25592fa6c2
ci / test (push) Successful in 6m14s
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.
748 lines
32 KiB
Python
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)
|