Files
willtools-demo/docs/pa-web-spec.md
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

36 KiB
Raw Blame History

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
}
  • 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

{"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 -- ..."}]}
  • 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

{"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 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

{"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 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

{
  "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:

  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):

{"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:

{"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:

  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.