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.
36 KiB
Panel Analysis page-spec: the JSON contract for the HTML front end
Panel Analysis's WPF input form (前端) is being replaced by an HTML page
rendered in WebView2. The Python side of that boundary is
WillTools.extension/lib/vibe/pa_pagespec.py's build_pagespec(), which
turns everything the old WPF renderer (PanelAnalysis.pushbutton/script.py)
consumed into one JSON document. This file is the contract: the page can
be built and tested in a browser from that JSON alone, with no Revit, no
WPF, and no other Python file open.
Nothing here invents new engineering. Every block, every field, every
default and its citation, and every drawing's numbers already exist in the
five section modules (pa_criteria / pa_extents / pa_structural /
pa_handling / pa_reinforcement), the field model
(panelanalysisform.py), the defaults module (pa_defaults.py), and the
four pa_sketch_* geometry modules. pa_pagespec.py only serialises what
they already compute.
1. Top-level shape
{
"schema": 1,
"title": "Panel Analysis",
"mark": "P101", // '' if not read/typed yet
"pages": [ { "id", "title", "blocks": [...], "problems": [...] }, ... ],
"values": { "<key>": "<string>", ... },
"basis": { "<key>": "<sentence>", ... },
"defaults_why": { "<key>": "<sentence>", ... },
"choices": { "<key>": ["<option>", ...], ... },
"tables": { "<name>": { "columns": [...], "min_rows": 0, "add_label": "Add row",
"empty_state": { "basis_keys": [...], "text": "..." } }, ... },
"geometry": { "<page_id>.<source>": { ... }, ... },
"derived": { "conc_ec_psi": { "sources": ["fc_psi", "unit_weight_pcf"],
"formula": "concrete_modulus_psi" }, ... },
"problem_targets": { "supports_ft": "support", "face_lifters": "face_lifter", ... },
"problems": [ "<sentence>", ... ]
}
derived and problem_targets (2026-09-07, issue #22) are described in
§2a and §7's error handling below; tables[<name>].empty_state in §4.
pages is always exactly five entries, in this order, matching Eriksson
Wall's own section names verbatim:
id |
title | source module |
|---|---|---|
design_criteria |
Design Criteria | pa_criteria |
concrete_extents |
Concrete Extents | pa_extents |
structural_model |
Structural Model | pa_structural |
handling |
Handling | pa_handling |
reinforcement |
Reinforcement | pa_reinforcement |
A page whose source module could not be imported, or whose page() raised,
still appears with "blocks": [] and a problems entry naming why — the
other four pages are unaffected. This is the same "one bad section never
takes the rest down" rule the old WPF renderer followed
(script.py's _load_section_module / _missing_section_note).
Top-level problems is for spec-wide issues (e.g. prefill() itself
raised); page-specific issues live in that page's own problems array.
2. Block kinds
Every block is a dict with a "kind" key. An unrecognised kind should
render as a small visible note naming it (never silently vanish) — same
discipline the WPF renderer's _render_unknown used.
heading
{"kind": "heading", "text": "Design Criteria", "level": 1}
level is 1, 2, or 3 — decreasing emphasis, no other meaning.
columns
{"kind": "columns", "columns": [ [block, block, ...], [block, ...] ]}
A row of side-by-side sub-lists of blocks, each its own vertical flow. Star
(equal-width) columns; nesting is allowed (a columns block can appear
inside another column).
field
{
"kind": "field", "key": "fc_psi", "label": "Concrete strength at 28 days",
"symbol": "f\u2032c (psi)", "hint": "The concrete's design strength...",
"required": true, "editor": "num", "choices": null, "multi_value": false
}
keyindexes into the top-levelvalues/basis/defaults_whydicts. A block with nokey(rare — some resistance-factor table cells) carries no value of its own.editoris one oftext,num,int,check,choice.pa_pagespechas already applied the same upgrade the WPF renderer did: a plaintext/numfield whose key is a known enumeration (panelanalysisform.choices_for) arrives here aseditor: "choice"withchoicesfilled in, even though the section module itself declared ittext.choicesisnullfor a free editor, else a list of strings for a dropdown/combo.multi_value: truemarks a field whose text holds more than one number or line (supports,openings— a comma list or several lines). Render it full-width (a<textarea>-shaped box), not an inline<input>— the WPF form once truncated these badly before that rule existed (script.py's_render_multi_value_field).hintis one plain-English sentence. The WPF form collects every field'shintinto one collapsed "Notes" panel instead of printing it inline (owner ruling, 2026-09-03/04) — the HTML page should do the same: walk everyfieldblock'shint(and everyquiet: truesentence, below) into one collapsed panel, rather than re-deriving that server-side.
kv
{"kind": "kv", "rows": [["Panel", "P101"], ["Design number", "PNL1.0"]]}
A plain two-column label/value readout. Never editable.
live (2026-09-07, issue #23): a kv row may be [label, value, live]
where live names a row-table sum the browser keeps in step with that
table's rows as they are edited (the Reinforcement page's Strand count,
Total IP and Aps rows, which used to go stale until the next build/Run):
{"kind": "kv", "rows": [
["Strand count", "4 (sum of the strand rows above)",
{"table": "strand_row", "terms": ["count"], "format": "%d"}],
["Aps, in\u00b2", "0.612",
{"table": "strand_row",
"terms": ["count", {"col": "dia_in",
"map": {"3/8": 0.085, "7/16": 0.115, "1/2": 0.153,
"1/2 SP": 0.167, "9/16": 0.192,
"0.6": 0.217, "0.7": 0.294}}],
"format": "%.4f",
"unknown": "not shown -- ..."}]}
tableis a row-table name (§3).termsis the per-row product list, summed over the table's PRESENT rows (<table>.<n>.presenttruthy, ascending). A term is either a column key (a string; the cell parsed as a number) or{"col": key, "map": {<cell text>: number}}(the cell text looked up inmap-- the page ships the map, so it must cover every value the editor can produce). A present row with any term that does not parse, or whose text is not in the map, makes the whole value theunknownsentence; a row with nounknownrenders an empty cell. A term never resolves to a guessed zero.formatis a printf-style float format applied to the sum --%.Nf, with trailing zeros and a trailing.trimmed exactly as the Python side trims (('%.4f' % v).rstrip('0').rstrip('.'));%d(an integer-only term list) prints the truncated integer.- The browser recomputes every live row of a table whenever ANY key of
that table changes --
<table>.<n>.<col>or<table>.<n>.present, add/remove row included. The server-renderedvalueis the initial display, so the page reads the same with JS off. Rows without a third element are unchanged two-element rows -- the third element is the spec itself, no"live"wrapper key.
sentence
{"kind": "sentence", "text": "No openings or reveals.", "quiet": false}
Plain prose. quiet: true marks helper/explanatory prose (mirrors a
field's hint) that belongs in the collapsed Notes panel rather than
inline; quiet absent or false prints in the normal flow (e.g. the
openings-count summary line, or a miter's "not included" footnote).
table
{
"kind": "table",
"headers": ["\u03c6", "Developed", "Transferring"],
"header_groups": null,
"rows": [
["Bending", {"editor": "num", "key": "phi_flexure", "choices": null}, "0.9"],
["Compression", {"editor": "num", "key": "design_criteria.phi_compression", "choices": null},
"Not applicable once the strand has fully transferred."]
]
}
A grid. Each cell is either a plain string (render as text) or a dict
with editor/key/choices (and optionally width, default) — render
a live input bound to values[key], same as a field block's editor.
header_groups, when present, is [[label, colspan], ...] spanning the
header row above the plain headers row.
lc_table
The ACI load-combination grid — same shape as table plus:
{"kind": "lc_table", "name": "strength", "headers": [...], "rows": [...],
"factors": ["D", "L", "R", "W", "E", "T", "H"], "row_key": null}
Every factor cell is {"editor": "num", "key": "load_combo.strength.1.D", "default": "1.4"}; the last cell of each row is
{"editor": "check", "key": "load_combo.strength.1.include"}. The page
should offer an "Add LC" affordance that appends a row with id u1, u2,
... (never a bare number, so a user row can never collide with a code row)
using the same factors list to build its keys
(load_combo.<name>.u<n>.<factor> / load_combo.<name>.u<n>.include) and
writing the new keys into its own values — this mirrors
script.py's _add_lc_row exactly, minus the WPF widgets.
editable_table
{"kind": "editable_table", "table": "restraint",
"columns": [{"col": "elevation_ft", "label": "Elevation (ft)", "editor": "num", "choices": null}, ...],
"add_label": "Add restraint"}
The block itself carries no rows — rows live in values under
"<table>.<n>.<col>" keys (see §3). The same shape is mirrored, de-duped
across the whole spec, in the top-level tables dict (§4) so the page does
not have to walk every block to discover a table's columns.
matrix
{
"kind": "matrix", "columns": ["Strip", "Rotate to Vertical", "Finish", "Ship", "Erect"],
"rows": [
{"label": "Load multiplier", "hint": null,
"cells": [{"editor": "num", "key": "stage.strip.load_multiplier"}, null, ..., {"na": true}]}
]
}
A fixed grid (the Handling "Stage Options" matrix) — one row label, N
column cells. A cell is null (blank, still fillable — should not
normally happen), {"na": true} (render an em dash — this stage does not
apply, never confuse with blank), or {"editor", "key", "choices"?} like
any other live cell. A row's hint, when present, is routed the same way a
quiet sentence is (§ above) — into the collapsed Notes panel, prefixed
with the row's own label, not printed under the row.
legend
{"kind": "legend", "compact": true,
"groups": [["Shapes", [["\u25cf", "Face lifter"], ["\u25b2", "Edge lifter (side or top)"]]]]}
Static, never editable — a key for a drawing's glyphs.
drawing
{
"kind": "drawing", "module": "pa_sketch_extents", "source": "outline",
"full_width": true, "aspect": 0.4, "caption": "Panel outline...",
"cutline": {"key": "_cut_frac", "default": 0.5},
"depends_on": ["openings_rows", "length_ft", "width_ft"],
"drawing_key": "concrete_extents.outline"
}
module/source/geometry(the pixel-box function name, WPF-only — see §5) are carried through unchanged for provenance/debugging; the HTML page does not need them and should ignoregeometryhere (a string) in favour of the top-levelgeometrydict.full_width/aspect: the drawing should span its container's full width; height = width ×aspect.cutline, when present, names avalueskey (a0..1fraction of the panel length) and its default — the page owns dragging this line and rewriting that key; see §6, "section-at-cut".depends_on: thevalueskeys that should trigger a redraw of THIS drawing when they change — never redraw every drawing on an edit. An entry is matched as an exact key OR as a<table>.prefix, so a row table is listed by its bare name (restraint,face_lifter); aface_lifter.3.x_ftedit then hitsface_lifter. List the TABLE, not the legacy text key it replaced — the Handling drawing listed onlyface_liftersuntil 2026-09-07 and never redrew on a row edit (issue #22, item 7).drawing_key: added bypa_pagespec(not present in the raw section module's block) — the key into the top-levelgeometrydict (§5) that holds this drawing's real-unit numeric data.
section
{"kind": "section", "title": "Seismic Loads", "collapsed": true, "blocks": [...]}
A collapsible group. collapsed: true means closed by default (some
Handling/Structural-Model groups start folded per owner ruling,
2026-09-04).
rule / space
{"kind": "rule"}
{"kind": "space", "h": 8}
A horizontal divider, and a vertical gap (h is an arbitrary unit, larger
= more space) — purely cosmetic.
2a. Attributes any block may carry (2026-09-07, issue #22)
visible_when
{"kind": "field", "key": "top_in_form", ..., "visible_when": {"key": "section_type", "in": ["Sandwich"]}}
Dependent visibility: the block is shown only while values[key],
trimmed and compared case-insensitively, is one of the in strings. The
page re-evaluates it on every edit of that key — no Run, no reload. Hidden
means hidden: the block's value is kept and stays bound, nothing is
cleared. Any block kind may carry it (a section hides whole). Two
sources today: a section module's own ruling on its block
(pa_extents's "Which face is up" — owner, 2026-09-07: it does not exist
for a Solid section), and pa_pagespec, which stamps every field in
panelanalysisform.SANDWICH_ONLY_KEYS with section_type ∈ {Sandwich}
the way the WPF renderer used to collapse them.
unused_by_solve
{"kind": "field", "key": "design_spec", ..., "unused_by_solve": true}
The control is collected only — nothing in panellift or
tools/panelanalysis.py reads it yet. The page must show that
(muted styling plus a "not used by the solve" tag and tooltip) while
leaving it editable, so a control that changes nothing can never pass
for a live one (owner, 2026-09-07: "我现在选择 code,有意义吗?"). Stamped
by pa_pagespec on any field block or matrix row whose own hint
says "not (yet) used by the solve", plus a short explicit list
(design_spec, the two miter flags, top_in_form). A matrix row carries
it at row level. docs/pa-unused-options.md is the inventory behind it.
3. The values contract
values is (almost entirely) {key: string}. Two exceptions, both
existing structured passthroughs the section modules already read as
lists, not strings:
openings_rows:[{"type", "label", "pos_along", "pos_along_from", "length", "pos_cross", "pos_cross_from", "width", "pos_vert", "height"}, ...]— LEGACY (2026-09-05): this shape is now a fallback only, read when theopeningrow table (§ below) has no present rows at all (a panel saved before that table existed). While the table has rows,panelanalysisform.validate()derives this list FROM the table (_opening_rows_from_table) rather than reading it directly, andpa_pagespec.build_pagespecrefreshesvalues['openings_rows']off the table too, so the outline/section drawings always agree with the table. Never[]and blank at once;[]is a real "no openings" answer.mild_bar_rows/strand_rows— the same idea, when a reads sibling supplied one; otherwise these keys are simply absent and the row tables below carry the data instead.
Row tables. Ten tables — restraint, support, conc_load,
wind_zone, strand_row, mild_bar, interrupt (2026-09-07: a station
along the length where reinforcement is cut, x_ft + cuts; collected
only, the solve does not read it yet), opening, face_lifter,
edge_lifter — store their rows as flat values keys:
<table>.<n>.<col> one cell, e.g. "restraint.1.elevation_ft"
<table>.<n>.present "yes" for a live row, "" (or missing) for a
removed one -- row numbers are NEVER reused once
issued (removing row 2 and adding a new row
always gets row 3+, never a recycled "2")
To read a table: find every n where values["<table>.<n>.present"] == "yes", ascending; for each, read values["<table>.<n>.<col>"] for every
col the matching tables[<table>].columns entry names. To add a row:
pick n = one past the highest n this table has EVER used (present or
removed — never reuse a retired number), set <table>.<n>.present = "yes",
and every column key to "". To remove a row: set
<table>.<n>.present = "" and simply stop reading that n — never delete
the cell keys (a stray read of a removed row's key is harmless, and it
keeps a removed row's id permanently retired).
The same load_combo.<name>.<n>.<factor> / load_combo.<name>.<n>.include
shape (§2's lc_table) is the same idea, one level namespaced under a
fixed strength/service name plus a user id (u1, u2, ...) instead of
a bare row-table name.
values is guaranteed to already contain every row key any table currently
has rows for — pa_pagespec.build_pagespec() pads every table up to its
own min_rows (today always 0, so a no-op, but honoured going forward)
before serialising, exactly as script.py's editable_table_widget used
to pad on the WPF side.
4. tables
{
"restraint": {
"columns": [
{"key": "elevation_ft", "label": "Elevation (ft)", "unit": "ft", "editor": "num"},
{"key": "type", "label": "Type", "unit": null, "editor": "choice",
"choices": ["Pin", "Fixed", "Free", "Floor tie"]},
...
],
"min_rows": 0,
"add_label": "Add restraint"
}
}
De-duped across every page — a table named once here even if referenced
from more than one place. unit, when the column's own label carries a
trailing parenthetical ("Elevation (ft)"), is split out ("ft") as a
convenience; the label text itself is never altered.
empty_state (2026-09-07, issue #22 items 5/11), mirrored from the
block's own empty_state, is what the page prints under the table while
it has NO present rows: the first basis[<key>] sentence found for
basis_keys, in order (prefixed "Model read: " — the read's own reason,
e.g. supports_basis), else text. A table with neither gets the page's
generic "No rows yet — nothing was read from the model for this table and
nothing has been entered." Could-not-judge (the read left no reason) and
judged-empty (the read said why) must never look alike, and neither may
look like "nothing wrong here" — an empty support table says the solve
needs two rows before Run is ever pressed. A column's choices,
when present, is ALSO filed into the top-level choices dict under
"<table>.<col>" (e.g. "restraint.type") — the dropdown options apply to
every row of that column, not to one row's not-yet-existing key.
5. geometry
Keyed "<page_id>.<source>" (e.g. "concrete_extents.outline",
"structural_model.freebody") — the same key a drawing block carries as
its own drawing_key. Every entry always has a "problems" list ([]
when nothing is wrong); a page should show those as small notes near the
drawing, the same way the WPF renderer printed a drawing function's
problems under the canvas.
Nothing here is pixels. Every number is in the model's own units (feet,
inches, psf, kips) — the page does its own layout, scaling, and margins.
The WPF-era outline_geometry(values, width_px, height_px) (a pixel box,
used only for the WPF cutline's hit-test math) still exists in
pa_sketch_extents.py for the WPF form, unrelated to anything below.
concrete_extents.outline and concrete_extents.section
Both keys hold the same object — pa_sketch_extents.outline_geometry_ft(values):
{
"length_ft": 20.0, "width_ft": 8.0, "thickness_in": 8.0,
"degraded": false,
"outline": [[0,0], [20,0], [20,8], [0,8]],
"openings": [
{"type": "Opening", "role": "opening", "label": "", "index": 1,
"along": [5.0, 8.0], "vert": [1.0, 3.0], "cross": null}
],
"problems": []
}
outline is the panel's plan rectangle, closed implied (draw an edge back
from the last point to the first). along is the ft range on the length
axis (horizontal), vert the ft range on the width axis (vertical, from
the bottom), cross the ft range through the thickness (only meaningful
for a side/cross-section view; null when not entered). degraded: true
means length_ft/width_ft were missing and this is a 1.0 × 0.3 ft
placeholder rectangle, not a real reading — still draw it (never a blank
canvas), but do not let a user mistake it for real geometry.
The concrete_extents.section drawing does NOT get its own separate
geometry — the page computes the cut profile itself, on demand, from this
same object. See §6.
structural_model.freebody
pa_sketch_freebody.freebody_geometry(values):
{
"length_ft": 20.0, "bottom_elevation_ft": 0.0, "top_elevation_ft": 20.0,
"restraints": [{"id": 1, "elevation_ft": 0.0, "type": "Pin"}],
"conc_loads": [{"id": 1, "label": "P1", "elevation_ft": 10.0,
"offset_in": 2.0, "dead_k": 5.0, "live_k": 2.0}],
"wind_zones": [{"id": 1, "label": "W1", "from_elev_ft": 0.0,
"to_elev_ft": 20.0, "width_ft": 8.0,
"pressure_psf": 15.0, "suction_psf": -10.0,
"flagged": false}],
"problems": []
}
A row missing the elevation(s) it needs to be placed is simply absent from
these lists (never a fabricated position) and named in problems.
flagged: true on a wind zone means its from/to were swapped to display in
order — draw it, but consider a visual flag.
handling.lifter_dunnage
pa_sketch_handling.lifter_dunnage_geometry(values):
{
"length_ft": 20.0, "width_ft": 8.0,
"face_lifters": [{"x_ft": 5.0, "frac": 0.5}],
"edge_lifters": [{"x_ft": 0.0, "frac": 0.5, "kind": "end", "side": "bottom",
"ambiguous": false, "source": "edge_lifters"}],
"dunnage_positions_ft": [2.0, 18.0],
"openings": [ /* same row shape as outline_geometry_ft's, minus 'cross' */ ],
"centroid": {"x_ft": 10.0, "z_ft": 4.0, "y_ft": 0.33,
"xz_basis": "solid rectangle", "y_basis": "half thickness"},
"problems": []
}
length_ft/width_ft are null (not a placeholder) when not entered —
every list above is only meaningful relative to a real length/width, so a
fabricated pair would misplace every lifter; show "enter length and width"
instead of a picture, per problems. centroid's three coordinates and
two basis sentences can each independently be null — see
pa_sketch_handling.centroid()'s own docstring for exactly when.
reinforcement.outline
pa_sketch_reinf.outline_geometry(values) — the reinforcement page's
plan view (issue #23 item 9i): the first of the page's two stacked
drawings, paired with reinforcement.section below it the same way
Concrete Extents pairs its outline with its section. Every reinforcement
type is its own group with its own continuous dimension chain from its own
reference. Reuses the section's own parse (section_geometry), so strand
positions, bar rows, and problems are resolved identically in both.
{
"length_ft": 10.0, "width_ft": 8.0,
"rail": {"location": "left", "offset_in": 1.0},
"strands": [
{"row_id": 1, "x_in": [1.0, 3.0, 5.0, 7.0],
"chain_in": [2.0, 2.0, 2.0, 2.0], "count": 4,
"dia_in": "1/2", "depth_in": 2.0}
],
"bars": [
{"row_id": 1, "size": "#4", "count": 6, "depth_in": 1.5,
"direction": "Longitudinal", "spacing_in": 12.0,
"from_ft": 2.0, "to_ft": 8.0}
],
"mesh": {"designation": "6x6-W2.9xW2.9", "layers": 2, "depth_in": 4.0},
"problems": []
}
length_ft / width_ft are the plan rectangle (null when not entered —
show the problems sentences instead of a fabricated box). rail is the
strand rail datum: location is 'left' or 'right' (matched the same
way the section matches it), offset_in the rail's distance from that
edge (0.0 when blank); rail is null when the location is blank.
strands is per row {'row_id', 'x_in', 'chain_in', 'count', 'dia_in', 'depth_in'} — x_in is the per-strand across-width position in inches
from the panel's left edge (the cumulative chain minus the rail
offset, mirrored when the rail is right), chain_in the rail-to-strand
lengths as typed, so the plan draws strands as thin lines along the length
at their x_in and ONE continuous dimension chain along the width edge
from the rail outward using chain_in. bars is per row {'row_id', 'size', 'count', 'depth_in', 'direction', 'spacing_in', 'from_ft', 'to_ft'} — the bar group's own position basis (from_ft/to_ft on the
length axis, depth_in on the width axis) so it draws its chain from ITS
own reference, never shared with the strand chain. mesh is
{'designation', 'layers', 'depth_in'}, or null when the mesh field is
blank (a null mesh is "no mesh", never a zero-size guess; layers /
depth_in may be null when un-entered). problems is present always and
carries the same chain/validation sentences section_geometry does.
reinforcement.section
pa_sketch_reinf.section_geometry(values):
{
"width_ft": 8.0, "thickness_in": 8.0,
"strands": [{"id": 1, "count": 4, "depth_in": 1.5, "dia_in": "1/2",
"pull_lb": 25000.0}],
"strand_x_in": null, "used_fallback_strand_x": true,
"bars": [{"id": 1, "size": "#4", "count": 6, "depth_in": 2.0,
"direction": "Longitudinal", "spacing_in": 12.0}],
"problems": []
}
strand_x_in, when not null, is the flat per-strand across-width
position in inches from the left, in the SAME order as strands' own
rows/counts flattened one dot at a time; used_fallback_strand_x: true
means the page should spread dots evenly across the width itself instead
(no explicit strand_x_in/strand_columns_in was entered, or it did not
validate). A Sandwich section's wythe/insulation layering is NOT included
here — read wythe_top_in / insulation_in / wythe_bottom_in /
section_type straight out of the top-level values if drawing that
detail.
6. Section-at-cut: the algorithm
The Concrete Extents page draws the panel outline with a draggable cut
line, and a cross-section below it that updates live as the line moves —
"like an MRI" (owner, 2026-09-04). All of that lives in ONE geometry object
(geometry["concrete_extents.outline"], §5) plus one small, pure
function the page must reimplement in JavaScript — pa_sketch_extents. section_at(geometry, x_ft) is the Python original both must agree with:
- Clamp
x_ftto[0, geometry.length_ft]. voids= the width-axis ranges ([lo, hi], ft from the left/bottom edge) that are open at this station: for every entry ingeometry.openingswhosetypeis"Opening"(a"Reveal"or"Solid Zone"never goes fully through — matchespanelanalysisform's own "an Opening row always goes full depth" rule) AND whosealongrange coversx_ftAND whosevertrange is notnull— collectvertas one candidate void. A row whosevertISnullcannot be localised across the width; do not guess, note it instead (seeproblemsbelow).- Merge overlapping/touching ranges (sort ascending by
lo; two ranges[a,b]and[c,d]withc <= bbecome[a, max(b,d)]), so two openings that overlap at one station read as one void, never a double-counted pair.
Return shape (matches the Python function's own return exactly):
{"x_ft": 6.0, "width_ft": 8.0, "thickness_in": 8.0,
"voids": [[1.0, 3.0]], "problems": []}
Draw the section as the full width_ft × thickness_in rectangle with
each voids range punched out clean through (both faces) — a Reveal's
partial-depth notch and a Sandwich section's wythe/insulation layering are
NOT part of this algorithm; read those straight out of values if drawing
that additional detail (see the previous section's note).
x_ft for the current cut position is cut_frac * geometry.length_ft,
where cut_frac = float(values[cutline.key] or cutline.default) — the
cutline block attribute (§2) names both.
7. Host <-> page message protocol
Transport note (2026-09-05, concurrent with this task): the owner's
scope update landed on vibe.webhost.py at the same time as it landed
here, and it changes the transport this section originally assumed. The
page must run with no Revit at all, so it is served by a local CPython
HTTP server OUTSIDE Revit (tools/pa_web/, not yet built when this was
written) and talks to it with fetch(), not with WebView2's
window.chrome.webview.postMessage. WebHost (the WPF window a button
pops up, in vibe.webhost.py) keeps that postMessage channel alive only
for cheap host-window control from inside Revit — closing the window,
client-side logging — never as this protocol's main channel; a page opened
in a plain desktop browser, with no WebHost and no window.chrome.webview
at all, must still work end to end against the local server.
The payload shapes below are transport-independent and are what
actually matters for this contract — pa_pagespec.build_pagespec()'s
return IS the spec object regardless of how it reaches the page. Reading
them as HTTP request/response bodies (the concrete endpoint paths are the
local server's own choice to make, not fixed here):
GET /spec(or equivalent) returns{"type": "init", "spec": {...}}.POST /runwith body{"values": {...}}returns, synchronously or via a follow-up the page polls/fetches,{"type": "result", ...}or{"type": "error", ...}(§ below).POST /cancelandPOST /logcarry the same two message bodies a postMessage channel would have carried.POST /api/geometry(2026-09-05,tools/pa_web.py's own concrete choice) with body{"values": {...}}returns{"geometry": {...}}— the SAMEgeometrydictGET /api/speccomputes, rebuilt off whatevervaluesthe client currently holds (not a save). This is whatapp.js'sscheduleGeometryRefreshcalls whenever an edit changes a key adrawingblock'sdepends_onnames but is not the page's own cut-line fraction — a row-table edit (an opening, a restraint, a support) or a geometry field, none of which the ONE geometry snapshot fetched at page load already reflects.
Everything below is written message-first (as if directly on a postMessage-shaped channel) because that is the simplest way to state the contract; map each one onto whatever HTTP verb/endpoint the local server ends up using, in either direction.
Host -> page, once, on load:
{"type": "init", "spec": { /* build_pagespec()'s own return, §1 */ }}
Page -> host:
{"type": "run", "values": {"<key>": "<string>", ...}}
Sent when the user presses the page's own "Run" / "Analyze" button — the
CURRENT values dict (every edit folded in, same shape as spec.values),
for the host to validate (panelanalysisform.validate) and, if it passes,
build a deck (panelanalysisform.build_deck) and run the solve.
{"type": "cancel"}
The user closed the page / gave up before running anything.
{"type": "log", "text": "..."}
Free-form diagnostic text from the page (e.g. a JS error caught and reported rather than silently swallowed) — the host should surface this the way any other tool logs a client-side problem, never block on it.
Host -> page, after a "run":
{"type": "result", "html_path": "C:\\...\\WPA655.result.html"}
The solve succeeded; html_path is where the rendered results page (the
existing panellift / panelanalysis_schema HTML report) was written —
the host opens it (a new window/tab), the input page stays as it was.
{"type": "error", "text": "...", "fields": ["fc_psi", "unit_weight_pcf"]}
The solve could not run — either validate() failed (missing/invalid
required fields, fields names every offending key so the page can
highlight them) or build_deck() / the solve itself raised (fields is
[] in that case). The page should show text prominently and, when
fields is non-empty, scroll to / highlight each one — never a silent
failure to run.
A problem keyed by a LEGACY field name that no page renders any more
(supports_ft, superseded by the support table) is routed through the
top-level problem_targets map (panelanalysisform.LEGACY_TABLE_KEYS:
legacy key → table name) onto that table — highlighted, its page
switched to — or, when the key IS a table name, onto that table
directly. Only a key that matches neither a rendered field nor a table
may fall into the banner's general text (2026-09-07, issue #22 item 11).
Derived fields. Top-level derived (panelanalysisform. DERIVED_FIELDS) is {key: {sources: [...], formula: <name>}}. The page
recomputes key client-side whenever one of sources is edited,
mirroring the named formula exactly (concrete_modulus_psi: Ec = 33 ·
wc^1.5 · √f'c, whole psi — the same rounding prefill uses), UNTIL the
user types into the derived field itself; from then on their value
stands and the auto-update stops for that field (owner's rule: editing
f'c updates Ec, editing Ec never rewrites f'c and freezes Ec). The freeze
is page state AND is persisted as values["_override.<key>"] = "yes", so
it survives save/reload; validate() ignores underscore keys the same
way it ignores _cut_frac (2026-09-07, issue #22 item 1).
Closing from inside Revit. The page's Close button sends
{"type": "close"} over window.chrome.webview.postMessage when that
channel exists (the WebView2 host closes its window on it — the
button's _on_page_message), THEN posts /api/close as before. In a
plain browser there is no such channel and only the server call runs
(2026-09-07, issue #22 item 10).
8. Provenance
basis[key] is the one-sentence reason a prefilled value is what it is —
sourced from panelanalysisform.prefill()'s own values["_basis"]
(written by the button's model reads, e.g. "ProjectInformation's 'Project Address'", "6 support positions") with the _basis key suffix
stripped, so it is looked up by the SAME key as the field's own value.
defaults_why[key] is the rule that filled a blank — from two sources,
both always consulted, neither dropping the other:
values["_defaults_applied"]'s own(key, value_text, why)entries — defaultspa_defaults.apply_defaults()actually applied this run (meaning: the model supplied nothing real for this key, and this IS the value showing).pa_defaults.why_of(key), for every field key this spec renders that (1) did not already cover — the RULE text ("plant knowledge X when set, else Y"), shown even for a key the model supplied a REAL value for (sowhy_ofis describing what a page reader could expect IF this were ever blank, not necessarily what happened this run —basis, notdefaults_why, is what the current value's real reason is).
A key can appear in both basis and defaults_why at once (a model read
supplied nothing, pa_defaults filled it, and why_of also documents the
rule) — a page can show whichever is more specific, or both, on hover.
9. Round-tripping and testing without Revit
python tools/pa_pagespec.py --out spec.json
builds a spec with reads = {} — no Revit, no model, values entirely
from panelanalysisform.PREFILL_DEFAULTS / pa_defaults's own generic
rules. Every field is present, every table's minimum rows exist, and every
drawing's geometry degrades to a placeholder (a rectangle from whatever
length_ft/width_ft happen to be typed, [] for every list otherwise) —
enough to develop and test the whole page's layout in a browser with
nothing else running.
python tools/pa_pagespec.py --reads _scratch/pa_reads_wpa655.json --out spec.json
does the same with a real model's reads (see _scratch/pa_pagespec_dump.py,
which saves that file from a live bridge dry-run) — the CPython path
exercised against real data, without needing the bridge itself.