Files
willtools-demo/tools/pa_web.py
T
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

633 lines
26 KiB
Python

#!/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())