Files
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

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} &mdash; 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}** &middot; {1} &times; {2} &times; {3}" '
'&middot; {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}** &nbsp; [{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** &mdash; {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** &mdash; the position search is switched off for '
'this run (`recommend_positions` in Project Config &rarr; 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 &mdash; 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}` &mdash; {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} &mdash; {1}\nReport: {2}\nJob file: {3}'
.format(summary['verdict'], panel['mark'],
out_path, job_path)))