Files
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

748 lines
32 KiB
Python

# -*- coding: utf-8 -*-
u"""Panel Analysis's WPF input form (前端), collapsed into ONE JSON document.
WHY THIS FILE EXISTS: the WPF renderer in `PanelAnalysis.pushbutton/script.py`
is being replaced by an HTML page rendered in WebView2 by another agent. That
agent does not write Python and does not read the five section modules
(`pa_criteria` / `pa_extents` / `pa_structural` / `pa_handling` /
`pa_reinforcement`) or the field model (`panelanalysisform`) directly -- this
module is the seam: it calls every `page(values, reads)` the way `script.py`
does, walks the block trees the way `script.py`'s `_render_block` dispatch
does, and turns the result into one `dict` that survives `json.dumps` with no
further Python involved. See `docs/pa-web-spec.md` for the exact JSON shape,
one example per block kind, the value/row-key contract, and the host<->page
message protocol built on top of this.
WHAT THIS MODULE IS NOT: it does not decide what a field means, what a
default is, or how a drawing is laid out -- all of that stays owned by
`panelanalysisform`, `pa_defaults`, and the four `pa_sketch_*` modules
respectively. `build_pagespec()` only serialises what those modules already
compute, the same "one seam, no new policy" discipline `script.py`'s own
renderer follows for the WPF side.
NO REVIT, EITHER PYTHON. Like every module it imports, this loads and runs
on plain CPython (`tests/conftest.py`'s trick, and `tools/pa_pagespec.py`'s
standalone CLI) and on IronPython 2.7 inside the button. No f-strings, no
`pathlib`, no `dataclasses`, no typing syntax; string formatting is
`'{0}'.format(...)`; every import is wrapped so a sibling module mid-edit (a
SyntaxError, not just an ImportError) degrades that one page to a named
`problems` entry rather than taking the whole spec down -- the same
discipline `script.py`'s `_load_section_module` already applies on the WPF
side, borrowed here rather than re-derived.
NEVER RAISES. `build_pagespec()` is meant to run with NO Revit and
sometimes with NOTHING typed in yet (the owner's scope note: the page must
open and run before Revit ever supplies a value) -- every lookup below is
defensive, and anything this module could not resolve becomes a `problems`
sentence, never a traceback.
THE TWO CALLING SHAPES:
build_pagespec(values, know) -- `values` already built (the
button's own `F.prefill()`
result, live from Revit).
build_pagespec(know=know, reads=reads) -- `values` is `None`; this
module builds it itself via
`panelanalysisform.prefill(
reads or {}, know)`, which
already applies every
`PREFILL_DEFAULTS` /
`DERIVED_FIELDS` / row-table
seed / `pa_defaults.
apply_defaults()` rule on an
EMPTY `reads` exactly as it
would on a real one -- so a
page opened with no Revit at
all still gets a complete,
defaulted form, not a blank
one.
Both shapes end up with a real `values` dict before anything else runs, so
every other rule below (row-table padding to `min_rows`, geometry
degrading to a placeholder rectangle, `choices` upgraded for a known
enumeration) applies identically whether Revit was ever in the loop.
"""
import json
# --------------------------------------------------------------- the pages
#
# (page id, display title, sibling module name) -- mirrors `script.py`'s own
# `SECTION_MODULES`, in the same order, so a reviewer can tell this is the
# same five sections, not a sixth invention.
PAGE_MODULES = (
('design_criteria', 'Design Criteria', 'pa_criteria'),
('concrete_extents', 'Concrete Extents', 'pa_extents'),
('structural_model', 'Structural Model', 'pa_structural'),
('handling', 'Handling', 'pa_handling'),
('reinforcement', 'Reinforcement', 'pa_reinforcement'),
)
# (drawing 'module', drawing 'source') -> the geometry function that module
# now exposes (see each `pa_sketch_*` module's own "real-unit geometry (ft)"
# section, added alongside this file). `pa_sketch_extents`'s 'outline' and
# 'section' sources share ONE geometry function: `section()`'s own picture is
# just the SAME outline+openings data cut at the current station, and
# `pa_sketch_extents.section_at()` is the general algorithm a page runs
# against that one geometry blob for any station -- there is no second,
# separate "section geometry" to compute up front. Extending this dict is
# the whole of what a sixth drawing needs on this side.
_GEOMETRY_FUNCS = {
('pa_sketch_extents', 'outline'): 'outline_geometry_ft',
('pa_sketch_extents', 'section'): 'outline_geometry_ft',
('pa_sketch_freebody', 'freebody'): 'freebody_geometry',
('pa_sketch_handling', 'lifter_dunnage'): 'lifter_dunnage_geometry',
('pa_sketch_reinf', 'section'): 'section_geometry',
('pa_sketch_reinf', 'outline'): 'outline_geometry',
}
_UNIT_RE = None # set lazily -- see `_split_unit`, kept off module import
# so a `re` compile failure (never seen, but this module's
# own rule is "no lookup may raise") cannot break import.
# ------------------------------------------------------------- basic safety
def _import_module(modname):
"""(module, None) or (None, exception) -- never raises.
Mirrors `script.py`'s `_load_section_module`: a sibling module mid-edit
(a SyntaxError, not just an ImportError while it does not exist yet) is
"not available", never a crash that takes every OTHER page down with it.
"""
try:
mod = __import__('vibe.' + modname, fromlist=[str(modname)])
return mod, None
except Exception as exc:
return None, exc
def _text_type():
try:
return (str, unicode) # Python 2
except NameError:
return (str,) # Python 3
_TEXT_TYPES = _text_type()
def _json_safe(obj):
"""`obj`, recursively coerced into something `json.dumps` accepts and a
JS reader gets back exactly what it expects: no tuples (-> lists), no
sets (-> sorted lists), no `NaN`/`Infinity` floats (-> `None`, the
honest "could not compute" answer this codebase uses everywhere else),
no non-string dict keys (-> `str(key)`). Never raises -- an object this
cannot recognise is returned as-is and left to `json.dumps` itself,
which raises a much clearer error than anything guessed here would.
"""
if obj is None or isinstance(obj, bool):
return obj
if isinstance(obj, _TEXT_TYPES):
return obj
if isinstance(obj, float):
try:
if obj != obj or obj in (float('inf'), float('-inf')):
return None
except Exception:
return None
return obj
if isinstance(obj, int):
return obj
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if not isinstance(k, _TEXT_TYPES):
k = str(k)
out[k] = _json_safe(v)
return out
if isinstance(obj, (list, tuple, set, frozenset)):
return [_json_safe(v) for v in obj]
return obj
def _split_unit(label):
"""('Elevation', 'ft') from 'Elevation (ft)'; (label, None) when there
is no trailing parenthetical. A display convenience only -- the label
text itself is never changed anywhere else in this module, this only
powers the `tables[*]['columns'][*]['unit']` field `docs/pa-web-spec.md`
documents. Never raises on a non-string label."""
if not label or not isinstance(label, _TEXT_TYPES):
return label, None
text = label.strip()
if text.endswith(')') and '(' in text:
i = text.rfind('(')
unit = text[i + 1:-1].strip()
base = text[:i].strip()
if unit and base:
return base, unit
return label, None
# --------------------------------------------------------- field vocabulary
def _choices_for(key):
"""`panelanalysisform.choices_for(key)`, or `None` on any failure --
same could-not-judge discipline every lookup in this module follows."""
try:
from vibe import panelanalysisform as F
return F.choices_for(key)
except Exception:
return None
def _field_spec(key):
try:
from vibe import panelanalysisform as F
return F._spec(key)
except Exception:
return None
_MULTI_VALUE_KINDS = ('supports', 'openings')
# Phrases a field's or matrix row's own `hint` uses to say the solve does
# not read it (the section modules already write one of these on every
# such control). A block whose hint carries one is stamped
# `unused_by_solve: true` so the page can mute it visibly -- the owner's
# question, 2026-09-07 (issue #22, item 2): "我现在选择 code,有意义吗?" A
# control that looks live and changes nothing is the thing this exists to
# stop; the hint text alone reaches only the collapsed Notes panel.
_UNUSED_HINT_PHRASES = ('not yet used by the solve', 'not used by the solve')
# Keys the solve does not read whose hint does not say so in those words.
# `design_spec`: no formula in `panellift` or `tools/panelanalysis.py`
# branches on the ACI edition (grep, 2026-09-07). The two miter flags
# only print a footnote sentence (`pa_extents._miter_sentence`);
# `top_in_form` (which face is up) is reference only. `section_type` is
# NOT here although its hint says "reference only": it drives the
# Sandwich-only fields' visibility and the wythe drawing, so muting it
# would misdescribe a control the page itself reacts to.
_UNUSED_BY_SOLVE_KEYS = frozenset(('design_spec', 'left_miter',
'right_miter', 'top_in_form'))
def _hint_says_unused(hint):
try:
text = (hint or '').lower()
except Exception:
return False
return any(phrase in text for phrase in _UNUSED_HINT_PHRASES)
def _sandwich_only_keys():
try:
from vibe import panelanalysisform as F
return tuple(getattr(F, 'SANDWICH_ONLY_KEYS', ()) or ())
except Exception:
return ()
def _normalize_field(block, ctx):
"""Mutates one `field` block dict in place: upgrades a plain text/num
editor to `choice` when `panelanalysisform` already knows a fixed list
for that key (mirrors `script.py`'s `_render_field`, so a page built
from this JSON offers the exact same dropdowns the WPF form does),
tags `multi_value` for a `supports`/`openings`-kind field (the field
model's OWN 'kind', which can disagree with the block's `editor` --
see `script.py`'s comment on `F._spec(key)['kind']` winning), and files
the key's choices (if any) into the shared `choices` dict."""
key = block.get('key')
editor = block.get('editor')
choices = block.get('choices')
if editor in (None, 'text', 'num') and key:
registry_choices = _choices_for(key)
if registry_choices:
editor = 'choice'
if not choices:
choices = list(registry_choices)
block['editor'] = editor
block['choices'] = list(choices) if choices else None
resolved = editor
if resolved not in _MULTI_VALUE_KINDS:
spec = _field_spec(key) if key else None
canonical = (spec or {}).get('kind')
if canonical in _MULTI_VALUE_KINDS:
resolved = canonical
block['multi_value'] = resolved in _MULTI_VALUE_KINDS
# Dependent visibility (issue #22, item 3): a Sandwich-only material /
# wythe field (`panelanalysisform.SANDWICH_ONLY_KEYS`, the field
# model's own declaration, which the retired WPF renderer honoured and
# the HTML page never did) is shown only while `section_type` is
# Sandwich. A block that already carries its own `visible_when` (the
# section module's ruling) keeps it.
if key and not block.get('visible_when') and key in _sandwich_only_keys():
block['visible_when'] = {'key': 'section_type', 'in': ['Sandwich']}
# "Not used by the solve" cue (issue #22, item 2) -- see
# `_UNUSED_HINT_PHRASES`. Never overrides a module's explicit False.
if 'unused_by_solve' not in block:
if (key in _UNUSED_BY_SOLVE_KEYS) or _hint_says_unused(
block.get('hint')):
block['unused_by_solve'] = True
if key:
ctx['field_keys'].add(key)
if block['choices']:
ctx['choices'][key] = list(block['choices'])
def _register_cell_choices(cell, ctx):
"""A `table` / `lc_table` / `matrix` cell dict -- files `choices[key]`
when the cell carries both, same as a `field` block does. Cells are
plain strings far more often than dicts; a non-dict cell is a no-op."""
if isinstance(cell, dict) and cell.get('key') and cell.get('choices'):
ctx['choices'][cell['key']] = list(cell['choices'])
def _register_table(block, ctx):
"""Files one `editable_table` block's shape into the shared `tables`
dict (`docs/pa-web-spec.md`'s `{name: {columns, min_rows, add_label}}`)
and each column's own `choices`, if any, under `'<table>.<col>'` --
a column's choices apply to every row of that column, not to one row's
key (which does not exist yet until a row is added), so this is the one
place `choices` is keyed by something other than a `values` key."""
name = block.get('table')
if not name:
return
colspecs = block.get('columns') or []
columns = []
for c in colspecs:
col = c.get('col')
label, unit = _split_unit(c.get('label'))
entry = {'key': col, 'label': c.get('label') or col,
'unit': unit, 'editor': c.get('editor')}
if c.get('choices'):
entry['choices'] = list(c['choices'])
ctx['choices']['{0}.{1}'.format(name, col)] = list(c['choices'])
columns.append(entry)
entry = {
'columns': columns, 'min_rows': block.get('min_rows') or 0,
'add_label': block.get('add_label') or 'Add row',
}
# `empty_state` (issue #22, items 5/11): what the page prints under a
# table with no rows -- `{'basis_keys': [...], 'text': ...}`, the first
# `basis[<key>]` sentence found wins, else `text`. Mirrored here so a
# page can find it without walking every block, same as `columns`.
if isinstance(block.get('empty_state'), dict):
entry['empty_state'] = dict(block['empty_state'])
ctx['tables'][name] = entry
# ------------------------------------------------------------- geometry
def _drawing_geometry(block, ctx):
"""Computes and files `ctx['geometry'][drawing_key]` for one `drawing`
block, and returns `drawing_key` so the caller can stamp it onto the
block. Never raises: a missing mapping, an unimportable module, a
missing function, or the geometry function itself raising all degrade
to a `{'problems': [...]}` stand-in -- a page must always find SOME dict
at `geometry[drawing_key]`, never a missing key."""
modname = block.get('module')
source = block.get('source')
drawing_key = '{0}.{1}'.format(ctx['page_id'], source or 'drawing')
if drawing_key in ctx['geometry']:
return drawing_key
fn_name = _GEOMETRY_FUNCS.get((modname, source))
if not fn_name:
ctx['geometry'][drawing_key] = {'problems': [
'no geometry function is registered for {0}.{1} yet; this '
'drawing cannot be built from data alone.'.format(
modname, source)]}
return drawing_key
mod, err = _import_module(modname)
if mod is None:
ctx['geometry'][drawing_key] = {'problems': [
"vibe.{0} could not be loaded ({1}).".format(modname, err)]}
return drawing_key
fn = getattr(mod, fn_name, None)
if fn is None:
ctx['geometry'][drawing_key] = {'problems': [
"'{0}' is not available in vibe.{1}.".format(fn_name, modname)]}
return drawing_key
try:
geo = fn(ctx['values'])
except Exception as exc:
geo = {'problems': ['{0} raised while computing geometry: {1!r}'
.format(fn_name, exc)]}
if not isinstance(geo, dict):
geo = {'problems': ['{0} returned something that was not a dict; '
'no geometry available.'.format(fn_name)]}
ctx['geometry'][drawing_key] = _json_safe(geo)
return drawing_key
# --------------------------------------------------------------- the walk
def _walk(block, ctx):
"""One block (or a list of blocks) -> the same shape, JSON-safe, with
every nested container recursed into and every field/table/drawing
registered into `ctx`. Mirrors `script.py`'s `_render_block` dispatch
one kind at a time, but SERIALISES instead of rendering -- nothing here
ever builds a WPF object, and nothing here ever raises: an exception
from a nested call is caught per-block, the same 'a bad block never
takes the page down' rule `script.py` follows, and becomes a `sentence`
block naming the failure in its place.
"""
if isinstance(block, list):
return [_walk(b, ctx) for b in block]
if not isinstance(block, dict):
return block
try:
out = dict(block)
kind = out.get('kind')
if kind == 'columns':
out['columns'] = [[_walk(b, ctx) for b in (col or [])]
for col in (out.get('columns') or [])]
elif kind == 'section':
out['blocks'] = [_walk(b, ctx) for b in (out.get('blocks') or [])]
elif kind == 'field':
_normalize_field(out, ctx)
elif kind in ('table', 'lc_table'):
rows = []
for row in (out.get('rows') or []):
new_row = []
for cell in row:
_register_cell_choices(cell, ctx)
new_row.append(cell)
rows.append(new_row)
out['rows'] = rows
elif kind == 'matrix':
rows = []
for row in (out.get('rows') or []):
row = dict(row) if isinstance(row, dict) else {'cells': []}
if 'unused_by_solve' not in row and _hint_says_unused(
row.get('hint')):
row['unused_by_solve'] = True
cells = []
for cell in (row.get('cells') or []):
_register_cell_choices(cell, ctx)
cells.append(cell)
row['cells'] = cells
rows.append(row)
out['rows'] = rows
elif kind == 'editable_table':
_register_table(out, ctx)
elif kind == 'drawing':
out['drawing_key'] = _drawing_geometry(out, ctx)
return _json_safe(out)
except Exception as exc:
return {'kind': 'sentence', 'quiet': False,
'text': '[block failed to serialise: {0!r}]'.format(exc)}
def _page_accepts_know(fn):
"""Whether `fn` (a page module's `page`) declares a `know` parameter --
checked by INTROSPECTION, not by calling it and catching `TypeError`,
because a `TypeError` raised from INSIDE a working `page()` call would
otherwise be silently reinterpreted as 'this module's signature is
older' and retried, masking a real bug as a compatibility fallback.
Both Pythons: `inspect.signature` (3.3+) first, `inspect.getargspec`
(still present on IronPython 2.7's CPython-2-shaped `inspect`, though
removed from CPython 3.11+) as the fallback. Any introspection failure
-- neither exists, or `fn` is not introspectable at all -- answers
`False`, the safe "call it the old way" direction.
"""
import inspect
try:
return 'know' in inspect.signature(fn).parameters
except (AttributeError, ValueError, TypeError):
pass
try:
spec = inspect.getargspec(fn) # noqa: E501 (py2/IronPython path)
return 'know' in spec.args
except Exception:
return False
def _load_page(page_id, title, modname, values, reads, know=None):
mod, err = _import_module(modname)
if mod is None:
return {'id': page_id, 'title': title, 'blocks': [],
'problems': ["vibe.{0} could not be loaded ({1}).".format(
modname, err)]}
try:
# `know` (the merged knowledge tree) is a NEWER, optional third
# argument -- today only `pa_handling.page` reads it (the Lifting
# Hardware table's Tension/Shear columns, `vibe.knowledge.
# lifter_capacity`). Every other page module still only accepts
# `(values, reads)`; `_page_accepts_know` decides which call shape
# to use so an older module is never handed an argument it does
# not declare.
if _page_accepts_know(mod.page):
blocks, page_problems = mod.page(values, reads, know=know)
else:
blocks, page_problems = mod.page(values, reads)
except Exception as exc:
return {'id': page_id, 'title': title, 'blocks': [],
'problems': ['page() raised: {0!r}'.format(exc)]}
return {'id': page_id, 'title': title, 'blocks': blocks or [],
'problems': list(page_problems or [])}
# ------------------------------------------------------------- provenance
def _basis_dict(values):
"""`values['_basis']` (keys like `'mark_basis'`, one sentence each,
written by `panelanalysisform.prefill()`) -> `{key: sentence}` with the
`_basis` suffix stripped, so the page can look a field's provenance up
by the SAME key it already uses for that field's value. Never raises on
a malformed `_basis`."""
raw = values.get('_basis') if isinstance(values, dict) else None
out = {}
if not isinstance(raw, dict):
return out
for key, sentence in raw.items():
if not isinstance(key, _TEXT_TYPES):
continue
clean = key[:-len('_basis')] if key.endswith('_basis') else key
out[clean] = sentence
return out
def _derived_dict():
"""`panelanalysisform.DERIVED_FIELDS` as `{key: {'sources': [...],
'formula': <name>}}` -- the page recomputes each derived field
client-side as a source is edited (issue #22, item 1), mirroring the
named formula (`concrete_modulus_psi`: Ec = 33 * wc^1.5 * sqrt(f'c),
rounded to whole psi, exactly as `prefill` does) until the user types
into the derived field itself. Empty on any failure: the page then
simply never recomputes, which is what it did before."""
out = {}
try:
from vibe import panelanalysisform as F
for key, spec in (getattr(F, 'DERIVED_FIELDS', None) or {}).items():
sources, fn = spec
out[key] = {'sources': list(sources),
'formula': getattr(fn, '__name__', str(fn))}
except Exception:
return {}
return out
def _problem_targets():
"""`panelanalysisform.LEGACY_TABLE_KEYS` -- legacy field key -> the row
table that replaced it, so a `validate()` problem still keyed by the
legacy name (`supports_ft`) lands on the table the user actually sees
(issue #22, item 11). Empty on any failure."""
try:
from vibe import panelanalysisform as F
return dict(getattr(F, 'LEGACY_TABLE_KEYS', None) or {})
except Exception:
return {}
def _defaults_why_dict(values, field_keys):
"""`{key: why}` from two sources, neither dropped:
1. `values['_defaults_applied']` -- the `[(key, value_text, why), ...]`
list `panelanalysisform.prefill()` records for defaults it actually
applied THIS run (a real value read off the model always wins, so a
key here means the default is what is actually showing).
2. `pa_defaults.why_of(key)`, for every field key this spec renders that
(1) did not already cover -- the RULE ("plant knowledge X when set,
else Y"), not necessarily the run's own choice, but still the honest
answer to "why would this ever be filled in" for a field the model
supplied a real value for and so never went through `apply_defaults`
at all.
Never raises: a malformed `_defaults_applied` entry is skipped, and a
`pa_defaults` that fails to import leaves (2) empty rather than losing
(1).
"""
out = {}
applied = values.get('_defaults_applied') if isinstance(values, dict) \
else None
for entry in (applied or []):
try:
key, _value_text, why = entry
except (TypeError, ValueError):
continue
if isinstance(key, _TEXT_TYPES):
out[key] = why
try:
from vibe import pa_defaults
for key in field_keys:
if key in out:
continue
why = pa_defaults.why_of(key)
if why:
out[key] = why
except Exception:
pass
return out
def _pad_table_rows(values, tables):
"""Adds blank rows, in place on `values`, until every table in `tables`
has at least its own `min_rows` present rows -- the CLI / no-Revit path
the owner asked for: a page opened with nothing typed yet still shows
each table's minimum shape, not zero rows. A `min_rows` of 0 (every
table today) makes this a no-op; it exists so a future page module that
DOES set `min_rows` gets the row padding for free, on Revit or off it,
the same way `script.py`'s `editable_table_widget` already pads on the
WPF side. Never raises -- a `panelanalysisform` that will not import
leaves every table exactly as `prefill()` (or the caller) built it.
"""
try:
from vibe import panelanalysisform as F
except Exception:
return
for name, meta in tables.items():
min_rows = meta.get('min_rows') or 0
if min_rows <= 0:
continue
cols = [c['key'] for c in meta.get('columns') or [] if c.get('key')]
if not cols:
continue
try:
ids = F.row_ids(name, values)
while len(ids) < min_rows:
F.add_row(name, values, cols, {})
ids = F.row_ids(name, values)
except Exception:
continue
# ------------------------------------------------------------------ public
def build_pagespec(values=None, know=None, reads=None):
u"""The whole Panel Analysis form as one JSON-serialisable `dict`.
`values` is `panelanalysisform.prefill()`'s own string-valued dict (plus
its structured passthrough keys, e.g. `openings_rows`) -- pass it when
the caller already built one (the live button always will). Pass `None`
to have this module build it itself, from `reads` (a live-model read
dict, or `{}`/`None` for "no Revit at all") and `know` (the merged
knowledge tree, or `None`/`{}` for "no plant layer") -- see the module
docstring's "THE TWO CALLING SHAPES". Either way the result is a COPY;
the caller's own `values` dict (if it passed one) is never mutated.
Returns:
{'schema': 1, 'title': 'Panel Analysis', 'mark': str,
'pages': [{'id', 'title', 'blocks': [...], 'problems': [...]}, ...],
'values': {key: str, ...}, # every table row key included
'basis': {key: str, ...},
'defaults_why': {key: str, ...},
'choices': {key: [str, ...], ...},
'tables': {name: {'columns': [...], 'min_rows', 'add_label'}, ...},
'geometry': {'<page_id>.<source>': {...}, ...},
'problems': [str, ...]}
See `docs/pa-web-spec.md` for the full schema, one example per block
kind, and the value/row-key contract. Never raises: every stage below is
wrapped so a caller gets a usable (if degraded, and honestly labelled)
spec rather than an exception, on Revit or off it.
"""
top_problems = []
know = know if isinstance(know, dict) else {}
reads = reads if isinstance(reads, dict) else {}
if values is None:
try:
from vibe import panelanalysisform as F
values = F.prefill(reads, know=know)
except Exception as exc:
top_problems.append(
'panelanalysisform.prefill() raised while building values '
'from reads/know: {0!r}; the spec below has no fields '
'pre-filled.'.format(exc))
values = {}
values = dict(values) if isinstance(values, dict) else {}
# Owner ruling, 2026-09-05: `openings_rows` (what the outline/section
# drawings and `panelanalysisform._openings_for_deck` read) is DERIVED
# from the `opening` row table whenever that table carries any present
# row, so an edit made through the page's own Add/Remove-row UI is what
# the drawings redraw from on the NEXT `/api/spec` -- not the frozen
# model-read snapshot `prefill`'s PASSTHROUGH_KEYS copy took at
# spec-build time. A table with no present rows (a fresh blank form, or
# a panel saved before this table existed) leaves `values['openings_
# rows']` exactly as `prefill` set it -- the same legacy fallback
# `validate()` and `pa_extents._openings_summary_sentence` also use.
try:
from vibe import panelanalysisform as _F_opening
_table_opening_rows = _F_opening._opening_rows_from_table(values)
if _table_opening_rows:
values['openings_rows'] = _table_opening_rows
except Exception:
pass
ctx = {'tables': {}, 'choices': {}, 'geometry': {},
'field_keys': set(), 'values': values, 'page_id': None}
pages = []
for page_id, title, modname in PAGE_MODULES:
raw_page = _load_page(page_id, title, modname, values, reads,
know=know)
ctx['page_id'] = page_id
raw_page['blocks'] = [_walk(b, ctx) for b in raw_page['blocks']]
pages.append(raw_page)
_pad_table_rows(values, ctx['tables'])
basis = _basis_dict(values)
defaults_why = _defaults_why_dict(values, ctx['field_keys'])
values_out = {}
for key, v in values.items():
if key in ('_basis', '_defaults_applied'):
continue
values_out[key] = v
values_out = _json_safe(values_out)
mark = (reads.get('mark') or values.get('mark') or '')
try:
mark = mark.strip()
except Exception:
mark = str(mark) if mark else ''
return {
'schema': 1,
'title': 'Panel Analysis',
'mark': mark,
'pages': pages,
'values': values_out,
'basis': _json_safe(basis),
'defaults_why': _json_safe(defaults_why),
'choices': _json_safe(ctx['choices']),
'tables': _json_safe(ctx['tables']),
'geometry': _json_safe(ctx['geometry']),
'derived': _json_safe(_derived_dict()),
'problem_targets': _json_safe(_problem_targets()),
'problems': top_problems,
}
def dumps(spec, **kwargs):
"""`json.dumps(spec)`, with `sort_keys=True` and `ensure_ascii=False`
unless the caller overrides either -- a thin convenience so
`tools/pa_pagespec.py` and any test do not each pick their own
formatting.
`ensure_ascii=False`, not json's own `True` default: IronPython 2.7's
`json` module has no C-accelerated ASCII escaper, and its pure-Python
fallback (`py_encode_basestring_ascii`) raises `UnicodeEncodeError` on
an ordinary BMP character (measured live, 2026-09-05, on one of this
form's own superscript/prime symbols, e.g. U+207B in a field's
`symbol`) instead of escaping it -- so the ASCII-safe path is the one
that actually crashes inside Revit. `ensure_ascii=False` sidesteps that
encoder entirely and writes real UTF-8 (matching every other UTF-8 file
this codebase writes, coding-cookie included); the caller's `io.open`
with `encoding='utf-8'` is what actually gets the bytes right, the same
as every other JSON file this codebase writes off Revit.
"""
kwargs.setdefault('sort_keys', True)
kwargs.setdefault('indent', 1)
kwargs.setdefault('ensure_ascii', False)
return json.dumps(spec, **kwargs)