Files
willtools-demo/WillTools.extension/lib/vibe/liftexport.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
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.
2026-09-03 16:33:47 -05:00

2795 lines
129 KiB
Python

# -*- coding: utf-8 -*-
"""One assembly, turned into the job file panellift reads.
Everything Revit-shaped stops here. The calculation runs on CPython with a
test suite that needs no model and no licence, so the boundary between the two
is a JSON file -- and this module is the whole of the Revit side of it.
Three things are decided here rather than in the calculation, because all
three are facts about the model and none of them is mechanics:
which end is up the RULE is public knowledge, not this module's: it
comes from the assembly origin (catalogue.yaml,
`orientation.up_end`, resolved by
`knowledge.up_end_rule`). This module reads that rule and
says in the job file which basis answered -- and while
the origin half is unverified, it falls back to global
elevation and names the fallback rather than passing it
off as the rule. Either way x is flipped so that x = 0 is
the bottom of the wall as erected, and every rule about
"the top row" then means what it says.
where an insert acts the box centre for a face lifter, but the panel end
for an anchor at the top edge -- its modelled box centre
sits inboard by however long the bar welded to it is.
which insert is what from the plant's lifter_groups, matched on family
name. Nothing here knows a part number.
Written for IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=import-error,invalid-name,broad-except
import io
import os
from vibe import knowledge
# vibe.ticketqc imports the Revit API at module scope, so it is imported
# inside the functions that need it. That leaves the pure parts of this module
# -- the grouping rules, the stage defaults, the interpreter search --
# importable and testable on an ordinary CPython with no Revit anywhere.
SCHEMA = 'panellift/1'
# ------------------------------------------------------------------ geometry
def _axes(lo, hi):
"""(length, width, thickness) as indices into a local xyz triple."""
span = [hi[i] - lo[i] for i in (0, 1, 2)]
thickness = min(range(3), key=lambda i: span[i])
rest = [i for i in (0, 1, 2) if i != thickness]
rest.sort(key=lambda i: -span[i])
return rest[0], rest[1], thickness
MIN_VERTICAL_SHARE = 0.7 # cos(45 deg) is 0.71; anything less is ambiguous
FALLBACK_UP_END_BASIS = 'global elevation'
RULED_UP_END_BASIS = 'assembly origin'
# The only attribute names `orientation.up_end.origin_up` may name. The value
# is data and is looked up with getattr precisely so a correction is an edit
# to the catalogue rather than a release -- but data reaching getattr on a
# native object is how Revit gets handed something it cannot survive (see
# CLAUDE.md, 2026-08-18), so the set it may reach is closed here.
ORIGIN_BASIS_NAMES = ('BasisX', 'BasisY', 'BasisZ')
def _local_shares(inverse, world_vector, axes):
"""A world direction expressed in the panel's own (length, width, thickness)."""
L, W, T = axes
local = inverse.OfVector(world_vector)
components = (local.X, local.Y, local.Z)
names = {L: 'length', W: 'width', T: 'thickness'}
best = max((L, W, T), key=lambda a: abs(components[a]))
return components, names, best
def erected_orientation(asm, panel, frame, axes, know=None, spans=None):
"""Which way up the piece stands, and on which of its own axes.
Returns (axis, up_is_positive, shares) where axis is 'length', 'width' or
'thickness', and shares is how much of each panel direction points up
plus, always, `basis` and `basis_why` -- WHERE the answer came from and on
whose authority. That pair is not decoration: this function used to be the
only place in the codebase that knew which end of a piece was up, and it
knew it as an unstated implementation detail no report mentioned.
WHAT THE OWNER RULED, 2026-08-30. Which end is up comes from the ASSEMBLY
ORIGIN -- he calls it TOT -- and the origin carries an up end. That is
PUBLIC knowledge, so it lives in `knowledge/catalogue.yaml` and is
resolved by `knowledge.up_end_rule()` and `up_end_from_origin()`; this
function is a reader of that rule, not its author.
HOW THE ORIGIN SAYS IT, measured on 282 assemblies: `GetTransform()`
returns a `Transform` whose `BasisY` is the erected up direction. The
catalogue names the attribute, so correcting it is an edit to a file.
WHEN THE ORIGIN CANNOT ANSWER -- an origin that does not stand the piece
up, a near-square piece, a panel that is not horizontal -- the origin rule
DECLINES, and this function then reads which end is up the way it always
has: WORLD UP expressed in the panel's own axes, i.e. which end of the
piece is higher in the model.
THE FALLBACK IS NAMED, EVERY TIME. `basis` says which of the two answered
and `basis_why` says why, and both ride out in the job file's `source`
block on every run. Falling back is not the problem; falling back
silently is, and the two mechanisms are not interchangeable. Global
elevation answers this for a wall panel erected on end and is meaningless
for a piece erected long-ways, where both ends are at the same height and
the winner is whatever the model's rounding happens to be. An origin has a
direction either way -- which is the reason the ruling was made.
A wall panel is erected on end, so its LENGTH runs up the building and the
top of the piece is an end of it. A horizontal panel -- a spandrel, a
piece that sits long-ways over an opening -- is the same shape erected the
other way round: its WIDTH runs up, and the top is a long EDGE.
Neither being convincing is a real answer too: a piece that stands none of
its axes up has no erected orientation in it, and guessing one puts the
erection anchors on the wrong edge with nothing in the output to suggest
it.
`spans` is (length_ft, width_ft) -- the piece's two in-plane dimensions,
which the origin rule needs to refuse a near-square piece. Measured off
the panel's own concrete rather than off the assembly's member boxes,
which is what the bridge holder unioned; the two are close but not the
same reading, and `_axes` has already sorted them long/short.
"""
from Autodesk.Revit.DB import XYZ
L, W, T = axes
know = know or {}
origin = None
try:
origin = asm.GetTransform()
except Exception:
origin = None
source, why = knowledge.up_end_rule(know)
basis, up_world = FALLBACK_UP_END_BASIS, XYZ(0, 0, 1)
origin_up_name = None
# ---- the ruled reading, and the three ways it declines -------------
if source and origin is not None:
rule = knowledge.get(know, 'orientation.up_end') or {}
origin_up_name = str(rule.get('origin_up') or '')
if origin_up_name not in ORIGIN_BASIS_NAMES:
why = ('orientation.up_end.origin_up is {0!r}, which is not one '
'of {1}. Nothing is fetched off the origin by a name this '
'code cannot vouch for.'
.format(rule.get('origin_up'),
', '.join(ORIGIN_BASIS_NAMES)))
else:
candidate = getattr(origin, origin_up_name)
_c, _n, best = _local_shares(frame[0], candidate, axes)
long_ft, short_ft = (spans or (None, None))
ok, ruled_why = knowledge.up_end_from_origin(
know, (candidate.X, candidate.Y, candidate.Z), _n[best],
long_ft, short_ft, label=_asm_label(asm))
why = ruled_why
if ok:
basis, up_world = RULED_UP_END_BASIS, candidate
elif source and origin is None:
why = ('This assembly has no origin transform, so the origin rule '
'has nothing to read.')
if basis == FALLBACK_UP_END_BASIS:
why = (why + ' Which end is up was therefore read from GLOBAL '
'ELEVATION instead -- the end of the piece that is '
'higher in the model. That is a fallback, not the rule.')
if origin is None:
return None, None, {'error': 'the assembly has no origin transform',
'basis': basis, 'basis_why': why}
components, names, best_axis = _local_shares(frame[0], up_world, axes)
shares = {'length': round(components[L], 4),
'width': round(components[W], 4),
'thickness': round(components[T], 4),
'origin_z_world': round(origin.BasisZ.Z, 4),
'basis': basis,
'basis_why': why}
if origin_up_name:
shares['origin_up'] = origin_up_name
value = components[best_axis]
if abs(value) < MIN_VERTICAL_SHARE:
return None, None, shares
return names[best_axis], value > 0.0, shares
def _asm_label(asm):
"""The assembly's name, for a sentence that has to name a piece."""
try:
return asm.Name or None
except Exception:
return None
MIN_SOLID_FT3 = 0.01 # below this a "solid" is a modelling artefact
def panel_solids(panel):
"""Every solid the panel body draws, largest first. Revit-side.
One geometry read, flattened out of the instance geometry exactly the way
`concrete_solid` has always done it -- this IS that walk, kept whole and
made to return the list rather than the maximum of it.
"""
from Autodesk.Revit.DB import Options, ViewDetailLevel, Solid, \
GeometryInstance
options = Options()
options.DetailLevel = ViewDetailLevel.Fine
found = []
stack = [panel.get_Geometry(options)]
while stack:
geometry = stack.pop()
if geometry is None:
continue
for item in geometry:
if isinstance(item, Solid):
try:
volume = item.Volume
except Exception:
continue
if volume > MIN_SOLID_FT3:
found.append(item)
elif isinstance(item, GeometryInstance):
try:
stack.append(item.GetInstanceGeometry())
except Exception:
pass
found.sort(key=lambda s: -s.Volume)
return found
def concrete_solid(panel, solids=None):
"""The panel's largest solid -- its SHAPE, and no longer its weight.
This is what the openings are read off (`_openings`) and what the piece's
extents are solved from, and for both of those the largest solid is the
right object: each is a question about one prism.
IT IS NOT THE WEIGHT, and it used to be. A 16 in. insulated body draws
more than one solid -- concrete wythes with an insulation layer between
them -- and the largest of those is one wythe. Weighing that alone put
every insulated piece in the job at roughly 65% of its printed weight,
which failed the handling weight gate, so the whole handling analysis
computed NOTHING on 27 pieces. Measured 2026-08-31 on WP115: printed
18,573 lb, the model's own weight parameter 18,376 lb, largest solid
11,983 lb -- the two independent statements about the piece agreed with
each other to 1% and the solid sum was the outlier.
The weight now comes from `concrete_volume`, which sums the solids
instead of picking one, and says which ones it summed. Both take
`solids` so one walk of the geometry answers both questions.
"""
solids = panel_solids(panel) if solids is None else solids
return solids[0] if solids else None
# ------------------------------------------------- what the solid sum is OF
#
# 50 pcf is not a plant fact and it is not a guess: it is the gap between the
# two things a precast sandwich panel is made of. Structural concrete is
# 145-150 pcf normalweight and 110+ pcf even lightweight; the rigid
# insulation between the wythes is under 5. Nothing a wall panel is cast from
# lands between them, so a threshold anywhere in the middle separates them
# without knowing a single product name. It is reachable as a knowledge key
# so a plant casting something exotic can move it, and it has a literal
# default here because the alternative to a default is a silent zero.
DEFAULT_CONCRETE_MIN_PCF = 50.0
def _material_facts(doc, material_id):
"""One material as {'id', 'name', 'density_pcf'} -- None where unread.
`density_pcf` None means "this material publishes no density", which is
NOT "this material weighs nothing". Every hop is guarded because a
material carrying no structural asset is ordinary, not an error.
"""
out = {'id': None, 'name': None, 'density_pcf': None}
if material_id is None:
return out
# .IntegerValue is deprecated for .Value in Revit 2024+; read it the way
# the rest of this extension does rather than assuming either.
try:
out['id'] = str(material_id.IntegerValue)
except Exception:
try:
out['id'] = str(material_id.Value)
except Exception:
out['id'] = None
if out['id'] in (None, '-1'):
out['id'] = None
return out
try:
material = doc.GetElement(material_id)
except Exception:
material = None
if material is None:
return out
try:
out['name'] = str(material.Name)
except Exception:
pass
# Convert rather than assume what Revit stores a density in, and import
# the unit BY NAME so a wrong one is a traceback instead of a number 16x
# out -- CLAUDE.md, never guess an API enum.
try:
from Autodesk.Revit.DB import UnitUtils, UnitTypeId
asset = doc.GetElement(material.StructuralAssetId).GetStructuralAsset()
out['density_pcf'] = UnitUtils.ConvertFromInternalUnits(
asset.Density, UnitTypeId.PoundsMassPerCubicFoot)
except Exception:
out['density_pcf'] = None
return out
def _solid_material(doc, solid, cache=None):
"""What a solid is made of, read off the material on its faces.
A solid's faces normally all carry one material; where they do not, the
one on the most faces is taken and `mixed` says so -- a body whose faces
disagree is something to report, not something to average.
`cache` memoises the material read across every face of every solid of
one body. A sandwich body is hundreds of faces and each read is three
element lookups; this is the same shape as the other memos here and it
cannot change an answer, because a hit returns what the lookup would.
"""
cache = {} if cache is None else cache
counts, facts = {}, {}
try:
faces = list(solid.Faces)
except Exception:
faces = []
for face in faces:
try:
mid = face.MaterialElementId
except Exception:
continue
try:
key = str(mid.IntegerValue)
except Exception:
try:
key = str(mid.Value)
except Exception:
key = None
if key not in cache:
cache[key] = _material_facts(doc, mid)
seen = cache[key]
key = seen['id']
counts[key] = counts.get(key, 0) + 1
facts[key] = seen
if not counts:
return {'id': None, 'name': None, 'density_pcf': None,
'mixed': False}
best = max(sorted(counts, key=lambda k: k or ''),
key=lambda k: counts[k])
out = dict(facts[best])
out['mixed'] = len(counts) > 1
return out
def solid_entries(doc, panel, solids=None, frame=None, axis=None):
"""Every solid the body draws, with what it is made of. Revit-side.
Returns [{'volume_ft3', 'material_id', 'material_name', 'density_pcf',
'mixed_material', 'lo_in', 'hi_in'}], largest first. Which of them are
concrete is `choose_concrete`'s question, and that one is pure.
`solids` is the already-walked list where the caller has one. The walk
is `panel.get_Geometry` at Fine on an untouched piece -- 30-60 s of the
minute Ticket QC spends per ticket -- so a caller that wants both the
shape solid and the weight pays for it ONCE.
`frame` and `axis` are what turn a LIST OF VOLUMES into a LAYUP. Volumes
alone say a sandwich exists; they cannot say which solid is the near
wythe, how thick each one is, or how much insulation is between them --
and without those there is no section to compute, only a refusal. So
where the caller has the panel frame, each solid's own local extents are
measured too. `lo_in` / `hi_in` are (length, width, thickness) triples in
INCHES in the panel's local axes, or None where the read failed; None is
"not measured", never zero.
"""
from vibe import ticketqc
out, cache = [], {}
for ix, solid in enumerate(panel_solids(panel) if solids is None
else solids):
material = _solid_material(doc, solid, cache)
lo_in = hi_in = None
if frame is not None:
try:
box = ticketqc.extents_of_solid(solid, frame)
except Exception:
box = None
if box is not None:
lo_in = [v * 12.0 for v in box[0]]
hi_in = [v * 12.0 for v in box[1]]
out.append({'volume_ft3': solid.Volume,
'material_id': material['id'],
'material_name': material['name'],
'density_pcf': material['density_pcf'],
'mixed_material': material['mixed'],
'lo_in': lo_in,
'hi_in': hi_in,
# This solid's position in the body's solid list, so the
# concrete DECISION (`choose_concrete`, pure) can be mapped
# back to the Solid OBJECTS the reconstruction has to
# rebuild -- equal wythes make equal entry dicts, and only
# the index tells them apart. Internal; never serialised.
'_ix': ix})
return out
# --------------------------------------------------------------- the layup
#
# WHY THE THICKNESS AXIS NEEDS ITS OWN ORIGIN HERE, and it is not pedantry.
#
# `panel.thickness_in` and every insert's `z_in` are measured off
# `ticketqc.concrete_extents`, which takes the LARGEST solid. On a solid panel
# that is the piece. On an insulated one it is ONE WYTHE, so the other wythe
# can sit at a negative z under that origin and the "thickness" is a wythe's
# thickness rather than the envelope's. Whether that is what it comes out as
# on a real insulated piece is UNMEASURED (docs/lift-check.md, "Open"), and
# nothing here guesses: the layup is measured from the lowest face of ANY
# solid the body draws -- the envelope -- and both the origin and the offset
# between the two are written into the job file so the difference is
# arithmetic a reader can do rather than a discrepancy they have to find.
#
# panellift refuses an insulated piece outright, so no number is riding on
# this today. It is written down so that when the refusal is lifted, the
# question is already answered in the file rather than in somebody's head.
# Which stages "which end is up" actually decides. Stripping and
# transportation are analysed FLAT and are invariant under mirroring x --
# reverse the piece end for end and the beam problem is its own mirror image,
# so the same peak moment comes out at the mirrored station. Erection is not:
# the erected axis decides which row `exclude_top_rows` drops and which edge
# the anchors sit on.
UP_END_AFFECTS = ('erection -- which row of face lifters the crane cannot '
'reach, and which edge the anchors are on')
def _solid_parts(concrete, axis):
"""`source.solid_parts`, each solid with its place through the thickness.
Ordered included-then-excluded, as it has been since 2026-08-31, so
anything already reading the list is unaffected; what is new on each
entry is z0_in / z1_in, which are null where the extents could not be
read. Null is "not measured" and is never read as zero downstream.
"""
T = axis[2] if axis else None
def part(entry, counted):
lo_in, hi_in = entry.get('lo_in'), entry.get('hi_in')
z0 = z1 = None
if T is not None and lo_in is not None and hi_in is not None:
z0, z1 = lo_in[T], hi_in[T]
return {'volume_ft3': round(entry.get('volume_ft3') or 0.0, 4),
'material': entry.get('material_name'),
'density_pcf': entry.get('density_pcf'),
'z0_in': None if z0 is None else round(z0, 4),
'z1_in': None if z1 is None else round(z1, 4),
'counted': counted}
parts = ([part(e, True) for e in concrete['included']] +
[part(e, False) for e in concrete['excluded']])
# Rebase onto the envelope's own form face, so the layup and the
# per-solid list agree with each other. `concrete_extents`' origin is the
# LARGEST solid's, under which the other wythe of a sandwich sits at a
# negative z; see Z_ORIGIN_NOTE.
floors = [p['z0_in'] for p in parts if p['z0_in'] is not None]
if floors and len(floors) == len(parts):
floor = min(floors)
for p in parts:
p['z0_in'] = round(p['z0_in'] - floor, 4)
p['z1_in'] = round(p['z1_in'] - floor, 4)
return parts
def _connectors(know):
"""What ties the wythes together -- every field null, with its reason.
NOTHING HERE IS MEASURED YET and the block is written anyway, because
rule 3 of the file contract is that a gather which could not read
something writes an explicit null AND a reason. A missing `connectors`
key would read downstream as "this piece has no connectors", which on an
insulated panel is the one thing it certainly is not.
`declared_action` is a PLANT DECLARATION and it is carried as evidence,
not as a ruling. The plant may describe its pins as non-composite; that
is strong evidence about design intent and it is not a statement about
behaviour, because partial composite action exists physically whatever
the label says. It is written into the file so the report can quote it
and stays out of the mechanics.
"""
return {
'count': None,
'spacing_in': None,
'per_row': None,
'family': None,
'capacity_shear_lb': None,
'capacity_tension_lb': None,
'stiffness_lb_per_in': None,
'declared_action': knowledge.get(
know, 'handling.connectors.declared_action'),
'why': ('connector recognition is not wired: nothing here reads the '
'connector instances off the piece, and no layer publishes a '
'capacity or a slip stiffness for them. Every field above is '
'null because it was not read, which is not the same as zero '
'and is not a piece with no connectors in it. What would '
'unblock each: a recognition rule for the connector family, '
'a capacity PER FAILURE MODE (interface shear, and '
'normal/tension) in lb, and a slip stiffness k in lb/in.'),
}
Z_ORIGIN_NOTE = (
'z is measured up from the lowest face of any solid the body draws (the '
'envelope form face). WHICH of the two faces of the piece is the form '
'face is NOT established -- the local thickness axis is taken as it '
'comes, the same convention the strand and insert z_in already use -- so '
'a layup that is symmetric top to bottom is the same either way and one '
'that is not may be the mirror of what was cast.')
def layup_of(entries, axis, know=None):
"""The piece through its thickness, as ordered layers. Pure.
`entries` is `solid_entries` output and `axis` is the (L, W, T) triple
`_axes` returned. Returns a dict with 'layers', 'envelope_thickness_in',
'z_origin_offset_in', 'measured' and 'why' -- ALWAYS all of them, and
'measured' False with a written reason wherever the positions could not
be read. A missing key would read downstream as "nothing to check".
Pure so it is testable on host CPython with no model and no licence, the
same reason `choose_concrete` is.
"""
out = {'layers': [], 'envelope_thickness_in': None,
'z_origin_offset_in': None, 'measured': False, 'why': '',
'z_origin': Z_ORIGIN_NOTE}
entries = [e for e in (entries or []) if (e.get('volume_ft3') or 0.0) > 0]
if not entries:
out['why'] = 'the panel body draws no solid, so there is no layup'
return out
if axis is None:
out['why'] = ('the panel frame was not solved, so no solid could be '
'placed through the thickness')
return out
T = axis[2]
placed = [e for e in entries
if e.get('lo_in') is not None and e.get('hi_in') is not None]
if len(placed) != len(entries):
out['why'] = (
'{0} of the {1} solids this body draws could not be measured '
'through the thickness, so the layup is incomplete -- and an '
'incomplete layup is not a thinner panel, it is a panel nobody '
'measured'.format(len(entries) - len(placed), len(entries)))
return out
floor = min([e['lo_in'][T] for e in entries])
ceiling = max([e['hi_in'][T] for e in entries])
layers = []
for entry in sorted(entries, key=lambda e: e['lo_in'][T]):
layers.append({'z0_in': round(entry['lo_in'][T] - floor, 4),
'z1_in': round(entry['hi_in'][T] - floor, 4),
'material': entry.get('material_name'),
'density_pcf': entry.get('density_pcf'),
'volume_ft3': round(entry.get('volume_ft3') or 0.0, 4)})
out['layers'] = layers
out['envelope_thickness_in'] = round(ceiling - floor, 4)
out['measured'] = True
out['why'] = ('{0} solid(s) placed through the thickness over an envelope '
'of {1:.3f} in'.format(len(layers),
out['envelope_thickness_in']))
return out
def _material_label(entry):
name = entry.get('material_name')
if name:
return name
if entry.get('material_id'):
return 'material {0}'.format(entry['material_id'])
return 'no material'
def _volume_of(entries):
return sum([(e.get('volume_ft3') or 0.0) for e in entries])
def _sum_sentence(included, excluded, min_pcf):
"""What the solid sum included, and what it deliberately left out."""
def parts(entries):
return ', '.join(['{0:.2f} ft3 of {1}'.format(
e.get('volume_ft3') or 0.0, _material_label(e))
for e in entries])
said = ('summed {0} of the {1} solids the body draws as concrete: {2}'
.format(len(included), len(included) + len(excluded),
parts(included)))
if not excluded:
return said + '. Nothing was left out.'
left = ('. Left out {0:.2f} ft3 -- {1} -- as not concrete'
.format(_volume_of(excluded), parts(excluded)))
if min_pcf is not None:
left += ' (under {0:.0f} pcf)'.format(min_pcf)
return said + left + '.'
def choose_concrete(entries, know=None):
"""Which of a body's solids are its concrete, and what the sum included.
PURE -- plain dicts in, plain dict out -- so the decision this repair
turns on is testable on host CPython with no Revit anywhere.
`solid_entries` is the Revit half that feeds it.
Returns, always with every key present:
volume_ft3 the concrete, in cubic feet, or None
basis 'one material' | 'material density' | 'material name'
| None
why one sentence. Where volume_ft3 is None it says why not;
where it is a number it says WHAT WENT INTO IT, because
"both wythes, the insulation left out" is a different
claim from a bare number and 2.2 has to be able to make
the difference.
included / excluded the entries on each side
None is "could not tell". It is never 0.0, and it is never a smaller
answer offered as the whole one -- picking the largest solid and calling
it the piece is precisely what this replaced.
"""
entries = [e for e in (entries or [])
if (e.get('volume_ft3') or 0.0) > 0.0]
out = {'volume_ft3': None, 'basis': None, 'why': '',
'included': [], 'excluded': []}
if not entries:
out['why'] = ('the panel body draws no solid, so there is no '
'concrete to weigh')
return out
ids = set([e.get('material_id') for e in entries])
# ONE material -- which covers a body drawing one solid, and covers a
# body whose solids carry no material at all. Nothing distinguishes
# them, so they are the same stuff and every one of them counts. This is
# the case every solid 8 in. panel in the reference job is in, and it is
# the case the old "largest solid" answer got wrong the moment a body
# drew two.
if len(ids) == 1:
out['included'] = list(entries)
out['volume_ft3'] = _volume_of(entries)
out['basis'] = 'one material'
if len(entries) == 1:
out['why'] = (
'the body draws one solid, {0:.2f} ft3 of {1}, and all of it '
'is counted'.format(out['volume_ft3'],
_material_label(entries[0])))
elif entries[0].get('material_id') is None:
out['why'] = (
'{0} solids totalling {1:.2f} ft3, ALL counted as concrete: '
'not one of them carries a readable material, so nothing '
'here can tell them apart. If any of them is insulation this '
'weight is too heavy by its volume.'
.format(len(entries), out['volume_ft3']))
else:
out['why'] = (
'{0} solids totalling {1:.2f} ft3, all of them {2}, and all '
'of them counted'.format(len(entries), out['volume_ft3'],
_material_label(entries[0])))
return out
# SEVERAL materials. Density tells concrete from insulation without
# anybody having to know a product name.
min_pcf = DEFAULT_CONCRETE_MIN_PCF
if know is not None:
min_pcf = knowledge.get(know, 'materials.concrete_min_pcf',
DEFAULT_CONCRETE_MIN_PCF)
if all([e.get('density_pcf') is not None for e in entries]):
heavy = [e for e in entries if e['density_pcf'] >= min_pcf]
light = [e for e in entries if e['density_pcf'] < min_pcf]
if not heavy:
out['why'] = (
'the body draws {0} solids and every one of them is under '
'{1:.0f} pcf ({2}) -- none of them is concrete, so there is '
'nothing here to weigh as concrete'
.format(len(entries), min_pcf,
', '.join(sorted(set(
['{0} at {1:.0f} pcf'.format(_material_label(e),
e['density_pcf'])
for e in entries])))))
return out
out['included'], out['excluded'] = heavy, light
out['volume_ft3'] = _volume_of(heavy)
out['basis'] = 'material density'
out['why'] = _sum_sentence(heavy, light, min_pcf)
return out
# No density to read. A plant may name its concrete instead; the key
# ships absent, and absent leaves the question unanswered rather than
# answering it "everything".
names = []
if know is not None:
names = [str(tok).upper() for tok in
(knowledge.get(know, 'materials.concrete_names', []) or [])
if str(tok).strip()]
if names:
unreadable = [e for e in entries if not e.get('material_name')]
if unreadable:
out['why'] = (
'materials.concrete_names is declared, but {0} of the {1} '
'solids this body draws carry no readable material name, so '
'whether they are concrete is unknown -- and a sum that '
'quietly leaves them out is a piece missing a wythe'
.format(len(unreadable), len(entries)))
return out
def named_concrete(entry):
up = entry['material_name'].upper()
return any([tok in up for tok in names])
# Split by the test, not by membership: two solids of the same
# material are equal dicts, and `in` would put both on whichever
# side the first one landed.
heavy = [e for e in entries if named_concrete(e)]
light = [e for e in entries if not named_concrete(e)]
if not heavy:
out['why'] = (
'no solid this body draws is made of anything '
'materials.concrete_names lists ({0}); what it is made of is '
'{1}'.format(', '.join(names),
', '.join(sorted(set([_material_label(e)
for e in entries])))))
return out
out['included'], out['excluded'] = heavy, light
out['volume_ft3'] = _volume_of(heavy)
out['basis'] = 'material name'
out['why'] = _sum_sentence(heavy, light, None)
return out
out['why'] = (
'the body draws {0} solids of {1} different materials ({2}) and '
'nothing can say which of them is concrete: no material on them '
'publishes a density, and no layer sets materials.concrete_names. '
'Summing them all would weigh the insulation as concrete; taking the '
'largest would drop a wythe, which is the defect this replaced.'
.format(len(entries), len(ids),
', '.join(sorted(set(['{0} {1:.2f} ft3'.format(
_material_label(e), e.get('volume_ft3') or 0.0)
for e in entries])))))
return out
def concrete_volume(doc, panel, know=None, solids=None, frame=None,
axis=None):
"""How much concrete this panel body is, and what that number included.
The weight half of what `concrete_solid` used to answer alone. Returns
`choose_concrete`'s dict, which carries the sentence as well as the
number -- the number must never travel without it -- plus 'entries', the
whole per-solid breakdown, and 'layup' where the frame was given.
Pass `solids` when the geometry has already been walked; see
`solid_entries`. Pass `frame` and `axis` to get the layup with it: the
same walk answers the weight and the section, and walking twice would
double the most expensive read in the button.
"""
entries = solid_entries(doc, panel, solids, frame, axis)
out = choose_concrete(entries, know)
out['entries'] = entries
out['layup'] = layup_of(entries, axis, know)
return out
def _openings(solid, frame, lo, hi, axes, tol=0.02):
"""Voids through the panel, as [x0, x1, y0, y1] in feet, in local axes.
Taken from the faces of the concrete solid whose normal lies in the plane
of the panel: the sides of a hole. Each hole contributes several such
faces, so they are clustered back into rectangles -- one face on its own
is a line, and four lines are not four blockouts.
The hard part is telling a hole's side from the OUTSIDE of the piece,
which is a side face too. The test is not "does it reach the edge":
a door running down to the bottom of a panel has jambs that reach the
edge, and dropping them left the head on its own, which clusters into a
line and reads as no opening at all. That is how a piece with a large
opening came back weighing what a solid one would.
The real distinction is shape. An outer face of a rectangular panel lies
flat IN one of the four bounding planes, so it is degenerate along one
in-plane direction and sits at that direction's extreme. A jamb at some
x strictly inside the piece is not that, however far it runs in y.
"""
L, W, T = axes
if solid is None:
return []
inv = frame[0]
length_ft = (hi[L] - lo[L]) / 12.0
width_ft = (hi[W] - lo[W]) / 12.0
rects = []
for face in solid.Faces:
try:
normal = inv.OfVector(face.ComputeNormal(face.GetBoundingBox().Min))
except Exception:
continue
if abs((normal.X, normal.Y, normal.Z)[T]) > 0.2:
continue # a top or bottom face, not a side
box = None
for loop in face.EdgeLoops:
for edge in loop:
curve = edge.AsCurve()
for end in (curve.GetEndPoint(0), curve.GetEndPoint(1)):
q = inv.OfPoint(end)
v = (q.X * 12.0, q.Y * 12.0, q.Z * 12.0)
box = [list(v), list(v)] if box is None else \
[[min(box[0][a], v[a]) for a in (0, 1, 2)],
[max(box[1][a], v[a]) for a in (0, 1, 2)]]
if box is None:
continue
xs = sorted([(box[0][L] - lo[L]) / 12.0, (box[1][L] - lo[L]) / 12.0])
ys = sorted([(box[0][W] - lo[W]) / 12.0, (box[1][W] - lo[W]) / 12.0])
flat_in_x = (xs[1] - xs[0]) < tol
flat_in_y = (ys[1] - ys[0]) < tol
at_end = flat_in_x and (xs[0] < tol or xs[1] > length_ft - tol)
at_side = flat_in_y and (ys[0] < tol or ys[1] > width_ft - tol)
if at_end or at_side:
continue # the outside of the piece
if flat_in_x and flat_in_y:
continue # a sliver, not a face of anything
rects.append([xs[0], xs[1], ys[0], ys[1]])
return _cluster(rects)
def extra_cast_ft3(doc, asm, know):
"""Cast concrete inside the assembly that is not the panel's own solid.
The panel rectangle is not always the whole cast piece. A slab-seat
ledge is modelled as its own family inside the assembly, it is real
concrete, and leaving it out understates every weight downstream:
WP371's ledge carried 8.99 ft3 = 1,349 lb, 7.5% of the piece, which is
how the weight gate started refusing panels whose model was right.
Which families count is plant knowledge (`families.cast_include`); with
no list the answer is 0.0, not a guess.
"""
return sum([(m['volume_ft3'] or 0.0)
for m in extra_cast_members(doc, asm, know)])
def extra_cast_members(doc, asm, know):
"""The members `extra_cast_ft3` sums, one entry each.
Split out 2026-08-30 for checklist 2.4, which asks a question the total
cannot answer: are the cast extras the ticket DRAWS the same set that
went into the weight? A number cannot be compared against a set, and a
second implementation of the matching rule would drift from the one that
actually fed the weight -- so this is now the single rule and
`extra_cast_ft3` is its sum.
Returns [{'family', 'mark', 'id', 'volume_ft3'}]. volume_ft3 is None
when the member has no readable Volume: that member added NOTHING to
the weight, which is a finding for 2.4 and must not read as 0.0.
"""
out = []
include = [str(tok).upper() for tok in
(knowledge.get(know, 'families.cast_include', []) or [])]
if not include or asm is None:
return out
# Solid-zone members are counted by `solid_zone_ft3` on their own concrete;
# they must not ALSO be swept in here, or a plant that named its solid-zone
# family in `families.cast_include` (a substring match -- "SOLID" would do
# it) would weigh that concrete twice. One mechanism owns them, and it is
# not this one.
zone_family = _solid_zone_family(know)
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
try:
family = str(member.Symbol.Family.Name)
except Exception:
continue
if zone_family and family.strip().upper() == zone_family:
continue
if not any(tok and tok in family.upper() for tok in include):
continue
volume = None
for p in member.Parameters:
try:
if p.Definition.Name == 'Volume' and p.HasValue:
volume = p.AsDouble()
break
except Exception:
pass
try:
mark = _member_mark(member)
except Exception:
mark = None
# .IntegerValue is deprecated for .Value in Revit 2024+; read it the
# way the rest of this extension does rather than assuming either.
try:
eid = str(member.Id.IntegerValue)
except Exception:
try:
eid = str(member.Id.Value)
except Exception:
eid = None
out.append({'family': family, 'mark': mark, 'id': eid,
'volume_ft3': volume})
return out
def solid_zone_ft3(doc, asm, know):
"""Cast concrete of the assembly's solid-zone members, or 0.0.
The thickened concrete zones of an insulated (sandwich) panel are modelled
as their OWN family members inside the assembly -- separate Generic Models,
not geometry of the panel's structural-framing element -- so neither the
solid sum nor the reconstruction, both of which walk only the panel body,
ever sees them. On an insulated sandwich that is real cast concrete silently
missing from every weight (WP113: 9.63 ft3, ~7.8% of the piece).
Which family they are is plant knowledge, read from
`insulation.solid_zone.family`; with no such key (a plant that casts no
solid zones) the answer is 0.0, not a guess, and the weight is unchanged.
Only their CONCRETE is counted -- `_member_concrete_ft3` runs the same
density discipline `concrete_volume` uses, so an all-insulation member
contributes nothing.
"""
return sum([(m['volume_ft3'] or 0.0)
for m in solid_zone_members(doc, asm, know)])
def _solid_zone_family(know):
"""The declared solid-zone family in UPPER case, or None.
One reader for the family name so `solid_zone_members` (which counts them)
and `extra_cast_members` (which must NOT, or a plant that also lists the
family in `families.cast_include` would count them twice) agree on exactly
which members belong to which mechanism. Absent key -> None -> nothing is
a solid zone, and the weight is unchanged.
"""
family = knowledge.get(know, 'insulation.solid_zone.family')
family = str(family).strip() if family is not None else ''
return family.upper() or None
def solid_zone_members(doc, asm, know):
"""The solid-zone members `solid_zone_ft3` sums, one entry each.
Returns [{'family', 'mark', 'id', 'volume_ft3'}], `volume_ft3` the member's
CONCRETE only and None where the member draws no concrete solid at all --
a member that added nothing to the weight, which is a fact to keep and not
a zero to invent, the same rule `extra_cast_members` follows.
The walk is `asm.GetMemberIds()` filtered to the declared family. Steel
(embeds, lifters, rebar) and everything else is a DIFFERENT family and is
never reached, so the concrete/steel split here is the family filter; the
density split WITHIN a matched member is `_member_concrete_ft3`'s.
"""
out = []
want = _solid_zone_family(know)
if not want or asm is None:
return out
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
try:
fam = str(member.Symbol.Family.Name)
except Exception:
continue
if fam.strip().upper() != want:
continue
try:
mark = _member_mark(member)
except Exception:
mark = None
# .IntegerValue is deprecated for .Value in Revit 2024+; read it the
# way the rest of this extension does rather than assuming either.
try:
eid = str(member.Id.IntegerValue)
except Exception:
try:
eid = str(member.Id.Value)
except Exception:
eid = None
out.append({'family': fam, 'mark': mark, 'id': eid,
'volume_ft3': _member_concrete_ft3(doc, member, know)})
return out
def _member_concrete_ft3(doc, member, know):
"""One member's concrete volume in ft3, or None if it draws no concrete.
The same density discipline the panel weight uses -- `choose_concrete` over
the member's own solids -- so a member cast entirely of insulation, or one
that draws no solid at all, returns None rather than a volume. Split out so
a host test can feed it entries through a stubbed `solid_entries` and
exercise the real decision with no Revit anywhere.
"""
return choose_concrete(solid_entries(doc, member), know).get('volume_ft3')
def _pick_concrete_solids(solids, concrete):
"""The Solid OBJECTS `choose_concrete` counted as concrete, body order.
`concrete['included']` is a subset of the entries `solid_entries` built,
each stamped with `_ix` = its position in `solids` before the choice ran.
Pure: given the list and the decision it returns the solids, and it is the
seam that makes the reconstruction rebuild EVERY concrete wythe rather than
only the largest.
"""
picked = []
for e in (concrete.get('included') or []):
ix = e.get('_ix')
if ix is not None and 0 <= ix < len(solids):
picked.append(solids[ix])
return picked
def _wythe_gross_ft3(lo_in, hi_in, hole_rects):
"""One concrete solid's gross length x width x thickness less its openings.
PURE. `lo_in`/`hi_in` are the solid's own local (x, y, z) extents in
inches; `hole_rects` is `_openings`' [[x0, x1, y0, y1]] in feet from `lo`.
The thickness axis is the smallest span, exactly as `_axes` picks it, so a
thin wythe is measured across its OWN thickness and not the envelope's.
This is the per-wythe unit the reconstruction sums.
"""
L, W, T = _axes(lo_in, hi_in)
thickness_ft = (hi_in[T] - lo_in[T]) / 12.0
gross_ft3 = ((hi_in[L] - lo_in[L]) / 12.0) * \
((hi_in[W] - lo_in[W]) / 12.0) * thickness_ft
holes_ft3 = sum([(r[1] - r[0]) * (r[3] - r[2]) * thickness_ft
for r in hole_rects])
return gross_ft3 - holes_ft3
def _reconstructed_ft3(concrete_solids, frame):
"""Sum of every concrete wythe's gross-extents-minus-openings, or None.
THE WYTHE-AWARE RECONSTRUCTION. The leg's purpose is unchanged -- it is the
self-check that catches a blockout read wrong, gross-from-extents against
the actual solid -- but it now rebuilds EACH concrete solid the body draws
and sums them, where it used to rebuild only the single largest and so
silently dropped every other wythe of a sandwich (WP113: 68.0 ft3 rebuilt
against 102.2 ft3 measured, a 33% gap that refused the piece).
None means a wythe's extents could not be read: a reconstruction MISSING a
wythe is the very defect this repairs, so the whole leg stands down and
says so rather than returning a sum that is short by a wythe.
"""
from vibe import ticketqc
total = 0.0
for csolid in concrete_solids:
extents = ticketqc.extents_of_solid(csolid, frame)
if extents is None:
return None
lo = [v * 12.0 for v in extents[0]]
hi = [v * 12.0 for v in extents[1]]
L, W, T = _axes(lo, hi)
rects = _openings(csolid, frame, lo, hi, (L, W, T))
total += _wythe_gross_ft3(lo, hi, rects)
return total
def _member_mark(member):
"""The instance's Mark, when it has one. Best-effort naming only: 2.4
says WHAT and WHERE, and an element id alone is a poor 'where'."""
try:
from Autodesk.Revit.DB import BuiltInParameter
p = member.get_Parameter(BuiltInParameter.ALL_MODEL_MARK)
if p is not None and p.HasValue:
value = p.AsString()
return value.strip() if value else None
except Exception:
pass
return None
def weight_volumes(doc, asm, panel, know):
"""The two GEOMETRY-derived weights of checklist 2.2, as volumes.
Returns a dict with 'solid_ft3' (the modelled concrete as it was
actually drawn: every concrete solid the body draws, plus the cast
extras, plus any solid-zone members), 'reconstructed_ft3' (each concrete
wythe's own length x width x thickness less its blockouts, summed, plus
the same extras and zones -- the piece as the drawing describes it),
'extra_cast_ft3', 'solid_zone_ft3', 'solid_basis', 'solid_note', and
'reason'.
'reason' is a sentence and it is the point of the function: when a
number could not be had, the caller has to be able to SAY why rather
than quietly compare the two it still has. Both volumes are None
whenever reason is set.
'solid_note' is the other half of the same discipline and is set
whenever solid_ft3 is a number: it says WHICH SOLIDS went into it --
"both wythes, the insulation left out at 2 pcf" is a different claim
from a bare 18,500 lb. See `choose_concrete`.
Both legs are wythe-aware. A 16 in. sandwich body draws several concrete
solids; the reconstruction used to rebuild only the largest (one wythe)
and so came out at ~65% of the piece, refusing every insulated piece at
the weight gate on a number the gate was right to reject. It now rebuilds
every concrete solid and sums them, which is `reconstructed_ft3`'s parity
with the measured `solid_ft3` -- and both include the solid-zone concrete
the body draws OUTSIDE the panel element (`solid_zone_ft3`).
Same definitions as `weight_check` above, deliberately -- this is the
ticket-report path asking the handling gate's own question -- but with
no erected orientation solved, because which way the piece stands up
does not change what it weighs.
"""
from vibe import ticketqc
def stood_down(reason, extras=None, zone=None):
return {'solid_ft3': None, 'reconstructed_ft3': None,
'extra_cast_ft3': extras, 'solid_zone_ft3': zone,
'solid_basis': None, 'solid_note': None, 'reason': reason}
if panel is None:
return stood_down('this assembly has no structural framing member, '
'so there is no concrete solid to weigh')
solids = panel_solids(panel)
if not solids:
return stood_down("the panel's concrete solid could not be read "
'out of its geometry')
# ONE geometry walk, both questions off it: `choose_concrete` decides which
# solids are the concrete (the measured leg) and `_pick_concrete_solids`
# hands those same solids back so the reconstruction rebuilds every one.
concrete = concrete_volume(doc, panel, know, solids)
extras_ft3 = extra_cast_ft3(doc, asm, know)
# Concrete the body draws OUTSIDE the panel element -- the thickened
# solid-zone members of an insulated sandwich. 0.0 where no plant names
# the family, so a piece without them is unchanged. Counted here on BOTH
# legs so they stay in parity.
zone_ft3 = solid_zone_ft3(doc, asm, know)
if concrete['volume_ft3'] is None:
# A body whose concrete cannot be identified has no measured weight
# AND no meaningful reconstruction -- the extents the reconstruction
# rebuilds from are one solid's, and on a body of several that is one
# part of the piece. Standing both down with the sentence is the
# honest answer; reporting the half we still have would be 2.2
# grading a piece on a number nobody can say the meaning of.
return stood_down(concrete['why'], extras_ft3, zone_ft3)
frame = ticketqc._panel_frame(panel)
concrete_solids = _pick_concrete_solids(solids, concrete)
reconstructed = _reconstructed_ft3(concrete_solids, frame)
if not concrete_solids or reconstructed is None:
return stood_down("the panel's own extents could not be solved, so "
'length x width x depth could not be rebuilt',
extras_ft3, zone_ft3)
return {
'solid_ft3': concrete['volume_ft3'] + extras_ft3 + zone_ft3,
'reconstructed_ft3': reconstructed + extras_ft3 + zone_ft3,
'extra_cast_ft3': extras_ft3,
'solid_zone_ft3': zone_ft3,
'solid_basis': concrete['basis'],
'solid_note': concrete['why'],
'reason': None,
}
def opening_penetration(panel, frame, solid, rects, axes, lo, hi):
"""Is each opening through the thickness, or a blind recess?
The dunnage judgement is torque, not bearing: a support across a
through-thickness void is the case that matters, and a shallow recess
is not. Through means 'the concrete does not exist where the opening
is' -- answered per opening by shooting a line along the thickness
axis through the opening's centre and intersecting it with the solid.
rects -- [(x0, x1, y0, y1)] in FEET in the piece's frame (as produced
by _openings).
lo, hi -- concrete extents in INCHES (the (lo, hi) used by _openings).
Returns a list aligned with rects:
{'through': bool, 'concrete_in': inches of concrete along the
centreline, 'x0': , 'x1': , 'y0': , 'y1': }
Measured on the detached model: WP199/WP213/WP200 OPENING 1 are
through (0.0 in of concrete), WP210's two openings and every 8-inch
corner pocket are blind recesses (3.5 in of concrete in an 8 in
panel) -- exactly the batch the plant knowledge asks for.
"""
from Autodesk.Revit.DB import (XYZ, Line, SolidCurveIntersectionOptions)
L, W, T = axes
inv = frame[0]
fwd = inv.Inverse
out = []
for (x0, x1, y0, y1) in rects:
cx = (x0 + x1) / 2.0 # feet along length, from lo[L]
cy = (y0 + y1) / 2.0 # feet across width, from lo[W]
a = [0.0, 0.0, 0.0]
b = [0.0, 0.0, 0.0]
a[L] = lo[L] / 12.0 + cx
b[L] = a[L]
a[W] = lo[W] / 12.0 + cy
b[W] = a[W]
a[T] = lo[T] / 12.0 - 0.5 # start below the panel
b[T] = hi[T] / 12.0 + 0.5 # end above it
line = Line.CreateBound(fwd.OfPoint(XYZ(a[0], a[1], a[2])),
fwd.OfPoint(XYZ(b[0], b[1], b[2])))
concrete_in = 0.0
try:
res = solid.IntersectWithCurve(
line, SolidCurveIntersectionOptions())
for k in range(res.SegmentCount):
concrete_in += res.GetCurveSegment(k).Length * 12.0
except Exception:
pass
out.append({
'through': concrete_in < 0.05,
'concrete_in': round(concrete_in, 2),
'x0': round(x0, 3), 'x1': round(x1, 3),
'y0': round(y0, 3), 'y1': round(y1, 3),
})
return out
def split_openings(rects, penetration, to_x, to_y, lo, axes, thickness_in):
"""The openings, split into through voids and blind recesses.
Until 2026-09-01 every rectangle `_openings` returned went into the job
file's `blockouts`, and `panellift` reads a blockout as a void through the
thickness: it takes the width out of the section, CUTS every strand that
passes over it, and restarts development at its edges. A blind recess does
none of that -- the concrete and the strand are both still there. The test
for which is which has existed here since `opening_penetration` landed and
simply was not wired into the export, so an 8-inch corner pocket with 3.5
in of concrete behind it arrived on the far side as a full-depth hole.
That is what produced the last genuine-looking capacity FAIL on the
reference sweep, on a piece whose model has no opening in it at all.
rects -- [[x0, x1, y0, y1]] in FEET from `lo`, as `_openings`
returns them.
penetration -- `opening_penetration`'s list, ALIGNED with rects. Short or
empty is allowed: the solve can fail on its own.
to_x, to_y -- the export's own converters into erected job coordinates.
lo -- concrete extents in INCHES.
axes -- (L, W, T).
thickness_in -- the piece's thickness, or None.
Returns (blockouts, recesses, unjudged):
blockouts the through ones, in the shape the job file has always had.
recesses the blind ones -- same rect fields, plus `concrete_in` (what
is left along the centreline) and `depth_in` where the
thickness is known.
unjudged how many rectangles had no penetration answer. Those stay in
`blockouts`, because "could not tell" is not "blind": the
through reading is the one that takes section away, it is
what this file emitted before, and standing it down on a
failed solve would quietly make a piece pass. The count is
returned so the caller can SAY so rather than only do it.
"""
L, W = axes[0], axes[1]
blockouts = []
recesses = []
unjudged = 0
for i, rect in enumerate(rects):
x0, x1 = sorted([to_x(lo[L] + rect[0] * 12.0),
to_x(lo[L] + rect[1] * 12.0)])
y0, y1 = sorted([to_y(lo[W] + rect[2] * 12.0),
to_y(lo[W] + rect[3] * 12.0)])
entry = {'x0': round(x0, 4), 'y0': round(y0, 4),
'length': round(x1 - x0, 4),
'width': round(y1 - y0, 4)}
pen = penetration[i] if i < len(penetration) else None
through = pen.get('through') if pen else None
if through is None:
unjudged += 1
if through is False:
concrete_in = pen.get('concrete_in')
entry['name'] = 'RECESS {0}'.format(len(recesses) + 1)
entry['concrete_in'] = concrete_in
entry['depth_in'] = (
None if (concrete_in is None or thickness_in is None)
else round(thickness_in - concrete_in, 3))
recesses.append(entry)
else:
entry['name'] = 'OPENING {0}'.format(len(blockouts) + 1)
blockouts.append(entry)
return blockouts, recesses, unjudged
def _cluster(rects, tol=0.02):
"""Merge rectangles that touch into one rectangle each."""
groups = []
for rect in rects:
joined = None
for group in list(groups):
if _touches(rect, group, tol):
if joined is None:
_absorb(group, rect)
joined = group
else: # this rect bridges two groups
_absorb(joined, group)
groups.remove(group)
if joined is None:
groups.append(list(rect))
return groups
def _touches(a, b, tol):
return (a[0] <= b[1] + tol and b[0] <= a[1] + tol and
a[2] <= b[3] + tol and b[2] <= a[3] + tol)
def _absorb(group, rect):
group[0] = min(group[0], rect[0])
group[1] = max(group[1], rect[1])
group[2] = min(group[2], rect[2])
group[3] = max(group[3], rect[3])
# ------------------------------------------------------------------- lifters
def strand_area_in2(know, dia_in):
"""Section area for a nominal strand diameter, or None if unlisted."""
table = knowledge.get(know, 'strands.area_in2', {}) or {}
for key, area in table.items():
try:
if abs(float(key) - float(dia_in)) < 1e-6:
return float(area)
except (TypeError, ValueError):
continue
return None
def _fast_box(know, batch):
"""Whether an insert's centre comes off its bounding box or its geometry.
Two keys, not one, because the two run modes want opposite answers: a
batch runs unattended and should be right, one piece at a time is
somebody waiting. See handling.fast_lifter_box in knowledge.DEFAULTS.
"""
key = ('handling.fast_lifter_box_batch' if batch
else 'handling.fast_lifter_box')
return bool(knowledge.get(know, key, not batch))
def read_prestress(doc, asm, panel, frame, know, to_y, lo, W, T, design,
fast=True):
"""The strands on this piece, with the pull the design put in them.
Two sources, on purpose, because neither one alone is the truth:
WHERE they are comes from the model. It is this piece, and a blockout
cuts the strands it actually passes over -- which strands those are is
a fact about geometry and nothing else.
HOW HARD they were pulled comes from the design summary, where it is
written as an I.T. figure per strand. Nothing in the model carries it.
Returns (block, problems). `block` is None when the piece has no strands,
which is a real answer. It is also None when the piece HAS strands and
the pull could not be established -- but then problems is non-empty, and
the caller refuses rather than checking a prestressed panel as though it
were reinforced. That mistake is worth 350 psi in the direction that
fails a piece which is fine.
"""
from vibe import designspec, ticketqc
problems = []
boxes = ticketqc.member_boxes(doc, asm, panel, frame)
found = []
for entry in boxes:
if not knowledge.is_strand(know, entry['family']):
continue
if not fast:
ticketqc.refine_box(entry, frame)
centre = [(entry['lo'][i] + entry['hi'][i]) / 2.0 for i in (0, 1, 2)]
found.append({
'mark': str(entry['id']),
'family': entry['family'],
'y': round(to_y(centre[W]), 4),
# Up from the form face. The form face is the low end of the
# thickness axis: a panel is cast face down on the bed.
'z_in': round(centre[T] - lo[T], 3),
})
if not found:
return None, problems
spec = designspec.load_spec(doc, design) if design else None
groups = designspec.get_path(spec or {}, 'reinforcing.strand_groups',
[]) or []
pulled = [g for g in groups if g.get('it_lb')]
if not design:
# The caller did not say which design this piece belongs to, so the
# summary could not even be looked up. That is a different failure
# from 'the summary exists but has no tension': this one is fixed by
# passing design=, not by running Extract Design -- which is what the
# old message sent the user to do, and it changed nothing.
problems.append(
'no design number was given for this piece, so its prestress '
'could not be looked up.\n\nPass the design (design=...) -- the '
'summary lookup needs it to find the strand tension.')
return None, problems
if not pulled:
problems.append(
'{0} strands are modelled on this piece but the design summary '
'for {1} gave no initial tension, so there is no prestress to '
'credit.\n\nRun Extract Design on {1} first. Checking a '
'pretensioned panel with the strands left out overstates every '
'handling stress by the whole of P/A -- on one real piece that '
'was 350 psi against a limit of 444, and it turned a panel that '
'passes into one that fails.'
.format(len(found), design or 'this design'))
return None, problems
if len(pulled) > 1:
problems.append(
'the design summary for {0} lists {1} strand groups pulled to '
'different forces ({2}). Which modelled strand belongs to which '
'group is not in the model, and guessing would put the wrong '
'force on the wrong line.'
.format(design, len(pulled),
', '.join('{0:,.0f} lb'.format(float(g['it_lb']))
for g in pulled)))
return None, problems
group = pulled[0]
dia = group.get('dia_in')
area = strand_area_in2(know, dia) if dia else None
if area is None:
problems.append(
'no section area known for a {0}" strand; add it to '
'strands.area_in2.'.format(dia))
return None, problems
qty = group.get('qty')
if qty and int(qty) != len(found):
problems.append(
'the design summary for {0} calls for {1} strands and {2} are '
'modelled on this piece'.format(design, int(qty), len(found)))
for strand in found:
strand['area_in2'] = area
strand['initial_pull_lb'] = float(group['it_lb'])
found.sort(key=lambda s: (s['z_in'], s['y']))
return {
'loss_pct': knowledge.get(know, 'handling.prestress_loss_pct', 8.0),
'diameter_in': float(dia),
'transfer_length_in': knowledge.get(know,
'handling.transfer_length_in'),
'strands': found,
}, problems
def _group_of(know, family):
"""Which lifter group a family belongs to, longest match wins."""
groups = knowledge.get(know, 'handling.lifter_groups', {}) or {}
best, best_len = None, -1
upper = (family or '').upper()
for name, spec in groups.items():
for pattern in (spec or {}).get('families', []) or []:
token = str(pattern).upper()
if token and token in upper and len(token) > best_len:
best, best_len = name, len(token)
return best
def _group_spec(know, group_name):
return (knowledge.get(know, 'handling.lifter_groups', {}) or {}) \
.get(group_name) or {}
def snap_to_edge(where, vertical_axis, x_ft, y_ft, length_ft, width_ft):
"""Move an insert onto the edge of the piece it really acts at.
An anchor cast into an edge acts AT that edge, but its modelled box centre
sits inboard by however long the bar welded to it is -- seven inches on
one real piece, which is not a rounding error on an erection pick.
Which edge depends on how the piece stands up:
vertical panel the top is an END. Snap x, leave y.
horizontal panel the top is a long EDGE. Snap y, leave x -- the
anchors are spread ALONG the length there, and
where each one sits along it is a real choice.
Returns (x, y, fixed_x, fixed_y).
"""
if where not in ('top', 'bottom'):
return x_ft, y_ft, False, False
at_max = (where == 'top')
if vertical_axis == 'width':
return x_ft, (width_ft if at_max else 0.0), False, True
return (length_ft if at_max else 0.0), y_ft, True, False
# ------------------------------------------------------- the cardinal sin
#
# `families.lifter_include` matching ZERO families on a panel is this
# project's own named cardinal sin (CLAUDE.md, error-pattern record) and it
# has bitten twice already: the shipped defaults matching no real family, and
# P78 on insulated panels, where the part the owner named was in no knowledge
# layer at all. Both times the check did not refuse -- it degraded, quietly,
# into a report with nothing to say no. `export()` must never do that a
# third time: a panel with zero recognised lifters is NOT JUDGED, not a job
# file with `panel.lifters: []` masquerading as a clean read.
#
# `no_lifters_message` is pure -- plain values in, one string out -- so the
# three-state wording is tested without Revit. `_lifter_include_provenance`
# is the Revit-shaped half that gathers those values; it is not tested here,
# the same as every other doc-shaped helper in this module (see
# tests/test_liftexport.py's own docstring).
def _lifter_include_provenance(doc, know):
"""Which layer, if any, sets families.lifter_include -- and the plant
file that is the right place for a plant to make that declaration.
Returns (include, declared_by, plant_path):
include the resolved list this run actually matched against
(knowledge.get(know, 'families.lifter_include', [])).
declared_by layer labels ('catalogue', 'plant', 'job', 'config') whose
OWN file names the key, in merge order -- never
'defaults', because the code's built-in fallback is not a
plant declaring anything. Empty means no real layer names
it. None means the layers could not be re-read to tell
(the refusal still fires; only the provenance sentence is
poorer).
plant_path the plant knowledge file this job resolves to, whether or
not that file exists yet, so the refusal can name where
the declaration belongs.
"""
include = knowledge.get(know, 'families.lifter_include', []) or []
declared_by, plant_path = [], None
try:
from vibe import configdump
for label, path, exists, data in configdump.layer_values(
doc, include_config=True):
if label == 'plant':
plant_path = path
if label == 'defaults' or not exists:
continue
node, found = data, True
for part in ('families', 'lifter_include'):
if not isinstance(node, dict) or part not in node:
found = False
break
node = node[part]
if found:
declared_by.append(label)
except Exception:
declared_by = None
return include, declared_by, plant_path
NO_LIFTERS_HEADLINE = (
'no modelled family matched families.lifter_include -- the plant layer '
'maps {0}; nothing was lifted-checked.')
def no_lifters_message(include, declared_by, assembly_families, plant_path):
"""Why zero lifters were recognised on this piece, in one of three ways.
Pure -- `export()`'s job is to gather the four arguments off the model
and the knowledge layers; this turns them into the sentence the refusal
shows. `declared_by` is what tells the three states apart:
None provenance could not be re-checked. Refuse anyway, hedged.
[] UNSET. Nobody has customised the key for this plant, so
the run is on the tool's own guess at somebody else's
naming -- the expected shape for a brand-new plant.
['plant', ...] SET. A real layer names the key and it still matched
nothing here -- a mapping gone stale, the same shape as
the shipped-defaults and P78 incidents.
"""
include = include or []
headline = NO_LIFTERS_HEADLINE.format(
'none' if not include else ', '.join(include))
if declared_by is None:
state = ('Which layer sets families.lifter_include could not be '
're-checked, so this cannot say whether that is a new '
"plant's default or a mapping gone stale -- only that "
'nothing modelled matched it.')
elif not declared_by:
state = (
'families.lifter_include is UNSET: no plant, job or project '
'config layer names it, so this run matched against the '
"tool's own built-in default ({0}). That is the expected shape "
'for a brand-new plant -- and it is also why nothing was found: '
"the default tokens are a guess at somebody else's naming, not "
'this one.'.format(', '.join(include) if include else 'none'))
else:
state = (
'families.lifter_include IS SET ({0}, from the {1} layer) and '
'it still matches no family modelled on this piece -- the '
"plant's mapping has gone stale, the same shape as the "
'shipped-defaults and P78 incidents this codebase has already '
'hit twice.'.format(
', '.join(include) if include else '(empty list)',
' + '.join(declared_by)))
seen = sorted(set(f for f in (assembly_families or []) if f))
modelled = ('The families actually modelled on this assembly: {0}.'
.format(', '.join(seen)) if seen else
'No other family names were read off this assembly either.')
where = ('Declare it in {0}.'.format(plant_path) if plant_path else
"This job's plant could not be resolved to a knowledge file "
'path, so there is nowhere to point at.')
return '{0}\n\n{1}\n\n{2}\n\n{3}'.format(headline, state, modelled, where)
def resolve_concrete_strength(summary_value, param_value, label):
"""(value, conflict_problem_or_None) for one concrete strength.
Two sources feed f'c / f'ci into a job file:
summary_value the drawing's OWN printed value, read off the
design summary view (`designspec.find_summary_views`
+ `ticketqc.parse_design_summary`) -- the design's
own statement of its concrete strength.
param_value the MODEL parameter, read through the same
`sheet.fc` / `sheet.fci` role
(`ticketqc._as_number_or_none(ticket['tkt_fc'])`)
Ticket QC already reads for its own comparison row.
Precedence, and why: the summary is the design's own printed value, so
it is the most authoritative source for a job whose drawing carries one,
and wins outright when present. The parameter is the fallback -- the
ONLY source on a job whose drawing carries no summary at all (the EMI
model, where the owner mapped `sheet.fc` -> `FINAL_STRENGTH` on the
model instead). When both exist and agree (within 1 psi, since these
are always whole numbers in practice), either would do and the summary
is returned. `export()` never invents a strength: a source that is
simply absent contributes nothing here, same as before this fallback
existed.
When both exist and DISAGREE, this mirrors `weight_check`'s "three
weights" rule for the same shape of problem -- a printed value and a
modelled value that describe the same physical fact are not averaged
and neither is silently preferred; the disagreement is reported, naming
both numbers, and nothing is calculated on a guess. So the value comes
back None with a `conflict_problem` string for `export()`'s `problems`
list -- a NOT-JUDGED conflict, not a wrong pick, exactly the same shape
`weight_check` reports as `False, message, numbers` for a stated-vs-solid
weight mismatch, adapted here to `export()`'s "return the job anyway,
with a problem" convention rather than a hard stop, since a bad fc is
caught downstream by every check that needs it and this is one field,
not the whole geometry read.
"""
if summary_value is not None and param_value is not None:
if abs(summary_value - param_value) < 1.0:
return summary_value, None
return None, (
"{0} disagrees between the drawing summary ({1:.0f} psi) and "
'the model parameter ({2:.0f} psi); neither was picked -- this '
'is a conflict to resolve, not a choice for the tool to make, '
'and the stages that need {0} cannot be checked until it '
'is.'.format(label, summary_value, param_value))
if summary_value is not None:
return summary_value, None
if param_value is not None:
return param_value, None
return None, None
# ------------------------------------------------------------------ the read
def export(doc, asm, know, sheets=None, design=None, batch=False):
"""Everything panellift needs about one assembly, as a plain dict.
Returns (job, problems). problems is a list of strings; a job with
problems is still returned so the caller can show what is missing rather
than only that something is.
"""
from Autodesk.Revit.DB import ElementId
from vibe import ticketqc
problems = []
ticket = ticketqc.read_ticket(doc, asm, sheets or {}, know=know)
if not ticket.get('panel_id'):
return None, ['no structural framing member in this assembly, so '
'there is no panel to check']
panel = doc.GetElement(ElementId(ticket['panel_id']))
frame = ticketqc._panel_frame(panel)
extents = ticketqc.concrete_extents(panel, frame)
if frame is None or extents is None:
return None, ['could not solve the panel geometry']
lo = [v * 12.0 for v in extents[0]]
hi = [v * 12.0 for v in extents[1]]
L, W, T = _axes(lo, hi)
length_ft = (hi[L] - lo[L]) / 12.0
width_ft = (hi[W] - lo[W]) / 12.0
thickness_in = hi[T] - lo[T]
vertical_axis, up_is_max, shares = erected_orientation(
asm, panel, frame, (L, W, T), know, (length_ft, width_ft))
if vertical_axis is None:
return None, [
'Cannot tell which way this piece stands up. None of its axes '
'points convincingly up: length {0}, width {1}, thickness {2} '
'(1.00 would be straight up).\n\n'
'Guessing puts the erection anchors on the wrong edge with '
'nothing in the output to show it.\n\n'
'Read from {3}: {4}'.format(
shares.get('length'), shares.get('width'),
shares.get('thickness'),
shares.get('basis'), shares.get('basis_why'))]
if vertical_axis == 'thickness':
return None, [
'This piece stands on its face: the THICKNESS direction points '
'up. A handling check idealises the panel as a beam spanning in '
'its own plane, and that has no meaning here.\n\n'
'Read from {0}: {1}'.format(shares.get('basis'),
shares.get('basis_why'))]
# x always runs along the length with 0 at the end that ends up lower --
# for a horizontal panel neither end is lower, so the sign is taken from
# the width direction instead and x simply keeps the model's order.
if vertical_axis == 'length':
x_ascends = up_is_max
y_ascends = True
else:
x_ascends = True
y_ascends = up_is_max
def to_x(value_in):
along = (value_in - lo[L]) / 12.0
return along if x_ascends else length_ft - along
def to_y(value_in):
across = (value_in - lo[W]) / 12.0
return across if y_ascends else width_ft - across
# -- openings ----------------------------------------------------------
# The largest solid for the SHAPE (the openings are its side faces), and
# `concrete_volume` for the weight -- they stopped being the same object
# the day an insulated body turned out to draw more than one solid. One
# geometry walk answers both; walking twice would double the most
# expensive read in the button.
solids = panel_solids(panel)
solid = concrete_solid(panel, solids)
concrete = concrete_volume(doc, panel, know, solids, frame, (L, W, T))
layup = concrete.get('layup') or {}
rects = _openings(solid, frame, lo, hi, (L, W, T))
# A rectangle in the side of the solid is not automatically a hole through
# it. `opening_penetration` shoots a line along the thickness axis and
# measures what concrete is left, off the SAME solid that is already in
# hand -- no second geometry walk. Only the through ones may go out as
# blockouts: panellift cuts every strand a blockout crosses and restarts
# development at its edges, and doing that for a corner pocket with 3.5 in
# of concrete behind it is how a solid piece came back failing.
try:
pen = opening_penetration(panel, frame, solid, rects, (L, W, T),
lo, hi)
except Exception:
pen = []
blockouts, recesses, unjudged = split_openings(
rects, pen, to_x, to_y, lo, (L, W, T), thickness_in)
if unjudged:
problems.append(
'the through-thickness test could not be solved for {0} of the {1} '
'opening(s) read off this piece; those are carried as '
'through-thickness blockouts, which takes section and strand away '
'that may really be there'.format(unjudged, len(rects)))
fast = _fast_box(know, batch)
# -- extra cast concrete ------------------------------------------------
extras_ft3 = extra_cast_ft3(doc, asm, know)
# Concrete the body draws OUTSIDE the panel element: the thickened
# solid-zone members of an insulated sandwich, real cast concrete that neither
# the panel-body solid sum nor the reconstruction can see. 0.0 where no
# plant names the family, so a piece without them is untouched.
zone_ft3 = solid_zone_ft3(doc, asm, know)
# THE RECONSTRUCTION LEG, MADE WYTHE-AWARE. `weight_check` rebuilds the
# reconstructed weight from `panel.thickness_in`, which is the LARGEST
# solid's extent (`concrete_extents`) and therefore ONE WYTHE on a
# sandwich -- so on a multi-solid body that recomputation drops every other
# wythe and refuses the piece. Where the body draws more than one concrete
# solid, the gate is handed a reconstruction summed over EVERY wythe here
# instead; a single-solid body has no wythe to drop and keeps the
# dimension-based recomputation (which also carries the recess-depth
# handling `weight_check` does). None where a wythe's extents could not be
# read -- the gate then falls back and refuses rather than passing short.
concrete_solids = _pick_concrete_solids(solids, concrete)
reconstructed_ft3 = None
if len(concrete_solids) > 1:
wythe_recon = _reconstructed_ft3(concrete_solids, frame)
if wythe_recon is not None:
reconstructed_ft3 = round(wythe_recon + extras_ft3 + zone_ft3, 4)
# -- concrete strengths ------------------------------------------------
# Two sources: the drawing's own printed summary (jobs whose drawing
# carries one) and the sheet.fc/sheet.fci MODEL parameter (the EMI
# model, whose drawing carries no summary at all) -- see
# `resolve_concrete_strength`'s docstring for the precedence and the
# disagreement rule. The parameter read reuses
# `ticket['tkt_fc']` / `['tkt_fci']`, already gathered by the
# `ticketqc.read_ticket()` call at the top of this function through the
# SAME `_sheet_param(sheets, know, 'sheet.fc')` Ticket QC's own f'c row
# reads -- not a parallel read of the parameter.
fc_summary = fci_summary = None
if design:
from vibe import designspec
views, _packet = designspec.find_summary_views(doc, design, know)
if views:
read = ticketqc.parse_design_summary(doc, views[0])
fc_summary, fci_summary = read.get('fc'), read.get('fci')
fc_param = ticketqc._as_number_or_none(ticket.get('tkt_fc'))
fci_param = ticketqc._as_number_or_none(ticket.get('tkt_fci'))
fc, fc_conflict = resolve_concrete_strength(fc_summary, fc_param, "f'c")
fci, fci_conflict = resolve_concrete_strength(fci_summary, fci_param,
"f'ci")
if fc_conflict:
problems.append(fc_conflict)
if fci_conflict:
problems.append(fci_conflict)
if fc is None and not fc_conflict:
problems.append("no f'c found for this design; the stages that need "
'it cannot be checked')
# -- lifters -----------------------------------------------------------
boxes = ticketqc.member_boxes(doc, asm, panel, frame)
# Every OTHER member's family name on this assembly -- the only place a
# tension bar welded to an embed can be corroborated at all, since Revit
# carries no link from the embed instance to the bar (see
# `knowledge._tension_bar_corroborated`). Built once, off the SAME walk
# that finds the lifters, rather than a second collector pass.
assembly_families = [b['family'] for b in boxes]
lifters = []
for entry in boxes:
family = entry['family']
if not knowledge.is_lifter(know, family):
continue
if not fast:
ticketqc.refine_box(entry, frame)
centre = [(entry['lo'][i] + entry['hi'][i]) / 2.0 for i in (0, 1, 2)]
group = _group_of(know, family)
acts_at = _group_spec(know, group).get('at', 'measured')
x, y, fixed_x, fixed_y = snap_to_edge(
acts_at, vertical_axis, to_x(centre[L]), to_y(centre[W]),
length_ft, width_ft)
# Ratings go out UNDERATED, with the strength they were derived at.
# One insert is checked at several concrete strengths in one report --
# release for stripping, 28 days for the rest -- so a single
# pre-derated number would be wrong for all but one of them. The
# derating is panellift's, where it is tested.
#
# capacity_lb / shear_capacity_lb are baked into the job file at
# THIS export, and panellift never re-reads the catalogue -- so the
# basis/why strings ride along here too, or the origin of a number
# (a direct override, a bare catalogue read, or the tension-bar gate
# picking a column) is gone the moment this file is written, exactly
# the gap that made the dropped P76 override take a git-history dig
# to explain.
tension_lb, tension_basis, tension_why = knowledge.lifter_capacity(
know, family, assembly_families=assembly_families)
shear_lb, shear_basis, shear_why = knowledge.lifter_capacity(
know, family, kind='shear', assembly_families=assembly_families)
lifters.append({
'mark': str(entry['id']),
'family': family,
'group': group,
'x': round(x, 4),
'y': round(y, 4),
'modelled_x': round(to_x(centre[L]), 4),
'modelled_y': round(to_y(centre[W]), 4),
'pinned': fixed_x,
'pinned_y': fixed_y,
'on_edge': acts_at in ('top', 'bottom'),
'capacity_lb': tension_lb,
'capacity_basis': tension_basis,
'capacity_why': tension_why,
'shear_capacity_lb': shear_lb,
'shear_capacity_basis': shear_basis,
'shear_capacity_why': shear_why,
'basis_fc_psi': knowledge.lifter_basis_fc(know, family),
'min_edge_distance_in': (
knowledge.lifter_edge_distance(know, family) or
knowledge.get(know, 'lifters.min_edge_distance_in')),
# PER PART, and absent rather than defaulted. These are the
# factors that turn an ultimate rating into a working load, and
# they were a single job-wide pair -- 4.0 / 2.67 -- until
# 2026-08-31. The 2.67 had no source and ALP catalog p53
# (02/2024) publishes 4:1 in shear for the parts it reached, so
# a factor now comes from the entry of the part that was tested
# or from nowhere; `panellift` reports an insert with none as
# unjudged rather than substituting a number.
'tension_fos': knowledge.lifter_safety_factor(
know, family, kind='tension')[0],
'shear_fos': knowledge.lifter_safety_factor(
know, family, kind='shear')[0],
})
lifters.sort(key=lambda l: (l['x'], l['y']))
# A hard stop, not a warning -- the cardinal sin (see the block above
# `_lifter_include_provenance`). Checked before the strand read below
# because there is nothing left worth computing on a piece nobody
# lifted-checked, and because this is the same class of refusal as the
# strand one just below it: a fact needed to make the check MEAN
# anything is missing, not merely vaguer.
if not lifters:
include, declared_by, plant_path = _lifter_include_provenance(
doc, know)
return None, [no_lifters_message(include, declared_by,
assembly_families, plant_path)]
# -- strands -----------------------------------------------------------
# A hard stop, not a warning. Every other gap here makes the answer
# vaguer; this one makes it wrong by 350 psi in the direction that fails
# a piece which is fine, and it does it silently.
pre, strand_problems = read_prestress(doc, asm, panel, frame, know,
to_y, lo, W, T, design, fast)
if strand_problems and pre is None:
return None, strand_problems
problems.extend(strand_problems)
ungrouped = sorted(set([l['family'] for l in lifters
if l['group'] is None]))
if ungrouped:
problems.append(
'no handling.lifter_groups entry for {0}; these inserts cannot be '
'assigned to a stage'.format(', '.join(ungrouped)))
job = {
'schema': SCHEMA,
'panel': {
'mark': ticket['mark'],
'design_number': ticket.get('design_number'),
'length_ft': round(length_ft, 4),
'width_ft': round(width_ft, 4),
'thickness_in': round(thickness_in, 3),
'unit_weight_pcf': ticket.get('pcf') or 150.0,
'weight_lb': ticket.get('weight_lb'),
'fc_psi': fc,
'fci_psi': fci,
'finish': knowledge.get(know, 'handling.finish', 'smooth'),
'product_type': knowledge.get(know, 'handling.product_type',
'flat'),
'blockouts': blockouts,
# The blind ones, kept apart from the through ones on purpose.
# Absent means "nothing said" and an empty list means "looked,
# found none" -- the same rule every other additive key in this
# file follows, and the reason the schema does not bump.
'recesses': recesses,
'extra_cast_ft3': round(extras_ft3, 4),
'lifters': [{k: v for k, v in l.items()
if k not in ('group', 'modelled_x', 'modelled_y')}
for l in lifters],
'prestress': pre,
},
'rigging': {
'mode': knowledge.get(know, 'handling.rig_mode', 'rolling'),
'sling_angle_deg': knowledge.get(know, 'handling.sling_angle_deg',
60.0),
'group_tolerance_ft': knowledge.get(
know, 'handling.group_tolerance_ft', 1.0),
# The JOB-WIDE fallback pair, and normally null -- the real
# factors ride on each lifter above, because a safety factor is
# a property of the part that was tested. This wrote 4.0 / 2.67
# until 2026-08-31 and those defaults are gone: 2.67 had no
# published source and the manufacturer's page for the parts it
# reached (ALP catalog p53, 02/2024) heads its capacity table
# "Capacity in Concrete 4:1 SWL" over BOTH load columns. The key
# is still written so a plant that has its OWN tested factor for
# hardware the catalogue does not cover can state one and have
# it read; with nothing set, an insert with no factor of its own
# is reported unjudged instead of quietly given one.
'tension_fos': knowledge.get(
know, 'catalogue.safety_factors.tension_fos'),
'shear_fos': knowledge.get(
know, 'catalogue.safety_factors.shear_fos'),
},
'stages': [],
'source': {
'model': os.path.basename(doc.PathName or ''),
'assembly_id': ticket['asm_id'],
'panel_id': ticket['panel_id'],
'design': design,
'erected_vertical_axis': vertical_axis,
'erected_up_is_axis_max': bool(up_is_max),
'vertical_share': shares,
# WHERE "which end is up" CAME FROM, on every run. The ruled
# answer is the assembly origin (see erected_orientation and
# knowledge.up_end_rule); until the origin half of that rule is
# verified against a live model this is global elevation, and a
# report that cannot say which of the two it used cannot be
# argued with. Quiet but on the record -- the same treatment
# `_plant_id` gets when no plant is named.
'up_end_basis': shares.get('basis'),
'up_end_basis_why': shares.get('basis_why'),
# Total cast concrete: every concrete solid the body draws, plus
# the extras, plus the solid-zone members. None when which solids
# are the concrete could not be established -- reporting a fraction
# of the piece would hand the weight gate a number that is a
# fraction of the piece and let it call the model wrong. It did
# exactly that on every insulated panel until 2026-08-31.
'solid_ft3': (round(concrete['volume_ft3'] + extras_ft3 + zone_ft3,
4)
if concrete['volume_ft3'] is not None else None),
'panel_solid_ft3': (round(concrete['volume_ft3'], 4)
if concrete['volume_ft3'] is not None
else None),
'extra_cast_ft3': round(extras_ft3, 4),
# The thickened solid-zone concrete cast OUTSIDE the panel element
# (separate Generic Model members). Included in solid_ft3 and in
# reconstructed_ft3 above; carried on its own so the weight gate
# (and a reader) can add it to the dimension-based reconstruction
# of a single-solid piece too. 0.0 where no plant names the family.
'solid_zone_ft3': round(zone_ft3, 4),
# THE WYTHE-SUMMED RECONSTRUCTION, present only where the body draws
# more than one concrete solid -- the case `weight_check`'s
# `panel.thickness_in` recomputation gets wrong by a whole wythe.
# Absent (None) on a single-solid piece, which keeps the gate's own
# dimension-and-recess reconstruction. Already includes the extras
# and the solid zones, so the gate uses it as-is.
'reconstructed_ft3': reconstructed_ft3,
# WHAT THE SOLID SUM INCLUDED, on every run, whether it worked
# or not. A weight travelling without this is a bare number
# nobody can argue with -- the same treatment `up_end_basis`
# gets above, and for the same reason.
'solid_basis': concrete['basis'],
'solid_note': concrete['why'],
# AND WHERE EACH OF THEM SITS THROUGH THE THICKNESS. The volumes
# alone were enough to say "this body draws more than one solid",
# which is enough to REFUSE an insulated piece -- and not enough
# to compute its section. z0_in/z1_in are what turn a list of
# volumes into a layup: which solid is the near wythe, how thick
# each one is, and how much insulation is between them. They are
# null where the read failed, never zero, and `panellift.sandwich`
# reads null as "the layup exists and nobody measured it" rather
# than as a layer of no thickness.
'solid_parts': _solid_parts(concrete, (L, W, T)),
# The layup as an ordered list, which is the same facts arranged
# the way the section wants them. Written whether or not it could
# be measured, with `measured` and `why` on it -- absence is not
# a value, and a missing key downstream reads as nothing to check.
'layup': layup,
# The envelope, measured over EVERY solid the body draws. Not the
# same number as `panel.thickness_in`, which comes off the
# largest solid alone and is therefore one wythe on a sandwich.
# Both are written so the difference is arithmetic rather than a
# discrepancy somebody has to notice.
'envelope_thickness_in': layup.get('envelope_thickness_in'),
'section_thickness_basis': (
"panel.thickness_in is the LARGEST solid's extent "
'(ticketqc.concrete_extents), which is the whole piece on a '
'solid panel and ONE WYTHE on an insulated one. '
'source.envelope_thickness_in is measured over every solid '
'the body draws. On a body drawing one solid they are the '
'same number.'),
# What is holding the wythes together, and it is empty today.
# Every value null with a reason beside it, because a missing key
# reads downstream as "nothing to check" and the connectors are
# the thing an insulated panel is actually checked on.
'connectors': _connectors(know),
# Which stages the up-end answer decides, so a report that had to
# fall back can say what the fallback touched instead of leaving
# the reader to work it out. See panellift.report.up_end_
# disclosure for why the fallback is allowed to answer at all.
'up_end_affects': UP_END_AFFECTS,
},
}
# Carried outside 'panel' so the job file stays exactly what panellift
# reads; the button needs the grouping to work out the default picks.
job['_groups'] = dict((l['mark'], l['group']) for l in lifters)
return job, problems
# -------------------------------------------------------------------- stages
def rows_of(lifters, tolerance=0.4, along='x'):
"""Inserts clustered into rows along the piece, bottom first.
`along` is the direction the piece spans in the stage being set up: 'x'
flat on the bed, 'y' for a horizontal panel stood up on its long edge.
Inserts share a row when they hang from the same point of the rigging,
and which coordinate decides that changes with the piece's orientation --
the anchors along the top edge of a horizontal panel are one support when
it is standing and several when it is flat.
Returns a list of dicts: x, marks, families, group.
"""
rows = []
for lifter in sorted(lifters, key=lambda l: l[along]):
value = lifter[along]
if rows and abs(value - rows[-1]['x']) <= tolerance:
row = rows[-1]
else:
rows.append({'x': value, 'xs': [], 'marks': [],
'families': set(), 'groups': set(), 'counts': {}})
row = rows[-1]
row['marks'].append(lifter['mark'])
row['families'].add(lifter['family'])
row['counts'][lifter['family']] = \
row['counts'].get(lifter['family'], 0) + 1
# A SET, not the first member's group. An edge anchor and a pair of
# face lifters can sit at the same station -- on one real piece they
# were an inch apart -- and stamping the row with whichever sorted
# first threw the other two out of every stage that wanted them.
row['groups'].add(lifter.get('group'))
row['xs'].append(value)
row['x'] = sum(row['xs']) / float(len(row['xs']))
return rows
def _dunnage_mode(know):
"""The plant's dunnage rule, or None when nobody has stated one."""
dunnage = knowledge.get(know, 'handling.dunnage') or {}
mode = dunnage.get('mode')
if mode in ('lifter_rows', 'fraction'):
return str(mode), dunnage
return None, dunnage
def dunnage_supports(know, length_ft, rows):
"""Support lines for the transportation stage, per the plant's rule.
Returns (supports, problems). supports is a list of x positions in feet,
or None when the plant has not configured dunnage -- in which case the
stage keeps the old lift approximation and the report labels it.
"""
mode, dunnage = _dunnage_mode(know)
if mode is None:
return None, []
if mode == 'lifter_rows':
xs = [r['x'] for r in (rows or [])]
if len(xs) < 2:
return None, [
'handling.dunnage.mode is lifter_rows but there are fewer '
'than two stripping lifter rows to sit the piece on']
return [round(x, 4) for x in xs], []
# fraction -- two dunnage lines, inset that much of the span each end.
fraction = dunnage.get('fraction')
if fraction is None:
return None, [
'handling.dunnage.mode is fraction but '
'handling.dunnage.fraction is not set']
try:
fraction = float(fraction)
except (TypeError, ValueError):
return None, [
'handling.dunnage.fraction is not a number: {0!r}'
.format(dunnage.get('fraction'))]
if not 0.0 < fraction < 0.5:
return None, [
'handling.dunnage.fraction must be between 0 and 0.5, got '
'{0}'.format(fraction)]
return [round(fraction * length_ft, 4),
round((1.0 - fraction) * length_ft, 4)], []
def default_stages(job, know):
"""The stages from knowledge, each with the picks the rules imply.
Returns a list of dicts ready to become job['stages'], the row structure
the caller shows the user, and any dunnage configuration problems found.
The user gets the last word: these are defaults, not decisions.
"""
groups = job.get('_groups') or {}
lifters = []
for lifter in job['panel']['lifters']:
item = dict(lifter)
item['group'] = groups.get(lifter['mark'])
lifters.append(item)
tolerance = job['rigging'].get('group_tolerance_ft', 1.0)
# WHICH WAY THE PIECE SPANS ONCE IT IS STANDING, and the `or 'length'`
# that used to be here was the same defect as an undisclosed fallback one
# step further on: a piece whose erected axis nobody established was
# quietly assumed to span its length, which is right for a wall panel and
# puts the erection anchors on the wrong edge of a horizontal one. It
# cannot be silent. `export()` refuses outright when no axis is
# convincing, so this only fires on a hand-written job file -- and a
# hand-written file is exactly where nobody would notice.
erected = (job.get('source') or {}).get('erected_vertical_axis')
erected_assumed = not erected
if erected_assumed:
erected = 'length'
all_rows = rows_of(lifters, tolerance, along='x')
fc = job['panel'].get('fc_psi')
fci = job['panel'].get('fci_psi')
# The tensile allowable, as a multiple of sqrt(f'c) in psi. This is the
# single largest disagreement between this tool and a commercial check of
# the same piece -- 5.0 against 7.5 is a factor of 1.5 on every stress
# verdict -- and the owner would not settle it for everybody, 2026-08-30:
# "应力极限写入 projectconfig 里面的 lifting&handling 板块里面去,让用户自
# 己填写。I can see it both ways." So it is a field in the Project Config
# window's lifting & handling module, resolved through the layers here
# and written onto every stage below, where panellift reads it.
limit = knowledge.get(know, 'handling.tension_limit_sqrt_fc', 5.0)
# The compression allowable's sibling field, resolved and written the
# exact same way: "0.6保持可调" (2026-09-01) is the ruling that made this
# one adjustable per job too, in the same Project Config module.
limit_comp = knowledge.get(know, 'handling.compression_limit_ratio', 0.6)
length_ft = job['panel'].get('length_ft') or 0.0
problems = []
stripping_rows = None
out = []
for spec in knowledge.get(know, 'handling.stages', []) or []:
wanted = spec.get('groups') or []
# Which direction the piece spans in this stage.
#
# Flat on the bed or the trailer it spans its LONG direction, always,
# whatever it will end up being: that is how it lies, and that is how
# the face lifters are rowed across it.
#
# Erected, gravity runs along whatever the assembly origin stands up,
# and the piece spans THAT. For a wall panel it is the long direction
# again and nothing changes. For a horizontal panel it is the width,
# and the rows have to be taken again in that direction.
uses_erected = bool(spec.get('erected') or 'edge_top' in wanted)
span = spec.get('span') or (erected if uses_erected else 'length')
# And if the erected axis was assumed rather than read, this is the
# stage it was assumed FOR, so it says so on that stage rather than
# in a log nobody opens.
assumed_here = (erected_assumed and uses_erected
and not spec.get('span'))
if assumed_here:
problems.append(
'stage "{0}" spans whatever the piece stands up, and nothing '
'in this job file says which axis that is '
'(source.erected_vertical_axis is absent). It was taken as '
'the LENGTH, which is right for a wall panel and puts the '
'anchors on the wrong edge of a horizontal one.'
.format(spec.get('name') or 'stage'))
# Inserts are chosen by GROUP first and rowed afterwards. Rowing first
# and then filtering asks a row which group it is, and a row that
# holds two answers can only give one of them: an anchor and a pair of
# face lifters at the same station cost the face lifters their place
# in every stage that wanted them. Six of eight got picked, on a real
# piece, and the missing pair is a whole support line.
picked = [l for l in lifters if l.get('group') in wanted]
rows = rows_of(picked, tolerance,
along='y' if span == 'width' else 'x')
# 'exclude_top_rows' drops rows from the top of the piece, and only
# rows that are PURELY the movable group -- the point of the rule is a
# crane that cannot reach the top face lifters, not one that cannot
# reach the anchors it is hooking on to, and a row holding both still
# holds an anchor. Rows are ordered along whatever the piece spans in
# this stage, so 'top' follows the piece round with it.
drop = spec.get('exclude_top_rows') or 0
dropped_marks = 0
if drop and wanted:
movable = [r for r in rows if r['groups'] == set([wanted[0]])]
for row in sorted(movable, key=lambda r: -r['x'])[:drop]:
rows.remove(row)
dropped_marks += len(row['marks'])
strength = fci if spec.get('strength') == 'fci' else fc
stage = {
'name': spec.get('name') or 'stage',
'span': span,
'multiplier': spec.get('multiplier'),
# Not defaulted to the stage's name: a plant may call a stage
# anything, and guessing its Table 8.3.1 row from what it is
# called would put a load factor on the piece that nobody chose.
# Missing both is refused downstream, loudly.
'pci_case': spec.get('pci_case'),
# None means 'hang it the way the job hangs it'. panellift
# inherits job rigging.mode when this is absent, and refuses
# anything that is neither mode nor blank.
'rig_mode': spec.get('rig_mode'),
'fc_psi': strength,
'tension_limit_sqrt_fc': (spec['tension_limit_sqrt_fc']
if spec.get('tension_limit_sqrt_fc')
is not None else limit),
# WHO chose that limit, carried through so the report can print
# it. A stage spec that states its own beats the job-wide field
# in Project Config, and the engineer who typed a number into
# that field and got a different one on the report has to be
# able to see why on the page rather than by reading YAML.
'tension_limit_source': (
'stage' if spec.get('tension_limit_sqrt_fc') is not None
else 'job'),
# The compression allowable's sibling of the two keys above --
# same per-stage override, same provenance word, same reason.
'compression_limit_ratio': (
spec['compression_limit_ratio']
if spec.get('compression_limit_ratio') is not None
else limit_comp),
'compression_limit_source': (
'stage' if spec.get('compression_limit_ratio') is not None
else 'job'),
'lifters': [m for r in rows for m in r['marks']],
'_rows': rows,
'_available': len(picked),
'_dropped_marks': dropped_marks,
}
# A stage the plant switched off. It stays in the list, carrying the
# flag, so panellift can print "not performed" beside the stages that
# ran -- which is the entire reason for having a flag instead of
# deleting the stage from handling.stages, where its absence would be
# indistinguishable from a stage that passed.
#
# The key is written ONLY when it is false. Every plant configured
# today has no `enabled` in its stage specs, absent means enabled at
# both ends, and writing `"enabled": true` on all of them would churn
# every job file on disk to say nothing.
if not spec.get('enabled', True):
stage['enabled'] = False
if spec.get('disabled_reason'):
stage['disabled_reason'] = str(spec['disabled_reason'])
# Transportation is the piece sitting on dunnage, not hanging from
# the inserts. The plant layer says where the dunnage goes; a stage
# that becomes supports has no pick to confirm in the model.
if spec.get('pci_case') == 'transportation':
supports, dunnage_problems = dunnage_supports(
know, length_ft, stripping_rows or rows)
problems.extend(dunnage_problems)
if supports is not None:
stage['supports'] = supports
# The key goes, rather than going empty. panellift refuses a
# stage that names both a support line and an insert, and an
# empty list is still a list -- which made every job file
# written with dunnage configured unreadable by the thing
# that reads job files.
stage.pop('lifters', None)
stage['_rows'] = []
stage['_available'] = 0
stage['_dropped_marks'] = 0
out.append(stage)
if spec.get('pci_case') == 'stripping' and span == 'length':
stripping_rows = rows
return out, all_rows, problems
def stage_audit(stages):
"""Every insert a stage was entitled to, against what the defaults gave it.
Run on the DEFAULTS, before anybody has chosen anything: after that,
fewer inserts is the engineer's decision and not a fault.
A stage quietly losing an insert it should have had is invisible in the
result. The report is complete, the diagrams are drawn, and the piece is
analysed on a support line that is not there -- which is how a panel came
back at 184% of its stripping limit on three rows when it has four.
Advisory, not a veto. The button now has the user click every insert on
the piece, so a default that is off costs clicks rather than an answer;
what this says is that the highlighted defaults are not to be trusted on
this piece, which is worth knowing before you glance at them and hit
Finish.
"""
problems = []
for stage in stages:
# A stage that is switched off is not going to be analysed on any
# inserts, so how many it was entitled to is not a finding about
# anything. Saying it anyway teaches the reader to skim the audit.
if stage.get('enabled', True) is False:
continue
available = stage.get('_available', 0)
expected = available - stage.get('_dropped_marks', 0)
got = len(stage.get('lifters') or [])
if got != expected:
problems.append(
'{0}: its insert groups hold {1}, {2} were set aside on '
'purpose, and {3} came through. {4} unaccounted for.'
.format(stage['name'], available,
stage.get('_dropped_marks', 0), got,
abs(expected - got)))
return problems
# ---------------------------------------------------------- the weight gate
def weight_check(job, know=None):
"""Does the geometry we read weigh what the model says it does?
Returns (ok, message, numbers). ok False means STOP -- do not calculate,
do not write a report.
Three weights have to agree, and which pair disagrees says what is wrong:
stated the panel's own weight parameter
solid every concrete solid the body draws x unit weight
(plus cast extras and solid-zone members)
reconstructed (each concrete wythe's length x width x thickness less
its blockouts, summed, + cast extras + solid-zone
members) x unit weight. On a single-solid piece the sum
is one term and comes off panel.thickness_in; on a
sandwich the exporter hands over source.reconstructed_ft3
so the reconstruction is not one wythe of several.
`solid` says which solids that was -- `source.solid_note`, written by
`choose_concrete` -- and this report prints it beside the number. Until
2026-08-31 it was the LARGEST solid rather than the sum of them, so an
insulated body was weighed one wythe at a time and every insulated piece
stopped here at about 65% of its printed weight. The gate was right; the
number it was handed was not.
stated vs solid is about the model: a weight parameter that has not been
recalculated, or a unit weight that is not what the piece is cast from.
It is also the free net under a units bug in `stated` itself: measured
live 2026-09-02, WPA545E's stored weight parameter read 560,537.22 via
AsDouble() against 38,409 lb via AsValueString() (the number the
engineer actually sees) -- a 14.6x units artifact. `weight_lb` is fed
from the AsValueString path (`ticketqc.rstr`), so that number should
never reach here, but if it ever did, this tolerance check refuses
rather than calculates on it: 560,537 lb implied against a 256.06 ft3
solid is ~2189 pcf, nowhere near real concrete.
solid vs reconstructed is about US. Those two describe the same concrete;
they differ only when the blockouts were read wrong, and reading them
wrong is silent. An opening that got missed makes the piece heavier than
it is, which is conservative for stress and NOT conservative for anything
else -- and a section modulus computed as if a door were not there is
simply the wrong number, in the direction that says a panel is fine.
That happened: a door running down to the bottom edge of a panel had its
jambs discarded as the outside of the piece, and the panel came back
weighing what a solid one would.
"""
panel = job['panel']
pcf = panel.get('unit_weight_pcf') or 150.0
tolerance = 0.02
if know is not None:
tolerance = knowledge.get(know, 'handling.weight_tolerance', 0.02)
gross_ft3 = (panel['length_ft'] * panel['width_ft'] *
panel['thickness_in'] / 12.0)
holes_ft3 = sum(b['length'] * b['width'] * panel['thickness_in'] / 12.0
for b in panel.get('blockouts') or [])
# A blind recess is concrete that is not there either, and it stopped
# being in `blockouts` on 2026-09-01 -- so without this the reconstruction
# would start calling every pocketed piece heavier than its own solid.
# It comes out at its OWN depth, not the thickness: subtracting a 4.5 in
# pocket as if it went through was the other half of the same error.
# `depth_in` absent means the depth was never solved, and an unmeasured
# pocket is left in rather than guessed at -- the gate then reads high,
# which is a refusal to explain and not a piece quietly blessed.
holes_ft3 += sum(r['length'] * r['width'] * (r.get('depth_in') or 0.0)
/ 12.0 for r in panel.get('recesses') or [])
extras_ft3 = panel.get('extra_cast_ft3') or 0.0
source = job.get('source') or {}
# Solid-zone concrete cast OUTSIDE the panel element (insulated sandwich
# thickened zones). Already inside source.solid_ft3, and added to the
# dimension-based reconstruction below so a single-solid piece that has
# zones still reconciles. 0.0 where no plant names the family.
zone_ft3 = source.get('solid_zone_ft3') or 0.0
solid_ft3 = source.get('solid_ft3')
# THE RECONSTRUCTION LEG. Prefer the wythe-summed volume the exporter
# measured over EVERY concrete solid: it is written only on a multi-solid
# (sandwich) body, which is exactly the body `panel.thickness_in` -- one
# wythe of a sandwich -- gets wrong, and it already carries the extras and
# the solid zones. On a single-solid piece the key is absent and the
# dimension-and-recess reconstruction stands (with the solid zones added),
# which keeps the recess-depth handling the wythe sum does not do. The leg
# is still gross-from-the-drawing against the actual solid, so it still
# catches a blockout read wrong -- it just no longer drops a wythe.
recon_ft3 = source.get('reconstructed_ft3')
wythe_summed = recon_ft3 is not None
if not wythe_summed:
recon_ft3 = gross_ft3 - holes_ft3 + extras_ft3 + zone_ft3
# The provenance rides with the number. `solid_note` is what went into
# the sum; where there is no sum, the same sentence is why there is not.
solid_note = source.get('solid_note') if solid_ft3 else None
solid_why = source.get('solid_note') if not solid_ft3 else None
numbers = {
'stated_lb': panel.get('weight_lb'),
'gross_lb': gross_ft3 * pcf,
'blockout_lb': holes_ft3 * pcf,
'extra_cast_lb': extras_ft3 * pcf,
'solid_zone_lb': zone_ft3 * pcf,
'reconstructed_lb': recon_ft3 * pcf,
'solid_lb': solid_ft3 * pcf if solid_ft3 else None,
'solid_basis': source.get('solid_basis'),
'solid_note': source.get('solid_note'),
'blockouts': len(panel.get('blockouts') or []),
'recesses': len(panel.get('recesses') or []),
'tolerance': tolerance,
}
def off(a, b):
return abs(a - b) / b if b else None
lines = [
'Weight does not reconcile, so nothing was calculated.',
'',
' panel {0:.2f} x {1:.2f} ft x {2:.2f} in at {3:.0f} pcf'
.format(panel['length_ft'], panel['width_ft'],
panel['thickness_in'], pcf),
' solid, gross {0:,.0f} lb'.format(
numbers['gross_lb']),
' {0} blockout(s) + {1} recess(es) -{2:,.0f} lb'.format(
numbers['blockouts'], numbers['recesses'],
numbers['blockout_lb']),
]
if extras_ft3:
lines.append(
' cast extras (ledge etc.) +{0:,.0f} lb'.format(
numbers['extra_cast_lb']))
if zone_ft3:
lines.append(
' solid-zone members +{0:,.0f} lb'.format(
numbers['solid_zone_lb']))
if wythe_summed:
# The breakdown above is the LARGEST solid's dimensions -- one wythe of
# a sandwich -- and does not sum to the reconstruction on a multi-solid
# body, so the reconstruction says how it was really built rather than
# letting the reader add up lines that cannot reach it.
lines.append(
' = what we think it is {0:,.0f} lb '
'(summed over every concrete wythe; the breakdown above is the '
'largest wythe alone)'.format(numbers['reconstructed_lb']))
else:
lines.append(
' = what we think it is {0:,.0f} lb'.format(
numbers['reconstructed_lb']))
if numbers['solid_lb']:
lines.append(' its actual concrete solid {0:,.0f} lb'
.format(numbers['solid_lb']))
if solid_note:
# What that number is OF, never the number on its own. A solid
# sum that does not say which solids it summed is exactly the
# claim nobody could check while an insulated body was being
# weighed one wythe at a time.
lines.append(' -- {0}'.format(solid_note))
elif solid_why:
lines.append(' its actual concrete solid not established -- '
'{0}'.format(solid_why))
lines.append(' the model\'s weight parameter {0}'.format(
'{0:,.0f} lb'.format(numbers['stated_lb'])
if numbers['stated_lb'] else 'not set'))
lines.append('')
if numbers['solid_lb']:
error = off(numbers['reconstructed_lb'], numbers['solid_lb'])
if error is not None and error > tolerance:
missed = numbers['reconstructed_lb'] - numbers['solid_lb']
lines.append(
'The blockouts were read wrong: what we reconstructed is '
'{0:+.1%} off the piece\'s own solid, which is {1:,.0f} lb '
'of concrete {2}.'.format(
error if missed > 0 else -error, abs(missed),
'we did not take out' if missed > 0
else 'we took out twice'))
lines.append('')
lines.append(
'Openings are read off the faces of the concrete solid. A '
'void modelled as a separate family, a recess that is not '
'full depth, or a non-rectangular opening will all land here.')
return False, '\n'.join(lines), numbers
else:
# NO MEASURED LEG, SO NO GATE. `solid` is the only one of the three
# read off the piece's own geometry rather than off its dimensions,
# and solid-vs-reconstructed is the comparison that catches a
# blockout read wrong -- which is the whole reason this is a refusal
# and not a warning. Passing on the two remaining legs would be a
# piece cleared by a check that never ran, and they can agree
# perfectly while the openings are misread, because both of them are
# built out of the same dimensions.
lines.append(
'The piece\'s own concrete was never weighed, so the one '
'comparison that catches a blockout read wrong -- what we '
'reconstruct against what is actually modelled -- did not run. '
'The other two weights are both built from the same dimensions '
'and can agree while the openings are wrong.')
return False, '\n'.join(lines), numbers
if numbers['stated_lb']:
reference = numbers['solid_lb'] or numbers['reconstructed_lb']
error = off(reference, numbers['stated_lb'])
if error is not None and error > tolerance:
lines.append(
'The geometry and the model\'s own weight parameter disagree '
'by {0:+.1%}. Either the parameter has not been recalculated, '
'or the unit weight is not what this piece is cast from.'
.format(error))
return False, '\n'.join(lines), numbers
else:
lines.append('The panel has no weight parameter to check against.')
return False, '\n'.join(lines), numbers
return True, '', numbers
def strip_private(job):
"""The job as panellift reads it: no keys of ours left in it."""
clean = dict((k, v) for k, v in job.items() if not k.startswith('_'))
clean['stages'] = [dict((k, v) for k, v in s.items()
if not k.startswith('_'))
for s in job.get('stages', [])]
return clean
# ------------------------------------------------------- running the maths
#
# The calculation is CPython. Revit's engine is IronPython 2.7 and cannot run
# it -- dataclasses and typing syntax alone rule that out -- and porting it
# would mean giving up the test suite that is the only reason to believe the
# numbers. So it runs as a subprocess, the same out-of-process pattern the
# rest of the tools use for anything CPython-only.
_PYTHON_NAMES = ('python.exe', 'python3.exe')
def _python_dirs():
local = os.environ.get('LOCALAPPDATA', '')
dirs = []
base = os.path.join(local, 'Programs', 'Python')
if os.path.isdir(base):
try:
for name in sorted(os.listdir(base), reverse=True):
dirs.append(os.path.join(base, name))
except Exception:
pass
for major in ('313', '312', '311', '310', '39'):
dirs.append('C:\\Python' + major)
dirs.append(os.path.join(os.environ.get('ProgramFiles', ''),
'Python' + major))
return dirs
def find_python(know=None):
"""CPython 3.9+, or None.
A configured path wins, then the usual install locations, then PATH. The
Microsoft Store aliases in WindowsApps are skipped on purpose: they are
zero-byte stubs that open the Store instead of running anything, and a
'python' that exists and does nothing is worse than one that is missing.
"""
if know is not None:
stated = knowledge.get(know, 'handling.python_exe')
if stated and os.path.isfile(stated):
return stated
override = os.environ.get('WILLTOOLS_PYTHON')
if override and os.path.isfile(override):
return override
dirs = _python_dirs() + os.environ.get('PATH', '').split(os.pathsep)
for folder in dirs:
if not folder or 'WindowsApps' in folder:
continue
for name in _PYTHON_NAMES:
candidate = os.path.join(folder, name)
try:
if os.path.isfile(candidate) and os.path.getsize(candidate):
return candidate
except Exception:
continue
return None
def panellift_src():
"""The panellift package directory that ships beside this extension."""
here = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))) # WillTools.extension
root = os.path.dirname(here)
return os.path.join(root, 'panellift', 'src')
def runner_script(python_exe, job_path, out_path, summary_path, log_path,
recommend=True):
"""The one-line cmd wrapper, as text.
Split out of `run()` so the command line can be checked without Revit.
`run()` itself imports System.Diagnostics and cannot be called off the
IronPython side at all, which meant the ONE string that decides what the
calculation is actually asked to do had no test on it. It has now:
`--no-recommend` has to be present when it was asked for and absent when
it was not, and a flag that silently never reaches the subprocess is a
setting that does nothing -- the exact trap CLAUDE.md names for the
project config file.
"""
return (u'@echo off\r\n'
u'chcp 65001 >nul\r\n'
u'set "PYTHONPATH={src}"\r\n'
u'set "PYTHONIOENCODING=utf-8"\r\n'
u'"{exe}" -m panellift "{job}" -o "{out}" --json{flags} '
u'> "{summary}" 2> "{log}"\r\n'
u'exit /b %ERRORLEVEL%\r\n').format(
src=panellift_src(), exe=python_exe, job=job_path,
out=out_path, summary=summary_path, log=log_path,
flags=u'' if recommend else u' --no-recommend')
def run(python_exe, job_path, out_path, timeout_ms=900000, on_tick=None,
recommend=True):
"""Run the calculation. Returns (exit_code, summary_or_None, stderr).
A non-zero exit is not necessarily an error: the CLI exits 1 when a stage
fails its check, which is a result and not a crash. 2 means it could not
read the job at all.
`recommend=False` adds `--no-recommend`, which switches the insert
POSITION SEARCH off. That search is 93% of the run -- 12.6 s of a 13.6 s
median piece -- and it is the only thing the flag changes: every stage is
still analysed, every check still runs and the verdict is the same. What
the report loses is the "then where?" half, and it says so where the
positions would have been rather than leaving a blank section, because a
section that quietly vanishes reads as a piece with nothing to recommend.
Default True, so a caller that has not thought about it gets the whole
answer.
TWO PIPES ARE NOT USED, and that is deliberate. Reading one to the end and
then the other deadlocks the moment the child writes more to the second
than its buffer holds: the child blocks writing stderr, the parent blocks
reading stdout, and neither ever moves. Four kilobytes of traceback is
enough. Worse, the WaitForExit timeout after those reads is unreachable,
so the guard that was supposed to catch it could never fire.
A one-line cmd wrapper redirects both streams to files instead. Nothing
can fill, nothing can deadlock, and an import error that never reaches
Python's own error handling still lands in the log.
on_tick(elapsed_ms) is called about four times a second while waiting. The
caller uses it to keep a progress bar alive: this runs on Revit's UI
thread, and a five minute wait with a frozen window is not distinguishable
from a crash by the person looking at it.
"""
import json as _json
from System.Diagnostics import Process, ProcessStartInfo
base = os.path.splitext(out_path)[0]
summary_path = base + '.summary.json'
log_path = base + '.log'
runner = base + '.run.cmd'
with io.open(runner, 'w', encoding='utf-8') as fh:
fh.write(runner_script(python_exe, job_path, out_path, summary_path,
log_path, recommend=recommend))
info = ProcessStartInfo()
info.FileName = 'cmd.exe'
info.Arguments = '/c "{0}"'.format(runner)
info.UseShellExecute = False
info.CreateNoWindow = True
process = Process.Start(info)
waited = 0
while not process.WaitForExit(250):
waited += 250
if on_tick is not None:
on_tick(waited)
if waited >= timeout_ms:
try:
process.Kill()
except Exception:
pass
return -1, None, ('the calculation did not finish in {0} minutes'
.format(timeout_ms // 60000))
def _read(path):
try:
with io.open(path, 'r', encoding='utf-8', errors='replace') as fh:
return fh.read()
except Exception:
return ''
stdout, stderr = _read(summary_path), _read(log_path)
summary = None
if stdout.strip():
try:
summary = _json.loads(stdout)
except Exception:
stderr = (stderr or '') + u'\n--- stdout ---\n' + stdout
for path in (runner, summary_path):
try:
os.remove(path)
except Exception:
pass
return process.ExitCode, summary, stderr
def open_file(path):
"""Hand a finished file to whatever the machine opens it with.
A report nobody looks at is not a report, and the gap between "written"
and "read" is one double-click that is easy not to take. Failing to open
it is not worth an error: the path is printed either way.
"""
from System.Diagnostics import Process, ProcessStartInfo
try:
info = ProcessStartInfo()
info.FileName = path
info.UseShellExecute = True # let the shell pick the browser
Process.Start(info)
return True
except Exception:
return False