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,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))
|
||||
Reference in New Issue
Block a user