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.
460 lines
22 KiB
Python
460 lines
22 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# Reinforcing suggestion for one assembly: which already-reinforced panels,
|
|
# between them, account for every feature of this one.
|
|
#
|
|
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
|
|
# a similarity ranking. Every detail of the target has to be covered by
|
|
# something, and the number of donors should be as small as possible. The
|
|
# search lives in `vibe.reinforcematch`, the candidate index in
|
|
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
|
|
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
|
|
# the Revit end and nothing more: it resolves the selection, decides how
|
|
# current the index has to be, and prints.
|
|
#
|
|
# READ ONLY, AND THAT IS THE POINT
|
|
# --------------------------------
|
|
# No Transaction is opened anywhere in this file or anything it calls. Nothing
|
|
# is written to the model -- not a parameter, not a rebar, not a copy of a
|
|
# donor's cage. The tool proposes; a human decides and a human draws it. This
|
|
# is the single most important constraint on the feature, because a suggestion
|
|
# tool is exactly the thing somebody will ask to "just copy it in for me", and
|
|
# the middle band of a wall panel's vertical reinforcing is explicitly PER
|
|
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
|
|
# automatically would look like an ordinary suggestion and would be the most
|
|
# dangerous thing this feature could do.
|
|
#
|
|
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
|
|
# ----------------------------------------------------
|
|
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
|
|
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
|
|
# on which a button press deserves. Which one to spend is a UI policy question
|
|
# (how many seconds may a click cost, and what does a modifier key mean), so it
|
|
# belongs at the UI, in one readable place, not buried in the library where two
|
|
# callers would end up disagreeing.
|
|
#
|
|
# The policy, with the measured numbers it rests on:
|
|
#
|
|
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
|
|
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
|
|
# moved. Nothing moved is the common case and then nothing is rebuilt and
|
|
# nothing is rewritten: about a second. A full rebuild on every press is
|
|
# 10 s of frozen UI to answer a question that usually has not changed.
|
|
# * cold or empty index -- a full build regardless, ~10 s, with a progress
|
|
# bar. There is no cheaper honest answer available.
|
|
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
|
|
#
|
|
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
|
|
# ---------------------------------------------------
|
|
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
|
|
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
|
|
# only between donor sets the geometry cannot separate. Owner's ruling: fit
|
|
# wins. The report marks it and says so.
|
|
#
|
|
# The staleness signature is `reinforced | on_hold | master | member-count |
|
|
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
|
|
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
|
|
# catch a geometry edit that leaves the member set alone -- an opening moved or
|
|
# resized in place, a panel stretched. Those pieces serve a stale measurement
|
|
# until something else about them moves.
|
|
#
|
|
# So an ordinary press can be wrong in exactly that one way, and the report
|
|
# says so every time, in `reinforcereport.staleness_note`, along with the
|
|
# instruction to shift-click. The report also always prints when the index was
|
|
# built, so a reader who knows he has been editing geometry can tell whether
|
|
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
|
|
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
|
|
# inconvenience, not a last resort.
|
|
#
|
|
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
|
# -----------------------------------------------------
|
|
# 1. One target per press. The engineer is reinforcing one piece; a batch of
|
|
# suggestions is a report nobody reads. Several assemblies preselected asks
|
|
# which one rather than fanning out.
|
|
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
|
|
# told, so it is in the footer of every report and in the staleness note
|
|
# the report prints when the index may be out of date. It used to be in the
|
|
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
|
|
# the report is now the only place that says it. A dialog
|
|
# on every press would be worse: the answer is the same 99 times out of 100.
|
|
# 3. The outline audit runs automatically the first time only. It costs one
|
|
# geometry read per indexed piece, and its answer changes only when the
|
|
# model does. Never-audited and audited-and-found-none are different
|
|
# answers, so the report says which one it has.
|
|
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
|
|
# four cannot account for everything, the report prints the best four and
|
|
# names what they miss -- it does not relax to a fifth. That overflow rule
|
|
# is INFERRED from the ruling, not stated by the owner, and is the thing to
|
|
# overrule first if this behaves wrongly on a real piece.
|
|
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
|
|
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
|
|
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
|
|
# 6. A target that is itself already reinforced, or on hold, is answered
|
|
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
|
|
# rules -- but silently treating an already-reinforced piece as a fresh
|
|
# request would hide a misclick.
|
|
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
|
|
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
|
|
# count as a problem is `reinforcereport`'s decision and is stated there,
|
|
# section by section; the one to argue with first is the staleness rule,
|
|
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
|
|
# a folded header names what it holds, and the whole report is written
|
|
# unfolded to `last-report.txt` beside the index on every press.
|
|
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
|
|
# "not judged" and an exit. It used to lose the index stamp and the
|
|
# staleness note on the way out, which are exactly what a reader needs in
|
|
# order to know whether to believe a refusal.
|
|
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
|
|
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
|
|
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
|
|
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
|
|
# before a single comparison is made, and when no same-design donor exists
|
|
# this button REFUSES -- it names the design, counts what the pool actually
|
|
# held, and says the way out. It never falls back to the nearest other
|
|
# design, not even labelled as one. Packet suffixes do not split a design:
|
|
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
|
|
# `specformat.design_key` reduction Ticket QC already uses.
|
|
# A design number nobody could read is UNJUDGED, never a match -- an index
|
|
# whose design numbers are all empty (which is what a build with no
|
|
# knowledge layer produces) refuses and asks for a rebuild.
|
|
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
__title__ = 'Reinforce Suggestion'
|
|
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
|
|
# the force-full-rebuild gesture. See the notes at the top of this file.
|
|
__shiftclick__ = True
|
|
|
|
import os
|
|
import traceback
|
|
|
|
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
|
|
|
from pyrevit import revit, script, forms
|
|
|
|
from vibe import compat
|
|
from vibe import foldreport as fold
|
|
from vibe import knowledge
|
|
from vibe import runlog
|
|
from vibe import scope as scopelib
|
|
from vibe import reinforcebind as bind
|
|
from vibe import reinforcecore as core
|
|
from vibe import reinforceindex as index
|
|
from vibe import reinforcereport as R
|
|
|
|
doc = revit.doc
|
|
uidoc = revit.uidoc
|
|
output = script.get_output()
|
|
output.close_others()
|
|
|
|
TITLE = R.TITLE
|
|
|
|
|
|
def die(message):
|
|
"""A usage error: the user has not given us something yet. Not a refusal
|
|
to judge -- those go in the report, where they survive the dialog."""
|
|
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
|
outcome='refused', report_text=message)
|
|
forms.alert(message, title=TITLE, exitscript=True)
|
|
|
|
|
|
# `not_judged` used to live here: a short page and an exit, for a target that
|
|
# could not be measured. It is gone, and deliberately -- 'could not judge' is
|
|
# a RESULT, and printing it on a stripped-down page dropped the index stamp
|
|
# and the staleness note, which are precisely what tells a reader whether to
|
|
# believe the refusal. `reinforcereport.render` now returns the whole page for
|
|
# that case too, with the headline saying NOT JUDGED, and this file prints it
|
|
# the same way it prints an answer.
|
|
|
|
|
|
# --------------------------------------------------------------------- main
|
|
# The main body is wrapped so an unexpected exception still leaves a run-log
|
|
# record (outcome='error') before pyRevit shows its own error dialog.
|
|
try:
|
|
# ------------------------------------------------------------ force rebuild
|
|
|
|
def _forced():
|
|
"""True when the button was shift-clicked. Never fatal if unavailable."""
|
|
try:
|
|
from pyrevit import EXEC_PARAMS
|
|
return bool(EXEC_PARAMS.config_mode)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ------------------------------------------------------------- the selection
|
|
#
|
|
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
|
|
# straight through, a picked member resolves to its owning assembly, nothing
|
|
# picked offers a list. The one difference is that this tool answers about
|
|
# ONE piece, so several preselected assemblies ask which.
|
|
|
|
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
|
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
|
|
|
if not picked_assemblies:
|
|
owners = []
|
|
for el in selection:
|
|
try:
|
|
owner = doc.GetElement(el.AssemblyInstanceId)
|
|
except Exception:
|
|
owner = None
|
|
if isinstance(owner, AssemblyInstance) and owner not in owners:
|
|
owners.append(owner)
|
|
picked_assemblies = owners
|
|
|
|
if selection and not picked_assemblies:
|
|
die('What is selected is not part of an assembly, so there is no piece '
|
|
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
|
|
'any member of it -- and run this again.')
|
|
|
|
all_assemblies = index.assemblies(doc)
|
|
by_name = dict(all_assemblies)
|
|
|
|
# The knowledge file is read here rather than further down, because the pick
|
|
# list below needs the DECLARED SCOPE before it can offer anything.
|
|
try:
|
|
know = knowledge.load(doc)
|
|
except Exception as exc:
|
|
know = None
|
|
output.print_md('> The knowledge file could not be read ({0}). No scope '
|
|
'is declared, so every assembly in the model is offered '
|
|
'— and **design numbers will be blank**, which '
|
|
'since 2026-09-01 means this run will REFUSE rather than '
|
|
'answer: a donor has to carry the same design number as '
|
|
'the target, and an unread design number is never assumed '
|
|
'to match. Fix the knowledge file, or shift-click to '
|
|
'rebuild the index once it reads.'.format(exc))
|
|
|
|
the_scope = scopelib.of(know) if know else scopelib.Scope()
|
|
|
|
if not picked_assemblies:
|
|
# The list this button OFFERS is scoped: this is the TOOL choosing, and
|
|
# it chooses inside the declared scope. What the user picks HIMSELF is
|
|
# never filtered -- see the branches below and the report's annotation.
|
|
names, left_alone = the_scope.split_names(
|
|
sorted(n for n, _a in all_assemblies))
|
|
if not names:
|
|
die('There is nothing here to pick from.\n\n{0}'
|
|
.format(the_scope.line(left_alone, total=len(all_assemblies),
|
|
unit='assembly')))
|
|
chosen = forms.SelectFromList.show(
|
|
names, multiselect=False, title=TITLE + ' - which piece',
|
|
button_name='Suggest donors')
|
|
if not chosen:
|
|
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
|
outcome='refused',
|
|
report_text='Piece picker closed -- no target chosen.')
|
|
script.exit()
|
|
target_name = chosen
|
|
elif len(picked_assemblies) > 1:
|
|
names = sorted(str(a.Name) for a in picked_assemblies)
|
|
chosen = forms.SelectFromList.show(
|
|
names, multiselect=False,
|
|
title=TITLE + ' - one piece at a time',
|
|
button_name='Suggest donors')
|
|
if not chosen:
|
|
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
|
outcome='refused',
|
|
report_text='Piece picker closed -- no target chosen.')
|
|
script.exit()
|
|
target_name = chosen
|
|
else:
|
|
target_name = str(picked_assemblies[0].Name)
|
|
|
|
target_asm = by_name.get(target_name)
|
|
if target_asm is None:
|
|
die('{0} is not an assembly in this model.'.format(target_name))
|
|
|
|
|
|
def _flag(el, pname):
|
|
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
|
|
try:
|
|
p = el.LookupParameter(pname)
|
|
return None if p is None else int(p.AsInteger())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
target_reinforced = _flag(target_asm, index.P_REINFORCED)
|
|
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
|
|
|
|
# ------------------------------------------------------------------ the index
|
|
#
|
|
# See the notes at the top of this file for the policy. The cheap plan is
|
|
# computed here rather than inside `refresh` so that the commonest press --
|
|
# nothing changed -- can skip `refresh` entirely and avoid a second
|
|
# signature pass, and so the progress bar knows how many pieces it is about
|
|
# to build.
|
|
|
|
forced = _forced()
|
|
|
|
# `know` was loaded above, for the pick list. Past the scope it is used for
|
|
# one optional thing -- reading each piece's design number for the report --
|
|
# so a missing or broken one costs a label, not the answer. It is not worth
|
|
# stopping the run over, unlike in Ticket QC where every check depends on it.
|
|
|
|
cache = index.read(doc)
|
|
if cache.get('_load_error'):
|
|
output.print_md('> The index on disk could not be read ({0}); it is being '
|
|
'rebuilt from the model. A cache is derived data, so this '
|
|
'costs time and nothing else.'
|
|
.format(cache['_load_error']))
|
|
forced = True
|
|
|
|
live = index.live_signatures(doc, all_assemblies, know=know)
|
|
the_plan = core.plan(cache, live)
|
|
todo = len(the_plan['build'])
|
|
refreshed = bool(todo or the_plan['drop']) or forced
|
|
|
|
refresh_report = None
|
|
if refreshed:
|
|
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
|
|
bar = 'Indexing donors ({value} of {max_value})'
|
|
if forced:
|
|
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
|
|
with forms.ProgressBar(title=bar, cancellable=False) as pb:
|
|
state = {'n': 0}
|
|
|
|
def _tick(_name):
|
|
state['n'] += 1
|
|
pb.update_progress(state['n'], max(total, 1))
|
|
|
|
pb.update_progress(0, max(total, 1))
|
|
cache, refresh_report = index.refresh(
|
|
doc, rebuild_all=forced, know=know, on_piece=_tick)
|
|
|
|
# The outline audit: once, unless forced. It is what keeps an approximated
|
|
# edge condition from ever being printed as a measured exact match.
|
|
audit_needed = forced or (bind.load_audit(doc) is None)
|
|
|
|
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
|
|
indeterminate=True):
|
|
suggestion, info = bind.suggest_for_piece(
|
|
doc, target_name, refresh=False, audit=audit_needed, know=know)
|
|
|
|
|
|
# ------------------------------------------------------------------- report
|
|
#
|
|
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
|
|
#
|
|
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
|
|
# 可以折叠的样子。"
|
|
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
|
|
# 给做成默认折叠。"
|
|
#
|
|
# So the target and the donor proposals are the page, printed big and first
|
|
# through pyRevit's own markdown and tables -- which is also what can linkify
|
|
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
|
|
# section that has a problem in it opens itself. That turns the page into a
|
|
# contract: if nothing below the answer is open, nothing needs him.
|
|
#
|
|
# The rule lives in `vibe.foldreport`, not here, and the sections come from
|
|
# `reinforcereport.render` -- the same sections the offline CLI prints as
|
|
# plain text, so the two cannot say different things.
|
|
#
|
|
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
|
|
# -----------------------------------------------
|
|
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
|
|
# hosted in WinForms, running in IE7 document mode unless the host process
|
|
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
|
|
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
|
|
# element renders inline and the "collapsed" content is simply always visible.
|
|
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
|
|
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
|
|
# but the toggle has NOT been exercised inside Revit, so it is designed for
|
|
# failure rather than trusted:
|
|
#
|
|
# * a section with a problem in it renders OPEN, as plain HTML, and does not
|
|
# need the script to run at all;
|
|
# * the whole report, nothing folded, is written to `last-report.txt` beside
|
|
# the index every press, and the page says where. A fold that will not
|
|
# open therefore costs a file open, never a lost sentence.
|
|
|
|
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
|
|
'last-report.txt')
|
|
|
|
|
|
def _donor_link(_name, record):
|
|
"""pyRevit's clickable element id, for a donor's line in the headline."""
|
|
if not (record or {}).get('asm_id'):
|
|
return None
|
|
try:
|
|
return output.linkify(ElementId(record['asm_id']), 'select assembly')
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
best = suggestion.best if suggestion is not None else None
|
|
rows = bind.explain(suggestion, info, best) if best is not None else []
|
|
|
|
sections = R.render(suggestion, info, rows, cache,
|
|
model=doc.Title,
|
|
refresh_report=refresh_report,
|
|
rebuilt_all=forced, refreshed=refreshed,
|
|
offline=False,
|
|
reinforced=target_reinforced, on_hold=target_on_hold,
|
|
full_path=full_path, donor_link=_donor_link)
|
|
|
|
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
|
|
# even if the printing throws.
|
|
try:
|
|
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
|
|
# string, and a piece mark or design number carrying a non-ASCII character
|
|
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
|
payload = compat.as_text(fold.text(sections, show_all=True))
|
|
handle = open(full_path, 'wb')
|
|
try:
|
|
handle.write(payload.encode('utf-8'))
|
|
finally:
|
|
handle.close()
|
|
except Exception as exc:
|
|
output.print_md('> The full report could not be written to disk ({0}). '
|
|
'Everything is still on this page; use the fold headers.'
|
|
.format(exc))
|
|
|
|
# ---- the answer, through pyRevit, big and first.
|
|
for block in sections[0].blocks:
|
|
if block[0] == fold.TABLE:
|
|
output.print_table(block[2], columns=block[1])
|
|
else:
|
|
output.print_md(block[1])
|
|
|
|
# ---- everything else, folded.
|
|
tail = sections[1:]
|
|
if tail:
|
|
output.add_style(fold.STYLE)
|
|
output.inject_script(fold.SCRIPT)
|
|
opened = fold.sections_with_problems(tail)
|
|
if opened:
|
|
output.print_md('---')
|
|
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
|
'— they have something in them that needs you.** '
|
|
'The rest are folded; click a header to open one.'
|
|
.format(len(opened), len(tail)))
|
|
else:
|
|
output.print_md('---')
|
|
output.print_md('**Nothing below needs you.** Every section is folded; '
|
|
'click a header to open one. The whole report is also '
|
|
'in `{0}`.'.format(full_path))
|
|
output.print_html(fold.html(tail))
|
|
|
|
# The report is on the page and the full text is on disk -- record the run
|
|
# as 'ok'. The headline (target + donors or the NOT JUDGED refusal) is what
|
|
# the page led with; keep the record short, since report_text is capped.
|
|
runlog.record('Reinforce Suggest', doc_title=doc.Title, outcome='ok',
|
|
report_path=full_path,
|
|
report_text=('Target {0}; suggestion {1}'
|
|
.format(target_name,
|
|
'found' if best is not None
|
|
else 'not judged')))
|
|
|
|
except Exception as e:
|
|
# The deliberate exits above never reach here (script.exit() raises
|
|
# SystemExit, which Exception does not catch) -- this is the
|
|
# unexpected-exception path, and pyRevit shows its own dialog after
|
|
# the record is written.
|
|
runlog.record('Reinforce Suggest', doc_title=doc.Title,
|
|
outcome='error', error=str(e),
|
|
traceback_text=traceback.format_exc())
|
|
raise
|