25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
1211 lines
57 KiB
Python
1211 lines
57 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Owner, verbatim (2026-08-30): "重组 project config ... 点开以后分几个模块:
|
|
# file_path; lifing&handeling; ticektqc; job_knowledge. 剩下的都不在这里显
|
|
# 示,让用户手动打开 config 文件去修改剩余的部分。后面三个模块全部做成可以互
|
|
# 动的样子。比如说 stripping rig mode 后面跟一个 static 的按钮,按一下变成
|
|
# rolling。sling angle 那个地方直接让用户可以自己填写。"
|
|
"""The four modules of the Project Config window, and the rules of its fields.
|
|
|
|
The owner, 2026-08-30:
|
|
|
|
"Restructure project config ... once opened, split it into a few
|
|
modules: file_path; lifting&handling; ticketqc; job_knowledge.
|
|
Everything else does not show here -- let the user open the config
|
|
file by hand to edit the rest. The last three modules should all be
|
|
made interactive. Like, put a static button after 'stripping rig
|
|
mode' that flips to 'rolling' when clicked. For the sling angle spot,
|
|
just let the user fill it in themselves directly."
|
|
|
|
So this is not a viewer with an edit menu bolted on. It is FOUR MODULES OF
|
|
CONTROLS, and everything that is not in one of them is deliberately absent --
|
|
reached by opening the file, not by scrolling further.
|
|
|
|
WHY THE MAPPING IS A TABLE HERE AND NOT A RULE
|
|
-----------------------------------------------
|
|
`configmodel.SETTINGS` carries a `section` ("Strength", "Lifters", "Output")
|
|
and it would be tempting to derive the modules from it. It does not line up:
|
|
the owner's four modules are about WHAT A PERSON COMES HERE TO CHANGE, and
|
|
`section` is about what a setting IS. `handling.report_dir` is in section
|
|
"Output" and belongs in `file_path`; `handling.weight_tolerance` is in the
|
|
same section and is not in this window at all.
|
|
|
|
So the mapping is `MODULE_OF` below, one line per key, and `NOT_SHOWN` is the
|
|
other half of it -- every editable setting that this window deliberately does
|
|
not carry, each with the reason. `unmapped()` returns anything in neither, and
|
|
`tests/test_configform.py` fails on a non-empty answer. That is the whole
|
|
point of writing it twice: A SETTING ADDED TO `configmodel` CANNOT QUIETLY
|
|
FAIL TO APPEAR. Somebody has to put it in a module or say why it is not in
|
|
one.
|
|
|
|
WHAT A FIELD IS
|
|
---------------
|
|
Four kinds of control, chosen by what the value is rather than by what is
|
|
convenient:
|
|
|
|
toggle a fixed, small set of values. The control shows the value it HAS
|
|
and clicking moves to the next one -- the owner's `static` button
|
|
that becomes `rolling`. Never a dropdown for two values.
|
|
combo a real dropdown, for a set of values too long to cycle through one
|
|
click at a time -- `job.plant`'s plant folders, which the "toggle"
|
|
kind would turn into a scroll-wheel. Only `job.plant` needs one so
|
|
far, and its options come from disk (`plant_options()`), never a
|
|
fixed tuple on the field.
|
|
number a box with the number in it.
|
|
text a box, for a path or a sentence.
|
|
list a box, comma separated, with the LISTS REPLACE warning attached.
|
|
mutes THE WHOLE TICKET CHECKLIST, one row each, with a speaker button
|
|
beside it -- the Windows volume affordance, click to mute, click
|
|
to unmute. The rows come from `ticketpage.CHECKLIST_ITEMS` and
|
|
never from a copy kept here (see `checklist_items()`). A REASON IS
|
|
REQUIRED and is typed by a person: no default text, no
|
|
placeholder, no pre-filled sentence anybody can accept without
|
|
reading it.
|
|
|
|
MUTING DOES NOT STOP THE CHECK. See MUTED_STATE below.
|
|
inert DECLARED AND NOT WIRED. A key that is really in the layers, that
|
|
the tool really reads nothing from, and whose being inert is a
|
|
decision somebody recorded rather than an oversight. Shown with
|
|
the ruling, never as a box you can type a number into: a control
|
|
that decides nothing is worse than no control, because the person
|
|
who typed in it believes something changed.
|
|
info shown, not editable. A derived path is a fact about where the
|
|
tool put something, not a setting; hiding it would send somebody
|
|
looking, and making it editable would be a lie.
|
|
|
|
NO REVIT. The caller passes the layers it read, the run options it loaded and
|
|
the derived paths it computed, and gets back the rows to build controls from.
|
|
That is what makes the whole of this testable on host CPython, where the
|
|
window itself cannot be opened at all.
|
|
|
|
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
|
|
"""
|
|
# pylint: disable=invalid-name
|
|
import datetime
|
|
import re
|
|
|
|
from vibe import configmodel, plantpath
|
|
|
|
|
|
# ------------------------------------------------------------- the modules
|
|
|
|
FILE_PATH = 'file_path'
|
|
LIFTING = 'lifting'
|
|
TICKETQC = 'ticketqc'
|
|
JOB_KNOWLEDGE = 'job_knowledge'
|
|
|
|
# The producing-plant dropdown's first entry, for `job.plant`. Chosen means
|
|
# "clear job.plant and let `knowledge.detect_plant` work it out" -- never a
|
|
# real folder name, so it can never collide with one
|
|
# (`plantpath.valid_plant_id` requires the first character to be
|
|
# alphanumeric; `(` is not).
|
|
AUTO_DETECT = '(auto-detect)'
|
|
|
|
|
|
def plant_options():
|
|
"""The producing-plant dropdown's choices, read fresh every call.
|
|
|
|
`(auto-detect)` first, then every plant folder `vibe.plantpath.plant_ids()`
|
|
finds right now -- NOT baked into a module-level tuple at import time,
|
|
because a plant folder can be added to the machine mid-session and this
|
|
file, like every other `vibe` module, is held by pyRevit's engine until
|
|
Revit restarts (see `checklist_items()` below for the same reasoning
|
|
applied to the ticket checklist). `plantpath` is Revit-free -- pure path
|
|
arithmetic over the local disk -- so this stays callable from a host test
|
|
with `WILLTOOLS_KNOWLEDGE` pointed at a fixture root.
|
|
"""
|
|
return (AUTO_DETECT,) + tuple(plantpath.plant_ids())
|
|
|
|
|
|
# ------------------------------------------------------ what a mute now means
|
|
#
|
|
# The owner, 2026-08-30, specifying the control and its meaning in one breath:
|
|
#
|
|
# "config-project config 里面,ticketqc 下面,把 ticket qc 的每一个 item 都列
|
|
# 在这里。并且旁边加一个 mute 键,就是 windows 的那个音量图标。我点一下就
|
|
# mute 住,然后就默认跑 ticketqc 的时候是 notrun,但是其实还是偷偷跑,只不过
|
|
# 只要不是 fail,就给一个灰色的 notrun,而不是红色的 notrun。"
|
|
#
|
|
# MUTING IS A VOLUME CONTROL, NOT A SWITCH, and that is a change of meaning
|
|
# from what muting used to be here. It used to mean "declared out of scope":
|
|
# the row left the checklist table and the check was not asked. It now means
|
|
# the check RUNS, every time, and only its PRESENTATION changes -- a result
|
|
# that is not a failure reports quietly, in grey, instead of as a red row that
|
|
# trains the reader to skip red rows.
|
|
#
|
|
# WHY THE STATE IS ITS OWN VALUE AND NOT RED-WITH-A-GREY-OVERRIDE. A row that
|
|
# ran and passed quietly, and a row that could not run at all, are different
|
|
# answers, and this whole toolchain exists because the wrong outcome keeps
|
|
# looking exactly like the right one. If muted-and-fine were NOT RUN painted a
|
|
# different colour then a real NOT RUN -- a check that could not be asked,
|
|
# which is a defect in the tooling -- would be one CSS rule away from being
|
|
# invisible. So it is a distinct status word, and a renderer that does not
|
|
# know the word shows it rather than mistaking it for something it knows.
|
|
#
|
|
# AND THE WORD HAS TO BE TRUE. "NOT RUN" on a row that ran is a false sentence
|
|
# in a report whose whole premise is that it never prints one. `MUTED` is the
|
|
# recommendation and is one line to change; the owner has been asked which
|
|
# word he wants and has not answered yet.
|
|
MUTED_STATE = 'MUTED'
|
|
|
|
# The seam. `ticketpage` owns the checklist renderer and this file owns the
|
|
# window that sets the mute, and the two must agree on the word or the window
|
|
# promises something the report does not print.
|
|
# `tests/test_configform.py::test_the_muted_state_seam_with_ticketpage`
|
|
# holds them equal from the day `ticketpage` grows the constant.
|
|
MUTED_STATE_ATTR = 'MUTED_STATE'
|
|
|
|
# The Windows volume affordance, which is what he asked for by name. Segoe
|
|
# MDL2 Assets rather than the emoji speakers: these two code points are in the
|
|
# Basic Multilingual Plane, the font ships with Windows 10 and 11, and they
|
|
# are the glyphs the volume flyout itself draws. If they ever render as boxes
|
|
# on his machine, these two lines are the whole fix.
|
|
#
|
|
# Written as escapes rather than as the characters themselves: they are
|
|
# private-use code points with no glyph in an editor, a diff or a terminal,
|
|
# so a literal one is a byte nobody reviewing this file can see or check.
|
|
SPEAKER_FONT = 'Segoe MDL2 Assets'
|
|
SPEAKER_ON = u'' # Volume -- this row is asked at full volume
|
|
SPEAKER_MUTED = u'' # Mute -- this row is asked quietly
|
|
|
|
# What the button will DO, which is what a button should be labelled by --
|
|
# a speaker with no words beside it says the state, and the state is
|
|
# already on the row. This is the tooltip.
|
|
SPEAKER_HINT = {
|
|
False: 'Mute this row. The check still runs on every ticket; it '
|
|
'reports quietly instead of red unless it fails.',
|
|
True: 'Unmute this row. It goes back to reporting at full volume.',
|
|
}
|
|
|
|
MODULES = (
|
|
(FILE_PATH, 'File paths',
|
|
'Where this job writes. A blank path means "beside the model", which is '
|
|
'what most jobs want; a share path is used exactly as written.'),
|
|
(LIFTING, 'Lifting and handling',
|
|
'How the piece hangs, and what a Panel Lift run is willing to spend. '
|
|
'Click a value to change it; type in a box to set a number.'),
|
|
(TICKETQC, 'Ticket QC',
|
|
'MUTE A CHECKLIST ITEM HERE. Every row the ticket checklist asks is '
|
|
'listed below with a speaker beside it. Click the speaker to mute a row '
|
|
'and say why. A muted row STILL RUNS on every ticket -- muting turns the '
|
|
'volume down, not the check off -- and a result that is not a failure '
|
|
'reports quietly in grey instead of red. A FAILURE IS NEVER QUIETENED. '
|
|
'Also: how close two numbers have to be before a check calls them '
|
|
'equal.'),
|
|
(JOB_KNOWLEDGE, 'Job knowledge',
|
|
'Facts about THIS job that the checks read: whose pieces they are, what '
|
|
'a ticket counts, and the words this job draws with.'),
|
|
)
|
|
|
|
|
|
def module_title(name):
|
|
for key, title, _note in MODULES:
|
|
if key == name:
|
|
return title
|
|
return name
|
|
|
|
|
|
def placement():
|
|
"""{dotted key: the box that changes it} for everything the window carries.
|
|
|
|
Handed to `configpage.render` so the full view's every row can say where
|
|
it is edited. A key that is missing from this map is edited in the file,
|
|
and the page says exactly that -- the two halves of the answer have to
|
|
come from one table or they drift.
|
|
"""
|
|
out = {}
|
|
for field in fields():
|
|
if field['source'] in ('info', 'inert'):
|
|
# Neither is CHANGED anywhere in the window, and naming a box
|
|
# beside one would send somebody to a control that is text.
|
|
continue
|
|
out[field['key']] = module_title(field['module'])
|
|
return out
|
|
|
|
|
|
# Which module each editable setting lands in. The key is the dotted path, so
|
|
# a rename in `configmodel` shows up here as an unmapped key rather than as a
|
|
# field that quietly stopped appearing.
|
|
MODULE_OF = {
|
|
'handling.report_dir': FILE_PATH,
|
|
'handling.rig_mode': LIFTING,
|
|
'handling.stages': LIFTING,
|
|
'handling.sling_angle_deg': LIFTING,
|
|
# Moved here from NOT_SHOWN on the owner's ruling, 2026-08-30:
|
|
# "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自
|
|
# 己填写。I can see it both ways." The old reason -- that a stress limit
|
|
# is argued once and left alone -- was the argument for a doctrine, and
|
|
# he declined to have one: 5.0 and 7.5 are both defensible and the
|
|
# engineer picks per job. The note under the box carries both values, so
|
|
# the choice is made in front of the two numbers rather than from memory.
|
|
'handling.tension_limit_sqrt_fc': LIFTING,
|
|
# The compression allowable's sibling field. Same ruling, same module,
|
|
# 2026-09-01: "0.6保持可调" -- see `configmodel.SETTINGS`'s row for the
|
|
# ACI citation and the owner's ruling against implementing the higher
|
|
# member-end value.
|
|
'handling.compression_limit_ratio': LIFTING,
|
|
'handling.group_tolerance_ft': LIFTING,
|
|
'handling.dunnage.mode': LIFTING,
|
|
'handling.dunnage.fraction': LIFTING,
|
|
'checks.muted': TICKETQC,
|
|
}
|
|
|
|
# Editable in `configmodel`, deliberately NOT in this window. Each line is the
|
|
# reason, and the reason is always the same shape: it is not a thing this job
|
|
# changes between one press and the next, so it belongs in the file where a
|
|
# change is a deliberate act with a comment above it.
|
|
NOT_SHOWN = {
|
|
'handling.product_type':
|
|
'the PCI table row for the piece. Set once per job, in the file.',
|
|
'handling.finish': 'the PCI table column. Set once per job, in the file.',
|
|
'handling.prestress_loss_pct':
|
|
'lowering it gives every piece more credit, so it is an engineering '
|
|
'decision with a paragraph behind it, not a field.',
|
|
'handling.transfer_length_in': 'set with the prestress loss, in the file.',
|
|
'concrete.min_fci_ratio': 'a release rule, set with the strength numbers.',
|
|
'handling.dunnage.opening_clearance_warn_in':
|
|
'a dunnage tolerance; it moves with the two dunnage settings that '
|
|
'are here, and it is the one people do not touch.',
|
|
'families.lifter_include':
|
|
'which families ARE lifting inserts is a plant naming fact that a '
|
|
'job inherits. A job list REPLACES the plant list entirely.',
|
|
'families.lifter_exclude': 'as families.lifter_include.',
|
|
'families.cast_include': 'as families.lifter_include.',
|
|
'lifters.dynamic_factor':
|
|
'a hardware factor. It belongs with the catalogue, not on a screen '
|
|
'somebody is clicking through.',
|
|
'lifters.min_edge_distance_in': 'as lifters.dynamic_factor.',
|
|
'handling.weight_tolerance':
|
|
'the gate that catches a misread blockout. It fails closed, which is '
|
|
'right, and loosening it should cost more than one click.',
|
|
'handling.open_report': 'a convenience, not a job fact.',
|
|
'handling.fast_lifter_box':
|
|
"a face lifter's position IS its moment arm; fast/precise is a "
|
|
'measurement decision, made once.',
|
|
'handling.fast_lifter_box_batch': 'as handling.fast_lifter_box.',
|
|
}
|
|
|
|
|
|
# --------------------------------------------------------------- the fields
|
|
#
|
|
# `key` dotted path into the merged knowledge tree, or the run-option key
|
|
# `module` which of the four
|
|
# `kind` toggle / number / text / list / mutes / stages / info
|
|
# `source` 'job' -- written to the job knowledge layer through splice/verify
|
|
# 'run' -- written to run-options.yaml, which has no layers at all
|
|
# 'info' -- shown, never written
|
|
# `values` for a toggle, the cycle, in order. `None` is a real member of it
|
|
# and means blank/inherit.
|
|
# `label` the words on screen. Falls back to the SETTINGS label.
|
|
# `note` one line under the control.
|
|
|
|
def _f(key, module, kind, source='job', values=None, label=None, note=None):
|
|
return {'key': key, 'module': module, 'kind': kind, 'source': source,
|
|
'values': tuple(values) if values else None,
|
|
'label': label, 'note': note}
|
|
|
|
|
|
# Keys this window edits that `configmodel.SETTINGS` does not carry a row for.
|
|
# They are job-layer knowledge all the same -- `splice` writes any dotted key
|
|
# -- and they are the ones the owner named for the job_knowledge module.
|
|
EXTRA = (
|
|
_f('lateral.output_dir', FILE_PATH, 'text', label='Lateral export folder',
|
|
note='Where Lateral Extract writes. Blank puts it in the model\'s own '
|
|
'_ticketqc/lateral/ folder, never in the repository.'),
|
|
_f('snapshot.detached_dir', FILE_PATH, 'text',
|
|
label='Snapshot archive folder',
|
|
note='Where timestamped detached copies go. BLANK TURNS PHASE C OFF: '
|
|
'the button stops after the index, which is the useful half.'),
|
|
|
|
_f('tolerances.dimension_in', TICKETQC, 'number',
|
|
label='Dimension tolerance',
|
|
note='Inches. How far a ticket dimension may sit from the model '
|
|
'before the check calls it different.'),
|
|
# MUTED, NOT DELETED. Owner's ruling on drift D1, 2026-08-30: "不要删掉,
|
|
# 要'静音'掉。静音配置应该在 config 里面。" The drift audit found nothing
|
|
# anywhere compares a psi against this key -- the f'c and f'ci checks
|
|
# compare exactly -- and offered him wire or delete. He took neither: the
|
|
# key stays declared and settable in the file, and the fact that it is
|
|
# inert becomes a recorded decision instead of a silence.
|
|
#
|
|
# MUTING A KEY IS NOT MUTING A CHECKLIST ROW, and the two must not be run
|
|
# together. A checklist mute changes what a run reports, is a job fact,
|
|
# deep-merges across the layers, and is the engineer's to set -- which is
|
|
# why it is a control. This is the opposite on every count: it changes no
|
|
# run (nothing reads the key on any job), it is the same on every job, and
|
|
# its audience is whoever next reads the drift report. So it is a
|
|
# DECLARATION, shown here with the ruling, and not a second mute list.
|
|
#
|
|
# The other half of the same ruling is one line in
|
|
# `tools/knowledge_drift_allow.txt`, which is what actually stops the
|
|
# drift report calling this key a defect. Neither half is optional: the
|
|
# allowlist line without this row leaves a number box on screen that
|
|
# decides nothing, and this row without the line leaves the report
|
|
# reporting a decision as drift.
|
|
_f('tolerances.psi', TICKETQC, 'inert', source='inert',
|
|
label='Strength tolerance',
|
|
note="MUTED, NOT DELETED (2026-08-30). Absolute psi for f'c and f'ci. "
|
|
"Nothing compares a psi against it -- 3.1 and 3.2 compare the "
|
|
"ticket and the design summary exactly -- so this key decides "
|
|
"nothing on any job today. It is kept, declared and settable in "
|
|
"the file rather than deleted, so that the day a tolerance is "
|
|
"wanted the number and its name are already there. It is NOT a "
|
|
"box here, because a box that decides nothing is worse than no "
|
|
"box: whoever typed in it would believe something had changed."),
|
|
_f('tolerances.it_lb', TICKETQC, 'number', label='Tension tolerance',
|
|
note='Absolute pounds, for strand jacking force.'),
|
|
|
|
_f('scope.mark_prefixes', JOB_KNOWLEDGE, 'list',
|
|
label='My pieces start with',
|
|
note='Mark prefixes, e.g. PNL. EMPTY MEANS NO SCOPE DECLARED, which '
|
|
'means judge everything and say so -- it never means judge '
|
|
'nothing.'),
|
|
_f('scope.declared_by', JOB_KNOWLEDGE, 'text', label='Scope declared by',
|
|
note='Who said so, so it can be argued with.'),
|
|
_f('scope.note', JOB_KNOWLEDGE, 'text', label='Scope note',
|
|
note='One sentence, printed with the scope on every report.'),
|
|
_f('nomenclature.ticket_unit', JOB_KNOWLEDGE, 'toggle',
|
|
values=(None, 'control_number', 'piece_mark'),
|
|
label='One ticket per',
|
|
note='What one exported ticket covers. Read by the takeoff '
|
|
'(takeoffcount.counting_plan): control_number lets the exported '
|
|
'tickets count pieces; piece_mark WITHDRAWS them, because a '
|
|
'ticket filename is then not a piece count; and BLANK withdraws '
|
|
'them too -- a refusal that names this key on the page, never a '
|
|
'guess that one ticket is one piece.'),
|
|
_f('drawings.reinforcing_families', JOB_KNOWLEDGE, 'list',
|
|
label='Reinforcing families',
|
|
note='Generic-model families that ARE bars. Empty is safe and costs '
|
|
'accuracy: a bar this does not identify can be reported as '
|
|
'missing from the bed plan when it correctly lives in a section.'),
|
|
_f('drawings.full_length_bar_fraction', JOB_KNOWLEDGE, 'number',
|
|
label='Full-length bar fraction',
|
|
note='A fraction in (0, 1]. Blank stands 5.2\'s reinforcing half down '
|
|
'and says why, rather than guessing.'),
|
|
_f('strand.debond_callout_tokens', JOB_KNOWLEDGE, 'list',
|
|
label='Debond call-out words',
|
|
note='The words this job draws debonding with, e.g. DEBOND.'),
|
|
|
|
# ---- which plant folder this job is produced at ----------------------
|
|
#
|
|
# Gitea will/bridge issue #2. `knowledge.detect_plant` resolves a plant in
|
|
# three levels -- Project Information's manufacturer field, then
|
|
# titleblock family name patterns, then nothing -- and the owner's ruling
|
|
# is that when all three miss, the ENGINEER picks the plant once, here,
|
|
# and it is written to the job layer as `job.plant` so every later run
|
|
# (and every other tool that reads `knowledge.load()`) reads it without
|
|
# asking again.
|
|
#
|
|
# A DROPDOWN, NOT A TOGGLE, because `next_value`'s cycle is for two or
|
|
# three states a person can click through; a plant list is arbitrarily
|
|
# long and open one click at a time is a scroll-wheel wearing a button's
|
|
# clothes. See the 'combo' kind above and `plant_options()`.
|
|
#
|
|
# `(auto-detect)` is not a fourth plant-choice state, the same way `null`
|
|
# already means exactly what it needs to (see `docs/knowledge-override.md`
|
|
# on `left_rail_is_fixed` for the general shape of that argument): it
|
|
# clears `job.plant` and lets `knowledge.detect_plant` answer again,
|
|
# which is precisely what an install that has never named a plant has
|
|
# always done.
|
|
_f('job.plant', JOB_KNOWLEDGE, 'combo', label='Producing plant',
|
|
note='Which plant folder this job\'s plant-layer knowledge comes from. '
|
|
'{0} lets knowledge.detect_plant work it out itself, in order: '
|
|
'the producer named on Project Information matched against each '
|
|
'plant folder\'s aliases, then (only if that misses) the '
|
|
"titleblock family names matched against each plant's "
|
|
'titleblock_patterns. THE PLANT KNOWLEDGE FILE PICKED BELOW '
|
|
'(Plant knowledge file) ALWAYS WINS OVER THIS -- while it names a '
|
|
'file, this dropdown is stored but not read. Clear that box '
|
|
'first if this choice should take effect.'.format(AUTO_DETECT)),
|
|
|
|
# ---- which FILE the plant / public layer reads, not a value in it -----
|
|
#
|
|
# The owner, 2026-09-02 (translated): "add plant knowledge -- let the
|
|
# user choose the plant knowledge file. And public knowledge -- let the
|
|
# user choose the public knowledge file." See
|
|
# `configmodel.KNOWLEDGE_SOURCE_KEYS` for why choosing WHICH FILE a
|
|
# layer reads from is not the same act as typing over a value inside
|
|
# it, and is allowed here on that basis.
|
|
#
|
|
# WIRED INTO THE READ SIDE as of the three-level `detect_plant` /
|
|
# `plant_choice` rework (Gitea will/bridge issue #2): `knowledge.load()`
|
|
# (through `plant_id(doc)` / `plant_choice(doc)`) now consults this key
|
|
# FIRST, ahead of `job.plant` above and ahead of detection -- this
|
|
# paragraph used to say the opposite and that was the state of the code
|
|
# until this rework landed. The "currently read from" info row above it
|
|
# (built in `script.py` from `knowledge.sources(doc)`) remains the ground
|
|
# truth for what a run reads today, independent of this paragraph.
|
|
_f('knowledge_sources.plant_file', JOB_KNOWLEDGE, 'filepick',
|
|
label='Plant knowledge file',
|
|
note='Pick the plant knowledge.yaml this job should read. Blank goes '
|
|
'back to the normal discovery -- WILLTOOLS_KNOWLEDGE, then '
|
|
"this job's job.plant, then the tool's default. A chosen file "
|
|
'that later goes missing is refused loudly, never silently '
|
|
'reused as the default.'),
|
|
_f('knowledge_sources.public_file', JOB_KNOWLEDGE, 'filepick',
|
|
label='Public knowledge file',
|
|
note="Pick the catalogue.yaml this job should read in place of the "
|
|
"tool's shipped knowledge/catalogue.yaml. Blank goes back to "
|
|
'the shipped file. A chosen file that later goes missing is '
|
|
'refused loudly, never silently reused as the default.'),
|
|
)
|
|
|
|
# The run-time options, split between the two modules whose runs they govern.
|
|
# They are NOT knowledge -- no layers, no provenance, nothing overrides them
|
|
# -- and the window says so where they sit.
|
|
RUN_MODULE = {
|
|
'recommend_positions': LIFTING,
|
|
'expensive_geometry': LIFTING,
|
|
'vision_escalation': TICKETQC,
|
|
'vision_escalation_max_calls': TICKETQC,
|
|
}
|
|
|
|
# How a SETTINGS row is presented when this window carries it. Without an
|
|
# entry the kind is derived from `row['kind']`; with one, this wins -- which
|
|
# is how `handling.rig_mode` becomes a two-state button rather than a list.
|
|
#
|
|
# A `note` here replaces the SETTINGS note for this window only. The full
|
|
# view in `configpage` is a table of every key in every layer and its note
|
|
# has to stay one line; a control somebody is about to type a number into
|
|
# can afford the sentence that says what the number does.
|
|
PRESENT = {
|
|
'handling.rig_mode': {'kind': 'toggle', 'values': ('rolling', 'static')},
|
|
'handling.dunnage.mode': {'kind': 'toggle',
|
|
'values': (None, 'lifter_rows', 'fraction')},
|
|
'handling.stages': {'kind': 'stages'},
|
|
'checks.muted': {
|
|
'kind': 'mutes',
|
|
'note': 'Every row the ticket checklist asks is listed below. Click '
|
|
'the speaker beside one and type why this job never has it -- '
|
|
'the reason is REQUIRED and the date is added for you. MUTING '
|
|
'IS NOT SWITCHING OFF: the check runs on every ticket exactly '
|
|
'as before, keeps its row on the table, and reports {0} in '
|
|
'grey rather than a red NOT RUN when it did not fail. A '
|
|
'FAILURE IS NEVER QUIETENED -- it stays red on the table and '
|
|
'in the tally, and every finding still reaches the JSON. The '
|
|
'layers merge this list, so a mute set by the plant is shown '
|
|
'here and is not this window\'s to remove.'.format(
|
|
MUTED_STATE)},
|
|
'handling.report_dir': {'kind': 'text'},
|
|
'handling.sling_angle_deg': {'kind': 'number'},
|
|
'handling.group_tolerance_ft': {'kind': 'number'},
|
|
'handling.dunnage.fraction': {'kind': 'number'},
|
|
# The two reference values are ON the row, because the number typed here
|
|
# multiplies sqrt(f'c) and a person who does not already know which two
|
|
# numbers are argued about cannot tell 5 from 7.5 from 6. The owner
|
|
# deliberately left the choice per job -- so the window has to put the
|
|
# choice in front of whoever is making it.
|
|
'handling.tension_limit_sqrt_fc': {
|
|
'kind': 'number',
|
|
'note': "Multiplies sqrt(f'c), in psi -- 5.0 on 4000 psi concrete is "
|
|
"316 psi allowable. 5.0 = rupture over a 1.5 factor (no "
|
|
"discernible cracking, conservative); 7.5 = the rupture "
|
|
"modulus itself (first cracking, no margin, PCI-permitted "
|
|
"and what the commercial check used). A FACTOR OF 1.5 ON "
|
|
"EVERY STRESS VERDICT. Blank inherits; the report names the "
|
|
"limit it ran."},
|
|
'handling.compression_limit_ratio': {
|
|
'kind': 'number',
|
|
'note': "Multiplies f'c (f'ci at stripping), in psi -- 0.6 on 4000 "
|
|
"psi concrete is 2400 psi allowable. 0.6 is ACI 318-19 Table "
|
|
"24.5.3.1 (\"all other locations\", transfer) / Sec. "
|
|
"24.5.4.1 (service). ACI permits 0.70 AT MEMBER ENDS; this "
|
|
"tool deliberately does not implement that and applies one "
|
|
"flat ratio everywhere on the piece instead -- the "
|
|
"conservative choice. Blank inherits; the report names the "
|
|
"ratio it ran."},
|
|
}
|
|
|
|
_KIND_OF = {'float': 'number', 'int': 'number', 'path': 'text',
|
|
'bool': 'toggle', 'choice': 'toggle', 'list': 'list',
|
|
'map': 'mutes', 'stages': 'stages', 'text': 'text'}
|
|
|
|
BLANK = '(blank)'
|
|
|
|
|
|
# --------------------------------------------------------------- the mapping
|
|
|
|
def settings_fields():
|
|
"""One field per editable SETTINGS row this window carries, in table order.
|
|
|
|
Reads `configmodel.editable()` live, so a build that does not know a
|
|
setting simply does not produce a field for it -- rather than producing a
|
|
field whose write nothing would validate.
|
|
"""
|
|
out = []
|
|
for row in configmodel.editable():
|
|
module = MODULE_OF.get(row['key'])
|
|
if not module:
|
|
continue
|
|
shape = PRESENT.get(row['key']) or {}
|
|
kind = shape.get('kind') or _KIND_OF.get(row['kind']) or 'text'
|
|
values = shape.get('values')
|
|
if kind == 'toggle' and not values:
|
|
if row['kind'] == 'bool':
|
|
values = (True, False)
|
|
else:
|
|
values = tuple(row['choices'] or ()) or (None,)
|
|
field = _f(row['key'], module, kind, source='job', values=values,
|
|
label=row['label'],
|
|
note=shape.get('note') or row.get('note'))
|
|
field['setting'] = row
|
|
out.append(field)
|
|
return out
|
|
|
|
|
|
def run_fields():
|
|
"""One field per run-time option, in `configmodel.RUN_OPTIONS` order."""
|
|
out = []
|
|
for row in configmodel.RUN_OPTIONS:
|
|
module = RUN_MODULE.get(row['key'])
|
|
if not module:
|
|
continue
|
|
if isinstance(row['default'], bool):
|
|
field = _f(row['key'], module, 'toggle', source='run',
|
|
values=(True, False), label=row['label'],
|
|
note=row['note'])
|
|
else:
|
|
field = _f(row['key'], module, 'number', source='run',
|
|
label=row['label'], note=row['note'])
|
|
out.append(field)
|
|
return out
|
|
|
|
|
|
def fields(info=None):
|
|
"""Every field in the window, grouped nowhere yet. `info` is [(module,
|
|
label, value, note)] for the derived paths the caller worked out."""
|
|
out = list(settings_fields()) + list(EXTRA) + run_fields()
|
|
for module, label, value, note in info or []:
|
|
row = _f(label, module, 'info', source='info', label=label, note=note)
|
|
row['value'] = value
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def unmapped():
|
|
"""Editable settings that are in no module and in no not-shown reason.
|
|
|
|
Empty is the contract. A non-empty answer means somebody added a setting
|
|
to `configmodel` and did not decide where a person changes it, which is
|
|
how a knob ships that nothing on screen can reach.
|
|
"""
|
|
return [row['key'] for row in configmodel.editable()
|
|
if row['key'] not in MODULE_OF and row['key'] not in NOT_SHOWN]
|
|
|
|
|
|
def not_shown_rows():
|
|
"""[(label, key, why)] for the settings this window deliberately omits."""
|
|
out = []
|
|
for row in configmodel.editable():
|
|
why = NOT_SHOWN.get(row['key'])
|
|
if why:
|
|
out.append((row['label'], row['key'], why))
|
|
return out
|
|
|
|
|
|
# ------------------------------------------------------ reading the current
|
|
|
|
def resolve_all(fields_, layers, run_options, know_get=None):
|
|
"""Each field with what it reads NOW: value, display text, chip, layer.
|
|
|
|
`know_get(key)` is only used for the EXTRA keys, which have no SETTINGS
|
|
row: their provenance still comes from `configmodel.resolve`, so this is
|
|
a single code path and a job override of `scope.mark_prefixes` shows the
|
|
same `J` chip as an override of `handling.rig_mode`.
|
|
"""
|
|
out = []
|
|
for field in fields_:
|
|
row = dict(field)
|
|
if field['source'] == 'info':
|
|
row['display'] = '{0}'.format(field.get('value'))
|
|
row['chip'] = ''
|
|
row['layer'] = ''
|
|
out.append(row)
|
|
continue
|
|
if field['source'] == 'run':
|
|
option = configmodel.run_option(field['key']) or {}
|
|
value = (run_options or {}).get(field['key'], option.get('default'))
|
|
row['value'] = value
|
|
row['display'] = display(field, value)
|
|
row['chip'] = '-'
|
|
row['layer'] = 'run options (no layers)'
|
|
# A run option has no layer to carry a chip, so "modified" is
|
|
# answered a different way for it: whether run-options.yaml
|
|
# actually names the key, not whether the value differs from the
|
|
# default -- a job that wrote its own value back to the default
|
|
# is still a decision on record, not a fold-away non-event.
|
|
row['is_set'] = field['key'] in (run_options or {})
|
|
out.append(row)
|
|
continue
|
|
res = configmodel.resolve(layers, field['key'])
|
|
row['res'] = res
|
|
if configmodel.merges(field['key']):
|
|
# THE VALUE AND THE WRITE TARGET ARE DIFFERENT THINGS HERE, and
|
|
# for a key the layers merge they have to be. `merged` is what the
|
|
# run reads, entry by entry, with the layer each entry came from;
|
|
# `value` is what THIS file holds, because that is the only thing
|
|
# a splice may replace. Using the merge as the write baseline
|
|
# would copy the plant's entries into the job file, where they
|
|
# would stop tracking the plant for good -- silently, which is the
|
|
# only way this project ever gets hurt.
|
|
row['merged'] = configmodel.merged_map(layers, field['key'])
|
|
row['value'] = configmodel.layer_map(layers, field['key'],
|
|
WRITE_LAYER)
|
|
row['known'] = res['known']
|
|
row['display'] = display(field, row['merged'])
|
|
row['chip'] = configmodel.map_chip(row['merged'])
|
|
row['layer'] = ' + '.join(
|
|
configmodel.map_layers(row['merged'])) or 'nothing'
|
|
out.append(row)
|
|
continue
|
|
row['value'] = res['value'] if res['known'] else None
|
|
row['known'] = res['known']
|
|
row['display'] = display(field, row['value'], known=res['known'])
|
|
row['chip'] = configmodel.chip(res)
|
|
row['layer'] = res['layer'] or 'nothing'
|
|
out.append(row)
|
|
return out
|
|
|
|
|
|
def by_module(rows):
|
|
"""[(module key, module title, note, [row])] in MODULES order."""
|
|
out = []
|
|
for key, title, note in MODULES:
|
|
out.append((key, title, note,
|
|
[row for row in rows if row['module'] == key]))
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------- folding, and its cost
|
|
#
|
|
# The owner, 2026-09-01, on the window having grown a fifth screenful:
|
|
#
|
|
# "project config现在有点儿太复杂了。。。给弄成每个部分都可以折叠的吧。
|
|
# 需要调啥,再把那个部分给打开,调整,完事儿。" ("Project Config is a
|
|
# bit too complicated now... make every section collapsible. Whatever
|
|
# needs adjusting, open that section, adjust it, done.")
|
|
#
|
|
# Every module folds, and folded is the DEFAULT -- his own interaction model
|
|
# is open -> adjust -> done, not scroll-past-four-screens-to-find-the-one-box.
|
|
# `script.py` builds each module inside a WPF `Expander` instead of a
|
|
# `GroupBox` for exactly this: the same content, the same border, and a
|
|
# native collapse the owner did not have to ask for a bespoke control to get.
|
|
#
|
|
# THE HAZARD THIS CREATES IS THE ONE THE REST OF THIS FILE EXISTS TO PREVENT.
|
|
# `docs/plan-panellift-three-stage.md` SS10 names it for a later, generated
|
|
# version of this window and the ruling is the same one here, today, on the
|
|
# hand-maintained one: a value folded away is a value hidden, and "the config
|
|
# becoming a place values hide" is this project's cardinal disease. So a
|
|
# module carrying a live override cannot simply fold to the same header as
|
|
# one that carries none -- `is_modified` and `modified_count` are what the
|
|
# header reads before it decides what to say, and `script.py` never folds a
|
|
# module without saying, on the header itself, how many of its rows are not
|
|
# the shipped default.
|
|
|
|
# Chips that mean "nothing here is a decision anybody made" -- 'D' is the
|
|
# code default, '?' is not known in any layer. Every other chip (`P`, `J`,
|
|
# `!`, `+`, or a merged map's single-layer letter) means a real layer put a
|
|
# real value in force, which is the thing a folded header must not hide.
|
|
_UNMODIFIED_CHIPS = ('D', '?')
|
|
|
|
|
|
def is_modified(row):
|
|
"""Whether this row carries a value that is not the shipped default.
|
|
|
|
`info` and `inert` rows are never counted: an `info` row is a derived
|
|
path, not a decision, and an `inert` row is declared-and-not-wired by
|
|
name (see the `inert` kind at the top of this file) -- whatever the file
|
|
says under it changes no run, so it is not the kind of override a folded
|
|
header exists to surface.
|
|
"""
|
|
if row['kind'] in ('info', 'inert'):
|
|
return False
|
|
if row['source'] == 'run':
|
|
return bool(row.get('is_set'))
|
|
return row.get('chip') not in _UNMODIFIED_CHIPS
|
|
|
|
|
|
def modified_count(rows):
|
|
"""How many of these rows carry a live override -- the number a folded
|
|
module's header states so folding it never hides one."""
|
|
return len([row for row in rows if is_modified(row)])
|
|
|
|
|
|
# `script.py` re-runs on every press of the button and throws its own
|
|
# globals away each time it does; this module does not -- pyRevit's engine
|
|
# holds `vibe` until Revit restarts (see CLAUDE.md SS "Editing an existing
|
|
# script.py needs no reload"). So a SESSION-ONLY memory of which module was
|
|
# left open lives HERE, in the module that survives between presses, rather
|
|
# than in the button script that does not.
|
|
#
|
|
# NOT WRITTEN TO DISK. Closing Revit forgets it, and that is the point: this
|
|
# is a screen convenience for the owner's own "open it, adjust it, close it"
|
|
# rhythm inside one sitting, not a setting, and it must never grow into one --
|
|
# the owner asked to remember it "if cheap," not to build persistence
|
|
# machinery for a WPF window's scroll position.
|
|
_EXPANDED = {}
|
|
|
|
|
|
def remembered_expanded(key):
|
|
"""Whether `key`'s module was left open the last time this window was
|
|
used in this Revit session. Collapsed (False) the very first time,
|
|
always: the owner's rhythm is open -> adjust -> done, and a module that
|
|
starts open because somebody looked at it once already undoes that."""
|
|
return bool(_EXPANDED.get(key, False))
|
|
|
|
|
|
def remember_expanded(key, is_expanded):
|
|
_EXPANDED[key] = bool(is_expanded)
|
|
|
|
|
|
def knowledge_source_state(res):
|
|
"""One sentence for a `knowledge_sources.*` row: never touched, cleared
|
|
back to the default, or pointing somewhere.
|
|
|
|
EMPTY AND ABSENT ARE DIFFERENT STATES and must read differently on
|
|
screen, the same distinction `configmodel.knowledge_source_problems`
|
|
keeps silent about on purpose: a job file that never mentions the key is
|
|
every install before this feature existed, unremarkable; a job file that
|
|
states the key as blank is a decision made in THIS window, on purpose,
|
|
to go back to the normal discovery. `res` is `configmodel.resolve`'s
|
|
answer for one of `configmodel.KNOWLEDGE_SOURCE_KEYS`.
|
|
"""
|
|
if not res['known']:
|
|
return 'Never set. Reads from the normal discovery.'
|
|
if not res['value']:
|
|
return 'Cleared here ({0}). Reads from the normal discovery.'.format(
|
|
res['layer'])
|
|
return 'Set ({0}): {1}'.format(res['layer'], res['value'])
|
|
|
|
|
|
def display(field, value, known=True):
|
|
"""What the control shows. Never an empty string.
|
|
|
|
A blank control and a control showing a blank VALUE look identical, and
|
|
only one of them means "nothing here decides this".
|
|
"""
|
|
kind = field['kind']
|
|
if kind == 'inert':
|
|
# BEFORE the `known` check, and that ordering is the point. "(not
|
|
# set)" on a key nothing reads is true and useless: it invites
|
|
# somebody to set it. What the row has to say is that setting it
|
|
# changes nothing, whether a layer has set it or not.
|
|
if value is None or not known:
|
|
return 'MUTED -- nothing reads it'
|
|
return 'MUTED -- nothing reads it (the file says {0})'.format(value)
|
|
if not known:
|
|
return '(not set)'
|
|
if kind == 'toggle':
|
|
if value is None:
|
|
return BLANK
|
|
if value is True:
|
|
return 'on' if field['source'] == 'run' else 'true'
|
|
if value is False:
|
|
return 'off' if field['source'] == 'run' else 'false'
|
|
return '{0}'.format(value)
|
|
if kind == 'combo':
|
|
# `None` here means "(auto-detect)", never BLANK -- BLANK is the
|
|
# toggle kind's "no opinion in any layer" word and this row always
|
|
# has an opinion: cleared IS auto-detect, not an absence of one.
|
|
return AUTO_DETECT if value is None else '{0}'.format(value)
|
|
if kind == 'list':
|
|
if not value:
|
|
return ''
|
|
return ', '.join('{0}'.format(part) for part in value)
|
|
if kind == 'stages':
|
|
if not isinstance(value, list):
|
|
return '(no stages)'
|
|
return '{0} stage(s)'.format(len(value))
|
|
if kind == 'mutes':
|
|
# TWO SHAPES REACH HERE and both are real. The row's `merged` is
|
|
# `configmodel.merged_map` -- {number: {'layer': ...}} -- and says who
|
|
# muted what; the row's `value`, and the before/after of a pending
|
|
# change, is this file's own plain {number: reason}. Told apart by
|
|
# looking rather than by a flag, because the caller that got it wrong
|
|
# would be the one printing the summary after the window has closed,
|
|
# where a traceback has nowhere to go.
|
|
if not isinstance(value, dict) or not value:
|
|
return 'nothing muted'
|
|
where = configmodel.map_layers(value) \
|
|
if all(isinstance(v, dict) and 'layer' in v
|
|
for v in value.values()) else []
|
|
if where:
|
|
return '{0} muted ({1})'.format(len(value), ' + '.join(where))
|
|
return '{0} muted: {1}'.format(len(value), ', '.join(sorted(value)))
|
|
if value is None:
|
|
return ''
|
|
return '{0}'.format(value)
|
|
|
|
|
|
# ---------------------------------------------------------------- the toggle
|
|
|
|
def next_value(field, value):
|
|
"""The value one click moves to. Wraps, so a mis-click is one more click.
|
|
|
|
The cycle is short on purpose. A toggle over six values is a dropdown
|
|
somebody has to click six times, and this is only used where the set is
|
|
two or three long.
|
|
"""
|
|
values = list(field.get('values') or ())
|
|
if not values:
|
|
return value
|
|
for index, candidate in enumerate(values):
|
|
if candidate == value and type(candidate) is type(value):
|
|
return values[(index + 1) % len(values)]
|
|
# Not one of ours -- including "not set in any layer". Start at the top
|
|
# rather than guessing which end of the cycle the reader meant.
|
|
return values[0]
|
|
|
|
|
|
# ---------------------------------------------------------------- the typing
|
|
|
|
def parse(field, text):
|
|
"""Text from a box into a value. (ok, value, complaint).
|
|
|
|
Delegates to `configmodel.parse_input`, which is where "nothing is
|
|
guessed" lives -- `static_lift` is refused rather than read as `static`.
|
|
"""
|
|
kind = field['kind']
|
|
if kind == 'number':
|
|
return configmodel.parse_input('float', text)
|
|
if kind == 'list':
|
|
return configmodel.parse_input('list', text)
|
|
return configmodel.parse_input('path', text)
|
|
|
|
|
|
def same(before, after):
|
|
"""Whether an edit actually changed anything.
|
|
|
|
Compared by value AND by type: `1` and `True` are equal in Python and are
|
|
not the same setting, and a write that changes nothing still stamps a new
|
|
override comment on a value nobody touched.
|
|
"""
|
|
if isinstance(before, bool) != isinstance(after, bool):
|
|
return False
|
|
return before == after
|
|
|
|
|
|
# ----------------------------------------------------------------- the mutes
|
|
#
|
|
# THE DEFECT THIS SHAPE EXISTS FOR. The owner went looking for the mute
|
|
# control twice and did not find it, on two separate days -- "我没找到配置静音
|
|
# 的位置啊。。。我以为在 project config 里面" and, that night, "我还想着打开一
|
|
# 下 config 调一调这个检查,结果没成功". It WAS in the Project Config window
|
|
# both times: a 60-pixel box you typed a checklist number into, under a
|
|
# heading that said "Muted checklist items", offering nothing to somebody who
|
|
# had a check in mind and did not know its number. A control you can only
|
|
# operate if you already know the answer is not a control, and looking for it
|
|
# twice and failing twice is the measurement that says so.
|
|
#
|
|
# So the module lists EVERY ROW THE CHECKLIST ASKS -- number, grade, name --
|
|
# and muting is clicking the speaker beside the one you mean. Nobody has to
|
|
# know that "release ratio" is 3.3.
|
|
#
|
|
# AND MUTING NO LONGER STOPS THE CHECK (see MUTED_STATE at the top of this
|
|
# file). The row runs on every ticket and reports quietly unless it failed.
|
|
# That is worth stating here as well as there, because everything below is
|
|
# about WHOSE mute an entry is, and it would read as an on/off switch
|
|
# otherwise.
|
|
#
|
|
# THE ROWS COME FROM `ticketpage.CHECKLIST_ITEMS`, NEVER FROM A COPY. That is
|
|
# not tidiness. `CHECKLIST_FOR_ENGINEER` was a hand-kept tuple holding
|
|
# `('5.5',)`, 5.3 was never added to it, and 5.3 printed a red NOT RUN on
|
|
# every ticket for as long as that lasted -- a day of somebody's attention
|
|
# spent on a row that was missing from a second list. A hand-kept copy here
|
|
# would fail the same way and worse: a checklist row this window never draws
|
|
# is a row nobody can mute, and nothing anywhere would say why.
|
|
|
|
WRITE_LAYER = 'job'
|
|
|
|
MUTE_WHO = 'Project Config'
|
|
|
|
# `<reason> (Project Config, 2026-08-30)` -- the shape the owner's own mutes
|
|
# already carry ("... (Will 2026-08-30)"), so a mute made here reads like a
|
|
# mute made by hand rather than like a machine's row.
|
|
_DATED = re.compile(r'\(\s*[^()]*\d{4}-\d{2}-\d{2}\s*\)\s*$')
|
|
|
|
|
|
def checklist_items():
|
|
"""The real checklist table: [(number, name, grade)], in its own order.
|
|
|
|
Imported HERE and lazily, in the shape `configmodel._validate_muted`
|
|
already uses. Two reasons, and neither is style: `vibe.ticketpage` is a
|
|
3000-line module this one otherwise has no business loading, and a Revit
|
|
session holds `vibe` until it restarts -- so this file can be newer than
|
|
the `ticketpage` beside it. An empty answer is a real answer and the
|
|
window says so on screen; it must never be a stack trace over a window
|
|
that was opened to change a path.
|
|
"""
|
|
try:
|
|
from vibe.ticketpage import CHECKLIST_ITEMS
|
|
except Exception: # pylint: disable=broad-except
|
|
return []
|
|
out = []
|
|
for item in CHECKLIST_ITEMS:
|
|
try:
|
|
out.append((item[0], item[1], item[2]))
|
|
except Exception: # pylint: disable=broad-except
|
|
continue
|
|
return out
|
|
|
|
|
|
def checklist_numbers():
|
|
return set(number for number, _name, _grade in checklist_items())
|
|
|
|
|
|
def stamp_reason(reason, when=None, who=MUTE_WHO):
|
|
"""The typed reason with today's date on it, and never twice.
|
|
|
|
THE DATE IS THE TOOL'S JOB AND THE SENTENCE IS THE PERSON'S. The reason
|
|
itself is never written, defaulted or suggested -- there is no placeholder
|
|
text anywhere in this feature, because a sentence somebody accepted
|
|
without reading is the same as no sentence at the moment it has to be
|
|
argued with. What a person should not have to do is remember to type the
|
|
date, so that half is added here.
|
|
|
|
Idempotent: a reason that already ends in a parenthesised date is left
|
|
exactly as it was, so re-muting an item does not stack `(Project Config,
|
|
2026-08-30) (Project Config, 2026-08-30)` on the end of it.
|
|
"""
|
|
text = ('{0}'.format(reason or '')).strip()
|
|
if not text:
|
|
return ''
|
|
if _DATED.search(text):
|
|
return text
|
|
when = when or datetime.datetime.now().strftime('%Y-%m-%d')
|
|
return '{0} ({1}, {2})'.format(text, who, when)
|
|
|
|
|
|
def add_mute(mutes, item, reason, when=None, known=None):
|
|
"""One checklist item muted, with the reason that is not optional.
|
|
|
|
Enforced HERE and not only in `configmodel.validate`, because the loaded
|
|
build of `configmodel` may predate the rule -- and a mute with no reason
|
|
is precisely the row that cannot be told apart from a bug by the person
|
|
reading the report six months from now.
|
|
|
|
`known` is the set of real checklist numbers. Passed in rather than looked
|
|
up so a caller that could not read the table (see `checklist_items`) can
|
|
say so by passing None, and get the old behaviour of accepting the number
|
|
on trust, instead of refusing every mute because a module would not load.
|
|
A number that is not a checklist item mutes NOTHING, and a mute that does
|
|
nothing is how somebody believes a row is off while it is on.
|
|
"""
|
|
out = dict(mutes or {})
|
|
number = ('{0}'.format(item or '')).strip()
|
|
why = ('{0}'.format(reason or '')).strip()
|
|
if not number:
|
|
return False, out, ('Which checklist item? Type its number, e.g. 3.3.')
|
|
if known is not None and number not in known:
|
|
return False, out, (
|
|
'{0} is not a ticket-checklist item, so muting it would mute '
|
|
'nothing at all. Press Mute on the row you mean.'.format(number))
|
|
if not why:
|
|
return False, out, (
|
|
'A reason is REQUIRED. Muting quietens a row on every report this '
|
|
'job produces, and a row that went quiet for no stated reason '
|
|
'cannot be told apart from a bug by whoever reads it next.')
|
|
out[number] = stamp_reason(why, when=when)
|
|
return True, out, None
|
|
|
|
|
|
def remove_mute(mutes, item):
|
|
out = dict(mutes or {})
|
|
number = ('{0}'.format(item or '')).strip()
|
|
if number in out:
|
|
del out[number]
|
|
return True, out, None
|
|
return False, out, '{0} is not muted.'.format(number or '(nothing)')
|
|
|
|
|
|
def mute_rows(value):
|
|
"""[(item, reason)] sorted, for building one control row each."""
|
|
if not isinstance(value, dict):
|
|
return []
|
|
return [(key, '{0}'.format(value[key])) for key in sorted(value)]
|
|
|
|
|
|
# The sentence a row muted by a layer this window does not write has to carry.
|
|
# Not "greyed out": a greyed control with no reason reads as a bug, and this
|
|
# one has a real reason that is also the whole design of the layer stack.
|
|
NOT_MINE = (
|
|
'Muted by the {0} layer, and this window writes the job layer only. The '
|
|
'layers MERGE this map, so a job entry cannot take a {0} mute away -- '
|
|
'unmuting it here would look like it worked and change nothing. Edit the '
|
|
'{0} file to unmute it.')
|
|
|
|
# And the sentence for the case that looks identical on screen and is not: a
|
|
# job mute sitting on top of a lower layer's mute of the SAME item.
|
|
FALLS_BACK = (
|
|
'Unmuting here removes this job\'s entry, and the {0} layer\'s mute of '
|
|
'{1} takes over: "{2}". The item stays muted.')
|
|
|
|
|
|
def mute_table(merged, job_mutes, items=None):
|
|
"""Every checklist row, its mute state, and whose mute it is.
|
|
|
|
Returns one dict per row:
|
|
|
|
number, name, grade from `ticketpage.CHECKLIST_ITEMS`
|
|
known False for a muted number that is not a
|
|
checklist item at all -- listed at the end
|
|
rather than dropped, because a number that
|
|
mutes nothing is a thing somebody believes is
|
|
working
|
|
muted, reason, layer the entry in force and the layer that set it
|
|
editable whether pressing a button here can change it
|
|
why_not for editable=False, the sentence saying why
|
|
falls_back for a job mute over a lower layer's mute of the
|
|
same item, the sentence saying that unmuting
|
|
does not unmute
|
|
|
|
`merged` is `configmodel.merged_map(layers, 'checks.muted')`; `job_mutes`
|
|
is the job layer's own map, which is what a splice replaces.
|
|
"""
|
|
merged = merged if isinstance(merged, dict) else {}
|
|
job_mutes = job_mutes if isinstance(job_mutes, dict) else {}
|
|
listed = checklist_items() if items is None else list(items)
|
|
out = []
|
|
for number, name, grade in listed:
|
|
out.append(_mute_row(number, name, grade, True, merged, job_mutes))
|
|
seen = set(number for number, _n, _g in listed)
|
|
for number in sorted(merged):
|
|
if number in seen:
|
|
continue
|
|
out.append(_mute_row(number, '(not a checklist item -- this mutes '
|
|
'nothing)', '', False, merged, job_mutes))
|
|
return out
|
|
|
|
|
|
def _mute_row(number, name, grade, known, merged, job_mutes):
|
|
row = {'number': number, 'name': name, 'grade': grade, 'known': known,
|
|
'muted': False, 'reason': '', 'layer': None, 'editable': True,
|
|
'why_not': None, 'falls_back': None}
|
|
entry = merged.get(number)
|
|
if entry is None:
|
|
# Not muted. Editable unless it is not a real row: muting a number
|
|
# that is not on the checklist is refused, not offered.
|
|
row['editable'] = bool(known)
|
|
if not known:
|
|
row['why_not'] = 'There is no checklist item {0}.'.format(number)
|
|
return row
|
|
row['muted'] = True
|
|
row['reason'] = '' if entry['value'] is None else \
|
|
'{0}'.format(entry['value']).strip()
|
|
row['layer'] = entry['layer']
|
|
if entry['layer'] != WRITE_LAYER or number not in job_mutes:
|
|
row['editable'] = False
|
|
row['why_not'] = NOT_MINE.format(entry['layer'])
|
|
return row
|
|
beaten = [pair for pair in entry.get('overrides') or []]
|
|
if beaten:
|
|
label, value = beaten[-1]
|
|
row['falls_back'] = FALLS_BACK.format(
|
|
label, number, '' if value is None else '{0}'.format(value))
|
|
return row
|
|
|
|
|
|
def mute_summary(rows):
|
|
"""One line above the list. A count that says how many rows exist too --
|
|
"2 muted" alone cannot tell you whether the list you are looking at is the
|
|
whole checklist or the part of it somebody remembered."""
|
|
rows = rows or []
|
|
real = [row for row in rows if row['known']]
|
|
muted = [row for row in rows if row['muted']]
|
|
mine = [row for row in muted if row['editable']]
|
|
if not real:
|
|
return ('The checklist table could not be read in this Revit session, '
|
|
'so there is no list to mute from. RESTART REVIT -- a pyRevit '
|
|
'reload is never the answer.')
|
|
line = '{0} of the {1} checklist rows are muted on this job.'.format(
|
|
len(muted), len(real))
|
|
if not muted:
|
|
return line + ' Every row reports at full volume.'
|
|
# Said on every screen that shows a mute, because it is the half people
|
|
# get wrong: the checks did not stop.
|
|
line += (' All {0} still run on every ticket -- muting only makes a '
|
|
'result that is not a failure report quietly.'.format(len(real)))
|
|
if len(mine) != len(muted):
|
|
line += (' {0} of the mutes {1} set by a layer this window does not '
|
|
'write and cannot be changed here.'.format(
|
|
len(muted) - len(mine),
|
|
'is' if len(muted) - len(mine) == 1 else 'are'))
|
|
return line
|
|
|
|
|
|
# ---------------------------------------------------------------- the stages
|
|
|
|
STAGE_MODES = ('rolling', 'static', None)
|
|
|
|
|
|
def stage_rows(value):
|
|
"""[(index, name, rig_mode, enabled)] -- one control row per stage.
|
|
|
|
The owner's example is exactly this: "put a static button after
|
|
'stripping rig mode' that flips to 'rolling' when clicked". The stage's name is the label and its rig mode
|
|
is the button.
|
|
"""
|
|
out = []
|
|
if not isinstance(value, list):
|
|
return out
|
|
for index, stage in enumerate(value):
|
|
if not isinstance(stage, dict):
|
|
continue
|
|
out.append((index, stage.get('name') or 'stage {0}'.format(index + 1),
|
|
stage.get('rig_mode'), stage.get('enabled', True) is not False))
|
|
return out
|
|
|
|
|
|
def set_stage(stages, index, name, value):
|
|
"""One cell changed, and the WHOLE list handed back.
|
|
|
|
`handling.stages` is one list and lists replace: writing it to the job
|
|
layer replaces the plant's entire list, not the one stage edited. So the
|
|
edit is made against a copy of everything in force, and the caller writes
|
|
all of it -- which is the only way the result is the list the reader saw
|
|
with one thing different.
|
|
"""
|
|
out = []
|
|
for position, stage in enumerate(stages or []):
|
|
copy = dict(stage) if isinstance(stage, dict) else {}
|
|
if position == index:
|
|
copy[name] = value
|
|
out.append(copy)
|
|
return out
|
|
|
|
|
|
def stage_display(mode):
|
|
return BLANK if mode is None else '{0}'.format(mode)
|
|
|
|
|
|
# --------------------------------------------------------------- the escape
|
|
|
|
def file_url(path):
|
|
"""A `file:///` link the output panel will actually open.
|
|
|
|
A Windows path is backslashed and a job folder usually has a space in it.
|
|
Markdown ends a link target at the first space, so `[x](file:///C:/My
|
|
Job/k.yaml)` silently becomes a broken link and half a sentence -- which
|
|
is exactly the affordance that is supposed to be the way OUT of this
|
|
window to the file. Escaped here, once, rather than at each caller.
|
|
"""
|
|
body = ('{0}'.format(path or '')).replace('\\', '/')
|
|
return 'file:///' + body.replace(' ', '%20').replace('(', '%28') \
|
|
.replace(')', '%29')
|
|
|
|
|
|
# ------------------------------------------------------------- the summary
|
|
|
|
def change_lines(changes):
|
|
"""One line per pending change, for the window's own status area.
|
|
|
|
Label first, value second, key last and small -- the same order the page
|
|
uses, because a person who has just clicked a button called "Lift type"
|
|
should not have to translate `handling.rig_mode` to check what they did.
|
|
"""
|
|
out = []
|
|
for field, before, after in changes or []:
|
|
out.append('{0}: {1} -> {2} ({3})'.format(
|
|
field.get('label') or field['key'],
|
|
display(field, before), display(field, after), field['key']))
|
|
return out
|