WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+516
@@ -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())
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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())
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 — {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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Executable
+90
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user