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,24 @@
|
||||
title: "Panel\nLift"
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to `liftexport` -- the Revit half of the
|
||||
# panellift boundary -- is visible without a Revit restart. It trades press
|
||||
# latency (engine construction instead of engine reuse) for restart-freedom.
|
||||
# The CPython half already needs no restart; this is for the half that does.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,910 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Handling check for one assembly: stripping, transportation, erection.
|
||||
#
|
||||
# What the selection says decides how this runs. One assembly preselected --
|
||||
# or any member of one -- is checked straight away. Several preselected run
|
||||
# as a background batch on exactly those pieces. Nothing preselected asks:
|
||||
# one piece, or a batch across the assemblies the job's DECLARED SCOPE says
|
||||
# are this engineer's (`vibe.scope`). The batch list is the TOOL choosing, so
|
||||
# it is scoped and says what it left off; a preselected batch is the USER
|
||||
# choosing, so it is never filtered.
|
||||
#
|
||||
# In the single-panel flow the piece is measured out of the model, you pick the
|
||||
# inserts that carry each stage by clicking them ON the piece, you say where the
|
||||
# report goes, and the calculation runs on CPython -- see vibe.liftexport for why
|
||||
# it is not in here.
|
||||
#
|
||||
# The only thing it writes to the model is a temporary isolate of the piece, in
|
||||
# whatever view you are already in, so that you are clicking inserts on the piece
|
||||
# rather than hunting them in a view of the whole building. It writes that twice:
|
||||
# on, and off again. Nothing else is changed -- including the inserts this report
|
||||
# recommends moving.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Panel\nLift'
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import (AssemblyInstance, ElementId,
|
||||
OverrideGraphicSettings, Reference,
|
||||
TemporaryViewMode)
|
||||
from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType
|
||||
from Autodesk.Revit.Exceptions import OperationCanceledException
|
||||
from System.Collections.Generic import List
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import (batchrun, configmodel, knowledge, liftexport, ticketqc,
|
||||
designspec, tx)
|
||||
from vibe import scope as scopelib
|
||||
from vibe.compat import as_text
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
|
||||
def die(message, title='Panel Lift Check'):
|
||||
"""Usage error, not a calculation refusal (Claude 08-28).
|
||||
|
||||
die() is ONLY for 'the user has not given us something yet' -- no
|
||||
assembly selected, nothing isolated, nothing in scope to offer. A
|
||||
refusal to CALCULATE (missing prestress / weight gate / no stages /
|
||||
no Python / no concrete strength) must go through not_run(), which
|
||||
leaves a row in the report saying what was not done and why. The
|
||||
report must survive the dialog.
|
||||
"""
|
||||
forms.alert(message, title=title, exitscript=True)
|
||||
|
||||
|
||||
def not_run(message, title='Panel Lift Check'):
|
||||
"""'Could not be calculated' is a RESULT, not a failure of the piece
|
||||
(Claude 08-28: exit code non-zero != the panel failed). Print a
|
||||
NOT RUN row with the reason that tells the user what to do next,
|
||||
then stop. The five-word discipline from Ticket QC applies here too:
|
||||
the tool must be able to say what it did not do.
|
||||
"""
|
||||
output.print_md('# Panel Lift Check — NOT RUN')
|
||||
output.print_md('**NOT RUN** — the check did not run for this piece.')
|
||||
output.print_md('> {0}'.format(message))
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- background batch engine
|
||||
from System.Threading import Thread, ThreadStart
|
||||
|
||||
|
||||
def _design_of(asm, groups):
|
||||
"""The design key an assembly belongs to, or None if it is in none."""
|
||||
for key, group in groups.items():
|
||||
for member in group:
|
||||
try:
|
||||
if member.Id == asm.Id:
|
||||
return key
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _run_batch(preselected=None):
|
||||
"""Run the full check on many panels in the background.
|
||||
|
||||
Called with no argument, the user picks from the assemblies inside the
|
||||
declared scope, and the page says how many were left off and of what
|
||||
kind. Given a selection -- several assemblies highlighted before the
|
||||
button was pressed -- those ARE the batch, whatever their marks: nobody
|
||||
is asked which pieces they meant and nothing is dropped.
|
||||
"""
|
||||
know = knowledge.load(doc)
|
||||
_summaries, sheets = ticketqc.collect_context(doc)
|
||||
groups = ticketqc.assemblies_by_design(doc, know) or {}
|
||||
|
||||
if preselected:
|
||||
seen = set()
|
||||
items = []
|
||||
for asm in preselected:
|
||||
try:
|
||||
asm_key = asm.Id.IntegerValue
|
||||
except Exception:
|
||||
asm_key = None
|
||||
if asm_key is None:
|
||||
try:
|
||||
asm_key = asm.Id.Value
|
||||
except Exception:
|
||||
continue
|
||||
if asm_key in seen:
|
||||
continue
|
||||
seen.add(asm_key)
|
||||
items.append([str(asm.Name), asm, _design_of(asm, groups)])
|
||||
items.sort(key=lambda r: (str(r[2]), r[0]))
|
||||
else:
|
||||
# Nobody preselected anything, so the TOOL is choosing what to offer
|
||||
# -- and it offers what the job's DECLARED SCOPE says is this
|
||||
# engineer's. What it left off the list is printed, by kind, rather
|
||||
# than quietly missing. A PRESELECTED batch (the branch above) is
|
||||
# never filtered: the selection is the request.
|
||||
the_scope = scopelib.of(know)
|
||||
items, left_alone = [], []
|
||||
for design, asm_list in groups.items():
|
||||
for asm in asm_list:
|
||||
name = str(asm.Name)
|
||||
(items if the_scope.includes(name)
|
||||
else left_alone).append([name, asm, design])
|
||||
items.sort(key=lambda r: (r[2], r[0]))
|
||||
scope_line = the_scope.line([r[0] for r in left_alone],
|
||||
total=len(items) + len(left_alone),
|
||||
unit='piece')
|
||||
if not items:
|
||||
die('There is nothing to offer for a batch.\n\n{0}'
|
||||
.format(scope_line))
|
||||
|
||||
names = [r[0] for r in items]
|
||||
output.print_md('> ' + scope_line)
|
||||
picked = forms.SelectFromList.show(
|
||||
names, multiselect=True,
|
||||
title='Batch - choose pieces (Ctrl/Shift to multi-select)')
|
||||
if not picked:
|
||||
script.exit()
|
||||
picked_set = set(picked)
|
||||
items = [r for r in items if r[0] in picked_set]
|
||||
if not items:
|
||||
script.exit()
|
||||
|
||||
chosen = items
|
||||
|
||||
start_dir = knowledge.get(know, 'handling.report_dir') or \
|
||||
designspec.sub_dir(doc, 'handling')
|
||||
try:
|
||||
if start_dir and not os.path.isdir(start_dir):
|
||||
os.makedirs(start_dir)
|
||||
except Exception:
|
||||
start_dir = ''
|
||||
python_exe = liftexport.find_python(know)
|
||||
if not python_exe:
|
||||
not_run('CPython 3.9 or newer was not found.\n\nSet '
|
||||
'handling.python_exe in the knowledge file to the full '
|
||||
'path of python.exe.')
|
||||
|
||||
compute = []
|
||||
total = len(chosen)
|
||||
with forms.ProgressBar(title='Batch: measuring {0} panels...'.format(total),
|
||||
cancellable=True) as pb:
|
||||
for i, (name, asm, design) in enumerate(chosen):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, total)
|
||||
job, problems = liftexport.export(doc, asm, know, sheets,
|
||||
design, batch=True)
|
||||
if job is None:
|
||||
continue
|
||||
stages, _rows, stage_problems = liftexport.default_stages(job, know)
|
||||
problems = list(problems or []) + list(stage_problems or [])
|
||||
missing = [s['name'] for s in stages
|
||||
if s.get('enabled', True) and not s.get('fc_psi')]
|
||||
if problems or missing:
|
||||
continue
|
||||
job['stages'] = stages
|
||||
job_path = os.path.join(start_dir or '',
|
||||
'{0}.job.json'.format(name))
|
||||
blob = json.dumps(liftexport.strip_private(job), indent=1,
|
||||
sort_keys=True)
|
||||
with io.open(job_path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(blob))
|
||||
compute.append([name, job_path])
|
||||
pb.update_progress(len(chosen), total)
|
||||
|
||||
if not compute:
|
||||
not_run('Nothing was measured for batch (all refused or '
|
||||
'cancelled).')
|
||||
|
||||
status_path = os.path.join(start_dir or '', 'batch-status.txt')
|
||||
|
||||
# The thread that runs the batch outlives this command. pyRevit tears
|
||||
# the command module's scope down when the command returns, so a thread
|
||||
# that looks ANY module global up from that scope -- `io`, `os`,
|
||||
# `liftexport` -- raises UnboundNameException, on a NON-MAIN thread,
|
||||
# which Revit cannot catch: the whole process dies (measured
|
||||
# 2026-08-27). The worker therefore lives in vibe.batchrun, whose module
|
||||
# scope is the session's, not the command's, and every name it needs is
|
||||
# bound as a default parameter at definition time. `run_one` does the
|
||||
# same for the two names that are this file's to pass on.
|
||||
#
|
||||
# recommend=False, and this is the whole reason a batch is usable.
|
||||
#
|
||||
# The position search is 93% of a run -- 12.6 s of a 13.6 s median piece,
|
||||
# 49 s on the worst. Measured through this exact path on the 184-piece
|
||||
# reference sweep: 38 minutes with the search on, 2.5 minutes without,
|
||||
# and about half a minute without it and eight at a time. The verdicts
|
||||
# are untouched -- 111 OK / 59 WARN / 13 FAIL / 1 refused either way --
|
||||
# because every stage is still analysed and every check still runs. A
|
||||
# batch is a triage pass: WHICH pieces need looking at. "Then where?" is
|
||||
# a question about ONE piece, and the single-piece run answers it in
|
||||
# thirteen seconds.
|
||||
#
|
||||
# The reports say so on the page. A batch of reports whose Recommended
|
||||
# positions section had quietly vanished would read, 184 times over, as
|
||||
# 184 pieces with nothing to recommend.
|
||||
def run_one(name, job_path, out_path, lx=liftexport, px=python_exe):
|
||||
lx.run(px, job_path, out_path, recommend=False)
|
||||
|
||||
# Several at once. Each job is already an isolated CPython subprocess
|
||||
# writing its own files, so this is a scheduling change, not a numerical
|
||||
# one -- and two cores are left alone so Revit stays usable while it runs.
|
||||
at_once = batchrun.worker_count()
|
||||
worker = batchrun.make_worker(compute, status_path, run_one,
|
||||
workers=at_once)
|
||||
|
||||
Thread(ThreadStart(worker)).Start()
|
||||
|
||||
output.print_md('**Batch started in the background.**')
|
||||
# The scope, on the page, every time. A batch of the pieces this engineer
|
||||
# owns and a batch of everything in the model look identical once they
|
||||
# are running.
|
||||
_scope = scopelib.of(know)
|
||||
_outside = [name for name, _asm, _d in chosen if not _scope.includes(name)]
|
||||
output.print_md('> ' + _scope.line([], unit='piece'))
|
||||
if _outside:
|
||||
output.print_md('> ' + (_scope.annotation(_outside, unit='piece') or ''))
|
||||
output.print_md('Panels were measured on the UI thread; each report is '
|
||||
'now computed in the background ({0} CPython subprocesses '
|
||||
'at a time, two cores left free), so Revit stays usable.'
|
||||
.format(at_once))
|
||||
# Said here as well as in every report. Somebody who starts a batch and
|
||||
# then opens one of its reports looking for the move list has to find out
|
||||
# why it is not there, and the place they are looking at the moment they
|
||||
# wonder is this page.
|
||||
output.print_md('> **Insert positions are not searched in a batch.** That '
|
||||
'search is 93% of a run — it is what made a sweep of '
|
||||
'this model take 38 minutes instead of half a minute. '
|
||||
'Every stage is still '
|
||||
'analysed and every check still runs, so the verdicts are '
|
||||
'the whole answer to *which pieces need looking at*. Run a '
|
||||
'piece on its own to be told **where** its inserts should '
|
||||
'go; each report says so where the positions would be.')
|
||||
output.print_md('Reports + job files go to:\n\n `{0}`'.format(start_dir))
|
||||
output.print_md('Progress is written to:\n\n `{0}`'.format(status_path))
|
||||
script.close()
|
||||
|
||||
|
||||
# -------------------------------------------------- mode: batch / assembly
|
||||
# The selection decides. One assembly preselected (any member of one
|
||||
# counts) runs straight through as the single-panel check. Several
|
||||
# preselected run as a background batch on exactly what is highlighted.
|
||||
# Nothing preselected asks.
|
||||
#
|
||||
# Deliberately off the preselection rather than a list: the piece is the
|
||||
# subject of the whole run, and picking it from several hundred assemblies
|
||||
# is worse than clicking it in the model.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not 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)
|
||||
assemblies = owners
|
||||
|
||||
if len(assemblies) > 1:
|
||||
_run_batch(assemblies)
|
||||
script.exit()
|
||||
|
||||
if not assemblies:
|
||||
_modes = ['Batch (many pieces)', 'Assembly (one piece)']
|
||||
_mode = forms.SelectFromList.show(_modes, multiselect=False,
|
||||
title='Panel Lift Check')
|
||||
if not _mode:
|
||||
script.exit()
|
||||
if _mode.startswith('Batch'):
|
||||
_run_batch()
|
||||
script.exit()
|
||||
die('Select the assembly you want checked, then run this again.\n\n'
|
||||
'Selecting any member of it works too.')
|
||||
|
||||
asm = assemblies[0]
|
||||
|
||||
# ------------------------------------------------------------------ read it
|
||||
|
||||
know = knowledge.load(doc)
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
not_run(problem)
|
||||
if not (knowledge.get(know, 'handling.lifter_groups', {}) or {}):
|
||||
not_run('No handling.lifter_groups in the knowledge files.\n\n'
|
||||
'The check needs to know which family is a face lifter and '
|
||||
'which is an edge anchor. Add them to\n\n{0}\n\nand run again.'
|
||||
.format(designspec.plant_knowledge_path()))
|
||||
|
||||
with forms.ProgressBar(title='Measuring {0}...'.format(asm.Name)) as pb:
|
||||
pb.update_progress(0, 3)
|
||||
_summaries, sheets = ticketqc.collect_context(doc)
|
||||
design = None
|
||||
for key, group in ticketqc.assemblies_by_design(doc, know).items():
|
||||
if any(a.Id == asm.Id for a in group):
|
||||
design = key
|
||||
break
|
||||
pb.update_progress(1, 3)
|
||||
job, problems = liftexport.export(doc, asm, know, sheets, design)
|
||||
pb.update_progress(3, 3)
|
||||
|
||||
if job is None:
|
||||
not_run('\n\n'.join(problems) +
|
||||
'\n\nFix the cause above and run again.')
|
||||
|
||||
panel = job['panel']
|
||||
|
||||
# Before anything is calculated: does the geometry we read weigh what the
|
||||
# model says it does? A blockout read wrong is silent, and it makes the
|
||||
# section modulus wrong in the direction that says a panel is fine.
|
||||
ok, message, _numbers = liftexport.weight_check(job, know)
|
||||
if not ok:
|
||||
not_run(message + '\n\nFix the weight mismatch and run again.',
|
||||
title='Panel Lift Check - {0}'.format(panel['mark']))
|
||||
|
||||
stages, _rows, stage_problems = liftexport.default_stages(job, know)
|
||||
if not stages:
|
||||
not_run('No handling.stages in the knowledge files, so there is nothing to '
|
||||
'check the piece through.')
|
||||
if not [s for s in stages if s.get('enabled', True)]:
|
||||
# Some stages are configured and every one of them is switched off. That
|
||||
# is a different fact from "no stages", and it belongs here rather than as
|
||||
# a JobError out of the calculation, where it would arrive as a stderr
|
||||
# line in a dialog about a job file the engineer never opened.
|
||||
not_run('Every handling stage is switched off in the configuration '
|
||||
'({0}), so there is nothing to check. Enable at least one stage '
|
||||
'in Project Config.'
|
||||
.format(', '.join(s['name'] for s in stages)))
|
||||
if stage_problems:
|
||||
not_run('\n\n'.join(stage_problems),
|
||||
title='Panel Lift Check - plant dunnage setting')
|
||||
|
||||
# The audit used to refuse the run. It was guarding an automatic pick nobody
|
||||
# could see; now every insert is clicked on the piece, so a bad default costs
|
||||
# a few clicks rather than a wrong answer. It is still worth saying out loud,
|
||||
# because it means the highlighted defaults are not to be trusted here.
|
||||
audit = liftexport.stage_audit(stages)
|
||||
|
||||
# --------------------------------------------------------- confirm the picks
|
||||
# The inserts for each stage are picked IN THE MODEL, one stage at a time.
|
||||
#
|
||||
# This used to be a checklist of rows. It was wrong twice over: a row is the
|
||||
# tool's guess about which inserts share a hook, and the label named one family
|
||||
# when the row held two -- which is how one edge anchor and two face lifters an
|
||||
# inch apart read as three edge anchors, and a whole support line went missing.
|
||||
#
|
||||
# Nobody can audit a checkbox that says 5'-8". Everybody can audit three
|
||||
# inserts lit up on the piece in front of them. The knowledge rules still
|
||||
# choose the defaults, so the usual answer is still one click; they just no
|
||||
# longer get the last word, and they no longer get it silently.
|
||||
|
||||
by_mark = dict((l['mark'], l) for l in panel['lifters'])
|
||||
insert_ids = set(int(m) for m in by_mark)
|
||||
|
||||
|
||||
class OnlyTheseInserts(ISelectionFilter):
|
||||
"""Only the lifting inserts of this piece can be clicked.
|
||||
|
||||
Without it the panel itself, its rebar and its neighbours are all pickable,
|
||||
and a stray click becomes a support line the piece does not have.
|
||||
"""
|
||||
|
||||
def AllowElement(self, element):
|
||||
try:
|
||||
return ticketqc.eid(element) in insert_ids
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def AllowReference(self, reference, point):
|
||||
return False
|
||||
|
||||
|
||||
pick_filter = OnlyTheseInserts()
|
||||
|
||||
|
||||
def elements_of(marks):
|
||||
found = []
|
||||
for mark in marks:
|
||||
try:
|
||||
el = doc.GetElement(ElementId(int(mark)))
|
||||
except Exception:
|
||||
el = None
|
||||
if el is not None:
|
||||
found.append(el)
|
||||
return found
|
||||
|
||||
|
||||
def zoom_state():
|
||||
"""The active view's zoom, so it can be put back after the fit."""
|
||||
try:
|
||||
for uiview in uidoc.GetOpenUIViews():
|
||||
if uiview.ViewId == uidoc.ActiveView.Id:
|
||||
corners = uiview.GetZoomCorners()
|
||||
return (uiview, corners[0], corners[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def id_int(element_id):
|
||||
try:
|
||||
return element_id.IntegerValue
|
||||
except Exception:
|
||||
return element_id.Value
|
||||
|
||||
|
||||
def show_the_piece():
|
||||
"""Isolate the piece in the view the user is already in, and see into it.
|
||||
|
||||
Their view, not one this picks for them and not a new one: the view on
|
||||
screen is the view they chose to be in.
|
||||
|
||||
Isolating is not enough on its own. An insert is cast INSIDE the concrete,
|
||||
so in any shaded view the panel is standing in front of every one of them
|
||||
-- you can still click them, because the filter passes the click through
|
||||
to the insert, but the highlight that says which one you got is painted
|
||||
behind six inches of opaque concrete. The pick works and shows you
|
||||
nothing. So everything that is not an insert goes 85% transparent, which
|
||||
leaves the panel and its openings as an outline to place them against.
|
||||
|
||||
A view template can lock the display style -- wireframe is not available
|
||||
to us here -- but per-element overrides go through it, which is why this
|
||||
is done insert by insert rather than by changing how the view draws.
|
||||
|
||||
Those two things are all this button writes to the model, and it writes
|
||||
them twice: on, and off again. Everything else here reads.
|
||||
|
||||
Returns the callable that puts the view back.
|
||||
"""
|
||||
view = uidoc.ActiveView
|
||||
ids = List[ElementId]()
|
||||
ids.Add(asm.Id)
|
||||
others = []
|
||||
for member in asm.GetMemberIds():
|
||||
ids.Add(member)
|
||||
if id_int(member) not in insert_ids:
|
||||
others.append(member)
|
||||
|
||||
ghost = OverrideGraphicSettings()
|
||||
ghost.SetSurfaceTransparency(85)
|
||||
try:
|
||||
with tx('Show assembly'):
|
||||
view.IsolateElementsTemporary(ids)
|
||||
for member in others:
|
||||
try:
|
||||
view.SetElementOverrides(member, ghost)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
die('This view cannot isolate the piece, so there is nothing to pick '
|
||||
'on. Schedules and sheets have no geometry to hide.\n\nOpen a view '
|
||||
'that shows {0} and run the check again.'.format(asm.Name))
|
||||
|
||||
zoom = zoom_state()
|
||||
# ZoomToFit and not ShowElements: with everything else hidden the fit IS
|
||||
# the piece, and ShowElements can raise a modal dialog when it disagrees
|
||||
# about whether a view shows something.
|
||||
if zoom is not None:
|
||||
try:
|
||||
zoom[0].ZoomToFit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def restore():
|
||||
plain = OverrideGraphicSettings()
|
||||
try:
|
||||
with tx('Show assembly'):
|
||||
for member in others:
|
||||
try:
|
||||
view.SetElementOverrides(member, plain)
|
||||
except Exception:
|
||||
pass
|
||||
view.DisableTemporaryViewMode(
|
||||
TemporaryViewMode.TemporaryHideIsolate)
|
||||
except Exception:
|
||||
pass
|
||||
if zoom is not None:
|
||||
try:
|
||||
zoom[0].ZoomAndCenterRectangle(zoom[1], zoom[2])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return restore
|
||||
|
||||
|
||||
def pick_inserts(stage, preset):
|
||||
"""Click the inserts for one stage. None if the user pressed Esc."""
|
||||
pre = List[Reference]()
|
||||
ids = List[ElementId]()
|
||||
for el in elements_of(preset):
|
||||
try:
|
||||
pre.Add(Reference(el))
|
||||
ids.Add(el.Id)
|
||||
except Exception:
|
||||
pass
|
||||
# Said twice on purpose. PickObjects takes the pre-selected references and
|
||||
# returns them, but does not reliably paint them, and a default the user
|
||||
# cannot see is a default they cannot accept. Setting the selection first
|
||||
# puts them on the screen in the ordinary blue.
|
||||
try:
|
||||
uidoc.Selection.SetElementIds(ids)
|
||||
uidoc.RefreshActiveView()
|
||||
except Exception:
|
||||
pass
|
||||
prompt = ('{0} of {1}: select the inserts that carry the piece, then '
|
||||
'click Finish'.format(stage['name'].upper(), panel['mark']))
|
||||
try:
|
||||
try:
|
||||
refs = uidoc.Selection.PickObjects(
|
||||
ObjectType.Element, pick_filter, prompt, pre)
|
||||
except TypeError: # older binding: no pre-selected overload
|
||||
refs = uidoc.Selection.PickObjects(
|
||||
ObjectType.Element, pick_filter, prompt)
|
||||
except OperationCanceledException:
|
||||
return None
|
||||
picked = []
|
||||
for ref in refs:
|
||||
el = doc.GetElement(ref.ElementId)
|
||||
if el is None:
|
||||
continue
|
||||
mark = str(ticketqc.eid(el))
|
||||
if mark in by_mark and mark not in picked:
|
||||
picked.append(mark)
|
||||
return picked
|
||||
|
||||
|
||||
def tally(marks):
|
||||
"""Families counted, and the stations they stand at, in order."""
|
||||
counts = {}
|
||||
stations = []
|
||||
for mark in sorted(marks, key=lambda m: by_mark[m]['x']):
|
||||
lifter = by_mark[mark]
|
||||
counts[lifter['family']] = counts.get(lifter['family'], 0) + 1
|
||||
where = ticketqc.fmt_feet(lifter['x'])
|
||||
if where not in stations:
|
||||
stations.append(where)
|
||||
return (', '.join('{0} x{1}'.format(f, c)
|
||||
for f, c in sorted(counts.items())),
|
||||
', '.join(stations))
|
||||
|
||||
|
||||
put_it_back = show_the_piece()
|
||||
|
||||
support_note = ''
|
||||
supported = [s for s in stages
|
||||
if s.get('supports') and s.get('enabled', True)]
|
||||
if supported:
|
||||
support_note = ('\n\nNot picked, read from plant knowledge: ' +
|
||||
'; '.join(
|
||||
'{0} on dunnage at {1}'.format(
|
||||
s['name'],
|
||||
', '.join(ticketqc.fmt_feet(x)
|
||||
for x in s['supports']))
|
||||
for s in supported))
|
||||
|
||||
forms.alert(
|
||||
'You will be asked to select the inserts for each stage in turn, on the '
|
||||
'piece itself:\n\n {0}\n\nWhat the rules expect is highlighted '
|
||||
'already. Ctrl-click adds, Shift-click removes, then Finish on the '
|
||||
'options bar. Esc cancels the whole run.{1}{2}'
|
||||
.format(' -> '.join(
|
||||
s['name'] if s.get('enabled', True)
|
||||
else '{0} (not analysed)'.format(s['name']) for s in stages),
|
||||
'' if not audit else
|
||||
'\n\nThe defaults need a look on this piece:\n\n ' +
|
||||
'\n '.join(audit),
|
||||
support_note),
|
||||
title='{0} - inserts'.format(panel['mark']))
|
||||
|
||||
chosen = []
|
||||
try:
|
||||
for stage in stages:
|
||||
# A stage switched off in the plant config is not analysed, so there
|
||||
# is nothing to pick for it. It still travels into the job file, and
|
||||
# panellift still prints it -- as the sentence "not performed", in
|
||||
# its place among the stages that ran. Asking for inserts here would
|
||||
# be asking the engineer to configure a check nobody is running.
|
||||
if stage.get('enabled', True) is False:
|
||||
chosen.append(stage)
|
||||
continue
|
||||
if stage.get('supports'):
|
||||
chosen.append(stage)
|
||||
continue
|
||||
preset = list(stage['lifters'])
|
||||
while True:
|
||||
marks = pick_inserts(stage, preset)
|
||||
if marks is None:
|
||||
script.exit()
|
||||
if len(marks) < 2:
|
||||
forms.alert('{0} needs at least two inserts to be a pick, '
|
||||
'and {1} were selected.'
|
||||
.format(stage['name'], len(marks)),
|
||||
title='Panel Lift Check')
|
||||
preset = marks or preset
|
||||
continue
|
||||
if len(marks) < len(stage['lifters']):
|
||||
families, stations = tally(marks)
|
||||
# Fewer inserts than the rules expected is a real pick and
|
||||
# often the right one -- a crane that cannot reach the top
|
||||
# row, an insert cast over. It is worth one question because
|
||||
# a support line dropped by accident is invisible in the
|
||||
# result: the report is complete, the diagrams are drawn, and
|
||||
# the piece was checked on a pick it does not have.
|
||||
if not forms.alert(
|
||||
'{0}: {1} inserts selected where the rules expected '
|
||||
'{2}.\n\n {3}\n at {4}\n\nUse these?'
|
||||
.format(stage['name'], len(marks),
|
||||
len(stage['lifters']), families, stations),
|
||||
title='{0} - {1}'.format(panel['mark'],
|
||||
stage['name']),
|
||||
ok=False, yes=True, no=True):
|
||||
preset = marks
|
||||
continue
|
||||
stage['lifters'] = marks
|
||||
chosen.append(stage)
|
||||
break
|
||||
finally:
|
||||
# Esc out of a pick raises through here. Leaving a temporary isolate
|
||||
# switched on in somebody's view because they changed their mind is not
|
||||
# something they should have to work out how to undo.
|
||||
if put_it_back is not None:
|
||||
put_it_back()
|
||||
|
||||
job['stages'] = chosen
|
||||
|
||||
missing = [s['name'] for s in chosen
|
||||
if s.get('enabled', True) and not s.get('fc_psi')]
|
||||
if missing:
|
||||
not_run('No concrete strength for: {0}.\n\nThe design summary for '
|
||||
'{1} did not give an f\'c or f\'ci that these stages could '
|
||||
'use.'.format(', '.join(missing), design or 'this piece'))
|
||||
|
||||
# ------------------------------------------------------------ where it goes
|
||||
|
||||
start_dir = knowledge.get(know, 'handling.report_dir') or \
|
||||
designspec.sub_dir(doc, 'handling')
|
||||
try:
|
||||
if start_dir and not os.path.isdir(start_dir):
|
||||
os.makedirs(start_dir)
|
||||
except Exception:
|
||||
start_dir = ''
|
||||
|
||||
out_path = forms.save_file(
|
||||
file_ext='html', init_dir=start_dir or '',
|
||||
default_name='{0}.html'.format(panel['mark']),
|
||||
title='Save the handling report as')
|
||||
if not out_path:
|
||||
script.exit()
|
||||
|
||||
# ------------------------------------------------------------------- run it
|
||||
|
||||
python_exe = liftexport.find_python(know)
|
||||
if not python_exe:
|
||||
not_run('CPython 3.9 or newer was not found.\n\n'
|
||||
'The calculation runs outside Revit so it can be tested '
|
||||
'without one. Install Python, or set handling.python_exe in '
|
||||
'the knowledge file to the full path of python.exe.')
|
||||
|
||||
# The job file is kept beside the report on purpose. It is the whole input to
|
||||
# the calculation, so a number anybody disputes can be re-run, by hand, on a
|
||||
# machine with no Revit on it.
|
||||
job_path = os.path.splitext(out_path)[0] + '.job.json'
|
||||
blob = json.dumps(liftexport.strip_private(job), indent=1, sort_keys=True)
|
||||
with io.open(job_path, 'w', encoding='utf-8') as fh:
|
||||
fh.write(as_text(blob))
|
||||
|
||||
# Whether this run is willing to spend the 13 seconds. ON by default and ON
|
||||
# after any failure to read the file, exactly like expensive_geometry: an
|
||||
# unreadable options file must never quietly take half the report away.
|
||||
search_positions = True
|
||||
try:
|
||||
search_positions = bool(configmodel.load_run_options(
|
||||
designspec.root_dir(doc))['recommend_positions'])
|
||||
except Exception:
|
||||
search_positions = True
|
||||
|
||||
# The position search is the long part and it can genuinely run for minutes on
|
||||
# a piece with many movable rows. It runs on Revit's UI thread, so without a
|
||||
# progress bar being ticked the window is simply frozen, which nobody can tell
|
||||
# apart from a crash -- and a run that looks crashed gets killed.
|
||||
#
|
||||
# The title tells the truth about which of the two it is doing. A bar that says
|
||||
# "searching insert positions" on a run that is not searching them is the same
|
||||
# lie as a report section that quietly disappears.
|
||||
with forms.ProgressBar(title='{0}: {1}... {{value}}s'.format(
|
||||
panel['mark'],
|
||||
'searching insert positions' if search_positions
|
||||
else 'checking the piece (position search off)'),
|
||||
cancellable=True) as pb:
|
||||
cancelled = []
|
||||
|
||||
def tick(elapsed_ms):
|
||||
if pb.cancelled:
|
||||
cancelled.append(True)
|
||||
pb.update_progress(elapsed_ms // 1000, max(60, elapsed_ms // 1000))
|
||||
|
||||
pb.update_progress(0, 60)
|
||||
code, summary, stderr = liftexport.run(python_exe, job_path, out_path,
|
||||
on_tick=tick,
|
||||
recommend=search_positions)
|
||||
|
||||
if code == 2 or summary is None:
|
||||
not_run('The calculation could not run.\n\n{0}\n\n'
|
||||
'Job file kept at:\n{1}'.format(stderr or 'no error text',
|
||||
job_path))
|
||||
# exit 1 is a RESULT (a stage failed its check), not a crash -- it is
|
||||
# shown as FAIL in the table below, which is 'calculated and failed',
|
||||
# distinct from the NOT RUN above.
|
||||
|
||||
if knowledge.get(know, 'handling.open_report', True):
|
||||
liftexport.open_file(out_path)
|
||||
|
||||
# -------------------------------------------------------------------- report
|
||||
|
||||
output.print_md('# {0} — handling'.format(panel['mark']))
|
||||
# The scope, on the single-piece report too. This piece was SELECTED, so it
|
||||
# was never a candidate for being set aside -- but if it is outside the
|
||||
# declared scope the reader is told that the numbers rest on knowledge nobody
|
||||
# validated for that kind of piece.
|
||||
_scope = scopelib.of(know)
|
||||
output.print_md('> ' + _scope.line([], unit='piece'))
|
||||
if not _scope.includes(panel['mark']):
|
||||
output.print_md('> ' + (_scope.annotation([panel['mark']], unit='piece')
|
||||
or ''))
|
||||
output.print_md('Design **{0}** · {1} × {2} × {3}" '
|
||||
'· {4} lb'
|
||||
.format(panel.get('design_number') or '-',
|
||||
ticketqc.fmt_feet(panel['length_ft']),
|
||||
ticketqc.fmt_feet(panel['width_ft']),
|
||||
panel['thickness_in'],
|
||||
'{0:,.0f}'.format(panel['weight_lb'] or 0)))
|
||||
# A bare Windows path is not a URL: with backslashes and no scheme a
|
||||
# browser will not navigate to it, so the link rendered but did nothing.
|
||||
# The text stays the real path -- that is what the engineer copies -- and
|
||||
# only the href gets the file:/// form.
|
||||
_href = 'file:///' + out_path.replace('\\', '/')
|
||||
output.print_md('**{0}** [{1}]({2})'.format(summary['verdict'],
|
||||
out_path, _href))
|
||||
|
||||
# The verdict above is the answer for the stages that RAN. If one was
|
||||
# switched off, saying only "OK" here claims the piece, and a reader who
|
||||
# takes the button's one bold word and closes the window has been told the
|
||||
# panel is fine when a whole handling condition was never looked at.
|
||||
_not_run = summary.get('not_run') or []
|
||||
if _not_run:
|
||||
output.print_md(
|
||||
'> **{0} of {1} stages were not analysed** — {2}. The verdict '
|
||||
'above covers the stages that were.'
|
||||
.format(len(_not_run), len(summary['stages']),
|
||||
'; '.join('{0}{1}'.format(
|
||||
s['name'], ' ({0})'.format(s['reason'])
|
||||
if s.get('reason') else '') for s in _not_run)))
|
||||
|
||||
# WHICH END IS UP, when the ruled mechanism did not answer it.
|
||||
#
|
||||
# `knowledge.up_end_from_origin` is the project's flagship declared-coverage
|
||||
# refusal, and `liftexport.erected_orientation` falls back to global elevation
|
||||
# when it declines. The fallback is ALLOWED -- global elevation is the sound
|
||||
# mechanism for a piece erected on end, which is the case the rule hands over
|
||||
# -- but it may never be silent, or it is a guess wearing the refusal's
|
||||
# credibility. The key is written only when the fallback answered, so this
|
||||
# block never fires on a piece the rule settled.
|
||||
_basis = summary.get('basis')
|
||||
if _basis:
|
||||
output.print_md(
|
||||
'> **Which end is up came from a fallback, not the rule.** Read from '
|
||||
'{0}. {1}<br>What it decides: {2}. Stripping and transportation are '
|
||||
'analysed flat and are unchanged by it.'
|
||||
.format(_basis.get('basis'), _basis.get('why') or '',
|
||||
_basis.get('affects') or 'the erected stage'))
|
||||
|
||||
for problem in problems:
|
||||
output.print_md('> {0}'.format(problem))
|
||||
|
||||
|
||||
def _stage_row(s):
|
||||
"""One row of the summary table. A stage that was not analysed gets
|
||||
words across the numeric columns, not dashes: a dash in a column of
|
||||
numbers is read as a number that came out small."""
|
||||
if not s.get('analysed', True):
|
||||
return [s['name'], '-', '-', 'not analysed', '-', '-', 'NOT RUN']
|
||||
return [s['name'], '{0:.2f}'.format(s['multiplier']),
|
||||
'{0:,.0f}'.format(s['fc_psi']),
|
||||
'-' if s['peak_tension_psi'] is None
|
||||
else '{0:,.0f}'.format(s['peak_tension_psi']),
|
||||
'{0:,.0f}'.format(s['allowable_psi']),
|
||||
'-' if s['ratio'] is None else '{0:.0%}'.format(s['ratio']),
|
||||
s['verdict']]
|
||||
|
||||
|
||||
output.print_table(
|
||||
[_stage_row(s) for s in summary['stages']],
|
||||
columns=['stage', 'mult', "f'c", 'peak psi', 'allowable', 'of allow',
|
||||
'result'])
|
||||
|
||||
# What was actually picked, in writing. The numbers above mean nothing without
|
||||
# it, and it is the one part of the run that came from a human.
|
||||
#
|
||||
# A stage carried on dunnage was not picked and has no 'lifters' key at all --
|
||||
# it is dropped rather than emptied, because panellift refuses a stage that
|
||||
# gives both a support line and an insert. So it gets its own row saying where
|
||||
# it sat, rather than an empty one implying nobody chose anything.
|
||||
output.print_md('## Inserts picked')
|
||||
rows = []
|
||||
for s in chosen:
|
||||
if s.get('enabled', True) is False:
|
||||
rows.append([s['name'], '-', 'not analysed - no pick was made', '-'])
|
||||
elif s.get('supports'):
|
||||
rows.append([s['name'], '-', 'on dunnage (plant knowledge)',
|
||||
', '.join(ticketqc.fmt_feet(x) for x in s['supports'])])
|
||||
else:
|
||||
marks = s.get('lifters') or []
|
||||
rows.append([s['name'], str(len(marks))] + list(tally(marks)))
|
||||
output.print_table(rows, columns=['stage', 'n', 'inserts', 'at'])
|
||||
|
||||
rec = summary.get('recommendation') or {}
|
||||
if rec.get('searched') is False:
|
||||
# `searched` is written by panellift ONLY when the search was skipped, so
|
||||
# `is False` is the whole test and a summary from a build that predates
|
||||
# the flag reads as "it ran" -- which it did. Printed under the heading it
|
||||
# would have filled, not dropped: a missing section reads as a piece with
|
||||
# nothing to recommend, and this piece was never asked.
|
||||
output.print_md('## Recommended insert positions')
|
||||
output.print_md(
|
||||
'**NOT COMPUTED** — the position search is switched off for '
|
||||
'this run (`recommend_positions` in Project Config → run-time '
|
||||
'options). Every stage above was checked on the inserts exactly as '
|
||||
'drawn and the verdict stands; what is missing is only where they '
|
||||
'should go instead. Switch it back on and run the piece again to get '
|
||||
'it.')
|
||||
elif rec.get('note') and not rec.get('moves'):
|
||||
output.print_md('> {0}'.format(rec['note']))
|
||||
elif rec.get('moves'):
|
||||
output.print_md('## Recommended insert positions')
|
||||
if rec.get('worth_moving'):
|
||||
output.print_md(
|
||||
'Moving them takes the governing stage from **{0:.0%}** of '
|
||||
'allowable to **{1:.0%}**.'.format(rec['as_built_ratio'],
|
||||
rec['recommended_ratio']))
|
||||
else:
|
||||
output.print_md('The inserts are already where the search would put '
|
||||
'them.')
|
||||
output.print_table(
|
||||
[[m['mark'], m['family'],
|
||||
ticketqc.fmt_feet(m['from_x_ft']), ticketqc.fmt_feet(m['from_y_ft']),
|
||||
ticketqc.fmt_feet(m['to_x_ft']), ticketqc.fmt_feet(m['to_y_ft']),
|
||||
'-' if abs(m['dx_in']) < 0.005 else '{0:+.2f}'.format(m['dx_in']),
|
||||
'-' if abs(m['dy_in']) < 0.005 else '{0:+.2f}'.format(m['dy_in']),
|
||||
'pinned' if m['pinned'] else ('move' if m['moved'] else '')]
|
||||
for m in rec['moves']],
|
||||
columns=['insert', 'family', 'x', 'y', 'to x', 'to y',
|
||||
'dx in', 'dy in', ''])
|
||||
for row in rec.get('rows') or []:
|
||||
if row.get('note'):
|
||||
output.print_md('- {0}: {1}'.format(
|
||||
ticketqc.fmt_feet(row['to_x_ft']), row['note']))
|
||||
output.print_md(
|
||||
'> The recommendation is one position per insert, solved for every '
|
||||
'stage at once. **x** is searched to minimise the governing '
|
||||
'utilisation; **y** is geometry only — a beam model has no '
|
||||
'transverse mechanics, so whether the piece hangs level is stated '
|
||||
'from the layout and not calculated.')
|
||||
|
||||
for stage in summary['stages']:
|
||||
bad = stage['findings']
|
||||
if not bad:
|
||||
continue
|
||||
output.print_md('### {0}'.format(stage['name']))
|
||||
for f in bad:
|
||||
output.print_md('- **{0}** `{1}` — {2}'
|
||||
.format(f['severity'], f['code'], f['message']))
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('Report: `{0}` \nJob file: `{1}`'.format(out_path, job_path))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,11 @@
|
||||
title: Snapshot
|
||||
# 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, and it is shorter than "Reinforce" beside it -- the
|
||||
# four large buttons on this panel all come out the same width.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,838 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Index the model as it stands, then hand the sandbox off -- or close Revit.
|
||||
#
|
||||
# WHAT THIS BUTTON IS NOW, 2026-09-01
|
||||
# ------------------------------------
|
||||
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
|
||||
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
|
||||
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
|
||||
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
|
||||
#
|
||||
# So the whole cycle moved out of this button and into
|
||||
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
|
||||
# it, and can be started by Claude. What is left here is the one job that
|
||||
# genuinely requires the live session and cannot be done anywhere else:
|
||||
# handing over the model's IDENTITY. Only the open document can cheaply report
|
||||
# its cloud region and GUIDs, and without those nothing else can find the
|
||||
# model to copy.
|
||||
#
|
||||
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
|
||||
# ----------------------------------------------
|
||||
# The design this replaced expected an in-session background open --
|
||||
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
|
||||
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
|
||||
# does not exist for this model, and the reason is not a version guard:
|
||||
#
|
||||
# * a cloud model cannot be opened detached at all. Any
|
||||
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
|
||||
# is not valid for cloud model"; Autodesk's answer is that the Revit team
|
||||
# does not allow it and that SaveAs is the way; and the UI agrees --
|
||||
# "Detach from Central" is greyed out for anything from Autodesk Docs.
|
||||
# * Revit holds one Document per model per session, so the live session
|
||||
# cannot open the model it already has open.
|
||||
# * the only call that could copy the document already in memory is
|
||||
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
|
||||
# here it would leave HIS Revit sitting in a local copy, disconnected
|
||||
# from the cloud. There is no SaveCopyAs.
|
||||
#
|
||||
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
|
||||
# The copy is therefore made by a Revit that is not his, and this button's
|
||||
# phase C is a spawn rather than an open.
|
||||
#
|
||||
# THE OLD RITUAL, KEPT
|
||||
# ---------------------
|
||||
# `snapshot.after_close` is NOT retired. The close-model path below is
|
||||
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
|
||||
# day, where the answer really is "close everything first". It is simply no
|
||||
# longer the only way to get a copy, and it is no longer the default.
|
||||
#
|
||||
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
|
||||
# ------------------------------------------------
|
||||
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
|
||||
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
|
||||
# session and a detached sandbox ran side by side all morning. What nobody has
|
||||
# measured is three, so the pipeline sequences to keep it to two and this
|
||||
# button never opens a model at all.
|
||||
#
|
||||
# The close-model ritual, still here as one of the options:
|
||||
#
|
||||
# he syncs his act, outside this button. This button NEVER
|
||||
# calls SynchronizeWithCentral and never asks about it.
|
||||
# phase A this button indexes the OPEN document. The model is
|
||||
# already in RAM and already paid for; a snapshot read
|
||||
# from the live document at sync state and one read
|
||||
# from a detached copy of that same state contain the
|
||||
# same numbers. Seconds, not minutes, and no copy.
|
||||
# phase B Revit closes. The press IS the authorisation for the
|
||||
# close -- that is what the button is called.
|
||||
# phase C AFTER Revit has exited and the 10 GB is free, a
|
||||
# deferred host process opens the model detached, saves
|
||||
# a timestamped copy and quits. Optional, off unless
|
||||
# the job layer configures it, and its outcome lands in
|
||||
# a log this button reads out on the next press.
|
||||
#
|
||||
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
|
||||
# --------------------------------------------------------------------
|
||||
# No Transaction is opened in this file or in anything it calls. Nothing is
|
||||
# written to the model -- not a parameter, not a workset, not a save. The only
|
||||
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
|
||||
# file, in another process, after this one has exited. The sync that used to
|
||||
# live here has been removed entirely at the owner's instruction.
|
||||
#
|
||||
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
|
||||
# ----------------------------------------------------
|
||||
# Revit is closed LAST and only after phase A has reported complete. A crash
|
||||
# halfway through the index must leave the session open, with the traceback on
|
||||
# screen, because the alternative is a closed Revit and no way to find out
|
||||
# what went wrong. `PostCommand` is therefore reached on exactly one path.
|
||||
#
|
||||
# The other half of that discipline is phase C. It runs when nobody is
|
||||
# watching, in a process this one has already outlived, and a day it silently
|
||||
# did not run looks exactly like a day it worked. So the request is journalled
|
||||
# BEFORE the close and the deferred process journals its own outcome; a
|
||||
# request with nothing after it is reported, in capitals, at the top of the
|
||||
# next press. Silence is not an acceptable report.
|
||||
#
|
||||
# JUDGEMENT CALLS, for the owner to overrule
|
||||
# -------------------------------------------
|
||||
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
|
||||
# press elsewhere trusts the cheap signature, which cannot see an opening
|
||||
# moved or resized in place. This is the daily archival snapshot and it is
|
||||
# the one that gets trusted months later, so it pays the measured 6-10 s
|
||||
# for an answer that owes nothing to yesterday's cache.
|
||||
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
|
||||
# hang on a mis-click, and "snapshot but stay open" is a real want (a
|
||||
# lunchtime checkpoint). Cancel writes nothing at all.
|
||||
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
|
||||
# declared scope and no parameter-role mapping, so the index would measure
|
||||
# the whole model and mark every piece could-not-judge on the review
|
||||
# signal -- and would overwrite a good index to do it. That is worth a
|
||||
# dialog. It is not worth a refusal on a machine that legitimately has no
|
||||
# job layer yet.
|
||||
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
|
||||
# default and None means not configured, never "pick somewhere". The button
|
||||
# says so instead of guessing a folder for a 300 MB file.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Snapshot'
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import knowledge
|
||||
from vibe import liftexport
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Snapshot & Close'
|
||||
|
||||
# Where this button sits, and therefore where the repo is. Hoisted to the top
|
||||
# because three separate places need it now (the deferred tool, the cycle
|
||||
# pipeline and the runner) and computing it three times is how two of them end
|
||||
# up one directory apart.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
||||
REPO_ROOT = os.path.dirname(EXT_ROOT)
|
||||
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
|
||||
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
|
||||
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
|
||||
|
||||
CYCLE = 'Snapshot, then run the sandbox cycle'
|
||||
CLOSE = 'Snapshot, then close Revit'
|
||||
STAY = 'Snapshot only, stay open'
|
||||
CANCEL = 'Cancel'
|
||||
|
||||
|
||||
def die(message):
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
if doc is None:
|
||||
die('No model is open, so there is nothing to snapshot.')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- versions
|
||||
|
||||
def _revit_version():
|
||||
try:
|
||||
app = doc.Application
|
||||
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pyrevit_version():
|
||||
try:
|
||||
from pyrevit import versionmgr
|
||||
return versionmgr.get_pyrevit_version().get_formatted()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _revit_pid():
|
||||
try:
|
||||
from System.Diagnostics import Process
|
||||
return Process.GetCurrentProcess().Id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------ what already happened
|
||||
#
|
||||
# Read and printed FIRST, before anything is decided, because the one thing
|
||||
# this design can lose is a phase C that never ran -- and the only moment
|
||||
# anybody is looking is the moment they press this button again.
|
||||
|
||||
know = knowledge.load(doc)
|
||||
job_folder = None
|
||||
try:
|
||||
from vibe import designspec
|
||||
job_folder = designspec.root_dir(doc)
|
||||
except Exception:
|
||||
job_folder = None
|
||||
|
||||
log = S.log_path(job_folder) if job_folder else None
|
||||
history = S.read(log) if log else []
|
||||
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
for line in S.summary(history):
|
||||
if line.startswith('WARNING'):
|
||||
output.print_md('**{0}**'.format(line))
|
||||
else:
|
||||
output.print_md('- {0}'.format(line))
|
||||
|
||||
|
||||
# ---------------------------------------------------- is this job set up
|
||||
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
die('\n\n'.join(problems))
|
||||
|
||||
has_job_layer = False
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
has_job_layer = True
|
||||
|
||||
if not has_job_layer:
|
||||
keep_going = forms.alert(
|
||||
'This model has no job knowledge file.\n\n'
|
||||
'Without it there is no declared scope and no parameter-name '
|
||||
'mapping, so the index will measure every assembly in the model and '
|
||||
'will record "could not judge" for the review signal on all of them '
|
||||
'-- and it will overwrite whatever index is there now.\n\n'
|
||||
'Snapshot anyway?',
|
||||
title=TITLE, yes=True, no=True)
|
||||
if not keep_going:
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------- phase C settings
|
||||
#
|
||||
# Resolved BEFORE the confirmation so the dialog can say what will actually
|
||||
# happen after the close, rather than promising a detached copy that turns out
|
||||
# not to be configured.
|
||||
|
||||
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
|
||||
after_close = knowledge.get(know, 'snapshot.after_close', False)
|
||||
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
|
||||
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
|
||||
# TEXT, so two call sites spelling the same value differently are reported as
|
||||
# disagreeing about what "unset" means.
|
||||
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
|
||||
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
|
||||
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
|
||||
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
|
||||
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
|
||||
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
|
||||
or S.revit_exe_guess(revit_year)
|
||||
if not revit_year:
|
||||
# `pyrevit run` (both the cycle route's detach step and the close-model
|
||||
# route) takes an optional `--revit=<year>`, and a job layer that states
|
||||
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
|
||||
# job's shape -- used to leave it off entirely. On a machine with more
|
||||
# than one Revit version attached that is not a neutral omission: see
|
||||
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
|
||||
# is named for. `revit_exe`'s own path almost always carries the year.
|
||||
revit_year = S.revit_year_from_exe(revit_exe)
|
||||
batch = knowledge.get(know, 'snapshot.batch', []) or []
|
||||
|
||||
|
||||
def _pyrevit_cli():
|
||||
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
|
||||
if pyrevit_exe and os.path.isfile(pyrevit_exe):
|
||||
return pyrevit_exe
|
||||
if pyrevit_exe:
|
||||
return None # stated and wrong: never fall back
|
||||
return S.pyrevit_exe_guess()
|
||||
|
||||
|
||||
def _copy_blocker():
|
||||
"""What stops ANY detached copy being made, whichever route. Or None."""
|
||||
if not detached_dir:
|
||||
return ('not configured -- set snapshot.detached_dir in the job '
|
||||
'knowledge file to switch it on')
|
||||
if not os.path.isdir(detached_dir):
|
||||
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
|
||||
if _pyrevit_cli() is None:
|
||||
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
|
||||
if liftexport.find_python(know) is None:
|
||||
return 'no host Python was found to run the deferred step'
|
||||
return None
|
||||
|
||||
|
||||
def phase_c_blocker():
|
||||
"""Why the OLD after-close route cannot run, or None.
|
||||
|
||||
`snapshot.after_close` is deliberately still consulted here and nowhere
|
||||
else: it gates the close-model ritual, which is the thing it has always
|
||||
meant. The cycle route below does not read it -- it is not chained to an
|
||||
exit, so "after close" has nothing to say about it, and making one key
|
||||
mean two things is how a setting becomes untrustworthy.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not after_close:
|
||||
return ('switched off -- snapshot.after_close is false in the job '
|
||||
'knowledge file')
|
||||
return None
|
||||
|
||||
|
||||
def cycle_blocker():
|
||||
"""Why the sandbox cycle cannot be started, or None.
|
||||
|
||||
Note what is NOT in here: `after_close`, and any check on other Revits.
|
||||
The cycle runs beside this session on purpose -- that is the whole point
|
||||
of it -- and it sequences its own steps so that at most two Revits are
|
||||
ever resident.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not revit_exe:
|
||||
return ('no Revit.exe to open the sandbox with -- set '
|
||||
'snapshot.revit_exe or snapshot.revit_year')
|
||||
if not os.path.isfile(revit_exe):
|
||||
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
|
||||
if not batch:
|
||||
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
|
||||
if not os.path.isfile(CYCLE_TOOL):
|
||||
return 'tools/snapshot_cycle.py is not where this button expects it'
|
||||
return None
|
||||
|
||||
|
||||
blocker = phase_c_blocker()
|
||||
cycle_stop = cycle_blocker()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the decision
|
||||
|
||||
if cycle_stop is None:
|
||||
cycle_tail = (
|
||||
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
|
||||
'own, then opened in a sandbox session, then {1} runs inside it, then '
|
||||
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
|
||||
.format(detached_dir, ' + '.join(batch)))
|
||||
else:
|
||||
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
|
||||
|
||||
if blocker is None:
|
||||
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
|
||||
'copy is written to:\n{0}'.format(detached_dir))
|
||||
else:
|
||||
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
|
||||
|
||||
choice = forms.alert(
|
||||
'Sync first if you have not -- this button does not sync, and it never '
|
||||
'touches the model.\n\n'
|
||||
'It rebuilds the reinforcing index from the model as it stands now and '
|
||||
'stamps it with where it came from.' + cycle_tail + close_tail,
|
||||
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
|
||||
|
||||
if choice not in (CYCLE, CLOSE, STAY):
|
||||
output.print_md('---')
|
||||
output.print_md('Cancelled. Nothing was read, written or closed.')
|
||||
script.exit()
|
||||
|
||||
closing = (choice == CLOSE)
|
||||
cycling = (choice == CYCLE)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase A
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase A - indexing the open model')
|
||||
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
|
||||
'an earlier press. Measured at 6-10 s on this model.')
|
||||
|
||||
# The index AS IT STOOD BEFORE this press, captured so the update statistics
|
||||
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
|
||||
# only loads the file, it never touches the model. Absent or damaged is fine:
|
||||
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
|
||||
try:
|
||||
old_cache = index.read(doc)
|
||||
except Exception:
|
||||
old_cache = None
|
||||
|
||||
report = None
|
||||
cache = None
|
||||
failure = None
|
||||
try:
|
||||
# An upper bound for the bar: a full rebuild measures at most every
|
||||
# assembly in the model. The cheap collector pass costs ~0.3 s here and
|
||||
# buys a progress bar that moves, instead of one pinned at 100%.
|
||||
total = max(len(index.assemblies(doc)), 1)
|
||||
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
|
||||
cancellable=False) as bar:
|
||||
state = {'n': 0}
|
||||
|
||||
def tick(_name):
|
||||
state['n'] += 1
|
||||
bar.update_progress(state['n'], total)
|
||||
|
||||
bar.update_progress(0, total)
|
||||
cache, report = index.refresh(doc, rebuild_all=True, know=know,
|
||||
on_piece=tick)
|
||||
except Exception:
|
||||
failure = traceback.format_exc()
|
||||
|
||||
if failure is not None:
|
||||
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
|
||||
output.print_md('## Phase A FAILED - Revit has not been closed')
|
||||
output.print_md('```\n{0}\n```'.format(failure))
|
||||
if log:
|
||||
S.append(log, {'event': 'phase-a-failed',
|
||||
'error': failure.splitlines()[-1]})
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- stamp it with where it came from
|
||||
#
|
||||
# `refresh` writes the index; this adds the block that says whose numbers
|
||||
# those are, and rewrites. It is written on EVERY press, including the press
|
||||
# where nothing changed and `refresh` therefore did not save -- an index whose
|
||||
# provenance is a week older than its numbers is worse than none.
|
||||
|
||||
source_path = None
|
||||
try:
|
||||
source_path = doc.PathName or None
|
||||
except Exception:
|
||||
source_path = None
|
||||
|
||||
prov = S.provenance(
|
||||
'A', S.LIVE_DOC,
|
||||
source_path=source_path,
|
||||
source_stamp=None, # a live document has no snapshot stamp
|
||||
source_title=index.doc_key(doc),
|
||||
revit=_revit_version(),
|
||||
pyrevit=_pyrevit_version(),
|
||||
job_folder=job_folder,
|
||||
note=('read from the open document immediately after the engineer synced; '
|
||||
'no detached copy was involved in these numbers'))
|
||||
|
||||
pieces = len(cache.get('pieces') or {})
|
||||
try:
|
||||
cache['provenance'] = prov
|
||||
core.save(report['path'], cache)
|
||||
stamped = True
|
||||
except Exception:
|
||||
stamped = False
|
||||
|
||||
output.print_md('- {0} assemblies seen, {1} pieces in the index'
|
||||
.format(report['assemblies'], pieces))
|
||||
output.print_md('- scope: {0}'.format(report['scope']))
|
||||
output.print_md('- built in {0} s, written to `{1}`'
|
||||
.format(report['seconds_total'], report['path']))
|
||||
if stamped:
|
||||
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
|
||||
else:
|
||||
output.print_md('- **the provenance stamp could not be written** -- this '
|
||||
'index cannot say where it came from')
|
||||
|
||||
missing = (report.get('coverage') or {}).get('missing') or []
|
||||
if missing:
|
||||
output.print_md('- **{0} pieces could not be measured** and are not in '
|
||||
'this snapshot'.format(len(missing)))
|
||||
|
||||
if log:
|
||||
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
|
||||
'path': report['path'], 'scope': report['scope'],
|
||||
'seconds': report['seconds_total'],
|
||||
'provenance': prov})
|
||||
|
||||
|
||||
# ------------------------------------------------- update statistics
|
||||
#
|
||||
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
|
||||
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
|
||||
# press changed: which pieces are newly donors, how many features each carries,
|
||||
# and which feature-IDs went into the shared vocabulary for the first time
|
||||
# versus which were already known. READ ONLY -- this only reads the two caches
|
||||
# and one cheap signature pass; nothing about the model is written.
|
||||
#
|
||||
# `index.refresh` already grew the registry (via `core.register_features`), so
|
||||
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
|
||||
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
|
||||
# runs, redone here so the diff sees the model exactly as it stands now.
|
||||
|
||||
output.print_md('## Update statistics -- what this snapshot marked')
|
||||
try:
|
||||
live = index.live_signatures(doc, know=know)
|
||||
stats = core.snapshot_stats(old_cache, cache, live)
|
||||
except Exception:
|
||||
stats = None
|
||||
output.print_md('- **the update statistics could not be computed** -- the '
|
||||
'index was still written; only this summary is missing')
|
||||
|
||||
if stats is not None:
|
||||
newly = stats['newly']
|
||||
if stats['first_snapshot']:
|
||||
output.print_md('- **first snapshot for this index** -- there was '
|
||||
'nothing to diff against, so every reinforced piece '
|
||||
'below is new to the donor pool, not a change since '
|
||||
'last press.')
|
||||
if not newly:
|
||||
output.print_md('- no piece was newly marked reinforced since the last '
|
||||
'snapshot. (This is "nothing changed", not "nothing '
|
||||
'is reinforced".)')
|
||||
else:
|
||||
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
|
||||
'{1}'.format(len(newly), ', '.join(newly)))
|
||||
output.print_md('- feature-IDs new to the vocabulary this press: '
|
||||
'**{0}**; already known: **{1}**'
|
||||
.format(len(stats['new_ids']),
|
||||
len(stats['known_ids'])))
|
||||
for panel in stats['panels']:
|
||||
if panel['feature_count'] is None:
|
||||
output.print_md(' - **{0}** -- could not be measured; no '
|
||||
'features recorded'.format(panel['name']))
|
||||
continue
|
||||
bits = ['{0} feature(s)'.format(panel['feature_count'])]
|
||||
if panel['new_ids']:
|
||||
bits.append('new IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
|
||||
if panel['known_ids']:
|
||||
bits.append('known IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
|
||||
if panel['unregistered']:
|
||||
bits.append('{0} unregistered'.format(panel['unregistered']))
|
||||
output.print_md(' - **{0}** -- {1}'.format(
|
||||
panel['name'], '; '.join(bits)))
|
||||
|
||||
if log:
|
||||
# Journal the same figures. Per-panel id lists are kept so the ledger
|
||||
# can be replayed without the model; `first_snapshot` distinguishes a
|
||||
# cold index from a genuine no-change press.
|
||||
S.append(log, {'event': 'phase-a-update-stats',
|
||||
'first_snapshot': stats['first_snapshot'],
|
||||
'newly': newly,
|
||||
'new_ids': stats['new_ids'],
|
||||
'known_ids': stats['known_ids'],
|
||||
'panels': stats['panels']})
|
||||
|
||||
|
||||
# ---------------------------------------------- handing the model over
|
||||
#
|
||||
# Both routes below need the same two things: WHERE the copy goes (a plan) and
|
||||
# HOW to find the model again (its identity). Built once, here, so the cycle
|
||||
# and the close-model ritual cannot drift into naming files differently.
|
||||
|
||||
|
||||
def _source_block():
|
||||
"""How another session is to find this model again.
|
||||
|
||||
Cloud GUIDs and file path both, when both are available: a cloud model is
|
||||
reopened from its GUIDs, a file-based one from its path, and which one a
|
||||
job is was never something to assume. A cloud model reports no usable
|
||||
local path, which is the ordinary case here, not an error.
|
||||
"""
|
||||
src = {'path': source_path, 'title': index.doc_key(doc)}
|
||||
try:
|
||||
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
|
||||
mp = doc.GetCloudModelPath()
|
||||
src['cloud'] = {
|
||||
'region': str(mp.Region),
|
||||
'project_guid': str(mp.GetProjectGUID()),
|
||||
'model_guid': str(mp.GetModelGUID()),
|
||||
}
|
||||
except Exception as exc:
|
||||
# Not a cloud model, or this Revit does not expose the GUIDs. The file
|
||||
# path is then the only handle and the runner says so if it is absent.
|
||||
src['cloud'] = None
|
||||
src['cloud_error'] = str(exc)
|
||||
return src
|
||||
|
||||
|
||||
def _write_request(run_id, plan, source, extra=None):
|
||||
"""The handoff file. Same schema for both routes -- one runner reads it."""
|
||||
request = {
|
||||
'run': run_id,
|
||||
'base': plan['title'],
|
||||
'layout': layout,
|
||||
'worksets': worksets,
|
||||
'detached_dir': detached_dir,
|
||||
'plan': plan,
|
||||
'log': log,
|
||||
'job_folder': job_folder,
|
||||
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'requested_by_revit': _revit_version(),
|
||||
'source': source,
|
||||
}
|
||||
request.update(extra or {})
|
||||
req_dir = os.path.join(detached_dir, '_requests')
|
||||
req_path = os.path.join(req_dir, run_id + '.json')
|
||||
if not os.path.isdir(req_dir):
|
||||
os.makedirs(req_dir)
|
||||
import json
|
||||
handle = open(req_path, 'wb')
|
||||
try:
|
||||
handle.write(json.dumps(request, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
# And the identity on its own, where the host pipeline looks for it.
|
||||
# A cloud model is found by its region and two GUIDs, and NOTHING on this
|
||||
# machine knows those except a session with the model open -- this one.
|
||||
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
|
||||
# later with no Revit anywhere and still find the model to copy. It is
|
||||
# identity, never state: it says which model, never anything about a copy.
|
||||
try:
|
||||
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
|
||||
try:
|
||||
cache.write(json.dumps(source, indent=2,
|
||||
sort_keys=True).encode('utf-8'))
|
||||
finally:
|
||||
cache.close()
|
||||
except Exception:
|
||||
pass # a lost cache costs a flag, not a snapshot
|
||||
return req_path
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
"""Start a host process that outlives this one. Returns its pid.
|
||||
|
||||
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
|
||||
minutes with nobody watching, and a console window popping up over the
|
||||
engineer's Revit is the kind of thing that gets a tool switched off.
|
||||
"""
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = argv[0]
|
||||
psi.Arguments = S.quote(argv[1:])
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
psi.WorkingDirectory = REPO_ROOT
|
||||
return Process.Start(psi).Id
|
||||
|
||||
|
||||
def _job_knowledge_path():
|
||||
"""The job knowledge file, so the host pipeline reads the same layer.
|
||||
|
||||
Handed over as a path rather than as resolved values: the pipeline needs
|
||||
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
|
||||
block, and a button that copies six settings across is a button that
|
||||
forgets the seventh the day one is added.
|
||||
"""
|
||||
try:
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------- the sandbox cycle
|
||||
#
|
||||
# The new route, and the one the commission is about. Nothing here opens,
|
||||
# saves or closes a model: it writes the handoff and starts a host process
|
||||
# that does all of it in Revits that are not this one.
|
||||
|
||||
run_id = None
|
||||
if cycling and cycle_stop is None:
|
||||
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
started_pid = None
|
||||
argv = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block(),
|
||||
{'requested_by': 'SnapshotClose button, '
|
||||
'cycle route'})
|
||||
argv = [liftexport.find_python(know), CYCLE_TOOL,
|
||||
'--fresh-detach',
|
||||
'--request', req_path,
|
||||
'--detached-dir', detached_dir,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--revit-exe', revit_exe]
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
if log:
|
||||
argv.extend(['--log', log])
|
||||
job_knowledge = _job_knowledge_path()
|
||||
if job_knowledge:
|
||||
argv.extend(['--knowledge', job_knowledge])
|
||||
for command in batch:
|
||||
argv.extend(['--run', command])
|
||||
started_pid = _spawn(argv)
|
||||
except Exception:
|
||||
started_pid = None
|
||||
cycle_stop = 'the pipeline could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - running now, beside this session')
|
||||
if started_pid:
|
||||
output.print_md(
|
||||
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
|
||||
'-- it is not closed, not saved, and not read again.'
|
||||
.format(run_id, started_pid))
|
||||
output.print_md('It writes:')
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('then opens that copy in a Revit of its own, runs '
|
||||
'`{0}`, and closes it again. Its log lands beside the '
|
||||
'copy and its outcome in `{1}`.'
|
||||
.format(' + '.join(batch), log))
|
||||
output.print_md('The command it is running:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'cycle', 'host_pid': started_pid})
|
||||
else:
|
||||
output.print_md('**Not started: {0}**'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'cycle', 'reason': cycle_stop})
|
||||
elif cycling:
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - not run')
|
||||
output.print_md('Not started: {0}'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
|
||||
'reason': cycle_stop})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase C
|
||||
#
|
||||
# The OLD route, kept: requested here, run later, by a process that outlives
|
||||
# this one. Everything it needs goes into a request file -- `pyrevit run`
|
||||
# passes no arguments to the script it runs, so a file named by an environment
|
||||
# variable is the only handoff there is.
|
||||
#
|
||||
# This is the only place `snapshot.after_close` still means anything.
|
||||
|
||||
if closing and blocker is None:
|
||||
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
launched = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block())
|
||||
argv = [liftexport.find_python(know), DEFER_TOOL,
|
||||
'--request', req_path,
|
||||
'--runner', RUNNER,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--wait-pid', str(_revit_pid()),
|
||||
'--wait-minutes', str(wait_minutes),
|
||||
# The close-model ritual is the one case where waiting for the
|
||||
# whole machine to be free is still the right answer: nobody
|
||||
# is at the keyboard and nothing else is queued behind it.
|
||||
'--machine-free', 'require']
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
_spawn(argv)
|
||||
launched = argv
|
||||
except Exception:
|
||||
launched = None
|
||||
blocker = 'the deferred process could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - the detached copy, after Revit exits')
|
||||
if launched:
|
||||
output.print_md('Requested as `{0}`. It waits for this Revit '
|
||||
'(pid {1}) to exit -- and then for the machine to be '
|
||||
'free of Revit altogether -- then opens the model in a '
|
||||
'session of its own and writes:'
|
||||
.format(run_id, _revit_pid()))
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('The command it will run:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(
|
||||
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
|
||||
output.print_md('Its outcome is journalled to `{0}` and read out the '
|
||||
'next time this button is pressed.'.format(log))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'after-close', 'wait_pid': _revit_pid()})
|
||||
else:
|
||||
output.print_md('**Not requested: {0}**'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'after-close', 'reason': blocker})
|
||||
elif closing:
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - not run')
|
||||
output.print_md('No detached copy: {0}'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
|
||||
'reason': blocker})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase B
|
||||
|
||||
output.print_md('---')
|
||||
if not closing:
|
||||
if cycling:
|
||||
output.print_md('Revit stays open. The index is written and stamped, '
|
||||
'and the cycle is running in processes of its own -- '
|
||||
'nothing here was closed and your model was never '
|
||||
'touched.')
|
||||
else:
|
||||
output.print_md('Revit stays open, as asked. The index is written and '
|
||||
'stamped; nothing was closed.')
|
||||
script.exit()
|
||||
|
||||
output.print_md('## Phase B - closing Revit')
|
||||
output.print_md('Revit will ask you about unsaved changes if it has any. '
|
||||
'That question is yours to answer -- this button never syncs '
|
||||
'and never saves the model.')
|
||||
|
||||
posted = False
|
||||
# There is no PostableCommand for quitting, so the internal command id is
|
||||
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
|
||||
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
|
||||
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
|
||||
# helper is a version-dependent convenience and this is the one step of the
|
||||
# button that cannot be retried by pressing it again.
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
HOST_APP.post_command('ID_APP_EXIT')
|
||||
posted = True
|
||||
except Exception:
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
from Autodesk.Revit.UI import RevitCommandId
|
||||
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
|
||||
if cmd is not None:
|
||||
HOST_APP.uiapp.PostCommand(cmd)
|
||||
posted = True
|
||||
except Exception:
|
||||
posted = False
|
||||
|
||||
if not posted:
|
||||
output.print_md('**Revit would not accept the close command.** The '
|
||||
'snapshot is written and stamped; close Revit yourself.')
|
||||
@@ -0,0 +1,24 @@
|
||||
title: "Ticket\nQC"
|
||||
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
|
||||
# on each click, so an edit to a module this button uses is visible without
|
||||
# a Revit restart. It trades press latency (engine construction instead of
|
||||
# engine reuse) for restart-freedom, and it is the hottest button here --
|
||||
# 51 commits to its six modules in the seven days to 2026-08-30.
|
||||
#
|
||||
# Read docs/architecture-migration.md section 8 before trusting or removing
|
||||
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
|
||||
# a clean engine, and this extension gets one today only because
|
||||
# extension.json has no `url:` and so is never registered as a package --
|
||||
# add a url and every button silently switches to the shared engine. This key
|
||||
# is the pin against that, not a new capability.
|
||||
#
|
||||
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
|
||||
engine:
|
||||
clean: true
|
||||
# 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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,742 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Check shop tickets against their design spec.
|
||||
#
|
||||
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
|
||||
# then compares every piece of the chosen design numbers against them.
|
||||
#
|
||||
# Every CHECK here is pure arithmetic - the same model always gives the same
|
||||
# answer, and every row of the checklist table is computed. One thing in the
|
||||
# report is not: with the run-time option `vision_escalation` on, the checks
|
||||
# code cannot settle (5.5) are escalated to a small model reading one rendered
|
||||
# view. That result is quarantined - its own list, its own codes, its own
|
||||
# section, and it can never set a checklist row. See vibe.ticketvision and
|
||||
# docs/ticket-checklist.md section 8bis.
|
||||
#
|
||||
# Read-only. Nothing here opens a transaction, including the image export.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Ticket\nQC'
|
||||
|
||||
import os
|
||||
|
||||
from Autodesk.Revit.DB import AssemblyInstance, ElementId
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import ticketqc as Q
|
||||
from vibe import configdump
|
||||
from vibe import designspec, knowledge
|
||||
from vibe import scope as scopelib
|
||||
from vibe import ticketchecks as TC
|
||||
from vibe import ticketvision as TV
|
||||
from vibe import ticketpage as TP
|
||||
from vibe import foldreport as fold
|
||||
from vibe import compat
|
||||
|
||||
doc = revit.doc
|
||||
uidoc = revit.uidoc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
# ------------------------------------------------------------------ context
|
||||
# The published checklist has to exist before anything else here is worth
|
||||
# doing: every row this button ever prints comes off it. On a knowledge-less
|
||||
# trial install (tools/make_dist.py ships no knowledge/catalogue.yaml) this
|
||||
# is the ordinary, expected state -- refuse loudly and say so, rather than
|
||||
# rendering a report with zero rows that would look exactly like a piece with
|
||||
# nothing wrong on it. See vibe.ticketpage's CHECKLIST_UNAVAILABLE_REASON.
|
||||
if TP.CHECKLIST_UNAVAILABLE_REASON:
|
||||
forms.alert(TP.CHECKLIST_UNAVAILABLE_REASON, title='Ticket QC',
|
||||
exitscript=True)
|
||||
|
||||
# The knowledge file has to come first: it holds the mapping from role to this
|
||||
# office's parameter names, and without it nothing can even be grouped.
|
||||
know = knowledge.load(doc)
|
||||
|
||||
# A project config that has gone missing means this run would use a
|
||||
# configuration nobody chose, and look entirely normal doing it.
|
||||
for problem in knowledge.config_problems(doc, know):
|
||||
forms.alert(problem, title='Ticket QC', exitscript=True)
|
||||
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
if missing:
|
||||
forms.alert(
|
||||
'These parameter roles are not mapped in knowledge.yaml, so the '
|
||||
'checks that need them cannot run:\n\n {0}\n\n'
|
||||
'Edit the "parameters:" section of\n{1}\n\n'
|
||||
'Run Self Check for the full picture.'
|
||||
.format('\n '.join(missing), designspec.knowledge_path(doc)),
|
||||
title='Ticket QC', exitscript=('panel.design_number' in missing))
|
||||
|
||||
# ------------------------------------------------------------- the selection
|
||||
# The selection decides, exactly as Panel Lift Check does: assemblies picked
|
||||
# straight through, a picked member resolves to its owning assembly, and
|
||||
# nothing picked means a checklist of every assembly. The one deliberate
|
||||
# difference from Panel Lift Check: there is no "batch or single" question
|
||||
# here -- Ticket QC is always a batch of one or more pieces.
|
||||
|
||||
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
|
||||
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
|
||||
|
||||
if not 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)
|
||||
assemblies = owners
|
||||
|
||||
specs = designspec.list_specs(doc) if doc.PathName else {}
|
||||
summaries, _sheets = Q.collect_context(doc)
|
||||
grouped = Q.assemblies_by_design(doc, know=know)
|
||||
|
||||
# ------------------------------------------------------------- the scope
|
||||
#
|
||||
# The DECLARED SCOPE, off the job knowledge. Two different things are done
|
||||
# with it, and the difference is the whole point:
|
||||
#
|
||||
# * the CHECKLIST this button offers when nothing is preselected is the
|
||||
# tool choosing what to look at, so it offers what the scope declares.
|
||||
# What it left off the list is stated on the report, by kind.
|
||||
# * a piece the USER preselected is judged whatever its prefix. The
|
||||
# selection is the request, and dropping it would be the silent
|
||||
# exclusion this whole tool exists to refuse. It is judged and the
|
||||
# report says the verdict rests on knowledge nobody validated for that
|
||||
# kind of piece.
|
||||
the_scope = scopelib.of(know)
|
||||
scope_line = None # what was left off the checklist
|
||||
scope_annotation = None # what was judged outside the scope anyway
|
||||
|
||||
if not assemblies:
|
||||
# Nothing preselected: a checklist of every assembly, usable at 238
|
||||
# pieces -- mark, design number, and whether a spec or only a regex
|
||||
# summary backs it (the information the old design dialog showed,
|
||||
# moved onto the pieces).
|
||||
items = []
|
||||
for design, asm_list in sorted(grouped.items()):
|
||||
for asm in sorted(asm_list, key=lambda a: a.AssemblyTypeName):
|
||||
if design in specs:
|
||||
state = 'spec'
|
||||
elif design in summaries:
|
||||
state = 'regex fallback'
|
||||
else:
|
||||
state = 'NO spec/summary'
|
||||
items.append((asm, '{0} - {1} - {2}'
|
||||
.format(asm.AssemblyTypeName, design, state)))
|
||||
items.sort(key=lambda r: r[1])
|
||||
offered, left_alone = [], []
|
||||
for row in items:
|
||||
(offered if the_scope.includes(row[0].AssemblyTypeName)
|
||||
else left_alone).append(row)
|
||||
scope_line = the_scope.line(
|
||||
[r[0].AssemblyTypeName for r in left_alone], total=len(items),
|
||||
unit='piece')
|
||||
items = offered
|
||||
if not items:
|
||||
forms.alert('There is nothing to check.\n\n{0}'.format(scope_line),
|
||||
title='Ticket QC', exitscript=True)
|
||||
names = [r[1] for r in items]
|
||||
picked = forms.SelectFromList.show(
|
||||
names, multiselect=True, title='Ticket QC - choose pieces',
|
||||
button_name='Check tickets')
|
||||
if not picked:
|
||||
script.exit()
|
||||
picked_set = set(picked)
|
||||
assemblies = [r[0] for r in items if r[1] in picked_set]
|
||||
if not assemblies:
|
||||
script.exit()
|
||||
|
||||
if not grouped and not assemblies:
|
||||
forms.alert(
|
||||
'No assembly carries a value for the "panel.design_number" role '
|
||||
'(mapped to: {0}).\n\nEither the mapping is wrong or this model does '
|
||||
'not group pieces that way.'
|
||||
.format(Q.role_label(know, 'panel.design_number')), exitscript=True)
|
||||
|
||||
# --------------------------------------------------------------------- run
|
||||
# Per-piece design intent (Phase 3): the picked pieces may span designs, so
|
||||
# each assembly's design number is looked up on its own -- not under one
|
||||
# design's summary. Progress is visible and cancellable: context is
|
||||
# collected once (not per piece), each piece runs in turn, and the bar
|
||||
# advances per piece so the user sees movement rather than a frozen window.
|
||||
# The first piece of an untouched design pays Revit's one-time geometry
|
||||
# generation, which can take a while -- the title says so.
|
||||
context = Q.collect_context(doc)
|
||||
results, missing = [], []
|
||||
total = len(assemblies)
|
||||
bar_title = 'Checking tickets ({0} pieces) - first of a design can be slow'
|
||||
with forms.ProgressBar(title=bar_title.format(total),
|
||||
cancellable=True) as pb:
|
||||
for i, asm in enumerate(assemblies):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, total)
|
||||
batch, miss = Q.run_for_assemblies(
|
||||
doc, [asm], specs=specs, know=know, context=context)
|
||||
results.extend(batch)
|
||||
missing.extend(miss)
|
||||
pb.update_progress(total, total)
|
||||
|
||||
# ------------------------------------------- the escalation, when asked for
|
||||
# Checks no comparison can settle (5.5) escalate to a small model reading one
|
||||
# rendered view. Its answer is NEVER a pass and NEVER a fail: the checklist
|
||||
# row stays as it was and a WARN says a person must look
|
||||
# (docs/ticket-checklist.md section 8bis). Kept in `model_read`, never in
|
||||
# `findings`, so nothing computed and nothing read can be mistaken for each
|
||||
# other. OFF unless the run option says otherwise -- a model call spends
|
||||
# money and leaves the building.
|
||||
_vision_on, _ = TV.run_settings(designspec.root_dir(doc))
|
||||
for r in results:
|
||||
r['model_read'] = []
|
||||
if _vision_on and results:
|
||||
with forms.ProgressBar(title='Escalating to a model reading the drawing',
|
||||
cancellable=True) as pb:
|
||||
for i, r in enumerate(results):
|
||||
if pb.cancelled:
|
||||
break
|
||||
pb.update_progress(i, len(results))
|
||||
try:
|
||||
r['model_read'] = TV.read_for_ticket(
|
||||
r['ticket'], know, designspec.root_dir(doc))
|
||||
except Exception as _ex:
|
||||
r['model_read'] = [TV.not_run_row(
|
||||
'AI-BARMARK', 'the drawing',
|
||||
'the escalation itself failed ({0})'
|
||||
.format(type(_ex).__name__))]
|
||||
|
||||
# ------------------------------------------------------------------ report
|
||||
#
|
||||
# THE CHECKLIST TABLE IS THE PAGE. Everything else is appendix.
|
||||
#
|
||||
# Owner, 2026-08-30, after reading a real run:
|
||||
#
|
||||
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
|
||||
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
|
||||
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
|
||||
#
|
||||
# So: the tables first and big; the design intent, the scope, the knowledge
|
||||
# path, the tally and the caveats after them, folded. The fold rule is not
|
||||
# this button's -- it lives in `vibe.foldreport` and it is the standing
|
||||
# pattern for every report this tool prints: default folded, a section that
|
||||
# carries a problem opens itself, and a folded header still says in one line
|
||||
# what is inside it. The reader's contract is that if nothing below the
|
||||
# tables is open, nothing below the tables needs him.
|
||||
#
|
||||
# WHY THE HTML IS HAND-WRITTEN AND LOOKS DATED
|
||||
# ---------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine
|
||||
# in WinForms, in IE7 document mode unless the host sets
|
||||
# FEATURE_BROWSER_EMULATION, which pyRevit does not. `<details>` /
|
||||
# `<summary>` DO NOT EXIST here 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
|
||||
# and works in every mode this control can be in.
|
||||
#
|
||||
# The checklist table itself uses NO script and NO HTML5 -- a plain <table>
|
||||
# with inline colours. If the toggle is dead, the product is still on the
|
||||
# page, in full, in colour. That is the whole point of putting it outside
|
||||
# the fold machinery.
|
||||
#
|
||||
# WHAT THE COLOURS SAY (vibe.ticketpage.state_color owns the grouping):
|
||||
# OK green
|
||||
# WARN, FOR ENGINEER yellow
|
||||
# NOT RUN, FAIL RED -- both of them, deliberately. An unrun check
|
||||
# tells you nothing about the piece, so it must never
|
||||
# look calmer than a failed one. Silence is not a pass.
|
||||
|
||||
# Muting: what this job has DECLARED it does not want asked, with a reason
|
||||
# (`checks.muted` in the job knowledge, edited on the Project Config screen).
|
||||
# It filters ROWS ONLY. `r['findings']` is untouched from here to the JSON
|
||||
# writer and to the clean/failing/warning tally, so a muted check that
|
||||
# failed still fails the ticket and still lands on disk.
|
||||
# THE MUTES, RESOLVED TO MASTER IDS AS THEY ARE READ.
|
||||
#
|
||||
# Owner, 2026-08-31: "以后引用需要前面加 plant-member。" A mute may be written
|
||||
# as a bare master id (`3.3` -- every existing one is) or qualified against a
|
||||
# plant's own board (`sample_plant-wall_panel 3.3`). Both end up keyed by the
|
||||
# MASTER id, which is the whole point: when the plant renumbers its board
|
||||
# tomorrow, the mute follows the QUESTION and not the POSITION. A ruling can
|
||||
# no longer be orphaned by a renumbering.
|
||||
_plant_id = knowledge.plant_id(doc)
|
||||
muted = TP.muted_checks(know, resolver=knowledge.resolve_check_reference,
|
||||
plant=_plant_id)
|
||||
_mute_problems = TP.muted_reference_problems(know, plant=_plant_id)
|
||||
|
||||
# Applicability: what a KNOWLEDGE LAYER declares is not a question about this
|
||||
# kind of piece, so the check was never run (`checks.not_applicable`). It is
|
||||
# not muting and it is not scope -- see the block over
|
||||
# `ticketpage.apply_applicability` for where each of the five states sits.
|
||||
#
|
||||
# WHICH LAYER DECLARED IT is computed rather than typed. `declared_by` in the
|
||||
# entry says who RULED; this says which FILE the tool read it from, which is
|
||||
# the file somebody has to edit to change it. It is resolved once per press,
|
||||
# not per row -- it re-reads the layer files.
|
||||
try:
|
||||
_layers = configdump.layer_values(doc)
|
||||
except Exception:
|
||||
_layers = []
|
||||
|
||||
|
||||
def _declaring_layer(item, condition):
|
||||
if not _layers or not condition:
|
||||
return None
|
||||
# KEYS, NOT A DOTTED PATH: a checklist row number contains a dot.
|
||||
for branch in ('not_applicable', 'only_applies_to'):
|
||||
found = knowledge.declaring_layer(
|
||||
_layers, ['checks', branch, item, condition])
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
for r in results:
|
||||
_rows = TC.checklist_states(r['findings'], r.get('run_log'))
|
||||
# A model read never sets a status. It only marks the row it looked at,
|
||||
# so a reader following that row knows to read the quarantined section
|
||||
# below rather than mistaking silence for a verdict.
|
||||
_rows = TV.annotate_rows(_rows, r.get('model_read'))
|
||||
# THE SAME RESOLUTION `run_all` USED. Two independent resolutions of
|
||||
# "does this row apply" is how a page comes to describe a run that did
|
||||
# not happen, so both sides call one function.
|
||||
_decisions, _classification, _member, _profile = \
|
||||
TC.applicability_decisions_for(r['ticket'], know)
|
||||
for _num in _decisions:
|
||||
_d = _decisions[_num]
|
||||
if _d.get('not_applicable'):
|
||||
_d['layer'] = _declaring_layer(_num, _d.get('condition'))
|
||||
_rows, _na_notes = TP.apply_applicability(_rows, _decisions)
|
||||
r['applicability_notes'] = _na_notes
|
||||
r['applicability_problems'] = TP.applicability_problems(_decisions)
|
||||
r['classification'] = _classification
|
||||
r['member'] = _member
|
||||
r['profile'] = _profile
|
||||
r['off_board'] = TP.off_board(_decisions)
|
||||
_kept, _muted_notes = TP.apply_muting(_rows, muted)
|
||||
r['rows'] = _kept
|
||||
# The same rows BEFORE muting. Nothing on the page reads this; it is what
|
||||
# lets `qc-run.json` record the state a muted row really reported as a
|
||||
# STATE rather than as a sentence buried in an English note. A grey row
|
||||
# whose original answer a machine cannot read back has thrown the answer
|
||||
# away, which is the thing muting is specifically not allowed to do.
|
||||
r['rows_unmuted'] = _rows
|
||||
r['muted_notes'] = _muted_notes
|
||||
# LAST, AND IT ONLY REORDERS AND LABELS. The stable master id stays at
|
||||
# `row[0]` -- `checklist_details`, the note folds, the JSON and every
|
||||
# rule key on it -- and the plant's own number rides at `row[5]`.
|
||||
r['rows'] = TP.profile_view(r['rows'], _profile)
|
||||
r['rows_unmuted'] = TP.profile_view(r['rows_unmuted'], _profile)
|
||||
r['details'] = TP.checklist_details(r['findings'])
|
||||
r['counts'] = TP.checklist_tally(r['rows'])
|
||||
|
||||
|
||||
def _loudness(r):
|
||||
"""(fails, not-runs, warns, for-engineer) -- how much this ticket wants."""
|
||||
c = r['counts']
|
||||
return (c.get('FAIL', 0), c.get('NOT RUN', 0), c.get('WARN', 0),
|
||||
c.get('FOR ENGINEER', 0))
|
||||
|
||||
|
||||
loud, all_green = [], []
|
||||
for r in results:
|
||||
(loud if any(_loudness(r)) else all_green).append(r)
|
||||
loud.sort(key=lambda r: (-_loudness(r)[0], -_loudness(r)[1],
|
||||
-_loudness(r)[2], r['ticket']['mark']))
|
||||
|
||||
clean, failed, warned = [], [], []
|
||||
tally = {}
|
||||
for r in results:
|
||||
bad = [f for f in r['findings'] if f.severity != 'OK']
|
||||
for f in bad:
|
||||
tally[f.code] = tally.get(f.code, 0) + 1
|
||||
if not bad:
|
||||
clean.append(r)
|
||||
elif any(f.severity == 'FAIL' for f in bad):
|
||||
failed.append(r)
|
||||
else:
|
||||
warned.append(r)
|
||||
|
||||
output.add_style(TP.CHECKLIST_STYLE)
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
|
||||
# ---- the headline, then the tables. Nothing folded above this line.
|
||||
output.print_md('# Shop ticket QC')
|
||||
output.print_md('Model **{0}** · {1} ticket(s) across {2} design '
|
||||
'number(s) · {3} clean · {4} failing '
|
||||
'· {5} warning only'
|
||||
.format(doc.Title, len(results),
|
||||
len(set([r['design'] for r in results])),
|
||||
len(clean), len(failed), len(warned)))
|
||||
output.print_html(TP.checklist_key_html())
|
||||
|
||||
for r in loud:
|
||||
t = r['ticket']
|
||||
head = '**{0}** [{1}] {2} x {3} — {4}'.format(
|
||||
t['mark'], r['design'],
|
||||
Q.fmt_feet(t.get('length')), Q.fmt_feet(t.get('width')),
|
||||
TP.checklist_headline(r['counts']))
|
||||
if t.get('sheet') is not None:
|
||||
head += ' ' + output.linkify(t['sheet'].Id, 'open ticket')
|
||||
if t.get('panel_id'):
|
||||
head += ' ' + output.linkify(ElementId(t['panel_id']), 'panel')
|
||||
output.print_md(head)
|
||||
output.print_html(TP.checklist_table_html(r['rows'], r['details']))
|
||||
|
||||
# A ticket whose every row is green needs no table -- there is nothing on it
|
||||
# to read. It gets a line, and the line says the table was all green rather
|
||||
# than that the ticket was "clean", which is a different and weaker claim.
|
||||
if all_green:
|
||||
# "green" is a claim, so it is only made when it is true. A piece whose
|
||||
# quiet rows include one a knowledge layer switched off has not had every
|
||||
# question answered, and saying so here is cheaper than a reader finding
|
||||
# out from the section below.
|
||||
_quiet = sum([r['counts'].get(TP.NOT_APPLICABLE_STATE, 0)
|
||||
for r in all_green])
|
||||
output.print_md('**{0} ticket(s) with nothing to read on the checklist:** '
|
||||
'{1}{2}'
|
||||
.format(len(all_green),
|
||||
', '.join([r['ticket']['mark']
|
||||
for r in all_green]),
|
||||
(' — and {0} row(s) across them were NOT '
|
||||
'ASKED because a knowledge layer declares they '
|
||||
'do not apply; see below'.format(_quiet))
|
||||
if _quiet else ' — every row green'))
|
||||
|
||||
# ------------------------------------------------- everything else, folded
|
||||
#
|
||||
# Built as `foldreport.Section`s so the same content renders as HTML here and
|
||||
# as plain text into the file below -- one source, so the page and the file
|
||||
# cannot say different things.
|
||||
sections = []
|
||||
|
||||
|
||||
def _section(key, title, summary, blocks, problem=None):
|
||||
if not blocks:
|
||||
return
|
||||
sections.append(fold.Section(key, title, summary=summary, blocks=blocks,
|
||||
problem=problem))
|
||||
|
||||
|
||||
# -- pieces that could not be checked. Always a problem when it is not empty:
|
||||
# 'could not be checked' is a result, not an absence.
|
||||
if missing:
|
||||
_section('missing', 'Pieces that could not be checked',
|
||||
'{0} piece(s)'.format(len(missing)),
|
||||
[(fold.MD, '- **{0}** (id {1}): {2}'
|
||||
.format(m.get('mark', '?'), m.get('id', '?'),
|
||||
m.get('why', 'unknown')))
|
||||
for m in missing],
|
||||
problem='{0} piece(s) were not judged at all'.format(len(missing)))
|
||||
|
||||
# -- weaker readings. A caveat that actually bites, so it opens itself.
|
||||
regex_designs = sorted(set([r['design'] for r in results
|
||||
if r['spec_source'] == 'regex']))
|
||||
if regex_designs:
|
||||
_section('regex', 'Designs read with regexes, not an extracted spec',
|
||||
', '.join(regex_designs),
|
||||
[(fold.MD, '**{0}** had no extracted spec, so the summary was '
|
||||
'read with regexes instead. That reading is weaker '
|
||||
'-- run **Extract Design** on them.'
|
||||
.format(', '.join(regex_designs)))],
|
||||
problem='{0} design(s) are being judged against a weaker '
|
||||
'reading'.format(len(regex_designs)))
|
||||
|
||||
# -- WHICH PROFILE RAN, and what it switched off.
|
||||
#
|
||||
# Required once per run, per piece, and as loud as the scope line: a
|
||||
# mistaken applicability rule is a safety check disabled in silence, and the
|
||||
# only thing standing between that and a signed report is this section. So it
|
||||
# names the classification AND the rows, and it opens itself whenever
|
||||
# anything was switched off or any declaration was refused -- unlike muting,
|
||||
# which folds when it is tidy, because a mute leaves the check running.
|
||||
_na_notes, _na_problems, _profile_lines = [], [], []
|
||||
_off_board = []
|
||||
_seen_na = set()
|
||||
for r in results:
|
||||
# WHICH BOARD, THEN WHICH KIND OF PIECE. The profile line says what the
|
||||
# numbers in the table even mean; the classification line says how the
|
||||
# piece was placed within it. A renumbered table with neither has shown
|
||||
# the reader a checklist without telling him whose.
|
||||
_profile_lines.append(TP.profile_line(
|
||||
r['ticket'].get('mark') or '(no mark)', _plant_id,
|
||||
r.get('member'), r.get('profile'), r.get('off_board')))
|
||||
for _num in (r.get('off_board') or []):
|
||||
if _num not in _off_board:
|
||||
_off_board.append(_num)
|
||||
_profile_lines.append(TP.classification_line(
|
||||
r['ticket'].get('mark') or '(no mark)', r.get('classification'),
|
||||
r.get('applicability_notes')))
|
||||
for note in (r.get('applicability_notes') or []):
|
||||
if note[0] not in _seen_na:
|
||||
_seen_na.add(note[0])
|
||||
_na_notes.append(note)
|
||||
for problem in (r.get('applicability_problems') or []):
|
||||
if problem not in _na_problems:
|
||||
_na_problems.append(problem)
|
||||
_pp = (r.get('profile') or {}).get('problem')
|
||||
if _pp and ('profile', _pp) not in _na_problems:
|
||||
_na_problems.append(('profile', _pp))
|
||||
for _written, _why in _mute_problems:
|
||||
_entry = ('mute {0!r}'.format(_written), _why)
|
||||
if _entry not in _na_problems:
|
||||
_na_problems.append(_entry)
|
||||
|
||||
_na_blocks = [(fold.MD, line) for line in _profile_lines]
|
||||
if _na_notes:
|
||||
_na_blocks.append((fold.MD, '**Rows not asked, and who said so:**'))
|
||||
_na_blocks.extend([(fold.MD, line)
|
||||
for line in TP.applicability_lines(_na_notes)])
|
||||
if _na_problems:
|
||||
_na_blocks.append((fold.MD, '**Declarations that were REFUSED, so their '
|
||||
'rows RAN:**'))
|
||||
_na_blocks.extend([(fold.MD, '**{0}** — {1}'.format(num, text))
|
||||
for (num, text) in _na_problems])
|
||||
_na_blocks.append(
|
||||
(fold.MD, '_A row shown as NOT APPLICABLE was **not run at all**. That '
|
||||
'is not muting (a muted check runs and is repainted grey), not '
|
||||
'scope (which sets aside a whole piece), and not dormancy (a '
|
||||
'check whose enabling knowledge is unset). It is a knowledge '
|
||||
'layer declaring that this question is not about this kind of '
|
||||
'piece. A row that FAILED is never switched off -- the '
|
||||
'declaration is declined and says so. A row scoped to a panel '
|
||||
'type, on a piece nothing could classify, is NOT RUN in red, '
|
||||
'never quiet: nobody could ask it._'))
|
||||
_section('applicability', 'Which checklist ran on each piece, and what it '
|
||||
'did not ask',
|
||||
'{0} row(s) not asked; {1} declaration(s) refused'
|
||||
.format(len(_na_notes), len(_na_problems)),
|
||||
_na_blocks,
|
||||
problem=TP.applicability_problem(_na_notes, _na_problems))
|
||||
|
||||
# -- muted on this job. Folded when the muting is tidy; open when it is not.
|
||||
_all_muted = []
|
||||
_seen_muted = set()
|
||||
for r in results:
|
||||
for note in r['muted_notes']:
|
||||
if note[0] not in _seen_muted:
|
||||
_seen_muted.add(note[0])
|
||||
_all_muted.append(note)
|
||||
if _all_muted:
|
||||
_section('muted', 'Checklist items muted on this job',
|
||||
'{0} item(s): {1}'.format(
|
||||
len(_all_muted), ', '.join([n[0] for n in _all_muted])),
|
||||
[(fold.MD, line) for line in TP.muting_lines(_all_muted)] +
|
||||
[(fold.MD, '_Muting does not remove a row -- it stays on the '
|
||||
'table, repainted grey as MUTED, with this note '
|
||||
'saying what the check really reported. The check '
|
||||
'still runs every time; only the presentation '
|
||||
'changes. It never removes a finding: everything '
|
||||
'below still reaches the machine-readable JSON '
|
||||
'(`qc-run.json`, written every press) and still '
|
||||
'counts in the header line, and a muted item that '
|
||||
'FAILED keeps its own red on the table -- muting '
|
||||
'never softens a failure._')],
|
||||
problem=TP.muting_problem(_all_muted))
|
||||
|
||||
# -- the issue tally. Routine; folded.
|
||||
if tally:
|
||||
_section('tally', 'Issues by code',
|
||||
'{0} code(s) across {1} ticket(s)'
|
||||
.format(len(tally), sum(tally.values())),
|
||||
[(fold.TABLE, ['issue', 'tickets'],
|
||||
[[c, str(n)] for c, n in
|
||||
sorted(tally.items(), key=lambda kv: -kv[1])])])
|
||||
|
||||
# -- what each design is being held to. Configuration: folded, by the rule.
|
||||
seen, intent_rows = [], []
|
||||
for r in results:
|
||||
intent = r['intent']
|
||||
if intent['name'] in seen:
|
||||
continue
|
||||
seen.append(intent['name'])
|
||||
env = intent.get('envelope') or {}
|
||||
|
||||
def bound(key, _env=env):
|
||||
return _env[key][1] if key in _env else '-'
|
||||
|
||||
intent_rows.append([
|
||||
intent['name'], r['spec_source'],
|
||||
Q._fmt_num(intent.get('fc')), Q._fmt_num(intent.get('fci')),
|
||||
Q._fmt_num(intent.get('pcf')),
|
||||
Q.strand_signature(intent.get('strands') or []),
|
||||
intent.get('wwf_mesh') or '-',
|
||||
'{0} .. {1}'.format(bound('MIN_LENGTH'), bound('MAX_LENGTH')),
|
||||
'{0} .. {1}'.format(bound('MIN_WIDTH'), bound('MAX_WIDTH')),
|
||||
])
|
||||
_section('intent', 'Design intent being enforced',
|
||||
'{0} design(s), from {1}'.format(
|
||||
len(intent_rows),
|
||||
', '.join(sorted(set([r[1] for r in intent_rows]))) or '-'),
|
||||
[(fold.TABLE,
|
||||
['design', 'from', "f'c", "f'ci", 'pcf', 'strands', 'W.W.F.',
|
||||
'length', 'width'], intent_rows)])
|
||||
|
||||
# -- scope and knowledge. The context every report carries; folded unless
|
||||
# the scope actually removed or annotated something.
|
||||
if scope_annotation is None:
|
||||
scope_annotation = the_scope.annotation(
|
||||
[str(a.AssemblyTypeName) for a in assemblies
|
||||
if not the_scope.includes(a.AssemblyTypeName)], unit='piece')
|
||||
_scope_blocks = [(fold.MD, '> ' + (scope_line or
|
||||
the_scope.line([], unit='piece')))]
|
||||
if scope_annotation:
|
||||
_scope_blocks.append((fold.MD, '> ' + scope_annotation))
|
||||
for _note in the_scope.declaration_lines():
|
||||
_scope_blocks.append((fold.MD, '_{0}_'.format(_note)))
|
||||
if doc.PathName:
|
||||
_scope_blocks.append((fold.MD, 'Knowledge file: `{0}`'
|
||||
.format(designspec.knowledge_path(doc))))
|
||||
_section('scope', 'Scope and knowledge',
|
||||
'what this run was and was not asked to judge',
|
||||
_scope_blocks,
|
||||
problem=(scope_annotation or None))
|
||||
|
||||
# -- lifting/handling provenance. Only when HANDLE-* actually fired.
|
||||
if any(f.code.startswith('HANDLE-') for r in results for f in r['findings']):
|
||||
_section('handling', 'How the lifting/handling result was obtained',
|
||||
'`HANDLE-*` rows on this run',
|
||||
[(fold.MD,
|
||||
'A current **Panel Lift Check** or sweep result is reused when '
|
||||
'one is on file; otherwise a default-stage check runs on the '
|
||||
'spot, rate limited so this does not hang on a large design. '
|
||||
'`HANDLE-STALE` means a saved result no longer matches the '
|
||||
'piece and was not used. A result not tagged "reviewed Panel '
|
||||
'Lift Check" is good enough to find a problem, not good enough '
|
||||
'to sign -- see docs/lift-check.md.')])
|
||||
|
||||
# ------------------------------- read by a model, quarantined from the above
|
||||
# Its own section, its own columns, and a column that names what read it. A
|
||||
# computed verdict and a model-read one fail differently -- a wrong computed
|
||||
# answer is a bug, a wrong read is a hallucination -- so a reader has to be
|
||||
# able to tell them apart at a glance. They are never in the same table, and
|
||||
# this section opens itself: a model read is never a verdict, so somebody has
|
||||
# to look at it.
|
||||
_read = [(r['ticket']['mark'], f)
|
||||
for r in results for f in (r.get('model_read') or [])]
|
||||
if _read:
|
||||
_head, _read_rows = TV.report_block(_read)
|
||||
_section('modelread', 'Read by a model (never a verdict)',
|
||||
'{0} row(s) a person must judge'.format(len(_read_rows)),
|
||||
[(fold.MD, _head),
|
||||
(fold.TABLE, list(TV.REPORT_COLUMNS), _read_rows)],
|
||||
problem='a model was asked to look at {0} thing(s); nothing '
|
||||
'here is a pass or a fail'.format(len(_read_rows)))
|
||||
elif _vision_on:
|
||||
output.print_md('> The vision escalation was on and produced nothing, '
|
||||
'which should not happen - every escalation reports a '
|
||||
'reason even when it does not run.')
|
||||
|
||||
# ---- THE MACHINE-READABLE COPY. Written on EVERY press, escalation or not.
|
||||
# The same separation as the page, on disk, for anything that reads the run
|
||||
# programmatically: two keys, never one list, and `computed` on every entry
|
||||
# so even a consumer that merges them cannot lose the difference. When no
|
||||
# model was consulted `model_read` is an empty list -- which is itself the
|
||||
# answer to "was one?", and is why this no longer waits for a vision row:
|
||||
# the muted section promises every run's findings reach this file, and
|
||||
# vision defaults off.
|
||||
#
|
||||
# AND THE DISPOSITION, added 2026-08-31. Until then this file held findings
|
||||
# and NOTHING ELSE -- no checklist rows, no mutes, no `run_all` log -- so a
|
||||
# consumer reading it could not tell a row that PASSED from a row NOBODY
|
||||
# ASKED. Both were an absence of findings. That is this project's cardinal
|
||||
# failure reproduced inside its own output, and `tickets` below is the fix:
|
||||
# every checklist row of every piece, with its state and, where it produced
|
||||
# no verdict, its reason verbatim. See the block above `qc_json`.
|
||||
#
|
||||
# NOTHING HERE CHANGES WHAT A HUMAN SEES. The page is already printed by this
|
||||
# point and `last-ticketqc.txt` below is written from the same `r['rows']`
|
||||
# this reads; `qc_json` builds a new document and never touches a row, a
|
||||
# finding or a note.
|
||||
#
|
||||
# NOTE ON MUTING: this is built from `r['findings']`, which muting never
|
||||
# touches. A muted checklist row is a rendering decision; the finding
|
||||
# behind it still reaches this file -- and now the ROW does too, in grey,
|
||||
# saying what the check really reported.
|
||||
try:
|
||||
_doc = TV.qc_json([f for r in results for f in r['findings']],
|
||||
[f for r in results
|
||||
for f in (r.get('model_read') or [])],
|
||||
tickets=results,
|
||||
envelope=Q.run_envelope(TV.RUN_SCHEMA, doc=doc))
|
||||
_path = TV.write_run_json(designspec.root_dir(doc), _doc)
|
||||
output.print_md('_Machine-readable: `{0}`_'.format(_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the JSON copy could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- THE EVIDENCE FILE. Migration step 1, docs/architecture-migration.md.
|
||||
#
|
||||
# Everything this press MEASURED, as a versioned JSON document with no live
|
||||
# Revit object in it, beside the model at `_ticketqc/evidence/run-<stamp>.json`.
|
||||
# One file per press, never one per piece.
|
||||
#
|
||||
# NOTHING READS IT. Not this button, not `ticketchecks`, not a runner -- the
|
||||
# reader is step 2. That is why this can ship on its own: a file nobody reads
|
||||
# cannot change what the press above reported. No judgement moved, `read_ticket`
|
||||
# is untouched, and `Q.ticket_to_json` builds a new dict rather than editing
|
||||
# the ticket the checks were run against.
|
||||
#
|
||||
# It carries no findings, deliberately. `qc-run.json` above owns the verdicts;
|
||||
# this file owns the inputs, and keeping the two apart is what will let step 2
|
||||
# re-judge one against the other.
|
||||
#
|
||||
# Wrapped exactly as the JSON copy above is: a failure here prints a line and
|
||||
# loses nothing. The report is already computed by this point.
|
||||
try:
|
||||
_ev_doc = Q.run_evidence(results, doc=doc)
|
||||
_ev_path = Q.write_evidence(
|
||||
designspec.sub_dir(doc, Q.EVIDENCE_FOLDER), _ev_doc)
|
||||
output.print_md('_Evidence (nothing reads it yet): `{0}`_'
|
||||
.format(_ev_path))
|
||||
except Exception as _ex:
|
||||
output.print_md('_(the evidence file could not be written: {0})_'
|
||||
.format(type(_ex).__name__))
|
||||
|
||||
# ---- FOLDED IS NOT GONE. The whole page, nothing folded, every checklist
|
||||
# table in full text with long notes wrapped rather than cut, written to a
|
||||
# file every press. A fold that will not open then costs a file open, never
|
||||
# a lost sentence.
|
||||
full_path = None
|
||||
try:
|
||||
full_path = os.path.join(designspec.root_dir(doc), 'last-ticketqc.txt')
|
||||
_lines = ['SHOP TICKET QC -- ' + str(doc.Title), '']
|
||||
for r in loud + all_green:
|
||||
_t = r['ticket']
|
||||
_lines.append('')
|
||||
_lines.append('{0} [{1}] {2}'.format(
|
||||
_t['mark'], r['design'],
|
||||
TP.checklist_headline(r['counts']).replace('·', '-')))
|
||||
_lines.append('-' * 78)
|
||||
_lines.append(TP.checklist_table_text(r['rows'], r['details']))
|
||||
_lines.append('')
|
||||
_lines.append(fold.text(sections, show_all=True))
|
||||
payload = compat.as_text('\n'.join(_lines))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
except Exception as _exc:
|
||||
full_path = None
|
||||
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))
|
||||
|
||||
if sections:
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
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(sections)))
|
||||
else:
|
||||
output.print_md('**Nothing below the tables needs you.** Every section '
|
||||
'is folded; click a header to open one.')
|
||||
if full_path:
|
||||
output.print_md('_The whole report, nothing folded, is in_ `{0}`'
|
||||
.format(full_path))
|
||||
output.print_html(fold.html(sections))
|
||||
@@ -0,0 +1,19 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
|
||||
# `title:` here -- `uimaker._produce_ui_panels` calls
|
||||
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
|
||||
#
|
||||
# THE FOUR FINISHED TOOLS, all as large icons, in the owner's own order
|
||||
# (2026-08-30: "reinforce, LIFT, ticketqc, snapshot 四个是大图标, 放在一起").
|
||||
# Nothing half-built may join them -- that is what Setup and Design are for.
|
||||
#
|
||||
# Every title here keeps each line under ~9 characters. A large ribbon button
|
||||
# is as wide as its widest line of text, so one long title makes one button
|
||||
# dwarf its neighbours, which is exactly what the owner objected to when
|
||||
# these four were spread across three panels.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ReinforceSuggest
|
||||
- PanelLiftCheck
|
||||
- TicketQC
|
||||
- SnapshotClose
|
||||
Reference in New Issue
Block a user