8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
810 lines
36 KiB
Markdown
810 lines
36 KiB
Markdown
# 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.
|