52d94303bc
ci / test (push) Has been cancelled
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.
2795 lines
129 KiB
Python
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
|