# -*- coding: utf-8 -*- """Panel Analysis input form -- the "Concrete Extents" section, laid out. The owner has said three times the Panel Analysis form is too ugly to read ("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成 普通人能看懂的样子" -- every part of it has to be something an ordinary person, not just an engineer, can understand. The fix agreed on is to make the form look like Eriksson Wall's own printed page for this section (a solid wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals / Solid Zones table, and a drawings row) but with a plain-English `label` beside every engineering `symbol` and a one-sentence `hint` on every field -- never the symbol alone, standing in as if it were the explanation. `page(values, reads=None)` returns `(blocks, problems)`: a flat list of block dicts from a fixed vocabulary a generic WPF renderer (built separately, not here) consumes, plus a list of plain-English sentences for anything that could not be laid out. This module never raises -- a field it cannot place becomes a `problems` entry, never a traceback the owner has to read a Python stack to understand. WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to load and run on plain CPython under `tests/conftest.py`'s trick (see that file's docstring) the same way the rest of Revit-free `vibe` does, and it has to run under IronPython 2.7 inside the button. `panelsketch` is named by STRING in a `drawing` block's `source` field -- the renderer calls it, not this module -- because a sibling agent is extending `panelsketch` at the same time this file is being written, and importing a module mid-rewrite is how two agents' work collides. WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns `vibe.panelanalysisform`, the source of the form's key names, and is restructuring it at the same time this file is being written. A key this module expects that is not actually there is "not present", never treated as a bug -- `_paf_field` returns `None` and the caller falls back rather than raising. The same discipline applies to the structured opening rows a third agent is meant to be adding at `values['openings_rows']` (shaped `{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross', 'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's own contract): when it is not there yet, or is not a list, this module falls back to the raw `values['openings']` text field and SAYS SO in `problems`, rather than guessing at a shape that has not landed. WHY THE DRAWING AREA NO LONGER PRINTS ERIKSSON'S OWN SECTION PROPERTIES ROWS (Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb, St) OR A SIDE/FRONT ELEVATION SPLIT: the owner ruled, 2026-09-03, that Concrete Extents "does not need all those parameters -- Area, Width and so on. In that section just draw the member's concrete edge. If there are openings, say so, and add a few dimension lines that locate the openings on the drawing." That replaced the old three-column drawings row (Side Elevation / Front Elevation / Section Properties, all built on `panelsketch.elevation()`, whose own docstring says it draws the front and side views together as ONE picture -- so "Side Elevation" and "Front Elevation" used to be the same combined image under two captions, a real limitation this module used to paper over with a `problems` sentence rather than a fix) with exactly two drawings and one sentence: `panelsketch.extents_outline()` (the panel's own length x width edge, two overall dimensions, and any openings drawn to scale with a locating dimension chain) and `panelsketch.extents_section()` (the width x thickness edge, a thickness dimension, and the Sandwich wythe layers when the section is one), plus `_openings_summary_sentence()`'s one line: "N opening(s), M reveal(s)" or "No openings or reveals." Both new `panelsketch` functions omit what `elevation()` / `section()` draw that this page has no data of its own for -- supports, a side view, section properties, and (the concrete reason the drawing "looked like garbage") the strand dots and labels `section()` always draws when `values['strand_rows']` happens to be present, which it can be even here since `values` is the WHOLE form's dict, not a slice scoped to this page. WHY THE TWO DRAWINGS ARE NOW STACKED, FULL WIDTH, WITH A DRAGGABLE CUT LINE (owner ruling, 2026-09-04): "concrete extent 那个地方,panel outline 的图放 的更大一些。然后 cross section 跟它做成上下并列,而不是左右并列。panel outline 上面做一个 cut-line,用户可以拖动,然后 cross section 动态加载, 显示当前的 cross-section,跟做核磁一样" -- the outline drawing gets bigger, the section moves BELOW it (not beside it) so the outline is never squeezed to make room for a companion, and a draggable cut line on the outline picks which along-length station the section below it shows -- "like an MRI", scrubbing through slices. And generally: "画图的时候,panel 的长度总要占满 整个显示窗口的宽度,因为不管什么板,你总是横着画" -- the panel's length always fills the full width of whatever window it is drawn in, because a panel is always drawn lying on its side, whatever its actual proportions. The two `columns`-wrapped, fixed-pixel (`w`/`h`) `drawing` blocks this page used to emit are gone; `_extents_drawing_blocks()` below emits two `full_width: True` blocks instead, each carrying `aspect` (the drawing's own height/width ratio, so the renderer can give it exactly the full column width and no more) rather than a baked-in pixel size, plus `module: 'pa_sketch_extents'` naming the sibling module that owns the actual drawing functions (`outline()` / `section()`) -- NOT `panelsketch` any more, because `panelsketch.extents_outline()` / `extents_section()` do not know about a cut line or a station, and teaching them would mean growing the OLD Structural Model drawings' shared module for a Concrete-Extents-only idea. `pa_sketch_extents` calls straight into `panelsketch`'s existing helpers (`extents_outline()` for the base outline picture, `_parse_opening_row()` for the section's opening/reveal geometry) rather than re-deriving them -- see that module's own docstring. The outline block also carries `cutline: {'key': '_cut_frac', 'default': 0.5}`, telling the renderer which form value the draggable line reads and writes (a plain 0..1 fraction of the length, stored as text like every other field this form has), and both blocks carry `depends_on`, the list of `values` keys a redraw should watch -- the section block's list starts with `'_cut_frac'` itself, because dragging the line is the whole point of the section being drawn separately from the outline in the first place. Miters get the same "print a sentence, never leave a blank" discipline Eriksson uses on its own page (an unchecked "Left Miter" box there is not just left bare -- it prints "Left miter not included in design" underneath). `_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is silent, because blank means "not yet decided", never "no" (same rule `panelanalysisform._parse_bool` documents for its own checkboxes); a miter answered explicitly "no" prints the sentence, in words, the way Eriksson does. """ # `panelanalysisform` carries no Revit import at module scope (see its own # docstring), so this succeeds on plain CPython under `tests/conftest.py`'s # trick and under IronPython 2.7 alike. If the sibling agent restructuring it # has, at some moment, left it unimportable, this module still has to load -- # so the import itself is guarded, and every reader of `paf` below is guarded # again on top of that (see `_paf_field`). try: from vibe import panelanalysisform as paf except ImportError: paf = None # `feet_str` (decimal feet -> '8\'-4 1/2"') is pure standard-library, no # Revit -- shared with `vibe.pa_handling`, which needed the identical # rounding for its own kv/table cells (2026-09-05); kept as `_feet_str` here # so every existing caller in this file needs no rewrite. from vibe.pa_units import feet_str as _feet_str # noqa: E402 # --------------------------------------------------------------- utilities def _num(value): """`value` (a form string, or already a number) as a float, or None. None covers "blank", "not a number", and "not present" alike -- the caller only ever needs to know whether there is a usable number, and `validate()`-style error text is not this module's job (the button's completeness gate already owns that; this module only lays out what it is given). """ if value is None: return None if isinstance(value, (int, float)): return float(value) text = str(value).strip() if not text: return None try: return float(text) except (TypeError, ValueError): return None def _fmt_num(value): """A float as the plain string this page prints -- '8', not '8.0'.""" if value is None: return '' if float(value) == int(value): return str(int(value)) return ('%.4f' % value).rstrip('0').rstrip('.') def _dim_or_dash(value): """A table cell for one dimension: Eriksson's own '-' for blank.""" if value is None: return '-' if isinstance(value, str): value = value.strip() return value if value else '-' text = _feet_str(value) return text if text is not None else '-' def _from_word(word): """'start' -> 'Start'; blank/None -> ''. Never invents a datum word.""" if not word: return '' word = str(word).strip() return word[:1].upper() + word[1:].lower() if word else '' def _pos_cell(pos, frm): """A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'.""" dim = _dim_or_dash(pos) if dim == '-': return '-' frm_text = _from_word(frm) return '{0} from {1}'.format(dim, frm_text) if frm_text else dim _TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal', 'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'} def _type_label(value): """The Type column's text -- a known word capitalised Eriksson's way, or, for anything this table does not recognise, the entered text back in title case rather than a guessed category -- echoing what was typed is honest; picking the "closest" known type is not. """ if not value: return '-' key = str(value).strip().lower().replace('_', ' ') if key in _TYPE_LABELS: return _TYPE_LABELS[key] return str(value).strip().title() if str(value).strip() else '-' def _tri_bool(text): """(True/False/None) for a checkbox's raw text. None = "not yet said". Mirrors `panelanalysisform._parse_bool`'s own rule (blank is "unspecified", never "no") without importing that private helper across a module boundary neither module owns the other's internals of. """ if text is None: return None t = str(text).strip().lower() if not t: return None if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'): return True if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded', 'unchecked'): return False return None # --------------------------------------------------------- panelanalysisform def _paf_field(key): """The raw field dict `panelanalysisform.FIELDS` has for `key`, or None. None covers every way `paf` can fail to have the answer: the module failed to import, it has no `FIELDS` (mid-restructure), or `key` is simply not one it declares yet. All three are "not present", never an error -- see the module docstring. """ if paf is None: return None try: fields = paf.FIELDS except AttributeError: return None try: for f in fields: if f.get('key') == key: return f except (TypeError, AttributeError): return None return None def _field(key, editor, fallback_label, fallback_symbol, fallback_hint, fallback_required, problems, choices=None, visible_when=None): """One `field` block, sourced from `panelanalysisform` when it has the key and from the fallback text when it does not. A missing key still gets a field on the page -- the Eriksson layout this section is copying has a slot there -- but `problems` names it, because a value typed into a field `panelanalysisform` does not declare cannot be saved into the deck yet. That is the honest state of a form being restructured by another agent at the same time, not a bug in this file. """ spec = _paf_field(key) if spec is not None and paf is not None: try: label = paf.label_of(key) symbol = paf.symbol_of(key) or None hint = paf.help_of(key) or fallback_hint required = bool(spec.get('required')) except AttributeError: spec = None if spec is None: label = fallback_label symbol = fallback_symbol or None hint = fallback_hint required = fallback_required problems.append( "{0!r} is not a field vibe.panelanalysisform declares yet; " "shown here to match Eriksson's own Concrete Extents page, but " "a value typed into it will not be saved until that module " "adds this key.".format(key)) block = {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol, 'hint': hint, 'required': required, 'editor': editor, 'choices': choices} if visible_when: # `{'key': , 'in': [, ...]}` -- the page shows # this field only while `values[key]` is one of those (see # `docs/pa-web-spec.md`, "visible_when"); absent = always shown. block['visible_when'] = dict(visible_when) return block def _miter_sentence(values, key, side): """Eriksson's own discipline: an OFF miter prints a sentence, not a gap. Blank (unanswered) prints nothing here, same as Eriksson's page shows nothing extra for a miter nobody has touched yet -- only an explicit "no" earns the sentence, because blank means "not yet decided", not "excluded" (see `_tri_bool`). `'quiet': True` (owner ruling, 2026-09-04): this is a footnote beside the miter field it explains, not a finding -- it stays a `sentence` block (the renderer already draws every `sentence` in grey italic, see `script.py`'s `_render_sentence`) with the extra flag set so a renderer that later wants to tell "a footnote beside a field" apart from "the openings summary line" (the other `sentence` this page emits, deliberately left un-quiet) can do so without guessing from the text. """ if _tri_bool(values.get(key)) is False: return {'kind': 'sentence', 'quiet': True, 'text': '{0} miter not included in this panel\'s design.' .format(side)} return None # ------------------------------------------------------------- sub-sections def _panel_geometry_columns(values, problems): """The three columns Eriksson's own Panel Geometry block prints: (Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) | (Width, Left Miter, Right Miter, Top In Form, Width, Height) | (Thickness). `length_ft` (this codebase's name for the panel's long dimension) is what Eriksson calls "Height" on this page -- the same field, read through `panelanalysisform`'s own label rather than renamed here, so a reviewer sees the one name every section of the form uses for it. """ col1 = [ _field('top_elevation_ft', 'num', 'Top elevation', 'ft', 'How high the top edge of the panel sits above the ' 'project reference elevation. Leave blank if not yet ' 'known.', False, problems), _field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft', 'How high the bottom edge of the panel sits above the ' 'project reference elevation. Leave blank if not yet ' 'known.', False, problems), _field('length_ft', 'num', 'Panel length', 'ft', "The panel's long dimension, end to end. Required -- " 'everything downstream is measured against this span.', True, problems), _field('section_type', 'text', 'Section type', '', 'A short description of the cross-section (solid, ' 'sandwich, ribbed, ...). Reference only.', False, problems), _field('top_in_form', 'text', 'Which face is up in the casting ' 'bed', 'Interior / Exterior', 'Which face of the panel faces up while it is cast. ' 'Reference only.', False, problems, choices=['Interior', 'Exterior'], # Owner, 2026-09-07 (issue #22, item 3): "section type = # solid 的时候,which face is up the casting bed 不应该存在" # -- a Solid panel has no face that matters here, so the # field is shown only while the section is Sandwich. The # page hides/shows it live off `section_type` (see # `docs/pa-web-spec.md`, `visible_when`). visible_when={'key': 'section_type', 'in': ['Sandwich']}), ] col2 = [ _field('width_ft', 'num', 'Panel width', 'ft', "The panel's dimension across the span -- how wide it is. " 'Required.', True, problems), _field('left_miter', 'choice', 'Left miter', '', 'Whether the left end of the panel is cut at an angle ' '(mitered) instead of square. Leave blank if this has ' 'not been decided yet.', False, problems, choices=['Yes', 'No']), ] sentence = _miter_sentence(values, 'left_miter', 'Left') if sentence: col2.append(sentence) col2.append( _field('right_miter', 'choice', 'Right miter', '', 'Whether the right end of the panel is cut at an angle ' '(mitered) instead of square. Leave blank if this has ' 'not been decided yet.', False, problems, choices=['Yes', 'No'])) sentence = _miter_sentence(values, 'right_miter', 'Right') if sentence: col2.append(sentence) # `top_in_form_check` / `top_in_form_width_in` / `top_in_form_height_in` # (Eriksson's "top in form" STEP -- a change in formed thickness at # the top edge) used to render here and are deliberately NOT rendered # since 2026-09-07 (issue #22, item 4): nothing reads them -- not # `pa_sketch_extents`, not the weight/centroid summary, not # `panellift` -- and the owner's complaint was exactly a checkbox and # two boxes that took input and changed nothing. They stay declared in # `panelanalysisform.FIELDS` so a saved value survives, and return to # this column the day the step is folded into the drawn section, the # weight and the centroid. See `test_pa_extents.py`'s tombstone. col3 = [ _field('thickness_in', 'num', 'Panel thickness', 'in', 'How thick the panel is. Required.', True, problems), ] return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2}, {'kind': 'columns', 'columns': [col1, col2, col3]}] # --------------------------------------------------------- extents drawings # # The block dict shape below is a CONTRACT with the renderer sibling (owner # ruling, 2026-09-04 -- see the module docstring's "WHY THE TWO DRAWINGS ARE # NOW STACKED" note): every key here is read by name on the other side of # that contract, so none of them is decorative and none may be renamed # without breaking the renderer's half. `full_width: True` and `aspect` # together replace the old fixed `w` / `h` pixel pair -- the renderer gives # the drawing the whole column width and asks THIS module for a height via # `aspect` (height/width), rather than this module baking in a pixel size # that only happened to fit one window. _OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, _OUTLINE_ASPECT_DEFAULT = 0.12, 0.5, 0.3 _SECTION_ASPECT_MIN, _SECTION_ASPECT_MAX, _SECTION_ASPECT_DEFAULT = 0.12, 0.35, 0.2 # Live-render finding, 2026-09-04: the outline's container was sized purely # off `width_ft / length_ft`, with no allowance for the fixed pixel rows # `pa_sketch_extents.outline()` draws OUTSIDE the panel rectangle itself -- # the title / cut-station label above it, the L label and its dimension # chain below. With no extra room the panel's own bottom edge was pinned to # the canvas bottom and the L label was clipped away entirely. This is a # flat ADDITION to the clamped ratio (not folded into the clamp bounds # above, which stay the panel's own true proportion) -- `outline_geometry()` # is what actually reserves the matching top/bottom pixel margins inside # whatever height this produces; see that function's own docstring. _OUTLINE_LABEL_ROWS_ASPECT = 0.10 def _ratio_clamp(numerator, denominator, lo, hi, default): """`numerator / denominator`, clamped to `[lo, hi]` -- `default` when either side is missing or the division is not a sane positive ratio (zero/negative denominator, a numerator that is not a real number yet). Never raises: an aspect ratio this module cannot compute yet still has to be SOME number, because the renderer sizes the drawing's canvas off it before this module ever gets a chance to draw anything into that canvas -- there is no "wait for real geometry" option here the way `sketch()` / `extents_outline()` have one (an empty `primitives` list).""" if numerator is None or denominator is None or denominator <= 0.0 \ or numerator <= 0.0: return default ratio = numerator / denominator if ratio < lo: return lo if ratio > hi: return hi return ratio def _extents_drawing_blocks(values): """The outline + section `drawing` block pair, stacked (owner ruling, 2026-09-04): outline first, full width; section BELOW it, also full width -- never side by side any more (see the module docstring). Both name `module: 'pa_sketch_extents'`, the sibling module that owns `outline()` / `section()`, the actual drawing functions -- this function only builds the two block dicts the renderer reads to decide how big to make each canvas and what to redraw it on, never draws anything itself. """ length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) thickness_in = _num(values.get('thickness_in')) thickness_ft = thickness_in / 12.0 if thickness_in and thickness_in > 0 \ else None outline_aspect = _ratio_clamp( width_ft, length_ft, _OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, _OUTLINE_ASPECT_DEFAULT) + _OUTLINE_LABEL_ROWS_ASPECT section_aspect = _ratio_clamp( thickness_ft, width_ft, _SECTION_ASPECT_MIN, _SECTION_ASPECT_MAX, _SECTION_ASPECT_DEFAULT) outline_block = { 'kind': 'drawing', 'module': 'pa_sketch_extents', 'source': 'outline', 'full_width': True, 'aspect': outline_aspect, 'caption': 'Panel outline -- drag the red cut line to move the ' 'section', 'cutline': {'key': '_cut_frac', 'default': 0.5}, # Names `pa_sketch_extents.outline_geometry` so the renderer's # `_add_cutline()` can place the draggable line's x at # `x0 + frac*(x1-x0)` -- the same box `outline()`'s own cut marker # uses -- instead of the bare canvas width. See that module's # "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER" note. 'geometry': 'outline_geometry', # 'opening' is the ROW TABLE prefix (`opening..`) -- the # renderer's `redrawDependents` treats a `depends_on` entry as # either an exact key match or a `key + '.'` PREFIX match, so this # one entry watches every cell of every opening row (add/remove # included) the same way 'openings_rows' used to watch the old # passthrough list. 'openings_rows' itself stays listed too, for a # saved panel still carrying the legacy passthrough with no table # rows at all. 'depends_on': ['opening', 'openings_rows', 'length_ft', 'width_ft'], } section_block = { 'kind': 'drawing', 'module': 'pa_sketch_extents', 'source': 'section', 'full_width': True, 'aspect': section_aspect, 'caption': 'Cross-section at the cut line', 'depends_on': ['_cut_frac', 'opening', 'openings_rows', 'thickness_in', 'width_ft', 'section_type', 'wythe_top_in', 'insulation_in', 'wythe_bottom_in'], } return [outline_block, section_block] # Owner ruling, 2026-09-05: the Openings / Reveals / Solid Zones table is an # EDITABLE ROW TABLE (`kind: 'editable_table'`, `table: 'opening'`), the same # mechanism `pa_structural.py` uses for `restraint` / `conc_load` / # `wind_zone` -- not a read-only dump of whatever `values['openings_rows']` # happened to carry. Columns are the table's own simplified, ABSOLUTE- # position vocabulary (`vibe.panelanalysisform.OPENING_COLS`): x_ft/length_ft # along the panel's length (from the start), y_ft/width_ft across its width # (from the bottom), depth_in for a Reveal's through-thickness cut only. _OPENING_COLUMNS = [ {'col': 'type', 'label': 'Type', 'editor': 'choice', 'choices': ['Opening', 'Reveal', 'Solid zone']}, {'col': 'x_ft', 'label': 'Position along length (ft)', 'editor': 'num'}, {'col': 'length_ft', 'label': 'Length (ft)', 'editor': 'num'}, {'col': 'y_ft', 'label': 'Position across width (ft)', 'editor': 'num'}, {'col': 'width_ft', 'label': 'Width (ft)', 'editor': 'num'}, {'col': 'depth_in', 'label': 'Depth, reveals only (in)', 'editor': 'num'}, ] def _openings_blocks(values, problems): """The Openings, Reveals, and Solid Zones heading + editable table. Always builds the table -- an editable row table has somewhere to add a first row even when `values` carries none yet, unlike the old read-only dump this replaced, which had nothing to show and no way to add anything when `openings_rows` came up empty or absent. `problems` is accepted for the same signature every other block-builder in this module uses; nothing in this function raises one today. """ blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid ' 'Zones', 'level': 2}] blocks.append({'kind': 'sentence', 'quiet': True, 'text': 'One row per opening, reveal or solid zone. ' 'Position and length are measured from the ' "panel's own start (length) and bottom (width); " 'Depth applies to a Reveal only -- leave it ' 'blank for an Opening (always full depth) or a ' 'Solid Zone.'}) blocks.append({'kind': 'editable_table', 'table': 'opening', 'columns': _OPENING_COLUMNS, 'add_label': 'Add row'}) return blocks # `_TYPE_LABELS` above already names the three row types this page's own # Openings/Reveals/Solid Zones TABLE recognises; the one-line drawing-area # summary counts only the first two by name, per the owner's own wording # (2026-09-03): "say so" means "N opening(s), M reveal(s)", not a count of # every row type the table happens to carry. def _opening_table_type_rows(values): """[{'type': text}, ...] read LIVE off the `opening` row table (owner ruling, 2026-09-05), one dict per present row -- or `None` when `panelanalysisform` cannot be reached (import failure) or the table carries no present rows at all, so the caller can fall back to whatever legacy `openings_rows` / `openings` text this `values` still carries (a panel saved before this table existed). Reading the table live here, rather than the `openings_rows` PASSTHROUGH_KEYS snapshot `prefill` took at spec-build time, is what keeps this summary sentence agreeing with the table after an edit + save + reload -- the passthrough key is never updated by a row edit, only the table cells are. """ if paf is None: return None try: rows = paf.rows_of(paf.OPENING_TABLE, values, paf.OPENING_COLS) except AttributeError: return None if not rows: return None return [{'type': r.get('type') or ''} for r in rows] def _openings_summary_sentence(values): """The ONE line of text the drawing area prints in place of the old parameter dump: 'N opening(s), M reveal(s)', or 'No openings or reveals.' when there are none. Counts only rows whose `type` is 'Opening' or 'Reveal' (case-insensitive) -- a Solid Zone is not an opening and the owner's own phrasing names only the two. Prefers the LIVE `opening` row table (`_opening_table_type_rows`); when that table carries no rows (or `panelanalysisform` cannot be reached), falls back to the legacy `values['openings_rows']` list, and then to the raw `openings` text field -- the same three-deep fallback `validate()` itself uses, so this line and the Run button never disagree about which source is live. """ values = values or {} rows = _opening_table_type_rows(values) if rows is None: rows = values.get('openings_rows') if not isinstance(rows, list): raw = values.get('openings') raw = raw.strip() if isinstance(raw, str) else \ (str(raw).strip() if raw else '') if raw: return ('Openings/reveals were entered as plain text, not the ' 'structured rows this line needs to count: ' '{0!r}'.format(raw)) return 'No openings or reveals.' n_open = 0 n_reveal = 0 for row in rows: if not isinstance(row, dict): continue key = str(row.get('type') or '').strip().lower().replace('_', ' ') if key == 'opening': n_open += 1 elif key == 'reveal': n_reveal += 1 if n_open == 0 and n_reveal == 0: return 'No openings or reveals.' def _plural(n, word): return '{0} {1}{2}'.format(n, word, '' if n == 1 else 's') return '{0}, {1}.'.format(_plural(n_open, 'opening'), _plural(n_reveal, 'reveal')) # ------------------------------------------------------------------ page() def page(values, reads=None): """(blocks, problems) for the Concrete Extents section. `values` is the form's current string-valued dict (plus, where a sibling agent has landed it, the structured `openings_rows` list -- see the module docstring). `reads` is what the button read straight off the live framing element, the same shape `panelanalysisform.prefill` takes; it is accepted for symmetry with the other section builders but not read here -- everything this section needs is already folded into `values` by the time `page` sees it, and reading `reads` again here would risk showing a MODEL value beside a form value the user has since typed over it, exactly the kind of stale-looking page the owner has complained about. Never raises. Every field this module cannot place is a `problems` sentence, not a traceback -- see the module docstring for the two running examples (a `panelanalysisform` key that is not there yet, and an `openings_rows` shape that has not landed yet). """ values = values if values is not None else {} problems = [] blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}] blocks.extend(_panel_geometry_columns(values, problems)) blocks.append({'kind': 'space', 'h': 8}) blocks.extend(_openings_blocks(values, problems)) blocks.append({'kind': 'space', 'h': 8}) # Just the member's own concrete edge, plus its openings -- not the # Area/Width/Height parameter dump the owner ruled out, 2026-09-03: # "Concrete Extents does not need all those parameters ... In that # section just draw the member's concrete edge. If there are openings, # say so, and add a few dimension lines that locate the openings." And, # 2026-09-04: the outline drawing bigger, full width; the section BELOW # it (not beside it), full width too, reading a draggable cut line off # the outline -- see `_extents_drawing_blocks()` and the module # docstring's "WHY THE TWO DRAWINGS ARE NOW STACKED" note for why this # is two `full_width` blocks naming `pa_sketch_extents`, not the old # side-by-side `columns` pair naming `panelsketch.extents_outline` / # `extents_section` directly. blocks.extend(_extents_drawing_blocks(values)) blocks.append({'kind': 'sentence', 'text': _openings_summary_sentence(values)}) return blocks, problems