Files
willtools-demo/WillTools.extension/lib/vibe/pa_supports.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

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}