8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1067 lines
46 KiB
Python
1067 lines
46 KiB
Python
# -*- 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 Lift Check'
|
|
|
|
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 runlog
|
|
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()
|
|
|
|
# The piece(s) this run is about, for the run log. Set once the selection is
|
|
# resolved (single mark after `asm = assemblies[0]`, the batch's chosen names
|
|
# inside `_run_batch`), and read by not_run()/die() -- so a NOT RUN or refusal
|
|
# records the piece it happened to, and a press that dies before any selection
|
|
# resolves records nothing. The record is written at the exit, where the
|
|
# outcome is known: every deliberate exit here leaves through sys.exit() or an
|
|
# exitscript alert, and a runlog.run() context would log that SystemExit as
|
|
# outcome='error'. Same shape as Ticket QC's runlog wiring.
|
|
_marks = None
|
|
|
|
|
|
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.
|
|
"""
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks,
|
|
outcome='refused', report_text=message)
|
|
forms.alert(message, title=title, exitscript=True)
|
|
|
|
|
|
def not_run(message, title='Panel Lift Check', outcome='not-run'):
|
|
"""'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.
|
|
|
|
`outcome` is the run-log disposition. It defaults to 'not-run' -- the
|
|
banner says NOT RUN -- and the one site that is a refusal rather than a
|
|
not-run (the weight gate, which refuses to bless a piece whose measured
|
|
geometry does not weigh what the model says) passes 'refused'.
|
|
"""
|
|
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))
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks,
|
|
outcome=outcome,
|
|
report_text=('# Panel Lift Check — NOT RUN\n'
|
|
'**NOT RUN** — the check did not run for '
|
|
'this piece.\n> {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.
|
|
"""
|
|
global _marks # the run-log piece list is read by not_run()/die()
|
|
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:
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=None,
|
|
outcome='refused',
|
|
report_text='Batch picker closed -- no pieces chosen.')
|
|
script.exit()
|
|
picked_set = set(picked)
|
|
items = [r for r in items if r[0] in picked_set]
|
|
if not items:
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=None,
|
|
outcome='refused',
|
|
report_text='Batch picker closed -- no pieces chosen.')
|
|
script.exit()
|
|
|
|
chosen = items
|
|
|
|
# What this batch is ABOUT, for the run log. The preselected branch
|
|
# (above) chose by clicking; the pick-list branch chose from the scope.
|
|
# Either way the record's marks are the pieces the run was asked for.
|
|
_marks = [r[0] for r in chosen]
|
|
|
|
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))
|
|
# The batch itself is the record here. Each panel's own report is written
|
|
# by the background worker, which lives on in vibe.batchrun after this
|
|
# command's scope is torn down -- so a per-piece record would have to come
|
|
# from the worker (which cannot reach this module's globals), and the
|
|
# command-scope record of a batch that was started is the honest one.
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks,
|
|
outcome='ok',
|
|
report_text=('Batch started in the background.\n{0} pieces '
|
|
'were measured on the UI thread and handed to '
|
|
'{1} CPython subprocesses at a time.\nReports '
|
|
'+ job files go to: {2}\nProgress: {3}'
|
|
.format(len(compute), at_once, start_dir,
|
|
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:
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=None,
|
|
outcome='refused',
|
|
report_text='Mode picker closed -- no run chosen.')
|
|
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]
|
|
_marks = [str(asm.Name)]
|
|
|
|
# ------------------------------------------------------------------ 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(knowledge.plant_file_in_use(know)))
|
|
|
|
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']
|
|
# The record's marks follow the piece into the job: `panel['mark']` is the
|
|
# mark the calculation ran on (assembly type name read from the model), which
|
|
# is the identifier the report and the job file carry.
|
|
_marks = [panel['mark']]
|
|
|
|
# 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:
|
|
# The weight gate REFUSES the run: measured geometry does not weigh what
|
|
# the model says, and the check must not bless a piece read that way. The
|
|
# page still says NOT RUN (the check did not run), but the log records the
|
|
# refusal distinctly from a plain not-run -- see not_run().
|
|
not_run(message + '\n\nFix the weight mismatch and run again.',
|
|
title='Panel Lift Check - {0}'.format(panel['mark']),
|
|
outcome='refused')
|
|
|
|
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:
|
|
eid = ticketqc.eid(element)
|
|
return eid in insert_ids or eid in companion_of
|
|
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 _location_point(el):
|
|
"""The element's placement point in model space, or None."""
|
|
try:
|
|
loc = el.Location
|
|
pt = getattr(loc, 'Point', None)
|
|
if pt is None:
|
|
curve = getattr(loc, 'Curve', None)
|
|
pt = curve.Evaluate(0.5, True) if curve is not None else None
|
|
return pt
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _companions_of():
|
|
"""Members of the assembly that sit ON an accepted lifter, keyed by
|
|
element id int -> the lifter's mark.
|
|
|
|
A lifter family can be two elements at one point: the lifter the plant
|
|
knowledge names and the body it is placed with (measured 2026-09-03 on
|
|
a top lifter: the named element and its body at distance 0.0). Only
|
|
the named one is in `panel['lifters']`, so without this the body is
|
|
ghosted with the concrete, the pre-selected top lifter highlights
|
|
nothing you can see, and a click that lands on the body is not a pick.
|
|
Co-located means within 0.05 ft.
|
|
"""
|
|
anchors = []
|
|
for mark in by_mark:
|
|
try:
|
|
el = doc.GetElement(ElementId(int(mark)))
|
|
except Exception:
|
|
el = None
|
|
pt = _location_point(el) if el is not None else None
|
|
if pt is not None:
|
|
anchors.append((mark, pt))
|
|
out = {}
|
|
if not anchors:
|
|
return out
|
|
for member in asm.GetMemberIds():
|
|
mid = id_int(member)
|
|
if mid in insert_ids:
|
|
continue
|
|
el = doc.GetElement(member)
|
|
pt = _location_point(el) if el is not None else None
|
|
if pt is None:
|
|
continue
|
|
for mark, apt in anchors:
|
|
try:
|
|
if pt.DistanceTo(apt) <= 0.05:
|
|
out[mid] = mark
|
|
break
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
companion_of = _companions_of()
|
|
|
|
|
|
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 and \
|
|
id_int(member) not in companion_of:
|
|
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
|
|
# The bodies that sit on the preset lifters come along, so the
|
|
# pre-selection highlights something the eye can find.
|
|
for cid, mark in companion_of.items():
|
|
if mark not in preset:
|
|
continue
|
|
try:
|
|
el = doc.GetElement(ElementId(cid))
|
|
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
|
|
eid = ticketqc.eid(el)
|
|
mark = companion_of.get(eid, str(eid)) # a click on the body is the lifter
|
|
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:
|
|
# Esc out of a pick: the isolate is restored by the finally
|
|
# below, and the run log records a user cancellation before
|
|
# the exit so the press is not silent.
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title,
|
|
marks=_marks, outcome='refused',
|
|
report_text=('Esc out of the insert pick for '
|
|
'{0} -- run cancelled.'.format(
|
|
panel['mark'])))
|
|
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:
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks,
|
|
outcome='refused',
|
|
report_text=('No report path chosen for {0} -- run '
|
|
'cancelled.'.format(panel['mark'])))
|
|
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))
|
|
|
|
# The report file exists and was (unless configured otherwise) opened -- the
|
|
# run produced its output, so the record is 'ok' with the path the report
|
|
# landed at. Left until after every print, as in Ticket QC: an exception while
|
|
# the page is rendering records nothing rather than claiming an ok the user
|
|
# never saw, and record() never raises.
|
|
runlog.record('Panel Lift Check', doc_title=doc.Title, marks=_marks,
|
|
outcome='ok', report_path=out_path,
|
|
report_text=('{0} — {1}\nReport: {2}\nJob file: {3}'
|
|
.format(summary['verdict'], panel['mark'],
|
|
out_path, job_path)))
|