# -*- coding: utf-8 -*- # pylint: disable=import-error,invalid-name,broad-except __title__ = 'Panel Analysis (WIP)' # Panel Analysis -- Stage 1: an INPUT CONFIRMATION form, not a lifting check. # # Press with one or more structural-framing panels selected. Up pops a dialog # with one COLLAPSED section per panel; each section replicates Eriksson Wall's # own input blocks (Design Criteria / Concrete Extents / Structural Model / # Handling / Reinforcement), PRE-FILLED with what the model could read off the # framing element and BLANK where it could not. The user confirms/fills the # fields and presses that panel's own "Run Analysis" button -- which stays # refused until the panel's required fields (geometry, materials, and at least # two support positions) are all filled. A bottom "Analyze All" runs every # panel whose form is complete and names the ones it skipped and why. # # There is still NO clash detection and NO OST_SpecialtyEquipment scan here -- # the owner ruled Stage 1 is purely an input-confirmation form (2026-09-02). # `liftexport.export` IS now called, once per panel, but only to READ what # Panel Lift already measures (support embeds, lifters, prestress) so the # fields above pre-fill instead of asking the engineer to retype the model's # own geometry -- the owner reversed the "no auto support detection yet" half # of the 2026-09-02 ruling the next day (`pa_supports.py`'s own docstring, # 2026-09-03). Nothing here runs the handling check itself or writes a job # file; see `_reads_for` / `pa_supports` / `pa_lifters` / `pa_reinf`. All the # gate / pre-fill / deck-build logic lives in vibe.panelanalysisform, # host-tested off Revit; this file is the WPF shell and the live geometry # read, which are owner-must-verify. import io import json import os import clr from Autodesk.Revit.DB import (AssemblyInstance, BuiltInCategory, ElementId, FilteredElementCollector) from pyrevit import revit, script, forms from vibe import designspec, knowledge, liftexport, runlog, ticketqc from vibe import pa_elevation, pa_lifters, pa_prefill, pa_reinf, pa_supports from vibe import panelanalysisform as F from vibe import panelsketch from vibe.compat import as_text, string_types from vibe.webhost import WebHost, WebHostError doc = revit.doc uidoc = revit.uidoc output = script.get_output() output.close_others() TITLE = 'Panel Analysis' # Owner scope update, 2026-09-05: the WPF input form is retired in favour of # an HTML page (front end) + local CPython server (tools/pa_web.py), shown in # a WebView2 window (`vibe.webhost.WebHost`) -- see that module's own OWNER # SCOPE UPDATE note and docs/pa-web-spec.md. The WPF renderer below (PanelForm # / show_form / _analyze_all / _report_one, and the System.Windows import # block right before PanelForm) is kept in the file, unreachable, only so the # owner can compare the two side by side once; flip this to True to go back # to it. Kept False, this button must not import WPF-only modules at load # time -- see the guarded import block below. _USE_WPF = False def not_run(message, title=TITLE): output.print_md('# {0} — NOT RUN'.format(TITLE)) output.print_md('**NOT RUN** — no analysis was produced.') output.print_md('> {0}'.format(message)) runlog.record(TITLE, doc_title=doc.Title, outcome='not-run', report_text=message) script.exit() # ---------------------------------------------------------- which pieces def _is_framing(el): try: cat = el.Category return (cat is not None and cat.Id.IntegerValue == int(BuiltInCategory.OST_StructuralFraming)) except Exception: try: return cat.Id.Value == int(BuiltInCategory.OST_StructuralFraming) except Exception: return False def _framing_of_assembly(asm): """The first structural-framing member of an assembly, or None.""" try: for mid in asm.GetMemberIds(): member = doc.GetElement(mid) if member is not None and _is_framing(member): return member except Exception: pass return None def _owning_assembly(el): """The AssemblyInstance this element belongs to, or None.""" try: owner = el.Document.GetElement(el.AssemblyInstanceId) except Exception: return None return owner if isinstance(owner, AssemblyInstance) else None def _resolve_framing(): """[(framing_element, assembly_or_None)] to build a form for. A framing element is taken as itself; an assembly (or any member of one) resolves to that assembly's framing member -- but an assembly is NOT required, a bare framing element is enough. De-duplicated, order kept. THE ASSEMBLY IS KEPT rather than discarded (2026-09-03). It used to be collapsed away the moment the framing member was found, which threw away the only handle on the panel's reinforcement: the strand, mesh and bar are separate MEMBERS of the assembly, not parameters of the panel, so with the assembly gone the whole Reinforcement section had nothing to pre-fill from. A bare framing element still has None here, and reads its reinforcement off the design spec instead (see `_read_reinforcement`). """ selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()] found = [] seen = set() def add(el, asm=None): if el is None: return try: key = el.Id.IntegerValue except Exception: key = id(el) if key in seen: return seen.add(key) found.append((el, asm if asm is not None else _owning_assembly(el))) for el in selection: if el is None: continue if _is_framing(el): add(el) continue if isinstance(el, AssemblyInstance): add(_framing_of_assembly(el), el) continue owner = _owning_assembly(el) if owner is not None: add(_framing_of_assembly(owner), owner) return found # ------------------------------------------------------- read off the model def _read_geometry(el): """{'length_ft','width_ft','thickness_in'} off the framing element, or {}. Reuses the panel-frame + concrete-extents readers Panel Lift Check uses -- the local frame makes 'along the length' mean what it says on a rotated wall panel -- and the same length/width/thickness axis split. Orientation needs no assembly origin: the supports the user types define the statics, so only the extents matter here. """ try: frame = ticketqc._panel_frame(el) if frame is None: return {} extents = ticketqc.concrete_extents(el, frame) if not extents: return {} lo = [v * 12.0 for v in extents[0]] hi = [v * 12.0 for v in extents[1]] L, W, T = liftexport._axes(lo, hi) return { 'length_ft': round((hi[L] - lo[L]) / 12.0, 4), 'width_ft': round((hi[W] - lo[W]) / 12.0, 4), 'thickness_in': round(hi[T] - lo[T], 3), } except Exception: return {} def _read_weight_centroid(el, asm=None, know=None): """{'weight_centroid_ft', 'weight_centroid_ft_basis'} off the concrete solid's TRUE centroid, or {}. Handling's own weight/eccentricity arithmetic today treats the piece as a solid rectangle and computes its centroid from the dimensions; this is what the model actually knows instead -- `Solid.ComputeCentroid()` on every concrete solid the body draws (`liftexport.panel_solids`, largest first, same walk the weight/shape reads already share), volume-weighted across all of them so a Sandwich panel's several wythes each pull the centroid their own share rather than only the largest solid answering for the whole piece (`liftexport.concrete_solid`'s own docstring warns that picking just the largest solid understated an insulated piece's weight by a third; the same bias would understate its centroid). Expressed in the SAME as-drawn (length, width, thickness) frame every other Panel Lift read here uses -- `pa_reinf._member_axes` (the same helper `mild_bar_rows_prefill` uses for its own `from_ft`/`to_ft`) gives the axis sort and the erected x/y flip, so `x`/`y` land at feet from the panel's own length/width zero exactly the way `job['panel']['lifters']`' x/y and `strand_x_in` already do, and `z` at feet up from the FORM FACE (the low end of the thickness axis -- a panel is cast face down on the bed, the same convention strand/mild-bar depth use). `asm`/`know` are optional and passed straight through to `_member_axes`; when they are not enough to resolve the erected orientation (no assembly, or the origin rule cannot tell which end is up), the centroid still comes back -- in the PANEL'S OWN RAW LOCAL FRAME instead, named as such in the basis rather than silently asserting an orientation nobody established. {} when the frame or every solid fails to solve, and defensively on any exception -- never raises, never guesses a centroid off the bounding box when the real one could not be computed. """ try: frame = ticketqc._panel_frame(el) if frame is None: return {} solids = liftexport.panel_solids(el) if not solids: return {} inv = frame[0] total_v = 0.0 s0 = s1 = s2 = 0.0 used = 0 for solid in solids: try: v = solid.Volume c = solid.ComputeCentroid() except Exception: continue if v is None or v <= 0 or c is None: continue local = inv.OfPoint(c) total_v += v s0 += local.X * v s1 += local.Y * v s2 += local.Z * v used += 1 if total_v <= 0: return {} avg = (s0 / total_v, s1 / total_v, s2 / total_v) # feet, raw local axes = None try: axes = pa_reinf._member_axes(el, asm, know) except Exception: axes = None if axes is not None: to_x, to_y = axes['to_x'], axes['to_y'] lo, L, W, T = axes['lo'], axes['L'], axes['W'], axes['T'] x_ft = to_x(avg[L] * 12.0) y_ft = to_y(avg[W] * 12.0) z_ft = (avg[T] * 12.0 - lo[T]) / 12.0 basis = ( 'volume-weighted Solid.ComputeCentroid() over {0} concrete ' 'solid(s) (liftexport.panel_solids), in the as-drawn ' '(length, width, thickness) frame liftexport uses ' 'everywhere else ({1}): x/y are feet from the panel\'s own ' 'length/width zero, z is feet up from the FORM FACE.' .format(used, axes['basis'])) else: x_ft, y_ft, z_ft = avg basis = ( 'volume-weighted Solid.ComputeCentroid() over {0} concrete ' "solid(s), in the panel's own RAW LOCAL FRAME " '(ticketqc._panel_frame) -- the length/width/thickness axis ' 'sort could not be established for this piece.' .format(used)) return { 'weight_centroid_ft': {'x': round(x_ft, 4), 'y': round(y_ft, 4), 'z': round(z_ft, 4)}, 'weight_centroid_ft_basis': basis, } except Exception: return {} def _read_elevations(el, length_ft=None): """{'top_elevation_ft', 'bottom_elevation_ft', 'height_ft', 'top_elevation_ft_basis'} off the element's own WORLD-space bounding box, or {}. Deliberately NOT the local panel frame `_read_geometry` uses -- that frame answers "how long/wide/thick is this piece" independent of how it sits in the model, which is exactly wrong here: Eriksson's Top/Bottom Elevation are where the piece actually SITS, in project elevation. A wall panel modelled in place (this tool's precast jobs place panels in the real building model, not just on a casting-bed sheet) already carries that as its own bounding box Z extent -- `el.get_BoundingBox(None)`, world coordinates, decimal feet, the same datum every Z coordinate in the model already uses. See `pa_elevation.elevations_from_bbox` for the arithmetic and for what the `length_ft` cross-check means. {} on any failure or a bounding box Revit could not compute -- never a fabricated elevation. """ try: bbox = el.get_BoundingBox(None) except Exception: bbox = None if bbox is None: return {} try: min_z = bbox.Min.Z max_z = bbox.Max.Z except Exception: return {} top, bottom, height, basis = pa_elevation.elevations_from_bbox( min_z, max_z, length_ft=length_ft) if top is None: return {} return { 'top_elevation_ft': round(top, 4), 'bottom_elevation_ft': round(bottom, 4), 'height_ft': round(height, 4), 'top_elevation_ft_basis': basis, 'bottom_elevation_ft_basis': basis, } def _read_restraints(reads): """{'restraints', 'restraints_basis'} off `supports_ft` + `bottom_elevation_ft`, already in `reads`, or `{'restraints_basis': why}` alone when there is nothing to convert -- the field stays blank but the reason is never lost. See `pa_elevation.restraints_from_supports`: this is the SAME embeds `pa_supports.read_supports` already measured, reported as elevations instead of a distance up from the erected-lower end, never a second measurement. """ text, why = pa_elevation.restraints_from_supports( reads.get('supports_ft'), reads.get('bottom_elevation_ft'), height_ft=reads.get('height_ft'), length_ft=reads.get('length_ft')) out = {'restraints_basis': why} if text: out['restraints'] = text return out def _read_section_type(el, know): """'Sandwich' + wythe thicknesses, or {} (Solid / could-not-tell). MEASURED, not read off bounding boxes: a line along the piece's own THICKNESS axis at each of a grid of interior points, intersected with every solid of the body (`liftexport.section_profile`); the most common non-empty profile is the section -- a line through a window hits nothing and does not vote. A per-solid extent cannot do this: an outer wythe drawn with edge returns spans the whole thickness in its bounding box, so two wythes read as one (measured 2026-09-03: the layup said 0-11 / 0-7 in, the probe says 0-3 and 7-11). `section_from_profile` then merges touching intervals and calls the largest gap of 0.5 in or more the insulation. Extents are the UNION over every solid so z = 0 is the envelope floor whichever solid is largest. Anything unreadable returns {} and the caller's 'Solid' default stands (never a fabricated section). """ try: frame = ticketqc._panel_frame(el) if frame is None: return {} solids = liftexport.panel_solids(el) lo = None hi = None for s in solids: ext = ticketqc.extents_of_solid(s, frame) if ext is None: continue l = [v * 12.0 for v in ext[0]] h = [v * 12.0 for v in ext[1]] lo = l if lo is None else [min(a, b) for a, b in zip(lo, l)] hi = h if hi is None else [max(a, b) for a, b in zip(hi, h)] if lo is None: return {} axes = liftexport._axes(lo, hi) profile, hits, samples = liftexport.section_profile( solids, frame, axes, lo, hi) return liftexport.section_from_profile(profile) except Exception: return {} def _read_openings(el): """[row, ...] of opening/reveal dicts off the piece's geometry, or None. The Concrete Extents page renders `values['openings_rows']` -- one row per opening / reveal, the same row shape `panelanalysisform.parse_openings` produces from typed text -- but until this reader existed nothing ever FILLED that key, so the grouped table and the sketches showed nothing or garbage. This reads the holes the way Panel Lift Check does: the panel's concrete solid, its side faces clustered into rectangles (`liftexport._openings`), each probed through the thickness to tell a through opening from a blind recess (`liftexport.opening_penetration`). Distances are decimal FEET in the panel's own frame -- what the table (`pa_extents._feet_str`) and `panelsketch` both expect. `pos_along` is from the START of the length, `length` the along-length extent, `pos_vert` from the BOTTOM edge (up the width axis) and `height` its extent there. An opening goes through the FULL thickness, so its `pos_cross`/`width` are None -- there is nothing to position between the faces; a reveal is measured from one face inward: `pos_cross = 0.0` from 'Face', `width` its depth in feet. None -- never an empty list -- is the could-not-read answer: the geometry (frame / concrete solid / extents) could not be solved, so the caller cannot honestly claim "no openings" either. """ try: frame = ticketqc._panel_frame(el) if frame is None: return None solids = liftexport.panel_solids(el) solid = liftexport.concrete_solid(el, solids) if solid is None: return None ext = ticketqc.extents_of_solid(solid, frame) if ext is None: return None lo = [v * 12.0 for v in ext[0]] hi = [v * 12.0 for v in ext[1]] L, W, T = liftexport._axes(lo, hi) thickness_in = hi[T] - lo[T] rects = liftexport._openings(solid, frame, lo, hi, (L, W, T)) except Exception: return None # Drop the degenerate rectangles FIRST -- a face that is a line in one # direction (24 of 39 face rects on one member were such) is not a hole, # and keeping one would draw a phantom opening and wrong the counts. kept = [r for r in rects if (r[1] - r[0]) > 1e-6 and (r[3] - r[2]) > 1e-6] try: penetration = liftexport.opening_penetration( el, frame, solid, kept, (L, W, T), lo, hi) except Exception: penetration = [None] * len(kept) rows = [] n_open = 0 n_reveal = 0 for rect, p in zip(kept, penetration): x0, x1, y0, y1 = rect through = p.get('through') if p else None if through is True or through is None: # Through, or unjudged -- carried as a through opening, the # conservative reading `liftexport.split_openings` makes ("could # not tell" is not "blind": the through reading takes section # away, and standing it down on a failed solve would quietly make # a piece pass). n_open += 1 rows.append({ 'type': 'Opening', 'pos_along': round(x0, 4), 'pos_along_from': 'start', 'length': round(x1 - x0, 4), 'pos_cross': None, 'pos_cross_from': None, 'width': None, 'pos_vert': round(y0, 4), 'height': round(y1 - y0, 4), 'label': 'OPENING {0}'.format(n_open), }) else: concrete_in = p.get('concrete_in') depth_in = (None if concrete_in is None else round(thickness_in - concrete_in, 2)) n_reveal += 1 rows.append({ 'type': 'Reveal', 'pos_along': round(x0, 4), 'pos_along_from': 'start', 'length': round(x1 - x0, 4), 'pos_cross': 0.0, 'pos_cross_from': 'Face', 'width': None if depth_in is None else depth_in / 12.0, 'pos_vert': round(y0, 4), 'height': round(y1 - y0, 4), 'label': 'RECESS {0}'.format(n_reveal), }) rows.sort(key=lambda r: (r['pos_along'] if r['pos_along'] is not None else 0.0)) return rows def _param_sources(el): """The element, then the TYPE it is an instance of. Some roles name a TYPE parameter, not an instance one -- the plant's thickness role is mapped to a type parameter and says so in its own comment. `LookupParameter` on the instance simply does not see those, so reading only the instance left `thickness_in` blank on every piece while the value sat on the symbol the whole time (measured live 2026-09-02 on a real 8" horizontal wall: the role resolved to its parameter name and the instance read `None`). Instance FIRST: where a value exists in both places the instance is the one that was set for this piece, and a type value is the fallback the piece inherited. """ out = [el] try: type_el = el.Document.GetElement(el.GetTypeId()) if type_el is not None: out.append(type_el) except Exception: pass return out def _num_from_param(el, name): for owner in _param_sources(el): value = _num_from_one(owner, name) if value is not None: return value return None def _num_from_one(el, name): try: p = el.LookupParameter(name) except Exception: p = None if p is None: return None for reader in ('AsValueString', 'AsString'): try: txt = getattr(p, reader)() except Exception: txt = None if txt: head = '' for ch in str(txt).replace(',', ''): if ch.isdigit() or ch in '.-': head += ch elif head: break try: return float(head) except Exception: continue return None def _str_from_param(el, name): """One parameter's value as text, searching the instance then its type.""" for owner in _param_sources(el): value = _str_from_one(owner, name) if value is not None: return value return None def _str_from_one(el, name): """`AsString` first: on a text parameter `AsValueString` can return the formatted display of an empty value rather than nothing at all, and a blank that looks like a name is worse than no name.""" try: p = el.LookupParameter(name) except Exception: p = None if p is None: return None for reader in ('AsString', 'AsValueString'): try: txt = getattr(p, reader)() except Exception: txt = None if txt and str(txt).strip(): return str(txt).strip() return None def _param_num(p): """One Parameter object's value as a float, or None. AsValueString first (it carries the unit-formatted display), AsString as the fallback; the leading numeric run is parsed off, commas stripped -- the same reader `_num_from_one` uses, factored out so the name-pattern search can reuse it on a Parameter it already holds.""" for reader in ('AsValueString', 'AsString'): try: txt = getattr(p, reader)() except Exception: txt = None if txt: head = '' for ch in str(txt).replace(',', ''): if ch.isdigit() or ch in '.-': head += ch elif head: break try: return float(head) except Exception: continue return None def _param_str(p): """One Parameter object's value as trimmed text, or None.""" for reader in ('AsString', 'AsValueString'): try: txt = getattr(p, reader)() except Exception: txt = None if txt and str(txt).strip(): return str(txt).strip() return None def _iter_named_params(el): """(param, normalized_name) over the instance's params then its type's. The name is lower-cased and every underscore turned into a space, so a parameter whose name is upper-case-with-underscores (the common precast convention) still matches the plain-English fragments the pattern readers below search for. Generic on purpose: this keys on common engineering NAME fragments, never on one job's literal parameter name (owner, 2026-09-03 -- pre-fill must work on the next job with no code change).""" for owner in _param_sources(el): try: params = owner.Parameters except Exception: params = [] for p in params: try: name = p.Definition.Name.lower().replace('_', ' ').strip() except Exception: continue yield p, name def _pattern_num(el, patterns, exclude=()): """First numeric parameter whose normalized name contains one of `patterns` and none of `exclude`. None when nothing matches -- the could-not-read answer, left blank for the user, never a guessed value.""" for p, name in _iter_named_params(el): if any(x in name for x in exclude): continue if any(x in name for x in patterns): v = _param_num(p) if v is not None: return v return None def _pattern_str(el, patterns, exclude=()): """First text parameter whose normalized name contains one of `patterns` and none of `exclude`, or None.""" for p, name in _iter_named_params(el): if any(x in name for x in exclude): continue if any(x in name for x in patterns): v = _param_str(p) if v: return v return None def _role_str(el, know, role): """The first non-empty value among the parameter names a plant maps to `role`. Empty when the plant mapped none -- unmapped is not an error here, it is the field staying blank for the user to fill.""" if know is None: return None for name in knowledge.param_names(know, role): value = _str_from_param(el, name) if value: return value return None def _read_materials(el, know): """Best-effort f'c / f'ci / unit weight / mark off parameters, else blank. Material strengths are usually a per-design/sheet fact rather than a panel parameter, so this reads whatever role the plant mapped and otherwise leaves the field blank for the user -- the owner's rule, not an invented default. """ out = {} if know is not None: for key, roles in (('fc_psi', ('sheet.fc',)), ('fci_psi', ('sheet.fci',)), ('unit_weight_pcf', ('panel.unit_weight', 'sheet.unit_weight'))): for role in roles: for name in knowledge.param_names(know, role): v = _num_from_param(el, name) if v is not None: out[key] = v break if key in out: break for name in knowledge.param_names(know, 'panel.design_number') + \ knowledge.param_names(know, 'sheet.design_number'): try: dn = el.LookupParameter(name) dn = dn.AsString() if dn is not None else None except Exception: dn = None if dn: out['design_number'] = dn break # GENERIC, job-independent fallback (owner, 2026-09-03: "很多的预填充应该是 # 通用的代码,避免换一个活儿就得重来" -- pre-fill must be generic, not keyed # to one job's parameter names). Where the plant mapped no role -- or the # mapped role read blank -- fall back to case-insensitive NAME-PATTERN # matching over the instance and type parameters, on common precast # engineering terms only. Validated live on an MC job with no roles wired: # these recovered the 28-day and release strengths, the unit weight, the # cast weight, the piece mark and the design number off that job's own # parameters -- every value found by a generic term match, not one # job-specific literal named anywhere in this file. if 'fci_psi' not in out: v = _pattern_num(el, ("f'ci", u"f′ci", 'fci', 'release strength', 'transfer strength', 'initial strength', 'strength at release', 'release')) if v is not None: out['fci_psi'] = v if 'fc_psi' not in out: v = _pattern_num( el, ("f'c", u"f′c", '28 day', '28-day', 'final strength', 'design strength', 'specified strength'), exclude=('release', 'transfer', 'initial', 'fci', "f'ci", u"f′ci", 'at release')) if v is not None: out['fc_psi'] = v if 'unit_weight_pcf' not in out: v = _pattern_num(el, ('unit weight', 'weight per', 'density', 'pcf')) if v is not None: out['unit_weight_pcf'] = v if 'weight_lb' not in out: v = _pattern_num(el, ('member weight', 'weight cast', 'panel weight', 'piece weight', 'total weight'), exclude=('per', 'unit')) if v is not None: out['weight_lb'] = v if 'concrete_type' not in out: s = _pattern_str(el, ('concrete type', 'lightweight', 'normalweight', 'weight class')) if s: out['concrete_type'] = s if 'design_number' not in out: s = _pattern_str(el, ('design number', 'design sheet', 'sheet number')) if s: out['design_number'] = s # No concrete has f'c / f'ci below 100 psi, so a value that small was read # off a parameter carrying ksi (e.g. 8 for 8 ksi); normalize it to psi so # the psi-keyed field is consistent. A safe, code-based unit fix, not an # invented value. for k in ('fc_psi', 'fci_psi'): v = out.get(k) if isinstance(v, (int, float)) and 0 < v < 100: out[k] = v * 1000.0 # The piece mark, through the same role the rest of the tool reads it by # (`ticketqc._member_mark`), NOT the built-in `Mark` alone. On this # plant's template the built-in is empty and the mark lives in the # parameter the plant maps to `member.mark` -- read only the built-in and # every MC panel comes up nameless, which is what put `panel 1 (id # 123456)` in the form's header where `MWH116` belonged. # Built-in `Mark` is the universal name (owner, 2026-09-03); the plant # `member.mark` role is only an accelerator where it is mapped. When both # are empty -- some jobs store the mark in a plant parameter and leave the # built-in blank -- fall back to the generic 'mark' name-pattern, which # finds any parameter whose name carries 'mark' without naming a specific # one; excludes the unrelated 'type mark' and a handful of other # '...mark' words. mark = (_role_str(el, know, 'member.mark') or _str_from_param(el, 'Mark') or _pattern_str(el, ('mark',), exclude=('type mark', 'benchmark', 'remark', 'watermark', 'bookmark'))) if mark: out['mark'] = mark # A CONTROL NUMBER is the second name a piece has: the mark says which # design it is, the control number says which physical piece. A plant # that records one maps `member.control_number`; one that does not gets # no parenthetical rather than a guessed one. Suppressed when it is the # same string as the mark -- a plant whose mark parameter IS its control # number (this one maps `member.mark` to the plant's mark parameter) should see the # name once, not printed twice with a bracket round the copy. control = (_role_str(el, know, 'member.control_number') or _pattern_str(el, ('control number', 'control no', 'piece number'))) if control and control != mark: out['control_number'] = control return out # Revit's own factory template ships these ProjectInformation built-ins # carrying their OWN LABEL as placeholder text until an engineer actually # fills them in -- 'Project Status' reads back the literal text "Project # Status", 'Project Issue Date' reads back "Issue Date". Generic (a Revit # template default, not one job's data): a value equal to its field's own # known placeholder is treated the same as blank, never shown as though it # were real project data. _PROJECT_INFO_PLACEHOLDERS = { 'Project Status': 'Project Status', 'Project Issue Date': 'Issue Date', } def _read_project_info(el, know=None): """Project name / client / design date / location / description / engineer-of-record for the Design Criteria header. Project name and client come off the model's Project Information (the same header Eriksson's page 1 carries); the design date defaults to today, as the model has no reliable design-date parameter. Doc-level -- identical for every panel -- and blank where the model does not carry it. `project_location` / `project_description` / `engineer_initials` are new (2026-09-04): the owner's list of fields that came up blank on WPA655's morning run even though the reads had been wired the night before. Each carries its own `_basis` sentence -- which built-in (or plant role) actually supplied it -- so a blank field on a job whose template carries none of these still says why rather than looking like an oversight. """ out = {} try: pinfo = el.Document.ProjectInformation except Exception: pinfo = None if pinfo is not None: def _pi(name): try: p = pinfo.LookupParameter(name) v = p.AsString() if p is not None else None v = v.strip() if v and v.strip() else None except Exception: return None if v is not None and _PROJECT_INFO_PLACEHOLDERS.get(name) == v: # Unedited Revit template default -- not a real value. return None return v pn = _pi('Project Name') if pn: out['project_name'] = pn # The "client" on a precast shop job is the PLANT this design is # produced for -- the precast manufacturer / producing plant named on # the model's Project Information -- not the building owner in the # built-in Client Name (owner, 2026-09-03: "client name = plant name"). # Read it generically by NAME PATTERN so it works on any job's # template, falling back to the built-in only when no plant field is # present. The plant name is live model data shown at run time, never # written into this file (scrub). cn = None try: for p in pinfo.Parameters: try: nm = p.Definition.Name.lower().replace('_', ' ') except Exception: continue if ('precast manufacturer' in nm or 'producing plant' in nm or 'precast plant' in nm or 'manufacturer' in nm): v = p.AsString() if p is not None else None if v and v.strip(): cn = v.strip() break except Exception: cn = None if not cn: cn = _pi('Client Name') if cn: out['client_name'] = cn # project_location -- the built-in 'Project Address' first (the # standard Revit ProjectInformation field; live-verified non-empty on # sample_job), else a generic NAME-PATTERN fallback for a template # that calls it something else. loc = _pi('Project Address') loc_basis = "ProjectInformation's 'Project Address'" if not loc: loc, loc_basis = None, None try: for p in pinfo.Parameters: try: nm = p.Definition.Name.lower().replace('_', ' ') except Exception: continue if ('address' in nm or 'location' in nm or 'site' in nm): v = p.AsString() if p is not None else None if v and v.strip(): loc = v.strip() loc_basis = ("name-pattern match on '{0}'" .format(p.Definition.Name)) break except Exception: loc = None if loc: out['project_location'] = loc out['project_location_basis'] = loc_basis else: out['project_location_basis'] = ( 'no Project Address and no address/location/site-named ' 'parameter on ProjectInformation') # project_description -- 'Project Status' first (Eriksson's own # header carries a status line), else 'Building Name'; either is # blank once the template-default placeholder is filtered out # above. Named which one supplied it, or that neither did. status = _pi('Project Status') if status: out['project_description'] = status out['project_description_basis'] = ( "ProjectInformation's 'Project Status'") else: bname = _pi('Building Name') if bname: out['project_description'] = bname out['project_description_basis'] = ( "ProjectInformation's 'Building Name' ('Project " "Status' was blank or its unedited template default)") else: out['project_description_basis'] = ( "'Project Status' and 'Building Name' are both blank " 'or carry only their unedited template defaults') # engineer_initials -- the plant's own role, when mapped; a # person's identity is never guessed at by name pattern (the same # rule CLAUDE.md states for client / job names), so there is no # generic fallback here -- blank means the plant has not named one. initials = None if know is not None: try: initials = knowledge.get( know, 'panelanalysis.engineer_initials') except Exception: initials = None if initials is not None: initials = str(initials).strip() or None if initials: out['engineer_initials'] = initials out['engineer_initials_basis'] = ( 'plant knowledge panelanalysis.engineer_initials') else: out['engineer_initials_basis'] = ( 'no panelanalysis.engineer_initials in the merged ' 'knowledge -- an identity is never guessed at') try: import System out['design_date'] = System.DateTime.Now.ToString('yyyy-MM-dd') except Exception: pass return out _STRAND_RAIL_LABELS = { 'left_rail': 'Left edge', 'right_rail': 'Right edge', 'centerline': 'Centerline', } def _read_strand_rail_location(know): """{'strand_rail_location', 'strand_rail_location_basis'} off the plant's `rails.strand_datum`, or `{'strand_rail_location_basis': why}` alone when the plant has not named one. Which rail is fixed is plant knowledge, never inferred from geometry (see the strand-layout note this mirrors) -- a plant that has not stated `rails.strand_datum` leaves this blank rather than guessing which edge. A sibling `pa_defaults` module may fill the same field from the same plant key as a DEFAULT rather than a read; harmless either way, since `panelanalysisform.prefill` keeps the first non-blank value. """ if know is None: return {'strand_rail_location_basis': 'no knowledge layer loaded for this document'} try: datum = knowledge.get(know, 'rails.strand_datum') except Exception: datum = None if datum is None: return {'strand_rail_location_basis': 'no rails.strand_datum in the merged knowledge -- which ' 'rail is fixed is plant knowledge, never inferred'} label = _STRAND_RAIL_LABELS.get(str(datum).strip().lower()) if not label: return {'strand_rail_location_basis': 'rails.strand_datum is {0!r}, which is not one of ' 'left_rail / right_rail / centerline'.format(datum)} return {'strand_rail_location': label, 'strand_rail_location_basis': 'plant knowledge rails.strand_datum'} def _spec_reinforcement(el, design_number): """PATH A -- reinforcement off this design's SPEC file, or {}. A bare structural-framing element carries no reinforcement of its own: the bar and strand are separate elements, and on a piece selected without its assembly there is nothing to walk. What there IS, once Design Extract has run for this design, is a spec file keyed by the design number, carrying the strand groups, the mesh and the mild grade exactly as they were read off the design sheet. That is the same number a person would read. Everything is optional and everything is defensive: no design number, no spec file, or a spec that does not carry a field, and that field simply stays blank. Nothing is defaulted to zero -- a zero strand count is a statement that the panel has no strand, which is not what "no spec" means. """ if not design_number: return {} try: from vibe import specformat spec = designspec.load_spec(el.Document, design_number) except Exception: return {} if not spec: return {} out = {} def _get(path, default=None): try: return specformat.get_path(spec, path, default) except Exception: return default groups = _get('reinforcing.strand_groups', []) or [] total = 0 dia = None grade = None pull_lb = None for group in groups: if not isinstance(group, dict): continue qty = group.get('qty') try: qty = int(qty) except (TypeError, ValueError): qty = None if qty and qty > 0: total += qty if dia is None and group.get('dia_in') is not None: dia = group.get('dia_in') if grade is None and group.get('grade_ksi') is not None: grade = group.get('grade_ksi') # `it_lb` is an initial tension in POUNDS -- a force, not a percentage. # It fills the per-strand pull directly. The pull PERCENT is left blank # rather than back-computed: that needs fpu AND the strand area, and a # percentage derived from a guessed area is a fabricated jacking force. if pull_lb is None and group.get('it_lb') is not None: pull_lb = group.get('it_lb') if total > 0: out['ps_count'] = total if dia is not None: out['ps_dia_in'] = dia if grade is not None: out['ps_fpu_ksi'] = grade if pull_lb is not None: out['ps_pull_lb'] = pull_lb mesh = _get('reinforcing.wwf.mesh') layers = _get('reinforcing.wwf.layers') per_face = _get('reinforcing.wwf.per_face') if mesh: text = str(mesh) extra = [] if layers is not None: extra.append('{0} layer(s)'.format(layers)) if per_face is not None: extra.append('{0} per face'.format(per_face)) if extra: text += ', ' + ', '.join(extra) out['mesh'] = text mild = _get('reinforcing.mild_grade_ksi') if mild is not None: out['rebar_fy_ksi'] = mild for key, path in (('fc_psi', 'concrete.fc_psi'), ('fci_psi', 'concrete.fci_psi'), ('unit_weight_pcf', 'concrete.unit_weight_pcf')): value = _get(path) if value is not None: out[key] = value return out def _assembly_reinforcement(el, asm, know): """PATH B -- reinforcement counted off the ASSEMBLY's members, or {}. When the piece was selected with its assembly, the strand, mesh and bar are real elements sitting in it, and counting them is a direct measurement rather than a document read. What the assembly canNOT say is how hard the strand was pulled or what grade it is -- that is a design decision, not a geometric fact -- so those come from the spec (Path A) and are never invented here. `reinforcing_verdict` returning None means "nothing here can judge whether that member is reinforcing", which is NOT "it is not reinforcing"; such a member is left out of the count and the field is left blank rather than reported as a smaller number that would look measured. """ if asm is None: return {} try: from vibe import ticketqc as tq from vibe import knowledge as K except Exception: return {} try: frame = tq._panel_frame(el) boxes = tq.member_boxes(el.Document, asm, el, frame, know) or [] except Exception: return {} if not boxes: return {} try: wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF') except Exception: wwf_prefix = 'WWF' wwf_prefix = wwf_prefix.upper() strands = [] mesh = [] rebar = [] for b in boxes: family = b.get('family') or '' try: if K.is_strand(know, family): strands.append(b) continue except Exception: pass if family.upper().startswith(wwf_prefix): mesh.append(b) continue try: verdict = K.reinforcing_verdict(know, family)[0] except Exception: verdict = None if verdict is True: rebar.append(b) out = {} if strands: out['ps_count'] = len(strands) for b in strands: try: dia = tq.strand_diameter(b.get('family'), b.get('mark')) except Exception: dia = None if dia: out['ps_dia_in'] = dia break if mesh: names = [] for b in mesh: name = b.get('family') if name and name not in names: names.append(name) if names: out['mesh'] = ', '.join(names) if rebar: marks = [] for b in rebar: mark = b.get('mark') or b.get('family') if mark and mark not in marks: marks.append(mark) if marks: out['mild_as'] = ', '.join(marks) return out def _read_reinforcement(el, asm, know, design_number): """The Reinforcement pre-fill, both paths, spec first then the assembly. The two do not compete for the same facts: the spec knows the design intent (grade, tension, mesh callout) and the assembly knows what was actually modelled (how many strands there really are). Where both answer, the ASSEMBLY wins on counts and sizes -- it is the measurement -- and the spec keeps the fields it is the only source for. Either path failing entirely leaves the other's answer standing; both failing leaves the whole section blank, which is the honest result and what the user then types. """ out = {} try: out.update(_spec_reinforcement(el, design_number)) except Exception: pass try: measured = _assembly_reinforcement(el, asm, know) except Exception: measured = {} for key, value in measured.items(): if value is not None: out[key] = value return out # The name fragments a job number hides behind, normalized (lower case, # underscores turned into spaces) the way `_iter_named_params` normalizes. NO # job's literal parameter name appears here -- these are the generic English # terms a title block uses, so the next job pre-fills with no code change # (owner, 2026-09-03). _JOB_NUMBER_PATTERNS = ('job number', 'job no', 'designer job', 'job #') # Which sheet carries it. A precast job's number is printed on the Design # Summary sheet's title block; the sheet is found by a generic NAME fragment, # never by a sheet number this job happens to use. _DESIGN_SUMMARY_SHEET = 'design summary' def _read_job_number(el): """The job's tracking number, or None. Never raises. The FIRST source is the model's own Project Number -- ProjectInformation's built-in `Project Number` parameter (owner, 2026-09-03: job_number = the model's Project Number). Only where that is empty does the read fall back to the existing reads, unchanged: the Design Summary sheet's title block, then name-pattern matches on Project Information. Nothing is guessed: no value anywhere, and the field simply comes up blank for the user to type. """ try: document = el.Document except Exception: return None # FIRST SOURCE -- the model's built-in Project Number (owner, 2026-09-03). try: pinfo = document.ProjectInformation except Exception: pinfo = None if pinfo is not None: try: p = pinfo.LookupParameter('Project Number') value = p.AsString() if p is not None else None except Exception: value = None if value and str(value).strip(): return str(value).strip() # FALLBACK -- the Design Summary sheet's title block, then name-pattern # matches on Project Information. Both unchanged from before the ruling. try: blocks = (FilteredElementCollector(document) .OfCategory(BuiltInCategory.OST_TitleBlocks) .WhereElementIsNotElementType() .ToElements()) except Exception: blocks = [] for tb in blocks: try: sheet_name = (_str_from_one(tb, 'Sheet Name') or '') except Exception: sheet_name = '' if _DESIGN_SUMMARY_SHEET not in sheet_name.lower(): continue value = _pattern_str(tb, _JOB_NUMBER_PATTERNS) if value: return value if pinfo is not None: value = _pattern_str(pinfo, _JOB_NUMBER_PATTERNS) if value: return value return None # `ticketqc.collect_context` / `assemblies_by_design` each walk every sheet / # assembly in the document -- fine once, wasteful once PER PANEL when several # are opened together. Cached here, keyed on the document itself, so a # multi-panel Panel Analysis run pays for the scan once no matter how many # panels' `_reads_for` calls follow. `liftexport.export` (the actual geometry # read) is NOT cached here -- it runs once per panel by construction, because # `_reads_for` itself is only ever called once per panel (see `PanelForm. # __init__` / the `forms_list` comprehension at the bottom of this file). _LIFTEXPORT_CTX = {'doc': None, 'sheets': {}, 'groups': {}} def _sheets_and_design_groups(doc, know): """(sheets, groups) from `ticketqc`, cached per document. `sheets` is `collect_context`'s second return (sheet name -> [ViewSheet], see that function's docstring for the summaries-vs-design-number convention it does NOT apply here). `groups` is `assemblies_by_design`'s design key -> [AssemblyInstance] map. Either failing leaves that half empty (`{}`), never raises -- a caller that cannot find sheets or design groups just gets no `design` to hand `liftexport.export`, which already treats a missing design as "unknown" rather than a fatal input. """ if _LIFTEXPORT_CTX['doc'] is doc: return _LIFTEXPORT_CTX['sheets'], _LIFTEXPORT_CTX['groups'] try: _summaries, sheets = ticketqc.collect_context(doc) except Exception: sheets = {} try: groups = ticketqc.assemblies_by_design(doc, know) or {} except Exception: groups = {} _LIFTEXPORT_CTX['doc'] = doc _LIFTEXPORT_CTX['sheets'] = sheets _LIFTEXPORT_CTX['groups'] = groups return sheets, groups def _design_key_for(asm, groups): """The design key `asm` belongs to in `groups`, or None -- same lookup `PanelLiftCheck`'s single-piece path uses (find the group whose members include this assembly's id), not `reads['design_number']`: that field is the RAW parameter string (e.g. 'WP1.0'), while `assemblies_by_design`'s keys are already reduced through `designspec.design_key` (e.g. 'WP1'), and `liftexport.export` expects the reduced form. """ try: for key, group in groups.items(): for member in group: if member.Id == asm.Id: return key except Exception: pass return None def _liftexport_job_for(el, asm, know): """The Panel Lift `job` dict for `asm`, or None -- one `liftexport.export` call, the single expensive geometry read `pa_lifters` / `pa_reinf`'s prestress half both need. Never raises: any failure (no python_exe configured, export's own `problems`, an exception in the measurement itself) comes back as None, and every caller already treats a None job as "nothing to pre-fill from Panel Lift" rather than an error to surface here -- the Run Analysis button, not this pre-fill pass, is where a real export failure belongs on screen. """ try: document = el.Document sheets, groups = _sheets_and_design_groups(document, know) design = _design_key_for(asm, groups) job, _problems = liftexport.export(document, asm, know, sheets, design) except Exception: return None return job def _reads_for(el, index, know, asm=None): reads = {} reads.update(_read_geometry(el)) reads.update(_read_materials(el, know)) reads.update(_read_project_info(el, know)) try: reads.update(_read_elevations(el, reads.get('length_ft'))) except Exception: pass reads.update(_read_strand_rail_location(know)) # The plant's own stress limits and factors, when its knowledge layer # pins them (`panelanalysis.settings.*`); the generic code values in # `PREFILL_DEFAULTS` stand in for whatever it leaves out. try: reads.update(F.settings_from_plant( knowledge.get(know, 'panelanalysis.settings'))) except Exception: pass # Reinforcement, both paths. Applied with `setdefault` so a value the # materials read already recovered off the panel's own parameters is not # replaced by the spec's copy of it -- the piece wins over the sheet. try: reinf = _read_reinforcement(el, asm, know, reads.get('design_number')) except Exception: reinf = {} for key, value in (reinf or {}).items(): if value is not None and reads.get(key) is None: reads[key] = value # Model-measured pre-fill: support embeds, face/edge lifters, and the # strand + rebar summary Panel Lift's own read produces -- one # `liftexport.export` call (the expensive geometry read) shared by the # lifters and prestress halves, exactly the way the button's own single- # panel run shares it. Each of the three reads is independently # defensive ({} on anything it cannot measure) and this call site adds no # exception handling on top that would hide which one failed; the merge # itself follows `pa_prefill.merge_model_reads`'s rule -- a value already # in `reads` wins, except `supports_ft` / `face_lifters` / `edge_lifters` # / `ps_rows` / `mild_as`, where the new measurement always wins (see # that module's docstring for why `mild_as` needs the override: it is # replacing `_assembly_reinforcement`'s cruder marks-list under the same # key). Only runs at all when there is an assembly to measure. job = None if asm is not None: try: job = _liftexport_job_for(el, asm, know) except Exception: job = None try: supports = pa_supports.read_supports(el, asm, know) except Exception: supports = {} try: lifters = pa_lifters.read_lifters(job, know) except Exception: lifters = {} try: dunnage = pa_lifters.read_dunnage(job) except Exception: dunnage = {} try: prestress = (job or {}).get('panel', {}).get('prestress') \ if job else None reinf2 = pa_reinf.read_reinforcement_prefill( el, asm, know, prestress=prestress, fpu_ksi=reads.get('ps_fpu_ksi')) except Exception: reinf2 = {} try: centroid = _read_weight_centroid(el, asm, know) except Exception: centroid = {} pa_prefill.merge_model_reads( reads, (supports, lifters, dunnage, reinf2, centroid)) # Restraints -- the SAME support embeds `supports_ft` above just # measured, re-expressed as elevations off this panel's own # `bottom_elevation_ft` (see `pa_elevation.restraints_from_supports`). # Runs after the `supports_ft` merge above, so a plant that names no # support families -- `supports_ft` stays blank -- gets nothing to # convert and `restraints_basis` says so, rather than this section # inventing an elevation. try: reads.update(_read_restraints(reads)) except Exception: pass job_number = None try: job_number = _read_job_number(el) except Exception: job_number = None if job_number: reads['job_number'] = job_number if not reads.get('mark'): # Nothing named this piece. An element id is a poor name but it is a # TRUE one, and it is the only handle that still points at the right # piece when two panels are equally nameless -- so it stays as the # last resort, and the header says outright that no mark was read # rather than letting a number look like a mark. try: reads['mark'] = 'unnamed panel {0} (element id {1})'.format( index + 1, el.Id.IntegerValue) except Exception: reads['mark'] = 'unnamed panel {0}'.format(index + 1) reads.setdefault('section_type', 'Solid') # A Sandwich (insulated) piece needs its Concrete Extents given per wythe # and carries wythe-2 materials; detection runs Panel Lift's own concrete # decision and only flips the section to Sandwich when that decision says # two+ concrete layers exist. On any failure it returns {} and the # 'Solid' default above stands (never an invented sandwich). try: section = _read_section_type(el, know) except Exception: section = {} if section: reads['section_type'] = section.get('section_type') or 'Solid' for key in ('wythe_top_in', 'insulation_in', 'wythe_bottom_in'): if section.get(key) is not None: reads[key] = section[key] # The Concrete Extents page's grouped openings table and the sketches read # `values['openings_rows']`; nothing filled it until `_read_openings`. # None = the geometry could not be read, and the key then stays ABSENT so # the caller cannot mistake "unreadable" for "no openings". The legacy # free-text `openings` field (the sketch's note still reads it) is set to a # real summary sentence only when there is something to summarise -- # NEVER fed into the form's `openings` editor, because `validate` re-parses # that field as typed CSV rows and a summary sentence would hard-block the # Run button. rows = None try: rows = _read_openings(el) except Exception: rows = None if rows is not None: reads['openings_rows'] = rows n_open = len([r for r in rows if r['type'] == 'Opening']) n_reveal = len([r for r in rows if r['type'] == 'Reveal']) if n_open or n_reveal: reads['openings'] = '{0} opening(s), {1} reveal(s)'.format( n_open, n_reveal) return reads def panel_title(reads): """The line that heads one panel's section: `MARK (CONTROL)`. Kept apart from `reads['mark']`, which names the run's output files and must stay a single stable token. """ mark = reads.get('mark') or '' control = reads.get('control_number') if mark and control: return u'{0} ({1})'.format(mark, control) return mark # ---------------------------------------------------------------- running REPO_ROOT = os.path.dirname(os.path.dirname(liftexport.panellift_src())) PANELANALYSIS_PY = os.path.join(REPO_ROOT, 'tools', 'panelanalysis.py') # What the CPython side of this button has to be able to import before it is # worth starting: tools/panelanalysis.py and tools/deckcheck.py (which # tools/pa_web.py imports first) both `import yaml` at module scope and # `sys.exit(2)` without it -- before pa_web has printed its URL, so the only # symptom was "did not report a URL in time" (Gitea #16). find_python_report # runs each candidate interpreter against this list and takes the first that # passes, not the first that exists. PA_NEEDS = ('yaml',) def _runner_script(exe, script_path, job, out, log): return (u'@echo off\r\n' u'chcp 65001 >nul\r\n' u'set "PYTHONIOENCODING=utf-8"\r\n' u'"{exe}" "{script}" "{job}" -o "{out}" > "{log}" 2>&1\r\n' u'exit /b %ERRORLEVEL%\r\n').format( exe=exe, script=script_path, job=job, out=out, log=log) def run_panelanalysis(exe, script_path, job, out, timeout_ms=120000): """(exit_code, log_text). One-line cmd wrapper redirecting to a file -- reading one pipe while the child blocks on the other is a deadlock, not a slow run (see liftexport.run()'s docstring for the incident).""" from System.Diagnostics import Process, ProcessStartInfo base = os.path.splitext(out)[0] log_path = base + '.log' runner = base + '.run.cmd' with io.open(runner, 'w', encoding='utf-8') as fh: fh.write(_runner_script(exe, script_path, job, out, log_path)) 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 waited >= timeout_ms: try: process.Kill() except Exception: pass return -1, ('panel analysis did not finish in {0} seconds' .format(timeout_ms // 1000)) def _read(path): try: with io.open(path, 'r', encoding='utf-8', errors='replace') as fh: return fh.read() except Exception: return '' log_text = _read(log_path) for path in (runner, log_path): try: os.remove(path) except Exception: pass return process.ExitCode, log_text def _safe_name(mark): out = '' for ch in str(mark): out += ch if (ch.isalnum() or ch in '-_.') else '_' return out or 'panel' def _run_one(mark, deck): """(ok, message). Builds the deck file, solves it, opens the page.""" python_exe, tried = liftexport.find_python_report(KNOW, PA_NEEDS) if not python_exe: return False, ('The page renders outside Revit, and ' + liftexport.describe_python_search(tried, PA_NEEDS)) if not os.path.isfile(PANELANALYSIS_PY): return False, 'tools/panelanalysis.py was not found at {0}.'.format( PANELANALYSIS_PY) start_dir = designspec.sub_dir(doc, 'panelanalysis') stem = _safe_name(mark) job_path = os.path.join(start_dir, '{0}.pa.json'.format(stem)) out_path = os.path.join(start_dir, '{0}.panelanalysis.html'.format(stem)) blob = json.dumps(deck, indent=1, sort_keys=True) with io.open(job_path, 'w', encoding='utf-8') as fh: fh.write(as_text(blob)) code, log_text = run_panelanalysis(python_exe, PANELANALYSIS_PY, job_path, out_path) if code != 0 or not os.path.isfile(out_path): return False, ('the page could not be rendered (exit {0}).\n\n{1}\n\n' 'Deck kept at:\n{2}'.format(code, log_text or 'no ' 'output', job_path)) liftexport.open_file(out_path) return True, out_path # ------------------------------------------------------------- pa_web (live) # # Owner scope update, 2026-09-05: Revit is only an optional PREFILL plug-in # now. The actual input form (前端) and results page (后端) are HTML, served # by `tools/pa_web.py` (a local CPython HTTP server) and shown in a WebView2 # window (`vibe.webhost.WebHost`) -- see `docs/pa-web-spec.md`. This button's # whole job per panel is: build `reads`, write them to a JSON file, launch the # server, pop the window, and clean the server up when the window closes. PA_WEB_PY = os.path.join(REPO_ROOT, 'tools', 'pa_web.py') def _launch_pa_web(python_exe, reads_path, timeout_s=20): """(process, url_or_None, err_lines). Starts `tools/pa_web.py --reads reads_path --port 0` detached and reads its stdout ASYNCHRONOUSLY -- .NET's OutputDataReceived/BeginOutputReadLine, not a second blocking read after this one -- which is exactly the deadlock `liftexport.run()`'s own docstring warns about (the child blocks writing one stream while this waits on the other and neither ever moves). That pattern is safe here because pa_web.py is a long-running SERVER, not a one-shot subprocess this can wait to exit before reading its output -- the port has to be read WHILE it keeps running. Polls `out_lines` for the `PA_WEB_URL=` line up to `timeout_s`; `url` is None on timeout or an early exit, and the caller decides what to do with the still-live `process` in that case (kill it; nothing else can). """ from System.Diagnostics import Process, ProcessStartInfo from System.Threading import Thread info = ProcessStartInfo() info.FileName = python_exe info.Arguments = u'"{0}" --reads "{1}" --port 0'.format( PA_WEB_PY, reads_path) info.UseShellExecute = False info.CreateNoWindow = True info.RedirectStandardOutput = True info.RedirectStandardError = True out_lines = [] err_lines = [] def _on_out(sender, e): if e.Data: out_lines.append(e.Data) def _on_err(sender, e): if e.Data: err_lines.append(e.Data) process = Process() process.StartInfo = info process.OutputDataReceived += _on_out process.ErrorDataReceived += _on_err process.Start() process.BeginOutputReadLine() process.BeginErrorReadLine() url = None waited = 0 step_ms = 100 while waited < timeout_s * 1000: for line in out_lines: if line.startswith('PA_WEB_URL='): url = line[len('PA_WEB_URL='):].strip() break if url: break try: if process.HasExited: break except Exception: break Thread.Sleep(step_ms) waited += step_ms return process, url, err_lines def _post_close(url): """Tells the server to shut itself down. Best-effort: the caller kills the process outright a few seconds later regardless (see `_run_panel_web`), so a failed POST here (a transient connection error, the server already gone) is never fatal.""" from System.Net import WebClient close_url = url.rstrip('/') + '/api/close' client = WebClient() client.Headers.Add('Content-Type', 'application/json') client.UploadString(close_url, 'POST', '{}') def _on_page_message(host, obj): """The cheap postMessage channel `vibe.webhost` keeps alive for exactly this (see that module's own OWNER SCOPE UPDATE note): `{'type': 'close'}` closes the window from inside the page itself, and `{'type': 'log', 'text': ...}` is free-form page-side diagnostic text, surfaced the way any other tool logs a client-side problem -- never raised, never blocked on. """ try: if not isinstance(obj, dict): return kind = obj.get('type') if kind == 'close': host.close() elif kind == 'log': output.print_md('- pa_web page: {0}'.format(obj.get('text'))) except Exception: pass def _run_panel_web(el, index, know, asm=None): """Builds one panel's reads, launches its own `tools/pa_web.py` server, and shows the page. Returns the mark that ran (for the caller's run-log), or None when nothing could be started at all. One server PER PANEL, shown modally (`show_dialog` blocks) one after the other when more than one panel is selected -- the simplest faithful reading of the new architecture; the old "Analyze All" batch UX was a WPF-form convenience and is not reproduced here. """ reads = _reads_for(el, index, know, asm) mark = reads.get('mark') or 'unnamed panel {0}'.format(index + 1) reads['job_root'] = designspec.root_dir(doc) pa_dir = designspec.sub_dir(doc, 'panelanalysis') stem = _safe_name(mark) reads_path = os.path.join(pa_dir, '{0}.reads.json'.format(stem)) blob = json.dumps(reads, indent=1, sort_keys=True) with io.open(reads_path, 'w', encoding='utf-8') as fh: fh.write(as_text(blob)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='opened', values=reads) python_exe, tried = liftexport.find_python_report(know, PA_NEEDS) if not python_exe: msg = ('The page renders outside Revit, and ' + liftexport.describe_python_search(tried, PA_NEEDS)) output.print_md('- **{0}** -- NOT RUN: {1}'.format(mark, msg)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='error', report_text=msg) return None if not os.path.isfile(PA_WEB_PY): msg = 'tools/pa_web.py was not found at {0}.'.format(PA_WEB_PY) output.print_md('- **{0}** -- NOT RUN: {1}'.format(mark, msg)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='error', report_text=msg) return None process, url, err_lines = _launch_pa_web(python_exe, reads_path) if not url: msg = 'pa_web.py did not report a URL in time.' if err_lines: msg += ' Last output: ' + ' | '.join(err_lines[-10:]) output.print_md('- **{0}** -- NOT RUN: {1}'.format(mark, msg)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='error', report_text=msg) try: if not process.HasExited: process.Kill() except Exception: pass return None fallback_reason = None try: host = WebHost(u'Panel Analysis – {0}'.format(mark), url, _on_page_message) if host.init_error: fallback_reason = host.init_error else: host.show_dialog() except WebHostError as exc: fallback_reason = str(exc) if fallback_reason: liftexport.open_file(url) # UseShellExecute=True opens a URL fine forms.alert( 'WebView2 could not be started ({0}). Opened {1} in your ' 'default browser instead -- the server keeps running until ' 'you press Close on the page.'.format(fallback_reason, url), title=TITLE) output.print_md('- **{0}** -- opened in the default browser ' '(WebView2 unavailable): {1}'.format(mark, url)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='ok', report_text='opened in browser: ' + url) return mark # The window closed. Tell the server to shut down, then give it a few # seconds before killing it outright -- the same "best-effort, then # force" shape `run_panelanalysis` uses for the one-shot CLI above. from System.Threading import Thread try: _post_close(url) except Exception: pass waited = 0 while waited < 3000: try: if process.HasExited: break except Exception: break Thread.Sleep(100) waited += 100 try: if not process.HasExited: process.Kill() except Exception: pass output.print_md('- **{0}** -- closed.'.format(mark)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='closed') return mark # ------------------------------------------------------------- WPF (retired) # # Everything below, down to `show_form`, is the retired WPF renderer -- kept # so the owner can compare it against the HTML page once, never imported or # called while `_USE_WPF` is False (see that constant's own note above). The # import block itself is the one part that MUST be gated: these names are # only referenced inside function/method BODIES below, which Python does not # evaluate until called, but `clr.AddReference` + the imports themselves run # at load time regardless of whether anything calls into them afterwards. if _USE_WPF: clr.AddReference('PresentationFramework') clr.AddReference('PresentationCore') clr.AddReference('WindowsBase') from System.Windows import (Window, Thickness, FontWeights, FontStyles, TextWrapping, TextDecorations, SizeToContent, WindowStartupLocation, HorizontalAlignment, VerticalAlignment, Visibility) from System.Windows.Controls import (StackPanel, ScrollViewer, Expander, TextBox, TextBlock, Button, Border, Orientation, ScrollBarVisibility, Canvas, Grid, ColumnDefinition, RowDefinition, TabControl, TabItem, CheckBox, ComboBox, Viewbox, TextChangedEventHandler) from System.Windows.Controls import Label as WpfLabel from System.Windows.Controls.Primitives import TextBoxBase from System.Windows.Documents import Run from System.Windows.Controls import StretchDirection from System.Windows.Media import (Brushes, RotateTransform, ScaleTransform, Stretch, DoubleCollection, ColorConverter, SolidColorBrush) from System.Windows.Input import Keyboard, ModifierKeys, Cursors from System.Windows.Shapes import Line, Polygon, Rectangle, Ellipse from System.Windows import (Point, BaselineAlignment, GridLength, GridUnitType) from System.Windows.Media import PointCollection from System.Windows.Threading import DispatcherPriority from System import Action def _label_block(text, bold=False, size=12, brush=None): tb = TextBlock() tb.Text = text tb.TextWrapping = TextWrapping.Wrap tb.FontSize = size if bold: tb.FontWeight = FontWeights.Bold if brush is not None: tb.Foreground = brush tb.Margin = Thickness(0, 2, 0, 2) return tb # A prime is the only thing in these labels that a subscript follows, so the # rule is narrow enough to be safe: f′c, f′ci. Everything else that needs a # subscript (Aₛ, Cₛ, fₚᵤ) carries a real Unicode one in the label itself. _PRIME = u'′' def _math_block(text, bold=False, size=12, brush=None): """A label whose primed symbols render as real notation. `f′ci` becomes an f, a prime, and a SUBSCRIPT ci -- three inline Runs, not a string that hopes the reader knows what `sqrt f'ci` meant. WPF does the baseline shift; nothing here draws glyphs. """ tb = TextBlock() tb.TextWrapping = TextWrapping.Wrap tb.FontSize = size if bold: tb.FontWeight = FontWeights.Bold if brush is not None: tb.Foreground = brush tb.Margin = Thickness(0, 2, 0, 2) if _PRIME not in text: tb.Text = text return tb i = 0 while i < len(text): j = text.find(_PRIME, i) if j < 0: tb.Inlines.Add(Run(text[i:])) break tb.Inlines.Add(Run(text[i:j + 1])) # up to and incl. the prime k = j + 1 while k < len(text) and text[k].isalpha() and text[k].islower(): k += 1 if k > j + 1: sub = Run(text[j + 1:k]) sub.BaselineAlignment = BaselineAlignment.Subscript sub.FontSize = max(7, size - 3) tb.Inlines.Add(sub) i = k return tb # ------------------------------------------------------------- the drawing # # `vibe.panelsketch` is Revit-free and host-tested; every entry point in it # (`sketch`, `elevation`, `plan`, `section`, `weight_diagram`, and whatever a # sibling agent adds after this lands) returns the SAME flat primitive # vocabulary -- rect / line / support / text / marker / swatch / dot, plain # dicts, pixel coordinates. This is the only place that vocabulary gets # turned into WPF shapes, so a new `source` needs no new code here as long # as it stays inside that vocabulary; a `kind` this dict has never seen is # simply skipped (the drawing degrades one primitive short, not to a crash -- # unlike a block `kind`, an unrecognised drawing PRIMITIVE has no owner- # visible obligation of its own, and `problems` already carries the sentence # that explains what is missing). SKETCH_W = 380 SKETCH_H = 190 _ROLE_BRUSH = { 'panel': Brushes.SteelBlue, 'panel-plan': Brushes.SteelBlue, 'panel-side': Brushes.SteelBlue, 'panel-section': Brushes.SteelBlue, 'panel-crosssection': Brushes.SteelBlue, 'panel-elevation-thin': Brushes.SteelBlue, 'support': Brushes.Firebrick, 'support-label': Brushes.Firebrick, 'support-fraction': Brushes.Firebrick, 'dimension': Brushes.DimGray, 'annotation': Brushes.DimGray, 'openings-note': Brushes.DarkGoldenrod, 'note': Brushes.DarkGoldenrod, 'lifters-note': Brushes.DarkGoldenrod, 'title': Brushes.Black, 'caption': Brushes.Black, 'opening': Brushes.DarkGoldenrod, 'opening-label': Brushes.DarkGoldenrod, 'reveal': Brushes.DarkOrange, 'reveal-label': Brushes.DarkOrange, 'solid-zone': Brushes.DimGray, 'solid-zone-label': Brushes.DimGray, 'section-property': Brushes.Black, 'legend-heading': Brushes.Black, 'legend-glyph': Brushes.DimGray, 'legend-fill-label': Brushes.DimGray, 'strand': Brushes.SteelBlue, 'strand-label': Brushes.Black, 'strand-line': Brushes.SteelBlue, 'centroid': Brushes.Firebrick, 'cg': Brushes.Firebrick, 'cg-lead': Brushes.Firebrick, 'moment-arm': Brushes.DarkGoldenrod, 'moment-arm-label': Brushes.DarkGoldenrod, 'beam': Brushes.Black, 'wythe': Brushes.SteelBlue, 'insulation': Brushes.DarkGoldenrod, 'layer-label': Brushes.DimGray, } # Fills for rect primitives by role; anything not listed is AliceBlue. _RECT_FILL = { 'opening': Brushes.Transparent, 'reveal': Brushes.Transparent, 'solid-zone': Brushes.Transparent, 'wythe': Brushes.LightSteelBlue, 'insulation': Brushes.LemonChiffon, } _FILL_BRUSH = { 'Not Specified': Brushes.Gainsboro, 'Stripping': Brushes.LightSkyBlue, 'Erection': Brushes.LightGreen, 'Stripping + Erection': Brushes.Khaki, 'Transportation': Brushes.LightSalmon, } def _role_brush(role): return _ROLE_BRUSH.get(role, Brushes.DimGray) def _prim_brush(value, default_hex): """A `'#rgb'` / `'#rrggbb'` colour-STRING hint (the convention `pa_sketch_freebody`'s restraint/arrowhead primitives use for their `'stroke'` / `'fill'` keys) -> a `SolidColorBrush`. `default_hex` is what stands in when `value` is missing, not text, or fails to parse -- `None` means "transparent" (the fill default: an un-hinted polygon/circle is meant to be hollow, not solid), any other string is itself parsed as a colour. Never raises -- an unparseable hex string is exactly as survivable as no hint at all, the same discipline `_resolve_drawing_fn` and friends already follow. """ text = value if (isinstance(value, string_types) and value) else default_hex if text is None: return Brushes.Transparent try: return SolidColorBrush(ColorConverter.ConvertFromString(text)) except Exception: return Brushes.Transparent if default_hex is None else Brushes.DimGray def _sketch_text(prim): tb = TextBlock() tb.Text = prim.get('text', '') # 'font_size' (pa_sketch_extents / pa_sketch_freebody) and 'font_px' # (pa_sketch_handling) are the same hint under two names; a primitive # normally carries only one, but 'font_px' wins when both are present. tb.FontSize = prim.get('font_px', prim.get('font_size', 10)) tb.Foreground = _role_brush(prim.get('role')) x = prim.get('x', 0) if prim.get('anchor') == 'middle': # Estimated, not measured: a Measure() pass here would need a layout # that does not exist yet at build time, and half a character of drift # on a label is invisible where a crash is not. x -= len(tb.Text) * 2.6 if prim.get('orientation') == 'vertical': tb.RenderTransform = RotateTransform(-90) Canvas.SetLeft(tb, x) Canvas.SetTop(tb, prim.get('y', 0)) return tb def _draw_primitive(canvas, prim): """One `panelsketch` primitive -> WPF shape, appended onto `canvas`. A `kind` this function does not recognise is skipped, never raised on -- a future primitive kind lands here the day this function is taught it, and until then the rest of the drawing still renders. """ kind = prim.get('kind') role = prim.get('role') flagged = bool(prim.get('flagged')) brush = Brushes.Red if flagged else _role_brush(role) if kind == 'rect': r = Rectangle() r.Width = max(1, prim.get('w', 1)) r.Height = max(1, prim.get('h', 1)) # 'stroke'/'fill'/'width' are the same colour/thickness hints # `circle`/`polygon` read below; a rect with none of them keeps the # original role-brush-and-fill behaviour exactly (no primitive today # sets them), flagged still wins over an explicit 'stroke' the way # every other kind here treats flagged as the louder signal. r.Stroke = brush if (flagged or 'stroke' not in prim) \ else _prim_brush(prim.get('stroke'), '#333') r.StrokeThickness = prim.get('width', 1.5) r.Fill = _RECT_FILL.get(role, Brushes.AliceBlue) if 'fill' not in prim \ else _prim_brush(prim.get('fill'), None) Canvas.SetLeft(r, prim.get('x', 0)) Canvas.SetTop(r, prim.get('y', 0)) canvas.Children.Add(r) elif kind == 'line': ln = Line() ln.X1, ln.Y1 = prim.get('x1', 0), prim.get('y1', 0) ln.X2, ln.Y2 = prim.get('x2', 0), prim.get('y2', 0) ln.Stroke = brush # 'width' is a stroke-thickness hint (pa_sketch_freebody's member # line: 'width': 3.0); default 1 keeps every other line unchanged. ln.StrokeThickness = prim.get('width', 1.0) # Dash hint: 'dash' is an explicit [on, off, ...] array; 'dashed' # (pa_sketch_handling's dunnage lines, pa_sketch_reinf's mesh) is # the plain boolean flag those two modules actually emit -- both # read here, an explicit 'dash' array wins if a caller ever gives # one, 'dashed': True falls back to a fixed pattern (same # [4.0, 2.0] the cutline itself already draws with). dash = prim.get('dash') if dash: ln.StrokeDashArray = DoubleCollection(dash) elif prim.get('dashed'): ln.StrokeDashArray = DoubleCollection([4.0, 2.0]) canvas.Children.Add(ln) elif kind == 'support': # The support triangle points UP at the soffit it holds, and a # support the user typed outside the panel is drawn hollow at the # end it was clamped to -- present and visibly wrong, never gone. # # `edge_kind` (pa_sketch_handling's plan-view edge lifters) is # 'side' for a lifter on one of the panel's two LONG edges -- the # default upward-pointing glyph above already reads correctly # there -- or 'end' for one on the panel's LEFT/RIGHT end, where # "pointing up at the soffit" makes no sense on a plan view: it # has to point INWARD, horizontally. `edge` names which end # ('bottom' = the panel's start, x_ft near 0, drawn at the # CANVAS's left -- see `_end_side_from_x` -- so it points RIGHT, # into the panel; 'top' = the finish end, drawn at the canvas's # right, so it points LEFT). x, y = prim.get('x', 0), prim.get('y', 0) tri = Polygon() pts = PointCollection() if prim.get('edge_kind') == 'end': if prim.get('edge') == 'top': pts.Add(Point(x, y)) pts.Add(Point(x + 11, y - 6)) pts.Add(Point(x + 11, y + 6)) else: pts.Add(Point(x, y)) pts.Add(Point(x - 11, y - 6)) pts.Add(Point(x - 11, y + 6)) else: pts.Add(Point(x, y)) pts.Add(Point(x - 6, y + 11)) pts.Add(Point(x + 6, y + 11)) tri.Points = pts tri.Stroke = brush tri.StrokeThickness = 1.5 tri.Fill = Brushes.Transparent if flagged else brush canvas.Children.Add(tri) elif kind == 'text': canvas.Children.Add(_sketch_text(prim)) elif kind == 'marker': x, y = prim.get('x', 0), prim.get('y', 0) glyph = prim.get('glyph') if glyph == 'diamond': poly = Polygon() pts = PointCollection() for dx, dy in ((0, -6), (6, 0), (0, 6), (-6, 0)): pts.Add(Point(x + dx, y + dy)) poly.Points = pts poly.Stroke = brush poly.StrokeThickness = 1.5 poly.Fill = brush canvas.Children.Add(poly) else: # 'cross', or unnamed for x1, y1, x2, y2 in ((x - 6, y, x + 6, y), (x, y - 6, x, y + 6)): ln = Line() ln.X1, ln.Y1, ln.X2, ln.Y2 = x1, y1, x2, y2 ln.Stroke = brush ln.StrokeThickness = 2.0 canvas.Children.Add(ln) elif kind == 'swatch': rect = Rectangle() rect.Width = 12 rect.Height = 12 rect.Stroke = Brushes.Gray rect.StrokeThickness = 0.5 rect.Fill = _FILL_BRUSH.get(prim.get('fill'), Brushes.Gainsboro) Canvas.SetLeft(rect, prim.get('x', 0)) Canvas.SetTop(rect, prim.get('y', 0)) canvas.Children.Add(rect) elif kind == 'dot': dot = Ellipse() dot.Width = 5 dot.Height = 5 dot.Stroke = brush dot.StrokeThickness = 1.0 dot.Fill = brush Canvas.SetLeft(dot, prim.get('x', 0) - 2.5) Canvas.SetTop(dot, prim.get('y', 0) - 2.5) canvas.Children.Add(dot) elif kind == 'circle': # `pa_sketch_freebody`'s "Free"/unrecognised restraint glyph -- # a hollow, positioned dot rather than the Pin/Floor-tie triangle # (there is no restraint SYMBOL for a type that restrains nothing). # `'cx'/'cy'/'r'` in pixels; `'stroke'/'fill'` are the same # `'#rgb'`/`'#rrggbb'` colour-string hints the module docstring # names (default stroke '#333', fill transparent -- an un-hinted # circle is hollow, not solid); `'width'` is the outline thickness # (default 1). `flagged` keeps the same "hollow, red outline" # convention `support` above uses, overriding any explicit colours. cx, cy = prim.get('cx', 0), prim.get('cy', 0) r = max(0.5, prim.get('r', 4)) dot = Ellipse() dot.Width = r * 2.0 dot.Height = r * 2.0 dot.Stroke = brush if flagged else _prim_brush(prim.get('stroke'), '#333') dot.StrokeThickness = prim.get('width', 1.0) dot.Fill = Brushes.Transparent if flagged \ else _prim_brush(prim.get('fill'), None) Canvas.SetLeft(dot, cx - r) Canvas.SetTop(dot, cy - r) canvas.Children.Add(dot) elif kind == 'polygon': # `pa_sketch_freebody`'s restraint triangles (Pin / Floor-tie) and # every arrowhead (loads, wind-zone arrows). `'points'` (or # `'pts'`) is a list of `(x, y)` pairs or `{'x':, 'y':}` dicts, in # pixels; `'stroke'/'fill'/'width'` are the same hints `circle` # reads above -- an arrowhead carries none of them (see # `pa_sketch_freebody`'s load/wind-zone arrowheads), so the # default stroke '#333' width 1, transparent fill has to be a # sensible LOOK on its own, not just a placeholder. points = prim.get('points') or prim.get('pts') or [] pts = PointCollection() for pt in points: if isinstance(pt, dict): px, py = pt.get('x', 0), pt.get('y', 0) else: px, py = pt[0], pt[1] pts.Add(Point(px, py)) poly = Polygon() poly.Points = pts poly.Stroke = brush if flagged else _prim_brush(prim.get('stroke'), '#333') poly.StrokeThickness = prim.get('width', 1.0) poly.Fill = Brushes.Transparent if flagged \ else _prim_brush(prim.get('fill'), None) canvas.Children.Add(poly) # else: an unrecognised primitive kind. Skipped, not raised on. def _resolve_drawing_fn(source, modname=None): """(fn, None) or (None, error_sentence) -- never raises. `modname` present -> `getattr(vibe., source)`, the new `pa_sketch_*` seam the sibling drawing modules land in (block contract: `'module': 'pa_sketch_extents'`); `modname` absent -> the original `vibe.panelsketch` lookup, unchanged. A module that does not exist yet (not landed) or a `source` it does not define degrades to a named sentence, exactly like the old bare `getattr(panelsketch, source, None)` did -- never an exception that takes the drawing, or the tab, down. """ if not source: return None, 'drawing has no source.' if not modname: fn = getattr(panelsketch, source, None) if fn is None: return None, "drawing source '{0}' is not available.".format( source) return fn, None mod, err = _load_section_module(modname) if mod is None: return None, ("drawing module 'vibe.{0}' could not be loaded " "({1}).").format(modname, err) fn = getattr(mod, source, None) if fn is None: return None, ("drawing source '{0}' is not available in " "vibe.{1}.").format(source, modname) return fn, None def _drawing_canvas(source, values, w, h, modname=None): """A Border-wrapped drawing for one `drawing` block. `source` names an entry point in `vibe.panelsketch` (or, with `modname`, in `vibe.` -- see `_resolve_drawing_fn`); an unknown name, or one that raises, becomes a visible note in place of the picture -- never a crash, never a blank box indistinguishable from an empty drawing. THE OVERLAP A FIXED-SIZE DRAWING USED TO CAUSE (owner, 2026-09-03: the drawings in Concrete Extents and Structural Model printed on top of the neighbouring column's text) is two causes, neither fixable by moving a number: * the problem NOTES used to be laid into the sketch `Canvas` itself with `Canvas.SetLeft/SetTop` at a fixed 24 px pitch. A Canvas does not measure its children, so a note that wrapped to three lines simply drew over whatever was under it, and the canvas never grew. They are now real `TextBlock`s in a `StackPanel` BELOW the drawing, in their own Auto grid row, so the block's height is the drawing plus however much text there actually is. * the drawings declare fixed pixel sizes (up to 900x260) inside equal star columns that are ~360 px wide on the standard window, and neither `Grid` nor `StackPanel` clips. Both the canvas and the outer border clip (`ClipToBounds`), so nothing can paint outside its own cell even if a primitive is drawn past the declared extent -- and the canvas is pre-shrunk to fit a normal column (see below), so the clip is a backstop, not the mechanism. WHY THE SHRINK IS A FIXED `ScaleTransform`, NOT A `Viewbox` (owner, 2026-09-03, second complaint the same day: "the two elevation drawings don't zoom with the rest"). This USED to be a `Viewbox` with `StretchDirection.DownOnly`, which fits itself to whatever space WPF offers at measure time -- and `show_form`'s Ctrl+wheel zoom is a `ScaleTransform` set as the page `stack`'s `LayoutTransform`, which works by DEFLATING the space it hands its children by `1 / scale` before measuring them, then INFLATING the result back by `scale` before reporting it upward. For an element whose own size is a function of "how much space am I given" -- exactly what a `Viewbox` is -- that deflate and that inflate cancel EXACTLY: at zoom=2 the `Viewbox` is handed HALF the column width, shrinks the drawing to fit that halved width, and the ancestor transform blows the (now half-sized) result back up by 2 -- net: the same pixel size as zoom=1, every time. Ordinary text has no such cancellation because a `TextBlock`'s glyph size does not depend on the space it is offered (it just wraps), which is why the rest of the page visibly zoomed and the drawings alone did not. `panelsketch.drawing_display_scale(w, h)` computes the shrink factor instead as plain arithmetic off the drawing's OWN declared size -- a fixed number, unrelated to whatever space WPF offers -- and `canvas.LayoutTransform = ScaleTransform(scale, scale)` applies it as an ordinary, non-adaptive transform. A fixed transform on a descendant composes MULTIPLICATIVELY with an ancestor's `LayoutTransform` (like any two nested transforms) instead of being cancelled by it, so the drawing now zooms exactly like the text around it. """ canvas = Canvas() canvas.Width = w canvas.Height = h canvas.Background = Brushes.WhiteSmoke fn, resolve_err = _resolve_drawing_fn(source, modname) if fn is None: primitives, problems = [], [resolve_err] else: try: primitives, problems = fn(values, w, h) except Exception as exc: # never take the form down primitives, problems = [], ['drawing failed: {0}'.format(exc)] for prim in primitives: try: _draw_primitive(canvas, prim) except Exception: pass # one bad primitive is never worth the whole page canvas.ClipToBounds = True scale = panelsketch.drawing_display_scale(w, h) if scale < 1.0: canvas.LayoutTransform = ScaleTransform(scale, scale) canvas.HorizontalAlignment = HorizontalAlignment.Left notes = StackPanel() notes.Margin = Thickness(4, 2, 4, 2) for problem in problems[:4]: note = TextBlock() note.Text = u'• ' + problem note.FontSize = 10 note.Foreground = Brushes.Firebrick note.TextWrapping = TextWrapping.Wrap note.Margin = Thickness(0, 0, 0, 3) notes.Children.Add(note) outer = Grid() outer.ColumnDefinitions.Add(ColumnDefinition()) for _ in range(2): row = RowDefinition() row.Height = GridLength.Auto outer.RowDefinitions.Add(row) Grid.SetRow(canvas, 0) Grid.SetColumn(canvas, 0) outer.Children.Add(canvas) Grid.SetRow(notes, 1) Grid.SetColumn(notes, 0) outer.Children.Add(notes) border = Border() border.BorderBrush = Brushes.Gainsboro border.BorderThickness = Thickness(1) border.Margin = Thickness(0, 4, 0, 4) border.ClipToBounds = True border.HorizontalAlignment = HorizontalAlignment.Left border.Child = outer return border # --------------------------------------------------------------- the chrome # # Eriksson prints one BLOCK per idea, titled, with its inputs in two columns # under the title. A person reading it never has to hold fifty rows in their # head at once, which is exactly what this form used to ask of them (the # owner, twice: "太丑了", "你这么输入用户上哪儿能看懂"). These three widgets # are the whole of the answer: a titled box, a two-column row, and a plain # sentence under a field saying what it is for. _INK = Brushes.Black _QUIET = Brushes.Gray _RULE = Brushes.Gainsboro _ZEBRA = Brushes.GhostWhite class _RowCount(object): """A field-row counter, so alternate rows get a faint background band. ONE PER CONTAINER, never global: a shared counter would make the stripe depend on how many rows some unrelated column happened to draw first, so two side-by-side columns would band out of step and the form would look like the bands meant something. A fresh counter per column in `_render_columns` and one per section tab keeps every visual group starting on white, which is what makes the banding read as "these rows go together" rather than as a highlight. """ def __init__(self): self.n = 0 def next_is_zebra(self): self.n += 1 return (self.n % 2) == 0 def _hint_block(text): """The plain sentence under a field. Grey, small, and never a warning -- a warning that looks like help is read as neither.""" tb = _math_block(text, size=10, brush=_QUIET) tb.Margin = Thickness(0, 0, 0, 4) return tb # The label column takes the slack (star) instead of a fixed width; the # symbol and editor columns stay narrow and fixed. The old layout gave all # three a FIXED width (210 + 92 + 150 = 452px) and packed three of these rows # side by side inside `columns` blocks whose star-sized columns are only # ~360-380px wide on the 1180px window -- so every row was ~80px wider than the # column that held it and its editor printed ON TOP of the next column's text. # That was the overlap the owner reported. A star label column can never make # the row wider than its container, so the row always fits and nothing lands on # top of anything, at any window width. SYMBOL_COL = 66 EDITOR_COL = 138 # A FOURTH fixed column, to the right of the editor, for a per-field read-out # (today only the enclosure classification's GCpi). It used to be packed into # the editor cell inside a horizontal StackPanel, which handed `_two_col_row` a # 366px widget for a 138px column -- so on that one row the star label column # was squeezed and its editor's left edge sat in from every other row's. That # was the misalignment the owner reported. Every row now reserves this column # whether it has an annotation or not, so no row's editor can be pushed. ANNOT_COL = 150 # Editor `kind`s whose text box carries more than one number or line -- # `make_editor` stretches these instead of pinning them to the fixed # `EDITOR_COL`. See `make_editor`'s own comment for the incident this fixes. _MULTI_VALUE_EDITOR_KINDS = ('supports', 'openings') def _two_col_row(label_widget, editor, symbol=None, hint=None, annot=None): """One input, laid out `name | symbol | box | note` with the plain-English name first and the notation BESIDE it rather than instead of it. The symbol column is what lets a label say `Tension allowed while handling` and still show `n·√f′ci` to the engineer who wants it. Neither reader has to decode the other's version. `hint` is accepted but no longer rendered here -- field help now lives in the one collapsed Notes panel at the top of the form (owner, 2026-09-03), collected by `_render_field`. `annot` is an optional widget for the fourth column; the column is present and fixed-width on EVERY row regardless, so an annotated row lines up with an unannotated one. """ grid = Grid() lab_col = ColumnDefinition() lab_col.Width = GridLength(1, GridUnitType.Star) lab_col.MinWidth = 110 grid.ColumnDefinitions.Add(lab_col) sym_col = ColumnDefinition() sym_col.Width = GridLength(SYMBOL_COL) grid.ColumnDefinitions.Add(sym_col) ed_col = ColumnDefinition() # FIXED, not Auto: an Auto editor column sizes to that row's own editor, so # a wider editor (the enclosure dropdown carries a GCpi read-out beside it) # shrank the star label column on THAT row and shifted its editor's left # edge in from every other row's -- the misalignment the owner reported. # A fixed width puts every editor's left edge at the same x, at any width. ed_col.Width = GridLength(EDITOR_COL) grid.ColumnDefinitions.Add(ed_col) annot_col = ColumnDefinition() annot_col.Width = GridLength(ANNOT_COL) grid.ColumnDefinitions.Add(annot_col) # A single Auto-height row: a star-height row (the WPF default for a bare # RowDefinition) vertically centres wrapped label text and lets a two-line # label ride up over its neighbours; Auto sizes the row to its tallest cell # so a long label pushes the row taller instead of overlapping. row = RowDefinition() row.Height = GridLength.Auto grid.RowDefinitions.Add(row) label_widget.Margin = Thickness(0, 3, 8, 3) label_widget.VerticalAlignment = VerticalAlignment.Center Grid.SetRow(label_widget, 0) Grid.SetColumn(label_widget, 0) grid.Children.Add(label_widget) sym = _math_block(symbol or '', size=11, brush=_QUIET) sym.Margin = Thickness(0, 3, 8, 3) sym.VerticalAlignment = VerticalAlignment.Center Grid.SetRow(sym, 0) Grid.SetColumn(sym, 1) grid.Children.Add(sym) editor.VerticalAlignment = VerticalAlignment.Center Grid.SetRow(editor, 0) Grid.SetColumn(editor, 2) grid.Children.Add(editor) note = annot if annot is not None else TextBlock() note.VerticalAlignment = VerticalAlignment.Center Grid.SetRow(note, 0) Grid.SetColumn(note, 3) grid.Children.Add(note) # Zero, not 2: `_render_field` wraps this grid in the zebra Border, and a # margin here would leave an unpainted gap between bands so the striping # read as separate boxes rather than a continuous table. grid.Margin = Thickness(0) return grid def _tab(title, child): item = TabItem() item.Header = title scroller = ScrollViewer() scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto # Disabled, not Auto: an Auto horizontal scrollbar measures the content at # INFINITE width, so a `columns` grid's star columns are measured against a # width they are never arranged at -- measure and arrange disagree, and the # drawings inside a column paint over the neighbouring column's text. With # the horizontal scroller off the content is measured at the viewport's own # width and the star columns agree in both passes. scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled scroller.Content = child item.Content = scroller return item # ------------------------------------------------------- the generic renderer # # Everything below turns the block vocabulary five sibling agents build # against (pa_criteria / pa_extents / pa_structural / pa_handling / # pa_reinforcement, each a `page(values, reads=None) -> (blocks, problems)`) # into WPF. This is the ONLY place that vocabulary is interpreted -- nothing # any of those five modules built is visible to the owner until it passes # through here. An unknown block `kind` renders as a visible grey note naming # it (never vanishes); a block that raises while rendering is caught right # where it is dispatched and becomes a small red note instead of taking the # whole tab down. SECTION_MODULES = ( ('Design Criteria', 'pa_criteria'), ('Concrete Extents', 'pa_extents'), ('Structural Model', 'pa_structural'), ('Handling', 'pa_handling'), ('Reinforcement', 'pa_reinforcement'), ) def _load_section_module(modname): """(module, None) or (None, exception) -- never raises. A sibling agent's file can be mid-edit (syntax error) or simply not landed yet (ImportError); both degrade the same way, to a tab that names the section and says why it is not available, never a crash that takes every OTHER section down with it. """ try: mod = __import__('vibe.' + modname, fromlist=[modname]) return mod, None except Exception as exc: return None, exc def _missing_section_note(title, modname, reason): box = StackPanel() box.Margin = Thickness(4) msg = (u"The '{0}' section is not available yet -- vibe.{1} could not " u"be loaded ({2}). Its inputs cannot be shown or edited on this " u"panel until it lands; nothing else on this form is affected." ).format(title, modname, reason) tb = _math_block(msg, size=11, brush=Brushes.Firebrick) tb.TextWrapping = TextWrapping.Wrap box.Children.Add(tb) return box def _grid_star_columns(grid, n): for _ in range(n): col = ColumnDefinition() col.Width = GridLength(1, GridUnitType.Star) grid.ColumnDefinitions.Add(col) def _cell_border(child, header=False, zebra=False): b = Border() b.BorderBrush = _RULE b.BorderThickness = Thickness(0.6) b.Padding = Thickness(4, 2, 4, 2) if header: b.Background = Brushes.WhiteSmoke elif zebra: b.Background = Brushes.GhostWhite else: b.Background = Brushes.White b.Child = child return b def _grid_text_cell(text, bold=False, header=False, zebra=False): tb = _math_block(u'' if text is None else str(text), bold=bold, size=11) tb.TextWrapping = TextWrapping.Wrap return _cell_border(tb, header=header, zebra=zebra) def _em_dash_cell(zebra=False): """A matrix cell that does NOT apply to this stage -- an em dash, never blank. Blank means 'applies, not filled in'; this means 'does not apply' -- the two must never look the same.""" tb = TextBlock() tb.Text = u'—' tb.HorizontalAlignment = HorizontalAlignment.Center tb.Foreground = _QUIET return _cell_border(tb, zebra=zebra) def _render_heading(block): level = block.get('level') or 1 text = block.get('text', '') panel = StackPanel() if level == 1: head = _math_block(text, bold=True, size=15) panel.Children.Add(head) rule = Border() rule.BorderBrush = _RULE rule.BorderThickness = Thickness(0, 0, 0, 2) rule.Margin = Thickness(0, 2, 0, 8) panel.Children.Add(rule) elif level == 2: head = _math_block(text, bold=True, size=13) panel.Children.Add(head) rule = Border() rule.BorderBrush = _RULE rule.BorderThickness = Thickness(0, 0, 0, 1) rule.Width = 170 rule.HorizontalAlignment = HorizontalAlignment.Left rule.Margin = Thickness(0, 2, 0, 6) panel.Children.Add(rule) else: head = _math_block(text, bold=True, size=11.5) head.Margin = Thickness(0, 4, 0, 2) panel.Children.Add(head) return panel def _render_columns(block, form): cols = block.get('columns') or [] if not cols: return StackPanel() grid = Grid() _grid_star_columns(grid, len(cols)) # Auto, not the default star: a star row hands every column the full height # whether it needs it or not, and a column whose content is taller than the # row is simply drawn past it. Auto sizes the row to the tallest column. row_def = RowDefinition() row_def.Height = GridLength.Auto grid.RowDefinitions.Add(row_def) for i, col_blocks in enumerate(cols): panel = StackPanel() panel.Margin = Thickness(0) panel.VerticalAlignment = VerticalAlignment.Top rows = _RowCount() for b in (col_blocks or []): w = _render_block(b, form, rows=rows) if w is not None: panel.Children.Add(w) # A clipping Border per column is what actually stops a fixed-size # drawing painting over the next column's text: neither Grid nor # StackPanel clips its children, so without this the overflow is drawn, # not cut. The 6px side margin moved off the panel and onto the border # so the clip boundary is the visible column edge. cell = Border() cell.ClipToBounds = True cell.Margin = Thickness(6, 0, 6, 0) cell.Child = panel Grid.SetColumn(cell, i) Grid.SetRow(cell, 0) grid.Children.Add(cell) return grid def _render_field(block, form, rows=None): key = block.get('key') label = block.get('label') or key or '' shown = label + u' *' if block.get('required') else label lab = _math_block(shown, size=11) editor_kind = block.get('editor') choices = block.get('choices') # Owner, 2026-09-03: pick-list fields render as a dropdown, not a text box. # A section module that already declared this field a 'choice' keeps its # own options; a plain text/number field whose key is a known enumeration # (F.choices_for) is upgraded here to an editable ComboBox over the generic # options -- so the sibling section modules need no edit for this. if editor_kind in (None, 'text', 'num'): registry_choices = F.choices_for(key) if registry_choices: editor_kind = 'choice' if not choices: choices = list(registry_choices) # Field help no longer renders inline under the box. Every explanation is # collected into the one collapsed Notes panel at the top of the form # (owner, 2026-09-03: keep the help, just tuck it into the notes) -- the # field row itself carries none. hint = block.get('hint') if hint and key: form.add_note(key, label, block.get('symbol'), hint) # A MULTI-VALUE editor ('supports', 'openings' -- a comma list or # several lines) never fits `_two_col_row`'s fixed 138px `EDITOR_COL`: # a `Grid`'s Fixed-width column arranges its child AT that pixel width # regardless of the child's own `MinWidth` or `HorizontalAlignment` # (only an Auto/Star column grows), so packing one into that row just # truncates it again under a different name (owner, 2026-09-04: # "Support positions" was still cut off). These fields get their own # stacked layout instead -- label above, editor below, `Stretch`ed to # the row's REAL width -- so `make_editor`'s stretch actually has # somewhere to grow into. # # A page module's OWN block is not the only source asked here: # `pa_structural.py`'s `_field()` helper draws `supports_ft` as plain # 'text' (never 'supports'), so trusting only `editor_kind` misses the # exact field the owner named. `F._spec(key)['kind']` -- the field # MODEL's own registry (`_F`'s docstring: `'kind'` is "what parses it") -- # is the more durable source of "this key holds more than one value" # than whichever string kind one page's block happens to spell; it wins # when it disagrees with the block. resolved_kind = editor_kind if resolved_kind not in _MULTI_VALUE_EDITOR_KINDS: try: spec = F._spec(key) if key else None except Exception: spec = None canonical = (spec or {}).get('kind') if canonical in _MULTI_VALUE_EDITOR_KINDS: resolved_kind = canonical if resolved_kind in _MULTI_VALUE_EDITOR_KINDS: return _render_multi_value_field(block, form, lab, resolved_kind, choices, rows) editor = form.make_editor(editor_kind, key, choices, width=EDITOR_COL) row = _two_col_row(lab, editor, symbol=block.get('symbol'), annot=form.annotation_for(key)) # A faint band on every second row, so the eye can follow a label across to # its own box on a page this wide (owner, 2026-09-03). No `rows` counter # means the field is being rendered outside a counted container -- it gets # the plain background rather than a stripe whose parity means nothing. band = Border() band.Background = (_ZEBRA if (rows is not None and rows.next_is_zebra()) else Brushes.White) band.Padding = Thickness(4, 1, 4, 1) band.Child = row return band def _render_multi_value_field(block, form, lab, editor_kind, choices, rows): """The stacked layout `_render_field` uses for a multi-value editor: the label (+ symbol) on their own line, the editor on the next, `Stretch`ed to the full row width -- see `_render_field`'s comment for why the ordinary fixed-column row cannot hold one of these without truncating it. Same zebra banding and annotation slot as an ordinary field row. """ key = block.get('key') editor = form.make_editor(editor_kind, key, choices, width=EDITOR_COL) # Belt and suspenders: `make_editor` already stretches a MULTI-VALUE # `kind`, but this layout's whole point is a full-width box, so force it # regardless of which `kind` string actually reached `make_editor`. try: editor.HorizontalAlignment = HorizontalAlignment.Stretch editor.ClearValue(TextBox.WidthProperty) except Exception: pass head = StackPanel() head.Orientation = Orientation.Horizontal lab.Margin = Thickness(0, 0, 8, 0) head.Children.Add(lab) symbol = block.get('symbol') if symbol: sym = _math_block(symbol, size=11, brush=_QUIET) head.Children.Add(sym) stack = StackPanel() stack.Children.Add(head) editor.Margin = Thickness(0, 3, 0, 3) stack.Children.Add(editor) annot = form.annotation_for(key) if annot is not None: stack.Children.Add(annot) band = Border() band.Background = (_ZEBRA if (rows is not None and rows.next_is_zebra()) else Brushes.White) band.Padding = Thickness(4, 3, 4, 3) band.Child = stack return band def _render_kv(block): rows = block.get('rows') or [] grid = Grid() c0 = ColumnDefinition() c0.Width = GridLength(200) grid.ColumnDefinitions.Add(c0) c1 = ColumnDefinition() c1.Width = GridLength(1, GridUnitType.Star) grid.ColumnDefinitions.Add(c1) for _ in rows: grid.RowDefinitions.Add(RowDefinition()) for i, pair in enumerate(rows): label, value = (pair[0], pair[1]) if len(pair) >= 2 else (pair[0], '') lab = _math_block(str(label), size=11, brush=_QUIET) lab.Margin = Thickness(0, 1, 8, 1) val = _math_block(u'' if value is None else str(value), size=11) val.Margin = Thickness(0, 1, 0, 1) Grid.SetRow(lab, i) Grid.SetColumn(lab, 0) grid.Children.Add(lab) Grid.SetRow(val, i) Grid.SetColumn(val, 1) grid.Children.Add(val) return grid def _render_sentence(block): tb = _math_block(block.get('text', ''), size=11, brush=_QUIET) tb.FontStyle = FontStyles.Italic tb.Margin = Thickness(0, 2, 0, 6) return tb def _render_table(block, form): headers = block.get('headers') or [] header_groups = block.get('header_groups') rows = block.get('rows') or [] ncols = len(headers) if ncols == 0: return StackPanel() grid = Grid() for _ in range(ncols): grid.ColumnDefinitions.Add(ColumnDefinition()) header_rows = 2 if header_groups else 1 for _ in range(header_rows + len(rows)): grid.RowDefinitions.Add(RowDefinition()) r = 0 if header_groups: c = 0 for label, span in header_groups: span = max(1, min(span, ncols - c)) cell = _grid_text_cell(label, bold=True, header=True) Grid.SetRow(cell, r) Grid.SetColumn(cell, c) Grid.SetColumnSpan(cell, span) grid.Children.Add(cell) c += span r += 1 for c, h in enumerate(headers): cell = _grid_text_cell(h, bold=True, header=True) Grid.SetRow(cell, r) Grid.SetColumn(cell, c) grid.Children.Add(cell) r += 1 for ridx, row in enumerate(rows): zebra = (ridx % 2 == 1) for cidx in range(ncols): value = row[cidx] if cidx < len(row) else '' if isinstance(value, dict) and value.get('editor'): # ANY editor kind, not just 'check': the load-combination # factors are live numeric boxes inside a grid cell, which is # the whole reason `lc_table` exists. widget = form.make_editor(value.get('editor'), value.get('key'), value.get('choices'), width=value.get('width')) if value.get('editor') == 'check': widget.HorizontalAlignment = HorizontalAlignment.Center cell = _cell_border(widget, zebra=zebra) else: cell = _grid_text_cell(value, zebra=zebra) Grid.SetRow(cell, r) Grid.SetColumn(cell, cidx) grid.Children.Add(cell) r += 1 return grid def _render_matrix(block, form): columns = block.get('columns') or [] rows = block.get('rows') or [] if not columns or not rows: return StackPanel() grid = Grid() c0 = ColumnDefinition() c0.Width = GridLength(220) grid.ColumnDefinitions.Add(c0) for _ in columns: col = ColumnDefinition() col.Width = GridLength(1, GridUnitType.Star) grid.ColumnDefinitions.Add(col) for _ in range(1 + len(rows)): grid.RowDefinitions.Add(RowDefinition()) hdr = _grid_text_cell('', bold=True, header=True) Grid.SetRow(hdr, 0) Grid.SetColumn(hdr, 0) grid.Children.Add(hdr) for ci, colname in enumerate(columns): cell = _grid_text_cell(colname, bold=True, header=True) Grid.SetRow(cell, 0) Grid.SetColumn(cell, ci + 1) grid.Children.Add(cell) for ridx, row in enumerate(rows): r = ridx + 1 zebra = (ridx % 2 == 1) lbl_panel = StackPanel() lbl_panel.Children.Add(_math_block(row.get('label', ''), size=11)) # Owner, 2026-09-04, naming this table directly: "Handling: 小灰字太 # 多了,去掉放到 notes 里面,默认折叠" -- a matrix row's quiet grey # hint moves into the SAME collapsed Notes panel a field's `hint` # does, instead of printing under every row label. Prefixed with the # row's own label so it still reads on its own once it is out of # context. if row.get('hint'): form.add_quiet_sentence( u'{0}: {1}'.format(row.get('label', ''), row['hint'])) lbl_cell = _cell_border(lbl_panel, zebra=zebra) Grid.SetRow(lbl_cell, r) Grid.SetColumn(lbl_cell, 0) grid.Children.Add(lbl_cell) cells = row.get('cells') or [] for ci in range(len(columns)): cellval = cells[ci] if ci < len(cells) else None if cellval is None: wrapped = _cell_border(TextBlock(), zebra=zebra) elif isinstance(cellval, dict) and cellval.get('na'): wrapped = _em_dash_cell(zebra=zebra) elif isinstance(cellval, dict): widget = form.make_editor(cellval.get('editor'), cellval.get('key'), cellval.get('choices')) wrapped = _cell_border(widget, zebra=zebra) else: wrapped = _grid_text_cell(cellval, zebra=zebra) Grid.SetRow(wrapped, r) Grid.SetColumn(wrapped, ci + 1) grid.Children.Add(wrapped) return grid def _render_legend(block): groups = block.get('groups') or [] panel = StackPanel() for name, items in groups: panel.Children.Add(_math_block(name, bold=True, size=11)) for symbol, desc in (items or []): row = StackPanel() row.Orientation = Orientation.Horizontal row.Margin = Thickness(8, 0, 0, 2) sym = _math_block(symbol, size=11, brush=_QUIET) sym.Width = 60 row.Children.Add(sym) row.Children.Add(_math_block(desc, size=11)) panel.Children.Add(row) return panel def _render_rule(): b = Border() b.BorderBrush = _RULE b.BorderThickness = Thickness(0, 0, 0, 1) b.Margin = Thickness(0, 6, 0, 6) return b def _render_space(block): b = Border() b.Height = max(2, (block.get('h') or 1) * 6) return b def _render_section(block, form): """`{'kind': 'section', 'title', 'collapsed', 'blocks': [...]}` -- an Expander holding its own child blocks, collapsed when the page asks for that (owner, 2026-09-04: "seismic, temp, soil 这几个板块先折叠掉" -- fold those sections away by default). `collapsed` is read with `bool(...)`, defaulting False (expanded) when a page module omits it -- an existing page that never emits `section` blocks is unaffected, and a page that starts using them without an explicit `collapsed` gets the ordinary always-visible behaviour rather than silently hiding content nobody asked to hide. A fresh `_RowCount` per section, same reasoning as `_render_columns`'s one-per-column: a section's own field rows band from white, independent of whatever came before it on the page. """ title = block.get('title') or '' collapsed = bool(block.get('collapsed')) child_blocks = block.get('blocks') or [] exp = Expander() exp.Header = title exp.IsExpanded = not collapsed exp.Margin = Thickness(0, 2, 0, 8) inner = StackPanel() inner.Margin = Thickness(6, 4, 6, 4) rows = _RowCount() for b in child_blocks: w = _render_block(b, form, rows=rows) if w is not None: inner.Children.Add(w) exp.Content = inner return exp # ---------------------------------------------------- editable-table rows # # `{'kind': 'editable_table', 'table': NAME, 'columns': [...], 'add_label', # 'min_rows'}` -- pre-filled, user-editable rows over the field model's row # keys `..`, present rows being those whose `
.. # present` reads 'yes' (owner, 2026-09-04: "只是让你预先输入,不是让你直接锁 # 死" -- a pre-filled table must stay editable, with a way to add a row). # # `panelanalysisform` (F) is meant to grow `row_ids` / `row_key` / `add_row` # / `remove_row` with this exact contract; until it does, these four do the # same key arithmetic locally under the SAME names, so picking up F's real # versions later is a one-line change at `_row_ids` etc. below, never a # rewrite of the widget code that calls them. def _local_row_ids(table, values): """Sorted row numbers `n` where `
..present` reads 'yes'.""" prefix = table + '.' ids = set() for key in values.keys(): if not key.startswith(prefix): continue parts = key[len(prefix):].split('.') if len(parts) != 2 or parts[1] != 'present': continue try: n = int(parts[0]) except (TypeError, ValueError): continue if str(values.get(key, '') or '').strip().lower() == 'yes': ids.add(n) return sorted(ids) def _local_row_key(table, n, col): return '{0}.{1}.{2}'.format(table, n, col) def _local_add_row(table, values, cols, defaults=None): """Appends one new row (its `present` flag set to 'yes') and returns its row number. The next number is one past the highest `n` this table has EVER used, present or not -- a removed row's number is never reissued, so a row a user just deleted never comes back under a stale key some editor is still bound to.""" prefix = table + '.' used = set() for key in values.keys(): if not key.startswith(prefix): continue parts = key[len(prefix):].split('.') if len(parts) != 2: continue try: used.add(int(parts[0])) except (TypeError, ValueError): pass n = (max(used) + 1) if used else 1 values[_local_row_key(table, n, 'present')] = 'yes' defaults = defaults or {} for col in cols: values.setdefault(_local_row_key(table, n, col), defaults.get(col, '')) return n def _local_remove_row(table, values, n): values[_local_row_key(table, n, 'present')] = '' def _row_ids(table, values): return getattr(F, 'row_ids', _local_row_ids)(table, values) def _row_key(table, n, col): return getattr(F, 'row_key', _local_row_key)(table, n, col) def _add_row(table, values, cols, defaults=None): return getattr(F, 'add_row', _local_add_row)(table, values, cols, defaults) def _remove_row(table, values, n): return getattr(F, 'remove_row', _local_remove_row)(table, values, n) def _render_unknown(block): """A block `kind` this renderer has never seen. Named and visible, never silently dropped -- a block that vanishes is a section of the form nobody knows is missing.""" tb = TextBlock() tb.Text = u"[unrendered block: kind={0!r}]".format(block.get('kind') if isinstance(block, dict) else block) tb.FontStyle = FontStyles.Italic tb.Foreground = Brushes.Gray tb.TextWrapping = TextWrapping.Wrap tb.Margin = Thickness(0, 2, 0, 2) return tb def _render_block(block, form, rows=None): """One block dict -> one WPF element. Never raises -- a block that fails to render becomes a small red note in its own place, and the rest of the tab keeps building. `rows` is the calling container's own `_RowCount` (see that class for why it is per-container); it is threaded down to `field` blocks so their zebra bands alternate within their own column, and ignored by every other kind. """ if not isinstance(block, dict): return _render_unknown(block) kind = block.get('kind') try: if kind == 'heading': return _render_heading(block) if kind == 'columns': return _render_columns(block, form) if kind == 'field': return _render_field(block, form, rows=rows) if kind == 'kv': return _render_kv(block) if kind == 'sentence': # Owner, 2026-09-04: quiet helper prose is clutter inline -- # route it into the page's collapsed Notes panel instead of # printing it in the flow. A non-quiet sentence is unchanged. if block.get('quiet'): form.add_quiet_sentence(block.get('text', '')) return None return _render_sentence(block) if kind == 'table': return _render_table(block, form) if kind == 'lc_table': return form.lc_table_widget(block) if kind == 'editable_table': return form.editable_table_widget(block) if kind == 'matrix': return _render_matrix(block, form) if kind == 'legend': return _render_legend(block) if kind == 'drawing': return form.drawing_widget(block) if kind == 'section': return _render_section(block, form) if kind == 'rule': return _render_rule() if kind == 'space': return _render_space(block) return _render_unknown(block) except Exception as exc: note = TextBlock() note.Text = u'[block failed to render: kind={0} -- {1}]'.format( kind, exc) note.Foreground = Brushes.Firebrick note.TextWrapping = TextWrapping.Wrap note.FontSize = 10 return note class PanelForm(object): """One selected panel's collapsed section: a tab per Eriksson section, built from the five sibling agents' `page()` block lists, plus that panel's own Run button. """ def __init__(self, el, index, know, asm=None): self.el = el self.index = index self.asm = asm self.reads = _reads_for(el, index, know, asm) try: self.values0 = F.prefill(self.reads, know=know) except TypeError: self.values0 = F.prefill(self.reads) self.mark = self.reads.get('mark') or 'panel {0}'.format(index + 1) self.title = panel_title(self.reads) or self.mark self.editors = {} # key -> (widget, 'text'|'check'|'choice') # Every registered drawing (fixed-size or full-width), as a dict -- # see `_fixed_drawing_widget` / `_full_width_drawing_widget` for the # shape. `_redraw_drawings` walks this list; `depends_on` is what # lets a cutline drag redraw only the entries that actually read the # key it just changed, instead of every drawing on the page. self.drawings = [] self.run_btn = None self.status = None self.gcpi_label = None self.tabctrl = None self.annotations = {} # key -> the widget in the 4th row column self.notes = [] # [{'kind': 'field'|'sentence', ...}] self._note_keys = set() # de-dupes a field/sentence seen twice # One pending flag per cutline key, so a burst of MouseMove events # while dragging schedules AT MOST one redraw of that key's # dependent drawings per dispatcher pass -- see `_throttled_redraw`. self._cutline_pending = {} # Live derived-field wiring (see `_editor_changed`). `_suspend` guards # against a programmatic write re-entering the change handler; # `_overridden` remembers which derived fields the user has typed into, # so their value stands from then on; `_handlers` keeps every Python # delegate handed to WPF alive -- an event handler that is only # referenced by the CLR side is a collection away from a dead callback. self._suspend = False self._overridden = set() self._handlers = [] # The Sandwich-only groups (per-wythe Concrete Extents thicknesses # and the wythe-2 material trio) are rendered collapsed and shown # only while this panel's `section_type` says Sandwich. Each entry # is the top-level WPF element to flip (a Border or StackPanel). self._sandwich_widgets = [] self.expander = self._build() # -- sandwich-only groups ---------------------------------------------- def _sandwich_only_group(self, title, keys): """One titled group of editors for the Sandwich-only `keys`. Field text (label / symbol / hint) comes from `panelanalysisform.FIELDS` through the same `_render_block` the section modules use, so these rows behave exactly like any other field row -- a live editor, a Notes entry, a `validate` participant. The group is returned COLLAPSED; `_sync_sandwich_groups` shows it only while the panel's Section type is Sandwich. """ cols = [[], []] for i, key in enumerate(keys): if F._spec(key) is None: continue block = {'kind': 'field', 'key': key, 'label': F.label_of(key), 'symbol': F.symbol_of(key), 'hint': F.help_of(key), 'required': False, 'editor': 'num', 'choices': None} cols[i % 2].append(block) blocks = [{'kind': 'heading', 'text': title, 'level': 2}, {'kind': 'columns', 'columns': cols}, {'kind': 'space', 'h': 1}] panel = StackPanel() panel.Margin = Thickness(0) rows = _RowCount() for b in blocks: w = _render_block(b, self, rows=rows) if w is not None: panel.Children.Add(w) return panel def _append_sandwich_groups(self, content, title): """Append the Sandwich-only groups belonging to tab `title`. Only the groups whose FIELDS rows sit in that tab's section are appended (Design Criteria -> the wythe-2 material trio; Concrete Extents -> the Sandwich Wythes thicknesses). Each is collapsed to start; `_editor_changed` re-syncs them when `section_type` changes. """ by_section = {} for key in F.SANDWICH_ONLY_KEYS: spec = F._spec(key) if spec is None: continue by_section.setdefault(spec['section'], []).append(key) for section, keys in by_section.items(): if section != title or not keys: continue group_title = ('Sandwich Wythes' if section == 'Concrete Extents' else 'Wythe 2 Concrete') widget = self._sandwich_only_group(group_title, keys) widget.Visibility = Visibility.Collapsed self._sandwich_widgets.append(widget) content.Children.Add(widget) def _sync_sandwich_groups(self): """Flip every Sandwich-only group to match the current Section type. Reads the section_type editor's text directly (not `current_values()`), so this stays cheap on every keystroke; a blank / unreadable value reads as not-Sandwich and the groups stay hidden unless the section is explicitly Sandwich. """ is_sandwich = False pair = self.editors.get('section_type') if pair is not None: try: text = pair[0].Text or '' except Exception: text = '' is_sandwich = str(text).strip().lower() == 'sandwich' shown = Visibility.Visible if is_sandwich else Visibility.Collapsed for widget in self._sandwich_widgets: try: widget.Visibility = shown except Exception: pass # -- annotations ------------------------------------------------------- def annotation_for(self, key): """The widget for a field row's fourth (annotation) column, or None. Only `wind_enclosure` has one today: the owner's requirement that choosing an enclosure classification shows its matching GCpi (ASCE Table 26.13-1). It lives in its OWN grid column rather than beside the editor inside a StackPanel, which is what used to make that one row wider than the column holding it and knock its editor out of line with every other row (see `ANNOT_COL`). """ if key != 'wind_enclosure': return None label = _label_block('', size=11, brush=Brushes.Gray) label.TextWrapping = TextWrapping.Wrap label.Margin = Thickness(8, 0, 0, 0) self.gcpi_label = label self.annotations[key] = label return label # -- notes ------------------------------------------------------------- def add_note(self, key, label, symbol, hint): """Record one field's plain-language help for the collapsed Notes panel at the top of the form. Called by `_render_field` in place of drawing the help inline under the box. First writer per key wins; a field that happens to render in two places is noted once.""" if not key or key in self._note_keys: return self._note_keys.add(key) self.notes.append({'kind': 'field', 'label': label, 'symbol': symbol or '', 'hint': hint}) def add_quiet_sentence(self, text): """Record one `{'kind': 'sentence', 'quiet': True}` block's text for the same collapsed Notes panel `add_note` feeds -- owner, 2026-09-04: "小灰字太多了,去掉放到 notes 里面,默认折叠" (too much quiet grey prose inline; move it into Notes, collapsed by default). De-duped on the exact text, the same way a field note de-dupes on its key: a sentence a page emits from two sibling blocks is listed once.""" text = text or '' if not text or text in self._note_keys: return self._note_keys.add(text) self.notes.append({'kind': 'sentence', 'text': text}) def _build_notes_expander(self): """One collapsed Expander listing every field's explanation and every quiet sentence -- the owner's ruling (2026-09-03, extended 2026-09-04): keep the help texts and the quiet helper prose, but move both out of the page's flow into a single Notes section, closed by default so the form reads as inputs, not prose.""" exp = Expander() exp.Header = u'Notes ({0})'.format(len(self.notes)) exp.IsExpanded = False exp.Margin = Thickness(0, 0, 0, 8) scr = ScrollViewer() scr.VerticalScrollBarVisibility = ScrollBarVisibility.Auto scr.MaxHeight = 300 inner = StackPanel() inner.Margin = Thickness(8, 4, 8, 6) for note in self.notes: if note.get('kind') == 'sentence': row = _hint_block(note.get('text', '')) row.Margin = Thickness(0, 0, 0, 6) inner.Children.Add(row) continue label = note.get('label', '') symbol = note.get('symbol', '') hint = note.get('hint', '') head = label if not symbol else u'{0} ({1})'.format(label, symbol) row = StackPanel() row.Margin = Thickness(0, 0, 0, 6) row.Children.Add(_math_block(head, bold=True, size=11)) row.Children.Add(_hint_block(hint)) inner.Children.Add(row) scr.Content = inner exp.Content = scr return exp # -- values ------------------------------------------------------------ def current_values(self): vals = dict(self.values0) for key, pair in self.editors.items(): widget, kind = pair if kind == 'check': vals[key] = 'yes' if widget.IsChecked else '' else: try: vals[key] = widget.Text or '' except Exception: vals[key] = '' return vals # -- editors ------------------------------------------------------------- def make_editor(self, kind, key, choices=None, width=None): """One live input widget for `key`, registered in `self.editors` so `current_values()` / the gate / the drawings can read it back. `check` -> CheckBox, `choice` -> an EDITABLE ComboBox over `choices` that never pre-selects a value the user did not choose (its `.Text` is set, never its `SelectedItem`), everything else -> TextBox. """ initial = self.values0.get(key, '') if key else '' if kind == 'check': w = CheckBox() w.IsChecked = (initial or '').strip().lower() in ( 'yes', 'y', 'true', '1', 'on') w.VerticalAlignment = VerticalAlignment.Center ekind = 'check' elif kind == 'choice': w = ComboBox() w.IsEditable = True for c in (choices or []): w.Items.Add(c) w.Text = initial or '' if width: w.Width = width ekind = 'choice' else: # 'text' / 'num' / other w = TextBox() w.Text = initial or '' # A sensible floor so a value ordinary in its own field is never # clipped inside the row's fixed editor column (owner, # 2026-09-04: "Support positions" -- a comma list of numbers -- # rendered truncated at the old blanket 138px). `num` values are # short by nature; `text` gets a wider floor. A MULTI-VALUE kind # ('supports', 'openings' -- anything holding more than one # number or line) additionally STRETCHES to whatever room its # row actually offers instead of being pinned to an explicit # `Width` -- the per-column `Border.ClipToBounds` in # `_render_columns` is what keeps a box that grows past a narrow # column's own width from painting over its neighbour. if kind == 'num': w.MinWidth = 90 if width: w.Width = width else: w.MinWidth = 240 if kind in _MULTI_VALUE_EDITOR_KINDS: w.HorizontalAlignment = HorizontalAlignment.Stretch elif width: w.Width = width ekind = 'text' if key: self.editors[key] = (w, ekind) # Wire AFTER the initial value is set, so pre-filling a box does not # itself count as the user editing it -- that is what would mark a # derived field overridden before the form is even on screen. self._bind(w, key, ekind) # The enclosure GCpi read-out no longer wraps the editor in a # StackPanel here: it is a separate grid column (`annotation_for`), so # this row's editor is exactly as wide as every other row's. return w def _bind(self, widget, key, kind): """Wire one editor's change event to `_editor_changed(key)`. Every delegate is appended to `self._handlers` first: a Python function handed to a CLR event is not kept alive by the event itself, and a collected handler is a box that silently stops updating anything. """ if not key: return handler = lambda s, e: self._editor_changed(key) self._handlers.append(handler) try: if kind == 'check': widget.Checked += handler widget.Unchecked += handler elif kind == 'choice': # An editable ComboBox raises SelectionChanged only for a # PICKED item; text typed into its box raises neither that nor # a TextChanged of its own -- the TextBox is inside its # template. LostFocus catches the typed value on the way out, # and the routed TextChangedEvent catches it as it is typed # where the template exposes one. widget.SelectionChanged += handler widget.LostFocus += handler try: widget.AddHandler(TextBoxBase.TextChangedEvent, TextChangedEventHandler(handler)) except Exception: pass else: widget.TextChanged += handler except Exception: pass def _set_editor_text(self, key, text): """Write a value INTO an editor without it counting as a user edit. `_suspend` is what makes that true: the change event still fires, and `_editor_changed` returns immediately while it is set, so a derived field being refreshed never marks itself overridden and two derived fields can never chase each other. """ pair = self.editors.get(key) if not pair: return widget, kind = pair if kind == 'check': return was = self._suspend self._suspend = True try: widget.Text = text or '' except Exception: pass finally: self._suspend = was def _editor_changed(self, key): """One editor changed. Owner's rule (2026-09-03): E follows f'c live, a manual edit of E locks it, and nothing ever writes back to f'c. So: editing a DERIVED field marks it overridden and recomputes nothing (one-directional -- the source is never rewritten from the derived value); editing anything else refreshes the derived fields that read it, skipping any the user has taken over. """ if self._suspend: return if key == 'section_type': self._sync_sandwich_groups() if key in F.DERIVED_FIELDS: self._overridden.add(key) elif key is not None: self._recompute_derived(key) self.refresh_gate() self._redraw_drawings() def _recompute_derived(self, source_key): values = self.current_values() for dkey in F.derived_dependents(source_key): if dkey in self._overridden: continue v = F.derived_value(dkey, values) self._set_editor_text( dkey, '' if v is None else F.format_number(round(v))) # -- load combinations --------------------------------------------------- def lc_table_widget(self, block): """One editable load-combination table plus its `Add LC` button. The table itself is an ordinary `_render_table` grid -- every factor cell is a live editor because `_render_table` now honours any `editor` kind, not only `check`. What is added here is the button, and the state it needs to append a row to the grid IN PLACE. In place, not by re-rendering the tab: rebuilding would construct a second set of widgets for every field in the section and re-register them in `self.editors` / `self.drawings`, orphaning the ones actually on screen -- the gate would then read boxes nobody can see. """ grid = _render_table(block, self) rows = block.get('rows') or [] used = [] for row in rows: rid = row[0] if row else '' if isinstance(rid, str) and rid.startswith('u'): try: used.append(int(rid[1:])) except (TypeError, ValueError): pass state = { 'grid': grid, 'name': block.get('name') or '', 'factors': list(block.get('factors') or []), # header row + the rows already drawn 'next_row': 1 + len(rows), 'body_rows': len(rows), 'next_id': (max(used) + 1) if used else 1, } panel = StackPanel() panel.Children.Add(grid) btn = Button() btn.Content = 'Add LC' btn.Width = 90 btn.Margin = Thickness(0, 4, 0, 8) btn.HorizontalAlignment = HorizontalAlignment.Left handler = lambda s, e: self._add_lc_row(state) self._handlers.append(handler) btn.Click += handler panel.Children.Add(btn) return panel def _add_lc_row(self, state): """Append one blank user combination to a live table grid. The new row's id is `u1`, `u2`, ... -- never a number, so it can never collide with a code row and a reader can always tell which rows are ACI's and which are the user's. Its factors start BLANK rather than at zero: a zero factor is a real instruction ("this load is excluded"), and seeding one would put a decision in the deck that nobody made. """ grid = state['grid'] name = state['name'] rid = 'u{0}'.format(state['next_id']) state['next_id'] += 1 row_def = RowDefinition() row_def.Height = GridLength.Auto grid.RowDefinitions.Add(row_def) r = state['next_row'] state['next_row'] += 1 zebra = (state['body_rows'] % 2 == 1) state['body_rows'] += 1 cell = _grid_text_cell(rid, zebra=zebra) Grid.SetRow(cell, r) Grid.SetColumn(cell, 0) grid.Children.Add(cell) c = 1 for col in state['factors']: key = F.load_combo_factor_key(name, rid, col) self.values0[key] = '' wrapped = _cell_border( self.make_editor('num', key, None, width=44), zebra=zebra) Grid.SetRow(wrapped, r) Grid.SetColumn(wrapped, c) grid.Children.Add(wrapped) c += 1 include_key = F.load_combo_include_key(name, rid) # Pre-ticked: a row the user just asked for is a row they mean to use. self.values0[include_key] = 'yes' check = self.make_editor('check', include_key) check.HorizontalAlignment = HorizontalAlignment.Center wrapped = _cell_border(check, zebra=zebra) Grid.SetRow(wrapped, r) Grid.SetColumn(wrapped, c) grid.Children.Add(wrapped) self.refresh_gate() # -- editable tables (owner, 2026-09-04) --------------------------------- def editable_table_widget(self, block): """`{'kind': 'editable_table', 'table', 'columns', 'add_label', 'min_rows'}` -- a pre-filled, user-editable grid over the field model's `
..` row keys (`_row_ids` / `_row_key` / `_add_row` / `_remove_row` above). Owner, 2026-09-04: "restrain 现在 为啥变成了不可编辑了?...只是让你预先输入,不是让你直接锁死" -- a pre-filled table is a STARTING POINT, never a lock. Every cell is a live editor registered in `self.editors` under its row key exactly the way `lc_table_widget`'s factor cells are, so `current_values()`, the gate, and `depends_on` drawings all see an edit the same way they see any other field. """ table = block.get('table') colspecs = block.get('columns') or [] add_label = block.get('add_label') or 'Add row' min_rows = block.get('min_rows') or 0 cols = [c.get('col') for c in colspecs] if not table or not cols: return StackPanel() grid = Grid() for _ in colspecs: grid.ColumnDefinitions.Add(ColumnDefinition()) rm_col = ColumnDefinition() rm_col.Width = GridLength(28) grid.ColumnDefinitions.Add(rm_col) header_row = RowDefinition() header_row.Height = GridLength.Auto grid.RowDefinitions.Add(header_row) for c, spec in enumerate(colspecs): cell = _grid_text_cell(spec.get('label') or spec.get('col') or '', bold=True, header=True) Grid.SetRow(cell, 0) Grid.SetColumn(cell, c) grid.Children.Add(cell) blank_hdr = _cell_border(TextBlock(), header=True) Grid.SetRow(blank_hdr, 0) Grid.SetColumn(blank_hdr, len(colspecs)) grid.Children.Add(blank_hdr) state = {'grid': grid, 'table': table, 'colspecs': colspecs, 'cols': cols, 'next_row': 1, 'body_rows': 0, 'rows': {}} panel = StackPanel() panel.Children.Add(grid) ids = _row_ids(table, self.values0) for n in ids: self._add_editable_row(state, n) for _ in range(max(0, min_rows - len(ids))): n = _add_row(table, self.values0, cols, {}) self._add_editable_row(state, n) btn = Button() btn.Content = add_label btn.Margin = Thickness(0, 4, 0, 8) btn.Padding = Thickness(8, 2, 8, 2) btn.HorizontalAlignment = HorizontalAlignment.Left handler = lambda s, e: self._append_editable_row(state) self._handlers.append(handler) btn.Click += handler panel.Children.Add(btn) return panel def _append_editable_row(self, state): n = _add_row(state['table'], self.values0, state['cols'], {}) self._add_editable_row(state, n) self.refresh_gate() self._redraw_drawings(keys=[state['table']]) def _add_editable_row(self, state, n): """Append one live row (row number `n`) of `state['table']` to its grid, plus a per-row "x" remove button. Every cell wrapper is kept in `state['rows'][n]` so `_remove_editable_row` can collapse exactly this row without disturbing any other row's grid position.""" grid = state['grid'] row_def = RowDefinition() row_def.Height = GridLength.Auto grid.RowDefinitions.Add(row_def) r = state['next_row'] state['next_row'] += 1 zebra = (state['body_rows'] % 2 == 1) state['body_rows'] += 1 table = state['table'] row_widgets = [] for c, spec in enumerate(state['colspecs']): col = spec.get('col') key = _row_key(table, n, col) editor = self.make_editor(spec.get('editor'), key, spec.get('choices'), width=spec.get('width')) if spec.get('editor') == 'check': editor.HorizontalAlignment = HorizontalAlignment.Center wrapped = _cell_border(editor, zebra=zebra) Grid.SetRow(wrapped, r) Grid.SetColumn(wrapped, c) grid.Children.Add(wrapped) row_widgets.append(wrapped) rm_btn = Button() rm_btn.Content = u'×' rm_btn.Width = 22 rm_btn.Height = 22 rm_btn.Padding = Thickness(0) handler = lambda s, e: self._remove_editable_row(state, n) self._handlers.append(handler) rm_btn.Click += handler wrapped_rm = _cell_border(rm_btn, zebra=zebra) Grid.SetRow(wrapped_rm, r) Grid.SetColumn(wrapped_rm, len(state['colspecs'])) grid.Children.Add(wrapped_rm) row_widgets.append(wrapped_rm) state['rows'][n] = row_widgets def _remove_editable_row(self, state, n): """Flip row `n`'s `present` flag off and collapse its cells. The editors stay registered (a stray `current_values()` read of a collapsed row's key is harmless) -- what actually drops the row from the deck / gate / any `_row_ids` walk is `present` going blank, the SAME mechanism `_add_editable_row` sets it through.""" _remove_row(state['table'], self.values0, n) for widget in state.get('rows', {}).get(n, []): try: widget.Visibility = Visibility.Collapsed except Exception: pass self.refresh_gate() self._redraw_drawings(keys=[state['table']]) # -- drawings ------------------------------------------------------------ def drawing_widget(self, block): """One `drawing` block -> its widget. `full_width: True` routes to the tab-width, aspect-ratio drawing (owner, 2026-09-04: "panel 的长度 总要占满整个显示窗口的宽度"); everything else is the original fixed-pixel-size drawing, unchanged.""" if block.get('full_width'): return self._full_width_drawing_widget(block) return self._fixed_drawing_widget(block) # -- fixed-size drawings (unchanged behaviour, `module`-aware) ---------- def _fixed_drawing_widget(self, block): source = block.get('source') modname = block.get('module') caption = block.get('caption') w = block.get('w') or SKETCH_W h = block.get('h') or SKETCH_H container = StackPanel() # Left-aligned and clipping for the same reason the border inside # `_drawing_canvas` is: a fixed-size drawing inside a star column must # never be able to paint into its neighbour. container.HorizontalAlignment = HorizontalAlignment.Left container.ClipToBounds = True entry = {'kind': 'fixed', 'container': container, 'source': source, 'modname': modname, 'caption': caption, 'w': w, 'h': h, 'depends_on': block.get('depends_on') or []} self.drawings.append(entry) self._redraw_fixed_entry(entry, self.values0) return container def _redraw_fixed_entry(self, entry, values): container = entry['container'] container.Children.Clear() container.Children.Add(_drawing_canvas( entry['source'], values, entry['w'], entry['h'], modname=entry.get('modname'))) if entry.get('caption'): cap = TextBlock() cap.Text = entry['caption'] cap.TextDecorations = TextDecorations.Underline cap.HorizontalAlignment = HorizontalAlignment.Center cap.FontSize = 11 cap.Margin = Thickness(0, 0, 0, 10) container.Children.Add(cap) # -- full-width drawings (owner, 2026-09-04) ----------------------------- def _full_width_drawing_widget(self, block): """`{'kind': 'drawing', 'full_width': True, 'aspect': h/w, ...}` -- a drawing whose Canvas is resized to the tab content's own usable width on every layout pass, height = width * aspect. Optionally carries a draggable `cutline` (see `_add_cutline`). WHY `border` (not `container`) IS LEFT ALIGNMENT-UNSET: every other drawing in this file (`_fixed_drawing_widget`, `_drawing_canvas`) is explicitly `HorizontalAlignment.Left` so a fixed-pixel drawing never stretches wider than its own declared size. This one is the opposite: `FrameworkElement`'s default `HorizontalAlignment` is `Stretch`, and this block's parent (`_section_tab`'s `content` StackPanel, itself inside a `ScrollViewer` whose horizontal scrollbar is DISABLED -- see `_tab`) hands a `Stretch`-aligned child its own full available width. That width is exactly "the tab content's usable width" the contract asks for, with no need to walk up to the `TabControl` / `ScrollViewer` by hand. """ source = block.get('source') modname = block.get('module') caption = block.get('caption') try: aspect = float(block.get('aspect') or 0.28) except (TypeError, ValueError): aspect = 0.28 if aspect <= 0: aspect = 0.28 cutline_spec = block.get('cutline') depends_on = block.get('depends_on') or [] canvas = Canvas() canvas.Background = Brushes.WhiteSmoke canvas.ClipToBounds = True canvas.HorizontalAlignment = HorizontalAlignment.Left notes = StackPanel() notes.Margin = Thickness(4, 2, 4, 2) outer = StackPanel() outer.Children.Add(canvas) outer.Children.Add(notes) if caption: cap = TextBlock() cap.Text = caption cap.TextDecorations = TextDecorations.Underline cap.HorizontalAlignment = HorizontalAlignment.Center cap.FontSize = 11 cap.Margin = Thickness(0, 4, 0, 4) outer.Children.Add(cap) border = Border() border.BorderBrush = _RULE border.BorderThickness = Thickness(1) border.Margin = Thickness(0, 4, 0, 4) border.Child = outer # No explicit HorizontalAlignment: Stretch (the WPF default) is what # makes `border.ActualWidth` track the tab content's own width. entry = {'kind': 'full_width', 'container': canvas, 'source': source, 'modname': modname, 'aspect': aspect, 'w': 1100.0, 'h': 1100.0 * aspect, 'depends_on': depends_on, 'cutline': cutline_spec, 'notes': notes, 'cutline_line': None, 'cutline_hit': None, 'cutline_key': None, # Optional block key naming a geometry function in this # drawing's own module (e.g. 'outline_geometry' in # `pa_sketch_extents`) -- see `_cutline_geometry`. 'geometry': block.get('geometry')} self.drawings.append(entry) def _resize(sender, args): try: new_w = border.ActualWidth except Exception: new_w = 0.0 if not new_w or new_w <= 1.0: new_w = 1100.0 # Ignore sub-pixel layout jitter -- redrawing on a fraction of a # pixel of movement is wasted work and can thrash during a # window resize. if abs(new_w - entry['w']) < 2.0: return entry['w'] = new_w entry['h'] = new_w * entry['aspect'] self._redraw_full_width_entry(entry, self.current_values()) self._handlers.append(_resize) border.SizeChanged += _resize # First paint at the fallback width (1100, the contract's own # number); `SizeChanged` fires once real layout happens and repaints # at the tab's true content width -- so the very first frame is # already close, and the real one follows within the same pass. self._redraw_full_width_entry(entry, self.values0) return border def _redraw_full_width_entry(self, entry, values): canvas = entry['container'] w, h = entry['w'], entry['h'] canvas.Width = w canvas.Height = h canvas.Children.Clear() fn, resolve_err = _resolve_drawing_fn(entry['source'], entry.get('modname')) if fn is None: primitives, problems = [], [resolve_err] else: try: primitives, problems = fn(values, int(w), int(h)) except Exception as exc: primitives, problems = [], ['drawing failed: {0}'.format(exc)] for prim in primitives: try: _draw_primitive(canvas, prim) except Exception: pass self._fill_drawing_notes(entry['notes'], problems) if entry.get('cutline'): self._add_cutline(entry, values) def _fill_drawing_notes(self, panel, problems): panel.Children.Clear() for problem in (problems or [])[:4]: note = TextBlock() note.Text = u'• ' + problem note.FontSize = 10 note.Foreground = Brushes.Firebrick note.TextWrapping = TextWrapping.Wrap note.Margin = Thickness(0, 0, 0, 3) panel.Children.Add(note) # -- the draggable cut line (owner, 2026-09-04) -------------------------- def _cutline_geometry(self, entry, values): """The pixel box `{'x0','x1','y0','y1'}` the draggable cutline should span -- the renderer's half of the contract `pa_sketch_extents.outline_geometry()`'s own docstring describes ("OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER"). A block opts in with `'geometry': ''` (resolved in the drawing's OWN module, the same `modname` `_resolve_drawing_fn` already uses); with no `geometry` key, an unresolved module/function, a non-dict return, or a return missing any of the four keys, this falls back to the bare canvas box (`x0=0, x1=w, y0=0, y1=h`) -- exactly the old behaviour -- so a drawing that never declares `geometry` keeps working unchanged. """ w, h = entry['w'], entry['h'] fallback = {'x0': 0.0, 'x1': w, 'y0': 0.0, 'y1': h} geom_name = entry.get('geometry') if not geom_name: return fallback mod, err = _load_section_module(entry.get('modname')) if mod is None: return fallback fn = getattr(mod, geom_name, None) if fn is None: return fallback try: geo = fn(values, w, h) except Exception: return fallback if not isinstance(geo, dict) or not all( k in geo for k in ('x0', 'x1', 'y0', 'y1')): return fallback return geo def _add_cutline(self, entry, values): """A draggable vertical line over a full-width outline drawing -- "panel outline 上面做一个 cut-line,用户可以拖动,然后 cross section 动态加载...跟做核磁一样" (like an MRI slice). Drawn as the LAST two children added to `entry['container']` (a plain visible line plus a wide transparent one on top of it as the real hit target -- a 2px stroke is nearly impossible to grab with a mouse), so it always paints over the primitives `_redraw_full_width_entry` just drew. Dragging writes the fraction as TEXT into `self.values0[key]` (never through an editor -- there is none) and throttles the redraw of every OTHER drawing that declares `'depends_on': [key]` to at most one per dispatcher pass (`_throttled_redraw`); it never rebuilds the form and never redraws this entry itself (repositioning the two lines directly is enough while dragging). THE LINE'S X IS `x0 + frac*(x1-x0)`, not `frac*w`: the outline drawing has margins (`pa_sketch_extents.outline_geometry`), so the panel rectangle's left/right edges sit at `x0`/`x1`, not `0`/`w` -- see `_cutline_geometry`. The line itself is also drawn only between `y0-8` and `y1+8` (the panel's own vertical extent, padded 8px past each end) rather than the whole canvas height, so it reads as marking the panel, not the canvas. """ spec = entry.get('cutline') or {} key = spec.get('key') if isinstance(spec, dict) else spec try: default = float(spec.get('default', 0.5)) if isinstance(spec, dict) else 0.5 except (TypeError, ValueError): default = 0.5 if not key: return raw = values.get(key, self.values0.get(key)) try: frac = float(raw) if raw not in (None, '') else default except (TypeError, ValueError): frac = default frac = max(0.0, min(1.0, frac)) canvas = entry['container'] w, h = entry['w'], entry['h'] geo = self._cutline_geometry(entry, values) x0, x1 = geo['x0'], geo['x1'] if x1 < x0: x0, x1 = x1, x0 span = x1 - x0 y_top = max(geo['y0'] - 8.0, 0.0) y_bot = min(geo['y1'] + 8.0, h) x = x0 + frac * span line = Line() line.X1, line.X2 = x, x line.Y1, line.Y2 = y_top, y_bot line.Stroke = Brushes.Firebrick line.StrokeThickness = 2.0 line.StrokeDashArray = DoubleCollection([4.0, 2.0]) line.IsHitTestVisible = False # the wide `hit` line below is grabbed try: line.Cursor = Cursors.SizeWE except Exception: pass hit = Line() hit.X1, hit.X2 = x, x hit.Y1, hit.Y2 = y_top, y_bot hit.Stroke = Brushes.Transparent hit.StrokeThickness = 14.0 try: hit.Cursor = Cursors.SizeWE except Exception: pass canvas.Children.Add(line) canvas.Children.Add(hit) entry['cutline_line'] = line entry['cutline_hit'] = hit entry['cutline_key'] = key drag = {'on': False} def _down(s, e): drag['on'] = True try: hit.CaptureMouse() except Exception: pass e.Handled = True def _move(s, e): if not drag['on']: return pos = e.GetPosition(canvas) nx = pos.X if nx < x0: nx = x0 if nx > x1: nx = x1 line.X1 = nx line.X2 = nx hit.X1 = nx hit.X2 = nx frac2 = ((nx - x0) / span) if span else 0.0 frac2 = max(0.0, min(1.0, frac2)) self.values0[key] = F.format_number(frac2) self._throttled_redraw(canvas, key) e.Handled = True def _up(s, e): drag['on'] = False try: hit.ReleaseMouseCapture() except Exception: pass e.Handled = True self._handlers.extend([_down, _move, _up]) hit.MouseLeftButtonDown += _down hit.MouseMove += _move hit.MouseLeftButtonUp += _up def _throttled_redraw(self, canvas, key): """Schedule at most ONE `_redraw_drawings(keys=[key])` per dispatcher pass -- a mouse-move batch during a cutline drag can raise dozens of events before WPF next paints, and redrawing a dependent section on every one of them is the "never a full form rebuild" the contract explicitly rules out. `_cutline_pending[key]` is the guard: a second `MouseMove` that arrives while a redraw for the same key is already queued is a no-op here, and the queued one reads whatever `values0` holds at the moment it actually runs -- always the latest position. """ if self._cutline_pending.get(key): return self._cutline_pending[key] = True def _do(): self._cutline_pending[key] = False self._redraw_drawings(keys=[key]) try: canvas.Dispatcher.BeginInvoke(DispatcherPriority.Background, Action(_do)) except Exception: _do() def _redraw_drawings(self, keys=None): """Redraw every registered drawing, or -- with `keys` -- only the ones whose own `depends_on` names one of `keys`. `keys=None` (every ordinary field edit, via `_editor_changed`) is the original behaviour, unchanged; `keys=[...]` is what the cutline drag uses to redraw the section below without touching the outline drawing that hosts the cutline itself. """ vals = self.current_values() for entry in self.drawings: if keys is not None: deps = entry.get('depends_on') or [] if not any(k in deps for k in keys): continue try: if entry.get('kind') == 'full_width': self._redraw_full_width_entry(entry, vals) else: self._redraw_fixed_entry(entry, vals) except Exception: pass # a drawing is never worth taking the form down # -- ui ---------------------------------------------------------------- def _section_tab(self, title, modname): content = StackPanel() content.Margin = Thickness(6, 4, 6, 4) mod, err = _load_section_module(modname) if mod is None: content.Children.Add(_missing_section_note(title, modname, err)) return _tab(title, content) if not hasattr(mod, 'page'): content.Children.Add(_missing_section_note( title, modname, 'module has no page()')) return _tab(title, content) try: blocks, problems = mod.page(self.values0, reads=self.reads) except Exception as exc: content.Children.Add(_missing_section_note(title, modname, exc)) return _tab(title, content) rows = _RowCount() for b in (blocks or []): w = _render_block(b, self, rows=rows) if w is not None: content.Children.Add(w) # The Sandwich-only groups (per-wythe Concrete Extents + the wythe-2 # material trio) have no home in the section modules yet; they are # appended here, collapsed, and shown only when `section_type` is # Sandwich (see `_sync_sandwich_groups`). self._append_sandwich_groups(content, title) if problems: exp = Expander() exp.Header = 'Notes ({0})'.format(len(problems)) exp.IsExpanded = False exp.Margin = Thickness(0, 0, 0, 8) plist = StackPanel() plist.Margin = Thickness(6, 4, 6, 4) for p in problems: plist.Children.Add(_hint_block(u'• ' + p)) exp.Content = plist content.Children.Insert(0, exp) return _tab(title, content) def _build(self): body = StackPanel() body.Margin = Thickness(6, 4, 6, 4) self.tabctrl = TabControl() for title, modname in SECTION_MODULES: self.tabctrl.Items.Add(self._section_tab(title, modname)) self.tabctrl.Height = 620 body.Children.Add(self.tabctrl) self.status = _label_block('', size=11) body.Children.Add(self.status) self.run_btn = Button() self.run_btn.Content = 'Run Analysis' self.run_btn.Margin = Thickness(0, 6, 0, 4) self.run_btn.Width = 140 self.run_btn.HorizontalAlignment = HorizontalAlignment.Left self.run_btn.Click += self._on_run body.Children.Add(self.run_btn) # The tabs have now been built, so every field's help has been handed # to `add_note`; drop the single collapsed Notes panel in at the very # top of the form (owner, 2026-09-03). if self.notes: body.Children.Insert(0, self._build_notes_expander()) exp = Expander() exp.Header = self.title exp.IsExpanded = False exp.Content = body exp.Margin = Thickness(0, 2, 0, 2) # Every tab is built now, so the Sandwich-only groups exist; sync them # to the (pre-filled) section_type once before the form shows. self._sync_sandwich_groups() self.refresh_gate() return exp # -- behaviour --------------------------------------------------------- def _update_gcpi(self): if self.gcpi_label is None: return pair = self.editors.get('wind_enclosure') box = pair[0] if pair else None gcpi, why = F.wind_gcpi(box.Text if box is not None else None) if gcpi is None: self.gcpi_label.Text = '' self.gcpi_label.Foreground = Brushes.Gray else: self.gcpi_label.Text = 'GCpi = +/-{0}'.format(gcpi) self.gcpi_label.Foreground = Brushes.SeaGreen def refresh_gate(self, *args): self._update_gcpi() errors = F.validate(self.current_values())[0] self.run_btn.IsEnabled = not errors if errors: shown = '; '.join(errors[:6]) if len(errors) > 6: shown += ' ...' self.status.Text = 'Still needed: ' + shown self.status.Foreground = Brushes.Firebrick else: self.status.Text = 'Ready to run.' self.status.Foreground = Brushes.SeaGreen def _on_change(self, sender, event): """Kept only for any caller that still wires a bare WPF handler: it is `_editor_changed` with no key, i.e. refresh the gate and the drawings but recompute nothing derived (there is no source key to derive from). Every editor this file builds goes through `_bind` instead.""" self._editor_changed(None) def _on_run(self, sender, event): errors, parsed = F.validate(self.current_values()) if errors: self.status.Text = 'Still needed: ' + '; '.join(errors[:6]) self.status.Foreground = Brushes.Firebrick runlog.record(TITLE, doc_title=doc.Title, marks=[self.mark], outcome='refused', report_text='Still needed: ' + '; '.join(errors), values=self.current_values()) return deck = F.build_deck(parsed) ok, message = _run_one(self.mark, deck) if ok: self.status.Text = 'Ran. Page opened.' self.status.Foreground = Brushes.SeaGreen _report_one(self.mark, True, message, deck) else: self.status.Text = 'Did not run -- see the output panel.' self.status.Foreground = Brushes.Firebrick _report_one(self.mark, False, message, deck) def _report_one(mark, ok, message, deck=None): # Every run, ok or not, lands in the run log with the deck it was fed -- # that is the form's values after validation, which is what a report # about "the button did X" needs to be checked against. if ok: output.print_md('- **{0}** — ran. [{1}]({2})'.format( mark, os.path.basename(message), 'file:///' + message.replace('\\', '/'))) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='ok', report_path=message, values=deck) else: output.print_md('- **{0}** — NOT RUN: {1}'.format(mark, message)) runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='error', report_text=message, values=deck) def _analyze_all(panels, overall_status): named = [(pf.mark, pf.current_values()) for pf in panels] to_run, skipped = F.batch_plan(named) output.print_md('## Analyze All') ran_ok = 0 for label, parsed in to_run: deck = F.build_deck(parsed) ok, message = _run_one(label, deck) _report_one(label, ok, message, deck) if ok: ran_ok += 1 for label, errors in skipped: output.print_md('- **{0}** — SKIPPED (incomplete): {1}'.format( label, '; '.join(errors[:6]))) runlog.record(TITLE, doc_title=doc.Title, marks=[label], outcome='refused', report_text='Skipped (incomplete): ' + '; '.join(errors)) overall_status.Text = ('Analyze All: {0} ran, {1} skipped (incomplete). ' 'See the output panel.'.format(ran_ok, len(skipped))) overall_status.Foreground = (Brushes.SeaGreen if not skipped else Brushes.DarkGoldenrod) def show_form(panels): # Sized for real content -- each tab is a full Eriksson printed page, not # a handful of text boxes, and the old 620x720 could not show one without # a scrollbar inside a scrollbar. This still fits a 1080p screen with # room to spare, and every scroller inside still has its own # ScrollBarVisibility.Auto for anyone on something smaller. win = Window() win.Title = TITLE win.Width = 1180 win.Height = 880 win.MinWidth = 760 win.MinHeight = 520 win.SizeToContent = SizeToContent.Manual win.WindowStartupLocation = WindowStartupLocation.CenterScreen root = Grid() root.Margin = Thickness(10) row_top = RowDefinition() row_bottom = RowDefinition() row_bottom.Height = GridLength.Auto root.RowDefinitions.Add(row_top) root.RowDefinitions.Add(row_bottom) header = _math_block( 'Confirm the pre-filled fields, fill the blanks (especially the ' 'support positions), and press Run Analysis on a panel below -- or ' 'Analyze All for every complete one.', size=11, brush=_QUIET) header.TextWrapping = TextWrapping.Wrap header.Margin = Thickness(0, 0, 0, 8) Grid.SetRow(header, 0) # header lives inside the scroller's stack instead of its own grid row, # so it scrolls with the panels rather than stealing a fixed row -- see # below. scroller = ScrollViewer() scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto stack = StackPanel() stack.Children.Add(header) for pf in panels: stack.Children.Add(pf.expander) scroller.Content = stack # Ctrl + mouse wheel zooms the whole form (owner, 2026-09-03). A # LayoutTransform on the stack, not a RenderTransform, so the scroller's # extent follows the scale and nothing is clipped; a plain wheel keeps # scrolling. Clamped to 50 %..300 %. # # This transform is WHY `_drawing_canvas` no longer wraps each drawing # in a `Viewbox`: a `Viewbox` fits itself to whatever space it is # offered, and THIS transform offers its descendants less space the # higher `scale` climbs (it deflates by 1/scale before measuring, then # inflates the result back by scale) -- the two effects cancel exactly # for anything that resizes itself to available space, which is why the # drawings alone did not zoom while ordinary text did. See # `_drawing_canvas`'s own docstring and `panelsketch.drawing_display_scale` # for the fixed-transform fix. zoom = ScaleTransform(1.0, 1.0) stack.LayoutTransform = zoom def _wheel(sender, e): if (Keyboard.Modifiers & ModifierKeys.Control) != ModifierKeys.Control: return factor = 1.1 if e.Delta > 0 else 1.0 / 1.1 scale = max(0.5, min(3.0, zoom.ScaleX * factor)) zoom.ScaleX = scale zoom.ScaleY = scale e.Handled = True scroller.PreviewMouseWheel += _wheel Grid.SetRow(scroller, 0) root.Children.Add(scroller) bottom = StackPanel() bottom.Margin = Thickness(0, 8, 0, 0) Grid.SetRow(bottom, 1) overall_status = _label_block('', size=11) btn_row = StackPanel() btn_row.Orientation = Orientation.Horizontal all_btn = Button() all_btn.Content = 'Analyze All' all_btn.Width = 160 all_btn.Margin = Thickness(0, 0, 8, 0) all_btn.Click += lambda s, e: _analyze_all(panels, overall_status) btn_row.Children.Add(all_btn) close_btn = Button() close_btn.Content = 'Close' close_btn.Width = 100 close_btn.Click += lambda s, e: win.Close() btn_row.Children.Add(close_btn) bottom.Children.Add(btn_row) bottom.Children.Add(overall_status) root.Children.Add(bottom) border = Border() border.Child = root win.Content = border win.ShowDialog() # ------------------------------------------------------------------- main KNOW = None try: KNOW = knowledge.load(doc) except Exception: KNOW = None panels = _resolve_framing() if not panels: not_run('Select one or more structural-framing panels (the pieces you ' 'want analysed), then run this again. Selecting an assembly, or ' 'any member of one, resolves to its framing member.') output.print_md('# {0}'.format(TITLE)) if _USE_WPF: output.print_md('Opened an input form for **{0}** panel(s). Confirm the ' 'pre-filled fields, fill the blanks (especially the ' 'support positions), and press **Run Analysis** on a ' 'panel -- or **Analyze All** for every complete one.' .format(len(panels))) forms_list = [PanelForm(el, i, KNOW, asm) for i, (el, asm) in enumerate(panels)] # The pre-filled state, before the user touches anything: what the reads # recovered from the model is the half of a "field X came up empty" # report that cannot be pasted back by hand. runlog.record(TITLE, doc_title=doc.Title, marks=[pf.mark for pf in forms_list], outcome='opened', values=dict((pf.mark, pf.current_values()) for pf in forms_list)) show_form(forms_list) runlog.record(TITLE, doc_title=doc.Title, marks=[pf.mark for pf in forms_list], outcome='closed') else: output.print_md('Opening the Panel Analysis page for **{0}** panel(s), ' 'one at a time.'.format(len(panels))) for i, (el, asm) in enumerate(panels): _run_panel_web(el, i, KNOW, asm)