#!/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 (`/ panelanalysis/.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 `.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/.html"}. A solve exception is caught and reported the same shape, never a dead server. GET /results/ 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, `\t`, to `/panelanalysis/.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 `.client.log` -- into the FILE, never stdout. JOB ROOT resolution (where `.values.json` / `.pa.json` / the results HTML all live, one folder per mark under `/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:/` -- 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 `\t` 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())