WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
# Beam Analysis web page — the contract
Written 2026-09-05. This is the **delta** over `docs/pa-web-spec.md`: everything
that document says about block kinds (§2), the values contract (§3), `tables`
(§4), the message protocol (§7) and provenance (§8) applies here unchanged and
is not restated. What differs is the page list, the value keys, the tables, the
geometry shapes, and the file the page reads and writes — the **beamcalc job
JSON** (`beamcalc/src/beamcalc/model.py:from_job`), which is also the file the
Revit-side gatherer will produce.
Two sides meet at this contract and are built on different machines:
- **Host CPython side (this machine):** `tools/ba_web.py` (server),
`tools/ba_pagespec.py` (job ⇄ pagespec), `tools/ba_web/static/` (index +
drawings), `beamcalc/` (the engine). No Revit anywhere on this path.
- **Revit side (the owner's machine):** a gatherer that writes the **reads
file** (§6) from the model and launches `ba_web.py --reads <file> --open`,
the same way Panel Analysis is launched.
Where this document and `model.py` disagree, `model.py` wins and this file is
wrong — fix the file and say so in the commit.
---
## 1. One file, three views
The **job** is a `beamcalc/1` JSON document (`model.py:658` requires that
`schema`). The page never invents a second schema:
| view | what it is | who writes it |
|---|---|---|
| **reads** (§6) | a *partial* job: only what the model can supply, plus `basis` | the Revit gatherer |
| **values** | the pagespec's flat `{key: string}` store, keys = job paths (§3) | `ba_pagespec.values_from_job` from reads + defaults; then the user |
| **job** | the full job the engine runs | `ba_pagespec.job_from_values` on Run |
Round trip is the invariant and the test: `job_from_values(values_from_job(J))`
reproduces `J` for both fixtures in `beamcalc/fixtures/`, key for key, after
`from_job` normalisation (a value the job left to a default may come back
explicit — that is the only permitted difference).
---
## 2. Pages
Five pages, fixed order and ids. Each page's blocks are built by one function
`page_<id>(values, reads) -> [blocks]` in `tools/ba_pagespec.py`; a page that
raises produces a page with a single `problems` sentence, never a blank tab
(`pa-web-spec.md:50` rule).
| # | id | title | job sections it edits | drawings |
|---|---|---|---|---|
| 1 | `extents` | Extents & Supports | `mark`, `description`, `extents.*`, `supports.*`, `bearing.*` | `extents.elevation` |
| 2 | `materials` | Materials & Criteria | `materials.*`, `settings.*` | — |
| 3 | `loads` | Loads & Combinations | `loads.*`, `combos.*` | `loads.elevation` |
| 4 | `reinforcement` | Reinforcement | `reinforcement.*` | `reinforcement.section`, `reinforcement.elevation` |
| 5 | `handling` | Handling | `handling.stripping.*` | — |
`handling.handling` and `handling.erection` exist in one fixture and are read
by nothing (`model.py:741-742`); the page does **not** show them until the
engine does. Showing an input the engine ignores is the silent-success failure
`PROJECT.md` §3.1 forbids.
---
## 3. Values keys
**A scalar's key is its dotted path in the job.** `extents.length_ft`,
`extents.section.width_in`, `materials.rebar.fy_ksi`,
`settings.deflection.total`, `supports.start.from_start_in`,
`handling.stripping.multiplier`, `bearing.mu`, `loads.self_weight_mult`,
`loads.pattern_live`. Every value is a string in `values`; `job_from_values`
parses it with the type `model.py` expects at that path. Booleans are
`'true'`/`'false'`. Deflection ratios accept `"L/240"` or `"240"` exactly as
`model.py:600-605` does.
`extents.section.type` is a `choices` field: `rectangular`, `L`, `inverted_t`
(`model.py:557-573`). The four stem/flange fields and `side` render on every
type but are `required` only when the type needs them.
**Lists are row tables** in the `pa-web-spec.md` §3 shape
(`<table>.<n>.<col>`, `<table>.<n>.present`, ids never reused):
| table | job list | columns (in order) |
|---|---|---|
| `point_loads` | `loads.point[]` | `id`, `x_in`, `from` (choices `start`/`end`), `ecc_in`, one column per case in `model.CASES`, `description` |
| `line_loads` | `loads.line[]` | `id`, `x0_in`, `x0_from`, `x1_in`, `x1_from`, `ecc0_in`, `ecc1_in`, per case **two** columns `<case>_start` / `<case>_end` (klf), `description` |
| `moments` | `loads.moments[]` | `id`, `x_in`, `from`, one column per case (kip-ft) |
| `combos_strength` | `combos.strength[]` | `label`, `D`, `L`, `R`, `W`, `E`, `include` |
| `combos_service` | `combos.service[]` | same |
| `bars` | `reinforcement.bars[]` | `size`, `rows`, `cols`, `left_in`, `right_in`, `top_in`, `bottom_in`, `start_in`, `end_in`, `start_cond`, `end_cond`, `area_in2` |
| `strands` | `reinforcement.strands[]` | `ids`, `size`, `pull_pct`, `rows`, `cols`, `left_in`, `right_in`, `first_row_in`, `spacing_in`, `force_k` |
| `transverse` | `reinforcement.transverse[]` | `size`, `legs`, `first_in`, `from`, `spacing_in`, `length_in`, `qty`, `symmetric`, `use`, `area_in2` |
`model.CASES` is `('deadnc', 'topping', 'deadc', 'live', 'sus_live', 'roof',
'pressure', 'suction', 'seismic')` (`model.py:29-30`); the pagespec reads it
from the module, never copies it. Column labels carry units in the
`"Label (unit)"` form so `tables.<name>.columns[].unit` splits out as in PA.
The two combination tables are `editable_table`s pre-filled from
`model.DEFAULT_STRENGTH` / `DEFAULT_SERVICE` when the job carries none; `include`
is a checkbox column. They are **not** PA's `lc_table` — that block's fixed
code-row/user-row split is the panel tool's vocabulary.
---
## 4. Geometry
Keyed `"<page_id>.<source>"`, real units only, every entry carries `problems`
(`pa-web-spec.md` §5 rules). Built by `ba_pagespec.geometry(job)`; the same
function answers `POST /api/geometry` after an edit.
```jsonc
"extents.elevation": {
"length_ft": 17.92,
"height_in": 22.0,
"supports": [ {"x_ft": 0.0, "length_ft": 0.5, "kind": "pin"}, // start
{"x_ft": 17.42, "length_ft": 0.5, "kind": "pin"} ], // end
"problems": []
},
"loads.elevation": { // extents.elevation + the loads on it
"length_ft": 17.92, "height_in": 22.0, "supports": [...],
"point": [ {"id": "P1", "x_ft": 3.0, "ecc_in": 0.0, "kips": {"deadnc": 4.2, "live": 6.0}} ],
"line": [ {"id": "L1", "x0_ft": 0.0, "x1_ft": 17.92, "klf": {"deadnc": [0.9, 0.9]}} ],
"moments": [ {"id": "M1", "x_ft": 0.0, "kipft": {"deadnc": 12.0}} ],
"problems": []
},
"reinforcement.section": {
"type": "rectangular", "width_in": 16.0, "height_in": 22.0,
"stem_width_in": null, "stem_height_in": null, "flange_width_in": null,
"flange_thickness_in": null, "side": null,
"bars": [ {"size": "#8", "x_in": 2.0, "y_in": 2.0, "dia_in": 1.0} ], // y from bottom
"strands": [ {"size": "1/2", "x_in": 3.0, "y_in": 2.0} ],
"problems": []
},
"reinforcement.elevation": {
"length_ft": 17.92, "height_in": 22.0,
"bars": [ {"size": "#8", "x0_ft": 0.0, "x1_ft": 17.92, "y_in": 2.0,
"start_cond": "straight", "end_cond": "hook"} ],
"transverse": [ {"size": "#4", "x0_ft": 0.0, "x1_ft": 4.0, "spacing_in": 6.0, "legs": 2} ],
"problems": []
}
```
Zero loads are omitted from the `kips`/`klf`/`kipft` maps. A row whose position
cannot be resolved is absent from the drawing and named in `problems`
(`pa-web-spec.md:403` rule) — never drawn at a fabricated x.
---
## 5. Server
`tools/ba_web.py` is `tools/pa_web.py` with the tool swapped. Same argv
(`--reads`, `--job-root`, `--port`, `--open`, `--title`), same first-line
`BA_WEB_URL=http://127.0.0.1:<port>/` on stdout, same `127.0.0.1` bind, same
`ThreadingHTTPServer`. Files live under `<job_root>/beamanalysis/`:
| path | file |
|---|---|
| `POST /api/save` | `<mark>.values.json` |
| `POST /api/run` | `<mark>.job.json` (the job actually run), `<mark>.ba.json` (`cli.summary(a)`), `<mark>.html` (`report.render(a)`) → `{"ok": true, "result_url": "/results/<mark>.html"}` |
| `POST /api/run` failure | `{"ok": false, "problems": [{"key": <values key or ''>, "text": ...}]}`; a `model.py` `ValueError` maps to the key whose value caused it where that can be told |
| `POST /api/geometry` | `{"geometry": ba_pagespec.geometry(job_from_values(values))}` |
| `POST /api/log` | appends the client-side error to `<mark>.client.log`, returns `{"ok": true}` — the route PA's `app.js` already calls and PA's server lacks; added to `pa_web.py` too |
| `GET /api/spec`, `GET /results/<file>`, `POST /api/close` | as PA |
The run calls `beamcalc.solve.analyse` in-process (the way PA calls
`panelanalysis.build_page`), never a subprocess: one interpreter, one traceback.
**Static files** are served from `tools/ba_web/static/` first and
`tools/pa_web/static/` second. `app.js`, `units.js`, `willtools.css` and
`draw.js` are **not copied** — one copy, PA's. `tools/ba_web/static/` holds
only `index.html` and `ba_drawings.js`.
---
## 6. The reads file — the Revit-side half
The gatherer writes a **partial job**:
```jsonc
{
"schema": "beamcalc/1",
"job_root": "C:\\...\\<job>", // where beamanalysis/ goes; as PA
"mark": "RB1",
"description": "16RB22, level 2",
"extents": { "length_ft": 17.92, "section": {"type": "rectangular", "width_in": 16, "height_in": 22} },
"materials": { "fc_ksi": 6.0, "fci_ksi": 4.0 },
"reinforcement": { "bars": [...], "strands": [...], "transverse": [...] },
"basis": { "extents.length_ft": "Length parameter of the beam instance",
"materials.fc_ksi": "Type parameter f'c" }
}
```
Rules, all of them `pa-web-spec.md` §8's:
- **Absent means unread.** Nothing the model does not carry appears at all —
never `0`, never `null`-as-zero. `ba_pagespec.values_from_job` fills the rest
from `model.py`'s own defaults and records each in `defaults_why`.
- `basis` keys are **values keys** (§3), one sentence each, so the page can
show the provenance icon on exactly the fields the model supplied.
- Product codes only (`#8`, `1/2`, `RB1`); no client, job or plant name in the
file (`CLAUDE.md` §3). `job_root` is a path and is never printed by the
server.
- Anything the gatherer wants to add that `model.py:from_job` does not read
is a **schema change**: it lands in `model.py` first, in this document
second, and the commit names both.
---
## 7. Front end
`app.js` is PA's, unchanged except one hook: before its own `redrawOne`
dispatch it consults `window.ToolDrawings` — `{ "<source>": fn(inst, state,
host) }` — and calls the tool's function when the block's `source` is
registered. `draw.js` exports its primitives (`svgRoot`, `label`, `dimH`,
`dimV`, `ftIn`, `inch`, `el`, `clear`, `safeNum`, `clamp`, `mergeRanges`,
`estWidth`, `_stagger`) on `window.Draw` so `ba_drawings.js` draws with the
same pen. Both edits are additive; PA's behaviour with no `ToolDrawings`
defined is byte-for-byte what it was.
`ba_drawings.js` registers `elevation` (used by `extents.elevation` and
`loads.elevation`) and `section`; `reinforcement.elevation` is `elevation`
with bars and stirrup zones overlaid, selected by `inst.page.id` the way PA
selects its two `section`s.
---
## 8. Tests
`WillTools.extension/tests/test_ba_pagespec.py`: round trip (§1) on both
fixtures; every §3 table present with the columns listed; `geometry()` on both
fixtures has no `problems`; a job with an `L` section renders the stem/flange
fields `required`. `tests/test_ba_web.py`: start the server on port 0, `GET
/api/spec`, `POST /api/save`, `POST /api/run` on the RBA5 fixture writes the
three files and returns a `result_url` that serves; `POST /api/log` writes the
log; static fallback serves `app.js` from PA's directory. No Revit in any of it.