25592fa6c2
ci / test (push) Successful in 6m14s
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.
4461 lines
189 KiB
Python
4461 lines
189 KiB
Python
# -*- 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}** — ran. [{1}]({2})'.format(
|
||
mark, os.path.basename(message),
|
||
'file:///' + message.replace('\\', '/')))
|
||
runlog.record(TITLE, doc_title=doc.Title, marks=[mark], outcome='ok',
|
||
report_path=message, values=deck)
|
||
else:
|
||
output.print_md('- **{0}** — NOT RUN: {1}'.format(mark, message))
|
||
runlog.record(TITLE, doc_title=doc.Title, marks=[mark],
|
||
outcome='error', report_text=message, values=deck)
|
||
|
||
|
||
def _analyze_all(panels, overall_status):
|
||
named = [(pf.mark, pf.current_values()) for pf in panels]
|
||
to_run, skipped = F.batch_plan(named)
|
||
output.print_md('## Analyze All')
|
||
ran_ok = 0
|
||
for label, parsed in to_run:
|
||
deck = F.build_deck(parsed)
|
||
ok, message = _run_one(label, deck)
|
||
_report_one(label, ok, message, deck)
|
||
if ok:
|
||
ran_ok += 1
|
||
for label, errors in skipped:
|
||
output.print_md('- **{0}** — SKIPPED (incomplete): {1}'.format(
|
||
label, '; '.join(errors[:6])))
|
||
runlog.record(TITLE, doc_title=doc.Title, marks=[label],
|
||
outcome='refused',
|
||
report_text='Skipped (incomplete): ' + '; '.join(errors))
|
||
overall_status.Text = ('Analyze All: {0} ran, {1} skipped (incomplete). '
|
||
'See the output panel.'.format(ran_ok,
|
||
len(skipped)))
|
||
overall_status.Foreground = (Brushes.SeaGreen if not skipped
|
||
else Brushes.DarkGoldenrod)
|
||
|
||
|
||
def show_form(panels):
|
||
# Sized for real content -- each tab is a full Eriksson printed page, not
|
||
# a handful of text boxes, and the old 620x720 could not show one without
|
||
# a scrollbar inside a scrollbar. This still fits a 1080p screen with
|
||
# room to spare, and every scroller inside still has its own
|
||
# ScrollBarVisibility.Auto for anyone on something smaller.
|
||
win = Window()
|
||
win.Title = TITLE
|
||
win.Width = 1180
|
||
win.Height = 880
|
||
win.MinWidth = 760
|
||
win.MinHeight = 520
|
||
win.SizeToContent = SizeToContent.Manual
|
||
win.WindowStartupLocation = WindowStartupLocation.CenterScreen
|
||
|
||
root = Grid()
|
||
root.Margin = Thickness(10)
|
||
row_top = RowDefinition()
|
||
row_bottom = RowDefinition()
|
||
row_bottom.Height = GridLength.Auto
|
||
root.RowDefinitions.Add(row_top)
|
||
root.RowDefinitions.Add(row_bottom)
|
||
|
||
header = _math_block(
|
||
'Confirm the pre-filled fields, fill the blanks (especially the '
|
||
'support positions), and press Run Analysis on a panel below -- or '
|
||
'Analyze All for every complete one.', size=11, brush=_QUIET)
|
||
header.TextWrapping = TextWrapping.Wrap
|
||
header.Margin = Thickness(0, 0, 0, 8)
|
||
Grid.SetRow(header, 0)
|
||
# header lives inside the scroller's stack instead of its own grid row,
|
||
# so it scrolls with the panels rather than stealing a fixed row -- see
|
||
# below.
|
||
|
||
scroller = ScrollViewer()
|
||
scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto
|
||
stack = StackPanel()
|
||
stack.Children.Add(header)
|
||
for pf in panels:
|
||
stack.Children.Add(pf.expander)
|
||
scroller.Content = stack
|
||
# Ctrl + mouse wheel zooms the whole form (owner, 2026-09-03). A
|
||
# LayoutTransform on the stack, not a RenderTransform, so the scroller's
|
||
# extent follows the scale and nothing is clipped; a plain wheel keeps
|
||
# scrolling. Clamped to 50 %..300 %.
|
||
#
|
||
# This transform is WHY `_drawing_canvas` no longer wraps each drawing
|
||
# in a `Viewbox`: a `Viewbox` fits itself to whatever space it is
|
||
# offered, and THIS transform offers its descendants less space the
|
||
# higher `scale` climbs (it deflates by 1/scale before measuring, then
|
||
# inflates the result back by scale) -- the two effects cancel exactly
|
||
# for anything that resizes itself to available space, which is why the
|
||
# drawings alone did not zoom while ordinary text did. See
|
||
# `_drawing_canvas`'s own docstring and `panelsketch.drawing_display_scale`
|
||
# for the fixed-transform fix.
|
||
zoom = ScaleTransform(1.0, 1.0)
|
||
stack.LayoutTransform = zoom
|
||
|
||
def _wheel(sender, e):
|
||
if (Keyboard.Modifiers & ModifierKeys.Control) != ModifierKeys.Control:
|
||
return
|
||
factor = 1.1 if e.Delta > 0 else 1.0 / 1.1
|
||
scale = max(0.5, min(3.0, zoom.ScaleX * factor))
|
||
zoom.ScaleX = scale
|
||
zoom.ScaleY = scale
|
||
e.Handled = True
|
||
|
||
scroller.PreviewMouseWheel += _wheel
|
||
Grid.SetRow(scroller, 0)
|
||
root.Children.Add(scroller)
|
||
|
||
bottom = StackPanel()
|
||
bottom.Margin = Thickness(0, 8, 0, 0)
|
||
Grid.SetRow(bottom, 1)
|
||
|
||
overall_status = _label_block('', size=11)
|
||
|
||
btn_row = StackPanel()
|
||
btn_row.Orientation = Orientation.Horizontal
|
||
|
||
all_btn = Button()
|
||
all_btn.Content = 'Analyze All'
|
||
all_btn.Width = 160
|
||
all_btn.Margin = Thickness(0, 0, 8, 0)
|
||
all_btn.Click += lambda s, e: _analyze_all(panels, overall_status)
|
||
btn_row.Children.Add(all_btn)
|
||
|
||
close_btn = Button()
|
||
close_btn.Content = 'Close'
|
||
close_btn.Width = 100
|
||
close_btn.Click += lambda s, e: win.Close()
|
||
btn_row.Children.Add(close_btn)
|
||
|
||
bottom.Children.Add(btn_row)
|
||
bottom.Children.Add(overall_status)
|
||
root.Children.Add(bottom)
|
||
|
||
border = Border()
|
||
border.Child = root
|
||
win.Content = border
|
||
win.ShowDialog()
|
||
|
||
|
||
# ------------------------------------------------------------------- main
|
||
|
||
KNOW = None
|
||
try:
|
||
KNOW = knowledge.load(doc)
|
||
except Exception:
|
||
KNOW = None
|
||
|
||
panels = _resolve_framing()
|
||
if not panels:
|
||
not_run('Select one or more structural-framing panels (the pieces you '
|
||
'want analysed), then run this again. Selecting an assembly, or '
|
||
'any member of one, resolves to its framing member.')
|
||
|
||
output.print_md('# {0}'.format(TITLE))
|
||
|
||
if _USE_WPF:
|
||
output.print_md('Opened an input form for **{0}** panel(s). Confirm the '
|
||
'pre-filled fields, fill the blanks (especially the '
|
||
'support positions), and press **Run Analysis** on a '
|
||
'panel -- or **Analyze All** for every complete one.'
|
||
.format(len(panels)))
|
||
forms_list = [PanelForm(el, i, KNOW, asm)
|
||
for i, (el, asm) in enumerate(panels)]
|
||
# The pre-filled state, before the user touches anything: what the reads
|
||
# recovered from the model is the half of a "field X came up empty"
|
||
# report that cannot be pasted back by hand.
|
||
runlog.record(TITLE, doc_title=doc.Title,
|
||
marks=[pf.mark for pf in forms_list], outcome='opened',
|
||
values=dict((pf.mark, pf.current_values())
|
||
for pf in forms_list))
|
||
show_form(forms_list)
|
||
runlog.record(TITLE, doc_title=doc.Title,
|
||
marks=[pf.mark for pf in forms_list], outcome='closed')
|
||
else:
|
||
output.print_md('Opening the Panel Analysis page for **{0}** panel(s), '
|
||
'one at a time.'.format(len(panels)))
|
||
for i, (el, asm) in enumerate(panels):
|
||
_run_panel_web(el, i, KNOW, asm)
|