WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
+809
View File
@@ -0,0 +1,809 @@
# 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
```jsonc
{
"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`
```json
{"kind": "heading", "text": "Design Criteria", "level": 1}
```
`level` is `1`, `2`, or `3` — decreasing emphasis, no other meaning.
### `columns`
```json
{"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`
```json
{
"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
}
```
- `key` indexes into the top-level `values` / `basis` / `defaults_why`
dicts. A block with no `key` (rare — some resistance-factor table cells)
carries no value of its own.
- `editor` is one of `text`, `num`, `int`, `check`, `choice`. `pa_pagespec`
has already applied the same upgrade the WPF renderer did: a plain
`text`/`num` field whose key is a known enumeration
(`panelanalysisform.choices_for`) arrives here as `editor: "choice"` with
`choices` filled in, even though the section module itself declared it
`text`.
- `choices` is `null` for a free editor, else a list of strings for a
dropdown/combo.
- `multi_value: true` marks 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`).
- `hint` is one plain-English sentence. The WPF form collects every field's
`hint` into one collapsed "Notes" panel instead of printing it inline
(owner ruling, 2026-09-03/04) — the HTML page should do the same: walk
every `field` block's `hint` (and every `quiet: true` `sentence`, below)
into one collapsed panel, rather than re-deriving that server-side.
### `kv`
```json
{"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):
```json
{"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 -- ..."}]}
```
- `table` is a row-table name (§3). `terms` is the per-row product list,
summed over the table's PRESENT rows (`<table>.<n>.present` truthy,
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 in `map` -- 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 the
`unknown` sentence; a row with no `unknown` renders an empty cell.
A term never resolves to a guessed zero.
- `format` is 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-rendered `value` is 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`
```json
{"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`
```json
{
"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:
```json
{"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`
```json
{"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`
```json
{
"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`
```json
{"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`
```json
{
"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 ignore `geometry` here (a string) in
favour of the top-level `geometry` dict.
- `full_width` / `aspect`: the drawing should span its container's full
width; height = width × `aspect`.
- `cutline`, when present, names a `values` key (a `0..1` fraction of the
panel length) and its default — the page owns dragging this line and
rewriting that key; see §6, "section-at-cut".
- `depends_on`: the `values` keys 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`); a
`face_lifter.3.x_ft` edit then hits `face_lifter`. List the TABLE, not
the legacy text key it replaced — the Handling drawing listed only
`face_lifters` until 2026-09-07 and never redrew on a row edit (issue
#22, item 7).
- `drawing_key`: added by `pa_pagespec` (not present in the raw section
module's block) — the key into the top-level `geometry` dict (§5) that
holds this drawing's real-unit numeric data.
### `section`
```json
{"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`
```json
{"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`
```json
{"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`
```json
{"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
the `opening` row 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, and
`pa_pagespec.build_pagespec` refreshes `values['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`
```json
{
"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)`:
```json
{
"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)`:
```json
{
"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)`:
```json
{
"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.
```json
{
"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)`:
```json
{
"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:
1. Clamp `x_ft` to `[0, geometry.length_ft]`.
2. `voids` = the width-axis ranges (`[lo, hi]`, ft from the left/bottom
edge) that are open at this station: for every entry in
`geometry.openings` whose `type` is `"Opening"` (a `"Reveal"` or
`"Solid Zone"` never goes fully through — matches
`panelanalysisform`'s own "an Opening row always goes full depth" rule)
AND whose `along` range covers `x_ft` AND whose `vert` range is not
`null` — collect `vert` as one candidate void. A row whose `vert` IS
`null` cannot be localised across the width; do not guess, note it
instead (see `problems` below).
3. Merge overlapping/touching ranges (sort ascending by `lo`; two ranges
`[a,b]` and `[c,d]` with `c <= b` become `[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):
```json
{"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 /run` with body `{"values": {...}}` returns, synchronously or via a
follow-up the page polls/fetches, `{"type": "result", ...}` or
`{"type": "error", ...}` (§ below).
- `POST /cancel` and `POST /log` carry 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 SAME `geometry` dict `GET /api/spec` computes, rebuilt off whatever
`values` the client currently holds (not a save). This is what
`app.js`'s `scheduleGeometryRefresh` calls whenever an edit changes a key
a `drawing` block's `depends_on` names 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:
```json
{"type": "init", "spec": { /* build_pagespec()'s own return, §1 */ }}
```
**Page -> host:**
```json
{"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.
```json
{"type": "cancel"}
```
The user closed the page / gave up before running anything.
```json
{"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"`:
```json
{"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.
```json
{"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:
1. `values["_defaults_applied"]`'s own `(key, value_text, why)` entries —
defaults `pa_defaults.apply_defaults()` actually applied this run
(meaning: the model supplied nothing real for this key, and this IS the
value showing).
2. `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 (so
`why_of` is describing what a page reader could expect IF this were
ever blank, not necessarily what happened this run — `basis`, not
`defaults_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.