Files
willtools-demo/WillTools.extension/lib/vibe/pa_extents.py
T
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

675 lines
32 KiB
Python

# -*- 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': <values key>, 'in': [<value>, ...]}` -- 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.<n>.<col>`) -- 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