# -*- 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 `'.'` -- 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[]` 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': }}` -- 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': {'.': {...}, ...}, '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)