8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pre-fills Panel Analysis's Structural Model `supports_ft` field.
|
|
|
|
Owner, 2026-09-03 (structural model 没能正确识别 embeds 当成是 support): the
|
|
connection embeds already modelled in the panel's assembly ARE the supports,
|
|
so a form that leaves `supports_ft` blank is asking the engineer to retype
|
|
something the model already says. This module reads the assembly's members
|
|
the same way `_assembly_reinforcement` in PanelAnalysis's `script.py` reads
|
|
them for the reinforcement fields -- `ticketqc._panel_frame` +
|
|
`ticketqc.member_boxes` -- and turns the ones the plant calls out as support
|
|
hardware (`panelanalysis.supports.families`, plant knowledge, never a
|
|
hardcoded family name) into positions along the panel's length, in the SAME
|
|
convention `liftexport.export` uses for a lifter's `x`: feet from the end
|
|
that ends up lower once the piece is erected.
|
|
|
|
WHICH MEMBERS ARE SUPPORTS is never decided here. `families` is read out of
|
|
`know` with default `[]` -- an empty list means "the plant has not said",
|
|
which reads as no match, not as a fabricated guess.
|
|
|
|
Written for IronPython 2.7: no f-strings, no pathlib.
|
|
"""
|
|
# pylint: disable=import-error,invalid-name,broad-except
|
|
|
|
from vibe import knowledge as K
|
|
|
|
# Two boxes are the same support when their length-axis positions land within
|
|
# this many feet of each other -- a connection plate is rarely a single point,
|
|
# so its members (e.g. a plate plus its bolt/rod assembly) commonly report two
|
|
# boxes a couple of inches apart for what is mechanically one support.
|
|
DEDUPE_FT = 0.25
|
|
|
|
|
|
def support_positions(boxes, know, length_ft, x_of_box):
|
|
"""(positions_ft, why) from the assembly's boxes.
|
|
|
|
`boxes` -- the list `ticketqc.member_boxes` returns (each a dict with at
|
|
least `family`); `know` -- the merged knowledge tree; `length_ft` -- the
|
|
panel's length, passed through for a caller building a fuller `why` (not
|
|
otherwise required by this function's own arithmetic); `x_of_box` -- a
|
|
callable, `box -> feet along the length` (or None when a box cannot be
|
|
placed), so this function stays ignorant of Revit units and axes.
|
|
|
|
Filters `boxes` to the ones whose `family` matches
|
|
`panelanalysis.supports.families` (case-insensitive substring, the same
|
|
test `knowledge.matches()` / `knowledge.is_strand()` use), takes each
|
|
matched member's `x_of_box` position, DEDUPES positions within
|
|
`DEDUPE_FT` of each other (keeping the mean of the group, not the first
|
|
or last), sorts ascending, and returns that list plus a sentence naming
|
|
how many members of which families produced it.
|
|
|
|
An empty `families` list, or no member matching it, both come back as
|
|
`([], why)` -- never a fabricated position. `why` is the reason either
|
|
way; a caller checking "did this work" should look at the list, not
|
|
parse the sentence.
|
|
"""
|
|
families = K.get(know, 'panelanalysis.supports.families', []) or []
|
|
if not families:
|
|
return [], ('no member matched panelanalysis.supports.families '
|
|
'(the list is empty -- the plant has not named which '
|
|
'embeds are supports)')
|
|
|
|
matched = [] # [(family, x_ft), ...] one per matching member
|
|
for box in (boxes or []):
|
|
family = (box or {}).get('family') or ''
|
|
if not K.matches(family, families, []):
|
|
continue
|
|
try:
|
|
x = x_of_box(box)
|
|
except Exception:
|
|
continue
|
|
if x is None:
|
|
continue
|
|
matched.append((family, float(x)))
|
|
|
|
if not matched:
|
|
return [], ('no member matched panelanalysis.supports.families '
|
|
'({0})'.format(', '.join(families)))
|
|
|
|
matched.sort(key=lambda pair: pair[1])
|
|
|
|
groups = [] # [{'xs': [...], 'families': [...]}, ...]
|
|
for family, x in matched:
|
|
# Chained on the LAST position added, not the group's running mean --
|
|
# a run of members each 0.2 ft from its neighbour is one support even
|
|
# if the run's two ends are more than DEDUPE_FT apart, the same way a
|
|
# connection plate's own hardware fans out a few inches at a time.
|
|
if groups and (x - groups[-1]['xs'][-1]) <= DEDUPE_FT:
|
|
groups[-1]['xs'].append(x)
|
|
groups[-1]['families'].append(family)
|
|
else:
|
|
groups.append({'xs': [x], 'families': [family]})
|
|
|
|
positions = [round(_group_mean(g), 4) for g in groups]
|
|
|
|
counts = {}
|
|
for family, _x in matched:
|
|
counts[family] = counts.get(family, 0) + 1
|
|
by_family = ', '.join('{0} {1}'.format(counts[f], f)
|
|
for f in sorted(counts))
|
|
why = ('{0} support position(s) from {1} matching member(s) '
|
|
'({2})'.format(len(positions), len(matched), by_family))
|
|
return positions, why
|
|
|
|
|
|
def _group_mean(group):
|
|
xs = group['xs']
|
|
return sum(xs) / float(len(xs))
|
|
|
|
|
|
def read_supports(el, asm, know):
|
|
"""{'supports_ft': ..., 'supports_basis': ...} for the form, or {}.
|
|
|
|
Builds the frame and boxes exactly like `_assembly_reinforcement` in
|
|
PanelAnalysis's `script.py` does (`ticketqc._panel_frame`,
|
|
`ticketqc.member_boxes`), then converts each matched member's box centre
|
|
along the length axis into feet from the lower end -- the SAME
|
|
convention `liftexport.export`'s `to_x` uses for a lifter, built off the
|
|
same `liftexport._axes` / `liftexport.erected_orientation` this module
|
|
borrows rather than re-deriving.
|
|
|
|
Fewer than two positions comes back as `{}` -- the form's field needs at
|
|
least two, and this never invents an end to pad the list out. Any
|
|
exception, anywhere in here, is also `{}`: a support guess that is wrong
|
|
is worse than a form field the engineer has to type in themselves.
|
|
"""
|
|
if el is None or asm is None:
|
|
return {}
|
|
try:
|
|
from vibe import ticketqc as tq
|
|
from vibe import liftexport as LE
|
|
except Exception:
|
|
return {}
|
|
|
|
try:
|
|
frame = tq._panel_frame(el)
|
|
boxes = tq.member_boxes(el.Document, asm, el, frame, know) or []
|
|
except Exception:
|
|
return {}
|
|
if frame is None or not boxes:
|
|
return {}
|
|
|
|
try:
|
|
extents = tq.concrete_extents(el, frame)
|
|
except Exception:
|
|
extents = None
|
|
if extents is None:
|
|
return {}
|
|
|
|
try:
|
|
lo = [v * 12.0 for v in extents[0]]
|
|
hi = [v * 12.0 for v in extents[1]]
|
|
L, W, T = LE._axes(lo, hi)
|
|
length_ft = (hi[L] - lo[L]) / 12.0
|
|
width_ft = (hi[W] - lo[W]) / 12.0
|
|
|
|
vertical_axis, up_is_max, _shares = LE.erected_orientation(
|
|
asm, el, frame, (L, W, T), know, (length_ft, width_ft))
|
|
except Exception:
|
|
return {}
|
|
|
|
if vertical_axis is None:
|
|
return {}
|
|
if vertical_axis == 'thickness':
|
|
# A flat piece: neither end ends up lower, so x keeps the model's
|
|
# order -- the same rule `liftexport.export` follows for a flat
|
|
# piece (see its `flat_piece` handling).
|
|
up_is_max = True
|
|
|
|
x_ascends = up_is_max if vertical_axis == 'length' else True
|
|
|
|
def x_of_box(box):
|
|
try:
|
|
box_lo = box.get('lo')
|
|
box_hi = box.get('hi')
|
|
if box_lo is None or box_hi is None:
|
|
return None
|
|
center_in = (box_lo[L] + box_hi[L]) / 2.0
|
|
along = (center_in - lo[L]) / 12.0
|
|
except Exception:
|
|
return None
|
|
return along if x_ascends else length_ft - along
|
|
|
|
try:
|
|
positions, why = support_positions(boxes, know, length_ft, x_of_box)
|
|
except Exception:
|
|
return {}
|
|
|
|
if len(positions) < 2:
|
|
return {}
|
|
|
|
text = ', '.join('{0:.2f}'.format(p) for p in positions)
|
|
return {'supports_ft': text, 'supports_basis': why}
|