Files
willtools-demo/docs/ba-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

12 KiB

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_tables 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.

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

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


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.