Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
title: Reinforce
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# One word, so one line. It is the only large button on this ribbon that does
|
||||
# not break, and it fits inside the width the two-line ones already take.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,425 @@
|
||||
# -*- 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'
|
||||
# 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
|
||||
|
||||
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 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."""
|
||||
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.
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 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:
|
||||
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:
|
||||
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))
|
||||
Reference in New Issue
Block a user