WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user