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

4461 lines
189 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 `<key>_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.<modname>, 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.<modname>` -- 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 `<table>.<n>.<col>`, present rows being those whose `<table>.<n>.
# 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 `<table>.<n>.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 `<table>.<n>.<col>` 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': '<fn name>'` (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}** &mdash; 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}** &mdash; 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}** &mdash; 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)