# -*- 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}