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
File diff suppressed because it is too large Load Diff
+2319
View File
File diff suppressed because it is too large Load Diff
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Beam Analysis's local server -- the CPython half of the HTML front/back end.
python tools/ba_web.py [--reads reads.json] [--job-root DIR]
[--port N|0] [--open] [--title T]
`tools/pa_web.py` with the tool swapped (docs/ba-web-spec.md section 5): same
argv, same first stdout line, same 127.0.0.1 bind, same
`http.server.ThreadingHTTPServer`, same reads/job-root/mark resolution. The
page the server feeds is built by `tools/ba_pagespec.py` (the job <-> values
half, `build_pagespec` / `geometry`), and `POST /api/run` runs the beamcalc
engine IN-PROCESS -- `beamcalc.model.from_job` -> `beamcalc.solve.analyse` --
the way PA's server calls `panelanalysis.build_page`, never a subprocess: one
interpreter, one traceback (spec section 5). Results are `<job-root>/
beamanalysis/<mark>.job.json` (the job actually run), `<mark>.ba.json`
(`beamcalc.cli.summary(a)`), `<mark>.html` (`beamcalc.report.render(a)`).
NO REVIT ANYWHERE ON THIS PATH. Like pa_web.py, this module registers the
bare `vibe` package stub at import (`vibe/__init__.py` imports the Revit
API, which is fatal off Revit), then imports `tools/pa_web.py` for its
job/reads plumbing and the shared static handler, and `tools/ba_pagespec.py`
for the pagespec and the job conversion. `beamcalc` is plain CPython
(imported by ba_pagespec the ordinary way, falling back to
`beamcalc/src` on `sys.path`); `beamcalc.report` pulls `panellift.report`,
which is CPython too.
Routes (shared handler and payload shapes inherited from pa_web.py -- the
PA `app.js` front end is reused for this tool, spec section 7):
GET / tools/ba_web/static/index.html when it exists,
else tools/pa_web/static/index.html (app.js,
draw.js, units.js and willtools.css are PA's
single copies -- ba_web's static dir only
overrides them).
GET /api/spec the pagespec JSON: `ba_pagespec.
build_pagespec(values=..., reads=...)` with the
values file a previous /api/save left for this
mark merged over the `--reads` prefill. reads
is a PARTIAL job (spec section 6): only what
the model could supply, plus `basis`.
POST /api/geometry body {"values": {...}} -- {"geometry":
ba_pagespec.geometry(ba_pagespec.job_from_values(
values))}, the SAME dict /api/spec computes off
the values the client currently holds (not a
save, not the prefill). A ValueError parsing the
values -- job_from_values raises one whose
message starts with the values key -- or any
other exception comes back {"geometry": {},
"error": text}, never a dead page.
POST /api/save body {"values": {...}} -- persists that dict as
this mark's saved edits
(`<job-root>/beamanalysis/<mark>.values.json`),
read back by the next /api/spec.
POST /api/run body {"values": {...}} -- `ba_pagespec.
job_from_values(values)`, `beamcalc.model.
from_job(job)` and `beamcalc.solve.analyse` in
process. On a ValueError from the conversion/
read (a value the engine refuses), {"ok": false,
"kind": "input", "problems": [{"key": <the
values key the message starts with, or ''>,
"text": ...}]}; on any other exception,
{"ok": false, "kind": "error", "problems":
[...]} with the exception text and the
traceback to stderr only. `kind` is what the
shared page renders the refusal by -- "input"
is the user's to fix, "error" is a bug (see
pa_web.run_analysis). On success it
writes `<mark>.job.json`, `<mark>.ba.json`
(`cli.summary(a)`) and `<mark>.html`
(`report.render(a)`) into the same
`beamanalysis/` folder and answers {"ok": true,
"result_url": "/results/<mark>.html"}.
GET /results/<file> serves a written results HTML (or anything else
landed in the same folder) by filename.
POST /api/close acknowledges, then shuts the server down (used
by a page's own Close button, and by the Revit
button once its WebView2 window closes).
POST /api/log body {"text": ...} (anything JSON) -- appends
one line, `<ISO timestamp>\t<body compact>`, to
`<job-root>/beamanalysis/<mark>.client.log` and
answers {"ok": true} -- the client-side error
sink PA's `app.js` posts to (ba-web-spec.md
section 5: the route PA's server already has
for Beam Analysis to reuse).
Static assets are served from `tools/ba_web/static/` first and
`tools/pa_web/static/` second (spec section 5): ba_web/static holds only
`index.html` and `ba_drawings.js`; `app.js`, `units.js`, `willtools.css`
and `draw.js` are PA's single copies, never duplicated. A static path in
neither directory is a 404.
Only binds 127.0.0.1. Threaded (`http.server.ThreadingHTTPServer`) so a slow
solve never blocks a concurrent static request. Every request and every solve
exception is logged to stderr; nothing here ever prints a plant, job or
client name (`--reads` / `--job-root` are data, supplied by the caller, never
this file's own business -- same rule `tools/pa_pagespec.py` follows; the
`mark`, the `job_root` and every path to a written file stay OFF stdout).
The one place a client-supplied value is written anywhere is `POST
/api/log`, into `<mark>.client.log` -- into the FILE, never stdout.
JOB ROOT resolution (where `<mark>.values.json` / `<mark>.job.json` / the
results HTML all live, one folder per mark under `<job-root>/beamanalysis/`):
identical to pa_web.py -- the `--reads` file's own `"job_root"` field wins
when present; else `--job-root`; else `%LOCALAPPDATA%\\WillTools\\panelanalysis`
(pa_web.py's own default; Beam Analysis deliberately keeps the same fallback
folder rather than invent a second). The MARK itself is `--reads`' own
`"mark"` field, slugged, or the literal string `'blank'` when there is no
mark at all -- so two blank-path runs never collide with a real beam's saved
files, and a re-run of the same mark picks its saved edits back up.
`--port 0` (the default) lets the OS pick a free port; the actual URL is
printed as `BA_WEB_URL=http://127.0.0.1:<port>/` -- the FIRST line on stdout,
flushed immediately, so a launcher (the Revit button, a test) can read it off
a pipe before the server does anything else. `--open` additionally opens it
in the default browser.
"""
from __future__ import print_function
import argparse
import io
import json
import os
import sys
import threading
import traceback
import types
import webbrowser
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
LIB = os.path.join(REPO, 'WillTools.extension', 'lib')
if LIB not in sys.path:
sys.path.insert(0, LIB)
# Same trick `tools/pa_web.py` / `tools/deckcheck.py` use: `vibe/__init__.py`
# imports the Revit API at module scope, which is fatal off Revit. Register a
# bare `vibe` package pointed at the directory so the Revit-free submodules
# import normally and `__init__.py` never runs. pa_web.py does the same at
# its own import, so whichever module registers first wins -- identical stub.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(LIB, 'vibe')]
sys.modules['vibe'] = _stub
if HERE not in sys.path:
sys.path.insert(0, HERE)
# `beamcalc` is plain CPython; ba_pagespec imports it the ordinary way and
# falls back to the in-repo source tree (<repo>/beamcalc/src on sys.path) when
# it is not installed -- importing ba_pagespec performs that fallback.
import pa_web # noqa: E402 -- job/reads plumbing + the shared handler
import ba_pagespec # noqa: E402 -- pagespec builder, job <-> values
import beamcalc.model as BM # noqa: E402
from beamcalc import cli as BC_CLI # noqa: E402
from beamcalc import report as BC_REPORT # noqa: E402
from beamcalc import solve as BC_SOLVE # noqa: E402
# Re-exported rather than re-derived (docs/ba-web-spec.md section 5: "same
# argv ... same job_root resolution ... files live under
# <job_root>/beamanalysis/"): these ARE pa_web.py's own functions, working on
# a Context that differs only in which folder it points at.
_load_reads = pa_web._load_reads
_slug = pa_web._slug
_ensure_dir = pa_web._ensure_dir
_default_job_root = pa_web._default_job_root
_send_file = pa_web.Handler._send_file
_serve_from = pa_web.Handler._serve_from
_read_json_body = pa_web.Handler._read_json_body
BA_DIR_NAME = 'beamanalysis'
STATIC_DIRS = (os.path.join(HERE, 'ba_web', 'static'),
os.path.join(HERE, 'pa_web', 'static'))
DEFAULT_JOB_ROOT = _default_job_root() # %LOCALAPPDATA%\WillTools\panelanalysis
DEFAULT_TITLE = 'Beam Analysis'
class Context(object):
"""Everything one server process needs, resolved once at startup -- the
beam analogue of `pa_web.Context`, with the folder swapped."""
def __init__(self, reads, job_root, ba_dir, mark):
self.reads = reads
self.job_root = job_root
self.ba_dir = ba_dir
self.mark = mark
def build_context(reads_path=None, job_root_override=None):
"""`(reads, job_root, ba_dir, mark)` resolved exactly the way pa_web.py
resolves its own: `--reads`' `job_root` field, else `job_root_override`,
else `pa_web._default_job_root()`; mark slugged off `--reads`' `mark`
field, else `'blank'`. The folder is `<job_root>/beamanalysis/`."""
reads, reads_err = _load_reads(reads_path)
if reads_err:
sys.stderr.write('ba_web: ' + reads_err + '\n')
job_root = reads.get('job_root') or job_root_override or DEFAULT_JOB_ROOT
ba_dir = os.path.join(job_root, BA_DIR_NAME)
_ensure_dir(ba_dir)
mark_raw = (reads.get('mark') or '').strip()
mark = _slug(mark_raw) if mark_raw else 'blank'
return Context(reads=reads, job_root=job_root, ba_dir=ba_dir, mark=mark)
# ------------------------------------------------------------- the pagespec
def _values_path(ctx):
return os.path.join(ctx.ba_dir, ctx.mark + '.values.json')
def _load_saved_values(ctx):
"""The `{key: value}` dict a previous `/api/save` for this mark left --
`{}` for anything else (no file yet, a file that will not parse, a file
that is not shaped `{"values": {...}}`). Never raises."""
path = _values_path(ctx)
if not os.path.isfile(path):
return {}
try:
with io.open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception:
return {}
if isinstance(data, dict):
values = data.get('values')
if isinstance(values, dict):
return values
return {}
def _save_values(ctx, values):
_ensure_dir(ctx.ba_dir)
blob = json.dumps({'values': values}, indent=1, sort_keys=True,
ensure_ascii=False)
with io.open(_values_path(ctx), 'w', encoding='utf-8') as fh:
fh.write(blob)
def build_spec_json(ctx):
"""The pagespec dict for GET /api/spec: prefill off `ctx.reads` -- a
PARTIAL job (ba-web-spec.md section 6: absent means unread) -- flattened
to values by `ba_pagespec.build_pagespec`, with any saved edits for this
mark folded over the result before the geometry is computed, so a saved
length/width edit also redraws the geometry, not just the raw number."""
try:
saved = _load_saved_values(ctx)
except Exception:
saved = {}
values = None
if isinstance(saved, dict) and saved:
# The saved dict is a complete values set from the last /api/save;
# build_pagespec copies it and fills geometry from it.
values = saved
return ba_pagespec.build_pagespec(values=values, reads=ctx.reads)
def geometry_for(ctx, values):
"""`{'geometry': {...}}` for `POST /api/geometry` -- the page's own
`values` dict, whatever the client currently holds, run back through
`ba_pagespec.geometry(ba_pagespec.job_from_values(values))` so the dict
reflects an edit the client made seconds ago and has not (or will not)
have saved yet. Never raises -- a `ValueError` from `job_from_values`
(its messages name the values key that will not parse) and any other
exception come back as an empty geometry dict plus an `error` string, so
a client-side redraw degrades to 'nothing to draw' rather than a dead
page."""
try:
geo = ba_pagespec.geometry(ba_pagespec.job_from_values(values))
return {'geometry': geo if isinstance(geo, dict) else {}}
except Exception as exc:
sys.stderr.write('ba_web: geometry rebuild raised:\n' +
traceback.format_exc())
return {'geometry': {},
'error': '{0}: {1}'.format(type(exc).__name__, exc)}
# ------------------------------------------------------------- run / solve
def _key_for_problem(message):
"""The values key a `ValueError` message is about, or `''` when none.
`ba_pagespec.job_from_values` and `beamcalc.model.from_job` both raise
`ValueError`s whose text names the key or job path that was refused
(`"extents.length_ft: not a number: 'abc'"`; `JobError('missing required
value: extents.section.width_in')`); the message may quote the key
(`'unknown section type: ...'` names no key at all). Best-effort: a
dotted key at the START of the message is the values key itself; a
dotted key inside the message maps to the values key when it is one the
spec renders; anything else is `''`."""
if not message:
return ''
head = message.split(':', 1)[0].strip()
if head and all(part.replace('_', '').isalnum() for part in head.split('.')):
return head
return ''
def run_analysis(ctx, values):
"""`{"ok": True, "result_url": ...}` or `{"ok": False, "kind": ...,
"problems": [...]}` -- `kind` as in `pa_web.run_analysis`: `'input'`
for a value the engine refuses, `'error'` for a solve/write that raised.
Runs `job_from_values` -> `model.from_job` -> `solve.analyse` IN-PROCESS
(the way PA's server calls `panelanalysis.build_page`), writes
`<mark>.job.json` (the job actually run), `<mark>.ba.json`
(`cli.summary(a)`) and `<mark>.html` (`report.render(a)`) into
`<ctx.ba_dir>`, and answers the result URL. Never raises: a `ValueError`
from the conversion/read is reported as a problem keyed to the values
key whose value caused it where that can be told (spec section 5); any
other exception is reported with the exception text and the traceback to
stderr only."""
job = None
try:
job = ba_pagespec.job_from_values(values)
except ValueError as exc:
return {'ok': False, 'kind': 'input', 'problems': [
{'key': _key_for_problem(str(exc)), 'text': str(exc)}]}
try:
beam = BM.from_job(job)
except ValueError as exc:
return {'ok': False, 'kind': 'input', 'problems': [
{'key': _key_for_problem(str(exc)), 'text': str(exc)}]}
try:
analysis = BC_SOLVE.analyse(beam)
except Exception as exc:
sys.stderr.write('ba_web: beamcalc.solve.analyse raised:\n' +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
_ensure_dir(ctx.ba_dir)
mark = ctx.mark
paths = {ext: os.path.join(ctx.ba_dir, mark + ext)
for ext in ('.job.json', '.ba.json', '.html')}
try:
with io.open(paths['.job.json'], 'w', encoding='utf-8') as fh:
json.dump(job, fh, indent=1, sort_keys=True, ensure_ascii=False)
with io.open(paths['.ba.json'], 'w', encoding='utf-8') as fh:
json.dump(BC_CLI.summary(analysis), fh, indent=1, sort_keys=True,
ensure_ascii=False)
with io.open(paths['.html'], 'w', encoding='utf-8') as fh:
fh.write(BC_REPORT.render(analysis))
except Exception as exc:
sys.stderr.write('ba_web: could not write results:\n' +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
return {'ok': True, 'result_url': '/results/' + mark + '.html'}
# ------------------------------------------------------------------ server
def make_server(ctx, port=0):
"""Bind 127.0.0.1 ONLY (never a routable interface), on `port` (0 = let
the OS pick). Threaded so a slow solve never blocks a concurrent static
request -- the same server shape as pa_web."""
server = pa_web.ThreadingHTTPServer(('127.0.0.1', port), Handler)
server.ctx = ctx
return server
class Handler(pa_web.Handler):
"""The route table, inherited plumbing from `pa_web.Handler` (the JSON
body reader, `_send_file`, `_serve_from`, the 404/403 handling, the
stderr request log). The static search walks ba_web/static FIRST and
pa_web/static SECOND, so `index.html` and `ba_drawings.js` come from the
beam folder and `app.js` / `draw.js` / `units.js` / `willtools.css` from
PA's single copies."""
server_version = 'BAWeb/1'
def log_message(self, fmt, *args):
try:
sys.stderr.write('ba_web: {0} - {1}\n'.format(
self.address_string(), fmt % args))
except Exception:
pass
def _static_candidates(self, path):
rel = path.lstrip('/') or 'index.html'
for root in STATIC_DIRS:
yield root, rel
# -------------------------------------------------------------- GET
def do_GET(self):
ctx = self.server.ctx
path = pa_web.unquote(pa_web.urlparse(self.path).path)
try:
if path == '/api/spec':
self._send_json(build_spec_json(ctx))
return
if path.startswith('/results/'):
self._serve_from(ctx.ba_dir, path[len('/results/'):],
'text/html')
return
if path.startswith('/api/'):
self._send_json({'ok': False, 'problems': [
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
return
for root, rel in self._static_candidates(path):
full = os.path.normpath(os.path.join(root, rel))
root_norm = os.path.normpath(root)
if not (full == root_norm or
full.startswith(root_norm + os.sep)):
self.send_error(403, 'forbidden')
return
if os.path.isfile(full):
self._send_file(full,
'text/html' if rel == 'index.html'
else 'application/octet-stream')
return
self.send_error(404, 'not found')
except Exception as exc:
self._send_error_json(exc)
# ------------------------------------------------------------- POST
def do_POST(self):
ctx = self.server.ctx
path = pa_web.urlparse(self.path).path
try:
if path == '/api/save':
values = self._read_json_body().get('values') or {}
_save_values(ctx, values)
self._send_json({'ok': True})
return
if path == '/api/run':
values = self._read_json_body().get('values') or {}
self._send_json(run_analysis(ctx, values))
return
if path == '/api/geometry':
values = self._read_json_body().get('values') or {}
self._send_json(geometry_for(ctx, values))
return
if path == '/api/log':
body = self._read_json_body()
try:
log_ctx = pa_web.Context(
reads=ctx.reads, know={}, job_root=ctx.job_root,
pa_dir=ctx.ba_dir, mark=ctx.mark,
static_dir=STATIC_DIRS[0])
pa_web._append_client_log(log_ctx, body)
except Exception as e: # the docstring's promise: never raises
sys.stderr.write('client.log append failed: %r\n' % (e,))
self._send_json({'ok': True})
return
if path == '/api/close':
self._send_json({'ok': True})
threading.Thread(target=self.server.shutdown).start()
return
self._send_json({'ok': False, 'problems': [
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
except Exception as exc:
self._send_error_json(exc)
# --------------------------------------------------------------------- CLI
def main(argv=None):
ap = argparse.ArgumentParser(
description='Serve the Beam Analysis HTML front/back end.')
ap.add_argument('--reads', default=None,
help='JSON file of the model reads dict -- a PARTIAL job '
'plus "basis" (the Revit gatherer writes one; omit '
'for a blank, still-complete form)')
ap.add_argument('--job-root', dest='job_root', default=None,
help='folder to store this run under (overridden by '
"--reads' own job_root field, if present)")
ap.add_argument('--port', type=int, default=0,
help='0 (default) picks a free port')
ap.add_argument('--open', action='store_true',
help='open the page in the default browser')
ap.add_argument('--title', default=DEFAULT_TITLE,
help='cosmetic only -- logged at startup')
args = ap.parse_args(argv)
ctx = build_context(args.reads, args.job_root)
try:
server = make_server(ctx, args.port or 0)
except OSError as exc:
sys.stderr.write('ba_web: could not bind 127.0.0.1:{0}: {1}\n'.format(
args.port, exc))
return 1
url = 'http://127.0.0.1:{0}/'.format(server.server_address[1])
# FIRST line on stdout, flushed immediately -- a launcher (the Revit
# button, a test) reads this off a pipe before anything else happens.
# The mark / job_root / folder paths are written to STDERR only.
print('BA_WEB_URL={0}'.format(url))
sys.stdout.flush()
sys.stderr.write('ba_web: serving {0} for mark={1} (job_root={2}, '
'title={3})\n'.format(url, ctx.mark, ctx.job_root,
args.title))
if args.open:
try:
webbrowser.open(url)
except Exception as exc:
sys.stderr.write('ba_web: could not open a browser: {0}\n'
.format(exc))
try:
server.serve_forever()
finally:
server.server_close()
return 0
if __name__ == '__main__':
raise SystemExit(main())
+543
View File
@@ -0,0 +1,543 @@
// Beam Analysis drawings for the geometry entries in docs/ba-web-spec.md
// section 4 -- registered on `window.ToolDrawings` so PA's app.js redrawOne
// hook (tools/pa_web/static/app.js:697-706) calls us instead of PA's own
// renderers for beam blocks. Drawn with the same pen as PA: the primitives
// on `window.Draw` exported by tools/pa_web/static/draw.js (svgRoot, label,
// dimH, dimV, ftIn, inch, el, clear, safeNum, clamp, mergeRanges, estWidth,
// _stagger).
//
// Everything works off real-unit numbers (ft, in) straight out of the
// geometry dict -- no pixels arrive from the server, all layout and scaling
// happens in here. Plain ES2017, no modules, no dependencies beyond
// window.Draw and window.Units (both loaded first). Never throws: any field
// may be absent or non-numeric and that load (or its part of the drawing) is
// simply skipped.
(function () {
'use strict';
// draw.js exports its primitives on window.Draw (draw.js:730) and app.js
// reaches them as D.* (app.js:11). Same here: alias the handful this file
// uses into local names so the drawing code below reads like draw.js's
// own renderers instead of wearing a `Draw.` prefix on every call.
var D = window.Draw;
var el = D.el, label = D.label, dimH = D.dimH, dimV = D.dimV;
var svgRoot = D.svgRoot, safeNum = D.safeNum, estWidth = D.estWidth;
var stagger = D._stagger;
var ftIn = D.ftIn, inch = D.inch;
// ------------------------------------------------------------- geometry
// Which shape a block's geometry entry carries is decided by the KEYS on
// the entry, never by page id: `extents.elevation` has the beam outline
// plus supports only; `loads.elevation` adds point/line/moments;
// `reinforcement.elevation` has the outline plus longitudinal bars and
// stirrup zones; `reinforcement.section` is the cross-section.
function isElevation(geom) {
return geom && (geom.length_ft !== undefined || geom.supports !== undefined);
}
function isSection(geom) {
return geom && (geom.width_in !== undefined || geom.type !== undefined);
}
function hasBars(geom) { return !!(geom && (geom.bars || geom.transverse)); }
function hasLoads(geom) { return !!(geom && (geom.point || geom.line || geom.moments)); }
// ---------------------------------------------------------- beam outline
// Shared by every elevation: the beam drawn as a horizontal rectangle
// (length_ft wide, height_in tall) with the height exaggerated the way PA's
// section drawing exaggerates a thin strip, and the support pads under it.
//
// `topReserve` is the band above the member already claimed by the load
// lanes (a small baseline when there are none); the member fills what is
// left between that band and the overall-length dimension row at the
// bottom, so load glyphs never collide with the outline.
function drawBeam(geom, svg, W, H, topReserve) {
var lengthFt = safeNum(geom.length_ft);
if (lengthFt === null || lengthFt <= 0) lengthFt = null;
var heightIn = safeNum(geom.height_in);
if (heightIn === null || heightIn <= 0) heightIn = null;
var marginL = 60, marginR = 30;
var drawW = W - marginL - marginR;
var scale = drawW / (lengthFt || 1);
var dimY = H - 14; // overall-length dimension row (draw.js dimH tick ~6)
var padH = 12; // bearing-pad projection below the member
// Member height: exaggerated up to the space available between the load
// lanes and the pads, never beyond.
var exaggerate = 1, rectH = 0;
if (heightIn !== null) {
var trueH = (heightIn / 12) * scale;
var avail = Math.max(20, dimY - topReserve - padH - 10);
exaggerate = Math.max(1, Math.min(8, avail / (trueH || 1)));
rectH = Math.min(avail, trueH * exaggerate);
}
var rectY = topReserve + 4;
var rectX = marginL;
function xAt(ft) { return rectX + ft * scale; }
var out = {
xAt: xAt, scale: scale, exaggerate: exaggerate,
rectX: rectX, rectY: rectY, rectW: drawW, rectH: rectH,
marginL: marginL, marginR: marginR, drawW: drawW,
dimY: dimY, padH: padH, lengthFt: lengthFt, heightIn: heightIn,
topReserve: topReserve
};
// Beam outline -- only when there is a length to place it by.
if (lengthFt !== null) {
el('rect', {
x: rectX, y: rectY, width: drawW, height: rectH,
fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5
}, svg);
}
// Supports: bearing pads the width of their own length_ft, drawn as a
// pad projecting below the beam, with a filled triangle glyph centred on
// the pad centreline (the bearing point). Pads that would run past the
// beam end are clipped to the beam face.
(geom.supports || []).forEach(function (s) {
if (!s || typeof s !== 'object') return;
var x = safeNum(s.x_ft);
if (x === null) return;
var len = safeNum(s.length_ft);
if (len === null || len < 0) len = 0;
var kind = s.kind;
var tri = (kind || '').toLowerCase() === 'pin' ? '#1b1e23' : '#475569';
var cx = xAt(x);
var padL = xAt(Math.max(0, x - len / 2));
var padR = xAt(Math.min(lengthFt || 0, x + len / 2));
if (padR - padL < 2) { padL = cx - 3; padR = cx + 3; }
var padTop = rectY + rectH;
var padBot = Math.min(dimY - 8, padTop + padH);
if (padBot > padTop) {
el('rect', {
x: padL, y: padTop, width: padR - padL, height: padBot - padTop,
fill: '#e2e8f0', stroke: '#1b1e23', 'stroke-width': 1.2
}, svg);
}
// Triangle glyph at the centreline, apex at the beam underside (the
// support reaction point), base at the pad bottom. With no member
// height the apex lands on the drawn member line itself.
var apexY = rectH ? padTop : rectY;
el('polygon', {
points: [cx - 6, padBot, cx + 6, padBot, cx, apexY].join(' '),
fill: tri, stroke: tri
}, svg);
});
// Overall length dimension, below the pads, in feet-inches.
if (lengthFt !== null) {
dimH(svg, rectX, rectX + drawW, dimY, ftIn(lengthFt));
}
return out;
}
// ----------------------------------------------------------------- load
//
// Loads are drawn ABOVE the beam in lanes: line loads at the top (their
// band + hatch is the tallest glyph), moments below them, and point-load
// arrows immediately above the member so their arrowheads land on the beam
// top without crossing another lane.
// Measure the lanes first (so the beam can be pushed below them), draw
// them after. Returns null when the geometry carries no loads; otherwise
// { topReserve, lines, points, moments } with every y absolute in canvas.
function loadLanes(geom, xAt) {
var points = [], lines = [], moments = [];
(geom.line || []).forEach(function (ln) {
if (!ln || typeof ln !== 'object') return;
var x0 = safeNum(ln.x0_ft), x1 = safeNum(ln.x1_ft);
if (x0 === null || x1 === null) return;
if (x1 < x0) { var t = x0; x0 = x1; x1 = t; }
var px0 = xAt(x0), px1 = xAt(x1);
if (px1 - px0 >= 2) lines.push({ px0: px0, px1: px1 });
});
(geom.point || []).forEach(function (p) {
if (!p || typeof p !== 'object') return;
var x = safeNum(p.x_ft);
if (x !== null) points.push({ px: xAt(x), id: p.id });
});
(geom.moments || []).forEach(function (m) {
if (!m || typeof m !== 'object') return;
var x = safeNum(m.x_ft);
if (x !== null) moments.push({ px: xAt(x), id: m.id });
});
if (!points.length && !lines.length && !moments.length) return null;
// Line loads: one 26px lane each (label above a band + hatch).
var lane = 0;
lines.forEach(function (ln) {
ln.y = 40 + lane * 26; // label row of this lane
lane++;
});
var laneH = lane * 26;
// Moment arcs sit below the line lanes, above the point-load arrows.
var momentH = moments.length ? 44 : 0;
moments.forEach(function (m, i) {
m.y = 40 + laneH + 12 + (moments.length > 1 ? i * 24 : 0); // arc centre row
});
// Point-load arrows are drawn lowest so their tips reach the beam top;
// their labels are staggered so close loads never print on top of each
// other. The reserve must hold the deepest stagger level.
var pItems = points.map(function (p) {
var w = estWidth(p.id || '?', 10);
return { x0: p.px - w / 2, x1: p.px + w / 2 };
});
var pLevels = stagger(pItems, 6);
var maxLvl = 0;
pLevels.forEach(function (l) { if (l > maxLvl) maxLvl = l; });
var pointH = points.length ? 36 + maxLvl * 16 : 0;
var topReserve = 40 + laneH + momentH + pointH;
points.forEach(function (p, i) {
p.y = topReserve - pointH + 8 + pLevels[i] * 16; // arrow tail row
});
return {
topReserve: topReserve,
lines: lines, points: points, moments: moments
};
}
// One glyph per load, labelled with the id ONLY -- the case values are too
// dense to print on an elevation (the tables carry them). `lanes` holds the
// measured positions; `out` is the beam layout whose top the arrows land
// on.
function drawLoads(geom, lanes, svg, out) {
var beamTop = out.rectY;
// Line loads: a band along their own extent with hatch below.
lanes.lines.forEach(function (ln) {
var by = ln.y + 13; // band line sits under the label row
el('line', { x1: ln.px0, y1: by, x2: ln.px1, y2: by, stroke: '#0ea5e9', 'stroke-width': 4 }, svg);
var ticks = Math.max(2, Math.round((ln.px1 - ln.px0) / 18));
for (var i = 0; i <= ticks; i++) {
var tx = ln.px0 + (ln.px1 - ln.px0) * (i / ticks);
el('line', { x1: tx, y1: by, x2: tx, y2: by + 8, stroke: '#0ea5e9', 'stroke-width': 1 }, svg);
}
});
// Line-load ids: draw after the bands so label backgrounds sit on top.
var li = 0;
(geom.line || []).forEach(function (ln) {
if (!ln || typeof ln !== 'object') return;
var x0 = safeNum(ln.x0_ft), x1 = safeNum(ln.x1_ft);
if (x0 === null || x1 === null) return;
if (x1 < x0) { var t = x0; x0 = x1; x1 = t; }
if (lanes.lines[li]) {
label(svg, (lanes.lines[li].px0 + lanes.lines[li].px1) / 2,
lanes.lines[li].y - 2, ln.id || '?', { size: 10, fill: '#0ea5e9' });
}
li++;
});
// Point loads: a down arrow whose tip reaches the beam top.
lanes.points.forEach(function (p, i) {
var tipY = beamTop - 2;
el('line', { x1: p.px, y1: p.y, x2: p.px, y2: tipY - 10, stroke: '#2f6fed', 'stroke-width': 2 }, svg);
el('polygon', {
points: [p.px - 4, tipY - 12, p.px + 4, tipY - 12, p.px, tipY - 2].join(' '),
fill: '#2f6fed'
}, svg);
var pid = (geom.point[i] && geom.point[i].id) || '?';
label(svg, p.px, p.y - 4, pid, { size: 10, fill: '#2f6fed' });
});
// Moments: a curved-arrow (rotation) glyph with the id below it.
lanes.moments.forEach(function (m, i) {
var cy = m.y;
var r = 9;
var x0 = m.px + r, y0 = cy;
var x1 = m.px + r * Math.cos(-2.4), y1 = cy + r * Math.sin(-2.4);
el('path', {
d: 'M ' + x0 + ' ' + y0 + ' A ' + r + ' ' + r + ' 0 1 0 ' + x1 + ' ' + y1,
fill: 'none', stroke: '#d33', 'stroke-width': 1.8
}, svg);
var tipX = m.px + r * Math.cos(-0.4), tipY = cy + r * Math.sin(-0.4);
var ang = -0.4 - Math.PI / 2;
el('polygon', {
points: [tipX, tipY,
tipX + 6 * Math.cos(ang + 0.5), tipY + 6 * Math.sin(ang + 0.5),
tipX + 6 * Math.cos(ang - 0.5), tipY + 6 * Math.sin(ang - 0.5)].join(' '),
fill: '#d33'
}, svg);
var mid = (geom.moments[i] && geom.moments[i].id) || '?';
label(svg, m.px, cy + r + 16, mid, { size: 10, fill: '#d33' });
});
}
// ----------------------------------------------------------------- bar
// Longitudinal bar rows on the reinforcement elevation: one horizontal line
// at the bar's y_in (measured up from the beam soffit), with a hook tick at
// the end whose condition is 'hook'. Straight ends get no glyph. A bar can
// only be placed once the outline has a height -- with no height entered
// there is nowhere to put it, and it is skipped (geometry problems name it).
function drawBars(geom, svg, out) {
if (!out.heightIn) return;
var hPx = out.rectH / out.heightIn; // pixels per inch of depth (already exaggerated)
(geom.bars || []).forEach(function (b) {
if (!b || typeof b !== 'object') return;
var x0 = safeNum(b.x0_ft), x1 = safeNum(b.x1_ft);
if (x0 === null || x1 === null) return;
if (x1 < x0) { var t = x0; x0 = x1; x1 = t; }
var yIn = safeNum(b.y_in);
if (yIn === null) return;
if (yIn < 0) yIn = 0;
if (yIn > out.heightIn) yIn = out.heightIn;
// y_in from the BOTTOM of the section; SVG y runs down from the top of
// the outline.
var y = out.rectY + out.rectH - yIn * hPx;
var px0 = out.xAt(x0), px1 = out.xAt(x1);
el('line', { x1: px0, y1: y, x2: px1, y2: y, stroke: '#1b1e23', 'stroke-width': 1.6 }, svg);
// Hook: a short perpendicular tick turned toward the member interior
// (up for a bar in the bottom half, down for one in the top half).
var hookUp = yIn < out.heightIn / 2;
if ((b.start_cond || '').toLowerCase() === 'hook') {
var hy = hookUp ? y - 6 : y + 6;
el('line', { x1: px0, y1: y, x2: px0, y2: hy, stroke: '#1b1e23', 'stroke-width': 1.6 }, svg);
}
if ((b.end_cond || '').toLowerCase() === 'hook') {
var hy2 = hookUp ? y - 6 : y + 6;
el('line', { x1: px1, y1: y, x2: px1, y2: hy2, stroke: '#1b1e23', 'stroke-width': 1.6 }, svg);
}
});
}
// Transverse ("stirrup") zones on the reinforcement elevation: vertical
// ticks over x0..x1 at the true spacing_in pitch -- the first tick at x0
// (the face of the first stirrup) and one every pitch after it, ticks that
// would land past x1 dropped. Cap at 60 ticks per zone: a 4" pitch over a
// 40' span would otherwise try to draw 120 hairlines the browser then has
// to rasterise. The band spans the member depth inside a small inset so it
// reads as ties around the bars. `legs > 2` adds a second hairline.
function drawTransverse(geom, svg, out) {
(geom.transverse || []).forEach(function (z) {
if (!z || typeof z !== 'object') return;
var x0 = safeNum(z.x0_ft), x1 = safeNum(z.x1_ft);
if (x0 === null || x1 === null) return;
if (x1 < x0) { var t = x0; x0 = x1; x1 = t; }
var spacingIn = safeNum(z.spacing_in);
if (spacingIn === null || spacingIn <= 0) return;
var yTop = out.rectY, yBot = out.rectY + out.rectH;
if (out.heightIn) {
// Stirrups wrap the steel inside the cover: draw the band inset from
// the outline faces so it reads as ties around the bars, not as a
// solid fill of the whole depth.
var inset = Math.max(1.5, (Math.min(2, out.heightIn / 4) / out.heightIn) * out.rectH);
yTop += inset;
yBot -= inset;
}
if (yBot - yTop < 2) return;
var legs = Math.max(1, Math.round(safeNum(z.legs) || 1));
var px0 = out.xAt(x0), px1 = out.xAt(x1);
if (px1 - px0 < 2) return;
// Step from x0 at spacing_in (converted to feet); each tick is one
// pitch on. The cap keeps an extreme zone from overdrawing.
var count = Math.min(60, Math.floor((x1 - x0) / (spacingIn / 12)) + 1);
for (var i = 0; i < count; i++) {
var tx = px0 + i * (spacingIn / 12) * out.scale;
if (tx > px1 + 0.5) break;
el('line', { x1: tx, y1: yTop, x2: tx, y2: yBot, stroke: '#7c3aed', 'stroke-width': 0.9 }, svg);
if (legs > 2) {
el('line', { x1: tx + 2.5, y1: yTop, x2: tx + 2.5, y2: yBot, stroke: '#c4b5fd', 'stroke-width': 0.6 }, svg);
}
}
});
}
// ---------------------------------------------------------------- hooks
// `elevation(inst, state, host, geom)`: the beam outline + supports +
// overall length (extents.elevation); load arrows/bands/moments when the
// geometry carries loads (loads.elevation); longitudinal bars and stirrup
// zones when it carries reinforcement (reinforcement.elevation). One glyph
// per load or bar, ids only -- never the case values. Which variant is
// drawn is decided by which keys the geometry entry carries, not page id.
function elevation(inst, state, host, geom) {
geom = geom || {};
var aspect = (inst && inst.block && inst.block.aspect) || 0.24;
var root = svgRoot(host, aspect, 1000);
var svg = root.svg, W = root.W, H = root.H;
if (isSection(geom) && !isElevation(geom)) {
// Defensive: a section entry mis-routed to the elevation fn.
label(svg, W / 2, H / 2, '(cross-section data -- use the section drawing)', { size: 12, fill: '#6b7280' });
return;
}
var lengthFt = safeNum(geom.length_ft);
if (lengthFt === null || lengthFt <= 0) lengthFt = null;
if (lengthFt === null && !hasLoads(geom) && !hasBars(geom)) {
label(svg, W / 2, H / 2, 'Enter a beam length to see the elevation.', { size: 12, fill: '#6b7280' });
return;
}
// Reserve the load lanes ABOVE the member before the beam is laid out,
// so the outline never draws over a load glyph. Loads only render for
// the loads.elevation entry (the reinforcement elevation carries bars).
var scale = (W - 90) / (lengthFt || 1);
var lanes = (hasLoads(geom) && !hasBars(geom))
? loadLanes(geom, function (ft) { return 60 + ft * scale; })
: null;
var topReserve = lanes ? lanes.topReserve : 18;
var out = drawBeam(geom, svg, W, H, topReserve);
if (hasBars(geom)) {
// reinforcement.elevation: bars + stirrup zones ON the outline.
drawBars(geom, svg, out);
drawTransverse(geom, svg, out);
} else if (lanes) {
// loads.elevation: one glyph per load above the outline.
drawLoads(geom, lanes, svg, out);
}
if (out.heightIn !== null && out.exaggerate > 1.1) {
label(svg, out.marginL, 12, '(depth exaggerated ' + out.exaggerate.toFixed(1) + '\u00d7)', { anchor: 'start', size: 10, fill: '#6b7280' });
}
}
// `section(inst, state, host, geom)`: the cross-section (rectangular / L /
// inverted_t), the bars as filled circles at their (x_in, y_in) -- y from
// the soffit, converted to SVG's top-down y -- strands as smaller OPEN
// circles, width/height dimensions in inches, and a `_stagger`ed label per
// distinct bar size.
function section(inst, state, host, geom) {
geom = geom || {};
var aspect = (inst && inst.block && inst.block.aspect) || 0.28;
var root = svgRoot(host, aspect, 1000);
var svg = root.svg, W = root.W, H = root.H;
var widthIn = safeNum(geom.width_in);
var heightIn = safeNum(geom.height_in);
if (widthIn === null || widthIn <= 0 || heightIn === null || heightIn <= 0) {
label(svg, W / 2, H / 2, 'Enter the section width and height to see the section.', { size: 12, fill: '#6b7280' });
return;
}
// Margins follow renderReinforcementSection: width dimension below the
// section, height dimension to its left, labels in inches.
var marginL = 70, marginR = 30, marginTop = 40, marginBottom = 56;
var drawW = W - marginL - marginR;
var drawH = H - marginTop - marginBottom;
var scale = Math.min(drawW / widthIn, drawH / heightIn);
var rectX = marginL + (drawW - widthIn * scale) / 2;
var rectY = marginTop + (drawH - heightIn * scale) / 2;
function xAt(xIn) { return rectX + xIn * scale; }
// y_in measured from the bottom of the section; SVG y runs down.
function yAt(yIn) { return rectY + (heightIn - yIn) * scale; }
// The section outline mirrors `model.Section.rects`
// (beamcalc/src/beamcalc/model.py:170-217) exactly -- that is the
// canonical shape the engine analyses, so the picture and the numbers
// cannot drift. Rects are (x0, y0, w, h) with y measured UP from the
// bottom, x from the left. `height_in` is the OVERALL height
// (stem_h + flange_t for L / inverted_t -- model.py:196,209); the stem
// rectangle is drawn at FULL height and the ledge at the bottom.
function addOutline(xIn, yBottomIn, wIn, hIn) {
el('rect', {
x: xAt(xIn), y: yAt(yBottomIn + hIn), width: Math.max(1, wIn * scale), height: Math.max(1, hIn * scale),
fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.2
}, svg);
}
var type = String(geom.type || 'rectangular').toLowerCase().replace(/[\s_]+/g, '_');
if (type === 'rect' || type.indexOf('rect') === 0) type = 'rectangular';
var stemW = safeNum(geom.stem_width_in);
var flangeW = safeNum(geom.flange_width_in);
var flangeT = safeNum(geom.flange_thickness_in);
var side = String(geom.side || 'left').toLowerCase();
if (type === 'inverted_t' || type === 'inverted_tee' || type === 'it') {
// model.py:205-217 -- a full-height centred stem over a bottom flange
// that runs out both sides: (flange_w, 0, stem_w, h) plus the two
// side ledges (0, 0, flange_w, flange_t) and
// (flange_w + stem_w, 0, flange_w, flange_t).
if (flangeT !== null) {
addOutline(0, 0, widthIn, flangeT);
}
if (stemW !== null && flangeW !== null) {
addOutline(flangeW, 0, stemW, heightIn);
}
} else if (type === 'l') {
// model.py:190-203 -- the stem occupies the full height; the ledge is
// at the BOTTOM on one side. side=left: stem starts at x=flange_w and
// the ledge fills the left; side=right is the mirror image.
if (stemW !== null && flangeW !== null) {
var stemX = side === 'right' ? 0 : flangeW;
var ledgeX = side === 'right' ? stemW : 0;
addOutline(stemX, 0, stemW, heightIn);
if (flangeT !== null) addOutline(ledgeX, 0, flangeW, flangeT);
} else if (flangeT !== null && flangeW !== null) {
// No stem dims (malformed): still show the ledge rather than a blank.
addOutline(side === 'right' ? Math.max(0, widthIn - flangeW) : 0, 0, flangeW, flangeT);
}
} else {
// rectangular -- and anything unrecognised falls back to a plain box
// rather than drawing nothing.
addOutline(0, 0, widthIn, heightIn);
}
// Bars: filled circles, diameter scaled to the section (capped so a #11
// bar does not cover its neighbour), at their own x_in / y_in.
(geom.bars || []).forEach(function (b) {
if (!b || typeof b !== 'object') return;
var xIn = safeNum(b.x_in), yIn = safeNum(b.y_in);
if (xIn === null || yIn === null) return;
var diaIn = safeNum(b.dia_in);
var r = (diaIn !== null && diaIn > 0) ? Math.max(2, Math.min(6, diaIn * scale * 0.5)) : 3;
el('circle', { cx: xAt(xIn), cy: yAt(yIn), r: r, fill: '#1b1e23' }, svg);
});
// Strands: smaller OPEN circles (the PA convention for prestressing
// steel in section).
(geom.strands || []).forEach(function (s) {
if (!s || typeof s !== 'object') return;
var xIn = safeNum(s.x_in), yIn = safeNum(s.y_in);
if (xIn === null || yIn === null) return;
el('circle', { cx: xAt(xIn), cy: yAt(yIn), r: 3, fill: '#fff', stroke: '#2f6fed', 'stroke-width': 1.4 }, svg);
});
dimH(svg, rectX, rectX + widthIn * scale, rectY + heightIn * scale + 16, inch(widthIn), { below: true });
dimV(svg, rectY, rectY + heightIn * scale, rectX - 14, inch(heightIn), { left: true });
// One `_stagger`ed label per distinct bar size, spread across the width
// just above the outline -- a legend that never prints over the concrete.
// `_stagger` levels push a label down only when it would otherwise
// overlap its neighbour's text.
var sizes = [];
(geom.bars || []).forEach(function (b) {
if (!b || typeof b !== 'object') return;
var size = b.size;
if (!size || sizes.indexOf(size) !== -1) return;
sizes.push(size);
});
if (sizes.length) {
var gap = 14;
var items = sizes.map(function (s, i) {
var w = estWidth(s, 11) + gap;
// Real x centres: spread the labels evenly across the section width.
var cx = rectX + widthIn * scale * (i + 0.5) / sizes.length;
return { x0: cx - w / 2, x1: cx + w / 2 };
});
var levels = stagger(items, 6);
var lvlY = rectY - 10;
sizes.forEach(function (s, i) {
var cx = rectX + widthIn * scale * (i + 0.5) / sizes.length;
label(svg, cx, lvlY + levels[i] * 13, s, { size: 11, fill: '#1b1e23' });
});
}
}
window.ToolDrawings = {
elevation: elevation,
section: section
};
})();
+20
View File
@@ -0,0 +1,20 @@
<meta charset="utf-8">
<title>Beam Analysis</title>
<link rel="stylesheet" href="willtools.css">
<div class="app-shell">
<div class="toolbar">
<div class="toolbar-title">Beam Analysis<span id="pa-mark" class="pa-mark"></span></div>
<div class="toolbar-spacer"></div>
<button id="btn-run" class="btn btn-primary">Run</button>
<button id="btn-close" class="btn">Close</button>
</div>
<div id="banner" class="banner" hidden></div>
<div id="spec-problems" class="spec-problems" hidden></div>
<div class="tabs" id="tabs"></div>
<div class="pages" id="pages"></div>
</div>
<pre id="errors" hidden></pre>
<script src="units.js"></script>
<script src="draw.js"></script>
<script src="ba_drawings.js"></script>
<script src="app.js"></script>
+777
View File
@@ -0,0 +1,777 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Is a frozen job deck still what CURRENT knowledge would freeze today?
python tools/deckcheck.py JOBS_DIR
python tools/deckcheck.py JOBS_DIR --json OUT.json
python tools/deckcheck.py JOBS_DIR --job-root DIR # skip the auto-guess
JOBS_DIR is a sweep's `jobs/` folder -- `*.job.json` files, each one panel's
complete frozen input deck (`docs/lift-check.md`, `docs/plan-panellift-
three-stage.md` SS5). Job files are input decks on purpose: a deck must
reproduce its own result forever, so `liftexport.py` freezes every knowledge-
derived value into it at export and `panellift` never re-reads the catalogue.
That is correct, and it is not what this tool checks.
WHAT WENT WRONG, TWICE, ON 2026-09-01
--------------------------------------
1. A capacity in `knowledge/catalogue.yaml` was corrected after 282 decks had
already been exported carrying the old number. Five real panels showed
FAIL at 113% off the stale value before anyone could say why, and
explaining it took a git-history dig across two repositories.
2. A frozen `panel.design_number` was read as CURRENT by an agent answering
the owner, and the model had moved since the export.
Freezing was never the defect. The defect is that staleness was
UNDETECTABLE -- nothing recorded which knowledge version froze a value, and
nothing could tell a live deck from a fossil. This tool detects it, for the
knowledge half only (point 4 below); a full-fidelity version, with a sha per
layer folded into every future deck, is designed in
`docs/plan-panellift-three-stage.md` SS5 and is not built here.
WHAT IT DOES
------------
For every `*.job.json` in JOBS_DIR, it re-runs the SAME knowledge lookups
`liftexport.py` ran at export time -- `knowledge.lifter_capacity()`,
`lifter_basis_fc()`, `lifter_edge_distance()`, `lifter_safety_factor()`, and
the stage-level `handling.tension_limit_sqrt_fc` -- against knowledge
re-resolved from the files ON DISK RIGHT NOW, and compares the fresh answer
to the number the deck already carries. A field is judged only when the deck
actually carries it (an older deck missing a field is silently not compared
on that field, never treated as a match).
Four outcomes, never collapsed into each other:
current the deck's frozen value is what current knowledge gives too
stale they disagree -- named: mark, field, frozen value, current
value, and (when `lifter_capacity`'s own `why` names one)
which layer moved
conditional `lifter_capacity`'s with-tension-bar branch depends on
`assembly_families` -- every OTHER member's family name on
the same assembly, which `liftexport.py` reads off the
live model and NEVER WRITES INTO THE DECK (see its own
comment on this: the basis/why strings ride along
specifically so this information is not lost, and the
gate's corroborating evidence is lost anyway). So when the
family is gated and the deck cannot settle which side of
the gate applies, this reports BOTH values the current
catalogue could give and does not guess which one this
piece would get. This is not a shortfall in this tool; it
is a real gap in what the deck records, named rather than
papered over.
unjudgeable current knowledge cannot resolve the field at all (no
catalogue alias, no matching stage spec, ...) -- named,
with why. An unjudgeable deck is NOT a passing deck; the
summary counts it apart from `current` for exactly that
reason (see CLAUDE.md SS3, "could not judge" and "judged,
found nothing" are different answers).
WHAT IT DOES NOT DO
--------------------
This checks a deck against KNOWLEDGE, not against the MODEL. A deck can pass
every check here and still describe a panel whose geometry has since changed
in Revit -- design numbers, lifter positions, strand layouts. Verifying that
half needs the live model and is out of scope offline; incident 2 above is
that half, and it is not what this tool answers. The caveat prints once, at
the top of every run, so it cannot be scrolled past.
WHY THIS DOES NOT CALL `knowledge.load(doc)`
---------------------------------------------
`load()` takes a live Revit `doc` and its file layers merge through
`knowledge._merge_file`, which reaches for `pyrevit.coreutils.yaml` -- a
reader that exists only inside Revit. Off Revit that import fails on every
layer, `_merge_file` records each one "unreadable" and returns the tree
unmerged, and every lookup would silently run against bare `DEFAULTS`. This
tool is the same substitution `tools/knowledge_drift.py` documents making,
for the same reason: `build_knowledge()` below re-implements the identical
five-layer order and the identical `job.plant` two-pass `load()`'s own
docstring insists on, reading each YAML file with PyYAML instead and reusing
`knowledge._coerce` / `knowledge._deep_update` / `knowledge.DEFAULTS`
directly -- so a coercion rule fixed in `knowledge.py` (the leading-zero
label rule, for one) is not a second bug waiting to happen here.
CONSTRAINTS THIS RESPECTS
--------------------------
Host CPython, no Revit, standalone. Imports `vibe.knowledge` and
`vibe.plantpath` the way `tools/ticket_delta.py` / `tools/snapshot_defer.py`
do -- see the stub registration below, and
`WillTools.extension/tests/conftest.py`'s docstring for why a plain
`import vibe.knowledge` cannot work off Revit.
Prints no plant or client name of its own. Family names, marks and plant ids
read out of a job file are DATA flowing through, exactly as `sweep.py` and
`knowledge_drift.py` already print them at runtime; nothing about that is
baked into this file's own strings. See `.scrub-terms` and CLAUDE.md SS "No
client or job information in the repo" -- design numbers, marks and part
codes are not client information.
"""
from __future__ import annotations
import argparse
import io
import json
import math
import os
import sys
import time
import types
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
LIB = os.path.join(REPO, 'WillTools.extension', 'lib')
if LIB not in sys.path:
sys.path.insert(0, LIB)
# Same trick `tools/ticket_delta.py`, `tools/snapshot_defer.py` and
# `WillTools.extension/tests/conftest.py` all use, and for the same reason:
# `vibe/__init__.py` imports the Revit API at module scope, which is right
# inside Revit and fatal on host CPython. Register a bare `vibe` package
# pointed at the directory so the Revit-free submodules import normally and
# `__init__.py` never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(LIB, 'vibe')]
sys.modules['vibe'] = _stub
from vibe import knowledge # noqa: E402
from vibe import plantpath # noqa: E402
try:
import yaml
except ImportError: # pragma: no cover
sys.stderr.write('PyYAML is required: pip install pyyaml\n')
raise SystemExit(2)
CURRENT = 'current'
STALE = 'stale'
CONDITIONAL = 'conditional'
UNJUDGEABLE = 'unjudgeable'
# Worse first. A deck's overall status is the worst of its findings' -- the
# same "not-judged-is-not-passed" rule as everywhere else in this codebase:
# an unjudged deck is not a clean deck, and a conditional one is not either.
_SEVERITY = {STALE: 3, CONDITIONAL: 2, UNJUDGEABLE: 1, CURRENT: 0}
# ------------------------------------------------------------- knowledge
class KnowledgeLayers(object):
"""What `build_knowledge` resolved, and what it found on disk.
Kept apart from the merged dict itself so a report can name which
layer's file is newest, and which layers were absent or unreadable,
without re-deriving any path a second time.
"""
def __init__(self):
self.defaults_path = None
self.catalogue_path = None
self.catalogue_status = None
self.plant_id = None
self.plant_path = None
self.plant_status = None
self.job_path = None
self.job_status = None
self.config_path = None
self.config_status = None
def rows(self):
"""(label, path, status) for every layer that names a path."""
return [
('defaults', self.defaults_path, 'source'),
('catalogue', self.catalogue_path, self.catalogue_status),
('plant', self.plant_path, self.plant_status),
('job', self.job_path, self.job_status),
('config', self.config_path, self.config_status),
]
def newest_mtime(self):
best = None
for _label, path, status in self.rows():
if not path or status in ('absent', 'missing', None):
continue
if not os.path.isfile(path):
continue
m = os.path.getmtime(path)
if best is None or m > best:
best = m
return best
def _read_yaml(path):
with io.open(path, 'r', encoding='utf-8') as fh:
return yaml.safe_load(fh)
def _merge_layer(merged, path):
"""Merge one YAML layer file, PyYAML instead of pyRevit's reader.
Mirrors `knowledge._merge_file`'s contract: a file that is not there is
ordinary and silent ('absent'); a file that IS there and will not parse
is recorded and skipped WHOLE, never partially merged ('unreadable') --
the same discipline `_merge_file`'s own docstring explains, because a
layer that fails to load must never look like a layer that agrees with
the defaults.
"""
if not path or not os.path.isfile(path):
return 'absent'
try:
data = _read_yaml(path)
except Exception:
return 'unreadable'
if isinstance(data, dict):
knowledge._deep_update(merged, knowledge._coerce(data))
return 'merged'
def build_knowledge(job_root):
"""Re-resolve knowledge the way `knowledge.load(doc)` does, no `doc`.
Same five layers, same order, same `job.plant` two-pass `load()`'s own
docstring insists cannot be collapsed into one pass (the plant file has
to be read from the name the job file states, and the job file merges
AFTER the plant layer -- so the name is read on its own, first, exactly
as `knowledge.plant_id()` does for the live-doc path).
`job_root` is the folder that holds the job's OWN `knowledge.yaml` --
for a sweep folder `<job_root>/sweep/jobs/*.job.json`, that is
`<job_root>/knowledge.yaml`, the same relationship
`designspec.root_dir()` / `sub_dir(doc, 'sweep')` establish for the live
path. See `_guess_job_root` for how the CLI finds it when not told.
The project config layer (last of the five) is included only in the
thin sense the rest of this tool needs: unwrap `knowledge:` if present,
else merge the file whole. The `decisions:` friendly-name mapping
`knowledge._merge_decisions` does for a live project config is NOT
replicated here -- no active-config.txt exists on the reference sweep
this was built against, and a config layer written entirely under
`decisions:` would be under-read by this tool. Named here rather than
silently: if that ever matters, `_merge_config_file`'s mapping needs a
host-side twin, not a guess bolted on here.
"""
layers = KnowledgeLayers()
merged = knowledge._deep_copy(knowledge.DEFAULTS)
layers.defaults_path = os.path.abspath(knowledge.__file__)
layers.catalogue_path = knowledge.catalogue_path()
layers.catalogue_status = _merge_layer(merged, layers.catalogue_path)
job_path = os.path.join(job_root, 'knowledge.yaml') if job_root else None
layers.job_path = job_path
plant = None
if job_path and os.path.isfile(job_path):
try:
raw = _read_yaml(job_path) or {}
except Exception:
raw = {}
job_block = raw.get('job') if isinstance(raw, dict) else None
if isinstance(job_block, dict) and job_block.get('plant') is not None:
plant = str(job_block['plant']).strip() or None
layers.plant_id = plant
if plant is not None and not plantpath.valid_plant_id(plant):
layers.plant_status = 'invalid_plant_id'
else:
plant_path = plantpath.plant_knowledge_path(plant)
layers.plant_path = plant_path
if plant is not None and not (plant_path and os.path.isfile(plant_path)):
# Same discipline as `knowledge.load()`'s `_plant_missing`:
# NEVER fall back to the machine-wide file when a plant was
# named and its folder is not there.
layers.plant_status = 'missing'
else:
layers.plant_status = _merge_layer(merged, plant_path)
layers.job_status = _merge_layer(merged, job_path) if job_path else 'absent'
if job_root:
pointer = os.path.join(job_root, 'active-config.txt')
if os.path.isfile(pointer):
with io.open(pointer, 'r', encoding='utf-8') as fh:
config_path = fh.read().strip()
layers.config_path = config_path or None
if config_path:
if os.path.isfile(config_path):
try:
holder = _read_yaml(config_path) or {}
except Exception:
layers.config_status = 'unreadable'
else:
body = holder.get('knowledge')
body = body if isinstance(body, dict) else holder
knowledge._deep_update(merged, knowledge._coerce(body))
layers.config_status = 'merged'
else:
layers.config_status = 'missing'
merged['_plant_id'] = plant
return merged, layers
def _guess_job_root(jobs_dir):
"""`<job_root>/sweep/jobs` -> `<job_root>`, verified against disk.
`tools/sweep.py`'s `sweep_dir()` writes `jobs/` at
`designspec.sub_dir(doc, 'sweep')/jobs`, i.e. two levels under
`root_dir(doc)` -- the same folder `knowledge_path(doc)` puts
`knowledge.yaml` in. So the parent of JOBS_DIR's parent is tried first;
if it holds no `knowledge.yaml`, every ancestor up to five levels is
tried in turn (a renamed sweep folder, or `--check`'s custom `--out`,
can move the depth). None found returns the two-level guess anyway, so
the caller always has a slightly wrong parent to complain about instead
of no parent at all.
"""
jobs_dir = os.path.abspath(jobs_dir)
two_up = os.path.dirname(os.path.dirname(jobs_dir))
candidate = two_up
node = jobs_dir
for _ in range(5):
node = os.path.dirname(node)
if not node or node == os.path.dirname(node):
break
if os.path.isfile(os.path.join(node, 'knowledge.yaml')):
return node
return two_up
# ------------------------------------------------------------ comparisons
def _num_eq(a, b):
if a is None or b is None:
return a is b
try:
return math.isclose(float(a), float(b), rel_tol=1e-9, abs_tol=1e-6)
except (TypeError, ValueError):
return a == b
def _verdict(current_val, frozen_val):
"""current/stale/unjudgeable for one plain numeric field.
Both None: current (frozen agreement on 'nothing known'). current is
None, frozen is not: unjudgeable -- current knowledge cannot confirm OR
deny the frozen value, which is not the same as it being wrong. frozen
is None, current is not: stale -- something is knowable now that was not
at export, which is exactly the kind of drift this tool exists to catch.
Both known: compared with a float tolerance.
"""
if current_val is None and frozen_val is None:
return CURRENT
if current_val is None:
return UNJUDGEABLE
if frozen_val is None:
return STALE
return CURRENT if _num_eq(current_val, frozen_val) else STALE
def check_capacity(know, family, kind, frozen_val, tension_bar_families):
"""(status, detail) for `capacity_lb` / `shear_capacity_lb`.
The gate `knowledge.lifter_capacity` applies (tension only; see its own
docstring) needs `assembly_families` -- every OTHER member's family
name on the same assembly -- which the deck never carries (see this
module's own docstring). Calling with `assembly_families=None` always
resolves SOMETHING (the conservative without-bar figure, by design --
"an omitted list never earns the 2x rating by accident"), and that
number can even happen to equal the frozen one. Trusting that match
would be exactly the guess point 1 of the spec this was built against
forbids: it is not evidence the piece lacks its tension bar, only that
nothing here was told either way. So whenever the without-bar branch is
the one that resolved, the with-bar branch is also computed (by forcing
corroboration with the plant's own `tension_bar_families`) and, if it
gives a different number, this reports CONDITIONAL naming both --
regardless of which one the frozen value happens to match.
"""
val, basis, why = knowledge.lifter_capacity(
know, family, kind=kind, assembly_families=None)
if kind == 'tension' and basis == 'without_bar':
alt_val, alt_basis, alt_why = knowledge.lifter_capacity(
know, family, kind=kind, assembly_families=tension_bar_families)
if alt_basis == 'with_bar' and not _num_eq(alt_val, val):
matches = [b for b, v in (('without_bar', val), ('with_bar', alt_val))
if _num_eq(v, frozen_val)]
return CONDITIONAL, {
'frozen': frozen_val,
'without_bar': val, 'without_bar_why': why,
'with_bar': alt_val, 'with_bar_why': alt_why,
'frozen_matches': matches,
'reason': (
"gated on a welded tension bar; this deck does not "
"carry the assembly's other member families, so "
"whether the bar was corroborated on THIS piece cannot "
"be re-derived offline. Current catalogue gives {0} lb "
"without the bar or {1} lb with it corroborated; "
"frozen {2} lb matches {3}."
).format(val, alt_val, frozen_val,
'/'.join(matches) if matches else 'neither'),
}
status = _verdict(val, frozen_val)
return status, {'current': val, 'basis': basis, 'why': why, 'frozen': frozen_val}
def _match_current_stage_spec(know, stage):
specs = knowledge.get(know, 'handling.stages', []) or []
pci = stage.get('pci_case')
name = stage.get('name')
for spec in specs:
if pci and spec.get('pci_case') == pci:
return spec
for spec in specs:
if name and spec.get('name') == name:
return spec
return None
def check_stage_tension_limit(know, stage, job_default_limit):
frozen = stage.get('tension_limit_sqrt_fc')
source = stage.get('tension_limit_source')
if source in (None, 'job'):
current = job_default_limit
why = 'handling.tension_limit_sqrt_fc, current job layer'
elif source == 'stage':
spec = _match_current_stage_spec(know, stage)
if spec is None:
return UNJUDGEABLE, {
'frozen': frozen,
'reason': (
'no current handling.stages entry matches pci_case={0!r}'
' / name={1!r} to compare the pinned limit against'
.format(stage.get('pci_case'), stage.get('name'))),
}
spec_limit = spec.get('tension_limit_sqrt_fc')
current = spec_limit if spec_limit is not None else job_default_limit
why = ('handling.stages[pci_case={0!r}].tension_limit_sqrt_fc'
.format(stage.get('pci_case')) if spec_limit is not None else
'handling.tension_limit_sqrt_fc, current job layer (matched '
'stage spec states none of its own)')
else:
return UNJUDGEABLE, {
'frozen': frozen,
'reason': 'unrecognised tension_limit_source {0!r} on this deck'
.format(source),
}
return _verdict(current, frozen), {'current': current, 'frozen': frozen,
'why': why}
# ------------------------------------------------------------- per deck
def check_deck(path, know):
"""The findings for one job file. Never raises on a well-formed deck."""
with io.open(path, 'r', encoding='utf-8') as fh:
deck = json.load(fh)
panel = deck.get('panel') or {}
mark = panel.get('mark') or os.path.splitext(os.path.basename(path))[0]
tension_bar_families = knowledge.get(know, 'lifters.tension_bar_families',
[]) or []
findings = []
def add(field, status, detail, lifter_mark=None, family=None):
findings.append({
'deck': path, 'mark': mark, 'lifter_mark': lifter_mark,
'family': family, 'field': field, 'status': status,
'detail': detail,
})
for lifter in panel.get('lifters') or []:
family = lifter.get('family')
lmark = lifter.get('mark')
if not family:
add('family', UNJUDGEABLE,
{'reason': 'this lifter carries no family name in the deck'},
lmark, family)
continue
if 'capacity_lb' in lifter:
status, detail = check_capacity(
know, family, 'tension', lifter['capacity_lb'],
tension_bar_families)
add('capacity_lb', status, detail, lmark, family)
if 'shear_capacity_lb' in lifter:
status, detail = check_capacity(
know, family, 'shear', lifter['shear_capacity_lb'],
tension_bar_families)
add('shear_capacity_lb', status, detail, lmark, family)
if 'basis_fc_psi' in lifter:
cur = knowledge.lifter_basis_fc(know, family)
add('basis_fc_psi', _verdict(cur, lifter['basis_fc_psi']),
{'current': cur, 'frozen': lifter['basis_fc_psi']},
lmark, family)
if 'min_edge_distance_in' in lifter:
cur = (knowledge.lifter_edge_distance(know, family)
or knowledge.get(know, 'lifters.min_edge_distance_in'))
add('min_edge_distance_in',
_verdict(cur, lifter['min_edge_distance_in']),
{'current': cur, 'frozen': lifter['min_edge_distance_in']},
lmark, family)
if 'tension_fos' in lifter:
cur, src, why = knowledge.lifter_safety_factor(
know, family, kind='tension')
add('tension_fos', _verdict(cur, lifter['tension_fos']),
{'current': cur, 'frozen': lifter['tension_fos'],
'source': src, 'why': why}, lmark, family)
if 'shear_fos' in lifter:
cur, src, why = knowledge.lifter_safety_factor(
know, family, kind='shear')
add('shear_fos', _verdict(cur, lifter['shear_fos']),
{'current': cur, 'frozen': lifter['shear_fos'],
'source': src, 'why': why}, lmark, family)
job_default_limit = knowledge.get(know, 'handling.tension_limit_sqrt_fc',
5.0)
for i, stage in enumerate(deck.get('stages') or []):
if 'tension_limit_sqrt_fc' not in stage:
continue
status, detail = check_stage_tension_limit(know, stage,
job_default_limit)
field = 'stages[{0}:{1}].tension_limit_sqrt_fc'.format(
i, stage.get('name') or stage.get('pci_case') or '?')
add(field, status, detail)
return findings
def deck_status(findings):
if not findings:
return CURRENT
return max((f['status'] for f in findings), key=lambda s: _SEVERITY[s])
# ----------------------------------------------------------------- CLI
def _fmt_num(v):
if v is None:
return 'null'
if isinstance(v, float) and v == int(v):
return str(int(v))
return str(v)
def _group_key(f):
"""Findings that would print an identical line collapse into one.
A deck with several lifters of the same gated family produces the same
CONDITIONAL (or STALE) numbers once per lifter; grouping is what keeps
"one line per stale deck" true instead of one line per lifter on it.
Two lifters of the same family that land on genuinely DIFFERENT numbers
-- a plant override that only matched one of them, say -- still get
separate lines, because the key includes the numbers themselves.
"""
d = f['detail']
if f['status'] == CONDITIONAL:
tail = (d.get('frozen'), d.get('without_bar'), d.get('with_bar'))
elif f['status'] == STALE:
tail = (d.get('frozen'), d.get('current'))
else:
tail = (d.get('reason') or d.get('why'),)
return (f['field'], f['status']) + tail
def _deck_lines(mark, findings):
"""One line per DISTINCT (field, status, values) on this deck.
Not one line per lifter -- see `_group_key`. `current` findings never
print here; pass `--all` for the raw per-finding dump instead.
"""
non_current = [f for f in findings if f['status'] != CURRENT]
if not non_current:
return []
groups = {}
for f in non_current:
groups.setdefault(_group_key(f), []).append(f)
lines = []
for key in sorted(groups, key=lambda k: (-_SEVERITY[k[1]], k)):
fs = groups[key]
f0 = fs[0]
d = f0['detail']
families = sorted(set(x['family'] for x in fs if x['family']))
marks = sorted(set(x['lifter_mark'] for x in fs if x['lifter_mark']))
note = ''
if marks:
note = ' ({0} lifter{1}{2})'.format(
len(marks), '' if len(marks) == 1 else 's',
' ' + '/'.join(families) if families else '')
status = f0['status']
if status == CONDITIONAL:
line = (' CONDITIONAL {0}{1} {2}: frozen={3} without_bar={4} '
'with_bar={5} -- {6}'
.format(mark, note, f0['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('without_bar')),
_fmt_num(d.get('with_bar')), d.get('reason')))
elif status == STALE:
why = d.get('why') or d.get('reason') or ''
line = (' STALE {0}{1} {2}: frozen={3} -> current={4}{5}'
.format(mark, note, f0['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('current')),
(' (' + why + ')') if why else ''))
else:
line = (' UNJUDGEABLE {0}{1} {2}: {3}'
.format(mark, note, f0['field'],
d.get('reason') or d.get('why') or
'current knowledge could not resolve this'))
lines.append(line)
return lines
def _finding_line(f):
d = f['detail']
who = f['mark']
if f.get('lifter_mark'):
who = '{0} lifter {1} ({2})'.format(f['mark'], f['lifter_mark'],
f['family'])
if f['status'] == CONDITIONAL:
return (' CONDITIONAL {0} {1}: frozen={2} without_bar={3} '
'with_bar={4} -- {5}'
.format(who, f['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('without_bar')),
_fmt_num(d.get('with_bar')), d.get('reason')))
if f['status'] == STALE:
why = d.get('why') or d.get('reason') or ''
return (' STALE {0} {1}: frozen={2} -> current={3}{4}'
.format(who, f['field'], _fmt_num(d.get('frozen')),
_fmt_num(d.get('current')),
(' (' + why + ')') if why else ''))
if f['status'] == UNJUDGEABLE:
return (' UNJUDGEABLE {0} {1}: {2}'
.format(who, f['field'], d.get('reason') or d.get('why') or
'current knowledge could not resolve this'))
return ' current {0} {1}'.format(who, f['field'])
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('jobs_dir', help="a sweep's jobs/ folder of *.job.json")
ap.add_argument('--job-root', help='the folder holding this job\'s own '
'knowledge.yaml (default: guessed '
'from jobs_dir, see _guess_job_root)')
ap.add_argument('--json', dest='json_out', help='write machine-readable '
'output here too')
ap.add_argument('--all', action='store_true',
help='print every finding, not just non-current ones')
args = ap.parse_args(argv)
jobs_dir = args.jobs_dir
if not os.path.isdir(jobs_dir):
sys.stderr.write('deckcheck: not a directory: {0}\n'.format(jobs_dir))
return 2
job_root = args.job_root or _guess_job_root(jobs_dir)
try:
know, layers = build_knowledge(job_root)
except Exception as exc: # pragma: no cover
sys.stderr.write('deckcheck: could not build current knowledge: '
'{0}: {1}\n'.format(type(exc).__name__, exc))
return 2
names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json'))
if not names:
sys.stderr.write('deckcheck: no *.job.json under {0}\n'.format(jobs_dir))
return 2
deck_paths = [os.path.join(jobs_dir, n) for n in names]
newest_deck_mtime = max(os.path.getmtime(p) for p in deck_paths)
newest_knowledge_mtime = layers.newest_mtime()
print('=' * 78)
print('DECKCHECK -- knowledge staleness only, NOT model staleness.')
print('This verifies each deck against KNOWLEDGE, never against the live')
print('MODEL: a deck can pass every check here and still describe a')
print('panel whose geometry has since changed in Revit.')
print('=' * 78)
if newest_knowledge_mtime is not None:
age_hours = (newest_knowledge_mtime - newest_deck_mtime) / 3600.0
if age_hours > 0:
print('these decks predate the current knowledge by {0:.1f} hour(s)'
.format(age_hours))
elif -age_hours > 0:
print('current knowledge is {0:.1f} hour(s) OLDER than the newest '
'deck -- decks were exported after the last knowledge edit'
.format(-age_hours))
else:
print('newest deck and newest knowledge layer share a timestamp')
else:
print('no knowledge layer file found to compare a timestamp against')
print('')
print('job root: {0}'.format(job_root))
for label, path, status in layers.rows():
print(' {0:<10} {1} [{2}]'.format(label, path or '(none)', status))
print('')
all_findings = []
deck_statuses = []
stale_lines = []
for path in deck_paths:
try:
findings = check_deck(path, know)
except Exception as exc:
findings = [{
'deck': path, 'mark': os.path.basename(path),
'lifter_mark': None, 'family': None, 'field': '(deck read)',
'status': UNJUDGEABLE,
'detail': {'reason': '{0}: {1}'.format(type(exc).__name__, exc)},
}]
all_findings.extend(findings)
status = deck_status(findings)
deck_statuses.append(status)
mark = findings[0]['mark'] if findings else os.path.basename(path)
if args.all:
stale_lines.extend(_finding_line(f) for f in findings)
elif status != CURRENT:
stale_lines.extend(_deck_lines(mark, findings))
if stale_lines:
print('-' * 78)
for line in stale_lines:
print(line)
print('-' * 78)
n_total = len(deck_statuses)
n_current = sum(1 for s in deck_statuses if s == CURRENT)
n_stale = sum(1 for s in deck_statuses if s == STALE)
n_conditional = sum(1 for s in deck_statuses if s == CONDITIONAL)
n_unjudgeable = sum(1 for s in deck_statuses if s == UNJUDGEABLE)
unjudgeable_reasons = sorted(set(
f['detail'].get('reason') or f['detail'].get('why') or '(no reason given)'
for f in all_findings if f['status'] == UNJUDGEABLE
))
print('')
print('{0} deck(s) checked, {1} current, {2} stale, {3} conditional, '
'{4} unjudgeable.'
.format(n_total, n_current, n_stale, n_conditional, n_unjudgeable))
if n_unjudgeable:
print('unjudgeable because:')
for r in unjudgeable_reasons:
print(' - {0}'.format(r))
if args.json_out:
payload = {
'job_root': job_root,
'layers': [{'layer': label, 'path': path, 'status': status}
for label, path, status in layers.rows()],
'newest_deck_mtime': newest_deck_mtime,
'newest_knowledge_mtime': newest_knowledge_mtime,
'decks_checked': n_total, 'current': n_current, 'stale': n_stale,
'conditional': n_conditional, 'unjudgeable': n_unjudgeable,
'findings': all_findings,
}
with io.open(args.json_out, 'w', encoding='utf-8') as fh:
json.dump(payload, fh, indent=1, default=str)
print('\njson written to {0}'.format(args.json_out))
return 0 if n_current == n_total else 1
if __name__ == '__main__':
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
@echo off
rem Old launcher for the Revit ribbon button that is now WillTools tab > Lab
rem panel > Claude (it was the AI tab's GOD_MOD when this was written).
rem Nothing references this file any more; it is kept for the owner to bin.
title DeepSeek Harness - Revit
cd /d "%~dp0.."
echo Revit dev folder: %CD%
echo.
rem `dsh web` boots the DeepSeek Harness browser UI and opens it in the
rem default browser. It serves from THIS folder, so the agent starts where
rem the project instructions and tools/rvt.py are.
call dsh web %*
echo.
echo [dsh exited - this window stays open so you can read the output]
pause >nul
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""dsh_pool - fan N tasks out to dsh_worker concurrently, bounded by a worker
cap, and hand back one result per task, in input order, each independently
marked ok-or-refused.
python tools/dsh_pool.py --tasks tasks.json --out results.json
python tools/dsh_pool.py --tasks tasks.json --max-workers 8 --task-timeout 120
`tasks.json` is a JSON array. Each element is either a plain string (the
task text) or an object with a `"task"` key (any other keys on that object
-- an id, a label -- are echoed back unchanged alongside that task's
result, so a caller can match results back to whatever it cared about
without relying on array position). Example:
["summarize file_a.py", "summarize file_b.py"]
[{"id": "a", "task": "summarize file_a.py"},
{"id": "b", "task": "summarize file_b.py"}]
Output is a JSON array, same length and same order as the input, one
object per task:
{"ok": true, "reply": "<the agent's final message>"}
{"ok": false, "refused": true, "reason": "<what went wrong>"}
plus whatever extra keys that task's input object carried. Printed to
stdout by default, or written to `--out <path>`.
WHY A POOL, NOT JUST N CALLS TO dsh_worker.py
-----------------------------------------------
`tools/dsh_worker.py`'s own docstring says a cheap backend's value is in
wide, cheap fan-out -- "N similar small tasks where a wrong one in isolation
is not costly" (docs/dsh-worker.md's routing table). Running dsh_worker.py N
times in a row gets none of that: it is still one task at a time, at
dsh_worker's own per-call timeout, serialized. This file is what actually
makes it a *pool*: a bounded ThreadPoolExecutor runs up to `max_workers`
dsh CLI processes at once (dsh_worker.run_task shells out to a real
subprocess per task, so there is no GIL contention worth worrying about --
the threads are waiting on I/O, not competing for the interpreter), and
every task gets its own DshRefusal-or-reply outcome independently of every
other task's.
THE RULE THIS FILE MUST NOT BREAK: one task failing must never take the
batch down, and a refused task must be distinguishable from a task that
succeeded with a short answer. Both are enforced the same way dsh_worker.py
enforces "could not judge" vs "judged, found nothing" (CLAUDE.md SS3/SS5):
every task's outcome is captured into its own result dict inside the worker
function passed to the executor, so an exception in task 3 becomes task 3's
`{"ok": false, "refused": true, ...}" entry, not an exception that aborts
tasks 4..N or corrupts the ordering promise.
Standard library only (concurrent.futures). CPython 3.8+, host-only --
same exemption from the IronPython 2.7 rules as tools/dsh_worker.py, which
this module imports as a sibling in tools/.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
import dsh_worker # noqa: E402 (sibling module, path set up above)
DEFAULT_MAX_WORKERS = int(os.environ.get('DSH_POOL_MAX_WORKERS', '4'))
DEFAULT_TASK_TIMEOUT_S = int(os.environ.get(
'DSH_POOL_TASK_TIMEOUT_S', str(dsh_worker.DEFAULT_TIMEOUT_S)))
def _run_one(task_text, profile, timeout_s, run_task):
"""Run one task and turn any DshRefusal (or any other exception -- a
task must never be allowed to kill the batch just because it raised
something dsh_worker did not anticipate) into a result dict shaped
exactly like dsh_worker's own CLI output.
"""
try:
reply = run_task(task_text, profile=profile, timeout_s=timeout_s)
return {'ok': True, 'reply': reply}
except dsh_worker.DshRefusal as exc:
return {'ok': False, 'refused': True, 'reason': str(exc)}
except Exception as exc: # noqa: BLE001 - deliberately broad: see
# module docstring, "must never take the
# batch down"
return {'ok': False, 'refused': True,
'reason': 'unexpected error running task ({0}): {1}'
.format(type(exc).__name__, exc)}
def run_pool(tasks, profile=None, max_workers=DEFAULT_MAX_WORKERS,
timeout_s=DEFAULT_TASK_TIMEOUT_S, run_task=None):
"""Run `tasks` (a list of task strings) concurrently, bounded by
`max_workers` at once. Returns a list the same length as `tasks`, in
the same order as `tasks`, each entry either
`{"ok": True, "reply": ...}` or
`{"ok": False, "refused": True, "reason": ...}`.
`run_task` is injected the same way dsh_worker.run_task injects `which`
/ `runner` -- so a test can fake the whole dsh round trip per task
without a real dsh install. Left as `None` it is resolved to
`dsh_worker.run_task` at call time (not bound as a default at import
time), so a caller/test that monkeypatches `dsh_worker.run_task` still
reaches its fake through `tools/dsh_pool.py main()`, which never passes
`run_task` explicitly.
"""
if run_task is None:
run_task = dsh_worker.run_task
profile = profile or dsh_worker.DEFAULT_PROFILE
max_workers = max(1, int(max_workers))
results = [None] * len(tasks)
if not tasks:
return results
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_to_index = {
pool.submit(_run_one, task_text, profile, timeout_s, run_task): i
for i, task_text in enumerate(tasks)
}
for future in as_completed(future_to_index):
i = future_to_index[future]
# _run_one already catches everything it can reach; .result()
# cannot raise here except for a framework-level cancellation,
# which this code never issues.
results[i] = future.result()
return results
def _load_tasks(path):
"""Read the tasks file. Returns (task_texts, extras) where `extras[i]`
is the dict of any non-"task" keys the i'th input object carried (empty
for a plain-string task), to be merged back into that task's result.
"""
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if not isinstance(data, list):
raise SystemExit('tasks file must contain a JSON array, got {0}'
.format(type(data).__name__))
task_texts = []
extras = []
for i, item in enumerate(data):
if isinstance(item, str):
task_texts.append(item)
extras.append({})
elif isinstance(item, dict) and 'task' in item:
task_texts.append(item['task'])
extras.append({k: v for k, v in item.items() if k != 'task'})
else:
raise SystemExit(
'tasks[{0}] must be a string or an object with a "task" '
'key, got: {1!r}'.format(i, item))
return task_texts, extras
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--tasks', required=True,
help='JSON file: array of task strings, or array of '
'{"task": ...} objects')
ap.add_argument('--out',
help='write the JSON results array here (default: '
'stdout)')
ap.add_argument('--profile', default=dsh_worker.DEFAULT_PROFILE,
help='dsh profile every task boots (default: {0}; also '
'settable with $DSH_WORKER_PROFILE)'
.format(dsh_worker.DEFAULT_PROFILE))
ap.add_argument('--max-workers', type=int, default=DEFAULT_MAX_WORKERS,
help='how many dsh calls run at once (default: {0}; '
'also settable with $DSH_POOL_MAX_WORKERS)'
.format(DEFAULT_MAX_WORKERS))
ap.add_argument('--task-timeout', type=int,
default=DEFAULT_TASK_TIMEOUT_S,
help='seconds to wait before refusing any one task '
'(default: {0}; also settable with '
'$DSH_POOL_TASK_TIMEOUT_S)'
.format(DEFAULT_TASK_TIMEOUT_S))
args = ap.parse_args(argv)
task_texts, extras = _load_tasks(args.tasks)
results = run_pool(task_texts, profile=args.profile,
max_workers=args.max_workers,
timeout_s=args.task_timeout)
merged = []
for extra, result in zip(extras, results):
row = dict(extra)
row.update(result)
merged.append(row)
payload = json.dumps(merged, ensure_ascii=False, indent=2)
if args.out:
with open(args.out, 'w', encoding='utf-8') as fh:
fh.write(payload)
fh.write('\n')
else:
print(payload)
# The batch itself ran to completion -- that is what this exit code
# reports. Whether any individual task was refused is in the JSON,
# per-task, by design (see module docstring): one refusal must never
# look like the whole run failing, or a caller scripting around this
# would stop trusting the N-1 tasks that actually succeeded.
return 0
if __name__ == '__main__':
raise SystemExit(main())
+319
View File
@@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
"""Feedback watcher for a dsh (DeepSeek Harness) background sub-session.
The contract: run me in the background; my EXIT is the wake signal.
------------------------------------------------------------------
A coordinator spawns a dsh session to do a piece of work, then launches THIS
script as a background job and goes back to its own business. dsh does not push
a "your sub is done / your sub is stuck" event to the coordinator, so this
watcher stands in for one: it polls the session and exits the instant the
session finishes, stalls (typically sitting on a permission/approval prompt in
the dsh web UI), or errors. The coordinator's harness delivers the watcher's
stdout when it exits, and that exit is what wakes the coordinator back up.
It prints exactly ONE line and exits with a code that says which of the five
things happened, so the coordinator can branch on the code alone:
DONE exit 0 turn/end reached, activity counter frozen
CUT_OFF exit 5 turn/end reached but the reply was truncated at the
model's output cap (finish reason "max-tokens")
ERROR exit 2 RPC error / result.ok false / session not found
STALLED exit 3 activity counter frozen past --stall, turn not ended
TIMEOUT exit 4 overall --max-minutes cap hit, session still active
How liveness is judged
----------------------
dsh serves a local JSON-RPC at POST <base>/api/<method>. We call
`session.history` and read four small things out of the (possibly huge)
response:
* `value.projections.asOfSeq` -- a monotonically increasing ACTIVITY counter.
It climbs while the session does work and FREEZES while the session sits
idle or blocked on a prompt. This is the liveness signal.
* `value.events[-1]["event"]["type"]` -- the tail event type. A finished/idle
turn ends with `turn/end`.
* the last `assistant/chunk` event whose `data.chunk.type == "finish"` --
its `data.chunk.reason.kind` says HOW the model's turn ended. dsh emits a
`finish` chunk on a normal stop (`reason.kind == "stop"`) AND when the
reply is truncated by the output cap (`reason.kind == "max-tokens"`), so
this is what tells a genuinely finished turn from a cut-off one.
* `value.projections.values.permissions.currentValue` -- the current sandbox
level, surfaced in the STALLED line so the coordinator can see whether the
sub is blocked wanting a higher level.
The events array can carry tens of thousands of entries; we never print it.
The tail event is enough for liveness; distinguishing DONE from CUT_OFF means
scanning back through the events for the last `finish` chunk, which is a cheap
walk over the same array we already hold. `session.history` + `asOfSeq` is a
correct, sufficient baseline. A lighter RPC (e.g. a per-session running/idle
flag from `session.list`/`session.get`) could be swapped into `_poll` if one is
confirmed available; nothing else would have to change.
CPython 3, stdlib only. Lives beside rvt.py / qna_watch.py, not under the
IronPython extension.
"""
import argparse
import collections
import json
import os
import sys
import time
import uuid
try: # py3
import urllib.request as _urlreq
import urllib.error as _urlerr
except ImportError: # pragma: no cover - py2 never runs this
import urllib2 as _urlreq
_urlerr = _urlreq
DEFAULT_BASE_URL = 'http://127.0.0.1:3080'
DASH = u'—' # em dash used in every output line
# One decision per cycle. kind is for programs/tests, message is the one line
# printed to stdout, code is the process exit status.
Verdict = collections.namedtuple('Verdict', 'kind code message')
def _base_url(base_url=None):
if base_url:
return base_url
return os.environ.get('DSH_BASE_URL', DEFAULT_BASE_URL)
def _post_json(url, body, timeout):
"""The one and only network call, isolated so a test can monkeypatch it.
Returns the decoded JSON response (a dict). Raises on any transport or
decode failure -- the caller (`_poll`) turns that into an ok:False dict.
"""
data = json.dumps(body).encode('utf-8')
req = _urlreq.Request(url, data=data,
headers={'Content-Type': 'application/json'})
resp = _urlreq.urlopen(req, timeout=timeout)
try:
raw = resp.read()
finally:
try:
resp.close()
except Exception:
pass
return json.loads(raw.decode('utf-8'))
def _poll(sid, base_url=None, timeout=10.0):
"""One liveness sample. Returns a compact dict the loop can classify:
{"ok": True, "asOfSeq": <int>, "tail": <str|None>, "perm": <str|None>,
"finish": <str|None>}
{"ok": False, "reason": <str>}
`finish` is the `reason.kind` of the LAST assistant/finish chunk in the
events ("stop" on a normal end, "max-tokens" when the reply was cut off by
the output cap, None when no finish chunk is present or it cannot be read).
Never raises: a transport hiccup, a malformed body, result.ok false, or a
missing session all come back as ok:False with a reason. The loop retries a
couple of times before it believes an ok:False, so a single hiccup is not a
dead session.
"""
url = _base_url(base_url).rstrip('/') + '/api/session.history'
body = {'type': 'client-request',
'rpcId': str(uuid.uuid4()),
'method': 'session.history',
'payload': {'sessionId': sid}}
try:
resp = _post_json(url, body, timeout)
except Exception as exc: # transport / decode failure
return {'ok': False, 'reason': 'poll failed: {0}'.format(exc)}
if not isinstance(resp, dict):
return {'ok': False, 'reason': 'malformed response (not an object)'}
result = resp.get('result')
if not isinstance(result, dict) or not result.get('ok'):
reason = None
if isinstance(result, dict):
reason = result.get('error') or result.get('reason')
return {'ok': False,
'reason': reason or 'rpc result not ok (session not found?)'}
value = result.get('value') or {}
proj = value.get('projections') or {}
seq = proj.get('asOfSeq')
if seq is None:
return {'ok': False, 'reason': 'no asOfSeq in projections'}
perms = (proj.get('values') or {}).get('permissions') or {}
perm = perms.get('currentValue')
tail = None
finish = None
events = value.get('events') or []
if events:
last = events[-1]
ev = last.get('event') if isinstance(last, dict) else None
if isinstance(ev, dict):
tail = ev.get('type')
# The LAST assistant/finish chunk carries how the model's turn ended:
# its reason.kind is "stop" on a normal end and "max-tokens" when the
# reply was truncated by the output cap. Walk back so a finish chunk
# after unrelated events is still found; read defensively throughout
# (any dict in the chain may be missing a key).
for entry in reversed(events):
if not isinstance(entry, dict):
continue
ev = entry.get('event')
if not isinstance(ev, dict):
continue
if ev.get('type') != 'assistant/chunk':
continue
data = ev.get('data')
if not isinstance(data, dict):
continue
chunk = data.get('chunk')
if not isinstance(chunk, dict):
continue
if chunk.get('type') != 'finish':
continue
reason = chunk.get('reason')
if isinstance(reason, dict):
finish = reason.get('kind')
break
try:
seq = int(seq)
except (TypeError, ValueError):
return {'ok': False, 'reason': 'asOfSeq not an int: {0!r}'.format(seq)}
return {'ok': True, 'asOfSeq': seq, 'tail': tail, 'perm': perm,
'finish': finish}
def _poll_with_retry(sid, base_url, sleep, attempts=3, retry_delay=1.0):
"""Call `_poll` up to `attempts` times; a transient failure is not a dead
session. Looks `_poll` up on the module at call time so tests can swap it.
"""
reason = 'poll failed'
for i in range(attempts):
try:
res = _poll(sid, base_url)
except Exception as exc: # a monkeypatched/broken poll
res = {'ok': False, 'reason': 'poll raised: {0}'.format(exc)}
if res.get('ok'):
return res
reason = res.get('reason') or reason
if i < attempts - 1:
sleep(retry_delay)
return {'ok': False, 'reason': reason}
def watch(sid, interval=15.0, stall=60.0, max_minutes=120.0, base_url=None,
sleep=None, now=None):
"""Poll `sid` until it finishes, stalls, errors, or the cap is hit.
Returns a Verdict(kind, code, message). Pure of I/O: `main` prints and
exits with it. `sleep` and `now` are injectable so tests run instantly with
a fake clock; production uses time.sleep / time.monotonic.
"""
sleep = sleep or time.sleep
now = now or time.monotonic
prev_seq = None
start = now()
last_change = start
while True:
res = _poll_with_retry(sid, base_url, sleep)
if not res.get('ok'):
return Verdict('ERROR', 2,
u'ERROR {0} {1} {2}'.format(sid, DASH, res.get('reason')))
seq = res['asOfSeq']
tail = res.get('tail')
perm = res.get('perm')
finish = res.get('finish')
t = now()
advanced = (prev_seq is None) or (seq != prev_seq)
if advanced:
last_change = t
prev_seq = seq
frozen = t - last_change
elapsed = t - start
# DONE / CUT_OFF: the turn has ended and the counter is no longer
# advancing. We require a non-advancing cycle so we never call a turn
# done off the very poll that emitted turn/end (that poll itself
# advances the seq). The last finish chunk's reason.kind splits the two
# cases: "max-tokens" means dsh ended the turn because the reply hit
# the model's output cap -- a CUT_OFF the coordinator should resume
# with "continue", not treat as a finished job.
if tail == 'turn/end' and not advanced:
if finish == 'max-tokens':
return Verdict(
'CUT_OFF', 5,
u'CUT_OFF {0} {1} reply cut off at max-tokens (turn/end '
u'reached); send "continue" to resume'.format(sid, DASH))
return Verdict('DONE', 0,
u'DONE {0} steps={1} {2} turn ended'.format(sid, seq, DASH))
# STALLED: counter frozen past the threshold and the turn is NOT over --
# the classic "waiting on a permission/approval prompt" shape.
if frozen >= stall and tail != 'turn/end':
return Verdict('STALLED', 3,
u'STALLED {0} {1} asOfSeq frozen {2}s at seq={3}, '
u'tail={4}, perm={5}; check the dsh web UI for a '
u'pending approval/prompt'.format(
sid, DASH, int(round(frozen)), seq, tail, perm))
# TIMEOUT: overall cap; the session is still doing something.
if elapsed > max_minutes * 60.0:
return Verdict('TIMEOUT', 4,
u'TIMEOUT {0} {1} watched {2}m, still active'.format(
sid, DASH, int(round(elapsed / 60.0))))
sleep(interval)
def _emit(verdict):
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
try:
print(verdict.message)
except UnicodeEncodeError:
sys.stdout.write(verdict.message.encode('ascii', 'replace').decode('ascii'))
sys.stdout.write('\n')
return verdict.code
def main(argv=None):
ap = argparse.ArgumentParser(
description='Watch a dsh background sub-session and exit when it '
'finishes, stalls, or errors.')
ap.add_argument('session_id', help='the dsh sessionId to watch')
ap.add_argument('--interval', type=float, default=15.0,
help='seconds between polls (default 15)')
ap.add_argument('--stall', type=float, default=60.0,
help='seconds of frozen asOfSeq before STALLED (default 60)')
ap.add_argument('--max-minutes', type=float, default=120.0,
help='overall watch cap in minutes (default 120)')
ap.add_argument('--base-url', default=_base_url(),
help='dsh RPC base URL (default $DSH_BASE_URL or '
+ DEFAULT_BASE_URL + ')')
args = ap.parse_args(argv)
verdict = watch(args.session_id,
interval=args.interval,
stall=args.stall,
max_minutes=args.max_minutes,
base_url=args.base_url)
return _emit(verdict)
if __name__ == '__main__':
sys.exit(main())
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""dsh_worker - shell out one task to a dsh (DeepSeek Harness) headless agent
and hand the reply back, or an honest, named REFUSAL if it did not get one.
python tools/dsh_worker.py "task text on the command line"
python tools/dsh_worker.py --file task.md
echo "task text" | python tools/dsh_worker.py -
python tools/dsh_worker.py --plain "task text" # reply/reason only, no JSON
Prints one JSON object to stdout and sets the exit code to match:
{"ok": true, "reply": "<the agent's final message>"} -> exit 0
{"ok": false, "refused": true, "reason": "<what went wrong>"} -> exit 1
`--plain` is the only non-default output shape: it prints just the reply
text (on success) or just the reason text (on refusal), for a human running
this at a terminal. Every caller that is code, not a person, should use the
default JSON shape -- that is the "structured mode" a script can rely on to
tell a reply from a diagnostic without parsing prose. `--json` also exists,
as a no-op, for a caller that wants to say so explicitly.
WHY THIS EXISTS
----------------
The owner's instruction (2026-09-02): stand up dsh and let it take the
routine work a Claude subagent would otherwise do, because dsh's models are
cheaper. `dsh --profile headless <task>` is dsh's own one-shot mode --
"Answer one task, print the final assistant message, and exit" (its own
--help text) -- which is exactly the CLI-worker shape a coordinator can
shell out to. There is no dsh SDK or importable client on this machine;
`tools/aiextract_run.py` talks to dsh's session.* RPC directly because it
needs to keep a session open and poll it (see that file's module docstring
for the wire contract), but a one-shot worker has no reason to reimplement
that protocol when the CLI already does the same round trip in one call.
THE RULE THIS FILE IS BUILT AROUND (CLAUDE.md SS3 / SS5): "could not judge" and
"judged, found nothing" are different answers, and this file must never
collapse them. A dsh call that errors, times out, or returns a blank reply is
reported as a REFUSAL naming what went wrong -- never as an empty-but-"ok"
result, which is indistinguishable from "the task legitimately produced
nothing" and is exactly the silent-pass failure mode CLAUDE.md's skip-probe
table exists to prevent for Ticket QC checks. Same discipline, different
place it can bite.
THE PROFILE IS CONFIGURABLE, NOT HARDCODED. The owner's actual working setup
(confirmed live 2026-09-02) does not use dsh's own `deepseek-official` route
-- it routes through an "opencode go" API instead: dsh's `headless` profile
plus `~/.dsh/settings.yaml`'s `agent-default-model` override, which points
the `llm-pi-ai` plugin's `openocde-go` provider (yes, that's the literal id
in his config -- do not "fix" the letter order, it is not this file's
config to rename) at `deepseek-v4-flash`, with the API key held by dsh's own
credentials service (`~/.dsh/.credentials.yaml`, keyed `OPENOCDE_GO_API_KEY`
-- never an OS environment variable on this machine). None of that is
this file's business to know beyond "which profile to boot": the profile is
`DEFAULT_PROFILE`, overridable with `DSH_WORKER_PROFILE` or `--profile`, and
whichever route that profile's own settings.yaml points at is what runs.
Someone else's machine with a real DEEPSEEK_API_KEY and no settings.yaml
override gets dsh's stock `deepseek-official` route through the exact same
`headless` profile, with no code change here.
THE WINDOWS ARGV TRAP: dsh installs on Windows as a `dsh.CMD` shim that
launches `node <shim-dir>\\node_modules\\@deepseek-ai\\dsh\\lib\\bin.js`.
Handing that shim a task argument through Python's subprocess module (a
plain list, no shell=True) still means Windows routes it through cmd.exe's
own command-line parser to resolve the .CMD file, and cmd.exe treats a
newline inside an argument as a command separator -- **silently truncating
the task at the first newline, exit 0, no error**. Measured 2026-09-02: a
two-line task (`"line one\\nline two with quotes"`) reached dsh as `"line
one"` alone, and dsh answered a real, well-formed reply to that truncated
text -- which is the most dangerous shape of this bug, because nothing
about the reply looks wrong on its own. Any task longer than one line
(which is most real work) hits this. So on Windows, when `which()` resolves
`dsh` to a `.cmd`/`.bat` shim, `_resolve_launch_cmd` skips the shim and
invokes `node.exe` on the underlying `bin.js` directly -- the same argv
element, no cmd.exe in the middle, newlines and all. Off Windows, or when
the shim can't be resolved to its underlying script, the shim is invoked as
before.
Standard library only. CPython 3.8+ -- this runs on the host, not inside
Revit; see CLAUDE.md's "Write for both Pythons" -- this file is exempt from
the IronPython 2.7 rules because nothing under WillTools.extension/ imports
it.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
DEFAULT_PROFILE = os.environ.get('DSH_WORKER_PROFILE', 'headless')
DEFAULT_TIMEOUT_S = int(os.environ.get('DSH_WORKER_TIMEOUT_S', '300'))
DSH_EXE = 'dsh'
class DshRefusal(Exception):
"""The task did not get a usable reply. `reason` is what a caller prints."""
def _find_dsh(which=shutil.which):
path = which(DSH_EXE)
if not path:
raise DshRefusal(
'dsh is not on PATH -- install it with '
'"npm install -g @deepseek-ai/dsh" and see docs/dsh-worker.md')
return path
def _resolve_launch_cmd(dsh_path):
"""Return the argv prefix to actually invoke `dsh` with.
On Windows, `which()` resolves `dsh` to a `.CMD`/`.BAT` shim that
forwards its arguments through cmd.exe to `node <shim-dir>\\
node_modules\\@deepseek-ai\\dsh\\lib\\bin.js`. cmd.exe's own
command-line parsing truncates any argument at its first newline --
see the module docstring's "Windows argv trap" -- so a multi-line task
silently loses everything after line one. Skip the shim: call node
directly on the resolved bin.js, so the task text reaches dsh exactly
as Python handed it to subprocess, newlines included.
Anywhere this can't be resolved (not Windows, not a .cmd/.bat shim, or
the expected files aren't where the shim would put them) this returns
`[dsh_path]` unchanged -- the previous, always-correct-off-Windows
behaviour.
"""
if os.name != 'nt':
return [dsh_path]
if not dsh_path.lower().endswith(('.cmd', '.bat')):
return [dsh_path]
shim_dir = os.path.dirname(dsh_path)
bin_js = os.path.join(shim_dir, 'node_modules', '@deepseek-ai', 'dsh',
'lib', 'bin.js')
if not os.path.isfile(bin_js):
return [dsh_path]
node_exe = os.path.join(shim_dir, 'node.exe')
if not os.path.isfile(node_exe):
node_exe = shutil.which('node')
if not node_exe:
return [dsh_path]
return [node_exe, bin_js]
def run_task(task, profile=DEFAULT_PROFILE, timeout_s=DEFAULT_TIMEOUT_S,
which=shutil.which, runner=subprocess.run, clock=time.time):
"""Run one task through a dsh headless profile.
Returns the reply text. Raises DshRefusal, always with a `reason` that
names the layer that failed, on any of: dsh missing, a non-zero exit,
a timeout, or a zero exit with an empty/whitespace-only reply -- the
last of those being the one an unwary caller would mistake for success.
`which` and `runner` are injected the same way `tools/aiextract_run.py`
injects `transport` -- so a test can fake the subprocess boundary
without a real dsh install or a real API key.
"""
task = (task or '').strip()
if not task:
raise DshRefusal('empty task text -- nothing to send to dsh')
dsh_path = _find_dsh(which)
launch_cmd = _resolve_launch_cmd(dsh_path)
cmd = launch_cmd + ['--profile', profile, task]
started = clock()
try:
proc = runner(cmd, capture_output=True, text=True, timeout=timeout_s,
encoding='utf-8', errors='replace')
except subprocess.TimeoutExpired:
raise DshRefusal(
'dsh --profile {0} did not answer within {1}s'
.format(profile, timeout_s))
except OSError as exc:
raise DshRefusal('could not start dsh ({0}): {1}'
.format(dsh_path, exc))
elapsed = clock() - started
if proc.returncode != 0:
stderr = (proc.stderr or '').strip()
raise DshRefusal(
'dsh --profile {0} exited {1} after {2:.1f}s: {3}'
.format(profile, proc.returncode, elapsed,
stderr or '(no stderr)'))
reply = (proc.stdout or '').strip()
if not reply:
# THE TRAP: exit 0 with nothing on stdout is NOT success. Treat it
# exactly like the CLAUDE.md skip-probe table treats an empty check
# result with no probe registered -- as a defect to surface, never
# as a quiet "ran, found nothing" pass. See module docstring.
stderr = (proc.stderr or '').strip()
raise DshRefusal(
'dsh --profile {0} exited 0 but printed no reply after {1:.1f}s '
'(stderr: {2}) -- could not judge, not judged-empty'
.format(profile, elapsed, stderr or '(none)'))
return reply
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('task', nargs='?',
help='task text, or "-" to read it from stdin')
ap.add_argument('--file', help='read the task text from this file')
ap.add_argument('--profile', default=DEFAULT_PROFILE,
help='dsh profile to boot (default: {0}; also settable '
'with $DSH_WORKER_PROFILE)'.format(DEFAULT_PROFILE))
ap.add_argument('--timeout', type=int, default=DEFAULT_TIMEOUT_S,
help='seconds to wait before refusing (default: {0}; '
'also settable with $DSH_WORKER_TIMEOUT_S)'
.format(DEFAULT_TIMEOUT_S))
ap.add_argument('--json', action='store_true',
help='structured JSON output (default; this flag is a '
'no-op kept for callers that want to say so '
'explicitly)')
ap.add_argument('--plain', action='store_true',
help='print only the reply text (success) or reason '
'text (refusal), no JSON -- for a human at a '
'terminal, not for a caller that is code')
args = ap.parse_args(argv)
if args.file:
with open(args.file, 'r', encoding='utf-8') as fh:
task = fh.read()
elif args.task == '-' or (args.task is None and not sys.stdin.isatty()):
task = sys.stdin.read()
elif args.task is not None:
task = args.task
else:
reason = ('no task given -- pass it as an argument, --file <path>, '
'or on stdin')
if args.plain:
print(reason)
else:
print(json.dumps({'ok': False, 'refused': True,
'reason': reason}))
return 1
try:
reply = run_task(task, profile=args.profile, timeout_s=args.timeout)
except DshRefusal as exc:
if args.plain:
print(str(exc))
else:
print(json.dumps({'ok': False, 'refused': True,
'reason': str(exc)}))
return 1
if args.plain:
print(reply)
else:
print(json.dumps({'ok': True, 'reply': reply}))
return 0
if __name__ == '__main__':
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+329
View File
@@ -0,0 +1,329 @@
# Knowledge keys deliberately exempted from tools/knowledge_drift.py.
#
# FORMAT -- one per line:
#
# <category> <dotted.path> -- why this one is fine
#
# categories: unread undeclared defaults surface-only test-only
#
# THE REASON IS MANDATORY. A line with no ' -- why' is refused and the run
# exits 2, because an unexplained suppression is how a checker quietly stops
# checking. The point of an exception is that the next person can read why it
# was made and decide whether it still holds; a bare path cannot be argued
# with, only inherited.
#
# WHY A FILE AND NOT A YAML COMMENT. Two of the five categories are about keys
# that exist only in Python (a hidden default) or only in a gitignored plant
# or job file that a colleague's clone does not have. A comment convention
# could not express those, so half the exceptions would have nowhere to live
# and would end up as a second, undocumented mechanism. One file, in the repo,
# beside the tool that reads it, greppable, and reviewed like code.
#
# HOW IT STAYS HONEST. Every run prints how many findings this file suppressed,
# so a shrinking report is never mistaken for a shrinking problem. Every run
# also lists entries here that no longer match anything, so a key that was
# fixed or deleted does not leave a permanent blindfold behind.
#
# DO NOT put a real drift here to make the report green. An exception says
# "this key is not meant to be read"; it does not say "we know and have not
# got to it yet". For the second, leave the finding and fix the code.
#
# Example of the shape (commented out -- this is not an active exemption):
#
# unread nomenclature.view_design_token_re -- derived from design_number_re
# when left None, so nothing reads the key itself by design.
# ---- active exemptions below this line ----
# ===========================================================================
# GROUP 1 -- the key IS read, by a call shape this detector cannot resolve.
# ===========================================================================
# Every line below was verified by hand on 2026-08-30 by opening the reader
# and reading it. None of them is a deferral: the code acts on the key today.
# Two shapes recur, and the tool's own blind-spot report already names both:
# a knowledge getter passed in as a FUNCTION ARGUMENT (so the call reads
# get(...) / getter(...), not K.get(...)), and a subtree fetched whole and
# then walked by dict access. Where the fix belongs in the detector rather
# than in the code the line says so; the day it lands, the run will call
# these lines stale and they should be deleted.
# takeoffcore.settings(know, get) loops its own module-owned DEFAULTS table
# and calls get(know, 'takeoff.' + key) once per entry. Both halves are
# invisible: `get` is an argument -- injected so the module needs no Revit and
# stays testable off a model -- and the path is concatenated. The takeoff acts
# on all seven; they are what classifies a family as bar / part / shape /
# not-ordered. See takeoffcore.DEFAULTS and takeoffcore.classify().
unread takeoff.bar_prefixes -- read by takeoffcore.settings() through an injected getter and a concatenated path; classifies bar families in takeoffcore.classify()
unread takeoff.part_prefixes -- read by takeoffcore.settings() through an injected getter and a concatenated path; classifies part families in takeoffcore.classify()
unread takeoff.shape_markers -- read by takeoffcore.settings() through an injected getter and a concatenated path; marks a bend-shape row in takeoffcore.classify()
unread takeoff.not_ordered -- read by takeoffcore.settings() through an injected getter and a concatenated path; drops families the plant does not order
unread takeoff.family_class -- read by takeoffcore.settings() through an injected getter and a concatenated path; a family named here beats every pattern
unread takeoff.nested_in -- read by takeoffcore.settings() through an injected getter and a concatenated path; rolls a nested family up into its parent
unread takeoff.order_attached_bars_separately -- read by takeoffcore.settings() through an injected getter and a concatenated path; decides whether an attached bar is ordered on its own line
# jobreconscan._roles() resolves nine roles in one loop over a literal tuple
# and calls K.param_names(know, role) with the loop variable. The tool sees
# the call and reports it as a blind spot -- 'non-literal key into
# param_names' -- but cannot bind the roles. Job Recon reads all four of
# these off the model on every run.
unread parameters.member.ticket_created -- resolved in jobreconscan._roles()'s role loop and read off every member by Job Recon; the loop variable hides it from the scan
unread parameters.member.ticket_detailed -- resolved in jobreconscan._roles()'s role loop and read off every member by Job Recon; the loop variable hides it from the scan
unread parameters.sheet.total_required -- resolved in jobreconscan._roles()'s role loop; Job Recon compares it against the pieces the model carries
unread parameters.sheet.sheet_count -- resolved in jobreconscan._roles()'s role loop; Job Recon compares it against the sheets actually placed
# ticketchecks._sheet_param_value(t, know, 'sheet.weight') -> ticketqc.rstr()
# -> K.param_names(). The literal role is right there, but behind two
# wrappers, and only _nomen / param_names / _s are unwrapped by the detector.
# It is deliberately NOT in knowledge.PARAMETER_ROLES: the printed-weight
# check stands down with its own sentence when the plant maps no name for it,
# so Self Check must not nag a plant that never prints a weight.
unread parameters.sheet.weight -- read by ticketchecks through _sheet_param_value() -> ticketqc.rstr() -> param_names; kept out of PARAMETER_ROLES on purpose, the weight check stands down and says so itself
# ticketpage.muted_checks(know, getter=None) reads it through an injected
# getter, the same shape as takeoffcore. Ticket QC calls it on every run and
# filters checklist rows with the result, so this is behaviour, not display.
surface-only checks.muted -- read by ticketpage.muted_checks() through an injected getter; Ticket QC mutes checklist rows on it every run
# liftexport fetches handling.dunnage WHOLE at behaviour tier and
# dunnage_supports() walks the dict by hand for mode and fraction -- together
# they decide where the transportation supports go. The detector only skips a
# surface-read path when a behaviour reader sits BELOW it; a covering
# behaviour read of an ANCESTOR is not considered, which is the only reason
# these two fire. Delete both lines when that is fixed in the tool.
surface-only handling.dunnage.mode -- liftexport reads the whole handling.dunnage subtree at behaviour tier and dunnage_supports() walks it; the tool does not credit a covering behaviour read of an ancestor
surface-only handling.dunnage.fraction -- liftexport reads the whole handling.dunnage subtree at behaviour tier and dunnage_supports() walks it; the tool does not credit a covering behaviour read of an ancestor
# liftexport._fast_box picks the key by run mode and passes `not batch` as the
# fallback: for handling.fast_lifter_box that expression IS True, and for
# handling.fast_lifter_box_batch it IS False -- exactly what configdump
# passes. The two call sites agree on the value and differ only in source
# text, which is all this category can compare.
defaults handling.fast_lifter_box -- liftexport._fast_box passes `not batch`, which for this key evaluates to True, the same fallback configdump prints; the two differ in source text only
defaults handling.fast_lifter_box_batch -- liftexport._fast_box passes `not batch`, which for this key evaluates to False, the same fallback configdump prints; the two differ in source text only
# The two call sites ask different questions and both answers are written
# down. designspec.design_key MUST always return a key, so it falls back to a
# built-in generic pattern. ticketchecks._view_design_token_re would have to
# strip the leading anchor off that pattern and hunt for it UNANCHORED inside
# a view name; guessing there is how a view called 'Elevation Top 2' becomes
# a design. It returns None instead and checklist 1.4 reads NOT RUN, which
# its docstring states outright. Not a disagreement about what unset means.
defaults nomenclature.design_number_re -- deliberate: designspec.design_key falls back to a built-in pattern because it must always return a key, while ticketchecks._view_design_token_re refuses to guess an unanchored view token and lets checklist 1.4 read NOT RUN
# ===========================================================================
# GROUP 2 -- not knowledge keys at all: a design spec read as a layer
# ===========================================================================
# knowledge_drift._job_files() walks the job folder recursively and takes
# every .yaml in it. A design spec -- designspec.spec_path writes them to
# <job>/specs/<DESIGN>.yaml, first line `schema: 1` -- is a scraped record of
# one design, not a knowledge layer, and merging one plants its whole shape
# as declared paths: schema, source, confidence, unresolved, and the measured
# values under them. Nothing reads them through knowledge.get because nothing
# should; designspec loads a spec by path.
#
# THE FIX IS IN THE TOOL and it is one line: _job_files() must not descend
# into specs/. These thirteen lines live only until it does, and the run will
# name them stale the moment it lands. While they are here they are also a
# small hazard worth knowing about: `source` and `schema` are generic enough
# that a future real knowledge key of either name would be suppressed by them.
unread schema -- design-spec field, not a knowledge key; _job_files() descends into <job>/specs/ and reads a spec document as a layer
unread source -- design-spec field (which model, sheet and view it was scraped from), not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread confidence -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread unresolved -- design-spec field (what the scrape could not settle), not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread design_number -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread piece_description -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread thickness_in -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread envelope -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread reinforcing -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread concrete.fc_psi -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer (the knowledge-layer concrete branch carries min_fci_ratio, which is read)
unread concrete.fci_psi -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread concrete.unit_weight_pcf -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
unread concrete.weight_class -- design-spec field, not a knowledge key; _job_files() reads <job>/specs/ as a layer
# ---- declared inert on purpose ----
# A key that is deliberately not wired, ruled on rather than overlooked. Not
# the same thing as muting a CHECKLIST ROW (that is `checks.muted`, a job
# fact, deep-merged, and set from the Project Config window): this changes no
# run on any job, is the same everywhere, and its audience is whoever reads
# this report next.
unread tolerances.psi -- MUTED, NOT DELETED. Owner's ruling on triage D1, 2026-08-30: neither wire nor delete. Nothing compares a psi against it (3.1 and 3.2 compare the ticket and the design summary exactly), so it decides nothing on any job today; it is kept declared and settable so that the day a tolerance is wanted the number and its name are already there. The engineer-facing half is configform's `inert` row, which shows it with this ruling instead of as a number box that decides nothing.
# ---- the master checklist moved into the public layer, 2026-08-31 --------
unread checklist -- the master checklist is read by ticketpage.load_master, which opens knowledge/catalogue.yaml DIRECTLY at import and parses it; it never goes through knowledge.get on the merged tree, so this detector cannot see the reader. It is the most-read data in the tool -- CHECKLIST_ITEMS is derived from it -- and two tests in test_ticketchecks_host.py pair every entry against the check functions.
# ---- connector knowledge, 2026-08-31 ------------------------------------
unread connectors.what_it_is -- the definition of a connector, in prose, for a person reading the catalogue. Deliberately not a predicate: what a connector IS belongs in the file that publishes the products, and what RECOGNISES one is connectors.marks, which is read.
unread connectors.insulation_note -- the model's own description of these pins as NON-COMPOSITE, quoted and dated. Recorded because it states a design assumption with engineering weight; deliberately read by nothing, and it must not become a check without the owner's word.
unread connectors.recognition_verified_on -- provenance for connectors.recognition_verified, which IS read. A date beside a flag is for the person deciding whether the flag still holds.
unread connectors.recognition_verified_by -- as above: what was measured, so the flag can be argued with rather than inherited.
# ===========================================================================
# GROUP 3 -- LANDED FROM THE PDF LIBRARY, READER PENDING. 2026-08-31.
# ===========================================================================
# Stage 3c of knowledge/library/RUNBOOK.md, executed against library row D1
# (the plant standards manual, 929 pages) whose 25-fact audit had been
# EXTRACTED AND NEVER LANDED. The owner's ruling that commissioned this pass
# is quoted in that runbook and in knowledge/library/LEDGER.md.
#
# EVERY LINE BELOW IS A FACT IN A KNOWLEDGE LAYER THAT NO CODE READS YET, and
# that is the intended state, not a deferral of a fix. Knowledge leads and
# readers follow: the alternative is a drift gate that PUNISHES LANDING, and a
# gate that punishes the right behaviour is a gate people route around --
# which is exactly how roughly 150 A-class records ended up in notes instead
# of in YAML. With these lines the drift list becomes the to-do list: every
# unread key is a reader somebody owes, named, dated, with its cite attached.
#
# ---- AND THIS FILE'S OWN HEADER SAYS THE OPPOSITE, IN AS MANY WORDS -------
# "An exception says 'this key is not meant to be read'; it does not say 'we
# know and have not got to it yet'. For the second, leave the finding and fix
# the code." THE COMMISSION AND THAT SENTENCE DISAGREE. The honest
# resolution is either a new category (`reader-pending`, dated) or an
# amendment to the header -- NOT a quiet re-use of `unread` that makes the
# header a lie. Per RUNBOOK.md stage 3c, until the owner rules: `unread` is
# used, every reason says "reader pending", and THE CONFLICT IS ON HIS LIST
# (QNA/open-questions-2026-08-30.md). The header was NOT edited here -- that
# is not this pass's call to make.
#
# Cites are PDF page / printed section, the convention the extraction notes
# use. Both halves always: the PDF page is how the next agent finds it, the
# printed section is how the owner does, and they do not match. Full
# argument, page cites and confidence marks for every one of these:
# QNA/knowledge-audit-plant-2026-08-30.md, and knowledge/library/LEDGER.md D1.
#
# Three of these are also PARKED CONTRADICTIONS -- a live value the manual
# disagrees with, landed BESIDE it rather than over it, awaiting a ruling.
# They are marked "RULING PENDING" and carry their open-questions number.
unread beds -- landed 2026-08-31 from the plant standards manual (library D1) p584-600 / 6110-6126, reader pending: the per-bed strand-hole grid, panel width and abutment capacity. 4.6 BED-WIDTH/BED-OFFSET reads the rails.max_bed_width_in scalar today and the manual contradicts it (open-questions A6.1); 4.6 STRAND-GRID does not exist yet
unread rails.narrow_panel -- landed 2026-08-31 from the plant standards manual p819 / 10315.1 and p804 / 10301.1 notes 27-29, reader pending: a narrow panel is HELD TO THE FIXED RAIL, so 4.6 BED-OFFSET's permitted band is an outer bound and not an expectation, and 4.4's asymmetric pattern is expected rather than a finding
unread strand.standard_patterns -- landed 2026-08-31 from the plant standards manual p174-178 / 3201-3205 and p873 / 12204, reader pending: the plant's three standard strand chains, so 4.4 STRAND-POS gets a second opinion beside the design summary
unread strand.edge_strand_in -- landed 2026-08-31 from the plant standards manual p876 / 12210, reader pending: EDGE STRAND @ 3 5/8 in, the first and last segment of every standard chain
unread strand.both_wythes_same_stations -- landed 2026-08-31 from the plant standards manual p174-178 / 3201-3205, reader pending: STRAND E.L. means both wythes share x-positions, so total strand is 2x the per-wythe count
unread strand.debond_by_blockout -- landed 2026-08-31 from the plant standards manual p817 / 10306.1, reader pending: 4.7 has the opening geometry and strand.debond_below_continuous_ft, but the L-vs-W ratio rows have no counterpart in the tool at all
unread strand.deflection -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 note 20C and p817 / 10306.1, reader pending: deflection is the alternative to debonding and is ranked LAST, max 2 in, min cover 1 1/2 in
unread strand.detensioning -- landed 2026-08-31 from the plant standards manual p628 / 6320, reader pending: nothing in the tool reads a cutting sequence. Landed because knowledge leads readers, audit 3.25
unread reinforce.panel_standard -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: ReinforceSuggest works only from donor pieces today, and this is its first non-donor source. The middle band is PER DESIGN and must never be proposed
unread reinforce.design_sheet_selector -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: which of the nine standard reinforcing sheets applies, from panel geometry alone
unread reinforce.weight_plf -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: a per-linear-foot sanity band for Takeoff and for bar-schedule plausibility
unread reinforce.master_ticket -- landed 2026-08-31 from the plant standards manual p716 / 8430.1, reader pending: it defines what reinforce.master_token marks. The durations and the submission list are process with no check behind them
unread lifters.stripping_rows_by_height_ft -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: panellift.layout decides where inserts go and does not know the plant's own row count by panel height
unread lifters.stripping_rows_gap_at_ft -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: the sheet's own bands leave H = 39 ft exactly unassigned; a reader must pick a side and say which
unread lifters.stripping_rows_gap_because -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: the sentence a reader must print when it resolves the 39 ft boundary itself
unread lifters.default_layout -- landed 2026-08-31 from the plant standards manual p713 / 8320.2, reader pending: fifth points is a DEFAULT and the sheet says to verify the insert type with the engineer
unread lifters.insert_type_verified_with_engineer -- landed 2026-08-31 from the plant standards manual p713 / 8320.2, reader pending: the half of that sentence that stops the default being read as governing
unread lifters.common_elevation_panel_to_panel -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 note 14, reader pending: lifting inserts at a common elevation panel to panel
unread lifters.dimension_centre_to_centre_and_both_edges -- landed 2026-08-31 from the plant standards manual p812 / 10303.1, reader pending: a dimensioning rule for lift inserts, checkable once something reads printed insert dimensions
unread lifters.edge_lifter_centre_matches_cg -- landed 2026-08-31 from the plant standards manual p812 / 10303.1, reader pending: 6.2 LIFT-EDGE has the geometry but not this condition
unread lifters.side_lifters -- landed 2026-08-31 from the plant standards manual p813 / 10303.2 and p804 / 10301.1 note 10, reader pending: an ADD'L FINISH = YES title block with no side lifters is a new check needing only two title-block reads
unread lifters.top_pick_count -- landed 2026-08-31 from the plant standards manual p183 / 3280, reader pending: 4 top-pick inserts. The PART is deliberately not landed -- the sheet names M42/M43 and the model uses P76 (audit Q5)
unread part_numbers.prefix_class -- landed 2026-08-31 from the plant standards manual p406 / 5001, reader pending: the material-type prefix, which gives families.* a fallback that cannot go stale the way a list does
unread part_numbers.number_bands -- landed 2026-08-31 from the plant standards manual p406 / 5001 and p713 / 8320.2, reader pending: the number range tells a standard part from an erection item from a repair item with no list at all
unread part_numbers.finish_suffix -- landed 2026-08-31 from the plant standards manual p406 / 5001, reader pending: g/p/z/s/e finish suffixes on a material designation
unread part_numbers.job_specific_prefixes -- landed 2026-08-31 from the plant standards manual p406 / 5001, reader pending: the printed rule behind the SP-series move to the job layer on 2026-08-30, which this file acted on without holding the rule
unread part_numbers.rebar_mark -- landed 2026-08-31 from the plant standards manual p536 / 5302, reader pending: 5.6 BARMARK-UNIQUE could catch a mark whose first digit disagrees with the modelled bar size with no geometry at all
unread panel_types.make_ups -- landed 2026-08-31 from the plant standards manual p174-178 / 3201-3205 and p879 / 12221, reader pending: 2.1 SIZE-* reads panel_type.thickness and has no list of the five thicknesses this plant builds
unread girders -- landed 2026-08-31 from the plant standards manual p805 / 10301.2 note 3 and p879 / 12221, reader pending: girder depth, the insulation-plus-3in size rule and the opening-proximity rules. The COUNT is deliberately null -- three sources give three answers (audit Q3)
unread nomenclature.control_numbers -- landed 2026-08-31 from the plant standards manual p693-695 / 8102.3-8102.5, reader pending: JobRecon does the 163-vs-61 reconciliation and does not hold the arithmetic rule that makes it a defect
unread nomenclature.marks -- landed 2026-08-31 from the plant standards manual p693-695 / 8102.3-8102.5, reader pending: a gap in the mark sequence is EXPECTED, and the renumber trigger reframes 2.1 SIZE-* and 2.2 WGT-* as a piece that should have been renumbered
unread nomenclature.ticket_set -- landed 2026-08-31 from the plant standards manual p827 / 10340.2 and p807 / 10301.4, reader pending: 1.3 TICKET-SET gathers sheets today but does not know that sheets after the first carry no product data, nor that third-party sheets are legitimately appended
unread nomenclature.sheet_families_manual_stated -- landed 2026-08-31 from the plant standards manual p819 / 10315.1, p822 / 10330.1, p821 / 10320, p716 / 8430.1, p851 / 10950, reader pending AND RULING PENDING: nomenclature.detail_sheet_prefix is live and unchanged; this is the manual's fuller set, parked beside it as open-questions A6.2
unread drawings.rebar_placement_manual_stated -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 notes 25, 26, 40, 47 and p805 / 10301.2 note 15C, reader pending AND RULING PENDING: drawings.rebar_must_appear_on is a residual and unchanged; this is the manual's per-bar-class enumeration, parked beside it as open-questions A6.5
unread drawings.dimension_exceptions -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 notes 6, 33, 46, 48, p816 / 10305, p726 / 8520.1, reader pending: every entry is a place where a MISSING dimension is correct by standard, and a completeness check that does not know them produces a steady drip of false findings
unread drawings.bom_rebar_has_no_description -- landed 2026-08-31 from the plant standards manual p713 / 8320.2, reader pending: an empty BOM description on a rebar row is CORRECT, and without this 5.1 BOM-MEMBERS would fire on every rebar row of every ticket
unread drawings.not_printed_on_ticket -- landed 2026-08-31 from the plant standards manual p701 / 8203, reader pending: the manual's own statement of what is deliberately absent from a ticket -- the panel ID and the behind-panel sections
unread drawings.alternate_datum -- landed 2026-08-31 from the plant standards manual p811 / 10302.2 and p814 / 10304, reader pending: on an angled-top panel embeds are dimensioned to a WORK POINT, a legitimate alternative datum that will confuse a naive datum check
unread drawings.dimensions -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 note 5, p810 / 10302.1, p814 / 10304, reader pending: centre-of-embed is this plant's universal datum and is a precondition for any check comparing a printed dimension to a modelled position
unread drawings.orientation_on_sheet -- landed 2026-08-31 from the plant standards manual p804 / 10301.1 notes 2, 3, 12, 13 and p819 / 10315.1, reader pending: drawings.section_a and section_b were measured off crop boxes on three pieces; this is the convention that binding should agree with
unread drawings.attached_bar_rule -- landed 2026-08-31 from the plant standards manual p413-418 / 5100.1-5100.6 and p701 / 8203, reader pending: the rule behind drawings.annotation_covered_by_parent, which is a two-entry list today and goes stale in silence the way drawings.reinforcing_rule's predecessor did
unread drawings.cad_layers -- landed 2026-08-31 from the plant standards manual p701 / 8203, reader pending: the layer a thing is drawn on IS its classification, and REBAR-HEAVY / REBAR-MED / REBAR-LIGHT is a reinforcing size split
unread handling.weight_band_lb -- landed 2026-08-31 from the plant standards manual p800 / 10230.1, reader pending AND RULING PENDING: knowledge.DEFAULTS' weight_tolerance 0.02 is unchanged and 2.2 WGT-* still runs on it; this is the manual's -0/+100 lb band, parked beside it as open-questions A6.4
unread geometry -- landed 2026-08-31 from the plant standards manual p168-170 / 3040-3042, p814 / 10304, p712 / 8320.1, p278 / 4294.0, p784 / 10150, reader pending: the recessed-plate rule ties a geometric property to a required X-sheet callout, which is checkable against DETAIL-CALLOUT
unread finishes -- landed 2026-08-31 from the plant standards manual p165-166 / 3010, 3011, reader pending: a closed vocabulary with two checkable obligations -- a raked panel needs a direction on the ticket, and an end finish is checkable against the product type
unread concrete.wall_panel_default -- landed 2026-08-31 from the plant standards manual p183-191 / 3280-3288, reader pending: f'ci 3500 gives 3.1/3.2/3.3 a plausibility anchor. f'c is null on purpose -- printed 5000, hand-revised 6000 in red, and neither state is settled (audit Q2)
unread concrete.release_range_psi -- landed 2026-08-31 from the plant standards manual p627 / 6200, reader pending: the release-strength range this plant offers
unread concrete.release_standard_psi -- landed 2026-08-31 from the plant standards manual p627 / 6200, reader pending: 3500 psi is the standard release, the rest is specialty
unread concrete.day28_range_psi -- landed 2026-08-31 from the plant standards manual p627 / 6200, reader pending: 5000-7000 psi at 28 days available or more
unread concrete.mix_role -- landed 2026-08-31 from the plant standards manual p800 / 10230.1, reader pending: FACE = BOTTOM wythe and BASE = TOP, because the panel is cast face-down. A reader that takes FACE MIX for the exterior mix is right by accident; one that takes it for the top wythe is wrong
unread concrete.single_wythe_uses -- landed 2026-08-31 from the plant standards manual p800 / 10230.1, reader pending: a single-layer piece uses the BASE mix number, not the face one
unread concrete.named_mixes -- landed 2026-08-31 from the plant standards manual p164 / 3003, reader pending: the plant's named mixes quoted directly. The positional digit decoding an extraction inferred is filed C there and deliberately not landed
unread material_grades -- landed 2026-08-31 from the plant standards manual p863 / 11301.1 and p412 / 5015, reader pending: a plant CHOICE among published ASTM options, confidence B because it is read off a filled example form. HSS is null -- A513 struck to A500-C in red, neither state settled
unread openings -- landed 2026-08-31 from the plant standards manual p760 / 9301 and p887 / 12261, reader pending: a void below 100 sq in or 8 in is one the plant does not cast, which filters the population 6.4 DUN-OPENING, 4.7 and liftexport._openings all count
unread revisions -- landed 2026-08-31 from the plant standards manual p723 / 8511, reader pending: a ticket at revision 3 whose two predecessors are missing is NORMAL, because the sequence advances project-wide. The old-cloud layer name is null -- two spellings in one manual (audit Q10)
unread connections -- landed 2026-08-31 from the plant standards manual p216-220 / 4201-4205, p231 / 4214, p243 / 4232, p375-403 / 4900-4974, reader pending: base-connection count by seismic category and butt-joint count by panel height are countable findings for 5.1 BOM-MEMBERS. The alignment count is null -- its table is red-marked (audit Q2)
# FOUR FACTS THIS PASS LANDED HAVE NO LINE HERE, AND THE REASON IS A BLIND
# SPOT RATHER THAN A READER: notes.handling_views_on_sheet,
# notes.handling_view_order, face_designations.carried_by_hatch and
# face_designations.insulation_hatch all sit under a subtree that some caller
# fetches WHOLE. The detector credits a covering read of an ancestor, so it
# never reports them and an allowlist line for them is refused as stale. They
# are as unread as everything below -- nothing acts on any of the four -- and
# they are recorded in knowledge/library/LEDGER.md D1 instead. If the covering
# read is ever narrowed, four findings appear at once and they are these.
# ---- two owner rulings landed the same day, 2026-08-31 -------------------
# Not from the library; from the owner directly, landed in the same pass
# because it holds the plant file. See knowledge/library/LEDGER.md D1's row
# for the pass and QNA/will-owes-2026-08-31.md for the open consequence.
unread lifters.min_edge_distance_in_by_panel_type -- landed 2026-08-31 from the owner directly ("insulated panel minimum edge distance is 2ft, plant knowledge"), reader pending: knowledge.families_for() resolves a *_by_panel_type map for a LIST role only and there is no scalar resolver, so 6.2 still runs on the published-first order in ticketchecks._edge_limit(). The composition a reader must implement is LARGER-OF with the provenance named -- the plant's 24 in exceeds every published part minimum, so published-first would silently discard it
unread panel_types.detect_verified_on -- provenance for panel_types.detect_verified, which IS read and was armed 2026-08-31. A date beside a flag is for the person deciding whether the flag still holds; same shape as connectors.recognition_verified_on above
unread panel_types.detect_verified_by -- as above: what the arming rested on (three signals at 100% separation over 282 panels, a 1,951-mark connector census, and the owner's ruling), and the one thing it did NOT rest on -- a live press -- so the flag can be argued with rather than inherited
# ---- the live-model guard, 2026-09-01 ------------------------------------
# Both ARE read, on every single bridge request, by
# `vibe/bridgeguard.config_from()`. The detector cannot see it: that function
# is deliberately given `knowledge.get` as an ARGUMENT rather than importing
# it, so the module stays free of anything that reads a file and can be unit
# tested off-Revit (tests/test_bridgeguard.py). The literal paths are there,
# one line apart -- they just arrive through a parameter named `getter`.
unread bridge.refuse_titles -- read every request via bridgeguard.config_from(know, knowledge.get); the path literal is passed to an injected getter, not to knowledge.get by name, which is the wrapper shape this detector says it does not follow
unread bridge.allow_roots -- as above, same call, same line pair
# ===========================================================================
# GROUP 4 -- LANDED FROM THE PDF LIBRARY, READER PENDING. 2026-09-07.
# ===========================================================================
# Stage 3c of knowledge/library/RUNBOOK.md, run against library row P1 -- the
# PCI Design Handbook, 9th ed. (MNL-120-25), read live in the owner's Chrome
# via the Apryse WebViewer (licensed, viewed online, not downloaded). This is
# the PUBLIC layer's own pass, unlike GROUP 3's plant-layer D1 pass: every
# value below identifies no producer, no job and no client, so it landed in
# knowledge/catalogue.yaml rather than a plant file. Same rule as GROUP 3
# applies: `unread` is used, every reason says "reader pending", and the
# tension with this file's own header is the same one GROUP 3 already
# raised and not re-argued here.
#
# Cites are PDF VIEWER page / printed page (footer), same convention as
# GROUP 3. Full extraction, confidence marks and screen-verification notes:
# knowledge/library/notes/PCI9-5.8-losses.md, PCI9-5.12-insulated.md,
# PCI9-5.17-design-aids.md, PCI9-ch8-handling.md, PCI9-ch15-reference.md.
# Ledger row: knowledge/library/LEDGER.md P1.
#
# The reason this is FIVE lines and not fifty: the detector reports the
# HIGHEST unread ancestor, not every leaf -- same shape as the pre-existing
# `catalogue.shear_grid` / `catalogue.slotted_inserts` findings, which this
# pass did not touch and which are still unread themselves (see the plain
# findings list, not this file).
unread catalogue.prestress_losses -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 5.8 "Loss of Prestress", viewer p293-296 / printed 5-42 to 5-45, reader pending: this is what will/REVIT #24 needs -- the panellift/liftexport engine today assumes a lump-sum prestress-loss percentage, and the owner ruled the tool must compute losses per the PCI/Zia method instead of assuming one. The ES/CR/SH/RE equations and the Kre/J/C coefficient tables are landed; nothing calls them yet.
unread catalogue.strand -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 15.2, Design Aids 15-9/15-10/15-11/15-12, viewer p862-863 / printed 15-31/15-32, reader pending: nominal diameter -> area/weight/fpu for ASTM A416 strand (incl. 1/2" SPECIAL at 0.167 in^2), A421 wire and A722 bars, plus the design stress-strain curve. No product-family module currently reads a strand area from this table; ticket QC's own strand count/spacing checks read the model's own strand matrix (strand-matrix-geometry-read), not a published property table.
unread catalogue.camber_and_bowing -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 5.9 and Design Aids 5-1/5-2, viewer p293-300 / printed 5-45 to 5-49 and p448-449 / printed 5-197/5-198, reader pending: the PCI multiplier-method table (1.85/1.80/2.70/2.45/3.00/2.30), the three camber-vs-strand-pattern formulas, and the bowing-restraint-force design aid (including the 0.75*Ec short-term effective-modulus fact). Nothing in this tool currently computes camber or bowing.
unread catalogue.insulated_panel -- landed 2026-09-07 from PCI Design Handbook 9th ed. Section 5.12, viewer p304-309 / printed 5-53 to 5-58, reader pending: insulation max-use-temperatures, the four composite/noncomposite/partially-composite/semicomposite structural design assumptions, and wythe/strand/connector-spacing limits (24 in o.c., 12-14 gauge). Distinct from `insulation.*` (a plant/job-layer namespace already read for the current job's recess/hatch geometry) -- this is the published PCI design-method layer, not this job's drafted geometry.
unread catalogue.handling -- landed 2026-09-07 from PCI Design Handbook 9th ed. Chapter 8 "Component Handling and Erection Bracing", viewer p620-642 / printed 8-3 to 8-23, reader pending: Table 8.3.1's stripping/handling/transport load multipliers, the 4x/5x/2x lifting/hardware/bracing safety factors (OSHA 1926.704), Table 8.3.3's aircraft-cable/wire-rope safe loads, the strand-loop capacity defaults (10 kip base, 1.7x/2.2x for double/triple loops), and Figure 8.6.1's rotation-pick moment/reaction coefficients. NAMING NOTE for whoever picks this up: there is an UNRELATED, already-read top-level `handling.*` namespace (`handling.connectors.declared_action`, `handling.ignore_lateral_prestress` in liftexport.py) that is panellift's OWN per-job handling-analysis config -- a different dotted path (`catalogue.handling.*` vs `handling.*`) so no collision today, but close enough in name to confuse a reader; consider renaming one of the two before wiring a check to either.
# ===========================================================================
# GROUP 5 -- LANDED FROM THE PDF LIBRARY, READER PENDING. 2026-09-07.
# ===========================================================================
# Stage 3 of knowledge/library/RUNBOOK.md, run against ACI 318-19 Chapter 25
# "Reinforcement Details" (§25.4 Development of reinforcement, §25.5
# Splices), read from the code's own text layer, not a producer's manual --
# PUBLIC, same as GROUP 4: it identifies no producer, no job, no client.
# Full extraction, confidence marks and the raw-text-fragment cross-checks:
# knowledge/library/notes/ACI318-19-ch25-development.md. Ledger row: C1 in
# knowledge/library/LEDGER.md.
unread catalogue.development_length -- landed 2026-09-07 from ACI 318-19 Chapter 25 §25.4/§25.5, printed p.471-491, reader pending: development-length and lap-splice formulas, minimums and modification factors for deformed bars/wires in tension and compression, standard hooks, headed bars, pretensioned strand, welded wire reinforcement, and the excess-reinforcement reduction with its six exclusions. No product-family module currently checks a bar's or a strand's development length against a formula -- ticket QC reads dimension strings and bar counts off the model today, not an embedment length. The reader this is for: a future rebar/strand embedment check (would sit beside ticketchecks.py's existing bar checks) and the strand transfer-length assumption panellift/liftexport currently treats as a plant-layer default rather than computing from Eq. 25.4.8.1.
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Strict-parse every knowledge layer on this machine. Exit 1 if one will not.
python tools/knowledgelint.py # every standard layer here
python tools/knowledgelint.py a.yaml b.yaml # just these
python tools/knowledgelint.py knowledge/ # everything under a folder
python tools/knowledgelint.py --list # what it would check
python tools/knowledgelint.py --json # machine readable
**AFTER EDITING ANY KNOWLEDGE LAYER, RUN THIS.** A landing agent that writes
a fact into `knowledge/plant/<plant>/knowledge.yaml`, a job file, the
catalogue or a project config is not done until this passes. It takes under a
second and it is the only thing between a mistyped file and a whole layer
disappearing inside Revit.
WHY IT IS A GATE, AND WHY `scrub.py --private` IS NOT
-----------------------------------------------------
`--private` is an inventory: what it prints is raw material, and a check that
fails on every run gets wrapped in `|| true`. This one is the opposite. An
unparseable knowledge layer is ALWAYS wrong -- there is no state of the world
in which a file the tool cannot read is acceptable -- so a failure here blocks,
and exit 1 means exactly that.
WHAT IT IS FOR (measured 2026-09-01)
------------------------------------
pyRevit's reader is YamlDotNet, and it is STRICT: a mapping key stated twice
raises `Duplicate key` and the read returns nothing. `knowledge._merge_file`
wrapped that read in a bare `except Exception: return merged`, so one nested
duplicate in one plant file silently discarded the ENTIRE 255KB plant layer.
Every parameter role mapped to None, the sweep enumerated 0 assemblies, and
every report rendered normally -- `sources()` and `config_problems()` both
said healthy. A layer that fails to load looks exactly like a layer that
agrees with the defaults.
`_merge_file` now records `_<layer>_unreadable` and `config_problems()` refuses
on it, so Revit will say so. But that is the late warning, one button press
before a wrong report; this is the early one, on the machine where the edit was
made, before Revit is even open.
Host CPython 3.x, PyYAML. It reads with a unique-key SafeLoader -- the same
strictness YamlDotNet has and the same one `tests/pyrevit_yaml_fake.py`
doubles -- and NOT with `vibe.yamlkit`, which is a hand-rolled subset that
takes the last of two duplicate keys and says nothing.
A NOTE ON WHAT IT PRINTS
------------------------
It names files, including the gitignored plant and job layers, because a lint
tool that will not tell you which file failed is not a lint tool. Those paths
carry job and client names. The output is for your terminal: do not paste it
into a tracked file, a commit message or a doc.
"""
from __future__ import annotations
import argparse
import io
import json
import os
import sys
try:
import yaml
except ImportError: # pragma: no cover
sys.stderr.write('PyYAML is required: pip install pyyaml\n')
raise SystemExit(2)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOGUE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml')
CATALOGUE_TEMPLATE = os.path.join(ROOT, 'knowledge', 'catalogue.yaml.template')
# Mirrors `vibe.plantpath` / `vibe.designspec`, which cannot be imported here:
# `vibe/__init__.py` pulls in the Revit API.
PLANT_ENV = 'WILLTOOLS_KNOWLEDGE'
PLANT_FOLDER = 'WillTools'
JOBS_ENV = 'WILLTOOLS_JOBS'
LAYER_FILE = 'knowledge.yaml'
CONFIG_POINTER = 'active-config.txt'
SKIP_DIRS = {'__pycache__', '.git', 'dist', '_archive', '_scratch',
'.pytest_cache', 'node_modules', 'library'}
YAML_SUFFIXES = ('.yaml', '.yml')
class DuplicateKeyError(yaml.YAMLError):
"""A mapping key stated twice -- what YamlDotNet refuses and PyYAML allows.
Named as its own class so a caller can tell the failure this tool exists
for from an ordinary syntax error, and worded to lead with the same two
words pyRevit's reader uses (`Duplicate key`) so the string a person sees
here is the string they will see in Self Check.
"""
def _unique_key_loader():
"""SafeLoader that raises on a duplicate mapping key at ANY depth.
The standard recipe: `construct_mapping` runs once per mapping node, so
nesting comes free, and `key_node.start_mark` points at the SECOND
statement of the key -- the line to delete.
"""
class UniqueKeyLoader(yaml.SafeLoader):
pass
def construct_mapping(loader, node, deep=False):
seen = set()
for key_node, _value in node.value:
key = loader.construct_object(key_node, deep=deep)
try:
duplicate = key in seen
except TypeError: # unhashable key: not this question
continue
if duplicate:
mark = key_node.start_mark
raise DuplicateKeyError(
'Duplicate key: {0!r}, stated again at line {1}, column '
'{2}. A mapping may state a key only once; pyRevit\'s '
'reader refuses the WHOLE FILE over it, so the layer is '
'not merged at all.'.format(
key, mark.line + 1, mark.column + 1))
seen.add(key)
return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
UniqueKeyLoader.add_constructor(
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, construct_mapping)
return UniqueKeyLoader
def check_file(path):
"""(ok, message) for one file. `ok` False means the tool cannot read it.
The message carries line and column wherever the parser gives them --
a duplicate key in a 4,000-line plant file is otherwise a search.
"""
try:
handle = io.open(path, 'r', encoding='utf-8')
except IOError as ex:
return False, 'cannot open: {0}'.format(ex)
try:
text = handle.read()
except (IOError, UnicodeDecodeError) as ex:
return False, 'cannot read: {0}'.format(ex)
finally:
handle.close()
try:
yaml.load(text, Loader=_unique_key_loader())
except DuplicateKeyError as ex:
return False, str(ex)
except yaml.YAMLError as ex:
return False, _yaml_message(ex)
except Exception as ex: # pragma: no cover
return False, '{0}: {1}'.format(type(ex).__name__, ex)
return True, 'ok'
def _yaml_message(ex):
"""A PyYAML error as one line, with the line/column it knows about."""
problem = (getattr(ex, 'problem', None) or str(ex)).strip()
mark = getattr(ex, 'problem_mark', None)
if mark is None:
return problem
context = (getattr(ex, 'context', None) or '').strip()
where = 'line {0}, column {1}'.format(mark.line + 1, mark.column + 1)
if context:
return '{0} ({1}) at {2}'.format(problem, context, where)
return '{0} at {1}'.format(problem, where)
# ------------------------------------------------------------------ targets
def plant_root():
"""The folder the plant layers live in. Mirrors `plantpath.plant_root`.
Set to a folder, that is the root; set to a file, its directory is (the
machine-wide file lives IN the root); unset, %APPDATA%/WillTools.
"""
override = os.environ.get(PLANT_ENV)
if not override:
base = os.environ.get('APPDATA') or os.path.expanduser('~')
return os.path.join(base, PLANT_FOLDER)
if os.path.isdir(override):
return override
return os.path.dirname(override) or override
def default_targets():
"""Every standard layer location that EXISTS on this machine.
The four layers `knowledge.load()` merges, resolved the way it resolves
them minus the document it does not have:
* `knowledge/catalogue.yaml`, in this repo;
* the plant root -- `<root>/knowledge.yaml` and every
`<root>/<plant>/knowledge.yaml`;
* every `<jobs root>/<job>/knowledge.yaml`, plus the project config each
job's `active-config.txt` points at;
* and, whatever the environment says, everything under this checkout's
own `knowledge/plant/` and `knowledge/job/`, because that is where
these files are edited here and an agent whose environment is not set
up must still be checking the files it just wrote.
Deduplicated by real path and returned in that order. A `.bak-*` snapshot
is skipped: nothing reads one, and yesterday's file failing would make
this a check people learn to ignore.
"""
out = []
_add(out, CATALOGUE)
root = plant_root()
_add(out, os.path.join(root, LAYER_FILE))
for name in _entries(root):
_add(out, os.path.join(root, name, LAYER_FILE))
jobs = os.environ.get(JOBS_ENV)
if jobs:
for name in _entries(jobs):
_add(out, os.path.join(jobs, name, LAYER_FILE))
_add(out, _config_pointed_at(os.path.join(jobs, name)))
for folder in ('plant', 'job'):
for path in walk_yaml(os.path.join(ROOT, 'knowledge', folder)):
_add(out, path)
return out
def _add(out, path):
"""Append an existing file, once, by real path."""
if not path or not os.path.isfile(path):
return out
real = os.path.realpath(path)
if real not in [os.path.realpath(p) for p in out]:
out.append(path)
return out
def _entries(folder):
try:
return sorted(os.listdir(folder))
except OSError:
return []
def _config_pointed_at(job_dir):
"""The project config a job's one-line pointer names, if it is there.
A pointer to a file that is GONE is `knowledge._config_missing`, which the
buttons already refuse on, and it is not a parse failure -- the file may
simply live on a share this machine is not on today. So it is not this
tool's verdict: it returns None and the run stays about parseability.
"""
pointer = os.path.join(job_dir, CONFIG_POINTER)
if not os.path.isfile(pointer):
return None
try:
handle = io.open(pointer, 'r', encoding='utf-8')
except IOError:
return None
try:
target = handle.read().strip()
finally:
handle.close()
return target or None
def walk_yaml(folder):
"""Every YAML file under a folder, sorted, without the `.bak-` snapshots."""
out = []
if not os.path.isdir(folder):
return out
for dirpath, dirnames, filenames in os.walk(folder):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for name in sorted(filenames):
if name.endswith(YAML_SUFFIXES) and '.bak-' not in name:
out.append(os.path.join(dirpath, name))
return out
def resolve(paths):
"""Command-line arguments as a list of files. A folder means everything in it."""
out = []
for path in paths:
if os.path.isdir(path):
for found in walk_yaml(path):
_add(out, found)
elif os.path.isfile(path):
_add(out, path)
else:
out.append(path) # kept, and reported as unreadable
return out
# --------------------------------------------------------------------- main
def run(paths):
"""[(path, ok, message)] for everything asked for, in order."""
return [(path,) + check_file(path) for path in paths]
def main(argv=None):
parser = argparse.ArgumentParser(
description='Strict-parse the knowledge layers. Exit 1 if one fails.')
parser.add_argument('paths', nargs='*',
help='files or folders; default: every standard '
'layer location that exists on this machine')
parser.add_argument('--list', action='store_true',
help='print what would be checked and stop')
parser.add_argument('--json', action='store_true', dest='as_json',
help='machine-readable results')
args = parser.parse_args(argv)
targets = resolve(args.paths) if args.paths else default_targets()
if args.list:
for path in targets:
print(path)
return 0
if not targets:
# Not a pass, ordinarily: nothing was checked, and saying "ok" to
# that is the exact collapse this repository keeps paying for on the
# owner's own machines, where knowledge/catalogue.yaml is always
# tracked and this branch should never be reachable.
#
# ONE EXCEPTION, and it is checked by absence of the file this
# checkout SHOULD have plus presence of the file that stands in for
# it on a stripped install (see tools/make_dist.py): a colleague
# trial distribution genuinely ships no knowledge layer at all, on
# the owner's own instruction, and "nothing to check" is the honest,
# expected answer there -- SKIP-shaped, not FAIL-shaped, same
# discipline tools/setup_machine.py --check applies to the same
# state. This is the only branch where exit 0 with nothing checked
# is not a lie.
if (not args.paths and os.path.isfile(CATALOGUE_TEMPLATE)
and not os.path.isfile(CATALOGUE)):
print('no knowledge layers configured -- checks that need them '
'stand down. This looks like a knowledge-less trial '
'install ({0} is present in place of the real catalogue): '
'expected here, not a failure. Ask the owner for '
'knowledge/catalogue.yaml plus a plant/job layer to check '
'them for real.'.format(CATALOGUE_TEMPLATE))
return 0
print('no knowledge layer found to check -- neither {0} nor {1} is '
'set and this checkout has no plant or job files. Name the '
'files to check, or set the variables.'.format(PLANT_ENV,
JOBS_ENV))
return 1
results = run(targets)
failed = [r for r in results if not r[1]]
if args.as_json:
print(json.dumps(
{'checked': len(results), 'failed': len(failed),
'results': [{'path': p, 'ok': ok, 'message': msg}
for p, ok, msg in results]},
indent=2))
return 1 if failed else 0
for path, ok, message in results:
print('{0} {1}'.format('ok ' if ok else 'FAILED', path))
if not ok:
print(' {0}'.format(message))
print('')
if failed:
print('{0} of {1} layer file(s) WILL NOT LOAD.'.format(len(failed),
len(results)))
print('pyRevit refuses the whole file, so the whole layer is dropped '
'and every value in it silently falls back -- which looks '
'exactly like a layer that agrees with the defaults. Fix the '
'file; nothing will guess at what it said.')
return 1
print('{0} layer file(s) parse.'.format(len(results)))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+173
View File
@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
"""Move long comment blocks out of a knowledge layer into a rationale file.
Values are never touched. Two gates prove it:
1. the parsed data must be byte-identical before and after;
2. every comment line that leaves the YAML must appear verbatim in the
rationale file.
Gate 1 is the caller's; gate 2 is here.
"""
import re, sys, json, hashlib
MIN_RUN = 10
KEY_RE = re.compile(r'^(\s*)([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$')
BLOCK_RE = re.compile(r':\s*[|>][-+]?\d*\s*$')
SOURCE_RE = re.compile(r'#.*\bSOURCE\b')
# page / date / confidence tokens: these stay beside the value, so a number is
# never left in the file with its provenance a file away.
CITE_RE = re.compile(r'\bp\.?\s?(\d{2,4})\b|\b(\d{4}-\d\d-\d\d)\b'
r'|\b[Cc]onfidence ([A-D])\b')
def classify(lines):
"""'comment' | 'data' | 'blank' | 'scalar' for each line, block-aware.
A '#' inside a |/> block scalar is CONTENT, not a comment; treating it as
one would delete data.
"""
kinds = [None] * len(lines)
i = 0
while i < len(lines):
l = lines[i]
s = l.strip()
if BLOCK_RE.search(l) and not s.startswith('#'):
kinds[i] = 'data'
m = KEY_RE.match(l)
base = len(m.group(1)) if m else len(l) - len(l.lstrip())
i += 1
while i < len(lines):
nxt = lines[i]
if not nxt.strip():
kinds[i] = 'scalar'; i += 1; continue
if len(nxt) - len(nxt.lstrip()) <= base:
break
kinds[i] = 'scalar'; i += 1
continue
kinds[i] = 'blank' if not s else ('comment' if s.startswith('#') else 'data')
i += 1
return kinds
def path_of(lines, kinds, idx):
m = KEY_RE.match(lines[idx])
if not m:
return None
ind = len(m.group(1)); parts = [m.group(2)]
for j in range(idx - 1, -1, -1):
if kinds[j] != 'data':
continue
mj = KEY_RE.match(lines[j])
if not mj:
continue
ij = len(mj.group(1))
if ij < ind:
parts.append(mj.group(2)); ind = ij
if ind == 0:
break
return '.'.join(reversed(parts))
def run(src, out_yaml, out_md, title):
text = open(src, encoding='utf-8').read()
lines = text.split('\n')
kinds = classify(lines)
runs, cur = [], []
for i, k in enumerate(kinds):
if k == 'comment':
cur.append(i)
elif k == 'blank' and cur:
cur.append(i) # a blank line inside a block is a paragraph
else:
if cur: runs.append([j for j in cur if kinds[j] == 'comment'])
cur = []
if cur: runs.append([j for j in cur if kinds[j] == 'comment'])
runs = [r for r in runs if r]
moved = {}
for r in runs:
if len(r) < MIN_RUN or r[0] == 0: # file preamble stays put
continue
nxt = next((j for j in range(r[-1] + 1, len(lines)) if kinds[j] == 'data'), None)
if nxt is None:
continue
p = path_of(lines, kinds, nxt)
if p:
moved[r[0]] = [p, r]
seen = {}
for k in sorted(moved):
p, r = moved[k]
n = seen.get(p, 0); seen[p] = n + 1
if n:
moved[k][0] = '%s (%d)' % (p, n + 1)
md = ['# %s -- rationale' % title, '',
'Why the values in `knowledge.yaml` are what they are. Moved out of',
'that file so the keys the tool actually reads stay legible. NOTHING',
'HERE IS PARSED by anything -- it is for the reader, human or model.',
'',
'Each section is named for the key it explains. The key itself keeps a',
'`# sources:` stub and a pointer back here, so no number sits in the',
'layer with its provenance out of sight.',
'',
'Generated once by tools/layersplit.py; edit freely from here on.',
'', '---', '']
drop, inject, gate2 = set(), {}, []
for k in sorted(moved):
p, r = moved[k]
body, keep = [], []
pages, dates, conf = [], [], []
for j in r:
t = lines[j].strip()[1:]
if t.startswith(' '):
t = t[1:]
body.append(t)
gate2.append(t)
if SOURCE_RE.search(lines[j]):
keep.append(lines[j])
for pg, dt, cf in CITE_RE.findall(lines[j]):
if pg and 'p' + pg not in pages: pages.append('p' + pg)
if dt and dt not in dates: dates.append(dt)
if cf and cf not in conf: conf.append(cf)
md += ['## %s' % p, ''] + body + ['']
indent = re.match(r'^(\s*)', lines[r[0]]).group(1)
stub = []
if pages: stub.append(' '.join(pages[:6]) + (' +' if len(pages) > 6 else ''))
if dates: stub.append(' '.join(sorted(dates)[:3]) + (' +' if len(dates) > 3 else ''))
if conf: stub.append('confidence ' + '/'.join(sorted(conf)))
ptr = list(keep)
if stub:
ptr.append('%s# sources: %s' % (indent, '; '.join(stub)))
ptr.append('%s# -> rationale.md \u00a7 %s' % (indent, p))
inject[r[0]] = ptr
drop.update(r)
out = []
for i, l in enumerate(lines):
if i in inject:
out.extend(inject[i])
if i not in drop:
out.append(l)
yaml_txt = '\n'.join(out)
md_txt = '\n'.join(md)
open(out_yaml, 'w', encoding='utf-8').write(yaml_txt)
open(out_md, 'w', encoding='utf-8').write(md_txt)
# ---- gate 2: nothing was lost or mangled on the way out --------------
missing = [t for t in gate2 if t and t not in md_txt]
print('runs %d, moved %d' % (len(runs), len(moved)))
print('yaml %d -> %d lines (%.0f%% smaller)'
% (len(lines), len(out), 100.0 * (1 - len(out) / float(len(lines)))))
print('rationale %d lines' % len(md))
print('GATE 2 (every moved comment line present verbatim): %s'
% ('PASS' if not missing else 'FAIL -- %d missing' % len(missing)))
for t in missing[:5]:
print(' missing: %r' % t)
return 0 if not missing else 1
sys.exit(run(*sys.argv[1:]))
+918
View File
@@ -0,0 +1,918 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Full-text index over the knowledge library, one row per PDF page.
python tools/libindex.py build
python tools/libindex.py search "lifting anchor" --limit 5
python tools/libindex.py status
The library is hundreds of pages of source PDFs plus the extraction notes
written beside them. Answering "which document, which page, says X" by
re-reading PDFs costs minutes every time and is the single most repeated act
in `knowledge/library/RUNBOOK.md`. This makes it one query.
SQLite + FTS5, lexical only. There is no embedding model here and no
semantic matching -- a search finds the WORDS you typed, so a miss means
"those words are not in the index", never "the library does not say that".
The schema leaves room for a vector layer later (`pages.method` already
distinguishes how a page's text was obtained) but nothing here pretends to be
one.
WHAT IS IN THE DATABASE, AND WHERE IT LIVES
--------------------------------------------
`knowledge/library/index.sqlite`, inside a folder that is gitignored as a
folder. That is deliberate twice over: the index holds the verbatim text of
documents that name producers and jobs, so it may never be committed -- and it
is a REBUILDABLE ARTIFACT, so it never needs to be. Delete it and run `build`.
Identity is the CONTENT HASH, the same convention the ledger keys on: a file
that moved is the same document and is not re-read; a file whose bytes changed
is a new document and replaces the old rows. Two paths holding identical bytes
are one document, and the second is reported rather than duplicated.
This tool PRINTS the contents of gitignored source material. Its output can
carry a producer's name even though this file never does. Do not paste a line
of its output into a tracked file; `CLAUDE.md` section 3 has the placeholders.
Host CPython 3.11+ (modern syntax is fine here, as in `tools/rvt.py`); it is
never imported from Revit.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import hashlib
import os
import re
import sqlite3
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_DB = os.path.join(ROOT, 'knowledge', 'library', 'index.sqlite')
DEFAULT_ROOTS = (
os.path.join(ROOT, 'knowledge', 'library'),
os.path.join(ROOT, 'knowledge', 'MC'),
)
SCHEMA_VERSION = 1
#: How a page's text was obtained. Only one value exists today; a per-page
#: vision transcription would land as a second one ('vision'), which is why
#: this is a column and not an assumption. A page with no text layer is
#: still 'text-layer' -- we looked, with that method, and there was nothing.
METHOD_TEXT_LAYER = 'text-layer'
# ---------------------------------------------------------------------------
# Tokenizer
# ---------------------------------------------------------------------------
# unicode61, diacritics folded, plus exactly three extra token characters:
# '#', "'" and '"'. Each one is a character that carries MEANING in this
# corpus and that the default tokenizer would throw away:
#
# #4 a bar size. Without '#', "#4" and a bare "4" are the same
# token and every page with a 4 on it answers a bar-size query.
# f'c concrete strength. Without "'", it is "f" AND "c".
# 8" an inch dimension. Without '"', it is a bare 8.
#
# Deliberately NOT token characters:
#
# '.' "7.5" would become one token -- and so would "7.5." at the end
# of a sentence, which then no longer matches a search for 7.5.
# Decimals are handled on the QUERY side instead: `_match_expr`
# turns 7.5 into the phrase "7" "5", which requires the two to be
# adjacent, so "value 7 and 5" does not match. Verified both ways
# in the tests.
# '-' hyphens join words far more often than they join marks, and a
# token character would stop "wall" finding "wall-panel".
# '_' same reasoning: leaving it a separator is what lets a search
# for "anchor" find ANCHOR_FACE_4T.
#
# Plate marks (P316) and part numbers need nothing special -- unicode61 does
# not split a letter/digit boundary.
#
# The quoting below is load-bearing and easy to get wrong: the value is an SQL
# double-quoted string (so the '"' inside it is doubled), and inside THAT the
# tokenchars list is an FTS5 single-quoted string (so the "'" inside it is
# doubled). FTS5 rejects '[...]' quoting here, whatever the SQL docs suggest.
TOKENIZE = '"unicode61 remove_diacritics 2 tokenchars \'#\'\'""\'"'
_SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
-- One row per DOCUMENT, keyed by content hash exactly as the ledger is.
-- `relpath` is where it was last seen; it is not the identity.
CREATE TABLE IF NOT EXISTS docs (
hash TEXT PRIMARY KEY,
relpath TEXT NOT NULL,
pages INTEGER NOT NULL,
bytes INTEGER NOT NULL,
mtime REAL NOT NULL,
ingested_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS docs_relpath ON docs(relpath);
-- One row per PAGE. An empty `text` with method 'text-layer' means "this
-- page was opened and its text layer was empty" -- a scanned image, whose
-- words live in the notes until a vision method exists to put them here.
CREATE TABLE IF NOT EXISTS pages (
doc_hash TEXT NOT NULL,
page_no INTEGER NOT NULL,
text TEXT NOT NULL,
method TEXT NOT NULL,
PRIMARY KEY (doc_hash, page_no)
);
-- One row per extraction note. `doc_ref` is the M#/D#/H# id the filename
-- starts with, when it has one -- the join back to the ledger's own numbering.
CREATE TABLE IF NOT EXISTS notes (
relpath TEXT PRIMARY KEY,
doc_ref TEXT,
text TEXT NOT NULL,
mtime REAL NOT NULL,
bytes INTEGER NOT NULL,
hash TEXT NOT NULL,
ingested_at TEXT NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(
text,
content='pages',
content_rowid='rowid',
tokenize = {tok}
);
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
text,
content='notes',
content_rowid='rowid',
tokenize = {tok}
);
CREATE TRIGGER IF NOT EXISTS pages_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, text) VALUES (new.rowid, new.text);
END;
CREATE TRIGGER IF NOT EXISTS pages_ad AFTER DELETE ON pages BEGIN
INSERT INTO pages_fts(pages_fts, rowid, text)
VALUES ('delete', old.rowid, old.text);
END;
CREATE TRIGGER IF NOT EXISTS pages_au AFTER UPDATE ON pages BEGIN
INSERT INTO pages_fts(pages_fts, rowid, text)
VALUES ('delete', old.rowid, old.text);
INSERT INTO pages_fts(rowid, text) VALUES (new.rowid, new.text);
END;
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, text) VALUES (new.rowid, new.text);
END;
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, text)
VALUES ('delete', old.rowid, old.text);
END;
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, text)
VALUES ('delete', old.rowid, old.text);
INSERT INTO notes_fts(rowid, text) VALUES (new.rowid, new.text);
END;
""".replace('{tok}', TOKENIZE)
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def use_utf8_stdout() -> None:
"""Windows consoles are cp1252 and this corpus is not.
A page of CJK, or a single typographic dash out of a PDF, raises
UnicodeEncodeError on print and the whole run looks like a crash rather
than like a result. `errors='replace'` is chosen over `'strict'`
deliberately: a character we cannot render is not a reason to lose the
hit that contains it.
"""
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding='utf-8', errors='replace')
except (AttributeError, ValueError): # pragma: no cover - odd host
pass
def fts5_available() -> bool:
"""True if this interpreter's sqlite3 can build an FTS5 table."""
try:
con = sqlite3.connect(':memory:')
except sqlite3.Error: # pragma: no cover
return False
try:
con.execute('CREATE VIRTUAL TABLE probe USING fts5(x)')
return True
except sqlite3.Error:
return False
finally:
con.close()
NO_FTS5 = ('this python has no sqlite3 FTS5 -- the index cannot be built or '
'searched on this machine')
def sha256_file(path: str) -> str:
"""Streamed: the biggest document in the library is 122 MB."""
h = hashlib.sha256()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1 << 20), b''):
h.update(block)
return h.hexdigest()
def relpath(path: str) -> str:
"""Repo-relative with forward slashes when it can be; absolute otherwise.
An extra `--root` may sit anywhere on disk, and forcing a `..\\..\\`
relative path onto it would make the stored path unreadable.
"""
path = os.path.abspath(path)
try:
rel = os.path.relpath(path, ROOT)
except ValueError: # different drive on Windows
return path.replace('\\', '/')
if rel.startswith('..'):
return path.replace('\\', '/')
return rel.replace('\\', '/')
def now_iso() -> str:
return _dt.datetime.now(_dt.timezone.utc).replace(
microsecond=0).isoformat()
#: `M11a-standards-details-p001-085.md` -> `M11a`. A note whose name does not
#: start with a ledger id simply has no `doc_ref`; that is not an error, and
#: the column is NULL rather than '' so the difference stays visible.
_DOC_REF = re.compile(r'^([A-Za-z]{1,2}\d+[A-Za-z]?)[-_]')
def note_doc_ref(name: str) -> str | None:
m = _DOC_REF.match(os.path.basename(name))
if not m:
return None
ref = m.group(1)
return ref[0].upper() + ref[1:]
# ---------------------------------------------------------------------------
# Connection / schema
# ---------------------------------------------------------------------------
def connect(db_path: str, create: bool = False) -> sqlite3.Connection:
if not create and not os.path.exists(db_path):
raise FileNotFoundError(db_path)
parent = os.path.dirname(os.path.abspath(db_path))
if create and parent and not os.path.isdir(parent):
os.makedirs(parent, exist_ok=True)
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
con.execute('PRAGMA journal_mode = WAL')
con.execute('PRAGMA synchronous = NORMAL')
return con
def _schema_version(con: sqlite3.Connection) -> int | None:
try:
row = con.execute(
"SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
except sqlite3.Error:
return None
return int(row['value']) if row else None
def ensure_schema(con: sqlite3.Connection, out=print) -> bool:
"""Create the schema, wiping first if an older one is in the file.
Returns True if it wiped. The index is a rebuildable artifact, so a
schema change costs a rebuild and never a migration -- but it says so out
loud, because a silent wipe and a silent no-op look identical from here.
"""
wiped = False
existing = _schema_version(con)
if existing is not None and existing != SCHEMA_VERSION:
out('index was built by schema v{0}, this is v{1} -- rebuilding from '
'scratch'.format(existing, SCHEMA_VERSION))
drop_all(con)
wiped = True
con.executescript(_SCHEMA)
con.execute(
"INSERT INTO meta(key, value) VALUES ('schema_version', ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(str(SCHEMA_VERSION),))
con.commit()
return wiped
def drop_all(con: sqlite3.Connection) -> None:
for name in ('pages_ai', 'pages_ad', 'pages_au',
'notes_ai', 'notes_ad', 'notes_au'):
con.execute('DROP TRIGGER IF EXISTS {0}'.format(name))
for name in ('pages_fts', 'notes_fts', 'pages', 'notes', 'docs', 'meta'):
con.execute('DROP TABLE IF EXISTS {0}'.format(name))
con.commit()
def set_meta(con: sqlite3.Connection, key: str, value: str) -> None:
con.execute(
'INSERT INTO meta(key, value) VALUES (?, ?) '
'ON CONFLICT(key) DO UPDATE SET value = excluded.value',
(key, str(value)))
def get_meta(con: sqlite3.Connection, key: str) -> str | None:
row = con.execute('SELECT value FROM meta WHERE key = ?',
(key,)).fetchone()
return row['value'] if row else None
# ---------------------------------------------------------------------------
# Walking
# ---------------------------------------------------------------------------
def find_pdfs(root: str) -> list[str]:
found = []
for base, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if not d.startswith('.')]
for name in sorted(files):
if name.lower().endswith('.pdf'):
found.append(os.path.join(base, name))
return sorted(found)
def find_notes(root: str) -> list[str]:
"""Every `*.md` under a folder called `notes`.
Not every `*.md` under the root: `LEDGER.md` and `RUNBOOK.md` are
bookkeeping about the library rather than content out of it, and mixing
them into the same ranked list would answer "which document says X" with
a row from the index of documents.
"""
found = []
for base, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if not d.startswith('.')]
parts = {p.lower() for p in
os.path.relpath(base, root).replace('\\', '/').split('/')}
if 'notes' not in parts:
continue
for name in sorted(files):
if name.lower().endswith('.md'):
found.append(os.path.join(base, name))
return sorted(found)
# ---------------------------------------------------------------------------
# Ingest
# ---------------------------------------------------------------------------
def read_pdf_pages(path: str) -> list[str]:
"""Text layer, one string per page. Raises on a PDF that cannot be read."""
import pymupdf # imported here so that
# `search` and `status` work on a machine with no pymupdf installed.
doc = pymupdf.open(path)
try:
if doc.needs_pass:
raise ValueError('encrypted -- no password')
return [page.get_text().replace('\r\n', '\n') for page in doc]
finally:
doc.close()
class BuildReport:
"""What one build run did. Printed by `cmd_build`, asserted by the tests."""
def __init__(self):
self.docs_new = 0
self.docs_known = 0
self.docs_moved = 0
self.docs_removed = 0
self.pages_added = 0
self.pages_removed = 0
self.duplicates: list[tuple[str, str]] = []
self.unreadable: list[tuple[str, str]] = []
self.notes_new = 0
self.notes_changed = 0
self.notes_known = 0
self.notes_removed = 0
self.seconds = 0.0
def _under_any(rel: str, root_rels: list[str]) -> bool:
for r in root_rels:
if rel == r or rel.startswith(r + '/'):
return True
return False
def build(con: sqlite3.Connection, roots: list[str], out=print) -> BuildReport:
"""Bring the index in step with what is on disk, under `roots` only.
Confined to `roots` on purpose. A run with `--root` naming one folder
must not delete the rows of a folder it was not asked to look at, or
every narrowed re-run would silently gut the index.
"""
import time
started = time.time()
rep = BuildReport()
stamp = now_iso()
root_rels = [relpath(r) for r in roots]
# ---- PDFs -------------------------------------------------------------
seen_hashes: dict[str, str] = {}
for root in roots:
if not os.path.isdir(root):
out('root not found, skipped: {0}'.format(relpath(root)))
continue
for path in find_pdfs(root):
rel = relpath(path)
try:
digest = sha256_file(path)
except OSError as exc:
rep.unreadable.append((rel, str(exc)))
continue
if digest in seen_hashes:
rep.duplicates.append((rel, seen_hashes[digest]))
continue
seen_hashes[digest] = rel
row = con.execute('SELECT relpath, mtime FROM docs WHERE hash = ?',
(digest,)).fetchone()
if row is not None:
rep.docs_known += 1
if row['relpath'] != rel:
con.execute('UPDATE docs SET relpath = ?, mtime = ? '
'WHERE hash = ?',
(rel, os.path.getmtime(path), digest))
rep.docs_moved += 1
continue
try:
texts = read_pdf_pages(path)
except Exception as exc: # noqa: BLE001 - any reader
rep.unreadable.append((rel, '{0}: {1}'.format(
type(exc).__name__, exc)))
del seen_hashes[digest]
continue
st = os.stat(path)
con.execute(
'INSERT INTO docs(hash, relpath, pages, bytes, mtime, '
'ingested_at) VALUES (?, ?, ?, ?, ?, ?)',
(digest, rel, len(texts), st.st_size, st.st_mtime, stamp))
con.executemany(
'INSERT INTO pages(doc_hash, page_no, text, method) '
'VALUES (?, ?, ?, ?)',
[(digest, i + 1, t, METHOD_TEXT_LAYER)
for i, t in enumerate(texts)])
rep.docs_new += 1
rep.pages_added += len(texts)
con.commit()
# Documents that were under a scanned root and are no longer there.
gone = [r['hash'] for r in con.execute('SELECT hash, relpath FROM docs')
if r['hash'] not in seen_hashes
and _under_any(r['relpath'], root_rels)]
for digest in gone:
n = con.execute('SELECT COUNT(*) c FROM pages WHERE doc_hash = ?',
(digest,)).fetchone()['c']
con.execute('DELETE FROM pages WHERE doc_hash = ?', (digest,))
con.execute('DELETE FROM docs WHERE hash = ?', (digest,))
rep.docs_removed += 1
rep.pages_removed += n
# ---- notes ------------------------------------------------------------
seen_notes: set[str] = set()
for root in roots:
if not os.path.isdir(root):
continue
for path in find_notes(root):
rel = relpath(path)
seen_notes.add(rel)
try:
with open(path, 'rb') as f:
raw = f.read()
except OSError as exc:
rep.unreadable.append((rel, str(exc)))
seen_notes.discard(rel)
continue
digest = hashlib.sha256(raw).hexdigest()
row = con.execute('SELECT hash FROM notes WHERE relpath = ?',
(rel,)).fetchone()
if row is not None and row['hash'] == digest:
rep.notes_known += 1
continue
text = raw.decode('utf-8', errors='replace')
st = os.stat(path)
if row is None:
con.execute(
'INSERT INTO notes(relpath, doc_ref, text, mtime, bytes, '
'hash, ingested_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
(rel, note_doc_ref(rel), text, st.st_mtime, st.st_size,
digest, stamp))
rep.notes_new += 1
else:
con.execute(
'UPDATE notes SET doc_ref = ?, text = ?, mtime = ?, '
'bytes = ?, hash = ?, ingested_at = ? WHERE relpath = ?',
(note_doc_ref(rel), text, st.st_mtime, st.st_size,
digest, stamp, rel))
rep.notes_changed += 1
con.commit()
gone_notes = [r['relpath'] for r in con.execute('SELECT relpath FROM notes')
if r['relpath'] not in seen_notes
and _under_any(r['relpath'], root_rels)]
for rel in gone_notes:
con.execute('DELETE FROM notes WHERE relpath = ?', (rel,))
rep.notes_removed += 1
rep.seconds = time.time() - started
set_meta(con, 'last_build', stamp)
set_meta(con, 'last_build_seconds', '{0:.2f}'.format(rep.seconds))
set_meta(con, 'last_build_roots', ' | '.join(root_rels))
con.commit()
return rep
# ---------------------------------------------------------------------------
# Query building
# ---------------------------------------------------------------------------
class QueryError(ValueError):
pass
def _chunks(query: str) -> list[str]:
"""Split a user query into phrase chunks.
A `"` only opens a quoted phrase at the START of a chunk. That is what
lets an inch mark stay attached to its number -- `8"` is one bareword,
while `"shear wall"` is one phrase.
"""
out: list[str] = []
i, n = 0, len(query)
while i < n:
if query[i].isspace():
i += 1
continue
if query[i] == '"':
j = query.find('"', i + 1)
if j < 0:
j = n
out.append(query[i + 1:j])
i = j + 1
else:
j = i
while j < n and not query[j].isspace():
j += 1
out.append(query[i:j])
i = j
return [c for c in out if c.strip()]
# ---------------------------------------------------------------------------
# Thousands-separator equivalence (query side)
# ---------------------------------------------------------------------------
# The defect this exists for: `7,690` is a published capacity that appears in
# the source PDFs and the notes ALWAYS with the comma (that is how the
# catalogue prints it), so searching the bare digits `7690` -- which is how
# anyone asking "where does 7690 come from" actually types it -- returned 0
# hits although the corpus holds the fact on four pages and in two notes. A
# search that cannot find a number it holds, and reports that as 0 rather
# than an error, is indistinguishable from "not in the corpus" and is exactly
# the failure this index exists to prevent (CLAUDE.md section on "could not
# judge" vs "judged, found nothing").
#
# Why query-side rather than a stored, normalised copy: the comma is not a
# tokenchar (see the TOKENIZE comment above), so `unicode61` already splits
# `7,690` into the two adjacent tokens "7" and "690" at INDEX time -- that is
# how `"7,690"` (quoted, comma and all) already found those hits before this
# fix. The only missing half is the reverse: a bare `7690` tokenizes to the
# single token "7690", which never occurs in a corpus that always writes the
# comma. So nothing about how text is stored needs to change; the query just
# has to offer FTS5 both spellings and let it match either. That costs no
# rebuild and touches every existing row immediately, at the price of only
# helping searches that go through `match_expr` (not `--raw`, which hands the
# string to FTS5 untouched on purpose).
#
# Deliberately narrow triggers, so this never reaches past the number:
# * A chunk with a '.' is left alone -- decimals already work today via the
# adjacent-phrase trick above, and `3.5` / `4.0` / `0.6` must keep
# behaving exactly as they do now. This function never sees them: the
# caller checks for '.' first.
# * A chunk containing anything but digits and commas (`QL589G`, a bar mark
# `SR5602`, a control number `25008-40085`) is left untouched -- the
# letters or the hyphen already fail `_BARE_DIGITS` / `_GROUPED_DIGITS`
# below, so a part number is never mistaken for a grouped integer.
# * Grouping is only offered from 4 digits up (`1,000` is the shortest real
# grouped number); `4`, `#4`, `123` do not get a comma variant nobody
# would ever type.
_BARE_DIGITS = re.compile(r'^\d{4,}$')
_GROUPED_DIGITS = re.compile(r'^\d{1,3}(,\d{3})+$')
def _group_thousands(digits: str) -> str:
"""`'7690'` -> `'7,690'`. Standard right-to-left grouping of 3."""
rev = digits[::-1]
groups = [rev[i:i + 3] for i in range(0, len(rev), 3)]
return ','.join(groups)[::-1]
def _numeric_variants(chunk: str) -> list[str] | None:
"""Both spellings of a thousands-grouped integer, or `None`.
Returns `[bare, grouped]` when `chunk` is unambiguously an integer typed
either way (`'7690'` or `'7,690'`); `None` for anything else, which tells
the caller to fall back to quoting `chunk` exactly as it always has.
"""
if _BARE_DIGITS.match(chunk):
grouped = _group_thousands(chunk)
return [chunk, grouped] if grouped != chunk else None
if _GROUPED_DIGITS.match(chunk):
return [chunk.replace(',', ''), chunk]
return None
def match_expr(query: str, raw: bool = False) -> str:
"""User text -> an FTS5 MATCH expression.
Every chunk becomes a quoted phrase, so nothing the user types is read as
FTS5 operator syntax by accident and `7.5` becomes the adjacent pair
"7" "5" rather than a parse error. A trailing `*` on a bareword survives
as an FTS5 prefix. `--raw` hands the string over untouched, for the rare
query that really wants OR / NEAR / column filters.
A chunk that is a bare or comma-grouped integer of 4+ digits (`7690` or
`7,690`) expands into an OR of both spellings, so a search finds a number
regardless of which way the source printed it. See `_numeric_variants`.
"""
if raw:
if not query.strip():
raise QueryError('empty query')
return query
parts = []
for chunk in _chunks(query):
prefix = chunk.endswith('*') and len(chunk) > 1
if prefix:
chunk = chunk[:-1]
if not chunk.strip():
continue
variants = None if prefix else _numeric_variants(chunk)
if variants:
parts.append('({0})'.format(' OR '.join(
'"{0}"'.format(v.replace('"', '""')) for v in variants)))
else:
parts.append('"{0}"{1}'.format(chunk.replace('"', '""'),
'*' if prefix else ''))
if not parts:
raise QueryError('empty query')
return ' '.join(parts)
_WS = re.compile(r'\s+')
def flatten(text: str, limit: int = 0) -> str:
"""PDF text is full of newlines; a terminal hit should be one line."""
flat = _WS.sub(' ', text).strip()
if limit and len(flat) > limit:
flat = flat[:limit - 3] + '...'
return flat
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
def search_pages(con, expr, limit, doc=None):
sql = ["SELECT d.relpath, d.hash, p.page_no, bm25(pages_fts) AS score,",
" snippet(pages_fts, 0, '[', ']', ' ... ', 14) AS snip",
"FROM pages_fts",
"JOIN pages p ON p.rowid = pages_fts.rowid",
"JOIN docs d ON d.hash = p.doc_hash",
"WHERE pages_fts MATCH ?"]
args: list = [expr]
if doc:
sql.append('AND (d.hash LIKE ? OR d.relpath LIKE ?)')
args += [doc.lower() + '%', '%' + doc + '%']
sql.append('ORDER BY score LIMIT ?')
args.append(limit)
return con.execute('\n'.join(sql), args).fetchall()
def search_notes(con, expr, limit, doc=None):
sql = ["SELECT n.relpath, n.doc_ref, bm25(notes_fts) AS score,",
" snippet(notes_fts, 0, '[', ']', ' ... ', 14) AS snip",
"FROM notes_fts",
"JOIN notes n ON n.rowid = notes_fts.rowid",
"WHERE notes_fts MATCH ?"]
args: list = [expr]
if doc:
sql.append('AND (IFNULL(n.doc_ref, "") LIKE ? OR n.relpath LIKE ?)')
args += [doc + '%', '%' + doc + '%']
sql.append('ORDER BY score LIMIT ?')
args.append(limit)
return con.execute('\n'.join(sql), args).fetchall()
# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------
def cmd_build(args, out=print) -> int:
roots = [os.path.abspath(r) for r in (args.root or DEFAULT_ROOTS)]
con = connect(args.db, create=True)
try:
if args.rebuild:
drop_all(con)
out('--rebuild: dropped every table, starting empty')
ensure_schema(con, out=out)
rep = build(con, roots, out=out)
finally:
con.close()
out('roots: ' + ', '.join(relpath(r) for r in roots))
out('docs: {0} new, {1} unchanged, {2} moved, {3} removed'.format(
rep.docs_new, rep.docs_known, rep.docs_moved, rep.docs_removed))
out('pages: +{0}, -{1}'.format(rep.pages_added, rep.pages_removed))
out('notes: {0} new, {1} changed, {2} unchanged, {3} removed'.format(
rep.notes_new, rep.notes_changed, rep.notes_known, rep.notes_removed))
for rel, first in rep.duplicates:
out('duplicate content, not indexed twice: {0} == {1}'.format(
rel, first))
for rel, why in rep.unreadable:
out('COULD NOT READ (not indexed, will be retried next build): '
'{0} -- {1}'.format(rel, why))
out('built in {0:.2f}s'.format(rep.seconds))
return 0
def _open_for_reading(db: str, out=print):
if not os.path.exists(db):
out('no index built yet -- run: python tools/libindex.py build')
return None
con = connect(db)
if _schema_version(con) != SCHEMA_VERSION:
con.close()
out('index is from another schema version -- run: '
'python tools/libindex.py build')
return None
return con
def cmd_search(args, out=print) -> int:
con = _open_for_reading(args.db, out=out)
if con is None:
return 1
try:
try:
expr = match_expr(args.query, raw=args.raw)
except QueryError as exc:
out('bad query -- {0}'.format(exc))
return 1
try:
hits = []
if args.kind in ('all', 'pages'):
hits += [('page', r)
for r in search_pages(con, expr, args.limit, args.doc)]
if args.kind in ('all', 'notes'):
hits += [('note', r)
for r in search_notes(con, expr, args.limit, args.doc)]
except sqlite3.OperationalError as exc:
# A malformed FTS5 expression is "could not judge", not "nothing
# matched", so it must never print 0 hits.
out('could not run that query -- {0}'.format(exc))
out('(the query was handed to FTS5 as: {0})'.format(expr))
return 1
hits.sort(key=lambda h: h[1]['score'])
hits = hits[:args.limit]
out('query: {0}'.format(expr))
if not hits:
out('0 hits')
return 0
out('{0} hit(s), best first (bm25: lower is better):'.format(len(hits)))
for kind, row in hits:
if kind == 'page':
where = '{0} p.{1}'.format(row['relpath'], row['page_no'])
tag = row['hash'][:8]
else:
where = row['relpath']
tag = row['doc_ref'] or 'note'
out('')
out(' [{0}] {1} bm25 {2:.2f} {3}'.format(
kind, where, row['score'], tag))
out(' {0}'.format(flatten(row['snip'], 400)))
return 0
finally:
con.close()
def cmd_status(args, out=print) -> int:
con = _open_for_reading(args.db, out=out)
if con is None:
return 1
def one(sql):
return con.execute(sql).fetchone()[0]
docs = one('SELECT COUNT(*) FROM docs')
pages = one('SELECT COUNT(*) FROM pages')
empty = one("SELECT COUNT(*) FROM pages WHERE TRIM(text) = ''")
notes = one('SELECT COUNT(*) FROM notes')
blank_docs = one(
"SELECT COUNT(*) FROM (SELECT doc_hash FROM pages GROUP BY doc_hash "
"HAVING SUM(CASE WHEN TRIM(text) = '' THEN 0 ELSE 1 END) = 0)")
out('index: {0}'.format(relpath(args.db)))
out('docs: {0}'.format(docs))
out('pages: {0} ({1} with an empty text layer'.format(pages, empty)
+ (', {0} document(s) empty throughout -- scanned, '
'their words are in the notes only)'.format(blank_docs)
if blank_docs else ')'))
out('notes: {0}'.format(notes))
roots_meta = get_meta(con, 'last_build_roots') or ''
root_rels = [r for r in roots_meta.split(' | ') if r]
out('roots: {0}'.format(roots_meta or '(none)'))
# `last_build_roots` is only the ROOTS NAMED ON THE MOST RECENT BUILD --
# not a history of everywhere the index has ever been told to look. A
# doc or note ingested by an earlier build with a different --root is
# never removed by a later build that does not name that root (`build`'s
# own docstring: "must not delete the rows of a folder it was not asked
# to look at"), so it stays in the database forever while this line goes
# on describing only the last build's roots. That is `status` UNDER-
# REPORTING what the index actually holds -- the same silence-as-success
# shape as a search returning 0 hits for a reason other than "not
# found" -- so state it explicitly rather than let the line above imply
# the database contains nothing else.
all_rels = [r['relpath'] for r in con.execute(
'SELECT relpath FROM docs UNION SELECT relpath FROM notes')]
outside = sorted(r for r in all_rels if not _under_any(r, root_rels))
if outside:
out(' ** {0} of those item(s) live OUTSIDE the roots above --'
.format(len(outside)))
out(' ** from an earlier build with a different --root; still '
'resident because no later')
out(' ** build has scanned (and could therefore remove) them. '
'The database holds more')
out(' ** than the "roots" line says. Examples:')
for r in outside[:8]:
out(' {0}'.format(r))
if len(outside) > 8:
out(' ... and {0} more'.format(len(outside) - 8))
out('built: {0} in {1}s'.format(get_meta(con, 'last_build') or '(never)',
get_meta(con, 'last_build_seconds')
or '?'))
con.close()
return 0
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def make_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog='libindex',
description='Page-granular full-text index over the knowledge '
'library (SQLite + FTS5, lexical only).')
sub = p.add_subparsers(dest='cmd')
b = sub.add_parser('build', help='ingest / re-ingest; safe to re-run')
b.add_argument('--root', action='append',
help='folder to scan; repeatable. Default: '
'knowledge/library and knowledge/MC')
b.add_argument('--db', default=DEFAULT_DB)
b.add_argument('--rebuild', action='store_true',
help='throw the index away and start from empty')
s = sub.add_parser('search', help='find pages and notes')
s.add_argument('query')
s.add_argument('--limit', type=int, default=10)
s.add_argument('--doc', help='hash prefix, ledger id, or path substring')
s.add_argument('--kind', choices=('all', 'pages', 'notes'), default='all')
s.add_argument('--raw', action='store_true',
help='pass the query to FTS5 verbatim (OR / NEAR / *)')
s.add_argument('--db', default=DEFAULT_DB)
t = sub.add_parser('status', help='what the index currently holds')
t.add_argument('--db', default=DEFAULT_DB)
return p
def main(argv=None, out=print) -> int:
use_utf8_stdout()
parser = make_parser()
args = parser.parse_args(argv)
if not args.cmd:
parser.print_help()
return 1
if not fts5_available():
out(NO_FTS5)
return 1
return {'build': cmd_build,
'search': cmd_search,
'status': cmd_status}[args.cmd](args, out=out)
if __name__ == '__main__':
sys.exit(main())
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""List the PDF library against its ledger.
python tools/library.py
Stage 1 of `knowledge/library/RUNBOOK.md`, and nothing more. It answers three
questions and refuses to grow a fourth: what is in the drop folder that the
ledger has never seen, what has changed under a name the ledger already knows,
and how many documents are sitting in each status.
The identity is the CONTENT HASH, not the filename. A file that moved is the
same document; a second copy of a hash already listed is a no-op, which is what
makes a re-run cheap -- and it has already saved one 929-page manual from being
extracted twice.
This prints filenames from a gitignored folder, so its OUTPUT can carry a
producer's name even though this file never does. Do not paste a line of it
into a tracked file; `CLAUDE.md` section 3 has the placeholders.
Host CPython. The pipeline is discipline, not machinery: if this ever needs a
second screen of code, delete the addition and follow the runbook by hand.
"""
from __future__ import annotations
import hashlib
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LIBRARY = os.path.join(ROOT, 'knowledge', 'library')
LEDGER = os.path.join(LIBRARY, 'LEDGER.md')
# A ledger index row: | id | `sha256` | `file` | kind | pages | status |
_ROW = re.compile(
r'^\|[^|]*\|\s*`([0-9a-f]{64})`\s*\|\s*`([^`]+)`\s*\|'
r'([^|]*)\|([^|]*)\|([^|]*)\|')
def sha256(path: str) -> str:
"""Streamed, because the manual is 122 MB and a read() of it is not free."""
h = hashlib.sha256()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1 << 20), b''):
h.update(block)
return h.hexdigest()
def read_ledger():
"""Index rows as (hash, filename, status). Missing file is not an error."""
if not os.path.exists(LEDGER):
return []
rows = []
with open(LEDGER, encoding='utf-8') as f:
for line in f:
m = _ROW.match(line.strip())
if m:
rows.append((m.group(1), m.group(2).strip(),
m.group(5).strip().strip('`')))
return rows
def dropped_pdfs():
"""Every PDF actually sitting in the drop folder, repo-relative."""
found = []
for base, dirs, files in os.walk(LIBRARY):
dirs[:] = [d for d in dirs if not d.startswith('.')]
for name in sorted(files):
if name.lower().endswith('.pdf'):
found.append(os.path.join(base, name))
return found
def main() -> int:
if not os.path.isdir(LIBRARY):
print('no library folder at knowledge/library/ -- nothing to list')
return 0
rows = read_ledger()
by_hash = {h: (name, status) for h, name, status in rows}
by_name = {}
for h, name, _ in rows:
by_name.setdefault(os.path.basename(name), set()).add(h)
print('ledger: {0} row(s), {1} distinct document(s)'
.format(len(rows), len(by_hash)))
new, changed, known = [], [], 0
for path in dropped_pdfs():
rel = os.path.relpath(path, ROOT).replace('\\', '/')
digest = sha256(path)
if digest in by_hash:
known += 1
elif os.path.basename(path) in by_name:
changed.append((rel, digest))
else:
new.append((rel, digest))
print('drop folder: {0} PDF(s) -- {1} already in the ledger'
.format(known + len(new) + len(changed), known))
if new:
print('\nNEW -- inventory these (runbook stage 1):')
for rel, digest in new:
print(' {0} {1}'.format(digest[:16], rel))
if changed:
print('\nHASH CHANGED -- a known filename holding new content. This is '
'a NEW document: reset its row to `inventoried` and re-extract.')
for rel, digest in changed:
print(' {0} {1}'.format(digest[:16], rel))
elsewhere = [n for h, n, _ in rows
if not os.path.exists(os.path.join(LIBRARY,
os.path.basename(n)))]
if elsewhere:
print('\nrecorded at another location (not in the drop folder), {0}: '
'{1}'.format(len(elsewhere), ', '.join(sorted(set(elsewhere)))))
counts = {}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
if counts:
print('\nstatus: ' + ' | '.join(
'{0} {1}'.format(k, v) for k, v in sorted(counts.items())))
debt = counts.get('extracted', 0)
if debt:
print('\n{0} document(s) EXTRACTED AND NOT LANDED. That is the debt the '
'library exists to drain, not a stage on the way to done -- '
'runbook stage 3.'.format(debt))
return 0
if __name__ == '__main__':
sys.exit(main())
+1091
View File
File diff suppressed because it is too large Load Diff
+363
View File
@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
"""Ribbon icons, drawn rather than drawn-in-a-paint-program.
python tools/make_icons.py
Needs Pillow. Writes icon.png into the pushbutton folders it knows about and
leaves everything else alone.
The house style, taken from the icons that already existed:
96 x 96 RGBA, a rounded square of ONE flat colour filling the canvas with
a 2px margin and a 20px corner radius, a white glyph on top, nothing else.
No gradients, no outlines, no text.
Colour is the identifier. Every button on the tab gets its own and they are
chosen to survive being 32px next to each other, which is the only size
anybody sees:
GodMod terracotta 217, 119, 87
ExtractDesign blue 45, 108, 178
TicketQC green 43, 138, 90
SelfCheck slate 86, 98, 112
PanelLiftCheck purple 109, 76, 163
ProjectConfig amber 198, 141, 26
SnapshotClose indigo 46, 52, 130
Config moss 55, 118, 45 (the pulldown, not a button)
DesignHub plum 124, 58, 136 (the pulldown, not a button)
PanelAnalysis seagreen 35, 139, 120 (a DesignHub row -- WIP)
Modeling forest 72, 119, 34 (the pulldown, not a button)
BeamAnalysis fern 45, 118, 57 (a DesignHub row -- WIP)
ConnectionAnalysis olive-gold 134, 130, 39 (a DesignHub row -- WIP)
PanelAnalysis sits at hue 169, the midpoint of the widest arc still open once
every hue above (14, 40, 75, 112, 149, 189, 211/212/214, 235, 262, 291, 321,
353) is plotted on the ring -- between TicketQC's green at 149 and Gravity's
teal at 189, 20 degrees from each. The ring is full enough now that 20 is the
best separation left anywhere on it.
Three more landed 2026-09-05, for the ribbon reorganisation that added a
`Modeling.pulldown` (ReinforceSuggest / ReinforcingQC / TicketQC) beside
SnapshotClose, and moved BeamAnalysis / ConnectionAnalysis onto DesignHub as
two more (WIP) rows. Same method as PanelAnalysis: plot every hue already in
use (now including 169) and take the widest remaining arcs.
`Modeling.pulldown` sits at 93, the midpoint of LateralExtract's 75 and
Config's 112 (18-19 degrees each side, the same class of separation
PanelAnalysis accepted at 20). `BeamAnalysis` takes 130, the midpoint of
Config's 112 and TicketQC's 149. `ConnectionAnalysis` takes 57, the midpoint
of ExtractDesign/blue-adjacent amber at 40 and LateralExtract's 75 -- the
only other arc left as wide as those two. All three are pulled darker /
less saturated than their nearest neighbour, the same value-before-hue rule
`archive_icon` and the two pulldowns above already use.
Purple and amber went to those two because they used to sit side by side on
one QC panel and were being pressed by mistake -- opposite hue and opposite
value is as far apart as the palette goes. The 2026-08-30 rebuild put them on
different panels, which fixes the confusion a second way; the colours stay as
they are because the owner has learned them.
THE TWO PULLDOWNS. A `.pulldown` is a component in its own right and it draws
its own `icon.png` (`genericcomps` resolves one for every component, and
`uimaker._produce_ui_pulldown` hands it to `create_pulldown_button`). Without
one it renders BLANK -- `_create_button_group` catches the icon error and only
debug-logs it, so nothing tells you. They are drawn here for that reason, in
the same canvas, palette and supersample as the seven above.
Their hues were not picked by eye. The tab already spends 14, 40, 75, 149,
189, 211/212/214, 235, 262, 321 and 353; the two widest unused arcs in that
ring are 75->149 and 262->321, so these two sit at their midpoints -- 112 and
291 -- which is as far from every existing button as the ring still allows.
Both are taken darker than their nearest neighbour (moss L .32 against
TicketQC's .35, plum L .38 against PanelLift's .47) so the separation is a
value difference before it is a hue difference, the same argument as
`archive_icon`.
Their glyphs are containers rather than tools on purpose: neither pulldown
does anything, it opens. Config is sliders and NOT a gear -- the gear is
ProjectConfig, which is a row inside it, and a gear opening onto a gear reads
as a bug. DesignHub is a hub, which promises no particular tool, which is
right for a button whose two rows are both unbuilt.
Everything is drawn at 4x and resampled. Pillow has no anti-aliased
primitives, and a 96px icon with hard edges looks broken next to the others.
"""
from __future__ import print_function
import math
import os
from PIL import Image, ImageDraw
S = 4 # supersample factor
N = 96 # final size
MARGIN = 2
RADIUS = 20
PURPLE = (109, 76, 163, 255)
AMBER = (198, 141, 26, 255)
INDIGO = (46, 52, 130, 255)
MOSS = (55, 118, 45, 255)
PLUM = (124, 58, 136, 255)
SEAGREEN = (35, 139, 120, 255)
FOREST = (72, 119, 34, 255)
FERN = (45, 118, 57, 255)
OLIVEGOLD = (134, 130, 39, 255)
WHITE = (255, 255, 255, 255)
HERE = os.path.dirname(os.path.abspath(__file__))
TAB = os.path.join(HERE, os.pardir, 'WillTools.extension', 'WillTools.tab')
def canvas(colour):
im = Image.new('RGBA', (N * S, N * S), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
d.rounded_rectangle([MARGIN * S, MARGIN * S,
(N - MARGIN) * S, (N - MARGIN) * S],
radius=RADIUS * S, fill=colour)
return im, d
def lift_icon():
"""A panel hanging from two slings.
Sized for 32px, which is where it actually gets looked at. Three earlier
versions lost there: inserts drawn on the panel turned into a pair of
eyes and the whole thing read as a face -- and at 32px they were grey
mush anyway, complexity paid for and not delivered. A deep panel read as
a box. What survives is a thin slab under a triangle of rigging, which is
the one silhouette nothing else on this ribbon has.
"""
im, d = canvas(PURPLE)
k = S
# shackle: an open ring, so it reads as rigging and not as a bubble
d.ellipse([38 * k, 8 * k, 58 * k, 28 * k], outline=WHITE, width=6 * k)
# slings, landing on the top corners of the panel
for x in (24, 72):
d.line([48 * k, 26 * k, x * k, 56 * k], fill=WHITE, width=6 * k)
# the panel: thin, because a panel is
d.rounded_rectangle([16 * k, 56 * k, 80 * k, 76 * k],
radius=4 * k, fill=WHITE)
return im
def gear_icon():
"""A gear: settings, and shaped like nothing else on the tab."""
im, d = canvas(AMBER)
k = S
cx = cy = 48.0 * k
teeth = 8
r_out, r_root, r_hole = 35.0 * k, 26.5 * k, 12.0 * k
pts = []
pitch = 2 * math.pi / teeth
for i in range(teeth):
base = i * pitch
# flanks at 9% and 41% of the pitch, root for the rest
for frac, r in ((0.00, r_root), (0.09, r_out),
(0.41, r_out), (0.50, r_root)):
a = base + frac * pitch
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
d.polygon(pts, fill=WHITE)
d.ellipse([cx - r_hole, cy - r_hole, cx + r_hole, cy + r_hole], fill=AMBER)
return im
def archive_icon():
"""An arrow going down into an open tray: put it away, then shut the door.
Hue was the constraint. The tab already spends 14 (terracotta), 40
(amber), 75 (olive), 149 (green), 189 (teal), 211/214 (blue and two
slates), 262 (purple), 321 (magenta) and 353 (red), which leaves the
indigo band around 238 genuinely unused. It is nearest to ExtractDesign's
211 blue, so this one is taken much darker and much less green -- navy
against medium blue, at 32px, is a value difference before it is a hue
difference.
The glyph carries more of the load than the colour here on purpose: no
other button on the tab has an arrow, and none has a container. The
silhouette is legible before the colour is.
"""
im, d = canvas(INDIGO)
k = S
# the arrow: shaft, then a head wide enough to survive being 32px
d.rectangle([40 * k, 12 * k, 56 * k, 38 * k], fill=WHITE)
d.polygon([(28 * k, 36 * k), (68 * k, 36 * k), (48 * k, 60 * k)],
fill=WHITE)
# the tray: open at the top, so the arrow reads as going INTO it
d.rectangle([16 * k, 70 * k, 80 * k, 80 * k], fill=WHITE)
d.rectangle([16 * k, 52 * k, 26 * k, 80 * k], fill=WHITE)
d.rectangle([70 * k, 52 * k, 80 * k, 80 * k], fill=WHITE)
return im
def sliders_icon():
"""Three sliders at three settings: this button holds the set-up commands.
NOT a gear. The gear is `gear_icon`, which is ProjectConfig -- the first
row inside this very pulldown -- and a gear that opens onto a gear reads
as a misclick rather than a menu.
Three horizontal bands is the one silhouette nothing else on this tab has;
every other glyph here is centred and roughly square. The knobs sit at
three different positions on purpose: evenly spaced they collapse into a
hamburger menu at 32px, which would promise a menu of anything at all
rather than a menu of settings.
"""
im, d = canvas(MOSS)
k = S
# track, then knob, top to bottom. x is where that knob sits on its track.
for y, x in ((28, 62), (48, 34), (68, 54)):
d.rounded_rectangle([18 * k, (y - 3) * k, 78 * k, (y + 3) * k],
radius=3 * k, fill=WHITE)
# the knob is drawn in the field colour with a white ring, so it stays
# readable where it overlaps its own track
d.ellipse([(x - 10) * k, (y - 10) * k, (x + 10) * k, (y + 10) * k],
fill=MOSS, outline=WHITE, width=5 * k)
return im
def hub_icon():
"""A hub: one centre, three spokes, three nodes.
A container, not a tool. Both rows behind this arrow are buttons-and-docs
only, so a glyph that promised a beam, a load or a moment would be
claiming more than the panel delivers -- what it has to say is "there is
more than one thing under here", and a hub says exactly that.
Three nodes rather than two: two reads as a dumbbell, and the count is not
meant to be a count of the rows (there are two today and the picture must
not go stale when a third arrives).
"""
im, d = canvas(PLUM)
k = S
cx = cy = 48.0 * k
r_hub, r_node, reach = 13.0 * k, 9.0 * k, 30.0 * k
# spokes first, so the nodes sit on top of their own ends
for a in (-math.pi / 2, math.pi / 6, 5 * math.pi / 6):
x, y = cx + reach * math.cos(a), cy + reach * math.sin(a)
d.line([cx, cy, x, y], fill=WHITE, width=6 * k)
d.ellipse([x - r_node, y - r_node, x + r_node, y + r_node], fill=WHITE)
d.ellipse([cx - r_hub, cy - r_hub, cx + r_hub, cy + r_hub], fill=WHITE)
return im
def form_icon():
"""A page of fields, with a small filled tag on one of them.
This button pre-fills an Eriksson-shaped input form from the model
rather than checking anything, which is a different promise than every
other glyph on this tab makes -- none of them is a page. Three plain
field lines read as a form; the short solid tag beside the last one --
smaller than the field it sits beside, so it reads as attached to it
rather than as a fourth line -- is the source chip this button's whole
idea rests on.
"""
im, d = canvas(SEAGREEN)
k = S
d.rounded_rectangle([20 * k, 12 * k, 76 * k, 84 * k],
radius=6 * k, fill=WHITE)
for y in (30, 48, 66):
d.rounded_rectangle([30 * k, (y - 3) * k, 58 * k, (y + 3) * k],
radius=3 * k, fill=SEAGREEN)
# the chip: a small solid tag beside the bottom field, in the page's own
# field colour, standing for the source badge every pre-filled value on
# the real page carries.
d.rounded_rectangle([62 * k, 63 * k, 74 * k, 69 * k], radius=3 * k,
fill=SEAGREEN)
return im
def layers_icon():
"""Three stacked plates: this pulldown holds the modelling tools.
A container, like `hub_icon` and `sliders_icon`, and drawn to look like
neither of them -- three offset rounded bars read as layers of a model
being built up, which is what the three rows behind this arrow actually
do (suggest reinforcing, check it while it is modelled, check it once
it is on a ticket). No spokes, no knobs, no gear: a stack is the one
silhouette this tab does not already use for a pulldown.
"""
im, d = canvas(FOREST)
k = S
for i, y in enumerate((26, 46, 66)):
inset = i * 6
d.rounded_rectangle([(18 + inset) * k, (y - 5) * k,
(78 - inset) * k, (y + 5) * k],
radius=4 * k, fill=WHITE)
return im
def beam_icon():
"""An I-beam cross-section: this row is a structural member check.
Two flanges and a web -- the one profile on this tab that reads as
"beam" rather than "panel" (the panel glyph in `lift_icon` is a thin
slab; this is deliberately a wide flange instead) or "gear"/"hub"
(nothing here is a container). Half-built like GravityLoadChase and
LateralExtract, so it is a placeholder colour, not a promise.
"""
im, d = canvas(FERN)
k = S
d.rectangle([20 * k, 18 * k, 76 * k, 30 * k], fill=WHITE)
d.rectangle([20 * k, 66 * k, 76 * k, 78 * k], fill=WHITE)
d.rectangle([42 * k, 30 * k, 54 * k, 66 * k], fill=WHITE)
return im
def connection_icon():
"""Two plates and a pin: this row checks how members connect.
Two overlapping rounded plates, offset so the overlap itself is
legible at 32px, with a single bolt circle through the middle -- the
one glyph on this tab that reads as a joint rather than a member or a
container. Half-built like its siblings; the colour says so.
"""
im, d = canvas(OLIVEGOLD)
k = S
d.rounded_rectangle([16 * k, 34 * k, 62 * k, 58 * k], radius=6 * k,
fill=WHITE)
d.rounded_rectangle([34 * k, 22 * k, 80 * k, 46 * k], radius=6 * k,
fill=WHITE)
d.ellipse([40 * k, 34 * k, 56 * k, 50 * k], fill=OLIVEGOLD)
return im
# Paths are relative to TAB and they are panel structure, which moves: the
# 2026-08-30 rebuild put two of the first three inside a `*.stack`, and the
# 2026-08-30 one moved all of them again and added the two pulldowns. `main()`
# prints "skipped, no such button" rather than failing, so a stale line here
# is silent -- check the output, not the exit.
#
# The last two are `.pulldown` folders, not buttons. That is not a mistake:
# see the module docstring. Everything else on the tab keeps the icon it
# already has and is deliberately not regenerated from here.
ICONS = (
('Design.panel/DesignHub.pulldown/PanelLiftCheck.pushbutton', lift_icon),
('Setup.panel/Config.pulldown/ProjectConfig.pushbutton', gear_icon),
('Tools.panel/SnapshotClose.pushbutton', archive_icon),
('Setup.panel/Config.pulldown', sliders_icon),
('Design.panel/DesignHub.pulldown', hub_icon),
('Design.panel/DesignHub.pulldown/PanelAnalysis.pushbutton', form_icon),
('Tools.panel/Modeling.pulldown', layers_icon),
('Design.panel/DesignHub.pulldown/BeamAnalysis.pushbutton', beam_icon),
('Design.panel/DesignHub.pulldown/ConnectionAnalysis.pushbutton',
connection_icon),
)
def main():
for where, draw in ICONS:
folder = os.path.join(TAB, *where.split('/'))
if not os.path.isdir(folder):
# Still not an error: the ribbon is allowed to move without this
# script being run. But nobody reads an exit code, so say it.
print('skipped, no such component: %s' % where)
continue
path = os.path.join(folder, 'icon.png')
draw().resize((N, N), Image.LANCZOS).save(path, 'PNG', optimize=True)
print('%s (%d bytes)' % (where, os.path.getsize(path)))
if __name__ == '__main__':
main()
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Build a Panel Analysis page-spec JSON with NO Revit running.
python tools/pa_pagespec.py
python tools/pa_pagespec.py --reads reads.json --out spec.json
python tools/pa_pagespec.py --reads reads.json --job-root JOBDIR
This is the CPython half of the owner's scope note on the HTML-page
rewrite: the front end (前端) must open and run before Revit ever supplies a
value, Revit only supplies PREFILL. `vibe.pa_pagespec.build_pagespec()` is
already Revit-free (see that module's own docstring); this script is what
gets `values` / `know` in front of it the same way `tools/deckcheck.py` gets
knowledge in front of its own checks -- off a live `doc`, on host CPython.
`--reads` is a JSON file shaped like whatever `PanelAnalysis.pushbutton`'s
`_reads_for()` hands `panelanalysisform.prefill()` -- the exact file a
bridge dry-run should save (see `_scratch/pa_pagespec_dump.py`, which writes
`_scratch/pa_reads_wpa655.json` for this purpose) or a hand-written stand-in
for testing the page with no model at all. Omitted, or pointed at a file
that is not there, this runs with `reads = {}` -- an EMPTY, but still
complete and defaulted, form (see `pa_pagespec.build_pagespec`'s "THE TWO
CALLING SHAPES" note): every field present, every `pa_defaults` rule
applied, every table at its minimum row count, every drawing's geometry
degraded to a placeholder rather than missing. That is the shape the HTML
page needs to be developable and testable in a browser with no Revit and no
model at all.
`--job-root` names the folder holding a job's OWN `knowledge.yaml` (see
`tools/deckcheck.py`'s `build_knowledge()` docstring for exactly what that
resolves) -- omitted, this runs with just the shipped defaults + catalogue
layers, same as `deckcheck.build_knowledge(None)`.
`--out` writes the spec there (UTF-8); omitted, it prints to stdout.
CONSTRAINTS THIS RESPECTS (same as `tools/deckcheck.py`, for the same
reason): host CPython, no Revit, standalone; prints no plant or client name
of its own -- design numbers and marks flowing through a `--reads` file are
data, not this file's business, per CLAUDE.md's "No client or job
information in the repo".
"""
from __future__ import print_function
import argparse
import io
import json
import os
import sys
import types
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
LIB = os.path.join(REPO, 'WillTools.extension', 'lib')
if LIB not in sys.path:
sys.path.insert(0, LIB)
# Same trick `tools/deckcheck.py` / `tools/ticket_delta.py` /
# `WillTools.extension/tests/conftest.py` all use: `vibe/__init__.py`
# imports the Revit API at module scope, which is fatal off Revit. Register
# a bare `vibe` package pointed at the directory so the Revit-free
# submodules (`panelanalysisform`, `pa_pagespec`, the five page modules, the
# four `pa_sketch_*` modules, `pa_defaults`) import normally and
# `__init__.py` never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(LIB, 'vibe')]
sys.modules['vibe'] = _stub
if HERE not in sys.path:
sys.path.insert(0, HERE)
import deckcheck # noqa: E402 -- reuses build_knowledge(), not re-derived
from vibe import pa_pagespec # noqa: E402
from vibe import panelanalysisform as F # noqa: E402
def _load_reads(path):
"""`(reads_dict, error_or_None)`. A path that is not given, or not
there, is the ordinary "no Revit at all" case -- `{}`, no error. A path
that IS there and will not parse as a JSON object is reported, never
silently treated as empty (the same "a layer that fails to load must
never look like a layer that agrees" discipline `knowledge._merge_file`
uses)."""
if not path:
return {}, None
if not os.path.isfile(path):
return {}, "'{0}' does not exist; running with reads = {{}}.".format(
path)
try:
with io.open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception as exc:
return {}, "'{0}' could not be read as JSON ({1!r}); running with " \
'reads = {{}}.'.format(path, exc)
if not isinstance(data, dict):
return {}, "'{0}' is not a JSON object; running with reads = {{}}." \
.format(path)
return data, None
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument('--reads', default=None,
help='JSON file of the model reads dict (omit for '
'an empty, still-complete form)')
parser.add_argument('--job-root', default=None,
help="folder holding the job's own knowledge.yaml "
'(omit for shipped defaults + catalogue only)')
parser.add_argument('--out', default=None,
help='write the spec here (UTF-8); omit to print '
'to stdout')
args = parser.parse_args(argv)
reads, reads_err = _load_reads(args.reads)
if reads_err:
sys.stderr.write(reads_err + '\n')
try:
know = deckcheck.build_knowledge(args.job_root)
except Exception as exc:
sys.stderr.write(
'knowledge could not be resolved off Revit ({0!r}); running '
'with the bare shipped defaults.\n'.format(exc))
know = {}
values = F.prefill(reads, know=know)
spec = pa_pagespec.build_pagespec(values, know=know, reads=reads)
blob = pa_pagespec.dumps(spec)
if args.out:
with io.open(args.out, 'w', encoding='utf-8') as fh:
fh.write(blob if isinstance(blob, str) else blob.decode('utf-8'))
sys.stderr.write('wrote {0} ({1} bytes)\n'.format(
args.out, len(blob)))
else:
print(blob)
return 0
if __name__ == '__main__':
raise SystemExit(main())
+632
View File
@@ -0,0 +1,632 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Panel Analysis's local server -- the CPython half of the HTML front/back end.
python tools/pa_web.py [--reads reads.json] [--job-root DIR]
[--port N|0] [--open] [--title T]
Owner ruling, 2026-09-05: every calculation tool has an HTML 前端 (input page)
and HTML 后端 (results page) served by a local CPython HTTP server; the engine
is CPython (`panellift` + `tools/panelanalysis.py`). Revit is only an OPTIONAL
prefill plug-in -- its button reads the model, writes a reads JSON, launches
THIS script and shows the page in a WebView2 window. Without Revit, this same
server opens the page from a saved reads JSON, or blank-with-defaults, and Run
works end to end. See `docs/pa-web-spec.md` for the pagespec schema and the
host<->page protocol this implements over plain HTTP instead of postMessage.
NO REVIT ANYWHERE ON THIS PATH. `vibe.panelanalysisform`, `vibe.pa_pagespec`
and `vibe.pa_defaults` are Revit-free (same `vibe` package stub trick
`tools/pa_pagespec.py` and `tools/deckcheck.py` use); `tools/panelanalysis.py`
(the solve + results-HTML renderer) is plain CPython too.
Routes:
GET / tools/pa_web/static/index.html (and every other
static asset under that folder, by relative path)
GET /api/spec the pagespec JSON: `vibe.pa_pagespec.
build_pagespec()`, built off `--reads` (or a
blank/defaulted form when absent) with any
previously SAVED user edits merged over the
prefill.
POST /api/geometry body {"values": {...}} -- `{"geometry": {...}}`,
the SAME `geometry` dict `/api/spec` returns,
rebuilt off the values the client currently
holds (not a save, not the prefill) -- how a
row-table edit (an opening, a restraint, a
support) or a geometry field the client has
not saved yet gets its outline/section/free-
body drawing redrawn without a full page
reload. See `app.js`'s `scheduleGeometryRefresh`.
POST /api/save body {"values": {...}} -- persists that dict as
this mark's saved edits (`<job-root>/
panelanalysis/<mark>.values.json`), read back by
the next `/api/spec`.
POST /api/run body {"values": {...}} -- `panelanalysisform.
validate()`; on problems, {"ok": false,
"kind": "input", "problems": [{"key", "text"},
...]} (best-effort key per problem sentence, see
`_key_for_problem`, '' when it cannot be matched
to one FIELDS key). `kind` says WHOSE fault the
refusal is: "input" is the piece / the form still
missing a value (nothing broke), "error" is the
solve or a write raising (a bug, traceback on
stderr). The page renders the two differently --
a validation refusal and a crash used to share
one red banner and read as the same failure;
otherwise `build_deck()`, write `<mark>.pa.json`,
run the SAME solve `tools/panelanalysis.py`
performs on that deck shape (`build_page()`),
write the results HTML beside it, and answer
{"ok": true, "result_url": "/results/<mark>.html"}.
A solve exception is caught and reported the same
shape, never a dead server.
GET /results/<file> serves a written results HTML (or anything else
landed in the same folder) by filename.
POST /api/close acknowledges, then shuts the server down (used
by a page's own Close button, and by the Revit
button once its WebView2 window closes).
POST /api/log body {"text": ...} (anything JSON) -- appends
one line, `<ISO timestamp>\t<body compact>`, to
`<job-root>/panelanalysis/<mark>.client.log` and
answers {"ok": true} (the client-side error
sink `app.js`'s `logError` posts to -- the route
`docs/ba-web-spec.md` §5 says PA's server gains
for Beam Analysis to reuse).
Only binds 127.0.0.1. Threaded (`http.server.ThreadingHTTPServer`) so a slow
solve never blocks a concurrent static request. Every request and every solve
exception is logged to stderr; nothing here ever prints a plant, job or client
name (`--reads` / `--job-root` are data, supplied by the caller, never this
file's own business -- same rule `tools/pa_pagespec.py` follows). The one
place a client-supplied value is written anywhere is `POST /api/log`, into
`<mark>.client.log` -- into the FILE, never stdout.
JOB ROOT resolution (where `<mark>.values.json` / `<mark>.pa.json` / the
results HTML all live, one folder per mark under `<job-root>/panelanalysis/`):
the `--reads` file's own `"job_root"` field wins when present (the Revit
button always writes one -- `designspec.root_dir(doc)`, the same folder
`knowledge.yaml` lives beside); else `--job-root`; else
`%LOCALAPPDATA%\\WillTools\\panelanalysis`. The MARK itself (the file-name
stem) is `--reads`' own `"mark"` field, slugged, or the literal string
`'blank'` when there is no mark at all -- so two blank-path runs never
collide with a real panel's saved files, and a re-run of the same mark picks
its saved edits back up.
`--port 0` (the default) lets the OS pick a free port; the actual URL is
printed as `PA_WEB_URL=http://127.0.0.1:<port>/` -- the FIRST line on stdout,
flushed immediately, so a launcher (the Revit button, a test) can read it off
a pipe before the server does anything else. `--open` additionally opens it
in the default browser.
"""
from __future__ import print_function
import argparse
import datetime
import io
import json
import mimetypes
import os
import sys
import threading
import traceback
import types
import webbrowser
try: # Python 2 vs 3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, unquote
except ImportError: # pragma: no cover
from BaseHTTPServer import BaseHTTPRequestHandler # noqa: F401
from SocketServer import ThreadingMixIn
from BaseHTTPServer import HTTPServer
from urlparse import urlparse # noqa: F401
from urllib import unquote # noqa: F401
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
LIB = os.path.join(REPO, 'WillTools.extension', 'lib')
if LIB not in sys.path:
sys.path.insert(0, LIB)
# Same trick `tools/pa_pagespec.py` / `tools/deckcheck.py` use: `vibe/
# __init__.py` imports the Revit API at module scope, which is fatal off
# Revit. Register a bare `vibe` package pointed at the directory so the
# Revit-free submodules import normally and `__init__.py` never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(LIB, 'vibe')]
sys.modules['vibe'] = _stub
if HERE not in sys.path:
sys.path.insert(0, HERE)
import deckcheck # noqa: E402 -- sets up the `vibe` stub as a side effect
import panelanalysis # noqa: E402 -- the CPython solve + results renderer
from vibe import pa_pagespec # noqa: E402
from vibe import panelanalysisform as F # noqa: E402
STATIC_DIR = os.path.join(HERE, 'pa_web', 'static')
# ------------------------------------------------------------- job / reads
def _load_reads(path):
"""`(reads_dict, error_or_None)` -- mirrors `tools/pa_pagespec.py`'s own
loader exactly: a path not given, or not there, is the ordinary "no
Revit at all" case (`{}`, no error); a path that IS there and will not
parse as a JSON object is reported, never silently treated as empty."""
if not path:
return {}, None
if not os.path.isfile(path):
return {}, "'{0}' does not exist; running with reads = {{}}.".format(
path)
try:
with io.open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception as exc:
return {}, "'{0}' could not be read as JSON ({1!r}); running with " \
'reads = {{}}.'.format(path, exc)
if not isinstance(data, dict):
return {}, "'{0}' is not a JSON object; running with reads = {{}}." \
.format(path)
return data, None
def _default_job_root():
base = os.environ.get('LOCALAPPDATA') or os.path.expanduser('~')
return os.path.join(base, 'WillTools', 'panelanalysis')
def _slug(text):
text = text or ''
out = ''.join(ch if (ch.isalnum() or ch in '-_.') else '_' for ch in text)
return out or 'panel'
def _ensure_dir(path):
if not os.path.isdir(path):
os.makedirs(path)
def _append_client_log(ctx, body):
"""Append one `<ISO timestamp>\t<json body compact>` line to this mark's
`client.log` and return the written path. Never raises -- logging must
never take the request down with it. The body is only ever written into
the file, never printed (this module promises nothing about a plant, job
or client name reaches stdout)."""
_ensure_dir(ctx.pa_dir)
path = os.path.join(ctx.pa_dir, ctx.mark + '.client.log')
line = datetime.datetime.now().isoformat() + '\t' + \
json.dumps(body, separators=(',', ':'), ensure_ascii=False) + '\n'
with io.open(path, 'a', encoding='utf-8') as fh:
fh.write(line)
return path
class Context(object):
"""Everything one server process needs, resolved once at startup."""
def __init__(self, reads, know, job_root, pa_dir, mark, static_dir):
self.reads = reads
self.know = know
self.job_root = job_root
self.pa_dir = pa_dir
self.mark = mark
self.static_dir = static_dir
def build_context(reads_path=None, job_root_override=None,
static_dir=STATIC_DIR):
reads, reads_err = _load_reads(reads_path)
if reads_err:
sys.stderr.write('pa_web: ' + reads_err + '\n')
job_root = reads.get('job_root') or job_root_override or \
_default_job_root()
pa_dir = os.path.join(job_root, 'panelanalysis')
_ensure_dir(pa_dir)
try:
know, _layers = deckcheck.build_knowledge(job_root)
except Exception as exc:
sys.stderr.write(
'pa_web: knowledge could not be resolved off {0!r} ({1!r}); '
'running with the bare shipped defaults.\n'.format(
job_root, exc))
know = {}
mark_raw = (reads.get('mark') or '').strip()
mark = _slug(mark_raw) if mark_raw else 'blank'
return Context(reads=reads, know=know, job_root=job_root, pa_dir=pa_dir,
mark=mark, static_dir=static_dir)
# ------------------------------------------------------------- the pagespec
def _load_saved_values(ctx):
"""The `{key: value}` dict a previous `/api/save` for this mark left --
`{}` for anything else (no file yet, a file that will not parse, a file
that is not shaped `{"values": {...}}`). Never raises."""
path = os.path.join(ctx.pa_dir, ctx.mark + '.values.json')
if not os.path.isfile(path):
return {}
try:
with io.open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
except Exception:
return {}
if isinstance(data, dict):
values = data.get('values')
if isinstance(values, dict):
return values
return {}
def _save_values(ctx, values):
_ensure_dir(ctx.pa_dir)
path = os.path.join(ctx.pa_dir, ctx.mark + '.values.json')
blob = json.dumps({'values': values}, indent=1, sort_keys=True,
ensure_ascii=False)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(blob)
return path
def geometry_for(ctx, values):
"""`{'geometry': {...}}` for `POST /api/geometry` -- the page's own
`values` dict, whatever the client currently holds (not re-prefilled,
not merged with a save on disk), run back through `pa_pagespec.
build_pagespec` so its `geometry` dict reflects an edit the client made
seconds ago and has not (or will not) have saved yet -- an opening row
added, a length typed in, a support moved. Rebuilding the WHOLE pagespec
to get one dict back is wasteful (every page's blocks/tables/choices are
recomputed too) but simple and safe: `build_pagespec` is pure and cheap,
and a second, geometry-only code path would be a second place for the
two to disagree. Never raises -- an exception comes back as an empty
geometry dict plus an `error` string, so a client-side redraw degrades
to 'nothing to draw' rather than a dead page.
"""
try:
spec = pa_pagespec.build_pagespec(values=values, know=ctx.know,
reads=ctx.reads)
return {'geometry': spec.get('geometry') or {}}
except Exception as exc:
sys.stderr.write('pa_web: geometry rebuild raised:\n' +
traceback.format_exc())
return {'geometry': {},
'error': '{0}: {1}'.format(type(exc).__name__, exc)}
def build_spec_json(ctx):
"""The pagespec dict for GET /api/spec: prefill off `ctx.reads`/`ctx.
know`, with any saved edits for this mark folded over it before
`build_pagespec` computes choices/tables/geometry off the RESULT -- so a
saved length/width edit also redraws the geometry, not just the raw
number."""
try:
values = F.prefill(ctx.reads, know=ctx.know)
except Exception as exc:
sys.stderr.write(
'pa_web: panelanalysisform.prefill() raised: {0!r}\n'.format(exc))
values = {}
saved = _load_saved_values(ctx)
if saved:
values = dict(values)
values.update(saved)
return pa_pagespec.build_pagespec(values=values, know=ctx.know,
reads=ctx.reads)
# ------------------------------------------------------------- run / solve
_DISPLAY_LABELS = None
def _display_label_pairs():
"""`[(label_text, key), ...]`, longest label first -- see
`_key_for_problem`. Built once; never raises (a `FIELDS` entry whose
`display_label` fails is simply skipped)."""
global _DISPLAY_LABELS
if _DISPLAY_LABELS is None:
pairs = []
for f in F.FIELDS:
key = f.get('key')
if not key:
continue
try:
label = F.display_label(key)
except Exception:
continue
if label:
pairs.append((label, key))
pairs.sort(key=lambda p: -len(p[0]))
_DISPLAY_LABELS = pairs
return _DISPLAY_LABELS
def _key_for_problem(text):
"""The `FIELDS` key a `validate()` error sentence is most likely about,
or `''` when nothing matches -- best-effort, per the deliverable's own
admission that `validate()`'s plain-English errors do not carry a key of
their own. Matches by the error sentence STARTING WITH that field's own
`display_label(key)` (`'{0} is required'` / `'{0} {1}'` / `'{0}: {1}'`
are exactly the three shapes `validate()` builds its per-field errors
from), longest label first so e.g. a wythe-2 field is not shadowed by
wythe-1's shorter, textually-overlapping label."""
if not text:
return ''
for label, key in _display_label_pairs():
if text.startswith(label):
return key
return ''
def run_analysis(ctx, values):
"""`{"ok": True, "result_url": ...}` or `{"ok": False, "kind": ...,
"problems": [...]}`.
Never raises -- a `build_deck` / solve exception is caught and reported
the same problems shape the deliverable specifies, with an empty `key`
(there is no single field a solve exception can be pinned on).
`kind` is `'input'` when `validate()` refused (the piece or the form is
still missing a value -- the user's to supply, nothing broke) and
`'error'` when the solve or a write raised (a bug -- the developer's,
traceback on stderr). The two are different answers -- "could not run
for want of data" against "ran and broke" -- and the reply names which,
because with only `problems` to go on the page rendered both through one
red banner and a validation refusal read as a crash (issue #19)."""
errors, parsed = F.validate(values)
if errors:
return {'ok': False, 'kind': 'input', 'problems': [
{'key': _key_for_problem(t), 'text': t} for t in errors]}
try:
deck = F.build_deck(parsed)
except Exception as exc:
sys.stderr.write('pa_web: build_deck() raised:\n' +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
_ensure_dir(ctx.pa_dir)
pa_path = os.path.join(ctx.pa_dir, ctx.mark + '.pa.json')
try:
blob = json.dumps(deck, indent=1, sort_keys=True, ensure_ascii=False)
with io.open(pa_path, 'w', encoding='utf-8') as fh:
fh.write(blob)
except Exception as exc:
sys.stderr.write('pa_web: could not write {0}:\n'.format(pa_path) +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
try:
html = panelanalysis.build_page(pa_path)
except Exception as exc:
sys.stderr.write('pa_web: panelanalysis.build_page() raised:\n' +
traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
result_name = ctx.mark + '.html'
result_path = os.path.join(ctx.pa_dir, result_name)
try:
with io.open(result_path, 'w', encoding='utf-8') as fh:
fh.write(html)
except Exception as exc:
sys.stderr.write('pa_web: could not write {0}:\n'.format(
result_path) + traceback.format_exc())
return {'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(type(exc).__name__, exc)}]}
return {'ok': True, 'result_url': '/results/' + result_name}
# ------------------------------------------------------------------ server
def make_server(ctx, port=0):
server = ThreadingHTTPServer(('127.0.0.1', port), Handler)
server.ctx = ctx
return server
class Handler(BaseHTTPRequestHandler):
server_version = 'PAWeb/1'
def log_message(self, fmt, *args):
try:
sys.stderr.write('pa_web: {0} - {1}\n'.format(
self.address_string(), fmt % args))
except Exception:
pass
# -------------------------------------------------------- plumbing
def _send_json(self, obj, status=200):
body = json.dumps(obj, ensure_ascii=False).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
try:
self.wfile.write(body)
except Exception:
pass
def _send_error_json(self, exc, status=500):
sys.stderr.write('pa_web: {0} {1} failed:\n{2}'.format(
self.command, self.path, traceback.format_exc()))
self._send_json({'ok': False, 'kind': 'error', 'problems': [
{'key': '', 'text': '{0}: {1}'.format(
type(exc).__name__, exc)}]}, status=status)
def _read_json_body(self):
try:
length = int(self.headers.get('Content-Length') or 0)
except (TypeError, ValueError):
length = 0
raw = self.rfile.read(length) if length else b''
if not raw:
return {}
try:
return json.loads(raw.decode('utf-8'))
except Exception:
return {}
def _send_file(self, path, default_type):
ctype, _enc = mimetypes.guess_type(path)
ctype = ctype or default_type
# Every text-ish type this server ever serves (html/js/css/json) is
# written as UTF-8 -- say so, or a browser guesses cp1252/latin-1 and
# every non-ASCII glyph (the title's en dash, the row x buttons, the
# section-arrow triangles) renders as mojibake. mimetypes.guess_type
# never appends a charset on its own.
if ctype.startswith('text/') or ctype in (
'application/javascript', 'application/json'):
ctype += '; charset=utf-8'
try:
with open(path, 'rb') as fh:
data = fh.read()
except Exception as exc:
self._send_error_json(exc)
return
self.send_response(200)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
try:
self.wfile.write(data)
except Exception:
pass
def _serve_from(self, root, rel, default_type):
rel = rel.lstrip('/') or 'index.html'
full = os.path.normpath(os.path.join(root, rel))
root_norm = os.path.normpath(root)
if not (full == root_norm or
full.startswith(root_norm + os.sep)):
self.send_error(403, 'forbidden')
return
if not os.path.isfile(full):
self.send_error(404, 'not found')
return
self._send_file(full, default_type)
# -------------------------------------------------------------- GET
def do_GET(self):
ctx = self.server.ctx
path = unquote(urlparse(self.path).path)
try:
if path == '/api/spec':
self._send_json(build_spec_json(ctx))
return
if path.startswith('/results/'):
self._serve_from(ctx.pa_dir, path[len('/results/'):],
'text/html')
return
if path.startswith('/api/'):
self._send_json({'ok': False, 'problems': [
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
return
self._serve_from(ctx.static_dir, path, 'application/octet-stream')
except Exception as exc:
self._send_error_json(exc)
# ------------------------------------------------------------- POST
def do_POST(self):
ctx = self.server.ctx
path = urlparse(self.path).path
try:
if path == '/api/save':
values = self._read_json_body().get('values') or {}
_save_values(ctx, values)
self._send_json({'ok': True})
return
if path == '/api/run':
values = self._read_json_body().get('values') or {}
self._send_json(run_analysis(ctx, values))
return
if path == '/api/geometry':
values = self._read_json_body().get('values') or {}
self._send_json(geometry_for(ctx, values))
return
if path == '/api/log':
body = self._read_json_body()
try:
_append_client_log(ctx, body)
except Exception as e: # the docstring's promise: never raises
sys.stderr.write('client.log append failed: %r\n' % (e,))
self._send_json({'ok': True})
return
if path == '/api/close':
self._send_json({'ok': True})
threading.Thread(target=self.server.shutdown).start()
return
self._send_json({'ok': False, 'problems': [
{'key': '', 'text': 'unknown endpoint'}]}, status=404)
except Exception as exc:
self._send_error_json(exc)
# --------------------------------------------------------------------- CLI
def main(argv=None):
ap = argparse.ArgumentParser(
description='Serve the Panel Analysis HTML front/back end.')
ap.add_argument('--reads', default=None,
help='JSON file of the model reads dict (the Revit '
'button writes one; omit for a blank, still-'
'complete form)')
ap.add_argument('--job-root', dest='job_root', default=None,
help='folder to store this run under (overridden by '
"--reads' own job_root field, if present)")
ap.add_argument('--port', type=int, default=0,
help='0 (default) picks a free port')
ap.add_argument('--open', action='store_true',
help='open the page in the default browser')
ap.add_argument('--title', default='Panel Analysis',
help='cosmetic only -- logged at startup')
args = ap.parse_args(argv)
ctx = build_context(args.reads, args.job_root)
try:
server = make_server(ctx, args.port or 0)
except OSError as exc:
sys.stderr.write('pa_web: could not bind 127.0.0.1:{0}: {1}\n'.format(
args.port, exc))
return 1
url = 'http://127.0.0.1:{0}/'.format(server.server_address[1])
# FIRST line on stdout, flushed immediately -- a launcher (the Revit
# button, a test) reads this off a pipe before anything else happens.
print('PA_WEB_URL={0}'.format(url))
sys.stdout.flush()
sys.stderr.write('pa_web: serving {0} for mark={1} (job_root={2}, '
'title={3})\n'.format(url, ctx.mark, ctx.job_root,
args.title))
if args.open:
try:
webbrowser.open(url)
except Exception as exc:
sys.stderr.write('pa_web: could not open a browser: {0}\n'
.format(exc))
try:
server.serve_forever()
finally:
server.server_close()
return 0
if __name__ == '__main__':
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+952
View File
@@ -0,0 +1,952 @@
// SVG drawings for Panel Analysis's five geometry blocks, plus the pure
// `sectionAt()` cut-profile function, reimplemented byte-for-byte from
// `vibe.pa_sketch_extents.section_at()` per docs/pa-web-spec.md section 6 --
// the two must always agree, not just on the panels covered by whatever
// tests happen to exist.
//
// Everything here works off real-unit numbers (ft, in) straight out of the
// spec's `geometry` dict -- no pixels arrive from the server, all layout and
// scaling happens in here. Plain ES2017, no modules, no dependencies other
// than window.Units (units.js, loaded first).
(function () {
'use strict';
var NS = 'http://www.w3.org/2000/svg';
// --------------------------------------------------------- small helpers
function safeNum(value) {
if (value === null || value === undefined) return null;
if (typeof value === 'boolean') return null;
if (typeof value === 'number') return isNaN(value) ? null : value;
var s = String(value).trim();
if (s === '') return null;
var n = parseFloat(s);
return isNaN(n) ? null : n;
}
function clamp(v, lo, hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
function el(name, attrs, parent) {
var e = document.createElementNS(NS, name);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
if (attrs[k] !== null && attrs[k] !== undefined) {
e.setAttribute(k, attrs[k]);
}
});
}
if (parent) parent.appendChild(e);
return e;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
// A nominal-coordinate SVG root sized by the block's own aspect ratio --
// width always "fills the container" via width:100%, height follows from
// aspect the way every drawing block in the spec declares it.
function svgRoot(container, aspect, nominalW) {
nominalW = nominalW || 1000;
var h = Math.max(60, Math.round(nominalW * (aspect || 0.3)));
clear(container);
// `aspect-ratio` (not a bare height:auto SVG attribute -- Chrome does
// NOT reliably derive height from viewBox alone on a replaced <svg>
// element, and falls back to a tall default box, letterboxing the
// drawing) is what actually makes the rendered box track the block's
// own aspect as the container's width changes.
var svg = el('svg', {
viewBox: '0 0 ' + nominalW + ' ' + h,
style: 'display:block;width:100%;aspect-ratio:' + nominalW + '/' + h + ';background:#fff'
}, container);
return { svg: svg, W: nominalW, H: h };
}
// Text with an opaque background patch behind it (drawn AFTER the text is
// measured, so it never overlaps a crossing line or another label).
function label(svg, x, y, text, opts) {
opts = opts || {};
var t = el('text', {
x: x, y: y,
'text-anchor': opts.anchor || 'middle',
'font-size': opts.size || 15,
fill: opts.fill || '#1b1e23',
'font-family': 'inherit'
}, svg);
t.textContent = text;
if (opts.rotate) {
t.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')');
}
if (opts.bg !== false) {
var box;
try { box = t.getBBox(); } catch (e) { box = null; }
if (box) {
var pad = 2;
var rect = el('rect', {
x: box.x - pad, y: box.y - pad,
width: box.width + pad * 2, height: box.height + pad * 2,
fill: opts.bgFill || '#fff',
opacity: opts.bgOpacity === undefined ? 0.85 : opts.bgOpacity
});
svg.insertBefore(rect, t);
if (opts.rotate) {
rect.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')');
}
}
}
return t;
}
// A horizontal dimension string: line + end ticks + centred text.
// `below` puts the text under the line instead of above it.
function dimH(svg, x1, x2, y, text, opts) {
opts = opts || {};
var tick = opts.tick || 6;
var g = el('g', { class: 'dim' }, svg);
el('line', { x1: x1, y1: y - tick, x2: x1, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g);
el('line', { x1: x2, y1: y - tick, x2: x2, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g);
el('line', { x1: x1, y1: y, x2: x2, y2: y, stroke: '#8a8f98', 'stroke-width': 1 }, g);
label(g, (x1 + x2) / 2, opts.below ? y + 16 : y - 6, text, { size: opts.size || 13 });
return g;
}
// A vertical dimension string: line + end ticks + rotated centred text.
function dimV(svg, y1, y2, x, text, opts) {
opts = opts || {};
var tick = opts.tick || 6;
var g = el('g', { class: 'dim' }, svg);
el('line', { x1: x - tick, y1: y1, x2: x + tick, y2: y1, stroke: '#8a8f98', 'stroke-width': 1 }, g);
el('line', { x1: x - tick, y1: y2, x2: x + tick, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g);
el('line', { x1: x, y1: y1, x2: x, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g);
var mx = opts.left ? x - 10 : x + 10;
label(g, mx, (y1 + y2) / 2, text, { size: opts.size || 13, rotate: -90 });
return g;
}
function ftIn(v) { return window.Units.fmtFtIn(v); }
function inch(v) { return window.Units.fmtIn(v); }
// A cheap text-width estimate (no DOM measurement needed) for staggering
// labels BEFORE they are drawn -- good enough to keep a long label like
// 'Floor tie @ 13\'-2 1/2"' from being scored as if it were as narrow as
// a short one, which is what let two such labels print on top of each
// other despite `stagger()` "succeeding".
function estWidth(text, size) { return String(text).length * size * 0.58; }
// Greedy label-collision avoidance: given item extents [{x0,x1}], returns
// a stacking "level" per item (0, 1, 2, ...) such that no two items on the
// same level overlap within `gap`. Used to stagger restraint / lifter
// labels that would otherwise sit on top of one another.
function stagger(items, gap) {
gap = gap || 4;
var levels = []; // each: last x1 placed on that level
var out = new Array(items.length);
var order = items.map(function (it, i) { return i; })
.sort(function (a, b) { return items[a].x0 - items[b].x0; });
order.forEach(function (i) {
var it = items[i];
var lvl = 0;
while (lvl < levels.length && levels[lvl] > it.x0 - gap) lvl++;
levels[lvl] = it.x1;
out[i] = lvl;
});
return out;
}
// One continuous dimension chain's cells: consecutive `[lo, hi]` spans (in
// inches-from-left) starting at `railPosIn`, each labelled with the chain
// length as typed (`cs[i]`). Shared by the reinforcement plan and section
// so the two can never disagree about the chain itself (issue #23 item 9i).
function chainCellsFromRail(xs, cs, railPosIn) {
var cells = [];
var prev = railPosIn;
for (var i = 0; i < xs.length; i++) {
var x = safeNum(xs[i]);
if (x === null) x = prev;
cells.push({ lo: Math.min(prev, x), hi: Math.max(prev, x), label: cs[i] });
prev = x;
}
return cells;
}
// ------------------------------------------------------- section-at-cut
function mergeRanges(ranges) {
var clean = ranges.filter(function (r) { return r[1] > r[0]; })
.sort(function (a, b) { return a[0] - b[0]; });
var merged = [];
clean.forEach(function (r) {
if (merged.length && r[0] <= merged[merged.length - 1][1]) {
merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]);
} else {
merged.push([r[0], r[1]]);
}
});
return merged;
}
var FALLBACK_LENGTH_FT = 1.0;
var FALLBACK_WIDTH_FT = 0.3;
// Reimplements vibe.pa_sketch_extents.section_at() -- see
// docs/pa-web-spec.md section 6. Never throws.
function sectionAt(geometry, xFt) {
geometry = (geometry && typeof geometry === 'object') ? geometry : {};
var problems = [];
var lengthFt = safeNum(geometry.length_ft);
if (lengthFt === null || lengthFt <= 0) lengthFt = FALLBACK_LENGTH_FT;
var widthFt = safeNum(geometry.width_ft);
if (widthFt === null || widthFt <= 0) widthFt = FALLBACK_WIDTH_FT;
var thicknessIn = safeNum(geometry.thickness_in);
var x = safeNum(xFt);
if (x === null) {
problems.push('x_ft is not a number; using the panel midpoint.');
x = lengthFt / 2.0;
}
x = clamp(x, 0, lengthFt);
var rawRanges = [];
(geometry.openings || []).forEach(function (row) {
if (!row || typeof row !== 'object') return;
var type = String(row.type || 'opening').trim().toLowerCase();
if (type !== 'opening' && type !== '') return;
var along = row.along;
if (!along || along.length !== 2 || along[0] === null || along[1] === null) return;
var lo = along[0], hi = along[1];
if (!(lo <= x && x <= hi)) return;
var vert = row.vert;
if (!vert || vert.length !== 2 || vert[0] === null || vert[1] === null) {
problems.push('opening ' + JSON.stringify(row.label || row.index) +
' crosses this station but has no across-width position entered; ' +
'not punched into the section.');
return;
}
rawRanges.push([Number(vert[0]), Number(vert[1])]);
});
var voids = mergeRanges(rawRanges);
return { x_ft: x, width_ft: widthFt, thickness_in: thicknessIn, voids: voids, problems: problems };
}
// -------------------------------------------------------------- outline
// Renders the panel outline (Concrete Extents drawing 1) with openings
// and a draggable cut line. `cutFrac` is the initial 0..1 fraction;
// `cb.onDrag(frac)` fires continuously while dragging, `cb.onDragEnd(frac)`
// once on release -- both mouse and touch, via Pointer Events.
function renderOutline(container, geometry, cutFrac, cb, aspect) {
geometry = geometry || {};
cb = cb || {};
aspect = aspect || 0.32;
var root = svgRoot(container, aspect);
var svg = root.svg, W = root.W, H = root.H;
var lengthFt = geometry.length_ft || FALLBACK_LENGTH_FT;
var widthFt = geometry.width_ft || FALLBACK_WIDTH_FT;
var marginL = 60, marginR = 20, marginTop = 34, marginBottom = 24;
// The degraded-placeholder note sits above the overall-length dimension
// string -- give it its own row instead of sharing the dimension's, or
// the two labels print on top of each other (both centred on the same
// x, both near y=14).
if (geometry.degraded) marginTop += 16;
var drawW = W - marginL - marginR;
var drawH = H - marginTop - marginBottom;
var scale = drawW / lengthFt;
var rectH = Math.min(drawH, widthFt * scale);
var rectY = marginTop + (drawH - rectH) / 2;
function xAt(ft) { return marginL + ft * scale; }
function yAt(vertFt) { return rectY + rectH - vertFt * scale; } // vert measured from bottom
if (geometry.degraded) {
label(svg, W / 2, 14, 'No length/width entered yet -- placeholder shown', { size: 12, fill: '#b45309' });
}
// panel outline
el('rect', {
x: marginL, y: rectY, width: drawW, height: rectH,
fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5
}, svg);
// openings
(geometry.openings || []).forEach(function (op) {
if (!op.along) return;
var x0 = xAt(op.along[0]), x1 = xAt(op.along[1]);
var type = (op.type || '').toLowerCase();
if (op.vert) {
var y0 = yAt(op.vert[1]), y1 = yAt(op.vert[0]);
el('rect', {
x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0),
fill: type === 'opening' ? '#ffffff' : (type === 'reveal' ? '#eef2ff' : '#f1f5f9'),
stroke: '#334155',
'stroke-dasharray': type === 'reveal' ? '4,3' : null,
'stroke-width': 1.2
}, svg);
} else {
// no across-width position: show a dashed full-height band + note
el('rect', {
x: x0, y: rectY, width: Math.max(1, x1 - x0), height: rectH,
fill: 'none', stroke: '#b45309', 'stroke-dasharray': '3,3', 'stroke-width': 1
}, svg);
label(svg, (x0 + x1) / 2, rectY + rectH / 2, '?', { size: 11, fill: '#b45309' });
}
if (op.label) {
label(svg, (x0 + x1) / 2, (op.vert ? yAt(op.vert[1]) : rectY) - 4, op.label, { size: 10 });
}
});
// overall dimensions
dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt));
dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true });
// cut line
var cutX = marginL + clamp(cutFrac, 0, 1) * drawW;
var lineGroup = el('g', { style: 'cursor:ew-resize' }, svg);
var hit = el('rect', {
x: cutX - 8, y: rectY - 6, width: 16, height: rectH + 12,
fill: 'transparent'
}, lineGroup);
var cutLine = el('line', {
x1: cutX, y1: rectY - 6, x2: cutX, y2: rectY + rectH + 6,
stroke: '#d33', 'stroke-width': 2
}, lineGroup);
el('polygon', {
points: [cutX - 5, rectY - 6, cutX + 5, rectY - 6, cutX, rectY - 6 + 8].join(' '),
fill: '#d33'
}, lineGroup);
function moveTo(clientX) {
var box = svg.getBoundingClientRect();
var svgX = (clientX - box.left) / box.width * W;
var frac = clamp((svgX - marginL) / drawW, 0, 1);
var px = marginL + frac * drawW;
cutLine.setAttribute('x1', px); cutLine.setAttribute('x2', px);
hit.setAttribute('x', px - 8);
return frac;
}
var dragging = false;
hit.addEventListener('pointerdown', function (ev) {
dragging = true;
try { hit.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ }
ev.preventDefault();
});
hit.addEventListener('pointermove', function (ev) {
if (!dragging) return;
var frac = moveTo(ev.clientX);
if (cb.onDrag) cb.onDrag(frac);
});
function endDrag(ev) {
if (!dragging) return;
dragging = false;
var frac = moveTo(ev.clientX);
if (cb.onDragEnd) cb.onDragEnd(frac);
}
hit.addEventListener('pointerup', endDrag);
hit.addEventListener('pointercancel', endDrag);
return {
setCutFrac: function (frac) { moveTo(marginL + clamp(frac, 0, 1) * drawW +
svg.getBoundingClientRect().left); }
};
}
// -------------------------------------------------------------- section
function renderSection(container, sec, values, aspect) {
sec = sec || { width_ft: FALLBACK_WIDTH_FT, thickness_in: 8, voids: [] };
aspect = aspect || 0.16;
var root = svgRoot(container, aspect);
var svg = root.svg, W = root.W, H = root.H;
var widthFt = sec.width_ft || FALLBACK_WIDTH_FT;
var thicknessIn = sec.thickness_in || 8;
var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 46;
var drawW = W - marginL - marginR;
var drawH = H - marginTop - marginBottom;
var scale = drawW / widthFt;
var rectH = Math.min(drawH, (thicknessIn / 12) * scale * 4); // exaggerate thin strips
if (rectH < 10) rectH = 10;
var rectY = marginTop + (drawH - rectH) / 2;
function xAt(ft) { return marginL + ft * scale; }
// draw solid segments, punching voids
var voids = (sec.voids || []).slice().sort(function (a, b) { return a[0] - b[0]; });
var cursor = 0;
var segs = [];
voids.forEach(function (v) {
if (v[0] > cursor) segs.push([cursor, v[0]]);
cursor = Math.max(cursor, v[1]);
});
if (cursor < widthFt) segs.push([cursor, widthFt]);
segs.forEach(function (s) {
el('rect', {
x: xAt(s[0]), y: rectY, width: Math.max(0, xAt(s[1]) - xAt(s[0])), height: rectH,
fill: '#e2e8f0', stroke: '#1b1e23', 'stroke-width': 1.2
}, svg);
});
voids.forEach(function (v) {
label(svg, (xAt(v[0]) + xAt(v[1])) / 2, rectY + rectH / 2, 'open', { size: 10, fill: '#94a3b8' });
});
dimH(svg, marginL, marginL + drawW, rectY + rectH + 18, ftIn(widthFt), { below: true });
dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true });
label(svg, W - marginR, marginTop - 8, 'cut at ' + ftIn(sec.x_ft), { anchor: 'end', size: 11, fill: '#6b7280' });
if (sec.thickness_in && (thicknessIn / 12) * scale * 4 > drawH + 2) {
label(svg, marginL, marginTop - 8, '(vertically exaggerated)', { anchor: 'start', size: 10, fill: '#6b7280' });
}
}
// ------------------------------------------------------------ freebody
// A classic beam-style free-body diagram: the member drawn as ONE
// horizontal line regardless of its true (vertical) orientation, with
// restraints, point loads and wind-zone bands positioned along it by
// elevation. Matches the wide/short aspect every drawing block declares.
function renderFreebody(container, geom, aspect) {
geom = geom || {};
aspect = aspect || 0.24;
var root = svgRoot(container, aspect);
var svg = root.svg, W = root.W, H = root.H;
var bot = geom.bottom_elevation_ft, top = geom.top_elevation_ft;
if (bot === undefined || bot === null) bot = 0;
if (top === undefined || top === null || top <= bot) top = bot + 1;
var marginL = 40, marginR = 40, marginTop = 60, marginBottom = 70;
var drawW = W - marginL - marginR;
var lineY = marginTop + (H - marginTop - marginBottom) / 2;
var scale = drawW / (top - bot);
function xAt(elev) { return marginL + (elev - bot) * scale; }
el('line', { x1: marginL, y1: lineY, x2: marginL + drawW, y2: lineY, stroke: '#1b1e23', 'stroke-width': 3 }, svg);
dimH(svg, marginL, marginL + drawW, H - 14, ftIn(top - bot) + ' overall', { below: false, size: 11 });
// restraints, ABOVE the line (owner ruling, 2026-09-05: supports below,
// restraints above), staggered by ESTIMATED LABEL WIDTH (not a fixed
// guess) so a long "Floor tie @ 13'-2 1/2"" label never overlaps its
// neighbour the way a fixed half-width would on a tightly spaced run.
// Reserves a fixed ~34px band directly above the line; point loads
// (below) start their own arrows further up still, clear of this band.
var restraints = geom.restraints || [];
var rTexts = restraints.map(function (r) { return (r.type || '?') + ' @ ' + ftIn(r.elevation_ft); });
var rItems = restraints.map(function (r, i) {
var x = xAt(r.elevation_ft);
var w = estWidth(rTexts[i], 10);
return { x0: x - w / 2, x1: x + w / 2 };
});
var rLevels = stagger(rItems, 6);
restraints.forEach(function (r, i) {
var x = xAt(r.elevation_ft);
var y0 = lineY;
var lvl = rLevels[i];
var sym = el('g', {}, svg);
var type = (r.type || '').toLowerCase();
if (type === 'pin') {
el('polygon', { points: [x - 7, y0 - 14, x + 7, y0 - 14, x, y0].join(' '), fill: '#fff', stroke: '#1b1e23' }, sym);
} else if (type === 'fixed') {
el('line', { x1: x - 9, y1: y0 - 10, x2: x + 9, y2: y0 - 10, stroke: '#1b1e23', 'stroke-width': 3 }, sym);
for (var h = -8; h <= 8; h += 4) {
el('line', { x1: x + h, y1: y0 - 10, x2: x + h - 4, y2: y0 - 18, stroke: '#1b1e23', 'stroke-width': 1 }, sym);
}
} else if (type === 'floor tie') {
el('circle', { cx: x, cy: y0 - 10, r: 5, fill: '#fff', stroke: '#1b1e23' }, sym);
el('line', { x1: x, y1: y0, x2: x, y2: y0 - 5, stroke: '#1b1e23' }, sym);
} else { // Free / unknown
el('circle', { cx: x, cy: y0 - 8, r: 3, fill: '#94a3b8' }, sym);
}
label(svg, x, lineY - 34 - lvl * 14, (r.type || '?') + ' @ ' + ftIn(r.elevation_ft), { size: 10 });
});
// supports (bearing positions for THIS analysis), BELOW the line --
// filled triangles, apex on the line, base below, one label each.
// Owner ruling, 2026-09-05: `support` table x_ft positions, already
// converted to this drawing's elevation axis by
// `pa_sketch_freebody._support_rows` (bottom_elevation_ft + x_ft).
var supports = geom.supports || [];
var sTexts = supports.map(function (s) { return 'Support @ ' + ftIn(s.elevation_ft); });
var sItems = supports.map(function (s, i) {
var x = xAt(s.elevation_ft);
var w = estWidth(sTexts[i], 10);
return { x0: x - w / 2, x1: x + w / 2 };
});
var sLevels = stagger(sItems, 6);
supports.forEach(function (s, i) {
var x = xAt(s.elevation_ft);
var lvl = sLevels[i];
el('polygon', { points: [x - 7, lineY + 14, x + 7, lineY + 14, x, lineY].join(' '), fill: '#1b1e23', stroke: '#1b1e23' }, svg);
label(svg, x, lineY + 34 + lvl * 14, sTexts[i], { size: 10, fill: '#1b1e23' });
});
// point loads, above the line -- pushed clear of the restraint band.
var loads = geom.conc_loads || [];
var lTexts = loads.map(function (ld) {
return (ld.label || 'P') + ': D=' + (ld.dead_k == null ? '-' : ld.dead_k) +
'k L=' + (ld.live_k == null ? '-' : ld.live_k) + 'k';
});
var lItems = loads.map(function (ld, i) {
var x = xAt(ld.elevation_ft);
var w = estWidth(lTexts[i], 10);
return { x0: x - w / 2, x1: x + w / 2 };
});
var lLevels = stagger(lItems, 6);
var loadTop = restraints.length ? 60 : 40;
loads.forEach(function (ld, i) {
var x = xAt(ld.elevation_ft);
var lvl = lLevels[i];
var topY = lineY - loadTop - lvl * 26;
el('line', { x1: x, y1: topY, x2: x, y2: lineY - 4, stroke: '#2f6fed', 'stroke-width': 2, 'marker-end': null }, svg);
el('polygon', { points: [x - 4, lineY - 10, x + 4, lineY - 10, x, lineY - 2].join(' '), fill: '#2f6fed' }, svg);
label(svg, x, topY - 6, lTexts[i], { size: 10, fill: '#2f6fed' });
});
// wind zones, distributed arrows over their extent, below loads / above line
var zones = geom.wind_zones || [];
zones.forEach(function (z, zi) {
var x0 = xAt(Math.min(z.from_elev_ft, z.to_elev_ft));
var x1 = xAt(Math.max(z.from_elev_ft, z.to_elev_ft));
var y = marginTop + 10 + zi * 16;
el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#16a34a', 'stroke-width': 1.5 }, svg);
var steps = Math.max(2, Math.round((x1 - x0) / 40));
for (var s = 0; s <= steps; s++) {
var ax = x0 + (x1 - x0) * (s / steps);
el('line', { x1: ax, y1: y - 8, x2: ax, y2: y, stroke: '#16a34a', 'stroke-width': 1 }, svg);
}
var pieces = [];
if (z.pressure_psf !== null && z.pressure_psf !== undefined) pieces.push('p=' + z.pressure_psf + ' psf');
if (z.suction_psf !== null && z.suction_psf !== undefined) pieces.push('s=' + z.suction_psf + ' psf');
label(svg, (x0 + x1) / 2, y - 12, (z.label || 'Wind') + (pieces.length ? ' ' + pieces.join(', ') : '') +
(z.flagged ? ' (reversed)' : ''), { size: 10, fill: '#16a34a' });
});
if ((!restraints.length) && (!supports.length) && (!loads.length) && (!zones.length)) {
label(svg, W / 2, lineY + 20, 'Nothing to draw yet -- add restraints, supports, loads or wind zones.', { size: 11, fill: '#6b7280' });
}
}
// ------------------------------------------------------ lifter/dunnage
function renderLifterDunnage(container, geom, aspect) {
geom = geom || {};
aspect = aspect || 0.26;
var root = svgRoot(container, aspect);
var svg = root.svg, W = root.W, H = root.H;
if (geom.length_ft === null || geom.length_ft === undefined ||
geom.width_ft === null || geom.width_ft === undefined) {
label(svg, W / 2, H / 2, 'Enter length and width to see lifter positions.', { size: 12, fill: '#6b7280' });
return;
}
var lengthFt = geom.length_ft, widthFt = geom.width_ft;
var marginL = 40, marginR = 40, marginTop = 40, marginBottom = 30;
var drawW = W - marginL - marginR;
var drawH = H - marginTop - marginBottom;
var scale = drawW / lengthFt;
var rectH = Math.min(drawH, widthFt * scale);
var rectY = marginTop + (drawH - rectH) / 2;
function xAt(ft) { return marginL + ft * scale; }
function yAt(vertFt) { return rectY + rectH - vertFt * scale; }
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f8fafc', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg);
(geom.openings || []).forEach(function (op) {
if (!op.along || !op.vert) return;
el('rect', {
x: xAt(op.along[0]), y: yAt(op.vert[1]),
width: Math.max(1, xAt(op.along[1]) - xAt(op.along[0])),
height: Math.max(1, yAt(op.vert[0]) - yAt(op.vert[1])),
fill: '#ffffff', stroke: '#94a3b8', 'stroke-dasharray': '3,2'
}, svg);
});
(geom.dunnage_positions_ft || []).forEach(function (d) {
var x = xAt(d);
el('line', { x1: x, y1: rectY - 6, x2: x, y2: rectY + rectH + 6, stroke: '#a855f7', 'stroke-dasharray': '5,3', 'stroke-width': 1.5 }, svg);
});
(geom.face_lifters || []).forEach(function (fl) {
var x = xAt(fl.x_ft), y = rectY + rectH * fl.frac;
el('circle', { cx: x, cy: y, r: 6, fill: '#1b1e23' }, svg);
});
// An 'end' lifter sits at a fixed x (a panel end) and is spread across
// the width by `frac`; a 'side' lifter sits at a fixed y (one of the
// long edges, named by `side`) and is spread along the length by
// `x_ft`. Conflating the two (deciding y from `side` alone) collapsed
// every 'end' lifter at the same end onto one point, since their
// distinguishing coordinate -- `frac` -- was never read for them.
(geom.edge_lifters || []).forEach(function (el2) {
var x, y, horiz;
if (el2.kind === 'end') {
x = xAt(el2.x_ft);
y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5);
horiz = true; // marker points along the length, toward the panel interior
} else {
x = xAt(el2.x_ft);
if (el2.side === 'top') { y = rectY; }
else if (el2.side === 'bottom') { y = rectY + rectH; }
else { y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5); }
horiz = false;
}
var inward = el2.x_ft > (geom.length_ft || 0) / 2 ? -1 : 1;
var downward = (el2.side === 'top') ? 1 : (el2.side === 'bottom' ? -1 : (y < rectY + rectH / 2 ? 1 : -1));
var pts = horiz
? [x, y - 6, x, y + 6, x + inward * 10, y].join(' ')
: [x - 6, y, x + 6, y, x, y + downward * 10].join(' ');
el('polygon', { points: pts, fill: el2.ambiguous ? '#f59e0b' : '#2f6fed' }, svg);
});
if (geom.centroid && geom.centroid.x_ft !== null && geom.centroid.x_ft !== undefined &&
geom.centroid.z_ft !== null && geom.centroid.z_ft !== undefined) {
var cx = xAt(geom.centroid.x_ft), cy = yAt(geom.centroid.z_ft);
el('line', { x1: cx - 8, y1: cy, x2: cx + 8, y2: cy, stroke: '#d33', 'stroke-width': 1.5 }, svg);
el('line', { x1: cx, y1: cy - 8, x2: cx, y2: cy + 8, stroke: '#d33', 'stroke-width': 1.5 }, svg);
el('circle', { cx: cx, cy: cy, r: 9, fill: 'none', stroke: '#d33', 'stroke-width': 1 }, svg);
label(svg, cx, cy - 12, 'CG', { size: 10, fill: '#d33' });
}
dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt));
dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true });
}
// --------------------------------------------------- reinforcement.outline
// The Reinforcement page's PLAN view (issue #23 item 9i): the panel
// rectangle (length x width), strand lines along the length at their
// across-width x_in, the rail datum, and a per-group continuous dimension
// chain for strands (from the rail) and bars (from their own basis) plus a
// light hatched mesh sheet. No cut line here -- this is the plan half of
// the outline+section pair, not Concrete Extents' draggable outline.
function renderReinforcementOutline(container, geom, aspect) {
geom = geom || {};
aspect = aspect || 0.44;
var root = svgRoot(container, aspect);
var svg = root.svg, W = root.W, H = root.H;
var lengthFt = safeNum(geom.length_ft);
var widthFt = safeNum(geom.width_ft);
if (lengthFt === null || lengthFt <= 0 || widthFt === null || widthFt <= 0) {
label(svg, W / 2, H / 2, 'Enter length and width to see the reinforcement plan.', { size: 12, fill: '#6b7280' });
return;
}
var widthIn = widthFt * 12;
var marginL = 110, marginR = 28, marginTop = 40, marginBottom = 48;
var drawW = W - marginL - marginR;
var drawH = H - marginTop - marginBottom;
var scale = drawW / lengthFt;
var rectH = Math.min(drawH, widthFt * scale);
var rectY = marginTop + (drawH - rectH) / 2;
function xAt(ft) { return marginL + ft * scale; }
function yAtIn(inches) { return rectY + rectH - (inches / 12) * scale; }
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg);
dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt));
dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true });
var rail = geom.rail;
var strands = geom.strands || [];
var bars = geom.bars || [];
var mesh = geom.mesh;
// Strands -- thin lines along the length at their across-width position
// x_in (inches from the panel's left edge). A row with no resolved x_in
// spreads evenly across the width, the same fallback the section uses.
strands.forEach(function (s) {
var count = (s.count && s.count > 0) ? s.count : 1;
var xs = s.x_in;
if (xs && xs.length) {
xs.forEach(function (x) {
el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#1b1e23', 'stroke-width': 1.4 }, svg);
});
} else {
for (var i = 0; i < count; i++) {
var x = widthIn * (i + 1) / (count + 1);
el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '3,3' }, svg);
}
}
});
// Rail -- a dashed line at the rail side, offset by rail.offset_in.
if (rail && rail.location) {
var railPos = rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0);
railPos = Math.max(0, Math.min(widthIn, railPos));
var ry = yAtIn(railPos);
el('line', { x1: marginL, y1: ry, x2: marginL + drawW, y2: ry, stroke: '#b45309', 'stroke-width': 1.6, 'stroke-dasharray': '6,4' }, svg);
label(svg, marginL + drawW + 4, ry - 4, 'rail (' + rail.location + ')', { anchor: 'start', size: 9, fill: '#b45309' });
}
// Strand group chain -- vertical, from the rail outward, one cell per
// chain_in length, on its own column left of the rectangle.
var chainRow = null;
for (var i = 0; i < strands.length; i++) {
if (strands[i] && strands[i].chain_in && strands[i].chain_in.length &&
strands[i].x_in && strands[i].x_in.length) { chainRow = strands[i]; break; }
}
var strandChainX = marginL - 42;
if (chainRow && rail && rail.location) {
label(svg, strandChainX, rectY - 6, 'strands', { anchor: 'middle', size: 10, fill: '#1b1e23' });
var cells = chainCellsFromRail(chainRow.x_in, chainRow.chain_in,
rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0));
cells.forEach(function (c) {
dimV(svg, yAtIn(c.hi), yAtIn(c.lo), strandChainX, inch(c.label), { size: 9 });
});
}
// Bars -- longitudinal bars run along the length (horizontal lines at
// their depth_in); transverse bars run across the width (vertical lines
// at their from_ft station).
var longitBars = [], transBars = [];
bars.forEach(function (b) {
if (b.depth_in === null || b.depth_in === undefined) return;
if ((b.direction || '').toLowerCase().indexOf('trans') === 0) transBars.push(b);
else longitBars.push(b);
});
longitBars.forEach(function (b) {
var y = yAtIn(b.depth_in);
var x0 = (b.from_ft !== null && b.from_ft !== undefined) ? xAt(b.from_ft) : marginL;
var x1 = (b.to_ft !== null && b.to_ft !== undefined) ? xAt(b.to_ft) : marginL + drawW;
el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#2f6fed', 'stroke-width': 1.4 }, svg);
});
transBars.forEach(function (b) {
var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft
: ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2);
el('line', { x1: xAt(xv), y1: rectY, x2: xAt(xv), y2: rectY + rectH, stroke: '#2f6fed', 'stroke-width': 1.2, 'stroke-dasharray': '5,3' }, svg);
});
// Bar group chain -- from each bar's own reference. Longitudinal bars
// measure their depth up from the panel's bottom edge (a vertical chain
// on its own column); transverse bars measure their station along the
// length (a horizontal chain stacked below the rectangle).
var barChainX = marginL - 74;
if (longitBars.length) {
label(svg, barChainX, rectY - 6, 'bars', { anchor: 'middle', size: 10, fill: '#2f6fed' });
}
longitBars.forEach(function (b) {
dimV(svg, yAtIn(b.depth_in), rectY + rectH, barChainX, (b.size || 'bar') + ' longit.', { size: 9 });
});
transBars.forEach(function (b, ti) {
var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft
: ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2);
var by = rectY + rectH + 20 + ti * 16;
if (ti === 0 && !longitBars.length) {
label(svg, marginL, by - 4, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' });
}
dimH(svg, marginL, xAt(xv), by, (b.size || 'bar') + ' transv.', { size: 9, below: true });
});
// Mesh -- a light hatched sheet over the panel, labelled once.
if (mesh && mesh.designation) {
var pid = 'reinout-hatch';
var defs = el('defs', {}, svg);
var pat = el('pattern', { id: pid, patternUnits: 'userSpaceOnUse', width: 7, height: 7 }, defs);
el('path', { d: 'M0 7 L7 0', stroke: '#94a3b8', 'stroke-width': 0.8 }, pat);
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: 'url(#' + pid + ')', opacity: 0.22 }, svg);
var meshText = 'mesh: ' + mesh.designation;
if (mesh.layers !== null && mesh.layers !== undefined) meshText += ' \u00d7 ' + mesh.layers;
if (mesh.depth_in !== null && mesh.depth_in !== undefined) meshText += ' @ ' + inch(mesh.depth_in);
label(svg, marginL + 6, rectY + 14, meshText, { anchor: 'start', size: 9, fill: '#334155' });
}
}
// --------------------------------------------------- reinforcement.section
function renderReinforcementSection(container, geom, aspect) {
geom = geom || {};
aspect = aspect || 0.28;
var root = svgRoot(container, aspect);
var svg = root.svg, W = root.W, H = root.H;
var widthFt = geom.width_ft || FALLBACK_WIDTH_FT;
var thicknessIn = geom.thickness_in || 8;
var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 128;
var drawW = W - marginL - marginR;
var drawH = H - marginTop - marginBottom;
var scaleX = drawW / widthFt;
var exaggerate = Math.max(1, Math.min(6, drawH / ((thicknessIn / 12) * scaleX || 1)));
var rectH = (thicknessIn / 12) * scaleX * exaggerate;
if (rectH > drawH) { exaggerate *= drawH / rectH; rectH = drawH; }
var rectY = marginTop + (drawH - rectH) / 2;
function xAt(ft) { return marginL + ft * scaleX; }
function xAtIn(inches) { return marginL + (inches / 12) * scaleX; }
function yAtDepth(depthIn) { return rectY + (depthIn / 12) * scaleX * exaggerate; }
el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg);
// strands: filled circles, x from strand_x_in flattened in strand order
var xs = geom.strand_x_in;
var flatIdx = 0;
var strandItems = [];
(geom.strands || []).forEach(function (s) {
for (var i = 0; i < (s.count || 0); i++) {
var xIn;
if (xs && !geom.used_fallback_strand_x && xs.length > flatIdx) {
xIn = xs[flatIdx];
} else {
xIn = null; // filled in after we know the total count
}
strandItems.push({ xIn: xIn, depth: s.depth_in, dia: s.dia_in, id: s.id });
flatIdx++;
}
});
if (geom.used_fallback_strand_x || !xs) {
var n = strandItems.length;
strandItems.forEach(function (it, i) {
it.xIn = n > 1 ? (widthFt * 12) * (i + 0.5) / n : (widthFt * 12) / 2;
});
}
strandItems.forEach(function (it) {
var x = xAt(it.xIn / 12), y = yAtDepth(it.depth);
el('circle', { cx: x, cy: y, r: 5, fill: '#1b1e23' }, svg);
});
if (geom.used_fallback_strand_x) {
label(svg, marginL, rectY + rectH + 112, '(strand positions spread evenly -- no explicit across-width layout entered)', { anchor: 'start', size: 10, fill: '#6b7280' });
}
// mild bars -- owner ruling 2026-09-05, matching `pa_sketch_reinf.py`'s
// own (already-correct) WPF picture: a LONGITUDINAL bar runs along the
// panel's LENGTH, so this cross-section (cut across the WIDTH) sees it
// END-ON -- one open circle per bar, at its own across-width position
// (the row's own `spacing_in` pitch when it is a real number, else
// spread evenly). A TRANSVERSE bar runs ACROSS the width, so the same
// cut sees it ALONG its own length -- one dashed line spanning the
// full section width at its depth, labelled once (e.g. '#4 @ 19"
// transv.'), never a row of circles the way this used to draw it
// (backwards from the physical picture -- see the fix's own note).
// Two bars whose depths sit within a fraction of an inch of each other
// (common: a top mat and a bottom mat of two different sizes) can land
// within a few px once exaggerated -- cascade the label sideways in
// that case, computed on DEPTH-SORTED groups (never original array
// order, which can interleave the two directions and miss an adjacent
// pair, or falsely flag two non-adjacent ones) and kept separate per
// direction since they print on opposite sides of the strip.
var allBars = geom.bars || [];
function isTransverse(b) { return (b.direction || '').toLowerCase().indexOf('trans') === 0; }
function cascadeOffsets(list) {
var withY = list.map(function (b) { return { id: b.id, y: yAtDepth(b.depth_in) }; })
.sort(function (a, b) { return a.y - b.y; });
var offsets = {}; var lastY = null, c = 0;
withY.forEach(function (it) {
c = (lastY !== null && Math.abs(it.y - lastY) < 11) ? c + 1 : 0;
lastY = it.y;
offsets[it.id] = c * 34;
});
return offsets;
}
var transverseOffsets = cascadeOffsets(allBars.filter(isTransverse));
var longitOffsets = cascadeOffsets(allBars.filter(function (b) { return !isTransverse(b); }));
allBars.forEach(function (b) {
var y = yAtDepth(b.depth_in);
if (isTransverse(b)) {
el('line', { x1: marginL + 4, y1: y, x2: marginL + drawW - 4, y2: y, stroke: '#2f6fed', 'stroke-width': 1, 'stroke-dasharray': '6,3' }, svg);
var spacingText = b.spacing_in ? ' @ ' + Math.round(b.spacing_in * 100) / 100 + '"' : '';
label(svg, marginL + drawW - 6 - (transverseOffsets[b.id] || 0), y - 4, (b.size || '') + spacingText + ' transv.', { anchor: 'end', size: 9, fill: '#2f6fed' });
} else {
var count = b.count && b.count > 0 ? b.count : 1;
var dotCount = Math.min(count, 20);
var xs = [];
var innerW = drawW - 8;
if (b.spacing_in && b.spacing_in > 0) {
var pitchPx = (b.spacing_in / 12) * scaleX;
for (var x = marginL + 4 + pitchPx; x < marginL + 4 + innerW && xs.length < dotCount; x += pitchPx) xs.push(x);
}
if (!xs.length) {
for (var i = 0; i < dotCount; i++) xs.push(marginL + 4 + innerW * (i + 1) / (dotCount + 1));
}
xs.forEach(function (cx) {
el('circle', { cx: cx, cy: y, r: 4, fill: '#fff', stroke: '#2f6fed', 'stroke-width': 1.4 }, svg);
});
label(svg, marginL + 4 + (longitOffsets[b.id] || 0), y - 6, (b.size || '') + ' longit.', { anchor: 'start', size: 9, fill: '#2f6fed' });
}
});
// Per-group dimension chains along the section's width (issue #23 item
// 9i): strands from the rail, bars from their own basis, stacked on
// their own rows below the overall width dimension so they never overlap.
var widthInSec = widthFt * 12;
var chainRow = null;
for (var ci = 0; ci < (geom.strands || []).length; ci++) {
var sr = geom.strands[ci];
if (sr && sr.chain_in && sr.chain_in.length && sr.x_in && sr.x_in.length) {
chainRow = sr;
break;
}
}
if (chainRow) {
var cxs = chainRow.x_in, ccs = chainRow.chain_in;
var railPos = (cxs[0] <= cxs[cxs.length - 1])
? (cxs[0] - ccs[0]) : (cxs[0] + ccs[0]);
railPos = Math.max(0, Math.min(widthInSec, railPos));
label(svg, marginL, rectY + rectH + 44, 'strands', { anchor: 'start', size: 10, fill: '#1b1e23' });
chainCellsFromRail(cxs, ccs, railPos).forEach(function (c) {
dimH(svg, xAtIn(c.lo), xAtIn(c.hi), rectY + rectH + 48, inch(c.label), { size: 9, below: true });
});
}
var longitBarRows = allBars.filter(function (b) { return !isTransverse(b); });
var transBarRows = allBars.filter(isTransverse);
var barChainY = rectY + rectH + 74;
var barRow = 0;
if (longitBarRows.length || transBarRows.length) {
label(svg, marginL, barChainY, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' });
}
longitBarRows.forEach(function (b) {
if (!b.spacing_in || b.spacing_in <= 0) return;
var x1 = Math.min(marginL + drawW, xAtIn(b.spacing_in));
dimH(svg, marginL, x1, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' longit.', { size: 9, below: true });
barRow += 1;
});
transBarRows.forEach(function (b) {
dimH(svg, marginL, marginL + drawW, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' transv.', { size: 9, below: true });
barRow += 1;
});
dimH(svg, marginL, marginL + drawW, rectY + rectH + 16, ftIn(widthFt), { below: true });
dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true });
if (exaggerate > 1.05) {
label(svg, marginL, marginTop - 8, '(depth exaggerated ' + exaggerate.toFixed(1) + '×)', { anchor: 'start', size: 10, fill: '#6b7280' });
}
label(svg, marginL + drawW, marginTop - 8, '● strand ○ bar', { anchor: 'end', size: 10, fill: '#6b7280' });
}
window.Draw = {
sectionAt: sectionAt,
renderOutline: renderOutline,
renderSection: renderSection,
renderFreebody: renderFreebody,
renderLifterDunnage: renderLifterDunnage,
renderReinforcementOutline: renderReinforcementOutline,
renderReinforcementSection: renderReinforcementSection,
_stagger: stagger,
svgRoot: svgRoot,
label: label,
dimH: dimH,
dimV: dimV,
ftIn: ftIn,
inch: inch,
el: el,
clear: clear,
safeNum: safeNum,
clamp: clamp,
mergeRanges: mergeRanges,
estWidth: estWidth
};
})();
+19
View File
@@ -0,0 +1,19 @@
<meta charset="utf-8">
<title>Panel Analysis</title>
<link rel="stylesheet" href="willtools.css">
<div class="app-shell">
<div class="toolbar">
<div class="toolbar-title">Panel Analysis<span id="pa-mark" class="pa-mark"></span></div>
<div class="toolbar-spacer"></div>
<button id="btn-run" class="btn btn-primary">Run</button>
<button id="btn-close" class="btn">Close</button>
</div>
<div id="banner" class="banner" hidden></div>
<div id="spec-problems" class="spec-problems" hidden></div>
<div class="tabs" id="tabs"></div>
<div class="pages" id="pages"></div>
</div>
<pre id="errors" hidden></pre>
<script src="units.js"></script>
<script src="draw.js"></script>
<script src="app.js"></script>
+246
View File
@@ -0,0 +1,246 @@
// Feet-inches formatting and parsing -- the browser twin of
// `panellift/src/panellift/units.py`. Same rules, same rounding, same
// accepted input forms; kept as a byte-for-byte port on purpose so a value
// typed on the page and the same value printed by the CPython report never
// disagree. See units.py's own module docstring for the owner's ruling this
// exists to satisfy (2026-09-05: every length on any front-end or results
// page is feet-inches, rounded to the nearest 1/8", never a decimal).
//
// Plain ES2017, no modules -- loaded with a bare <script src="units.js">
// the way the rest of this page's vanilla JS is (see EDIT_JS in
// tools/panelanalysis.py). Exposes window.Units = {fmtFtIn, fmtIn,
// parseLength, parseLengthIn}.
(function () {
'use strict';
function gcd(a, b) {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
var t = b;
b = a % b;
a = t;
}
return a || 1;
}
// A reduced "n/d" fraction string, mirroring Python's `fractions.Fraction`
// reduction (e.g. 4/8 -> "1/2").
function fracString(num, den) {
var g = gcd(num, den);
return (num / g) + '/' + (den / g);
}
// Decimal feet -> "8'-4 1/2\"", rounded to the nearest 1/precision inch.
// null/undefined/NaN -> '-'. Negative values keep the sign in front of the
// whole string ("-0'-6\""), never inside it. A remainder that rounds up to
// a full inch carries into the inch count, and 12 inches carries into the
// foot count.
function fmtFtIn(feet, precision) {
precision = precision || 8;
if (feet === null || feet === undefined || isNaN(feet)) {
return '-';
}
var sign = feet < 0 ? '-' : '';
var value = Math.abs(feet);
var wholeFt = Math.floor(value);
// work in units of 1/precision inch so the carry logic is exact integer
// arithmetic, never a float comparison against 12.0 or 1.0.
var unitsTotal = Math.round((value - wholeFt) * 12.0 * precision);
var inch = Math.floor(unitsTotal / precision);
var rem = unitsTotal - inch * precision;
if (inch >= 12) {
wholeFt += Math.floor(inch / 12);
inch = inch % 12;
}
var out = wholeFt + "'-" + inch;
if (rem) {
out += ' ' + fracString(rem, precision);
}
return sign + out + '"';
}
// Decimal inches -> "6 3/8\"" -- for thicknesses, depths, offsets,
// diameters and anything else already spoken of in inches, never feet.
// null/undefined/NaN -> '-'.
function fmtIn(inches, precision) {
precision = precision || 8;
if (inches === null || inches === undefined || isNaN(inches)) {
return '-';
}
var sign = inches < 0 ? '-' : '';
var value = Math.abs(inches);
var wholeIn = Math.floor(value);
var units = Math.round((value - wholeIn) * precision);
if (units >= precision) {
wholeIn += 1;
units = 0;
}
var out = '' + wholeIn;
if (units) {
out += ' ' + fracString(units, precision);
}
return sign + out + '"';
}
// ------------------------------------------------------------ parsing
// A leading sign, held separately from the feet/inch markers below because
// a hyphen INSIDE the string (the "8'-4" separator, not a minus) must
// never be read as negation -- only a hyphen before the first digit is a
// sign.
var SIGN_RE = /^-\s*/;
// "8", optionally decimal, followed by a foot mark, followed by whatever
// is left (which may be empty, or an inch remainder with its own quote).
var FEET_RE = /^(\d+(?:\.\d+)?)\s*'\s*([\s\S]*)$/;
// A hyphen used as the visual separator between a whole inch and a
// fraction ("4-1/2") rather than as a sign -- only recognised with a digit
// already on its left, so a leading "-" (the "8'-6\"" separator right
// after the foot mark) is stripped as its own case, not caught here.
var HYPHEN_FRAC_RE = /(\d)\s*-\s*(\d+\s*\/\s*\d+)/g;
var MIXED_RE = /^(\d+)\s+(\d+)\s*\/\s*(\d+)$/;
var FRAC_RE = /^(\d+)\s*\/\s*(\d+)$/;
var DEC_RE = /^(\d+(?:\.\d+)?)$/;
function badLength(orig) {
return new Error('not a length: ' + orig);
}
// num/den, or badLength(orig) for a 0 denominator -- a typed "1/0" is bad
// input, not a licence to silently hand back Infinity/NaN the way plain
// JS division would.
function safeDiv(num, den, orig) {
if (den === 0) {
throw badLength(orig);
}
return num / den;
}
function normalizeQuotes(s) {
return s.replace(/’/g, "'").replace(/‘/g, "'")
.replace(/”/g, '"').replace(/“/g, '"');
}
// Parse what follows a foot mark (or the whole string, for a bare inch
// value): an optional leading '-' separator, a whole number and/or a
// fraction, an optional trailing '"'. Returns inches as a number.
function parseInchRemainder(rest, orig, allowEmpty) {
rest = rest.trim();
if (rest.slice(-1) === '"') {
rest = rest.slice(0, -1).trim();
}
if (rest.slice(0, 1) === '-') {
// the "8'-6" separator, not a sign -- the sign (if any) was already
// consumed before the foot mark was even found.
rest = rest.slice(1).trim();
}
if (rest === '') {
if (allowEmpty) {
return 0.0;
}
throw badLength(orig);
}
rest = rest.replace(HYPHEN_FRAC_RE, '$1 $2');
var m = MIXED_RE.exec(rest);
if (m) {
return parseFloat(m[1]) +
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
}
m = FRAC_RE.exec(rest);
if (m) {
return safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
}
m = DEC_RE.exec(rest);
if (m) {
return parseFloat(m[1]);
}
throw badLength(orig);
}
// No `'` and no trailing `"` anywhere -- a plain number, or a bare
// fraction / mixed number, read in `defaultUnit`.
function parseBareNumber(s, orig, defaultUnit) {
var value;
var m = DEC_RE.exec(s);
if (m) {
value = parseFloat(m[1]);
} else {
var s2 = s.replace(HYPHEN_FRAC_RE, '$1 $2');
m = MIXED_RE.exec(s2);
if (m) {
value = parseFloat(m[1]) +
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
} else {
m = FRAC_RE.exec(s2);
if (m) {
value = safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
} else {
throw badLength(orig);
}
}
}
return defaultUnit === 'ft' ? value * 12.0 : value;
}
// The shared parser. Returns inches, given how a bare unmarked number
// should be read ('ft' or 'in').
function parseCoreIn(text, defaultUnit) {
if (text === null || text === undefined) {
throw badLength(text);
}
if (typeof text === 'number') {
return defaultUnit === 'ft' ? text * 12.0 : text;
}
var orig = String(text);
var s = orig.trim();
if (s === '') {
throw badLength(orig);
}
s = normalizeQuotes(s);
var negative = false;
var signMatch = SIGN_RE.exec(s);
if (signMatch) {
negative = true;
s = s.slice(signMatch[0].length).trim();
}
if (s === '') {
throw badLength(orig);
}
var totalIn;
var feetMatch = FEET_RE.exec(s);
if (feetMatch) {
var feet = parseFloat(feetMatch[1]);
totalIn = feet * 12.0 + parseInchRemainder(feetMatch[2], orig, true);
} else if (s.slice(-1) === '"') {
totalIn = parseInchRemainder(s, orig, false);
} else {
totalIn = parseBareNumber(s, orig, defaultUnit);
}
return negative ? -totalIn : totalIn;
}
// Parse any of the accepted forms and return inches. A bare number with
// no `'` or `"` anywhere is read in `defaultUnit` ('in' by default here).
function parseLengthIn(text, defaultUnit) {
return parseCoreIn(text, defaultUnit || 'in');
}
// Parse any of the accepted forms and return decimal feet. A bare number
// with no `'` or `"` anywhere is read in `defaultUnit` ('ft' by default,
// per the owner's ruling).
function parseLength(text, defaultUnit) {
return parseCoreIn(text, defaultUnit || 'ft') / 12.0;
}
window.Units = {
fmtFtIn: fmtFtIn,
fmtIn: fmtIn,
parseLength: parseLength,
parseLengthIn: parseLengthIn
};
})();
+475
View File
@@ -0,0 +1,475 @@
/* WillTools shared front-end look -- Panel Analysis's new 前端 today, the
shell every future tool (Beam Analysis, Connection Analysis) and every
results page adopts next. Reference: Eriksson Wall's own input dialogs --
plain engineering software, dense but tidy. Light theme, one accent
colour, system font stack, no build step, no framework. */
:root {
--bg: #f3f4f6;
--panel-bg: #ffffff;
--border: #d7dae0;
--border-strong: #b7bcc6;
--text: #1b1e23;
--text-muted: #6b7280;
--text-faint: #9aa1ac;
--accent: #2f6fed;
--accent-dark: #1f56c9;
--accent-contrast: #ffffff;
--error: #d33333;
--error-bg: #fdecec;
--warn: #b45309;
--warn-bg: #fff7ed;
--row-stripe: #fafbfc;
--row-hover: #eef2ff;
--radius: 4px;
--focus-ring: 0 0 0 3px rgba(47, 111, 237, 0.28);
color-scheme: light;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
line-height: 1.45;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
/* --------------------------------------------------------------- layout */
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
}
.pa-mark {
font-weight: 400;
color: var(--text-muted);
margin-left: 6px;
}
.toolbar-spacer { flex: 1 1 auto; }
.btn {
appearance: none;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text);
border-radius: var(--radius);
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
}
.btn:hover { background: #f4f5f7; }
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.btn-primary {
background: var(--accent);
border-color: var(--accent-dark);
color: var(--accent-contrast);
font-weight: 600;
}
.btn-primary:hover { background: var(--accent-dark); }
.banner {
flex: 0 0 auto;
background: var(--error-bg);
color: var(--error);
border-bottom: 1px solid #f3c2c2;
padding: 8px 14px;
font-size: 12.5px;
}
.banner[hidden] { display: none; }
/* Two faces, never alike: amber = input still needed (the user's to fill),
red = something broke (a bug). The bare `.banner` is red so an
unlabelled failure is loud, never calm. */
.banner.banner--input {
background: var(--warn-bg);
color: var(--warn);
border-bottom-color: #f2d9b6;
}
.banner.banner--crash {
background: var(--error-bg);
color: var(--error);
border-bottom-color: #f3c2c2;
}
.spec-problems {
flex: 0 0 auto;
background: var(--warn-bg);
color: var(--warn);
border-bottom: 1px solid #f2d9b6;
padding: 6px 14px;
font-size: 12px;
}
.tabs {
display: flex;
gap: 2px;
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
padding: 0 10px;
flex: 0 0 auto;
overflow-x: auto;
}
.tab {
appearance: none;
border: none;
background: transparent;
padding: 10px 14px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 3px solid transparent;
white-space: nowrap;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--text);
font-weight: 600;
border-bottom-color: var(--accent);
}
.pages {
flex: 1 1 auto;
overflow-y: auto;
background: var(--bg);
}
.page-panel {
display: none;
padding: 18px 20px 60px;
max-width: 1180px;
margin: 0 auto;
}
.page-panel.active { display: block; }
/* -------------------------------------------------------------- headings */
h1.pa-h, h2.pa-h, h3.pa-h {
margin: 18px 0 8px;
font-weight: 600;
color: var(--text);
}
h1.pa-h { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
h2.pa-h { font-size: 14.5px; margin-top: 20px; }
h3.pa-h { font-size: 13.5px; color: var(--text-muted); }
hr.pa-rule {
border: none;
border-top: 1px solid var(--border);
margin: 14px 0;
}
.pa-space { display: block; }
/* -------------------------------------------------------------- columns */
.pa-columns {
display: flex;
gap: 22px;
align-items: flex-start;
}
.pa-col {
flex: 1 1 0;
min-width: 0;
}
/* ---------------------------------------------------------------- field */
.field {
margin-bottom: 10px;
max-width: 420px;
}
.field.full-width { max-width: none; }
.field-label-row {
display: flex;
align-items: baseline;
gap: 5px;
margin-bottom: 3px;
}
.field-label {
font-size: 12.5px;
color: var(--text-muted);
}
.field.required .field-label::after {
content: " *";
color: var(--error);
}
.field-symbol {
font-size: 11px;
color: var(--text-faint);
}
.field-input {
width: 100%;
padding: 5px 7px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
}
.field-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
textarea.field-input { resize: vertical; min-height: 44px; }
.field-input.has-error {
border-color: var(--error);
background: var(--error-bg);
}
.field-error-msg {
color: var(--error);
font-size: 11px;
margin-top: 2px;
}
.field-check-row {
display: flex;
align-items: center;
gap: 6px;
}
.field-check-row .field-label { margin: 0; }
/* "not used by the solve" (docs/pa-web-spec.md, unused_by_solve) --
collected only; muted so it can never pass for a live input */
.unused-by-solve .field-input,
tr.unused-by-solve .field-input { opacity: 0.55; }
.unused-by-solve .field-label,
tr.unused-by-solve td:first-child { color: var(--text-muted); }
.unused-tag {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
border: 1px dashed var(--text-faint);
border-radius: 3px;
color: var(--text-muted);
font-size: 10px;
line-height: 15px;
white-space: nowrap;
}
/* row-table empty state + table-level error (issue #22) */
.table-empty-state {
color: var(--text-muted);
font-size: 12px;
font-style: italic;
margin: 4px 0;
}
.editable-table-wrap.has-error table.pa-editable {
outline: 2px solid var(--error);
background: var(--error-bg);
}
.table-error-msg {
color: var(--error);
font-size: 11px;
margin: 2px 0;
}
/* provenance */
.prov-icon {
display: inline-block;
width: 13px;
height: 13px;
line-height: 13px;
text-align: center;
border-radius: 50%;
font-size: 10px;
color: var(--accent);
cursor: help;
border: 1px solid var(--accent);
}
/* ------------------------------------------------------------ sentence */
.sentence { margin: 6px 0; color: var(--text); }
.sentence.emph { font-weight: 600; }
/* ------------------------------------------------------------------ kv */
table.kv-table { border-collapse: collapse; margin: 6px 0 12px; }
table.kv-table td { padding: 3px 10px 3px 0; vertical-align: top; font-size: 12.5px; }
table.kv-table td:first-child { color: var(--text-muted); white-space: nowrap; }
/* --------------------------------------------------------------- tables */
table.pa-table, table.pa-editable, table.pa-matrix, table.pa-lc {
border-collapse: collapse;
width: 100%;
margin: 6px 0 14px;
font-size: 12.5px;
}
table.pa-table th, table.pa-table td,
table.pa-editable th, table.pa-editable td,
table.pa-matrix th, table.pa-matrix td,
table.pa-lc th, table.pa-lc td {
border: 1px solid var(--border);
padding: 4px 6px;
text-align: left;
vertical-align: middle;
}
table.pa-table th, table.pa-editable th, table.pa-matrix th, table.pa-lc th {
background: #eef1f5;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
table.pa-table tbody tr:nth-child(even),
table.pa-editable tbody tr:nth-child(even) {
background: var(--row-stripe);
}
table.pa-table td input.field-input,
table.pa-editable td input.field-input,
table.pa-editable td select.field-input,
table.pa-matrix td input.field-input,
table.pa-matrix td select.field-input,
table.pa-lc td input.field-input {
padding: 3px 5px;
min-width: 44px;
}
table.pa-matrix td.na-cell {
text-align: center;
color: var(--text-faint);
}
table.pa-matrix th, table.pa-matrix td:first-child {
white-space: normal;
}
td.row-remove { text-align: center; width: 28px; }
.row-remove-btn, .row-check {
cursor: pointer;
color: var(--text-muted);
border: none;
background: none;
font-size: 14px;
}
.row-remove-btn:hover { color: var(--error); }
.add-row-btn {
margin: 2px 0 14px;
}
/* -------------------------------------------------------------- legend */
.legend {
display: flex;
gap: 18px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-muted);
margin: 4px 0 10px;
}
.legend-group-title { font-weight: 600; margin-right: 6px; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.legend-glyph { font-size: 13px; }
/* ------------------------------------------------------------- drawing */
.drawing-block {
margin: 10px 0 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-bg);
padding: 8px 10px;
}
.drawing-caption {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.drawing-host { width: 100%; }
.drawing-problems {
font-size: 11px;
color: var(--warn);
margin-top: 4px;
}
.drawing-problems:empty { display: none; }
/* -------------------------------------------------------------- details */
details.pa-section {
margin: 10px 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-bg);
}
details.pa-section > summary {
cursor: pointer;
padding: 8px 10px;
font-weight: 600;
font-size: 13px;
list-style: none;
}
details.pa-section > summary::-webkit-details-marker { display: none; }
details.pa-section > summary::before {
content: "▸ ";
color: var(--text-muted);
}
details.pa-section[open] > summary::before { content: "▾ "; }
details.pa-section > .pa-section-body { padding: 4px 12px 12px; }
details.pa-notes {
margin: 20px 0 4px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
background: #fbfbfc;
}
details.pa-notes > summary {
cursor: pointer;
padding: 7px 10px;
color: var(--text-muted);
font-size: 12.5px;
list-style: none;
}
details.pa-notes > summary::-webkit-details-marker { display: none; }
.pa-notes-body { padding: 2px 14px 12px; font-size: 12px; color: var(--text-muted); }
.pa-notes-body h4 { margin: 8px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; color: var(--text-faint); }
.pa-notes-body ul { margin: 0 0 6px; padding-left: 18px; }
.pa-notes-body li { margin-bottom: 3px; }
.unknown-block {
background: #fff3cd;
border: 1px solid #ffe08a;
border-radius: var(--radius);
padding: 6px 10px;
font-size: 12px;
margin: 6px 0;
}
#errors {
position: fixed;
left: 0; right: 0; bottom: 0;
max-height: 30vh;
overflow: auto;
margin: 0;
background: #2b0000;
color: #ffd9d9;
font-size: 11px;
padding: 8px 10px;
z-index: 999;
white-space: pre-wrap;
}
#errors[hidden] { display: none; }
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Build a deployable zip of this dev folder.
python tools/package.py -> dist/willtools-<version>-<date>.zip
python tools/package.py --out somewhere.zip
Runs on the host CPython, never inside Revit, so modern syntax is fine here.
What ships is the extension, the bridge client, and the docs. What does not
ship is local state: session logs, caches, editor settings, and anything under
a model's `_ticketqc` folder -- specs belong to their model, not to the tool.
The zip unpacks to a single `pyrevit-dev/` folder, which is exactly the folder
you point pyRevit's custom-extension setting at.
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import os
import subprocess
import sys
import zipfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TOP = 'pyrevit-dev'
# Included verbatim, in this order. A directory brings its whole tree.
INCLUDE = [
'INSTALL.md',
'CLAUDE.md',
'docs',
'knowledge',
'tools',
'panellift/src',
'WillTools.extension',
]
EXCLUDE_DIRS = {'__pycache__', '.git', '.claude', '.vscode', 'dist',
'_ticketqc', 'scratch'}
EXCLUDE_EXTS = {'.pyc', '.pyo', '.log'}
EXCLUDE_NAMES = {'.bridge-sessions.log', '.DS_Store', 'Thumbs.db'}
def version() -> str:
with open(os.path.join(ROOT, 'WillTools.extension', 'extension.json'),
encoding='utf-8') as fh:
return json.load(fh).get('version', '0.0.0')
def wanted(path: str) -> bool:
name = os.path.basename(path)
if name in EXCLUDE_NAMES:
return False
if os.path.splitext(name)[1].lower() in EXCLUDE_EXTS:
return False
parts = os.path.relpath(path, ROOT).split(os.sep)
return not any(part in EXCLUDE_DIRS for part in parts)
def collect() -> list[str]:
"""Absolute paths of everything that ships, sorted for a stable zip."""
found = []
for entry in INCLUDE:
full = os.path.join(ROOT, entry)
if os.path.isfile(full):
if wanted(full):
found.append(full)
elif os.path.isdir(full):
for folder, dirs, files in os.walk(full):
dirs[:] = sorted(d for d in dirs if d not in EXCLUDE_DIRS)
for f in sorted(files):
path = os.path.join(folder, f)
if wanted(path):
found.append(path)
else:
print(' missing, skipped: {}'.format(entry), file=sys.stderr)
return found
def manifest(paths: list[str], ver: str) -> str:
"""A listing with hashes, so a deployed copy can be checked against source."""
lines = [
'WillTools {}'.format(ver),
'built {}'.format(datetime.datetime.now().strftime('%Y-%m-%d %H:%M')),
'developed on Revit 2025.4 / pyRevit 6.5.3 / IronPython 2.7.12',
'see INSTALL.md',
'',
'{:<8} {:>8} {}'.format('sha256', 'bytes', 'file'),
]
for path in paths:
with open(path, 'rb') as fh:
blob = fh.read()
lines.append('{} {:>8} {}'.format(
hashlib.sha256(blob).hexdigest()[:8], len(blob),
os.path.relpath(path, ROOT).replace(os.sep, '/')))
return '\n'.join(lines) + '\n'
def build(out: str) -> str:
ver = version()
paths = collect()
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zf:
for path in paths:
arc = os.path.join(TOP, os.path.relpath(path, ROOT))
zf.write(path, arc.replace(os.sep, '/'))
zf.writestr(TOP + '/MANIFEST.txt', manifest(paths, ver))
return out
def scrub_gate(force: bool) -> bool:
"""Refuse to build a zip carrying a client name. See tools/scrub.py.
A package is the form the work takes when it leaves this machine, so it is
the right place to fail closed. --force exists because a false positive
should not block a build outright, but it has to be typed on purpose.
"""
result = subprocess.run(
[sys.executable, os.path.join(ROOT, 'tools', 'scrub.py'), '--worktree'],
cwd=ROOT, check=False, capture_output=True, text=True, errors='replace')
if result.returncode == 0:
return True
print(result.stdout, file=sys.stderr)
print(result.stderr, file=sys.stderr)
if force:
print('scrub found hits; building anyway because --force was given.',
file=sys.stderr)
return True
print('refusing to build. Fix the hits, or pass --force if they are noise.',
file=sys.stderr)
return False
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--out', help='output zip path')
ap.add_argument('--force', action='store_true',
help='build even if the client-name scan finds something')
args = ap.parse_args()
if not scrub_gate(args.force):
return 2
ver = version()
default = os.path.join(ROOT, 'dist', 'willtools-{}-{}.zip'.format(
ver, datetime.date.today().strftime('%Y%m%d')))
out = build(args.out or default)
size = os.path.getsize(out)
with zipfile.ZipFile(out) as zf:
count = len(zf.namelist())
print('{}\n{} files, {:.1f} KB'.format(out, count, size / 1024.0))
return 0
if __name__ == '__main__':
sys.exit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Watch the QNA folder and report updates, so the agent can act on them.
Why this exists
---------------
QNA/ is where Will drops questions and tasks (`questions.md`, `for-dsh-*.md`,
`task_*.md`). An older attempt (QNA/.watch.register.ps1) watched the folder
but only *logged* events -- nobody read the log. This tool closes the loop:
it runs as a background job of the live agent session, exits the moment
something changes, and the harness delivers its stdout to the agent, who then
reads the update and does whatever it asks, then re-arms the watcher.
Tracked files
-------------
Top-level files in QNA/ ending in .md or .txt, excluding README.md (the
convention doc, effectively static) and anything starting with '.' (logs,
state). Subfolders (wp345_case/, .dsh-home/) are work artifacts, not tasks --
not tracked.
Usage
-----
python tools/qna_watch.py --status # what is tracked, what is pending
python tools/qna_watch.py # diff now against last armed state, exit
python tools/qna_watch.py --wait 21600 # arm: block up to 6 h, exit on first change
python tools/qna_watch.py --interval 2.0 # poll cadence (default 2 s)
Exit codes: 0 on report (change or timeout) -- the caller just reads stdout.
State lives in QNA/.qna-watch.json (gitignored like the rest of QNA).
CPython 3 only -- this sits beside rvt.py/sweep.py, not under the extension.
"""
import argparse
import hashlib
import json
import os
import re
import sys
import time
from datetime import datetime
STATE_NAME = '.qna-watch.json'
TRACKED_EXT = ('.md', '.txt')
EXCLUDED_NAMES = {'readme.md'} # convention doc, not tasks
def qna_dir_default():
return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'QNA'))
def list_tracked(qna):
"""Names of top-level files worth watching, sorted."""
out = []
try:
entries = os.listdir(qna)
except OSError as exc:
sys.stderr.write('cannot list {0}: {1}\n'.format(qna, exc))
return out
for name in entries:
path = os.path.join(qna, name)
if name.startswith('.'):
continue
if name.lower() in EXCLUDED_NAMES:
continue
if not os.path.isfile(path):
continue
if os.path.splitext(name)[1].lower() not in TRACKED_EXT:
continue
out.append(name)
return sorted(out)
def snapshot(qna):
"""{name: sha256hex} for every tracked file. Missing/unreadable -> None."""
snap = {}
for name in list_tracked(qna):
path = os.path.join(qna, name)
try:
with open(path, 'rb') as fh:
snap[name] = hashlib.sha256(fh.read()).hexdigest()
except OSError:
snap[name] = None
return snap
def load_state(qna):
path = os.path.join(qna, STATE_NAME)
try:
with open(path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
if isinstance(data, dict) and isinstance(data.get('hashes'), dict):
return data['hashes']
except (OSError, ValueError):
pass
return None
def save_state(qna, hashes):
path = os.path.join(qna, STATE_NAME)
tmp = path + '.tmp'
with open(tmp, 'w', encoding='utf-8') as fh:
json.dump({'version': 1, 'saved': datetime.now().isoformat(timespec='seconds'),
'hashes': hashes}, fh, ensure_ascii=False, indent=1)
os.replace(tmp, path)
def diff_hashes(old, new):
"""(changed, added, deleted) as sorted name lists."""
changed = sorted(n for n in set(old) & set(new) if old[n] != new[n])
added = sorted(set(new) - set(old))
deleted = sorted(set(old) - set(new))
return changed, added, deleted
QUESTION_RE = re.compile(r'^##\s+(Q\s*[::])', re.IGNORECASE)
ANSWER_RE = re.compile(r'^\*\*A')
def unanswered_questions(path):
"""First lines of `## Q:` sections that carry no `**A**` reply yet."""
try:
with open(path, 'r', encoding='utf-8', errors='replace') as fh:
lines = fh.read().splitlines()
except OSError:
return []
found, current, in_fence, in_q = [], None, False, False
for raw in lines:
if raw.lstrip().startswith('```'):
in_fence = not in_fence
if current is not None:
current.append(raw)
continue
if not in_fence and raw.startswith('## '):
if in_q and current is not None:
found.append(current)
in_q = bool(QUESTION_RE.match(raw))
current = [raw.strip()] if in_q else None
continue
if in_q and current is not None:
if ANSWER_RE.match(raw.lstrip()):
in_q = False # answered; stop collecting
current = None
else:
current.append(raw)
if in_q and current is not None:
found.append(current)
out = []
for sec in found:
head = sec[0]
body = next((l.strip() for l in sec[1:] if l.strip()), '')
out.append('{0} -- {1}'.format(head, body[:120]))
return out
def report(qna, changed, added, deleted, hashes):
stamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
print('=== QNA-WATCH update detected {0} ==='.format(stamp))
for name in changed:
print(' changed: {0}'.format(name))
for name in added:
print(' added: {0}'.format(name))
for name in deleted:
print(' deleted: {0}'.format(name))
qpath = os.path.join(qna, 'questions.md')
if os.path.isfile(qpath):
pend = unanswered_questions(qpath)
if pend:
print('unanswered questions in questions.md ({0}):'.format(len(pend)))
for one in pend:
print(' - {0}'.format(one.replace('\n', ' ')))
if hashes is not None:
save_state(qna, hashes)
print('AGENT ACTION: read the changed file(s) under QNA/, do what they ask,')
print('then re-arm: python tools/qna_watch.py --wait 21600')
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--root', default=qna_dir_default(), help='folder to watch (default: repo QNA)')
ap.add_argument('--wait', type=float, default=0.0,
help='seconds to block waiting for a change (0 = diff once and exit)')
ap.add_argument('--interval', type=float, default=2.0, help='poll seconds while waiting')
ap.add_argument('--status', action='store_true', help='print tracked files + pending questions, exit')
args = ap.parse_args()
try:
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
except Exception:
pass
qna = os.path.normpath(args.root)
if not os.path.isdir(qna):
sys.stderr.write('no such folder: {0}\n'.format(qna))
return 2
if args.status:
names = list_tracked(qna)
print('watching {0} ({1} tracked)'.format(qna, len(names)))
for name in names:
path = os.path.join(qna, name)
mt = datetime.fromtimestamp(os.path.getmtime(path)).strftime('%Y-%m-%d %H:%M:%S')
print(' {0} {1}'.format(mt, name))
qpath = os.path.join(qna, 'questions.md')
if os.path.isfile(qpath):
pend = unanswered_questions(qpath)
print('unanswered questions: {0}'.format(len(pend)))
for one in pend:
print(' - {0}'.format(one))
return 0
prev = load_state(qna)
base = snapshot(qna)
if prev is None:
# first ever run: lay down the baseline quietly, nothing counts as new
save_state(qna, base)
print('(qna-watch) baseline initialized: {0} tracked files'.format(len(base)))
prev, base = base, snapshot(qna)
else:
changed, added, deleted = diff_hashes(prev, base)
if changed or added or deleted:
# something happened while nobody was watching -- backlog first
report(qna, changed, added, deleted, base)
return 0
if args.wait <= 0:
print('(qna-watch) no changes since last arm')
return 0
deadline = time.monotonic() + args.wait
while time.monotonic() < deadline:
time.sleep(max(0.2, args.interval))
now = snapshot(qna)
if now == base:
continue
# debounce: let the writer finish, take the settled snapshot
time.sleep(1.0)
now = snapshot(qna)
changed, added, deleted = diff_hashes(base, now)
report(qna, changed, added, deleted, now)
return 0
print('(qna-watch) no changes in {0:.0f}s; exiting for re-arm'.format(args.wait))
return 0
if __name__ == '__main__':
sys.exit(main())
+338
View File
@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""Reinforcing suggestions with NO REVIT RUNNING -- answered from the index.
python tools/reinforce_offline.py WP157
python tools/reinforce_offline.py WP157 --index "<...>/reinforce/index.json"
python tools/reinforce_offline.py --model sample_job WP157
python tools/reinforce_offline.py --list WP
python tools/reinforce_offline.py WP157 --all --out report.txt
WHAT IT PRINTS, AND WHY MOST OF IT IS FOLDED
---------------------------------------------
The owner's rule for this report, 2026-08-30: the target and the donor
proposals are the answer and everything else starts folded, because "除非有啥
问题我感觉我不会读" -- unless something is wrong, he will not read it. A
section that HAS something wrong in it opens itself and says why, so the page
carries a contract: if nothing below the answer is open, nothing needs you.
`--all` opens every section; `--out FILE` writes the whole thing unfolded, so
a folded section is never the only copy of those words. The folding itself is
`vibe.foldreport`, shared with the button.
WHY THIS CAN EXIST AT ALL
-------------------------
A Revit model file cannot be parsed outside Revit -- it is a proprietary
format and nothing here pretends otherwise. (Spelling out the extension would
trip `tools/scrub.py`, which reads `<word>.rvt` as a leaked model filename.)
But the model is not what the matcher reads.
It reads the INDEX: `<jobs root>/<model>/reinforce/index.json`, roughly a
megabyte, which already holds every measurement the search needs. Measured on
this project, 2026-08-30: a full index rebuild inside Revit is ~10-16 s, and
the match itself is about 0.05 s of ordinary CPython.
Up to schema 4 exactly one thing on that path still needed a document: the
TARGET was measured live, because donor eligibility kept every not-yet-
reinforced piece -- which is to say, every ordinary target -- out of the file.
Schema 5 measures in-scope non-donors too (`reinforcecore.plan(include=...)`),
and with that gone the whole chain runs off the snapshot. Refresh the index
once with the model open, close Revit, and suggestions run all day.
WHAT THAT COSTS, SAID ON EVERY PAGE THIS PRINTS
------------------------------------------------
The answer is as true as the model was when the index was written and no
truer. Inside Revit the cheap signature pass reruns on every press, so a
member added, removed or swapped, a piece newly reinforced, held or typed
MASTER is caught; only a geometry edit that leaves the member set alone
(an opening dragged, a panel stretched) can go stale. Offline NONE of it is
caught, because nothing looks at the model at all. Every report this prints
opens with the OFFLINE banner and the build stamp, and closes with
`reinforcereport.staleness_note(offline=True)` spelling the whole list out.
NO REVIT IMPORT IS REACHABLE FROM HERE, and that is a property of the call
graph, not a hope: `reinforcecore`, `reinforcematch`, `reinforcereport`,
`scope` and `compat` touch nothing; `reinforceindex` imports the Revit API
only inside the functions that need a document, none of which this calls;
`reinforcebind` likewise, and the two functions in it that do need one --
`outline_audit` and `suggest_for_piece` -- are the two this file never calls.
It calls `suggest_from_index`, which is the same code with the cached record
in place of the live measurement. `tests/test_reinforceoffline.py` asserts
that no `Autodesk` module was loaded after a full offline run.
CPython 3.12 -- this file is a `tools/` script, so modern syntax is fine here.
The `vibe` modules it imports are IronPython 2.7 and stay that way.
"""
from __future__ import print_function
import argparse
import json
import os
import sys
import types
_HERE = os.path.dirname(os.path.abspath(__file__))
_LIB = os.path.join(_HERE, '..', 'WillTools.extension', 'lib')
sys.path.insert(0, _LIB)
# The same trick `tools/ticket_delta.py` and the test conftest use, for the
# same reason: `vibe/__init__.py` imports the Revit API at module scope, which
# is right inside Revit and fatal here. Register a bare `vibe` package pointed
# at the directory so the Revit-free submodules import normally and
# `__init__.py` never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(_LIB, 'vibe')]
sys.modules['vibe'] = _stub
from vibe import foldreport as fold # noqa: E402
from vibe import reinforcebind as bind # noqa: E402
from vibe import reinforcecore as core # noqa: E402
from vibe import reinforceindex as index # noqa: E402
from vibe import reinforcereport as R # noqa: E402
JOBS_ENV = 'WILLTOOLS_JOBS'
WIDTH = 78
# ------------------------------------------------------------ finding the file
def resolve_index(index_arg=None, jobs=None, model=None):
"""(path, how) for the index to read. Never guesses silently.
Order: an explicit `--index` path; else `<jobs root>/<model>/reinforce/
index.json`, which is what WILLTOOLS_JOBS means. With a jobs root and no
`--model`, a root holding exactly one model is used and named in the
output; a root holding several refuses and lists them, because picking one
of somebody's jobs at random is the kind of quiet wrong answer this
project spends its comments avoiding.
"""
if index_arg:
return os.path.abspath(index_arg), 'given with --index'
root = jobs or os.environ.get(JOBS_ENV)
if not root:
raise SystemExit(
'No index to read. Give one of:\n'
' --index <path to reinforce/index.json>\n'
' --jobs <jobs root> --model <model name>\n'
' set %s and pass --model <model name>' % JOBS_ENV)
if not os.path.isdir(root):
raise SystemExit('jobs root does not exist: %s' % root)
if not model:
names = sorted(n for n in os.listdir(root)
if os.path.isdir(os.path.join(root, n)))
if len(names) != 1:
raise SystemExit(
'Which model? %s holds %d:\n%s\n\nPass --model <name>.'
% (root, len(names), '\n'.join(' ' + n for n in names)))
model = names[0]
return (index.index_path_in(index.jobs_folder(root, model)),
'jobs root %s, model %s' % (root, model))
def load_index(path):
"""The cache, or a refusal that says what to do about it.
A schema mismatch is not a corrupt file and must not be reported as one:
it means the index was written by an older version of this tool, and the
fix is one refresh inside Revit. The cache is discarded WHOLE -- see
`reinforcecore.load` -- because a schema-4 file holds eligible donors only
and adopting its pieces would answer "that piece is not in the index" for
every ordinary target while looking entirely healthy.
"""
if not os.path.isfile(path):
raise SystemExit(
'No index at %s\n\n'
'The index is written from inside Revit -- press Reinforcing '
'Suggestion once with the model open, then this will work with '
'Revit closed.' % path)
cache = core.load(path)
problem = cache.get('_load_error')
if problem:
raise SystemExit(
'The index at\n %s\ncould not be used: %s\n\n'
'Nothing was adopted from it -- an index from another schema is '
'discarded whole rather than half-read. Open the model in Revit, '
'press Reinforcing Suggestion once to rewrite it (a full rebuild '
'measured ~10-16 s), and run this again.' % (path, problem))
return cache
# ---------------------------------------------------------------- printing
# The plain-text renderers live in `vibe.foldreport`, not here, because the
# button needs the identical ones to write its unfolded copy to disk. These
# are thin names over them so the CLI reads as a CLI.
plain = fold.md_text
table = fold.table_text
def emit(sections, show_all=False):
"""Print the report, folding the routine sections to one line each.
The owner's rule, applied at a terminal: the answer, then a short list of
what else there is, and full text only for the sections that have a
problem in them. `--all` prints everything.
"""
print(fold.text(sections, show_all=show_all, width=WIDTH))
# -------------------------------------------------------------------- the run
def run(cache, name, folder, include_voids=True, prefixes=None,
no_scope=False, max_proposals=5, max_donors=4, full_path=None):
"""(suggestion, info, sections) for one target, entirely from the snapshot."""
the_scope, stamped = index.scope_in(cache)
if no_scope:
from vibe import scope as scopelib
the_scope = scopelib.Scope()
elif prefixes:
from vibe import scope as scopelib
the_scope = scopelib.Scope(prefixes=prefixes,
source='the --prefix option')
# The outline audit's RESULT, read from the file the live run left beside
# the index. It cannot be recomputed here -- it needs each panel's real
# solid -- and never having been run is not the same as every outline
# being a clean rectangle, which is why `load_audit_at` returns None
# rather than an empty set and the report says which it got.
approximate = bind.load_audit_at(folder)
suggestion, info = bind.suggest_from_index(
cache, name, approximate=approximate, the_scope=the_scope,
include_voids=include_voids, max_proposals=max_proposals,
max_donors=max_donors)
info['scope_stamped'] = stamped
target = (cache.get('pieces') or {}).get(name) or {}
rows = (bind.explain(suggestion, info, suggestion.best)
if suggestion is not None and suggestion.best else [])
sections = R.render(suggestion, info, rows, cache,
model=cache.get('doc_key'), offline=True,
reinforced=target.get('reinforced'),
on_hold=target.get('on_hold'),
full_path=full_path)
if not stamped:
# An index that never recorded a scope is a problem, not a footnote:
# every count below rests on a pool bounded by nobody knows what.
sections[0].md(
'> **This index carries no declared scope.** It was built before '
'the scope was stamped into the file, so the donor pool below is '
'bounded by nothing except the filters named in the rejection '
'table. That is "nobody recorded it", not "no scope was '
'declared". Pass `--prefix` to bound it yourself.')
return suggestion, info, sections
def listing(cache, prefix=None):
"""What this snapshot can answer about. Donors and targets kept apart."""
donors = [r['name'] for r in core.donors(cache, include_unjudged=True)]
targets = [r['name'] for r in core.targets(cache)]
if prefix:
donors = [n for n in donors if n.startswith(prefix)]
targets = [n for n in targets if n.startswith(prefix)]
return donors, targets
def main(argv=None):
ap = argparse.ArgumentParser(
description='Reinforcing suggestions from the cached index, with no '
'Revit running.',
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('mark', nargs='?', help='the piece to suggest donors for')
ap.add_argument('--index', help='path to reinforce/index.json')
ap.add_argument('--jobs', help='jobs root (default: $%s)' % JOBS_ENV)
ap.add_argument('--model', help='model name under the jobs root')
ap.add_argument('--list', dest='list_prefix', nargs='?', const='',
help='list what the index holds, optionally by prefix')
ap.add_argument('--prefix', action='append',
help='bound the donor pool to these mark prefixes, '
'instead of the scope stamped in the index')
ap.add_argument('--no-scope', action='store_true',
help='ignore the stamped scope; every donor may donate')
ap.add_argument('--no-voids', action='store_true',
help='ligaments only -- do not ask a donor to account '
'for the target\'s openings as well')
ap.add_argument('--max-donors', type=int, default=4)
ap.add_argument('--max-proposals', type=int, default=5)
ap.add_argument('--all', dest='show_all', action='store_true',
help='print every section, including the folded ones')
ap.add_argument('--out', dest='out_path',
help='write the whole report, nothing folded, to a file')
ap.add_argument('--json', dest='json_out',
help='write the raw (info minus features) to a file')
args = ap.parse_args(argv)
path, how = resolve_index(args.index, args.jobs, args.model)
cache = load_index(path)
folder = os.path.dirname(path)
if args.list_prefix is not None:
donors, targets = listing(cache, args.list_prefix or None)
print('index: %s (%s)' % (path, how))
print('built: %s' % (cache.get('built_at') or 'never'))
print('%d donor(s), %d piece(s) indexed only as targets'
% (len(donors), len(targets)))
print('\ndonors (may be copied from):')
print(_columns(donors))
print('\ntargets only (not reinforced, or on hold -- ask about these):')
print(_columns(targets))
if not targets:
print(' (none -- this index was built without target pieces. '
'Refresh it inside Revit to add them.)')
return 0
if not args.mark:
ap.error('give a piece mark, or --list')
suggestion, info, sections = run(
cache, args.mark, folder,
include_voids=not args.no_voids,
prefixes=args.prefix, no_scope=args.no_scope,
max_proposals=args.max_proposals, max_donors=args.max_donors,
full_path=args.out_path)
if args.out_path:
# FOLDED IS NOT GONE. When the reader has asked for a file, it holds
# every section unfolded -- so a section the terminal showed as one
# line is never the only copy of those words.
with open(args.out_path, 'w', encoding='utf-8') as fh:
fh.write(fold.text(sections, show_all=True, width=WIDTH))
emit(sections, show_all=args.show_all)
print('')
folded = [s for s in sections if not s.is_open and not s.headline]
opened = fold.sections_with_problems(sections)
if folded and not args.show_all:
print(' %d section(s) folded: %s'
% (len(folded), ', '.join(s.key for s in folded)))
# THE CONTRACT, SAID OUT LOUD. Claiming "nothing needs you" on a page
# where a section opened itself would be exactly the lie the whole rule
# exists to prevent, so the two cases get two different sentences.
if opened:
print(' %d section(s) opened themselves: %s'
% (len(opened), ', '.join(s.key for s in opened)))
elif not args.show_all:
print(' nothing below the answer needs you. --all opens them.')
print(' index: %s' % path)
print(' found: %s' % how)
if args.out_path:
print(' full: %s' % args.out_path)
if args.json_out:
slim = dict((k, v) for k, v in info.items()
if k not in ('target_features', 'features_by_donor'))
slim['status'] = suggestion.status if suggestion else None
with open(args.json_out, 'w') as fh:
json.dump(slim, fh, indent=1, default=str, sort_keys=True)
print(' json: %s' % args.json_out)
return 0
def _columns(names, per_line=8):
if not names:
return ' (none)'
out = []
for i in range(0, len(names), per_line):
out.append(' ' + ' '.join(n.ljust(10) for n in names[i:i + per_line]))
return '\n'.join(out).rstrip()
if __name__ == '__main__':
sys.exit(main())
+763
View File
@@ -0,0 +1,763 @@
#!/usr/bin/env python3
"""rvt - talk to the live Revit session from your terminal.
Requires: pyRevit routes server running inside Revit (see ../README.md).
Standard library only, Python 3.8+.
python tools/rvt.py status
python tools/rvt.py exec scratch/scratch.py
python tools/rvt.py exec -c "print(doc.Title)"
python tools/rvt.py exec scratch/rename.py --tx "Rename walls"
echo "print(len(list(FilteredElementCollector(doc))))" | python tools/rvt.py exec -
python tools/rvt.py sel
python tools/rvt.py collect OST_Walls --limit 20
python tools/rvt.py params 123456
python tools/rvt.py reload
python tools/rvt.py --strict status # refuse an unverifiable answer
python tools/rvt.py bridges # list live bridge sessions
python tools/rvt.py use "sample_job" # resolve a model title to a port
Every request carries a correlation id and every answer is checked against it.
See `call()` and `REQUEST_CORRELATION` below for why that is not optional.
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.request
import uuid
# pyRevit does not pin the routes port: `serverinfo._get_next_available_port`
# starts at the configured one and walks UP past every port a running Revit
# already holds. So with two sessions open, 48884 is "whichever Revit started
# first" and the second is on 48885. That is not a detail - it is how a sweep
# aimed at the sandbox lands in the live model instead.
#
# The port is therefore settable from the environment rather than hard-coded,
# so `tools/snapshot_cycle.py` can scan the small range, find the session that
# is actually the sandbox, and hand that port down to every batch command it
# runs. Unset, this is exactly what it always was.
DEFAULT_PORT = 48884
PORT_ENV = "WILLTOOLS_BRIDGE_PORT"
def _port() -> int:
try:
return int(os.environ.get(PORT_ENV) or DEFAULT_PORT)
except (TypeError, ValueError):
return DEFAULT_PORT
def base_url() -> str:
"""The bridge base URL for THIS call.
Read per call, not once at import: a caller that sets the variable and
then makes a request is the ordinary case, and a module-level constant
would silently serve the port that was set when the module loaded.
"""
return f"http://localhost:{_port()}/vibe"
BASE = base_url()
TIMEOUT = 300
ROOT = pathlib.Path(__file__).resolve().parent.parent
SESSION_LOG = ROOT / ".bridge-sessions.log"
# --------------------------------------------------------- request correlation
#
# REQUEST_CORRELATION: why every request carries an id, and every answer is
# checked against it.
#
# Measured 2026-09-01, twice, during a 282-piece sweep. One request timed out
# client-side ("[260/282] no response from Revit within 300s"), and from then
# on the pairing was off by one: `sweep_in_revit.py`'s `open_model_title()`
# asked for `doc.Title` and got the SWEEP's progress string back, which it then
# printed as the document title; an agent polling `doc.Title` as a drift guard
# read the same stolen answer as "the model changed underneath me" and raised a
# false alarm. The data on disk was fine. The attribution was not, and **a
# response from the wrong request is indistinguishable from a valid answer**.
#
# WHERE THE DESYNC ACTUALLY LIVES -- not here. This client is stateless per
# request: `urlopen` builds a new connection every call and urllib sends
# `Connection: close`, so there is no client-side socket to go out of step and
# nothing here to "drain". The shared state is inside pyRevit, in
# `pyrevit/routes/server/server.py`:
#
# REQUEST_HNDLR = handler.RequestHandler() # ONE, module level
# EVENT_HNDLR = UI.ExternalEvent.Create(REQUEST_HNDLR)
#
# The HTTP server is a `ThreadingMixIn` server, so each connection gets its own
# thread -- but every one of those threads writes the SAME singleton
# (`REQUEST_HNDLR.request = request`), calls the SAME `reset()` (clearing one
# shared `_response` / `_done` pair), raises the SAME external event, and then
# spins in `req_hndlr.join()` waiting on that one `_done` flag before reading
# that one `_response`.
#
# So a client-side timeout does not cancel anything. The server thread is still
# spinning, Revit still owes it an answer, and when the next request arrives:
#
# * it overwrites `REQUEST_HNDLR.request`/`.handler` with its own, so the
# abandoned event -- if Revit has not serviced it yet -- executes against
# the NEW request; and `ExternalEvent.Raise()` on an already-pending event
# coalesces, so the new request's own raise can be swallowed and its
# snippet never run at all; and
# * both threads then wait on one `_done` flag and read one `_response`, so
# whichever handler finishes first answers BOTH sockets. The abandoned
# one's write goes to a closed socket and is lost; the live one carries the
# abandoned request's body to a client that asked something else.
#
# That is the whole mechanism, and it is why the fix is in two halves that both
# have to exist:
#
# 1. an id minted here and echoed by `WillTools.extension/startup.py`, so a
# stolen answer is REFUSED loudly instead of returned (`_verified`); and
# 2. a latch (`_latch_timeout`) written the moment a request times out, which
# makes the NEXT call drain that shared slot with a self-identifying probe
# before it sends anything real (`_drain_channel`). The latch is a file
# because `tools/sweep.py` runs `rvt.py` as a fresh subprocess per call --
# an in-process flag would not survive to the request it has to protect,
# which is precisely the request that got the stolen answer.
#
# Nothing here can stop the theft happening inside Revit. It can stop the
# result being mistaken for data, which is the part that cost real time.
CORRELATION_KEY = "_rid"
UNVERIFIED_KEY = "_unverified"
STRICT_ENV = "WILLTOOLS_BRIDGE_STRICT"
# Where the "the last request timed out" latch lives. One file per port: which
# Revit owns which port is decided by start order (see DEFAULT_PORT above), so
# a latch left by a call to 48885 must not gate a call to 48884.
LATCH_DIR = ROOT
# Escalating waits for the drain probes. Short on purpose: the point is to find
# out whether the channel is clear, not to sit out another long request. One
# abandoned request leaves at most one stale answer, so two probes are the
# minimum that can both consume it and then confirm - the third is headroom for
# a first probe that gets no answer at all because Revit is still busy.
DRAIN_TIMEOUTS = (15, 30, 60)
_STRICT = False
class BridgeTimeout(Exception):
"""A request that got no answer.
Deliberately an exception and never a value: "Revit did not answer" and
"Revit answered" are different states, and the whole incident above is what
happens when something downstream reads one as the other.
"""
def _strict() -> bool:
"""Whether an unverifiable answer is refused rather than flagged."""
return bool(_STRICT or (os.environ.get(STRICT_ENV) or "").strip())
def warn(msg: str):
"""Say something on stderr.
stderr, always: `tools/sweep.py` parses this process's STDOUT as JSON, so a
warning printed there would corrupt the answer it is warning about.
"""
print(msg, file=sys.stderr)
def new_request_id() -> str:
"""A request id that is unique across processes and across time.
The pid alone is not enough (`sweep.py` runs one `rvt.py` per call, and pids
are reused); a counter alone is not enough for the same reason. This is
pid + wall clock + random, which is unique enough to make a match mean
something and short enough to read in an error message.
"""
return "{0}-{1:x}-{2}".format(os.getpid(), int(time.time() * 1000),
uuid.uuid4().hex[:8])
def _latch_path() -> pathlib.Path:
return LATCH_DIR / ".bridge-timeout-{0}.json".format(_port())
def _latch_timeout(rid: str, path: str, reason: str):
"""Record that this bridge may still owe an answer to a dead request."""
body = {"rid": rid, "path": path, "reason": reason, "port": _port(),
"at": time.time(), "pid": os.getpid()}
try:
_latch_path().write_text(json.dumps(body, indent=1), encoding="utf-8")
except OSError as exc:
# A guard that silently failed to arm is worse than no guard: say so
# here rather than letting the next call believe the channel is clean.
warn("WARNING: could not write the bridge timeout latch at {0} ({1}).\n"
" The NEXT request will not know to drain the channel first, so\n"
" it may be handed this request's late answer. Treat the next\n"
" answer as unattributed."
.format(_latch_path(), exc))
def _read_latch():
"""The latch for this port, or None. An unreadable latch still counts."""
path = _latch_path()
if not path.exists():
return None
try:
body = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
return {"rid": None, "path": None, "reason": "unreadable latch: {0}"
.format(exc), "at": None}
return body if isinstance(body, dict) else {"rid": None, "path": None,
"reason": "malformed latch",
"at": None}
def _clear_latch():
try:
_latch_path().unlink()
except OSError:
pass
def _drain_channel():
"""Consume any late reply still sitting in the bridge's one response slot.
Returns (clear, sentence).
The probe is `/exec` with a snippet that returns a nonce this process just
invented. That gives the answer TWO independent ways to identify itself:
the echoed correlation id (only a current in-Revit half sends it) and the
nonce coming back as `result` (works against any half, including the one
running right now, which was installed before ids existed). An answer that
is neither is somebody else's -- reading it here is what removes it from
the shared slot, which is the actual draining.
"""
stale = 0
notes = []
for wait in DRAIN_TIMEOUTS:
nonce = "vibe-drain-" + uuid.uuid4().hex[:16]
rid = new_request_id()
body = {"code": "result = {0!r}".format(nonce), CORRELATION_KEY: rid}
try:
res = _send("/exec", "POST", body, wait)
except BridgeTimeout:
notes.append("a {0}s probe got no answer at all".format(wait))
continue
if isinstance(res, dict) and res.get("refused"):
return False, ("the bridge is refusing every route, so nothing can "
"prove the channel is clean: {0}"
.format(res.get("error")))
echoed = res.get(CORRELATION_KEY) if isinstance(res, dict) else None
if echoed is not None and echoed != rid:
stale += 1
notes.append("a probe came back stamped {0}, which is another "
"request's id".format(echoed))
continue
if isinstance(res, dict) and res.get("result") == nonce:
return True, ("the bridge answered its own probe{0}"
.format("" if not stale else
" after {0} stale repl{1} drained"
.format(stale, "y" if stale == 1 else "ies")))
stale += 1
notes.append("a probe came back without its own nonce in `result` "
"-- that answer belonged to something else")
return False, "; ".join(notes) or "no probe could identify itself"
def _resolve_timeout_latch():
"""Refuse to reuse a channel that may still be carrying a dead request.
Called before every real request. Draining is attempted, loudly, and only a
probe that identifies itself clears the latch -- "the bridge answered" is
not the same fact as "the bridge answered ME", and this whole module exists
because those two were once the same code path.
"""
latch = _read_latch()
if latch is None:
return
warn("NOTE: the last request on port {0} ({1}) did not complete: {2}.\n"
" pyRevit serves every route through one shared response slot, so a\n"
" late answer to that request would be handed to this one. Draining\n"
" before sending anything real."
.format(_port(), latch.get("path") or "unknown route",
latch.get("reason") or "unknown"))
clear, why = _drain_channel()
if clear:
_clear_latch()
warn(" channel clear: {0}. Continuing.".format(why))
return
die("REFUSING to send - this bridge cannot be proven clean.\n\n"
" The previous request ({0}) never came back, and every probe since\n"
" has failed to identify itself: {1}.\n\n"
" Sending now risks being handed that request's answer and reading it\n"
" as an answer to this one - which is not a hypothetical, it is what\n"
" happened on 2026-09-01 (see REQUEST_CORRELATION in this file).\n\n"
" Wait for Revit to finish what it is doing and run this again; the\n"
" drain retries every time. If Revit is idle and this keeps failing,\n"
" a modal dialog is blocking the API context, or the session needs a\n"
" RESTART (never a pyRevit reload).\n\n"
" Latch: {2}".format(latch.get("path") or "unknown route", why,
_latch_path()))
def check_bridge_safe():
"""Refuse to talk to a bridge that has survived a pyRevit reload.
pyRevit's routes handler keeps pointing at the scripting engine from before
the reload. The next request executes against a dead engine and kills Revit
outright - an unhandled exception on a non-main thread, which Revit cannot
catch. Our code never runs, so this has to be caught out here, before the
request is ever sent.
startup.py appends one line per pyRevit load. Two lines for the same Revit
process means a reload happened and the bridge is now a landmine.
One exception, proven live 2026-09-03: a 4-field line stamped `hotreload`
is a load that came up AFTER the bridge was stopped cleanly first (the
stop-first Hot Reload under Setup > Config > Hot Reload). The engine is
fresh and the handler points at it, so a second load is safe when -- and
only when -- the LAST load of this process carries that flag. A second+
load with NO such flag is a raw `pyRevit > Reload` into a live bridge,
which is the crash.
"""
if not SESSION_LOG.exists():
# Either Revit has never loaded the extension, or the tripwire in
# startup.py is not firing. Say so - a safety net that silently stopped
# working is worse than one that was never there.
print(
"WARNING: no bridge session log at "
f"{SESSION_LOG}\n"
" The reload tripwire is not recording. If Revit IS running with\n"
" pyRevit loaded, the guard is broken - do not reload pyRevit.",
file=sys.stderr,
)
return
entries = []
for raw in SESSION_LOG.read_text(encoding="utf-8", errors="replace").splitlines():
parts = raw.strip().split("|")
# when | pid | proc_start [| hotreload]. The optional 4th field is the
# stop-first Hot Reload stamp (startup.py); an older half writes 3.
if len(parts) >= 3:
flag = parts[3] if len(parts) >= 4 else ""
entries.append((parts[0], parts[1], parts[2], flag))
if not entries:
return
# This process's lines are those of the LAST entry: startup.py stamps the
# pid and process start of whatever pyRevit load wrote the line, so the
# current engine is the one the newest line belongs to.
_, pid, proc_start, last_flag = entries[-1]
own = [e for e in entries if e[1] == pid and e[2] == proc_start]
if len(own) > 1:
if last_flag == "hotreload":
# A stop-first Hot Reload: the bridge was stopped cleanly BEFORE
# this load re-registered a fresh server against a fresh engine, so
# the handler is not pointing at a dead engine. Proven live
# 2026-09-03: 4 clean requests after a hot reload on a throwaway
# model, Revit unharmed.
return
die(
f"REFUSING to send - the bridge is unsafe right now.\n\n"
f" pyRevit has been reloaded {len(own) - 1}x inside this Revit process (pid {pid}).\n"
f" Its request handler still points at the scripting engine from before\n"
f" the reload, so the next request would crash Revit outright.\n\n"
f" Fix: use Setup > Config > Hot Reload (it stops the bridge first), or\n"
f" restart Revit. A raw 'pyRevit > Reload' is what leaves the handler on\n"
f" a dead engine.\n\n"
f" Log: {SESSION_LOG}"
)
def _send(path: str, method: str, payload, timeout: int):
"""One HTTP round trip. Raises BridgeTimeout; never checks correlation.
Arguments always travel as a JSON body, never a query string, and never a
header. pyRevit's routes `Request` does not reliably expose a parsed query
string across versions - on pyRevit 5.0.1 it exposes none at all, under any
attribute name (see startup.py's `_body` docstring) - so every endpoint
reads `request.data` instead, and `payload` is how this client fills it in.
A header would be no better: `base.Request.__init__` sets `_headers = {}`
and `HttpRequestHandler._prepare_request` never fills it, so the request
headers are as unreachable from handler code as the query string is. That
is why the correlation id travels in the body, on GET routes too - the
server reads a body whenever Content-Length is set, whatever the method.
"""
base = base_url()
url = base + path
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
except TimeoutError:
raise BridgeTimeout(path)
except urllib.error.URLError as exc:
# A CONNECT timeout arrives wrapped in URLError (socket.timeout is an
# OSError, and `do_open` wraps those); a READ timeout is raised bare and
# is caught above. Both are the same answer - nobody replied - and must
# not be reported as "cannot reach Revit", which reads as "Revit is
# closed" and is a different fact.
if isinstance(exc.reason, TimeoutError):
raise BridgeTimeout(path)
die(
f"cannot reach Revit at {base}\n"
f" -> {exc.reason}\n"
" Is Revit open with pyRevit loaded? Routes server must be enabled\n"
" (pyrevit configs routes enable) and Revit restarted once after install."
)
except ConnectionResetError as exc:
die(
f"Revit closed the connection ({type(exc).__name__}).\n"
" Usually means the routes server or Revit itself went down mid-request.\n"
" Check Revit is still running, then restart it if the bridge stays dead."
)
try:
return json.loads(body)
except json.JSONDecodeError:
return body
def _verified(res, rid: str, path: str):
"""The answer, but only if it is an answer to THIS request.
Three outcomes, and they are three, not two:
matching id the answer, with the id stripped back off so callers see
exactly the shape they always saw.
wrong id REFUSED. Named loudly, both ids printed, nothing returned -
a stolen answer is indistinguishable from a real one, so
there is no safe value to hand back.
no id at all the in-Revit half predates this and cannot say. Warned and
flagged `_unverified`, or refused under --strict; see the
warning text for why that is the default.
"""
got = res.get(CORRELATION_KEY) if isinstance(res, dict) else None
if got is not None and got != rid:
# Proof, not suspicion: the channel is serving somebody else's answers.
_latch_timeout(rid, path, "an answer arrived stamped with another "
"request's id ({0})".format(got))
die("REFUSING this answer - it belongs to a different request.\n\n"
" asked as {rid}\n"
" answered as {got}\n"
" route {path}\n\n"
" pyRevit serves every route through one shared response slot, so\n"
" an abandoned request's answer can be handed to the next caller.\n"
" This is that, caught. Nothing is returned, because a wrong\n"
" answer and a right one look identical once they are a value.\n\n"
" The channel is now latched; the next call drains it first."
.format(rid=rid, got=got, path=path))
if got is None:
if _strict():
die("REFUSING this answer - it cannot be attributed ({0}).\n\n"
" This client stamped the request {1} and the in-Revit half\n"
" echoed nothing back, so there is no way to tell this answer\n"
" from an abandoned request's answer served out of pyRevit's\n"
" shared response slot.\n\n"
" The in-Revit half only echoes ids once Revit has been\n"
" RESTARTED after this version of the extension was installed\n"
" (never a pyRevit reload). Drop --strict / unset {2} to take\n"
" the answer with a warning instead."
.format(path, rid, STRICT_ENV))
warn("WARNING: this answer could not be attributed.\n"
" Asked as {0} on {1}; the in-Revit half echoed no id back, so it\n"
" predates request correlation and cannot say which request it is\n"
" answering. It is almost certainly right - but if a request has\n"
" timed out in this session, an answer like this one is exactly\n"
" how the wrong body gets read as data.\n"
" Fix: RESTART Revit (never a pyRevit reload) to load the current\n"
" extension. Use --strict / {2}=1 to refuse instead of warn."
.format(rid, path, STRICT_ENV))
if isinstance(res, dict):
res[UNVERIFIED_KEY] = True
return res
res.pop(CORRELATION_KEY, None)
return res
def call(path: str, method: str = "GET", payload=None):
"""Call one bridge endpoint, and prove the answer is this call's answer.
See REQUEST_CORRELATION at the top of this file for the incident and the
mechanism. In short: a correlation id goes out in the body (every route,
GET included), the current in-Revit half echoes it, and an answer that does
not carry this request's id is refused rather than returned.
"""
check_bridge_safe()
_resolve_timeout_latch()
rid = new_request_id()
body = dict(payload or {})
body[CORRELATION_KEY] = rid
try:
res = _send(path, method, body, TIMEOUT)
except BridgeTimeout:
_latch_timeout(rid, path, "no answer within {0}s".format(TIMEOUT))
die(
f"COULD NOT JUDGE: no response from Revit within {TIMEOUT}s ({path}).\n"
" This is not an answer about the model. Nothing was read and\n"
" nothing was ruled out - the request may still be running inside\n"
" Revit, and its answer may arrive after this process is gone.\n"
" A modal dialog in Revit blocks the API context the bridge runs on -\n"
" dismiss any open dialog, then retry.\n\n"
f" The bridge is now latched ({_latch_path().name}): the next call\n"
" drains that late answer out of pyRevit's shared response slot\n"
" before it sends anything, so it cannot be served as data to\n"
" whoever asks next."
)
return _verified(res, rid, path)
def die(msg: str, code: int = 1):
print(msg, file=sys.stderr)
raise SystemExit(code)
def show(res):
"""Print an /exec-style response in a human- and agent-friendly way."""
if not isinstance(res, dict):
print(res)
return 0
out = res.get("stdout")
if out:
print(out, end="" if out.endswith("\n") else "\n")
if res.get("ok"):
result = res.get("result")
if result is not None:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
print("ERROR: " + str(res.get("error", res)), file=sys.stderr)
if res.get("traceback"):
print(res["traceback"], file=sys.stderr)
return 1
def cmd_status(_):
return show_json(call("/status"))
def show_json(res):
print(json.dumps(res, indent=2, ensure_ascii=False))
return 0 if isinstance(res, dict) and res.get("ok") else 1
def cmd_exec(args):
if args.code:
code = args.code
elif args.file == "-":
code = sys.stdin.read()
elif args.file:
with open(args.file, "r", encoding="utf-8") as fh:
code = fh.read()
else:
die("give a file, -c CODE, or - to read stdin")
return show(call("/exec", "POST", {"code": code, "transaction": args.tx}))
def cmd_sel(_):
return show_json(call("/selection"))
def cmd_collect(args):
return show_json(
call("/collect", "POST", {"category": args.category,
"limit": args.limit,
"types": bool(args.types)})
)
def cmd_params(args):
return show_json(call("/params", "POST", {"id": args.id}))
def _probe_status(port: int, timeout: int = 3):
"""A bare GET /vibe/status probe for bridge DISCOVERY.
Deliberately not routed through `call()`/`_send()`: correlation, the drain
latch and the reload tripwire all belong to real requests, and this probe
is the opposite - it only asks "is anything live on this port". It is also
cheap and bounded (`timeout` seconds), where a real request may legitimately
take the full 300s. Returns the parsed JSON body, or None when the port
refuses the connection or answers something unparseable. `refused` bridges
DO answer HTTP (with a refusal body), so they come back as a dict - that is
how `bridges` distinguishes a live-but-protected session from a dead port.
"""
url = "http://localhost:{0}/vibe/status".format(port)
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")
except Exception:
return None
try:
return json.loads(body)
except ValueError:
return None
def discover_bridges(base=None, span=8, timeout=3, _probe=None):
"""Which of ports base..base+span-1 has a live bridge on it.
Returns a list of dicts (empty when nothing is up):
{"port": port, "model": <doc_title or None>, "refused": bool,
"ok": bool}
A port that will not answer at all contributes nothing. `_probe` is an
injection seam for tests and defaults to `_probe_status`; it must accept
(port, timeout) and return a dict, or None.
"""
probe = _probe or _probe_status
base = base or DEFAULT_PORT
found = []
for port in range(base, base + span):
d = probe(port, timeout)
if not isinstance(d, dict):
continue
found.append({"port": port,
"model": d.get("doc_title"),
"refused": bool(d.get("refused")),
"ok": bool(d.get("ok"))})
return found
def cmd_bridges(args):
found = discover_bridges(span=args.span, timeout=args.timeout)
if args.json:
print(json.dumps(found, ensure_ascii=False))
return 0 if found else 1
if not found:
warn("no live bridge was found on the scanned range (ports {0}..{1})"
.format(DEFAULT_PORT, DEFAULT_PORT + args.span - 1))
print("(none)")
return 1
for entry in found:
refused = " [REFUSED - live, protected]" if entry["refused"] else ""
print("{0} {1}{2}".format(entry["port"], entry["model"] or "", refused))
return 0
def cmd_use(args):
"""Resolve a model-title substring OR an explicit port to a live bridge."""
found = discover_bridges(span=args.span, timeout=args.timeout)
if args.target.isdigit():
want = int(args.target)
hits = [e for e in found if e["port"] == want]
if not hits:
die("no live bridge on port {0} (scanned ports {1}..{2})"
.format(want, DEFAULT_PORT, DEFAULT_PORT + args.span - 1))
port, model = hits[0]["port"], hits[0]["model"]
else:
needle = args.target.lower()
matches = [e for e in found
if e["model"] and needle in str(e["model"]).lower()]
if not matches:
live = ", ".join("{0} ({1})".format(e["port"], e["model"])
for e in found) or "(none)"
die("no live bridge matches {0!r}.\n live bridges: {1}"
.format(args.target, live))
if len(matches) > 1:
listed = ", ".join("{0} ({1})".format(e["port"], e["model"])
for e in matches)
die("ambiguous: {0} live bridges match {1!r}: {2}.\n"
" use a longer substring, or the port number."
.format(len(matches), args.target, listed))
port, model = matches[0]["port"], matches[0]["model"]
if args.json:
print(json.dumps({"port": port, "model": model}, ensure_ascii=False))
elif args.export:
print("export {0}={1}".format(PORT_ENV, port))
else:
print(port)
warn("bridge {0} -> {1}".format(port, model))
return 0
def cmd_reload(_):
die(
"Reloading over HTTP is not supported - it crashes Revit.\n"
" Reloading rebuilds the pyRevit session, including the routes server\n"
" that would be serving this request; the half-dead server then throws\n"
" on a background thread and takes Revit with it.\n\n"
" Use the ribbon instead: Vibe > System > Reload pyRevit\n"
" (only needed after ADDING or RENAMING a button - editing an existing\n"
" script.py is picked up on the next click with no reload.)"
)
def main(argv=None):
ap = argparse.ArgumentParser(prog="rvt", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
# Before the subcommand: `rvt.py --strict status`. Also settable as
# WILLTOOLS_BRIDGE_STRICT=1, which is how it reaches the tools that shell
# out to this one (sweep.py, snapshot_cycle.py) without threading a flag
# through every command line they build.
ap.add_argument("--strict", action="store_true",
help="refuse an answer that carries no correlation id "
"(an in-Revit half from before this Revit restart) "
"instead of warning and returning it")
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="Revit + open model info").set_defaults(fn=cmd_status)
p = sub.add_parser("exec", help="run a Python snippet inside Revit")
p.add_argument("file", nargs="?", help="path to a .py file, or - for stdin")
p.add_argument("-c", "--code", help="inline code")
p.add_argument("--tx", metavar="NAME",
help="wrap in a Revit Transaction with this name")
p.set_defaults(fn=cmd_exec)
sub.add_parser("sel", help="current selection").set_defaults(fn=cmd_sel)
p = sub.add_parser("collect", help="collect elements by BuiltInCategory")
p.add_argument("category", help="e.g. OST_Walls")
p.add_argument("--limit", type=int, default=100)
p.add_argument("--types", action="store_true", help="element types, not instances")
p.set_defaults(fn=cmd_collect)
p = sub.add_parser("params", help="dump all parameters of one element")
p.add_argument("id", help="element id")
p.set_defaults(fn=cmd_params)
sub.add_parser("reload", help="reload pyRevit (picks up new buttons)").set_defaults(fn=cmd_reload)
p = sub.add_parser("bridges", help="list live bridge sessions")
p.add_argument("--span", type=int, default=8,
help="how many ports to scan up from the base")
p.add_argument("--timeout", type=int, default=3,
help="seconds per port probe")
p.add_argument("--json", action="store_true", help="print the list as JSON")
p.set_defaults(fn=cmd_bridges)
p = sub.add_parser("use", help="resolve a model title (or port) to a port")
p.add_argument("target", help="a model-title substring, or a port number")
p.add_argument("--span", type=int, default=8,
help="how many ports to scan up from the base")
p.add_argument("--timeout", type=int, default=3,
help="seconds per port probe")
p.add_argument("--json", action="store_true",
help="print {\"port\": ..., \"model\": ...} as JSON")
p.add_argument("--export", action="store_true",
help="print 'export WILLTOOLS_BRIDGE_PORT=<port>'")
p.set_defaults(fn=cmd_use)
args = ap.parse_args(argv)
global _STRICT
_STRICT = bool(args.strict)
return args.fn(args)
if __name__ == "__main__":
raise SystemExit(main())
+839
View File
@@ -0,0 +1,839 @@
#!/usr/bin/env python3
"""Refuse to publish anything carrying a client's name.
python tools/scrub.py # working tree + full git history
python tools/scrub.py --worktree # working tree only, tracked and not
python tools/scrub.py --staged # what the pre-commit hook runs
python tools/scrub.py --private # the gitignored tree, on its own terms
Exits non-zero on a hit, so it can gate a commit, a package build, or a push.
--worktree and --staged look at different sets on purpose. --worktree is an
audit: every file git can see, tracked or not, and a hit anywhere is a hit.
--staged is a gate on one commit: only what is staged can block it, because
only what is staged is about to enter history. Untracked hits are still
printed, loudly, every time -- they are one `git add -A` from being the other
kind -- but they do not stop a commit they have nothing to do with. A gate
that blocks unrelated work is a gate people learn to pass with --no-verify.
--private is the third set, and it is an inventory rather than a gate: it
always exits 0. Every mode above honours .gitignore, so none of them has ever
opened `QNA/`, `knowledge/plant/` or `knowledge/job/` -- the other
repository's territory, where a name is legitimate, and also the largest body
of quoted source material in the checkout. Nothing looking at all is worse
than looking on the right terms, so --private looks, and asks a different
question: not "is there a name here", which is answered yes by design, but
"is there a name here in a shape somebody will copy" -- a fenced block, a
proposed key, a quoted literal, a path, a name glued into a longer
identifier. Those are aggregated by the distinct string and printed; names in
prose and in data values are counted and never listed. See `scan_private`.
It exits 0 because it has no verdict of its own to give. Every string it
prints contains either a denylist term or a structural pattern, so if one of
them had already been copied into a tracked file, --worktree would be failing
on that file right now. --private cannot find a leak that --worktree misses;
what it finds is the material a leak would be made out of, weeks before.
A mode that returned non-zero on that would return non-zero every single run,
and a check that always fails is a check that gets wrapped in `|| true`.
The terms to look for live in `.scrub-terms`, one per line, which is itself
gitignored -- the list of client names must never become the thing that leaks
them. `.scrub-terms.example` shows the format.
History matters as much as the working tree. Editing a file does not remove a
name from the commit that introduced it, and `git push` sends the commits, not
the checkout. A repo that has never been pushed can still be rewritten
cleanly; one that has been pushed cannot.
Structural patterns below are always checked, term list or not: a model
filename or an absolute user path identifies a job whether or not anyone
remembered to add it to the list.
The one exemption: the sanctioned placeholder names CLAUDE.md's "No client or
job information" table tells everyone to write instead of a real one -- the
job placeholder plus `.rvt` and friends -- are read out of that table (see
`load_placeholders`) and excused from the model/family-filename patterns,
whole-name only. Gluing anything else onto the placeholder still fails, on
purpose -- see `_is_placeholder_filename`.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TERMS_FILE = os.path.join(ROOT, '.scrub-terms')
CLAUDE_MD = os.path.join(ROOT, 'CLAUDE.md')
# CLAUDE.md does not ship in a colleague distribution (tools/make_dist.py) --
# CONTRIBUTING.md is the fallback there, carrying the identical heading and
# table verbatim so `load_placeholders()` still finds it. Checked in this
# order; CLAUDE.md wins where both exist, which is every owner checkout.
CONTRIBUTING_MD = os.path.join(ROOT, 'CONTRIBUTING.md')
# Always suspicious, whatever the term list says.
STRUCTURAL = [
(r'[A-Za-z]:\\Users\\[A-Za-z0-9._-]+', 'absolute Windows user path'),
(r'\b[\w -]+\.rvt\b', 'Revit model filename'),
(r'\b[\w -]+\.rfa\b', 'Revit family filename'),
(r'\b[\w.+-]+@[\w-]+\.[\w.]+\b', 'email address'),
]
# The two structural categories above whose hit is a whole filename, and so
# can be checked against the sanctioned placeholders rather than a real name.
# The absolute-path and email patterns get no such exemption: there is no
# placeholder for those, and `sample_client`/`sample_job` never look like one.
FILENAME_CATEGORIES = {'Revit model filename', 'Revit family filename'}
# Files whose hits are noise rather than leaks.
SKIP = {'.scrub-terms', '.scrub-terms.example'}
SKIP_DIRS = {'.git', 'dist', '__pycache__', '_ticketqc'}
def load_terms(path=None) -> list[tuple[str, bool]]:
"""(term, case_sensitive) pairs. A leading '=' means case-sensitive.
Names are matched case-insensitively by default, because a client name is
a client name however it is typed. Office parameter names are the
exception: SCREAMING_SNAKE literals must not appear, but the lowercase
role identifiers that replaced them (panel.design_number) are the whole
point of the refactor and must not be flagged. Prefix those with '='.
"""
path = path or TERMS_FILE
if not os.path.isfile(path):
return []
terms = []
with open(path, encoding='utf-8') as fh:
for line in fh:
line = line.split('#', 1)[0].strip()
if not line:
continue
if line.startswith('='):
terms.append((line[1:].strip(), True))
else:
terms.append((line, False))
return [t for t in terms if t[0]]
def load_placeholders(path=None) -> list[str]:
"""The sanctioned stand-ins for a real client/job name -- read out of
CLAUDE.md's own table rather than retyped here.
CLAUDE.md Section 3 ("No client or job information in the repo. Ever.")
is the one place that defines what a placeholder is allowed to be:
| a client / owner | `sample_client` |
| a job / project / model | `sample_job` |
A second, hardcoded list in this file would drift the day someone adds or
renames a row there -- silently, because nothing would fail, the
exemption below would just stop covering the current placeholder. Reading
the table instead means there is exactly one place to edit and this file
always agrees with it.
Only the table between that heading and the following "Product codes
are..." paragraph is read, and only backtick-quoted tokens on table rows
(lines starting with "|") are taken -- the product-code examples two
paragraphs down (`PNL1.0`, `PNL101`, `ANCHOR_FACE_4T`) are prose, not a
table row, and are deliberately NOT placeholders: CLAUDE.md says outright
they "stay as they are", i.e. they must still be scrubbable text, not
exempted from anything.
If neither heading can be found -- both files missing, moved, or
reworded -- this returns [] rather than guessing at a fallback list. An
empty result only makes the filename check stricter (nothing is exempt),
never looser, which is the safe direction for a scrub gate to fail in.
NEITHER FILE, on a colleague distribution: CLAUDE.md is the owner's own
agent operating manual and does not ship (see tools/make_dist.py) --
CONTRIBUTING.md carries the identical heading and table there instead,
and is checked second when `path` is not given explicitly. Measured
2026-09-02: without this fallback, CI on a stripped checkout failed on
the sanctioned placeholder itself (`sample_job.rvt` in the test suite),
which is exactly the "nothing is exempt" failure mode above -- safe, but
a false positive on a totally clean fixture, not a leak.
"""
paths = [path] if path else [CLAUDE_MD, CONTRIBUTING_MD]
marker = 'No client or job information in the repo. Ever.'
for candidate in paths:
try:
with open(candidate, encoding='utf-8') as fh:
text = fh.read()
except OSError:
continue
start = text.find(marker)
if start == -1:
continue
end = text.find('Product codes are', start)
block = text[start:end] if end != -1 else text[start:start + 2000]
names = []
for line in block.splitlines():
line = line.strip()
if not line.startswith('|'):
continue
for m in re.finditer(r'`([^`]+)`', line):
names.append(m.group(1).strip().lower())
if names:
return names
return []
def _is_placeholder_filename(matched_text: str, placeholders: list[str]) -> bool:
"""True only if `matched_text` IS a placeholder name plus extension --
never if a placeholder merely appears somewhere inside it.
`os.path.splitext` peels off exactly the trailing `.rvt` / `.rfa`, and
the stem that is left is compared to the placeholder list whole. That
whole-name comparison is the point: a placeholder with anything else
appended to its stem is a DIFFERENT stem, absent from the list, so it is
still flagged. Only the placeholder alone, plus the extension, passes.
"""
if not placeholders:
return False
stem, ext = os.path.splitext(matched_text.strip())
if ext.lower() not in ('.rvt', '.rfa'):
return False
return stem.lower() in placeholders
# `\b` treats an underscore as a word character, the same as a letter, so it
# does not sit between them: a term does not match where it is immediately
# followed by "_SOMETHING" -- there is no boundary at the position between
# the term's last letter and the underscore. A term glued to a role
# identifier, a piece mark, or a version number this way slips the scan
# silently -- the exact shape of a name that is very much still in the file.
#
# What should end a match is a LETTER continuing the same word on either
# side -- that is the one case that means "this is a substring of a longer
# ordinary word, not the term" (a fake example: "Northgate" must not match
# inside "Northgateway" or "preNorthgate"). An underscore, a digit,
# punctuation, or the edge of the line all end the term just as surely as
# whitespace does, so none of those may appear immediately adjacent to the
# match either -- which is what these lookarounds check instead of `\b`.
_TERM_BOUNDARY_BEFORE = r'(?<![A-Za-z])'
_TERM_BOUNDARY_AFTER = r'(?![A-Za-z])'
def patterns(terms: list[tuple[str, bool]]) -> list[tuple[re.Pattern, str]]:
out = [(re.compile(p, re.I), why) for p, why in STRUCTURAL]
for term, cased in terms:
flags = 0 if cased else re.I
out.append((re.compile(_TERM_BOUNDARY_BEFORE + re.escape(term) +
_TERM_BOUNDARY_AFTER, flags),
'term {0!r}'.format(term)))
return out
def git(*args: str) -> str:
return subprocess.run(['git'] + list(args), cwd=ROOT, check=False,
capture_output=True, text=True,
errors='replace').stdout
def tracked_files() -> list[str]:
return [r for r in git('ls-files').splitlines() if r]
def untracked_files() -> list[str]:
"""Files git can see but is not tracking, .gitignore honoured.
These are where a name arrives: a scratch script somebody pasted a model
path into an hour ago is invisible to plain 'git ls-files', which is what
this scan used to run on. Ignored files stay out, so the real denylist and
the local job folders are not scanned.
"""
return [r for r in git('ls-files', '--others',
'--exclude-standard').splitlines() if r]
def staged_files() -> list[str]:
"""What this commit is actually about to write into history."""
out = git('diff', '--cached', '--name-only', '--diff-filter=ACMR')
return [r for r in out.splitlines() if r]
def scan_worktree(pats, listed=None, placeholders=None) -> list[str]:
hits = []
if listed is None:
listed = tracked_files() + untracked_files()
for rel in listed:
if os.path.basename(rel) in SKIP:
continue
path = os.path.join(ROOT, rel)
if not os.path.isfile(path):
continue
try:
with open(path, encoding='utf-8', errors='replace') as fh:
lines = fh.readlines()
except OSError:
continue
for n, line in enumerate(lines, 1):
for pat, why in pats:
m = pat.search(line)
if not m:
continue
if why in FILENAME_CATEGORIES and \
_is_placeholder_filename(m.group(0), placeholders):
continue
hits.append('{0}:{1}: {2} -- {3}'.format(
rel, n, why, m.group(0)[:60]))
return hits
def scan_history(pats, placeholders=None) -> list[str]:
"""Every version of every file that has ever been committed."""
if not git('rev-parse', '--git-dir').strip():
return []
log = git('log', '--all', '--patch', '--no-color', '--format=%H%x00%s')
hits, commit, subject = [], '?', '?'
seen = set()
for line in log.splitlines():
if '\x00' in line and len(line.split('\x00')[0]) == 40:
commit, subject = line.split('\x00', 1)
continue
if not line.startswith('+'):
continue
for pat, why in pats:
m = pat.search(line)
if not m:
continue
if why in FILENAME_CATEGORIES and \
_is_placeholder_filename(m.group(0), placeholders):
continue
key = (commit, why, m.group(0))
if key in seen:
continue
seen.add(key)
hits.append('{0} {1!r}: {2} -- {3}'.format(
commit[:8], subject[:40], why, m.group(0)[:60]))
return hits
# ---------------------------------------------------------------- private tree
#
# Everything above honours .gitignore, on purpose and for good reasons -- and
# the consequence is that nothing in this file has ever opened `QNA/`,
# `knowledge/plant/` or `knowledge/job/`. Those are the other repository's
# territory (see the .gitignore comment that says so), a name is legitimate
# in them, and they are also the largest body of prose in this working tree
# and the only part of it whose job is to quote source material verbatim. A
# name is not merely possible there, it is certain.
#
# So this is deliberately NOT a gate and must never be grown into one. If it
# flagged every producer name in a plant file it would print a wall of
# expected hits, and a wall of expected hits gets read exactly once. That is
# the same failure --staged avoids by not blocking a commit on untracked
# files, one directory further out.
#
# The question --private asks is therefore not "is there a name in here" --
# answered yes, obviously, that is what the file is -- but "is there a name
# in here in a shape that somebody will COPY". A sentence of prose about a
# producer's practice stays in the private repo when the note is read. A
# fenced block, a proposed YAML key, a quoted literal, a path: those are the
# strings that get lifted out of an extraction note and pasted into a module,
# a docs page or a test, and they cross the repo boundary with the name still
# inside them. That is the distinction this mode is built on, and it is what
# keeps the report short enough to still be read next month. (`_scratch/` and
# `sample data/` are gitignored too and get the same treatment, for the same
# reason: nothing was looking at them either.)
#
# Be honest about what it cannot do, because the shape of the blind spot is
# the useful part:
#
# * It cannot judge prose. A name in a sentence is counted, never read, so
# a producer's name inside a verbatim quotation that somebody later
# pastes whole into a docs page is resident here and invisible. What
# catches that is --worktree, on the day it lands in a tracked file.
# * It cannot read a PDF, and the exported tickets carry the job name in
# the title block. Those are in the "not read" tally, deliberately
# visible as a number.
# * "Travelling" is a guess about a shape, not a proof about a future.
# `key: value` in prose and a Markdown fence that is really a diagram
# both look like code to it, and a name written into a sentence that is
# about to become a docstring does not.
# * It only knows names that are on the term list, plus the structural
# patterns. A producer nobody has added to `.scrub-terms` is as
# invisible here as it is everywhere else.
PRIVATE_SKIP_DIRS = {'__pycache__', 'dist', 'build', 'node_modules',
'site-packages'}
# Opaque to a line scanner: binary, or an export whose text this cannot read
# anyway. They are counted and reported as a number rather than passed over
# silently -- "not scanned" and "scanned, clean" are different answers and a
# scrub report may not collapse them. A ticket PDF is the standing example:
# it carries the job name in its title block and nothing here can see it.
PRIVATE_SKIP_EXT = {'.pdf', '.rvt', '.rfa', '.png', '.jpg', '.jpeg', '.gif',
'.bmp', '.ico', '.zip', '.7z', '.gz', '.tar', '.xlsx',
'.xls', '.docx', '.doc', '.pptx', '.pyc', '.pyo', '.dll',
'.exe', '.so', '.dat', '.db', '.sqlite', '.bin'}
PRIVATE_MAX_BYTES = 2000000
# What kind of file this is decides which contexts count as travelling below.
PRIVATE_DOC_EXT = {'', '.md', '.markdown', '.txt', '.text', '.rst'}
PRIVATE_DATA_EXT = {'.yaml', '.yml', '.json', '.csv', '.tsv', '.toml', '.ini',
'.cfg', '.xml'}
_FENCE = re.compile(r'^\s{0,3}(```|~~~)')
_INLINE_CODE = re.compile(r'`[^`]+`')
_QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"")
# A YAML-shaped line: a space-free lowercase key, a colon, a value. Prose
# rarely looks like this -- "Note: the producer says" is capitalised and
# "the plant says: x" has a space in the key, and the key pattern excludes
# both. What this is for is the proposed shape written INTO a note --
# `plant_default: 4` in the middle of a paragraph -- which is a line somebody
# is about to paste into knowledge.DEFAULTS.
_YAML_LINE = re.compile(r'^\s*(-\s+)?[a-z_][a-z0-9_.\-]*:\s*\S')
# A whitespace-and-punctuation-delimited token: the unit a name gets glued
# into, and the unit somebody copies. Angle brackets and `=` are delimiters
# too, or a name inside a generated HTML report comes back as sixty
# characters of markup with the interesting part buried in the middle.
_TOKEN = re.compile(r'[^\s`\'"(),;<>=]+')
def ignored_files() -> list[str]:
"""Files git can see and is deliberately ignoring.
The mirror image of `untracked_files()`: the same walk, the other side of
.gitignore. Between this, `untracked_files()` and `tracked_files()` there
is no longer any part of the working tree that no mode ever opens.
"""
out = git('ls-files', '--others', '--ignored', '--exclude-standard')
return [r for r in out.splitlines() if r]
def private_files(listed=None):
"""The gitignored files worth reading, and a tally of what was dropped.
The tally is returned rather than discarded because the count is part of
the answer. A report that says "480 files read" while silently passing
over 200 PDFs is claiming a coverage it does not have -- the same reason
a check that could not run may not render as OK.
Dot-prefixed path segments go in the tally, not the scan: one agent's
browser profile cache under `QNA/` is 58,000 files on its own, and
dot-names in a working tree are tool state rather than documents.
"""
if listed is None:
listed = ignored_files()
keep = []
dropped = {'tool state': 0, 'build output': 0, 'unreadable': 0,
'over size cap': 0, 'gone': 0}
for rel in listed:
parts = rel.replace('\\', '/').split('/')
if any(p.startswith('.') for p in parts):
dropped['tool state'] += 1
continue
if os.path.basename(rel) in SKIP:
dropped['tool state'] += 1
continue
if any(p in PRIVATE_SKIP_DIRS for p in parts):
dropped['build output'] += 1
continue
if os.path.splitext(rel)[1].lower() in PRIVATE_SKIP_EXT:
dropped['unreadable'] += 1
continue
try:
size = os.path.getsize(os.path.join(ROOT, rel))
except OSError:
dropped['gone'] += 1
continue
if size > PRIVATE_MAX_BYTES:
dropped['over size cap'] += 1
continue
keep.append(rel)
return keep, dropped
def _file_kind(rel: str) -> str:
ext = os.path.splitext(rel)[1].lower()
if ext in PRIVATE_DOC_EXT:
return 'doc'
if ext in PRIVATE_DATA_EXT:
return 'data'
return 'code'
def _token_at(line: str, pos: int) -> str:
for tok in _TOKEN.finditer(line):
if tok.start() <= pos < tok.end():
return tok.group(0)
return ''
def _inside(pos: int, spans) -> bool:
"""True if `pos` is strictly inside one of these delimited spans, i.e.
between the delimiters rather than on one."""
return any(s.start() < pos < s.end() - 1 for s in spans)
def _travels(kind: str, line: str, start: int, why: str, in_fence: bool):
"""Where this hit sits, when that place is one a string travels out of --
otherwise None, meaning the name is *resident*: this file's own subject
matter, sitting in the repository where it is allowed to be.
Read the branches as a claim about copy-and-paste, not about danger.
Nothing here is unsafe where it is. The only question is which of these
strings has a plausible future in a tracked file.
"""
if not why.startswith('term '):
# Structural. A machine's user path, an email address, a model or
# family filename: none of those is a plant convention or a
# producer's vocabulary. Each identifies one person, one machine or
# one job, and reads identically wherever it is pasted, so there is
# no "doing its job" reading of it to weigh against travelling.
return 'structural'
tok = _token_at(line, start)
if '/' in tok or '\\' in tok:
return 'in a path'
if kind == 'code':
# A private script is all instruction. There is no prose half of it
# for a name to be resident in, and a scratch script is the exact
# thing the untracked-file scan already exists for -- this one just
# happens to sit under an ignored directory.
return 'in code'
if kind == 'data':
# Values in a plant or job file ARE the file. A comment is not: it
# is written for a reader, in sentences, and it is what gets carried
# across when the shape of the file is proposed somewhere else.
hash_at = line.find('#')
if hash_at != -1 and hash_at < start:
return 'in a comment'
return None
if in_fence:
return 'in a fenced code block'
if _inside(start, _INLINE_CODE.finditer(line)):
return 'in inline code'
if _inside(start, _QUOTED.finditer(line)):
return 'in a quoted literal'
if _YAML_LINE.match(line):
return 'in a key: value line'
return None
def _safe(text: str) -> str:
"""Whatever of `text` this console can actually print.
The private tree is notes, and notes are written in whatever language the
note-taker was thinking in. A Windows console defaulting to cp1252 raises
UnicodeEncodeError on the first character it cannot render, which kills
the process halfway down the report -- a scrub that stops printing looks
exactly like a scrub that found nothing more.
"""
enc = getattr(sys.stdout, 'encoding', None) or 'ascii'
return text.encode(enc, 'replace').decode(enc, 'replace')
def scan_private(pats, listed=None, placeholders=None):
"""Read the private tree and sort what is found into three piles.
Returns `(travelling, named_dirs, resident)`:
travelling {token: [count, example, why, glued, where]} -- names in
a shape that gets copied. Keyed on the whole delimited
TOKEN the name sits in, not on the line, because the unit
that travels is a string, not an occurrence: forty
mentions of one parameter name are one thing to decide
about, and forty lines of it is the wall this mode exists
to avoid printing. `glued` is True when the token is
larger than the name itself -- the name buried inside an
identifier, a filename, a piece mark. That is the shape a
human reader skims past, so it sorts to the top.
named_dirs {(why, text): [count, example]} -- names in the tree's own
file and folder names, aggregated by the name. A job
folder named after the job produces one of these for every
file underneath it; 380 identical lines would drown
everything above.
resident {path: count} -- everything else, reported as numbers.
Never listed. A producer's name in a sentence about that
producer, in the repository that documents them, is the
file working correctly.
"""
if listed is None:
listed = private_files()[0]
travelling, named_dirs, resident = {}, {}, {}
for rel in listed:
slashed = rel.replace('\\', '/')
for pat, why in pats:
m = pat.search(slashed)
if not m:
continue
if why in FILENAME_CATEGORIES and \
_is_placeholder_filename(m.group(0), placeholders):
continue
entry = named_dirs.setdefault((why, m.group(0)[:40]), [0, slashed])
entry[0] += 1
try:
with open(os.path.join(ROOT, rel), encoding='utf-8',
errors='replace') as fh:
lines = fh.readlines()
except OSError:
continue
kind = _file_kind(rel)
in_fence = False
for n, line in enumerate(lines, 1):
if kind == 'doc' and _FENCE.match(line):
in_fence = not in_fence
continue
for pat, why in pats:
m = pat.search(line)
if not m:
continue
if why in FILENAME_CATEGORIES and \
_is_placeholder_filename(m.group(0), placeholders):
continue
where = _travels(kind, line, m.start(), why, in_fence)
if where is None:
resident[slashed] = resident.get(slashed, 0) + 1
continue
if where == 'structural':
# The pattern already matched the whole string that
# identifies somebody -- a user path, an address, a model
# filename. Widening that to the surrounding token only
# chops it at the first space and prints half of it.
token = m.group(0)
else:
token = _token_at(line, m.start()) \
.strip('`\'"*,.:;()[]{}') or m.group(0)
glued = token.lower() != m.group(0).lower()
# Keyed on the token alone, not on (where, token): one string
# is one thing to decide about, and the same identifier
# quoted in a fence in one note, inline in another and in a
# scratch script in a third is still that one string. The
# first `where` seen labels it, and structural patterns run
# before terms, so the more specific label wins.
entry = travelling.setdefault(
token[:70],
[0, '{0}:{1}'.format(slashed, n), why, glued, where])
entry[0] += 1
return travelling, named_dirs, resident
def report_private(listed, dropped, travelling, named_dirs, resident,
show=30) -> None:
"""Print the three piles: the decidable one first, the expected ones as
numbers. Every section is capped, because a section that can grow without
limit is a section that stops being read."""
skipped = sum(dropped.values())
print('\nprivate tree -- {0} gitignored file(s) read, {1} not read'
.format(len(listed), skipped))
if skipped:
print(' not read: ' + ', '.join(
'{0} {1}'.format(v, k) for k, v in sorted(dropped.items()) if v))
if travelling:
# Glued first: the name buried inside a longer identifier is the one
# a human eye slides over. A bare mention in a code span is still a
# word somebody can see is a name.
ordered = sorted(travelling.items(),
key=lambda kv: (not kv[1][3], -kv[1][0], kv[0]))
glued_n = sum(1 for _, v in ordered if v[3])
print('\nTRAVELLING -- {0} distinct string(s) in a shape that gets '
'copied, {1} of them with the name glued into a longer token. '
'Legitimate where they are; the question is only whether one '
'has already been pasted somewhere tracked:'
.format(len(ordered), glued_n))
for token, (count, example, why, glued, where) in ordered[:show]:
print(_safe(' {0}{1}: {2} -- {3} x{4}, e.g. {5}'.format(
'*' if glued else ' ', where, token, why, count, example)))
if len(ordered) > show:
print(' ... and {0} more distinct string(s)'
.format(len(ordered) - show))
if named_dirs:
ordered = sorted(named_dirs.items(), key=lambda kv: (-kv[1][0], kv[0]))
print('\nnamed paths -- {0} name(s) in the private tree\'s own file '
'and folder names. Nothing to fix here, but a tracked file that '
'quotes one of these paths publishes it:'.format(len(ordered)))
for (why, text), (count, example) in ordered[:show]:
print(_safe(' {0}: {1} -- in {2} path(s), e.g. {3}'
.format(why, text, count, example)))
if len(ordered) > show:
print(' ... and {0} more'.format(len(ordered) - show))
if resident:
total = sum(resident.values())
ordered = sorted(resident.items(), key=lambda kv: (-kv[1], kv[0]))
print('\nresident -- {0} match(es) in {1} file(s), not listed. A name '
'in prose, or in a data value, is these files doing their job:'
.format(total, len(ordered)))
for rel, count in ordered[:show]:
print(_safe(' {0:>6} {1}'.format(count, rel)))
if len(ordered) > show:
print(' ... and {0} more file(s)'.format(len(ordered) - show))
# This mode has no verdict and deliberately returns 0 whatever it finds:
# see the paragraph above `main`'s --private branch.
print('\nThis is an inventory, not a gate. Nothing above blocks a commit, '
'and nothing above is wrong for being where it is -- the question '
'each row asks is whether that exact string has already been '
'copied into a tracked file. Check one with:\n'
' git grep -i -- "<string>"')
# The interpreter is baked in rather than left as bare `python`, and that is
# not fussiness. On Windows `python` usually resolves to the Microsoft Store
# alias in WindowsApps: a stub that prints "Python was not found" and exits
# non-zero. The hook then blocks every commit on this repository with a
# message about the Store, which looks nothing like a scrub failure and sends
# you looking in the wrong place entirely.
#
# A hook is per-clone and not part of the repository, so pinning the
# interpreter that installed it is exactly the right scope. The fallback chain
# is there for a clone whose Python later moves.
HOOK = """#!/bin/sh
# Installed by tools/scrub.py --install-hook. Blocks a commit carrying a
# client name. Bypass with: git commit --no-verify
for py in "{python}" python3 python; do
case "$py" in *WindowsApps*|"") continue;; esac
command -v "$py" >/dev/null 2>&1 || [ -x "$py" ] || continue
exec "$py" tools/scrub.py --staged
done
echo "scrub: no Python found. Reinstall the hook with:" >&2
echo " python tools/scrub.py --install-hook" >&2
exit 1
"""
def install_hook() -> int:
"""Put the worktree scan in front of every commit on this clone.
Hooks are not part of a repository, so this has to be run once per clone
-- including on the office machine after cloning.
"""
hooks = os.path.join(git('rev-parse', '--git-dir').strip() or '.git',
'hooks')
if not os.path.isdir(hooks):
os.makedirs(hooks)
path = os.path.join(hooks, 'pre-commit')
with open(path, 'w', encoding='utf-8', newline='\n') as fh:
fh.write(HOOK.format(python=sys.executable.replace('\\', '/')))
os.chmod(path, 0o755)
print('installed pre-commit hook: {0}\n using {1}'
.format(path, sys.executable))
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--worktree', action='store_true',
help='skip the history scan')
ap.add_argument('--staged', action='store_true',
help='what the pre-commit hook runs: hits in STAGED files '
'block the commit, hits in untracked files are '
'reported but do not. An untracked file cannot reach '
'history without being staged first, and blocking on '
'one stops work that has nothing to do with the '
'commit -- which is how a gate ends up bypassed with '
'--no-verify, and then it guards nothing.')
ap.add_argument('--private', action='store_true',
help='audit the gitignored tree (QNA/, knowledge/plant/, '
'knowledge/job/) -- the other repository, where a '
'name is allowed to be. An inventory, not a gate: '
'never run by the hook and it always exits 0. It '
'lists only names sitting in a shape that travels '
'(a fenced block, a proposed key, a quoted literal, '
'a path, a name glued into a longer identifier), '
'aggregated by the distinct string, and counts the '
'rest without listing them -- a wall of expected '
'hits is a report nobody reads twice.')
ap.add_argument('--terms', default=None,
help="terms file to scan for (default .scrub-terms). CI "
"passes .scrub-terms.example, the committed generic "
"sample file, so it never needs the real denylist.")
ap.add_argument('--install-hook', action='store_true',
help='install as a pre-commit hook on this clone')
args = ap.parse_args()
if args.install_hook:
return install_hook()
terms = load_terms(args.terms)
if not terms:
print('note: no .scrub-terms file, only structural patterns checked.\n'
' copy .scrub-terms.example and add your client names.',
file=sys.stderr)
pats = patterns(terms)
placeholders = load_placeholders()
if not placeholders:
print("note: could not find CLAUDE.md's placeholder table -- the "
'model/family-filename patterns will flag the sanctioned '
'placeholder name too, with nothing exempted.',
file=sys.stderr)
if args.private:
listed, dropped = private_files()
travelling, named_paths, resident = scan_private(
pats, listed, placeholders)
report_private(listed, dropped, travelling, named_paths, resident)
# Always 0, deliberately -- see the paragraph in the module docstring.
# Every string this prints carries a denylist term or a structural
# pattern, so a copy of one into a tracked file is already a --worktree
# failure; there is no leak here for an exit code to catch that is not
# caught one directory out. What is here is the raw material, and a
# mode that failed on the raw material would fail on every run.
return 0
loose = []
if args.staged:
worktree = scan_worktree(pats, staged_files(), placeholders)
loose = scan_worktree(pats, untracked_files(), placeholders)
history = []
else:
worktree = scan_worktree(pats, placeholders=placeholders)
history = [] if args.worktree else scan_history(pats, placeholders)
label_now = 'staged for this commit' if args.staged else 'working tree'
for label, hits in ((label_now, worktree), ('git history', history)):
if hits:
print('\n{0} -- {1} hit(s):'.format(label, len(hits)))
for hit in hits:
print(' ' + hit)
if loose:
# Not fatal, and said every time until it is dealt with. These are one
# 'git add -A' away from being the blocking kind.
print('\nWARNING -- {0} hit(s) in untracked files. Not committed, and '
'not blocking this commit, but one "git add -A" away from '
'being:'.format(len(loose)))
for hit in loose:
print(' ' + hit)
if not args.staged:
# The blind spot, named out loud in every audit run rather than left
# to be rediscovered. These files are not scanned here and should not
# be -- a hit in them is not a leak -- but "nothing looks at all" is
# how a name sits in a note for six weeks and then gets pasted into a
# module. --private is what looks.
n_private = len(private_files()[0])
print('\nnote: {0} readable gitignored file(s) were not scanned here '
'-- QNA/, knowledge/plant/, knowledge/job/, _scratch/. A name '
'is allowed in those, so they are not part of this verdict, '
'but nothing else looks at them either:\n'
' python tools/scrub.py --private'.format(n_private))
if not worktree and not history:
if args.staged:
print('clean: {0} staged file(s) checked against {1} term(s) + {2} '
'structural pattern(s)'
.format(len(staged_files()), len(terms), len(STRUCTURAL)))
return 0
scope = 'working tree' if args.worktree else 'working tree and history'
print('clean: {0} checked against {1} term(s) + {2} structural pattern(s)'
.format(scope, len(terms), len(STRUCTURAL)))
return 0
if history:
print('\nA hit in history is not fixed by editing the file. If nothing '
'has been pushed yet, rewrite the history; if it has, the name '
'is public and rotating the repo is the only real fix.')
return 1
if __name__ == '__main__':
sys.exit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Phase C's host half: wait for Revit to exit, then make the detached copy.
python tools/snapshot_defer.py --request <req.json> --runner <run.py> \
[--pyrevit <pyrevit.exe>] --wait-pid <revit pid> [--revit 2025]
`--pyrevit` is auto-discovered at the usual install path when left off
(`vibe.snapshotmeta.pyrevit_exe_guess`) -- the same fallback the two callers
already made for themselves; passing it explicitly still wins. `--revit` is
also worth passing explicitly on any machine with more than one Revit
version attached: see WHAT IT GUARANTEES below for why omitting it is now a
loud warning rather than a silent maybe.
python tools/snapshot_defer.py --request <req.json> ... --dry-run
python tools/snapshot_defer.py --newest <folder>
WHY A SEPARATE PROCESS EXISTS AT ALL
-------------------------------------
The machine cannot hold two copies of this model. 300+ MB on disk, 10+ GB
open; the engineer's session and a scanning session cannot both be resident,
which is what killed the original concurrent design. So the copy is made
AFTER his Revit has exited, and something has to still be running to notice
that it has. That something is this: a host process, started by the button,
which outlives the Revit that started it, sleeps until the pid is gone, and
then launches exactly one Revit of its own.
THE PARAGRAPH ABOVE IS NO LONGER TRUE, AND `--machine-free` IS THE SWITCH
--------------------------------------------------------------------------
Measured 2026-09-01: the live cloud session and a detached sandbox session
ran side by side on this machine for a whole morning. Two Revits are fine;
the untested number is three. So the machine-wide gate -- "wait until NO
Revit at all is running" -- is now OPT-IN:
--machine-free ignore (default) only the watched pid is waited for
--machine-free require the old behaviour, for the close-model ritual
`tools/snapshot_cycle.py` calls this with the default and sequences instead:
it closes the sandbox Revit BEFORE launching the one that writes the copy, so
two is the most that are ever resident. The `require` mode is kept, not
deleted, because the close-model ritual on a slower machine is a real use for
it and the give-up-and-journal behaviour underneath it is the tested part.
It is deliberately small. Everything it decides -- what the copy is called,
which snapshot is newest, what goes in the log -- lives in
`vibe.snapshotmeta`, where it is unit-tested without Revit. What is left here
is waiting, launching, and reporting, and the two of those that can be tested
without a licence (`wait_for_exit`, `command_for`) are pure functions with
their clock and their liveness check injected.
WHAT IT GUARANTEES
-------------------
* It never starts Revit while the watched pid is alive.
* Under `--machine-free require` it never starts Revit while ANY Revit is
running -- not just that pid. He can close Revit and open it again while
this is sleeping, and under the old one-model premise launching into that
would have put two 10 GB models in memory.
* It gives up after `--wait-minutes` and JOURNALS THE GIVE-UP. A Revit sitting
on an unanswered save prompt is the ordinary reason, and a day when the copy
silently did not happen must not look like a day when it did.
* It checks afterwards that the file it was promised actually exists, and
journals a failure if not -- the in-Revit runner can die in ways that take
its own logging with it.
Host CPython 3.12. The extension side is IronPython 2.7; the shared module is
written to both, and loaded here by path for the same reason the test suite
does it -- importing `vibe` as a package runs `vibe/__init__.py`, which pulls
in the Revit API and dies.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import traceback
import types
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
VIBE = os.path.join(ROOT, 'WillTools.extension', 'lib', 'vibe')
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [VIBE]
_stub.__file__ = os.path.join(VIBE, '__init__.py')
sys.modules['vibe'] = _stub
from vibe import snapshotmeta as S # noqa: E402
# Revit does not release the model the instant its window closes: the process
# lingers while it flushes journals and drops file locks. Starting a new Revit
# into that window is how you get a licence check-out race and a half-freed
# 10 GB. Measured nowhere -- this is a guess, deliberately generous, and it is
# the first number to turn down if the wait feels long.
SETTLE_SECONDS = 30
POLL_SECONDS = 5
# ------------------------------------------------------------- is it gone yet
def process_alive(pid):
"""True while `pid` is a live process. Windows, no third-party module.
An unknown answer is reported as ALIVE, never as gone: being wrong in that
direction costs a wait, and being wrong the other way starts a second
Revit next to a live one -- the exact thing this whole design exists to
prevent.
"""
if not pid:
return False
try:
import ctypes
from ctypes import wintypes
SYNCHRONIZE = 0x00100000
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(
SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
if not handle:
return False # no such process, or already exited
try:
code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return True # could not tell -> assume alive
return code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
except Exception:
pass
try:
out = subprocess.run(
['tasklist', '/FI', 'PID eq {0}'.format(pid), '/NH'],
capture_output=True, text=True, timeout=30).stdout
return str(pid) in out
except Exception:
return True # could not tell -> assume alive
def any_revit_running(run=None):
"""True while ANY Revit is running on this machine, whoever started it.
Waiting for the button's own pid is not enough. The engineer can close
Revit, get a coffee, and open it again inside the window this tool is
sleeping through -- and then launching Revit would put a second 10 GB
model next to his live one, which is the exact failure the whole deferred
design exists to avoid. So the last check before launching is not "has
that process gone" but "is the machine free".
Unknown answers count as RUNNING, for the same reason as in
`process_alive`: the cost of being wrong is a wait in one direction and a
thrashed workstation in the other.
"""
run = run or (lambda argv: subprocess.run(
argv, capture_output=True, text=True, timeout=30).stdout)
try:
out = run(['tasklist', '/FI', 'IMAGENAME eq Revit.exe', '/NH'])
except Exception:
return True
return 'Revit.exe' in (out or '')
def wait_for_exit(pid, timeout_s, alive=None, sleep=None, clock=None,
poll=POLL_SECONDS):
"""Block until `pid` is gone. True if it went, False on timeout.
Every dependency is injected so the loop can be tested with no process and
no wall clock. It returns rather than raising on timeout because the
timeout is a REPORTABLE OUTCOME, not an error: somebody left a save prompt
open, and the next press of the button needs to say so.
"""
if not pid:
# Nothing to wait for. Guarded here as well as in `process_alive`
# because this loop's only exits are "the process went" and "the clock
# advanced past the timeout" -- a caller that passes no pid and a
# liveness check that always says yes would otherwise spin forever,
# which is exactly how this was first written and exactly what the
# test suite caught.
return True
alive = alive or process_alive
sleep = sleep or time.sleep
clock = clock or time.time
started = clock()
while alive(pid):
if clock() - started >= timeout_s:
return False
sleep(poll)
return True
# ----------------------------------------------------------- the command line
def command_for(pyrevit_exe, runner, revit_year=None):
"""The exact `pyrevit run` argv. One source for it, so the line printed by
`--dry-run` and the line actually executed cannot drift apart."""
return S.pyrevit_run_argv(pyrevit_exe, runner, revit_year)
# ------------------------------------------------------------------- the run
def load_request(path):
with open(path, 'rb') as handle:
return json.loads(handle.read().decode('utf-8'))
def fallback_log_for(request_path):
"""`<detached_dir>/snapshots.jsonl`, recovered from the REQUEST'S OWN
PATH rather than its contents.
For the one failure `journal()` cannot report: the request file could not
even be read, so there is no `log` field to send the line to. But every
request this tool is ever handed lives at
`<detached_dir>/_requests/<run>.json` -- the button writes it there, and
so does `tools/snapshot_cycle.py`'s own `write_detach_request` -- so the
well-known log path can be recovered from the FOLDER STRUCTURE alone,
with no JSON parse required. None when the path does not have that
shape (a caller pointed `--request` at something else entirely), because
a log path guessed at random is worse than no journal line at all.
"""
try:
requests_dir = os.path.dirname(os.path.abspath(request_path))
if os.path.basename(requests_dir) != '_requests':
return None
return S.log_path(os.path.dirname(requests_dir))
except Exception:
return None
def journal(request, event, **fields):
path = (request or {}).get('log')
if not path:
return
row = {'event': event, 'run': (request or {}).get('run')}
row.update(fields)
S.append(path, row)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--request', help='the request file the button wrote')
ap.add_argument('--runner', help='the script pyrevit run executes')
ap.add_argument('--pyrevit', help='path to pyrevit.exe')
ap.add_argument('--revit', help='Revit year, e.g. 2025')
ap.add_argument('--wait-pid', type=int, default=0,
help='wait for this process to exit first')
ap.add_argument('--wait-minutes', type=float, default=20.0)
ap.add_argument('--settle', type=float, default=SETTLE_SECONDS,
help='seconds to wait after the pid is gone')
ap.add_argument('--machine-free', choices=('ignore', 'require'),
default='ignore',
help='require: also wait until no Revit at all is running '
'(the old close-model behaviour). Default ignore -- '
'two Revits are measured to be fine, three are not, '
'and the caller sequences for that.')
ap.add_argument('--dry-run', action='store_true',
help='print what would happen and exit, launching nothing')
ap.add_argument('--newest', metavar='FOLDER',
help='print the newest snapshot in FOLDER and exit')
args = ap.parse_args(argv)
if args.newest:
rows = S.snapshots(args.newest)
if not rows:
print('no snapshots in {0}'.format(args.newest))
return 1
for row in rows:
mark = '*' if row is rows[-1] else ' '
print('{0} {1} {2:8} {3}'.format(mark, row['stamp'],
row['layout'], row['path']))
print('\nnewest: {0}'.format(rows[-1]['path']))
return 0
if not (args.request and args.runner):
ap.error('--request and --runner are required')
# A missing or unreadable --request has nowhere of its own to be
# journalled -- the request IS where the log path lives. But its own
# location still says where the detached_dir is (see
# `fallback_log_for`), so a crash here is not silent even so; only a
# `--request` that does not even have a `_requests` parent gives up on
# a journal line entirely, and that is loud on stderr instead.
try:
request = load_request(args.request)
except Exception:
tb = traceback.format_exc()
print(tb, file=sys.stderr)
fallback = fallback_log_for(args.request)
if fallback:
S.append(fallback, {
'event': S.WATCHER_CRASHED, 'run': None,
'error': 'could not read --request {0}: {1}'
.format(args.request, tb.splitlines()[-1]),
'traceback': tb})
return 2
target = (request.get('plan') or {}).get('path')
# `--pyrevit` used to be required by `ap.error()` above, with no journal
# possible at that point (the request was not loaded yet) -- a run
# started with the flag simply missing died silently. It is optional now:
# discovered the same way the two callers that already had this guess
# discover it (`tools/snapshot_cycle.py`'s `_pyrevit_guess`, the
# SnapshotClose button's `_pyrevit_cli`), so a caller that forgets it no
# longer has to be the third copy of the same fallback.
pyrevit_exe = args.pyrevit or S.pyrevit_exe_guess()
if not pyrevit_exe:
error = ('the pyRevit CLI was not found -- pass --pyrevit, or set '
'snapshot.pyrevit_exe, or install pyRevit at the usual '
'path ({0}\\pyRevit-Master\\bin\\pyrevit.exe)'
.format(os.environ.get('APPDATA', '%APPDATA%')))
journal(request, S.FAILED, path=target, error=error)
print(error, file=sys.stderr)
return 2
command = command_for(pyrevit_exe, args.runner, args.revit)
if args.dry_run:
print('request {0}'.format(args.request))
print('run id {0}'.format(request.get('run')))
print('source {0}'.format(
(request.get('source') or {}).get('path') or
'cloud GUIDs in the request'))
print('target {0}'.format(target))
print('log {0}'.format(request.get('log')))
print('wait for pid {0} (up to {1} min, then {2} s to settle),'
.format(args.wait_pid, args.wait_minutes, args.settle))
if args.machine_free == 'require':
print(' then until NO Revit at all is running on this '
'machine')
else:
print(' and NOT for other Revits (--machine-free '
'ignore)')
if not args.revit:
print('\nWARNING: no --revit=<year> was given. On a machine '
'with more than one Revit version attached, pyRevit has '
'to pick one on its own -- confirm this is intended '
'(see snapshot.revit_year / the Revit year folded into '
'snapshot.revit_exe).')
print('\nthen runs:\n {0}'.format(S.quote(command)))
print('\nwith {0}={1}'.format('WILLTOOLS_SNAPSHOT_REQUEST',
args.request))
return 0
# From here on, this process is the whole reason the "requested and
# never reported" question (`vibe.snapshotmeta.outstanding`) can be
# answered instead of guessed at: a REQUESTED with no WATCHER_LAUNCHED
# after it means this process never made it this far at all (never
# spawned, or crashed on the request load above); one with
# WATCHER_LAUNCHED but nothing terminal after it means it started and
# then something below killed it hard enough to skip even the `except`
# net a few lines down -- a signal, a forced kill, the machine itself
# going away. Both are now distinguishable from "still running" and
# from each other, which the ledger could not do before this line
# existed.
journal(request, S.WATCHER_LAUNCHED, path=target,
wait_pid=args.wait_pid or None, wait_minutes=args.wait_minutes,
machine_free=args.machine_free, pyrevit_exe=pyrevit_exe,
revit_year=args.revit or None)
try:
deadline = args.wait_minutes * 60.0
if args.wait_pid:
gone = wait_for_exit(args.wait_pid, deadline)
if not gone:
journal(request, S.FAILED, path=target,
error='Revit (pid {0}) was still running after {1} '
'minutes; no copy was made. A save prompt left '
'unanswered is the usual reason.'
.format(args.wait_pid, args.wait_minutes))
return 2
time.sleep(args.settle)
# THE MACHINE, not just that one process -- but only when asked for.
# He may have opened Revit again while this was sleeping; under the
# retired one-model premise that was fatal, and under
# `--machine-free require` it still stands down. `wait_for_exit` is
# reused with a machine-wide predicate; the pid is a placeholder it
# never inspects.
if args.machine_free == 'require' and \
not wait_for_exit(1, deadline,
alive=lambda _pid: any_revit_running()):
journal(request, S.FAILED, path=target,
error='a Revit is running again on this machine, so no '
'copy was made -- --machine-free require was '
'asked for. Press the button again at the end of '
'the next session, or run with --machine-free '
'ignore.')
return 2
env = dict(os.environ)
env['WILLTOOLS_SNAPSHOT_REQUEST'] = args.request
try:
result = subprocess.run(command, env=env, capture_output=True,
text=True)
code = result.returncode
tail = (result.stderr or result.stdout or '').strip().splitlines()
except Exception as exc:
journal(request, S.FAILED, path=target,
error='could not run the pyRevit CLI: {0}'.format(exc))
return 2
# The runner journals its own DONE. This is the backstop for the
# case where it could not -- Revit died, the journal never played,
# the CLI refused. An outcome nobody wrote down is the failure this
# design is least able to survive.
if target and os.path.isfile(target):
# The file is the only evidence that counts. `pyrevit run`
# returns non-zero for journal-playback noise that has nothing
# to do with whether the copy was written, so the exit code is
# recorded when it is odd and is not allowed to overrule the
# filesystem.
if code != 0:
journal(request, 'phase-c-note', path=target, exit_code=code,
note='the copy was written; the pyRevit CLI still '
'exited {0}'.format(code))
return 0
hint = ''
if not args.revit:
hint = (' No --revit=<year> was passed for this run -- on a '
'machine with more than one Revit version attached, '
'that is the first thing to try.')
journal(request, S.FAILED, path=target, exit_code=code,
error=('the pyRevit run finished but the copy is not there. '
'Last line: {0}{1}'
.format(tail[-1] if tail else '(nothing)', hint)))
return 2
except Exception:
# The backstop for anything above that was not already one of the
# named, journalled failures -- so "requested, watcher launched,
# then nothing" can never again mean "go look at a console nobody
# kept" (the button spawns this with no window and no output
# redirection at all; an unjournalled traceback here is provably
# unread by anything).
tb = traceback.format_exc()
journal(request, S.WATCHER_CRASHED, path=target,
error=tb.splitlines()[-1], traceback=tb)
print(tb, file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())
+431
View File
@@ -0,0 +1,431 @@
# -*- coding: utf-8 -*-
"""Check every WP assembly in the open model, and rank what is wrong.
python tools/sweep.py measure in Revit, then check
python tools/sweep.py --check re-check the saved job files only
python tools/sweep.py --out DIR somewhere other than beside the model
Two halves, deliberately separable:
**Measure** drives `tools/sweep_in_revit.py` through the bridge, ten pieces a
call, until every assembly has been measured. It only reads the model. It is
resumable -- stop it, run it again, it carries on.
**Check** runs `panellift` over the job files that came out. That half needs
no Revit at all, because a job file is the complete input to the check. So a
change to the mechanics can be re-checked against the whole model in a couple
of minutes, and that is the point of keeping the job files rather than only the
reports.
That promise had quietly stopped being true. The position search grew until it
was 93% of a run and this sweep took **42 minutes**, which is long enough that
nobody re-checks anything. It passes `--no-recommend` now -- it never read a
recommendation in the first place -- and is back to about a minute of compute.
That separability has a trap in it, and this refuses on it now: a job file is
the complete input to the check, so a sweep folder can be re-checked against a
model it was never measured from and every report will still render, still
reconcile, and still be about the wrong piece. Each job file records
`source.model`; if that names a model other than the open document, nothing is
checked and the message names both. `--any-model` proceeds anyway and says so
at the top of the output; `--model TITLE` states the model when Revit is not
running. With neither, the guard reports that it could not judge -- which is
not the same answer as agreement, and is printed rather than assumed.
Writes `problems.txt` and `problems.json` next to the jobs.
What this does NOT do is choose the inserts. The stages come from
`liftexport.default_stages`, which is what the grouping rules imply with
nobody having clicked anything -- good enough to find what breaks across a
whole model, not good enough to sign. See docs/handoff.md.
"""
from __future__ import print_function
import argparse
import io
import json
import ntpath
import os
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, os.pardir))
RVT = os.path.join(HERE, 'rvt.py')
IN_REVIT = os.path.join(HERE, 'sweep_in_revit.py')
PANELLIFT = os.path.join(REPO, 'panellift')
SRC = os.path.join(PANELLIFT, 'src')
# ------------------------------------------------------------------- files
def read(path):
with io.open(path, 'r', encoding='utf-8') as fh:
return json.loads(fh.read())
def write(path, obj):
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(json.dumps(obj, indent=1, sort_keys=True, default=str))
# ------------------------------------------------------------------- Revit
def bridge(*args):
"""One rvt.py call. Returns (ok, parsed stdout or raw text)."""
r = subprocess.run([sys.executable, RVT] + list(args),
cwd=REPO, capture_output=True, text=True)
text = (r.stdout or '').strip()
try:
return r.returncode == 0, json.loads(text)
except ValueError:
return False, text or (r.stderr or '').strip()
def sweep_dir():
"""Ask the open model where its sweep folder is."""
ok, out = bridge('exec', '-c',
"from vibe import designspec; "
"result = designspec.sub_dir(doc, 'sweep')")
if not ok or not isinstance(out, str):
raise SystemExit(
'Could not reach Revit: {0}\n\n'
'Revit closed, or a modal dialog is open in it -- handlers run in '
"Revit's API context and cannot fire behind a dialog.".format(out))
return out
def open_model_title():
"""`doc.Title` of the open model, or None if Revit is not reachable.
None is "could not ask", never "it matches" -- see `check_source_model`.
"""
ok, out = bridge('exec', '-c', 'result = doc.Title')
if not ok or not isinstance(out, str) or not out.strip():
return None
return out.strip()
# ------------------------------------------------- which model is this OF?
#
# A job file is the complete input to the check, which is what makes the
# offline half possible -- and it is also how a whole sweep can be re-checked
# against a model it was never measured from without anything looking wrong.
# Measured 2026-09-01: every one of the reference sweep's job files carries
# `source.model` naming one model, while the document open in Revit was a
# detached copy that had since DIVERGED from it -- one notch 3.5 in in one
# and 2.5 in in the other. Every report still rendered, every number still
# reconciled against the job file it came from, and the two models were only
# told apart by hand. `source.model` is the field that would have caught it
# immediately, and nothing was reading it.
def model_stem(name):
"""'sample_job.rvt' and 'sample_job' are the same model.
`source.model` is written as `os.path.basename(doc.PathName)`, so it
carries the extension; `doc.Title` does not. Compared case-insensitively
because Windows does. Returns None for anything empty -- and None is a
question, not a match.
"""
if not name:
return None
# `doc.PathName` -- and so `source.model` -- is always a WINDOWS path,
# drive letter and backslashes, even when this runs on a Linux dev box.
# `os.path.basename` only splits on the host's own separator, so it is
# a no-op for a Windows path on POSIX; `ntpath.basename` is correct on
# both.
stem = ntpath.basename(str(name).strip())
if stem.lower().endswith('.rvt'):
stem = stem[:-len('.rvt')]
return stem.strip().lower() or None
def check_source_model(jobs, doc_title):
"""Which of these job files were exported from a different model.
`jobs` is [(mark, job dict)]. Returns (mismatched, unstamped, reason):
mismatched [(mark, the model the job file names)] -- exported from a
DIFFERENT model from the one open. This is the refusal.
unstamped [mark] for job files carrying no `source.model` at all.
Written before the field existed, or by hand: they cannot
be judged, and they are NOT thereby cleared.
reason a sentence, when nothing could be judged at all because
there is no open document to compare against. `mismatched`
is then empty and that emptiness means "did not ask" --
which is why the caller has to print the reason rather than
read an empty list as agreement.
"""
want = model_stem(doc_title)
if want is None:
return [], [mark for mark, _job in jobs], (
'no open document to compare against, so nothing here says '
'whether these job files were measured from the model you mean')
mismatched = []
unstamped = []
for mark, job in jobs:
said = (job.get('source') or {}).get('model')
stem = model_stem(said)
if stem is None:
unstamped.append(mark)
elif stem != want:
mismatched.append((mark, said))
return mismatched, unstamped, None
def model_refusal(mismatched, doc_title, total):
"""The message the mismatch refuses with. Names BOTH models, always."""
said = sorted(set(name for _mark, name in mismatched))
return (
'These job files were exported from a different model.\n\n'
' the open document says {open}\n'
' the job files say {said}\n\n'
'{n} of {total} job file(s) name a model that is not the one open, '
'and the first of them is {first}.\n\n'
'Nothing was checked. A job file is the complete input to the check, '
'so this would have run to completion and produced a full set of '
'plausible reports about a model nobody is looking at -- which is '
'exactly what happened before this refusal existed, on two copies of '
'one model that had since diverged.\n\n'
'Re-export the sweep from the open model, point --out at the sweep '
'folder that belongs to it, or pass --any-model if you really do mean '
'to check these files against this document.'.format(
open=doc_title, said=', '.join(said),
n=len(mismatched), total=total,
first=sorted(mark for mark, _name in mismatched)[0]))
def measure():
"""Drive the in-Revit half until every assembly has a meta file."""
root = sweep_dir()
print('sweep folder: %s' % root)
meta_dir = os.path.join(root, 'meta')
todo_path = os.path.join(root, 'todo.json')
stuck = 0
while True:
total = len(read(todo_path)) if os.path.isfile(todo_path) else 0
done = len(os.listdir(meta_dir)) if os.path.isdir(meta_dir) else 0
if total and done >= total:
print('measured %d/%d' % (done, total))
return root
ok, out = bridge('exec', IN_REVIT)
print('[%d/%d] %s' % (done, total, str(out)[:200]))
sys.stdout.flush()
if not ok or 'did 0 ' in str(out):
stuck += 1
if stuck >= 3:
print('STOPPING: three calls in a row got nowhere. Revit is '
'busy, or a dialog is open in it.')
return root
time.sleep(10)
else:
stuck = 0
# --------------------------------------------------------------- the check
def check(root, doc_title=None, any_model=False):
jobs_dir = os.path.join(root, 'jobs')
meta_dir = os.path.join(root, 'meta')
reports = os.path.join(root, 'reports')
if not os.path.isdir(reports):
os.makedirs(reports)
env = dict(os.environ, PYTHONPATH=SRC)
rows = []
names = sorted(n for n in os.listdir(jobs_dir) if n.endswith('.job.json'))
# WHICH MODEL, before a single number is computed. See check_source_model.
jobs = [(n[:-len('.job.json')], read(os.path.join(jobs_dir, n)))
for n in names]
mismatched, unstamped, reason = check_source_model(jobs, doc_title)
notes = []
if mismatched and not any_model:
raise SystemExit(model_refusal(mismatched, doc_title, len(jobs)))
if mismatched:
notes.append(
'!! --any-model: %d of %d job file(s) name a model that is not '
'%s. These reports are about a different model.'
% (len(mismatched), len(jobs), doc_title))
if reason:
notes.append('!! model NOT verified: %s.' % reason)
if unstamped and not reason:
notes.append(
'!! %d of %d job file(s) carry no source.model, so which model '
'they came from was not checked.' % (len(unstamped), len(jobs)))
for note in notes:
print(note)
for i, name in enumerate(names, 1):
mark = name[:-len('.job.json')]
meta = read(os.path.join(meta_dir, mark + '.json'))
row = dict((k, meta.get(k)) for k in
('mark', 'design', 'axis', 'length_ft', 'width_ft',
'thickness_in', 'lifters', 'strands'))
# --no-recommend, because this sweep never reads a recommendation:
# `row` below takes the verdict, the per-stage ratios and the findings
# and nothing else. The position search is 93% of a run -- 41 minutes
# of this sweep's 42 -- and every second of it was being computed,
# written to an HTML file and thrown away. The verdicts, ratios and
# findings are untouched by the flag; the reports say on the page that
# the positions were not computed, so nobody reads one as a piece with
# nothing to recommend. Re-run a single piece through the button, or
# `python -m panellift <job> -o <html>` by hand, to get positions.
r = subprocess.run(
[sys.executable, '-m', 'panellift', os.path.join(jobs_dir, name),
'-o', os.path.join(reports, mark + '.html'), '--json',
'--no-recommend'],
cwd=PANELLIFT, env=env, capture_output=True, text=True)
text = (r.stdout or '').strip()
# A FAIL verdict exits non-zero, so the exit code says nothing about
# whether the check ran. The JSON does.
if not text.startswith('{'):
row['verdict'] = 'CHECK CRASHED'
row['error'] = (r.stderr or r.stdout or '')[-1200:]
else:
res = json.loads(text)
row['verdict'] = res.get('verdict')
row['stages'] = [
{'name': s.get('name'), 'ratio': s.get('ratio'),
'verdict': s.get('verdict'),
'findings': [(f.get('severity'), f.get('code'),
f.get('message'))
for f in s.get('findings') or []]}
for s in res.get('stages') or []]
if i % 25 == 0 or i == len(names):
print('checked %d/%d' % (i, len(names)))
sys.stdout.flush()
rows.append(row)
return rows, notes
def refused(root):
"""The pieces that never produced a job file, and why."""
meta_dir = os.path.join(root, 'meta')
out = []
for name in sorted(os.listdir(meta_dir)):
meta = read(os.path.join(meta_dir, name))
if meta.get('status') != 'exported':
out.append(meta)
return out
# -------------------------------------------------------------- the report
def worst(row):
return max([s.get('ratio') or 0.0
for s in row.get('stages') or []] or [0.0])
def render(rows, not_exported, notes=()):
lines = []
def say(s=''):
lines.append(s)
# At the top, and inside problems.txt as well as on the terminal: a
# caveat that only ever scrolled past is a caveat nobody read.
for note in notes or ():
say(note)
if notes:
say()
say('%d pieces checked, %d never got that far'
% (len(rows), len(not_exported)))
counts = {}
for r in rows:
counts[r.get('verdict')] = counts.get(r.get('verdict'), 0) + 1
for k in sorted(counts, key=lambda k: -counts[k]):
say(' %-14s %d' % (k, counts[k]))
say()
checked = [r for r in rows if r.get('stages')]
over = sorted([r for r in checked if worst(r) > 0.8], key=worst,
reverse=True)
say('== OVERSTRESSED: a stage above 0.80 of its allowable tension ==')
if not over:
say(' none')
for r in over:
say(' %-8s %6.1f x %5.1f ft %s'
% (r['mark'], r.get('length_ft') or 0, r.get('width_ft') or 0,
' '.join('%s %.2f' % (s['name'][:4], s.get('ratio') or 0)
for s in r['stages'])))
say()
for severity in ('FAIL', 'WARN'):
buckets = {}
for r in checked:
for stage in r['stages']:
for sev, code, message in stage['findings']:
if sev == severity:
buckets.setdefault(code, {})[r['mark']] = message
say('== %s findings, by code ==' % severity)
if not buckets:
say(' none')
for code in sorted(buckets, key=lambda c: -len(buckets[c])):
marks = sorted(buckets[code])
say(' %-8s %3d %s' % (code, len(marks), ', '.join(marks[:10])))
say(' %s' % list(buckets[code].values())[0][:150])
say()
crashed = [r for r in rows if r.get('verdict') == 'CHECK CRASHED']
if crashed:
say('== CHECK CRASHED: %d ==' % len(crashed))
for r in crashed:
say(' %-8s lifters=%s %s'
% (r['mark'], r.get('lifters'),
str(r.get('error'))[-200:].replace(chr(10), ' ')))
say()
if not_exported:
reasons = {}
for meta in not_exported:
why = '; '.join(meta.get('problems') or []) or meta.get('status')
reasons.setdefault(str(why)[:200], []).append(meta['mark'])
say('== NEVER CHECKED: %d, by reason ==' % len(not_exported))
for why in sorted(reasons, key=lambda w: -len(reasons[w])):
say(' %d x %s' % (len(reasons[why]), why))
say(' %s' % ', '.join(sorted(reasons[why])[:8]))
return lines
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--check', action='store_true',
help='skip the Revit half and re-check saved job files')
ap.add_argument('--out', help='sweep folder (default: beside the model)')
ap.add_argument('--model', help='the model title the job files should '
'name; default is whatever Revit has open')
ap.add_argument('--any-model', action='store_true',
help='check these job files even though they were '
'exported from a different model')
args = ap.parse_args()
if args.out:
root = args.out
elif args.check:
root = sweep_dir()
else:
root = measure()
# --model lets the guard work with no Revit at all (an offline re-check of
# a sweep folder whose model is not open); with neither, the title is
# None and the guard says it could not judge instead of saying it agrees.
doc_title = args.model or open_model_title()
rows, notes = check(root, doc_title, any_model=args.any_model)
write(os.path.join(root, 'problems.json'), rows)
text = chr(10).join(render(rows, refused(root), notes))
with io.open(os.path.join(root, 'problems.txt'), 'w',
encoding='utf-8') as fh:
fh.write(text + chr(10))
print()
print(text)
if __name__ == '__main__':
main()
+249
View File
@@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
"""Measure every in-scope assembly in the open model. Runs INSIDE Revit.
Not run directly -- `tools/sweep.py` feeds it to the bridge over and over:
python tools/rvt.py exec tools/sweep_in_revit.py
Read only. No transaction, no view change, no selection, nothing that would
show up in the user's undo stack.
WHICH ASSEMBLIES, AND WHO SAYS SO
---------------------------------
The DECLARED SCOPE (`vibe.scope`), read from the job knowledge layer, exactly
as Job Recon, Takeoff and Ticket QC read it. Until 2026-08-31 this file
filtered on a two-letter mark prefix written into the code, which was wrong
twice over: the declaration in the job file was not load-bearing here (delete
it and this still filtered; change it and this did not follow), and one job's
naming was baked into a repository that must not carry one.
`run()` already loads the knowledge dict for the measurement itself, so the
scope costs nothing to read -- there was never a reason for the literal.
WHAT IT SAYS ABOUT WHAT IT LEFT ALONE
--------------------------------------
Out of scope is a third state, not an absence, so this writes the scope line
`scope.json` next to the other outputs AND returns it from the enumerating
call, which is the call `tools/sweep.py` prints. A sweep that silently
measured a subset would look exactly like a sweep that measured everything.
todo.json every in-scope assembly, its element id and its design
scope.json the declared scope, the line, and what was left alone
jobs/<mark>.job.json panellift input, default stages, nothing picked
meta/<mark>.json what happened: status, axis, weight gate, audit
One call does CHUNK pieces and returns how many are left. A piece with a meta
file is done, so this is resumable and safe to re-run: delete the sweep folder
to start over -- including after the declaration changes, because `todo.json`
is enumerated once and does not re-read the scope.
"""
import io
import json
import os
import traceback
from Autodesk.Revit.DB import ElementId
from vibe import designspec, knowledge, liftexport, ticketqc
from vibe import scope as scopelib
from vibe.compat import text_type
CHUNK = 10
def _dump(path, obj):
text = json.dumps(obj, indent=1, sort_keys=True, default=repr)
if not isinstance(text, text_type):
text = text.decode('utf-8')
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(text)
def _load(path):
with io.open(path, 'r', encoding='utf-8') as fh:
return json.loads(fh.read())
def _folders(doc):
root = designspec.sub_dir(doc, 'sweep')
return root, designspec.sub_dir(doc, os.path.join('sweep', 'jobs')), \
designspec.sub_dir(doc, os.path.join('sweep', 'meta'))
def enumerate_pieces(doc, know, todo_path, scope_path=None):
"""Every in-scope assembly with the design it belongs to, written once.
Returns (todo, scope_info). What the DECLARED scope left alone is
counted and named in `scope_info` and written to `scope.json` -- it is
never simply absent, which is the one thing `vibe.scope` exists to stop.
"""
the_scope = scopelib.of(know)
todo, left_alone, unreadable, total = [], [], [], 0
for design, group in ticketqc.assemblies_by_design(doc, know).items():
for asm in group:
name = str(asm.Name)
total += 1
if the_scope.is_unreadable(name):
# No readable prefix: 'I could not tell' never removes work.
unreadable.append(name)
if not the_scope.includes(name):
left_alone.append(name)
continue
todo.append({'id': ticketqc.eid(asm), 'mark': name,
'design': design})
todo.sort(key=lambda r: (r['design'], r['mark']))
_dump(todo_path, todo)
scope_info = {
'declared': the_scope.is_declared,
'label': the_scope.label(),
'declaration': the_scope.declaration_lines(),
'line': the_scope.line(left_alone, total=total, unit='assembly',
unreadable_names=unreadable),
# The headline is written HERE, at enumeration, and read back
# verbatim on every later call. See `_scope_headline`.
'headline': the_scope.short_line(left_alone, total=total,
unit='assembly'),
'near_miss': the_scope.near_miss_line(left_alone, unit='assembly'),
'left_alone': the_scope.tally(left_alone),
'left_alone_count': len(left_alone),
'measured_count': len(todo),
'assemblies_seen': total,
}
if scope_path:
_dump(scope_path, scope_info)
return todo, scope_info
def measure(doc, know, sheets, item, jobs_dir):
"""One assembly, as far as it gets. Never raises."""
meta = {'mark': item['mark'], 'design': item['design'],
'status': 'unknown'}
try:
asm = doc.GetElement(ElementId(item['id']))
# Unattended: take the time and locate the inserts properly.
job, problems = liftexport.export(doc, asm, know, sheets,
item['design'], batch=True)
meta['problems'] = problems
if job is None:
meta['status'] = 'refused'
return meta
src = job.get('source') or {}
panel = job.get('panel') or {}
meta['axis'] = src.get('erected_vertical_axis')
meta['length_ft'] = panel.get('length_ft')
meta['width_ft'] = panel.get('width_ft')
meta['thickness_in'] = panel.get('thickness_in')
meta['lifters'] = len(panel.get('lifters') or [])
meta['strands'] = len(
((panel.get('prestress') or {}).get('strands')) or [])
meta['fc_psi'] = panel.get('fc_psi')
meta['fci_psi'] = panel.get('fci_psi')
ok, message, _numbers = liftexport.weight_check(job, know)
meta['weight_ok'] = bool(ok)
if not ok:
meta['weight_msg'] = message
stages, _rows, stage_problems = liftexport.default_stages(job, know)
if not stages:
meta['status'] = 'no stages'
return meta
if stage_problems:
meta['status'] = 'dunnage misconfigured'
meta['stage_problems'] = stage_problems
return meta
# A stage carried on dunnage has no 'lifters' key at all -- it is
# dropped rather than emptied, because panellift refuses a stage that
# gives both a support line and an insert.
meta['stage_lifters'] = dict(
(s['name'], len(s.get('lifters') or [])) for s in stages)
meta['stage_span'] = dict(
(s['name'], s.get('span')) for s in stages)
meta['stage_supports'] = dict(
(s['name'], s.get('supports')) for s in stages
if s.get('supports'))
try:
meta['audit'] = repr(liftexport.stage_audit(stages))
except Exception as exc:
meta['audit'] = 'audit failed: {0}'.format(exc)
# The stages here are the DEFAULTS -- what the grouping rules imply,
# with nobody having clicked anything. That is the point of a sweep
# and it is also its limit: see docs/handoff.md.
missing = [s['name'] for s in stages if not s.get('fc_psi')]
if missing:
meta['status'] = 'no strength'
meta['missing_fc'] = missing
return meta
job['stages'] = stages
_dump(os.path.join(jobs_dir, item['mark'] + '.job.json'),
liftexport.strip_private(job))
meta['status'] = 'exported'
except Exception:
meta['status'] = 'crash'
meta['error'] = traceback.format_exc()
return meta
def _scope_headline(info):
"""The recorded headline, for the FRONT of every return value.
Short and first on purpose. `tools/sweep.py` prints `str(out)[:200]` --
it truncates -- so the full sentence lives in `scope.json` and what a
reader sees at the console is the count, which is what tells him there is
a file to open. A scope line cut off the end of a progress line would be
a scope line nobody read.
A sweep folder enumerated before this existed has no `scope.json`, and
that is reported rather than filled in: what such a run filtered on is
genuinely unknown to this code.
"""
if not info:
return ('SCOPE: NOT RECORDED -- this sweep folder was enumerated '
'before the scope was read, so what it left out is unknown. '
'Delete the sweep folder and re-run.')
return (info.get('headline') or 'SCOPE: recorded without a headline') \
+ ' (scope.json)'
def run(doc):
know = knowledge.load(doc)
root, jobs_dir, meta_dir = _folders(doc)
todo_path = os.path.join(root, 'todo.json')
scope_path = os.path.join(root, 'scope.json')
if not os.path.isfile(todo_path):
todo, scope_info = enumerate_pieces(doc, know, todo_path, scope_path)
return '{0} | enumerated {1} assemblies into {2}'.format(
_scope_headline(scope_info), len(todo), root)
# Re-read rather than re-derive: the enumeration is what the scope was
# applied to, and that happened on an earlier call. Saying it again from
# a knowledge file that may have been edited since would describe a
# filter this run did not use.
scope_info = _load(scope_path) if os.path.isfile(scope_path) else None
todo = _load(todo_path)
_summaries, sheets = ticketqc.collect_context(doc)
did, remaining = [], 0
for item in todo:
meta_path = os.path.join(meta_dir, item['mark'] + '.json')
if os.path.isfile(meta_path):
continue
if len(did) >= CHUNK:
remaining += 1
continue
meta = measure(doc, know, sheets, item, jobs_dir)
_dump(meta_path, meta)
did.append('{0}:{1}'.format(item['mark'], meta['status']))
return '{0} | did {1} [{2}] remaining {3}'.format(
_scope_headline(scope_info), len(did), ', '.join(did), remaining)
result = run(doc) # noqa: F821 -- doc is injected by tools/rvt.py
+372
View File
@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
"""`tools/pa_web.py` end to end -- no Revit, no subprocess.
The server is started IN-PROCESS (`pa_web.build_context` + `pa_web.
make_server`, `serve_forever()` on a background thread) so this runs
everywhere `python -m pytest` already does: no CPython subprocess launch, no
scraping `PA_WEB_URL=` off a pipe (that half is the Revit button's own job --
see the button script's `_launch_pa_web`, covered by the manual dry run and
the live bridge smoke, not a host test).
Two runs, same exercise:
* BLANK path (no `--reads`) -- must always pass. This is the "no Revit at
all" contract `docs/pa-web-spec.md` and the button's own architecture
note both promise, and the one CI can always exercise.
* `_scratch/pa_reads_wpa655.json`, when present -- a real model's reads
(gitignored agent scratch space, per CLAUDE.md's "No client or job
information in the repo" -- it is not tracked). SKIPPED, never failed,
when the file is not on this checkout.
Job root is a pytest `tmp_path` in both cases -- never the real
`%LOCALAPPDATA%\\WillTools\\panelanalysis` a live run would use, and never a
path inside this checkout (CLAUDE.md's "tests must not write into the working
directory").
"""
import json
import os
import sys
import threading
import pytest
from urllib.request import Request, urlopen
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
import pa_web # noqa: E402
REPO = os.path.abspath(os.path.join(TOOLS, os.pardir))
WPA655_READS = os.path.join(REPO, '_scratch', 'pa_reads_wpa655.json')
_EXPECTED_PAGE_IDS = ['design_criteria', 'concrete_extents',
'structural_model', 'handling', 'reinforcement']
class _Running(object):
"""Runs `server.serve_forever()` on a daemon thread for the `with` body,
then shuts it down and joins -- a bare fixture would need the same
teardown twice (one test exercises `/api/close` itself, which already
shuts the server down; the `__exit__` call after that must be a safe,
idempotent no-op, not a hang)."""
def __init__(self, server):
self.server = server
self.thread = threading.Thread(target=server.serve_forever)
self.thread.daemon = True
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc_info):
try:
self.server.shutdown()
except Exception:
pass
self.thread.join(timeout=5)
try:
self.server.server_close()
except Exception:
pass
@property
def base_url(self):
return 'http://127.0.0.1:{0}'.format(self.server.server_address[1])
def _get(url):
"""(status, parsed_json_or_None) -- a non-JSON body (the results HTML,
a static asset) comes back as `None`, not a parse error; a caller that
needs the raw text uses `_get_text` instead."""
with urlopen(url, timeout=10) as resp:
status = resp.getcode()
ctype = resp.headers.get('Content-Type') or ''
body = resp.read().decode('utf-8')
if 'json' not in ctype:
return status, None
return status, (json.loads(body) if body else None)
def _get_text(url):
with urlopen(url, timeout=10) as resp:
return resp.getcode(), resp.read().decode('utf-8')
def _post(url, body):
data = json.dumps(body).encode('utf-8')
req = Request(url, data=data,
headers={'Content-Type': 'application/json'},
method='POST')
with urlopen(req, timeout=30) as resp:
return resp.getcode(), json.loads(resp.read().decode('utf-8'))
def _real_field_keys():
return set(f['key'] for f in pa_web.F.FIELDS if f.get('key'))
def _exercise(tmp_path, reads_path):
"""Runs the whole GET spec / save / run / close cycle against one
server instance and returns nothing -- every assertion lives here so
the blank and WPA655 tests below cannot drift apart."""
ctx = pa_web.build_context(reads_path=reads_path,
job_root_override=str(tmp_path))
server = pa_web.make_server(ctx, port=0)
with _Running(server) as running:
base = running.base_url
# -------------------------------------------------------- /api/spec
status, spec = _get(base + '/api/spec')
assert status == 200
assert spec['schema'] == 1
assert spec['title'] == 'Panel Analysis'
assert [p['id'] for p in spec['pages']] == _EXPECTED_PAGE_IDS
assert isinstance(spec.get('values'), dict) and spec['values']
assert isinstance(spec.get('basis'), dict)
assert isinstance(spec.get('defaults_why'), dict)
assert isinstance(spec.get('choices'), dict)
assert isinstance(spec.get('tables'), dict) and spec['tables']
assert isinstance(spec.get('geometry'), dict) and spec['geometry']
assert isinstance(spec.get('problems'), list)
# -------------------------------------------------------- /api/save
status, saved = _post(base + '/api/save', {'values': spec['values']})
assert status == 200
assert saved.get('ok') is True
values_path = os.path.join(str(tmp_path), 'panelanalysis',
ctx.mark + '.values.json')
assert os.path.isfile(values_path)
with open(values_path, 'r', encoding='utf-8') as fh:
on_disk = json.load(fh)
assert on_disk.get('values') == spec['values']
# A saved edit is picked back up by the next /api/spec (the "saved
# user edits merged over the prefill" contract).
status, spec2 = _get(base + '/api/spec')
assert status == 200
assert spec2['values'].get('length_ft') == spec['values'].get(
'length_ft')
# --------------------------------------------------------- /api/run
status, result = _post(base + '/api/run', {'values': spec['values']})
assert status == 200
assert isinstance(result, dict) and 'ok' in result
if result['ok']:
assert result['result_url'].startswith('/results/')
result_name = result['result_url'][len('/results/'):]
result_path = os.path.join(str(tmp_path), 'panelanalysis',
result_name)
assert os.path.isfile(result_path)
pa_json_path = os.path.join(str(tmp_path), 'panelanalysis',
ctx.mark + '.pa.json')
assert os.path.isfile(pa_json_path)
status, html = _get_text(base + result['result_url'])
assert status == 200
assert '<title>' in html
else:
problems = result.get('problems')
assert isinstance(problems, list) and problems
real_keys = _real_field_keys()
for p in problems:
assert set(p.keys()) >= {'key', 'text'}
assert isinstance(p['text'], str) and p['text']
# A non-empty key must name a real FIELDS key -- '' (could
# not be mapped) is the only other allowed value.
assert p['key'] == '' or p['key'] in real_keys
# ------------------------------------------------------- /api/close
status, closed = _post(base + '/api/close', {})
assert status == 200
assert closed.get('ok') is True
running.thread.join(timeout=5)
assert not running.thread.is_alive()
def test_pa_web_blank_path(tmp_path):
_exercise(tmp_path, reads_path=None)
def test_pa_web_wpa655_reads(tmp_path):
if not os.path.isfile(WPA655_READS):
pytest.skip('no _scratch/pa_reads_wpa655.json on this checkout -- '
'gitignored agent scratch space, not always present')
_exercise(tmp_path, reads_path=WPA655_READS)
def _get_headers(url):
with urlopen(url, timeout=10) as resp:
return resp.getcode(), dict(resp.headers)
def test_static_and_json_responses_carry_utf8_charset(tmp_path):
"""Item 1 of the 2026-09-05 review: `index.html` gets a `<meta
charset="utf-8">` and every text-ish response (`text/html`,
`application/javascript`, `text/css`, JSON) carries an explicit
`charset=utf-8` -- a browser with no declared charset guesses cp1252/
latin-1 and every non-ASCII glyph (the title's en dash, the row X
buttons, the section-arrow triangles) renders as mojibake."""
ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path))
server = pa_web.make_server(ctx, port=0)
with _Running(server) as running:
base = running.base_url
status, headers = _get_headers(base + '/')
assert status == 200
assert headers['Content-Type'] == 'text/html; charset=utf-8'
status, headers = _get_headers(base + '/app.js')
assert status == 200
assert 'charset=utf-8' in headers['Content-Type']
status, headers = _get_headers(base + '/willtools.css')
assert status == 200
assert 'charset=utf-8' in headers['Content-Type']
status, headers = _get_headers(base + '/api/spec')
assert status == 200
assert headers['Content-Type'] == 'application/json; charset=utf-8'
_post(base + '/api/close', {})
running.thread.join(timeout=5)
def test_index_html_declares_utf8_meta_charset():
static_dir = os.path.join(TOOLS, 'pa_web', 'static')
with open(os.path.join(static_dir, 'index.html'), 'r',
encoding='utf-8') as fh:
head = fh.read(2000)
assert '<meta charset="utf-8">' in head
def test_api_geometry_matches_the_spec_geometry(tmp_path):
"""`POST /api/geometry` (item 3's live-redraw fix) must return the SAME
`geometry` dict `/api/spec` computes off the identical `values` -- the
client-side redraw path (`app.js`'s `scheduleGeometryRefresh`) depends
on the two never disagreeing."""
ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path))
server = pa_web.make_server(ctx, port=0)
with _Running(server) as running:
base = running.base_url
status, spec = _get(base + '/api/spec')
assert status == 200
status, geom_result = _post(base + '/api/geometry',
{'values': spec['values']})
assert status == 200
assert geom_result.get('geometry') == spec['geometry']
_post(base + '/api/close', {})
running.thread.join(timeout=5)
def test_api_geometry_reflects_an_opening_row_edit(tmp_path):
"""Adding an `opening` row through the client's own key shape must
change the outline geometry `/api/geometry` returns for it -- the
server-side half of "add/remove rows must redraw the outline live"."""
ctx = pa_web.build_context(reads_path=None, job_root_override=str(tmp_path))
server = pa_web.make_server(ctx, port=0)
with _Running(server) as running:
base = running.base_url
status, spec = _get(base + '/api/spec')
assert status == 200
values = dict(spec['values'])
values['length_ft'] = '24'
values['width_ft'] = '8'
status, before = _post(base + '/api/geometry', {'values': values})
assert status == 200
values['opening.1.present'] = 'yes'
values['opening.1.type'] = 'Opening'
values['opening.1.x_ft'] = '2'
values['opening.1.length_ft'] = '3'
values['opening.1.y_ft'] = '1'
values['opening.1.width_ft'] = '2'
status, after = _post(base + '/api/geometry', {'values': values})
assert status == 200
assert after['geometry'] != before['geometry']
_post(base + '/api/close', {})
running.thread.join(timeout=5)
def test_unknown_endpoint_is_404_not_a_crash(tmp_path):
ctx = pa_web.build_context(reads_path=None,
job_root_override=str(tmp_path))
server = pa_web.make_server(ctx, port=0)
with _Running(server) as running:
base = running.base_url
try:
_get(base + '/api/nope')
except Exception as exc:
assert '404' in str(exc)
# the server must still be answering afterwards
status, spec = _get(base + '/api/spec')
assert status == 200
assert spec['schema'] == 1
_post(base + '/api/close', {})
running.thread.join(timeout=5)
# ---------------------------------------------------------------------------
# /api/run names WHOSE fault a refusal is (issue #19)
#
# A validation refusal ("the piece is still missing f'c") and a solve crash
# used to come back in one shape -- `{"ok": false, "problems": [...]}` -- and
# the page rendered both through one red banner, so a data gap read as a
# broken tool and a fixed dependency bug was mistaken for a new one. The
# reply now carries `kind`: 'input' (the user's to fill, nothing broke) or
# 'error' (a bug, traceback on stderr). Two different answers, never one
# shape.
def test_run_refusal_for_missing_input_is_kind_input(tmp_path):
ctx = pa_web.build_context(reads_path=None,
job_root_override=str(tmp_path))
result = pa_web.run_analysis(ctx, {})
assert result['ok'] is False
assert result['kind'] == 'input'
assert result['problems']
# every problem is a plain validate() sentence, none an exception
for p in result['problems']:
assert not p['text'].endswith('Error')
assert 'Traceback' not in p['text']
def test_run_refusal_for_a_solve_crash_is_kind_error(tmp_path, monkeypatch):
ctx = pa_web.build_context(reads_path=None,
job_root_override=str(tmp_path))
monkeypatch.setattr(pa_web.F, 'validate', lambda values: ([], {}))
def boom(parsed):
raise RuntimeError('deck exploded')
monkeypatch.setattr(pa_web.F, 'build_deck', boom)
result = pa_web.run_analysis(ctx, {})
assert result['ok'] is False
assert result['kind'] == 'error'
assert result['problems'] == [
{'key': '', 'text': 'RuntimeError: deck exploded'}]
def test_run_kind_reaches_the_client_over_http(tmp_path):
ctx = pa_web.build_context(reads_path=None,
job_root_override=str(tmp_path))
server = pa_web.make_server(ctx, port=0)
with _Running(server) as running:
status, result = _post(running.base_url + '/api/run',
{'values': {}})
assert status == 200
assert result['ok'] is False
assert result['kind'] == 'input'
def test_page_renders_input_and_crash_banners_differently():
"""The page half of the same rule: two banner faces, and the bare
`.banner` (an unlabelled failure) stays the loud one."""
static = os.path.join(TOOLS, 'pa_web', 'static')
with open(os.path.join(static, 'app.js'), 'r', encoding='utf-8') as fh:
js = fh.read()
with open(os.path.join(static, 'willtools.css'), 'r',
encoding='utf-8') as fh:
css = fh.read()
assert 'banner--input' in js and 'banner--crash' in js
assert '.banner.banner--input' in css and '.banner.banner--crash' in css
# the reply's `kind` is what the page branches on
assert 'showProblems(data.problems || [], data.kind)' in js
# an unlabelled kind renders as a crash, never as a calm input note
assert "kind === 'input' ? 'banner--input' : 'banner--crash'" in js
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""`tools/panelanalysis.py::build_analysis_page` -- the READ-ONLY results
page for the `panelanalysis/1` schema (2026-09, results-page redesign).
Guards the redesign's own hard rules, on two decks:
MINIMAL `schema.new_deck()` as-is -- two dunnage supports, two
straight strands, no in-place restraints/wind at all (the
Stage-0 default). Exercises the "not judged" path for
in-place strength/service.
IN-PLACE the same deck with lateral restraints and a manual wind zone
added, so the in-place strength/service models actually
solve and their own SVG envelope diagrams
(`panelanalysis._inplace_diagram`) render.
This does not repeat `test_panelanalysis_schema.py`'s coverage of the
engine/schema layer -- it only checks what the WRITTEN PAGE looks like:
- never an `<input>` or `<select>` (the input side lives in the 前端 now)
- never a DECK / NOT READ / COMPUTED badge (`CHIPS`' own vocabulary)
- the Summary card is present (`class="verdict"` + `class="stageboxes"`)
- no decimal-feet number anywhere (every length is feet-inches)
- the page is well under the ~1 MB budget and opens standalone (its own
`<style>` carries the embedded CSS -- no `<link rel="stylesheet">`)
Import pattern mirrors `test_panelanalysis_schema.py`: `tools/` on
`sys.path`, `deckcheck` imported first for its `vibe` stub side effect.
"""
import copy
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
import deckcheck # noqa: E402,F401 -- registers the `vibe` stub
import panelanalysis # noqa: E402
import panelanalysis_schema as schema # noqa: E402
DECIMAL_FT = re.compile(r'\d+\.\d+\s*ft\b')
def minimal_deck():
return schema.new_deck()
def in_place_deck():
"""`new_deck()`, wired for the in-place strength/service models to
solve: three lateral restraints (so `schema.restraints()` sees >= 2)
and one manual wind zone -- the same shape `WPA655.pa.json` (the deck
this redesign was built and screenshotted against) carries.
"""
deck = schema.new_deck(length_ft=37.5833, width_ft=8.375)
deck['structural_model']['restraints'] = [
{'x_ft': 2.0, 'type': 'Pin', 'gravity': True, 'thermal': False},
{'x_ft': 15.0, 'type': 'Floor tie', 'gravity': True,
'thermal': False},
{'x_ft': 22.0, 'type': 'Floor tie', 'gravity': True,
'thermal': False},
]
deck['loading']['wind'] = {
'mode': 'manual',
'zones': [{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': deck[
'concrete_extents']['length_ft'], 'pressure_psf': 30.0,
'suction_psf': 35.0}],
}
return deck
def _check_common_invariants(html):
assert '<input' not in html
assert '<select' not in html
for badge in ('>DECK<', '>NOT READ<', '>COMPUTED<', '>SPEC<',
'>KNOWLEDGE<', '>PLANNED<', '>NOT COMPUTED<'):
assert badge not in html, badge
assert 'class="chip' not in html
assert '<link rel="stylesheet"' not in html
assert 'class="verdict"' in html
assert 'class="stageboxes"' in html
assert 'class="stagebox' in html
bad = DECIMAL_FT.findall(html)
assert not bad, 'decimal-feet numbers found: {0}'.format(bad)
assert len(html.encode('utf-8')) < 1024 * 1024
def test_minimal_deck_page_has_no_inputs_or_badges_and_a_summary_card():
html = panelanalysis.build_analysis_page(minimal_deck())
_check_common_invariants(html)
# Nothing wires the in-place model on this deck -- both its models are
# NOT JUDGED, named as such, never a fabricated zero.
assert 'not judged' in html.lower()
def test_in_place_deck_solves_and_draws_its_own_envelope_diagrams():
deck = in_place_deck()
html = panelanalysis.build_analysis_page(deck)
_check_common_invariants(html)
# The in-place models actually ran on this deck -- PASS/WARN/FAIL, not
# "not judged", for In-Place Strength and In-Place Service.
assert 'In-Place Strength' in html
assert 'In-Place Service' in html
# This file's own SVG diagrams for the governing in-place combination
# (`panelanalysis._inplace_diagram`) rendered -- their titles are
# unique to them (the handling stages' reused `R.diagram_plot` figures
# never say "governing combination").
assert 'governing combination' in html.lower()
def test_handling_stages_each_get_their_own_section_and_tag():
html = panelanalysis.build_analysis_page(minimal_deck())
for stage in ('Stripping', 'Transportation', 'Erection'):
assert 'Handling — {0}'.format(stage) in html or \
'Handling &mdash; {0}'.format(stage) in html
def test_page_is_deterministic_given_the_same_deck_modulo_timestamp():
"""Two builds a second apart differ only in the 'generated HH:MM' line
-- nothing else on a read-only report should vary run to run."""
deck = in_place_deck()
h1 = panelanalysis.build_analysis_page(copy.deepcopy(deck))
h2 = panelanalysis.build_analysis_page(copy.deepcopy(deck))
strip = lambda s: re.sub(r'generated \d{4}-\d\d-\d\d \d\d:\d\d',
'generated X', s)
assert strip(h1) == strip(h2)
+257
View File
@@ -0,0 +1,257 @@
# -*- coding: utf-8 -*-
"""`tools/panelanalysis_schema.py` -- the manual wind zone contract, the
'Free' restraint filter, and `reinforcement.mild_bars` (PANELANALYSIS-4).
The engine side only. `WillTools.extension/tests/test_panelanalysis_wind.py`
(and its siblings) already cover the front-end's own `build_deck` shape and
the pre-existing legacy wind precedence (direct > computed > blank); this
file does not repeat those, and does not touch anything under
`WillTools.extension/` -- that tree is the front-end sibling's territory.
Import pattern mirrors `_scratch/run_w1.py`: `tools/` goes on `sys.path`,
`deckcheck` is imported first purely for its side effect of registering a
bare `vibe` package stub (`panelanalysis_schema` does `from vibe import
panelanalysisform`, which needs that stub to resolve off Revit), THEN
`panelanalysis_schema` itself.
"""
import os
import sys
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
import deckcheck # noqa: E402,F401 -- registers the `vibe` stub
import panelanalysis_schema as schema # noqa: E402
from panellift import inplace # noqa: E402
from panellift.model import Panel # noqa: E402
def base_deck(**wind_overrides):
deck = schema.new_deck(strands=[])
deck['loading']['wind'] = dict(wind_overrides)
return deck
def wall_panel(length_ft=40.0, width_ft=8.0):
return Panel(mark='TEST', length_ft=length_ft, width_ft=width_ft,
thickness_in=8.0, unit_weight_pcf=150.0, fc_psi=5000.0,
fci_psi=3500.0)
# --------------------------------------------------------------- wind.mode
def test_project_config_mode_refuses_with_the_named_reason():
deck = base_deck(mode='project_config')
zones, trib, reason = schema.wind_zones(deck)
assert zones == []
assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT
assert reason == 'wind from project config is not built yet'
def test_project_config_mode_refuses_even_carrying_other_wind_keys():
"""The refusal is unconditional on `mode` alone -- an asce block or a
direct pressure sitting beside it changes nothing."""
deck = base_deck(mode='project_config', direct_pressure_psf=16.0,
asce={'v_ult_mph': 115.0})
zones, _trib, reason = schema.wind_zones(deck)
assert zones == []
assert reason == schema.WIND_PROJECT_CONFIG_NOT_BUILT
def test_manual_mode_two_zone_different_pressure_suction_reaches_the_engine():
"""Two zones with DIFFERENT pressure/suction magnitudes: `wind_zones`
hands `inplace` genuinely different fields, and the deck-level
`in_place_inputs` flags `wind_zone_mode='manual'` so the engine solves
them as two independent cases (not the old negate shortcut)."""
deck = base_deck(
mode='manual',
zones=[
{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 20.0, 'width_ft': 8.0,
'pressure_psf': 16.0, 'suction_psf': 12.0},
{'label': 'W2', 'x0_ft': 20.0, 'x1_ft': 40.0, 'width_ft': 8.0,
'pressure_psf': 20.0, 'suction_psf': None},
])
panel = wall_panel()
zones, trib, reason = schema.wind_zones(deck, panel)
assert reason is None
assert len(zones) == 2
z1, z2 = zones
assert z1.pressure_psf == 16.0 and z1.suction_psf == 12.0
assert z1.width_ft == 8.0
assert z2.pressure_psf == 20.0 and z2.suction_psf is None
assert schema.wind_zone_mode(deck) == 'manual'
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'Pin'}, {'x_ft': 20.0, 'type': 'Pin'},
{'x_ft': 40.0, 'type': 'Pin'}]
inputs, in_reason = schema.in_place_inputs(deck, panel)
assert in_reason is None
assert inputs.wind_zone_mode == 'manual'
res = inplace.run(panel, inputs)
assert res.refusal is None
# A pure-negate suction field would peak at exactly the pressure peak;
# zone 2 (20 psf pressure, no suction) contributing nothing to W- while
# zone 1 does breaks that equality.
key_xs = res.cases['D'].xs
cases_suction = inplace.build_wind_suction_cases(panel, inputs, key_xs,
res.cases)
assert cases_suction is not None
w_plus_peak = max(abs(m) for m in res.cases['W'].moment_lbft)
w_minus_peak = max(abs(m) for m in cases_suction['W'].moment_lbft)
assert w_minus_peak != pytest.approx(w_plus_peak)
def test_manual_mode_zone_width_is_per_zone_not_panel_wide():
deck = base_deck(
mode='manual',
zones=[{'label': 'W1', 'x0_ft': 0.0, 'x1_ft': 40.0,
'width_ft': 4.2, 'pressure_psf': 10.0, 'suction_psf': 10.0}])
zones, trib, reason = schema.wind_zones(deck, wall_panel())
assert reason is None
assert zones[0].width_ft == 4.2
# trib (the panel-wide fallback) is untouched -- a zone carrying its own
# width does not have to state a deck-wide tributary_width_ft too.
assert trib is None
def test_legacy_zones_deck_is_unaffected_by_the_mode_reader():
"""No `mode` key at all (the golden shape) -- the pre-existing single-
pressure zone reading is byte-for-byte what it always was."""
deck = base_deck(zones=[{'x0_ft': 0.0, 'x1_ft': 37.6,
'pressure_psf': 16.0, 'label': 'W-pressure'}])
zones, trib, reason = schema.wind_zones(deck, wall_panel())
assert reason is None
assert len(zones) == 1
assert zones[0].pressure_psf == 16.0
assert zones[0].suction_psf is None
assert zones[0].width_ft is None
assert schema.wind_zone_mode(deck) == 'legacy'
# ---------------------------------------------------- restraints, 'Free'
def test_free_restraint_is_skipped_others_are_kept():
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'Pin'},
{'x_ft': 10.0, 'type': 'Free'},
{'x_ft': 20.0, 'type': 'Fixed'},
{'x_ft': 30.0, 'type': 'Floor tie'},
]
xs, reason = schema.restraints(deck)
assert reason is None
assert xs == [0.0, 20.0, 30.0]
def test_free_restraint_match_is_case_insensitive():
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'FREE'},
{'x_ft': 10.0, 'type': 'free'},
{'x_ft': 20.0, 'type': 'Pin'},
{'x_ft': 30.0, 'type': 'Pin'},
]
xs, _reason = schema.restraints(deck)
assert xs == [20.0, 30.0]
def test_an_unrecognised_type_word_still_counts_as_lateral():
"""Only the literal word 'Free' is excluded -- an unknown word (the
golden's own 'restrained', or 'dunnage') is counted, never quietly
dropped (CLAUDE.md sec.3: unrecognised never means silently excluded)."""
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 0.0, 'type': 'restrained'},
{'x_ft': 10.0, 'type': 'something-nobody-has-seen'},
]
xs, _reason = schema.restraints(deck)
assert xs == [0.0, 10.0]
def test_restraint_rows_carries_every_extra_key_for_the_page():
deck = schema.new_deck()
deck['structural_model']['restraints'] = [
{'x_ft': 5.0, 'type': 'Pin', 'elevation_ft': 102.5, 'gravity': True,
'thermal': False, 'stiffness_kin': 250.0},
{'x_ft': 15.0, 'type': 'Free'},
]
rows = schema.restraint_rows(deck)
assert len(rows) == 2
assert rows[0]['elevation_ft'] == 102.5
assert rows[0]['gravity'] is True
assert rows[0]['thermal'] is False
assert rows[0]['stiffness_kin'] == 250.0
assert rows[0]['lateral'] is True
assert rows[1]['lateral'] is False
# ---------------------------------------------------------- mild_bars
def test_mild_bars_with_depth_enter_the_capacity_blank_depth_does_not():
deck = schema.new_deck(strands=[])
deck['reinforcement']['mild_bars'] = [
{'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0,
'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6},
{'size': '#3', 'count': 10, 'area_in2': 1.1, 'depth_in': None,
'direction': 'Longitudinal', 'from_ft': 0.0, 'to_ft': 37.6},
{'size': '#4', 'count': 8, 'area_in2': 1.6, 'depth_in': 2.0,
'direction': 'Transverse', 'from_ft': 0.0, 'to_ft': 37.6},
]
bars = schema.mild_bars(deck)
assert len(bars) == 3
assert bars[1]['depth_in'] is None
layers = schema.mild_bar_reinforcement(deck)
# Only the first row: has a depth AND is Longitudinal.
assert len(layers) == 1
assert layers[0].area_in2 == pytest.approx(4.8)
assert layers[0].count == 1 # total already, not per-bar
assert layers[0].z_in == pytest.approx(2.0)
assert layers[0].from_ft == pytest.approx(0.0)
assert layers[0].to_ft == pytest.approx(37.6)
assert layers[0].grade_ksi == pytest.approx(schema.DEFAULT_MILD_BAR_GRADE_KSI)
def test_mild_bars_grade_reads_settings_rebar_fy_ksi():
deck = schema.new_deck(strands=[])
deck['settings']['rebar_fy_ksi'] = 40.0
deck['reinforcement']['mild_bars'] = [
{'size': '#5', 'count': 4, 'area_in2': 1.24, 'depth_in': 3.0,
'direction': 'Longitudinal'}]
layers = schema.mild_bar_reinforcement(deck)
assert layers[0].grade_ksi == pytest.approx(40.0)
def test_no_mild_bars_block_is_the_identity_on_to_panel():
"""A deck with no `reinforcement.mild_bars` at all -- every deck before
PANELANALYSIS-4 -- must reach `to_panel` with EXACTLY the reinforcement
`mild_reinforcement` alone would have produced (here: none), proving the
handling path (pu=0) sees no change for a bar-less deck."""
deck = schema.new_deck(strands=[])
panel = schema.to_panel(deck)
assert panel.reinforcement == []
def test_mild_bars_area_in2_is_the_layer_total_not_per_bar():
"""24 x #4 (area_in2=4.8 TOTAL, per the field model's own convention) must
not be re-multiplied by count into 24 * 4.8."""
deck = schema.new_deck(strands=[])
deck['reinforcement']['mild_bars'] = [
{'size': '#4', 'count': 24, 'area_in2': 4.8, 'depth_in': 2.0,
'direction': 'Longitudinal'}]
panel = schema.to_panel(deck)
assert len(panel.reinforcement) == 1
assert panel.reinforcement[0].total_area_in2 == pytest.approx(4.8)
def test_mild_bars_missing_required_key_refuses_loudly():
deck = schema.new_deck(strands=[])
deck['reinforcement']['mild_bars'] = [{'size': '#4', 'depth_in': 2.0}]
with pytest.raises(schema.DeckError):
schema.mild_bars(deck)
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""Cross-checks `tools/pa_web/static/units.js` against its CPython twin,
`panellift.units` -- the two are meant to be a byte-for-byte port of each
other (see units.py's own module docstring), and this is the one place that
proves it rather than trusting two hand-written files stayed in sync.
Node-free on purpose: this whole file SKIPS, never fails, when `node` is not
on PATH. A missing dev tool on some machine is not a reason a CPython test
run should go red, and the JS's own behaviour is otherwise unverified only
by inspection -- an acceptable gap, not a gate anyone has to work around.
"""
import json
import os
import shutil
import subprocess
import sys
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.abspath(os.path.join(HERE, os.pardir))
REPO = os.path.abspath(os.path.join(TOOLS, os.pardir))
UNITS_JS = os.path.join(TOOLS, 'pa_web', 'static', 'units.js')
PANELLIFT_SRC = os.path.join(REPO, 'panellift', 'src')
if PANELLIFT_SRC not in sys.path:
sys.path.insert(0, PANELLIFT_SRC)
from panellift import units # noqa: E402
NODE = shutil.which('node')
# (js function name, python function name, args, output kind)
# output kind 'str' compares exactly; 'num' compares within a float epsilon
# (JS and CPython float arithmetic can differ in the last bit).
CASES = [
('fmtFtIn', 'fmt_ft_in', [8.375], 'str'),
('fmtFtIn', 'fmt_ft_in', [None], 'str'),
('fmtFtIn', 'fmt_ft_in', [-0.5], 'str'),
('fmtFtIn', 'fmt_ft_in', [0.53125], 'str'), # 0'-6 3/8"
('fmtFtIn', 'fmt_ft_in', [37.583333333333336], 'str'), # carries to 37'-7"
('fmtFtIn', 'fmt_ft_in', [0.0], 'str'),
('fmtIn', 'fmt_in', [6.375], 'str'),
('fmtIn', 'fmt_in', [None], 'str'),
('fmtIn', 'fmt_in', [12.0], 'str'),
('fmtIn', 'fmt_in', [0.0], 'str'),
('parseLength', 'parse_length', ["8'-4 1/2\""], 'num'),
('parseLength', 'parse_length', ["8'4\""], 'num'),
('parseLength', 'parse_length', ["8' 4-1/2\""], 'num'),
('parseLength', 'parse_length', ['4 1/2"'], 'num'),
('parseLength', 'parse_length', ['100"'], 'num'),
('parseLength', 'parse_length', ["-0'-6\""], 'num'),
('parseLength', 'parse_length', ['5'], 'num'),
('parseLength', 'parse_length', ['5', 'in'], 'num'),
('parseLengthIn', 'parse_length_in', ['6 3/8"'], 'num'),
('parseLengthIn', 'parse_length_in', ['5', 'ft'], 'num'),
]
_HARNESS = """
'use strict';
var fs = require('fs');
global.window = global;
var src = fs.readFileSync(process.argv[2], 'utf8');
eval(src);
var Units = global.Units;
var cases = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
var out = cases.map(function (c) {
try {
return {ok: true, value: Units[c.fn].apply(null, c.args)};
} catch (e) {
return {ok: false, error: String(e)};
}
});
process.stdout.write(JSON.stringify(out));
"""
def _run_js(tmp_path):
harness = tmp_path / 'harness.js'
harness.write_text(_HARNESS, encoding='utf-8')
cases_path = tmp_path / 'cases.json'
cases_path.write_text(
json.dumps([{'fn': fn, 'args': args} for fn, _p, args, _k in CASES]),
encoding='utf-8')
proc = subprocess.run(
[NODE, str(harness), UNITS_JS, str(cases_path)],
capture_output=True, text=True, timeout=30)
assert proc.returncode == 0, (
'node harness failed: stdout={0!r} stderr={1!r}'.format(
proc.stdout, proc.stderr))
return json.loads(proc.stdout)
@pytest.mark.skipif(NODE is None, reason='node is not on PATH')
def test_units_js_matches_units_py(tmp_path):
assert os.path.isfile(UNITS_JS), UNITS_JS
js_results = _run_js(tmp_path)
assert len(js_results) == len(CASES)
for (fn, py_name, args, kind), js in zip(CASES, js_results):
py_fn = getattr(units, py_name)
py_value = py_fn(*args)
assert js['ok'], '{0}{1} threw in JS: {2}'.format(fn, args,
js.get('error'))
js_value = js['value']
if kind == 'str':
assert js_value == py_value, (fn, args, js_value, py_value)
else:
assert abs(js_value - py_value) < 1e-9, (
fn, args, js_value, py_value)
def test_units_js_file_exists_even_without_node():
"""The one assertion that always runs: the file this whole module is
about is actually there, node or not."""
assert os.path.isfile(UNITS_JS)
+226
View File
@@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
"""Revision delta for precast shop tickets -- CLI.
python tools/ticket_delta.py OLD NEW [--crops DIR] [--json OUT.json]
OLD and NEW may each be a single PDF or a folder of them. Folders are paired
by mark, because the two issues of a real set were named differently (old by
mark, new by job-control-mark) and the mark is the only identifier both carry.
Needs pymupdf on the path. It is not a dependency of the extension -- the
Revit side runs IronPython and cannot import it -- so this script is the only
thing in the repo that touches a PDF.
"""
from __future__ import print_function
import argparse
import json
import os
import sys
import types
_HERE = os.path.dirname(os.path.abspath(__file__))
_LIB = os.path.join(_HERE, '..', 'WillTools.extension', 'lib')
sys.path.insert(0, _LIB)
# Same trick the test conftest uses, and for the same reason: vibe/__init__.py
# imports the Revit API at module scope, which is right inside Revit and fatal
# on host CPython. Register a bare `vibe` package pointed at the directory so
# the Revit-free submodules import normally and __init__.py never runs.
if 'vibe' not in sys.modules:
_stub = types.ModuleType('vibe')
_stub.__path__ = [os.path.join(_LIB, 'vibe')]
sys.modules['vibe'] = _stub
from vibe import ticketdelta # noqa: E402
def _load_extract():
try:
from vibe import ticketextract
import pymupdf # noqa: F401
except ImportError as exc:
raise SystemExit(
'ticket_delta needs pymupdf on PYTHONPATH (import name pymupdf, '
'not fitz). %s' % exc)
return ticketextract
SEV_MARK = {
ticketdelta.MATERIAL: '!!',
ticketdelta.UNCERTAIN: '??',
ticketdelta.ADVISORY: ' *',
ticketdelta.HOUSEKEEP: ' ',
}
def _pdfs(path):
if os.path.isdir(path):
return sorted(f for f in os.listdir(path) if f.lower().endswith('.pdf'))
return [os.path.basename(path)]
def _dir(path):
return path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path))
def render_crop(pdf, page, x, y, out_png, dpi=300, pad=90):
"""Render the neighbourhood of a finding so a human can read the number.
This exists because the text layer is not the sheet. Four independent
text-layer failure modes have been measured on this project's sources, and
each one produces a confident wrong number rather than an error. Every
reported change ships with the picture it was read from; a reviewer who
disagrees with the tool can see immediately which of them is wrong.
"""
import pymupdf
doc = pymupdf.open(pdf)
pg = doc[page]
clip = pymupdf.Rect(max(0, x - pad), max(0, y - pad),
min(pg.rect.x1, x + pad), min(pg.rect.y1, y + pad))
pix = pg.get_pixmap(dpi=dpi, clip=clip)
pix.save(out_png)
doc.close()
return out_png
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('old')
ap.add_argument('new')
ap.add_argument('--crops', help='directory to render a PNG per material '
'finding into')
ap.add_argument('--json', dest='json_out')
ap.add_argument('--all', action='store_true',
help='print housekeeping findings too')
args = ap.parse_args(argv)
tx = _load_extract()
old_dir, new_dir = _dir(args.old), _dir(args.new)
plan = ticketdelta.pair_sets(_pdfs(args.old), _pdfs(args.new))
print('=' * 78)
print('REVISION DELTA')
print(' old issue: %s (%d file(s))' % (args.old, plan['naming']['old_count']))
print(' new issue: %s (%d file(s))' % (args.new, plan['naming']['new_count']))
if plan['naming']['changed']:
print('')
print(' !! THE FILE NAMING CHANGED BETWEEN ISSUES.')
print(' old files carry a control number on %d of %d;'
' new on %d of %d.'
% (plan['naming']['old_has_control'], plan['naming']['old_count'],
plan['naming']['new_has_control'], plan['naming']['new_count']))
print(' These two issues therefore CANNOT be paired by filename.')
print(' Pairing below is on the mark, the only identifier both'
' namings carry.')
if plan['naming']['fan_out']:
print('')
print(' !! ONE OLD FILE MAPS TO SEVERAL NEW ONES:')
for m, n in sorted(plan['naming']['fan_out'].items()):
print(' %s: 1 old file -> %d new files' % (m, n))
print(' One ticket per control number: several identical panels'
' now get')
print(' several tickets. The comparison below runs against each'
' of them.')
for f in plan['old_only']:
print(' !! WITHDRAWN: %s is in the old issue and has no counterpart'
' in the new' % f)
for f in plan['new_only']:
print(' !! ADDED: %s is in the new issue and has no counterpart'
' in the old' % f)
print('=' * 78)
cache = {}
def get(path):
if path not in cache:
cache[path] = tx.extract(path)
return cache[path]
results = []
for orec, nrecs in plan['pairs']:
a = get(os.path.join(old_dir, orec['file']))
for nrec in nrecs:
b = get(os.path.join(new_dir, nrec['file']))
fs = ticketdelta.compare(a, b, tx.parse_dim, tx.format_inches,
tx.locate)
results.append({'old': orec['file'], 'new': nrec['file'],
'mark': orec['mark'], 'control': nrec['control'],
'findings': fs})
for r in results:
counts = ticketdelta.summarise(r['findings'])
print('')
print('-' * 78)
print('%s %s -> %s' % (r['mark'], r['old'], r['new']))
print(' %d material, %d uncertain, %d advisory, %d housekeeping'
% (counts[ticketdelta.MATERIAL], counts[ticketdelta.UNCERTAIN],
counts[ticketdelta.ADVISORY], counts[ticketdelta.HOUSEKEEP]))
shown = 0
for i, f in enumerate(r['findings']):
if f['severity'] == ticketdelta.HOUSEKEEP and not args.all:
continue
shown += 1
print(' %s [%s] %s' % (SEV_MARK[f['severity']], f['kind'], f['where']))
print(' %s' % f['what'])
if f['old'] is not None or f['new'] is not None:
print(' %s -> %s'
% (_short(f['old']), _short(f['new'])))
if f['note']:
print(' (%s)' % f['note'])
if not shown:
print(' nothing above housekeeping.')
if args.crops:
if not os.path.isdir(args.crops):
os.makedirs(args.crops)
n = 0
for r in results:
for i, f in enumerate(r['findings']):
if f['severity'] != ticketdelta.MATERIAL:
continue
for side, key, pdfdir, fname in (
('old', 'loc_old', old_dir, r['old']),
('new', 'loc_new', new_dir, r['new'])):
loc = f.get(key)
if not loc:
continue
stem = os.path.splitext(fname)[0]
out = os.path.join(args.crops, '%s_%02d_%s.png'
% (stem, i, side))
try:
render_crop(os.path.join(pdfdir, fname), loc['page'],
loc['x'], loc['y'], out)
f.setdefault('crops', {})[side] = out
n += 1
except Exception as exc:
print(' crop failed: %s' % exc)
print('\n%d crop(s) written to %s' % (n, args.crops))
if args.json_out:
with open(args.json_out, 'w') as fh:
json.dump({'plan': {k: v for k, v in plan.items() if k != 'pairs'},
'results': results}, fh, indent=1, default=str)
print('\njson written to %s' % args.json_out)
total_material = sum(ticketdelta.summarise(r['findings'])[ticketdelta.MATERIAL]
for r in results)
print('')
print('=' * 78)
print('%d pair(s) compared, %d material change(s) in total.'
% (len(results), total_material))
return 0
def _short(v, n=110):
if v is None:
return '(none)'
v = str(v)
return v if len(v) <= n else v[:n - 3] + '...'
if __name__ == '__main__':
sys.exit(main())
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Ask a small vision model one question about one rendered ticket view.
python tools/vision_read.py <request.json> <response.json>
This is the CPython half of the Ticket QC vision escalation. It exists as a
separate process for the same two reasons `vibe.aiextract` does:
* the extension runs on IronPython 2.7 inside Revit's UI thread, and there
is no HTTP client there worth trusting with a model call;
* nothing here touches the Revit API. It reads PNGs and JSON and writes
JSON, so it has no business being inside Revit.
The two halves talk through files, never imports. `vibe.ticketvision` builds
the request, this writes the response, and neither imports the other.
WHAT THIS MAY AND MAY NOT DO
----------------------------
It may return a verdict from a model that looked at a picture. It may not
return anything that renders like a computed result. Every row it writes
carries `source: "model-read"`, and a row it could not obtain is
`status: "NOT_RUN"` with a reason -- never a pass, never silence. Reaching
the model is not the same as the model being right, and neither is the same
as this program having run at all; the three are kept separate on purpose.
CREDENTIALS
-----------
Read from the environment by the Anthropic SDK: `ANTHROPIC_API_KEY`, or an
`ant auth login` profile. There is no key in this file, no key in the request
JSON, no key in the response JSON, and no key in anything printed. Nothing
here ever reads a key into a variable, so nothing here can leak one. A missing
credential is a NOT_RUN with that reason.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import time
# Haiku, because the ruling was Haiku: the work code cannot do goes to the
# small model, and what the small model cannot settle goes to a person. It is
# also the only sane choice per-sheet on a model with four figures of them.
DEFAULT_MODEL = "claude-haiku-4-5"
# The three answers the skill allows. Anything else the model says is not an
# answer, and is treated as a refusal rather than coerced into one.
ANSWERS = ("clear", "problem_seen", "cannot_tell")
# One image, one question, one JSON object back. 400 output tokens is roughly
# four times the longest legal answer -- enough that a well-formed reply is
# never truncated, small enough that a runaway one costs nothing.
MAX_TOKENS = 400
# The API downscales any image whose long edge exceeds 1568 px, so a bigger
# render buys nothing and costs upload time. The extension renders to 1500;
# this is the ceiling it is staying under, restated here because the two
# halves cannot import each other and a silent drift between them would show
# up as unexplained illegibility.
IMAGE_LONG_EDGE_CEILING_PX = 1568
def _not_run(item, reason):
return {
"id": item.get("id"),
"question_id": item.get("question_id"),
"status": "NOT_RUN",
"reason": reason,
"answer": None,
"where": None,
"why": None,
"source": "model-read",
}
def _read_json(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def _write_json(path, obj):
with open(path, "w", encoding="utf-8") as fh:
json.dump(obj, fh, indent=2, sort_keys=True)
def load_skill(path):
"""The instructions the model reads, from the file that is versioned with
the code. Deliberately not a string in this module: a prompt buried in a
function is a prompt nobody reviews."""
with open(path, encoding="utf-8") as fh:
return fh.read()
def parse_answer(text, question_id):
"""The model's reply -> a result row, or None when it is not an answer.
Strict on purpose. A reply that is prose, or JSON with an answer word the
skill does not define, is NOT coerced into the nearest legal value -- it
is a refusal, because a coerced verdict is exactly the hallucination this
whole path is built to keep out of the report.
"""
if not text:
return None
body = text.strip()
if body.startswith("```"):
body = body.split("\n", 1)[-1]
if body.rstrip().endswith("```"):
body = body.rstrip()[:-3]
start, end = body.find("{"), body.rfind("}")
if start < 0 or end <= start:
return None
try:
obj = json.loads(body[start:end + 1])
except ValueError:
return None
if not isinstance(obj, dict):
return None
answer = obj.get("answer")
if answer not in ANSWERS:
return None
if obj.get("question_id") not in (None, question_id):
# It answered a different question than the one it was asked. That is
# not a near-miss to be salvaged; it means the reply is not about this
# image.
return None
why = obj.get("why")
where = obj.get("where")
return {
"answer": answer,
"where": where if isinstance(where, str) and where.strip() else None,
"why": why if isinstance(why, str) and why.strip() else None,
}
def build_user_content(item):
"""One image block, then the question. Image first: the guidance is that a
question about an image reads better after the image."""
with open(item["image"], "rb") as fh:
data = base64.standard_b64encode(fh.read()).decode("utf-8")
lines = [
"question_id: " + str(item.get("question_id")),
"",
"This is one view rendered off a precast shop ticket.",
]
if item.get("view_kind"):
lines.append("The view is a " + str(item["view_kind"]) + ".")
if item.get("effective_dpi"):
lines.append(
"It was rendered at about {0:.0f} dots per inch of the printed "
"sheet. If that was not enough to read it, say so."
.format(float(item["effective_dpi"])))
lines.append("")
lines.append("Answer the question named above, as one JSON object.")
return [
{"type": "image",
"source": {"type": "base64", "media_type": "image/png",
"data": data}},
{"type": "text", "text": "\n".join(lines)},
]
def ask(client, model, skill_text, item):
"""One call. Returns a result row; never raises for an API problem."""
try:
content = build_user_content(item)
except OSError as ex:
return _not_run(item, "the rendered image could not be read: "
"{0}".format(type(ex).__name__))
started = time.time()
try:
message = client.messages.create(
model=model,
max_tokens=MAX_TOKENS,
system=skill_text,
messages=[{"role": "user", "content": content}],
)
except Exception as ex: # noqa: BLE001 - see below
# Every failure to REACH the model is one outcome: NOT RUN, with the
# reason. Never a pass. The exception type is reported and the message
# is not, because an SDK error string can carry request detail and
# this file's whole discipline is that nothing sensitive is written
# to a file the tool keeps.
return _not_run(item, "the model could not be reached: {0}"
.format(type(ex).__name__))
text = "".join([b.text for b in message.content
if getattr(b, "type", None) == "text"])
parsed = parse_answer(text, item.get("question_id"))
if parsed is None:
return _not_run(item, "the model's reply was not one of the three "
"answers the skill defines")
usage = getattr(message, "usage", None)
row = _not_run(item, None)
row.update(parsed)
row["status"] = "OK"
row["reason"] = None
row["model"] = model
row["elapsed_s"] = round(time.time() - started, 2)
row["input_tokens"] = getattr(usage, "input_tokens", None)
row["output_tokens"] = getattr(usage, "output_tokens", None)
return row
def run(request):
"""The request dict -> the response dict. No I/O beyond the images."""
items = request.get("items") or []
model = request.get("model") or DEFAULT_MODEL
out = {"model": model, "source": "model-read", "results": []}
max_calls = request.get("max_calls")
if isinstance(max_calls, int) and max_calls >= 0 and len(items) > max_calls:
# The bound is the caller's, and it is enforced here as well as there.
# Over-budget items are NOT RUN naming the bound -- a dropped item
# would read as an item that passed.
for item in items[max_calls:]:
out["results"].append(_not_run(
item, "this run's vision call budget ({0}) was already spent"
.format(max_calls)))
items = items[:max_calls]
try:
skill_text = load_skill(request["skill"])
except (OSError, KeyError) as ex:
out["results"][:0] = [_not_run(
item, "the ticketqc-vision skill file could not be read "
"({0})".format(type(ex).__name__)) for item in items]
return out
try:
import anthropic
except ImportError:
out["results"][:0] = [_not_run(
item, "the anthropic SDK is not installed for this Python -- "
"pip install anthropic") for item in items]
return out
try:
client = anthropic.Anthropic()
except Exception as ex: # noqa: BLE001
out["results"][:0] = [_not_run(
item, "no Anthropic credential is available ({0}) -- set "
"ANTHROPIC_API_KEY in the environment, or run "
"`ant auth login`".format(type(ex).__name__))
for item in items]
return out
for i, item in enumerate(items):
out["results"].insert(i, ask(client, model, skill_text, item))
return out
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("request", help="request JSON written by ticketvision")
ap.add_argument("response", help="where to write the response JSON")
args = ap.parse_args(argv)
request = _read_json(args.request)
response = run(request)
_write_json(args.response, response)
ok = len([r for r in response["results"] if r["status"] == "OK"])
print("{0} answered, {1} NOT RUN".format(
ok, len(response["results"]) - ok))
return 0
if __name__ == "__main__":
sys.exit(main())
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Render a WillTools web page headless and screenshot every tab -- the
browser dry-run this machine could not do until 2026-09-07 (owner: "浏览器你
能不能在debian上面自己渲染一个?").
python tools/webshot.py pa --reads _scratch/pa_reads_x.json --out /tmp/shots
python tools/webshot.py ba --reads beamcalc/fixtures/rba5_16x22.json --out /tmp/shots
Starts tools/pa_web.py or tools/ba_web.py on a free port, drives Chrome
headless (google-chrome / chromium on PATH) to /?tab=N&expand=all for each
tab, writes <out>/<tool>-tab<N>.png plus <out>/<tool>-console.txt (the page's
console errors, which is the half a screenshot cannot show), then closes the
server through POST /api/close. Exit 1 if any tab logged a console error.
CPython 3 only; no Revit anywhere.
"""
import argparse, json, os, shutil, subprocess, sys, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def chrome():
for c in ('google-chrome', 'chromium', 'chromium-browser', 'chrome'):
p = shutil.which(c)
if p:
return p
sys.exit('no chrome/chromium on PATH')
def start_server(tool, reads):
args = [sys.executable, os.path.join(ROOT, 'tools', '%s_web.py' % tool), '--port', '0']
if reads:
args += ['--reads', reads]
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=ROOT)
url = None
for _ in range(200):
line = p.stdout.readline()
if not line:
break
if '_WEB_URL=' in line:
url = line.split('=', 1)[1].strip()
break
if not url:
err = p.stderr.read()
sys.exit('server did not print its URL\n' + err)
return p, url
def main():
ap = argparse.ArgumentParser()
ap.add_argument('tool', choices=['pa', 'ba'])
ap.add_argument('--reads')
ap.add_argument('--out', default='/tmp/webshot')
ap.add_argument('--tabs', type=int, default=5)
ap.add_argument('--width', type=int, default=1400)
ap.add_argument('--height', type=int, default=2200)
a = ap.parse_args()
os.makedirs(a.out, exist_ok=True)
p, url = start_server(a.tool, a.reads)
exe = chrome()
errors = []
try:
spec = json.load(urllib.request.urlopen(url + 'api/spec', timeout=30))
tabs = len(spec.get('pages', [])) or a.tabs
for n in range(1, tabs + 1):
png = os.path.join(a.out, '%s-tab%d.png' % (a.tool, n))
log = os.path.join(a.out, '%s-tab%d.console.txt' % (a.tool, n))
cmd = [exe, '--headless=new', '--no-sandbox', '--disable-gpu', '--hide-scrollbars',
'--window-size=%d,%d' % (a.width, a.height), '--virtual-time-budget=4000',
'--enable-logging=stderr', '--v=0',
'--screenshot=' + png, '%s?tab=%d&expand=all' % (url, n)]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
console = [l for l in r.stderr.splitlines() if 'CONSOLE' in l]
with open(log, 'w') as fh:
fh.write('\n'.join(console))
bad = [l for l in console if 'error' in l.lower() or 'uncaught' in l.lower()]
errors += bad
print('tab %d -> %s (%d console lines, %d errors)' % (n, png, len(console), len(bad)))
finally:
try:
urllib.request.urlopen(urllib.request.Request(url + 'api/close', method='POST', data=b'{}'), timeout=5)
except Exception:
pass
p.terminate()
if errors:
print('CONSOLE ERRORS:\n' + '\n'.join(errors[:20]))
sys.exit(1)
if __name__ == '__main__':
main()
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""wpf_smoke -- exec a button's System / System.Windows imports inside Revit.
A wrong WPF namespace is invisible to every host gate: host CPython cannot
import System.Windows at all (no .NET), so a button whose
`from System.Windows.Media import StretchDirection` (the type lives in
System.Windows.Controls) passes every host test and dies at import the first
time the owner clicks it. The bridge runs IronPython INSIDE Revit, where the
.NET assemblies exist, so the check is cheap: collect the script's System.*
import statements, ship them through tools/rvt.py as one snippet, and require
zero import errors. That is the only place they can be checked.
python tools/wpf_smoke.py <script.py> [--port N]
Host CPython 3 only -- this file never runs inside Revit (the SNIPPET it
builds is IronPython 2.7). Verdicts:
OK (exit 0) every collected import loaded
FAIL (exit 1) at least one import raised; one line per error
COULD NOT JUDGE (exit 2) the bridge could not run the check -- never OK
"""
from __future__ import annotations
import argparse
import os
import re
# Every complete import statement whose FIRST line names the System
# namespace. Anchored to the start of a (possibly indented) line so comment
# prose and string bodies are ignored; `System` must be followed by a dot,
# whitespace or end of line so a longer identifier like `Systematically` is
# not matched. Parenthesised continuations are followed separately.
_IMPORT_START = re.compile(r'^\s*from System(\.|\s)|^\s*import System(\.|\s|$)')
# The snippet head. The four references cover the WPF surface (System.Xaml
# holds System.Windows.Markup); the rest of System.* resolves through the
# assemblies IronPython already has. An AddReference failure is swallowed: a
# missing assembly is not itself the answer, the import below either finds
# the name or raises.
_REFERENCE_HEAD = (
'import clr\n'
"for _r in ('PresentationFramework','PresentationCore','WindowsBase',"
"'System.Xaml'):\n"
' try:\n'
' clr.AddReference(_r)\n'
' except Exception:\n'
' pass\n'
'_errors = []\n'
'_n = 0\n'
)
# How much of a failing statement is echoed in its error line.
_ERR_PREFIX_LEN = 80
def collect_system_imports(source: str) -> list:
"""Every complete ``System.*`` import statement in `source`, one per entry.
A statement starts at a line matching `_IMPORT_START`; a parenthesised
multi-line import is followed to the line that balances its parentheses
(counting ``(`` and ``)``). Each physical line is stripped and the parts
joined with a space, so the result is one valid one-line statement --
exactly the shape IronPython's ``exec`` expects when each entry runs on
its own inside the snippet.
"""
lines = source.splitlines()
out = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
if not _IMPORT_START.match(line):
i += 1
continue
parts = [line.strip()]
depth = line.count('(') - line.count(')')
i += 1
while depth > 0 and i < n:
nxt = lines[i]
# A line that itself starts a System import cannot be a
# continuation: the current parenthesised import is broken
# (never closed), so stop here and let the outer loop collect
# the next statement normally rather than swallowing it.
if _IMPORT_START.match(nxt):
break
depth += nxt.count('(') - nxt.count(')')
parts.append(nxt.strip())
i += 1
collapsed = ' '.join(parts)
# Whitespace-normalise the collapsed one-liner: no space after `(`,
# no space before `)`, and no runs of spaces elsewhere (a `(` or
# `)` alone on its own physical line would otherwise leave a
# padding space around it).
collapsed = re.sub(r'\(\s+', '(', collapsed)
collapsed = re.sub(r'\s+\)', ')', collapsed)
collapsed = re.sub(r'\s+', ' ', collapsed).strip()
out.append(collapsed)
return out
def build_snippet(statements: list) -> str:
"""The IronPython 2.7 snippet that loads each statement inside Revit.
Each statement is exec'd in its own try/except so one bad namespace -- the
bug this tool exists for -- is collected as an error instead of killing
the snippet and leaving the rest un-checked. The final `result` line is
the contract /exec reads to send data back (startup.py picks it out of
the namespace after running the snippet). The snippet is IronPython 2.7:
no f-strings, no pathlib; the formatting happens on the HOST side below.
"""
parts = [_REFERENCE_HEAD]
for stmt in statements:
# The error line is THREE concatenated literals in the emitted
# snippet: <repr of the statement, first 80 chars> + ' -> ' +
# str(_e). The statement text is single-quoted by repr(); ' -> '
# is its own separate literal, never folded into the statement
# string, so the emitted line stays valid Python.
parts.append(
'_n += 1\n'
'try:\n'
' exec({0})\n'
'except Exception as _e:\n'
' _errors.append({1} + \' -> \' + str(_e))'
.format(repr(stmt), repr(stmt[:_ERR_PREFIX_LEN])))
parts.append("result = {'import_statements_checked': _n, 'errors': _errors}")
return '\n'.join(parts) + '\n'
def _bridge_call(code, port=None):
"""Send the snippet through tools/rvt.py's /exec -- the same path that
`rvt.py exec <file>` takes, so the answer carries everything rvt guards
(correlation, the timeout latch, the reload tripwire) for free.
`port` is honoured the way every other bridge tool honours it: set
WILLTOOLS_BRIDGE_PORT in the environment BEFORE the call, because rvt
reads that variable per request, not at import. rvt is imported here
rather than at module scope so importing this module never touches the
network and never needs `tools/` on sys.path.
"""
if port is not None:
os.environ['WILLTOOLS_BRIDGE_PORT'] = str(port)
import rvt
return rvt.call('/exec', 'POST', {'code': code})
def judge(script: str, port=None):
"""Run the whole smoke for one script file.
Returns (exit_code, lines-to-print): 0 for OK, 1 for FAIL, 2 for COULD
NOT JUDGE. A refusal, a transport failure or an /exec that did not run
are all COULD NOT JUDGE -- "the bridge did not answer" is never reported
as "the imports are fine".
"""
try:
with open(script, 'r', encoding='utf-8') as fh:
source = fh.read()
except (OSError, UnicodeDecodeError) as exc:
return 2, ['COULD NOT JUDGE -- cannot read {0}: {1}'
.format(script, exc)]
statements = collect_system_imports(source)
snippet = build_snippet(statements)
try:
res = _bridge_call(snippet, port=port)
except SystemExit as exc:
# rvt.py refused or could not reach the bridge and already printed
# why on stderr; the verdict must still say it was not judged.
return 2, ['COULD NOT JUDGE -- the bridge call did not complete '
'(exit {0}); rvt.py printed why on stderr'
.format(exc.code)]
except Exception as exc: # pragma: no cover - defensive only
return 2, ['COULD NOT JUDGE -- the bridge call raised {0}: {1}'
.format(type(exc).__name__, exc)]
if not isinstance(res, dict):
return 2, ['COULD NOT JUDGE -- unexpected bridge answer: {0!r}'
.format(res)]
if res.get('refused'):
return 2, ['COULD NOT JUDGE -- the bridge refused to run the check: '
'{0}'.format(res.get('error') or 'refused')]
if not res.get('ok'):
return 2, ['COULD NOT JUDGE -- the snippet did not run inside Revit: '
'{0}'.format(res.get('error') or 'exec failed')]
result = res.get('result')
if not isinstance(result, dict):
return 2, ['COULD NOT JUDGE -- /exec returned no usable result']
errors = result.get('errors')
checked = result.get('import_statements_checked')
if not isinstance(errors, list) or not isinstance(checked, int):
return 2, ['COULD NOT JUDGE -- /exec result had an unexpected shape']
if errors:
head = 'FAIL {0} -- {1} import error(s):'.format(script, len(errors))
return 1, [head] + ['{0}'.format(e) for e in errors]
return 0, ['OK {0} -- {1} WPF import statements load inside Revit'
.format(script, checked)]
def main(argv=None) -> int:
ap = argparse.ArgumentParser(
prog='wpf_smoke', description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('script', help='path to the button script.py to smoke')
ap.add_argument('--port', type=int, default=None,
help='bridge port (default: WILLTOOLS_BRIDGE_PORT, '
'else 48884)')
args = ap.parse_args(argv)
code, lines = judge(args.script, port=args.port)
for line in lines:
print(line)
return code
if __name__ == '__main__':
raise SystemExit(main())