8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
675 lines
32 KiB
Python
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
|