WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
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:
@@ -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.
|
||||
Reference in New Issue
Block a user