Files
willtools-demo/WillTools.extension/lib/vibe/pa_extents.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

571 lines
25 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 SECTION PROPERTIES BLOCK OMITS MOST OF ERIKSSON'S OWN ROWS: Eriksson
prints Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb and St, but the last
seven need either an axis convention this module has no authority to invent,
or a net-of-openings deduction it does not compute. Printing a plausible
number in a column headed "St, in³" when nobody has verified it is exactly
the fabricated section modulus the owner has ruled against elsewhere in this
codebase (`CLAUDE.md` "no invented default" discipline) -- so this module
computes ONLY Area / Width / Height, from the panel's own width and
thickness, and names the rest in `problems` as omitted, never fills them in
with a guess.
WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT:
`panelsketch.elevation()`'s own docstring says it draws the front elevation
BESIDE a side elevation as one combined picture, not two separate ones.
Eriksson's page prints them as two separately captioned drawings, and this
module is asked to lay out a `columns` row of exactly that shape -- so it
does, naming `source: 'elevation'` under both the "Side Elevation" and
"Front Elevation" captions, and says in `problems` that until `panelsketch`
is split in two, both captions currently draw the same combined image. That
is a real, current limitation, not something to paper over by inventing a
`source` name that does not exist.
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
# --------------------------------------------------------------- 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('.')
# Eighths-of-an-inch -> the fraction word Eriksson prints. A precast
# dimension finer than 1/8" is not meaningful, so `_feet_str` rounds to it
# rather than pretending to more precision than the field carries.
_EIGHTHS = {0: '', 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2',
5: '5/8', 6: '3/4', 7: '7/8'}
def _feet_str(value):
"""Decimal feet -> an architectural dimension string, e.g. 8'-4 1/2".
Internal units are decimal feet throughout this codebase (`CLAUDE.md`
§3), so a bare number arriving in a position/length field is read as
feet and converted here to the feet-inches form Eriksson prints (`8"`,
`2'-11.5"`). A value that is already a string is left untouched by the
caller (`_dim_or_dash`) rather than reparsed here -- a sibling agent may
hand this module an already-formatted dimension, and reformatting a
string that already reads correctly is how a "2'-11.5\"" becomes a wrong
"2'-11 1/2\"" glued onto a stray decimal.
Returns None only when `value` cannot be read as a number at all.
"""
try:
feet = float(value)
except (TypeError, ValueError):
return None
negative = feet < 0.0
feet = abs(feet)
whole_ft = int(feet)
inches = (feet - whole_ft) * 12.0
eighths_total = int(round(inches * 8.0))
whole_in, eighths = divmod(eighths_total, 8)
if whole_in >= 12:
whole_ft += whole_in // 12
whole_in = whole_in % 12
frac = _EIGHTHS[eighths]
if frac and whole_in:
in_part = '{0} {1}'.format(whole_in, frac)
elif frac:
in_part = frac
else:
in_part = str(whole_in)
if whole_ft > 0:
text = u'{0}\'-{1}"'.format(whole_ft, in_part)
else:
text = u'{0}"'.format(in_part)
return u'-' + text if negative else text
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):
"""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))
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
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`).
"""
if _tri_bool(values.get(key)) is False:
return {'kind': 'sentence',
'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']),
]
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', 'check', 'Left miter', '',
'Whether the left end of the panel is cut at an angle '
'(mitered) instead of square. Leave unchecked if this has '
'not been decided yet.', False, problems),
]
sentence = _miter_sentence(values, 'left_miter', 'Left')
if sentence:
col2.append(sentence)
col2.append(
_field('right_miter', 'check', 'Right miter', '',
'Whether the right end of the panel is cut at an angle '
'(mitered) instead of square. Leave unchecked if this has '
'not been decided yet.', False, problems))
sentence = _miter_sentence(values, 'right_miter', 'Right')
if sentence:
col2.append(sentence)
col2.append(
_field('top_in_form_check', 'check',
'Top edge has a formed step', '',
'Whether the top edge of the panel has its own formed '
'step or insert built in, separate from which face faces '
'up (see "Which face is up" at left). Leave unchecked if '
'there is none.', False, problems))
col2.append(
_field('top_in_form_width_in', 'num', 'Formed step width', 'in',
'How wide the formed step at the top edge is, if there is '
'one. Leave blank if there is no step.', False, problems))
col2.append(
_field('top_in_form_height_in', 'num', 'Formed step height', 'in',
'How tall the formed step at the top edge is, if there is '
'one. Leave blank if there is no step.', False, problems))
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]}]
# The grouped header the Openings / Reveals / Solid Zones table prints,
# spanning the last 6 of its 7 columns: 'Type' stands alone at the left the
# way Eriksson's own page leaves it, and (2, 4) is what leaves the group
# spans adding up to exactly `len(headers) - 1`.
_OPENINGS_HEADERS = ['Type', 'Position', 'Length', 'Horizontal Position',
'Width', 'Vertical Position', 'Height']
_OPENINGS_GROUPS = [('Position along Length', 2), ('Position in Cross '
'Section', 4)]
def _openings_row_cells(row):
row = row or {}
return [
_type_label(row.get('type')),
_pos_cell(row.get('pos_along'), row.get('pos_along_from')),
_dim_or_dash(row.get('length')),
_pos_cell(row.get('pos_cross'), row.get('pos_cross_from')),
_dim_or_dash(row.get('width')),
_dim_or_dash(row.get('pos_vert')),
_dim_or_dash(row.get('height')),
]
def _openings_blocks(values, problems):
"""The Openings, Reveals, and Solid Zones heading + table (or fallback).
`values['openings_rows']` is the structured shape a sibling agent is
meant to be adding (see the module docstring); when it is missing, or
is not a list, this degrades to the raw `values['openings']` text field
and a `problems` entry -- never a guessed re-parse of that free text
into the grouped shape, which would be inventing datum words
("from Start" / "from Left") nobody has actually said.
"""
blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid '
'Zones', 'level': 2}]
rows = values.get('openings_rows') if values else None
if isinstance(rows, list):
table_rows = [_openings_row_cells(r) for r in rows]
blocks.append({'kind': 'table', 'headers': _OPENINGS_HEADERS,
'header_groups': _OPENINGS_GROUPS,
'rows': table_rows, 'row_key': None})
if not rows:
blocks.append({'kind': 'sentence',
'text': 'No openings, reveals, or solid zones '
'entered.'})
return blocks
problems.append(
"'openings_rows' is not present in the form values (or is not a "
'list), so the grouped Position-along-Length / Position-in-'
'Cross-Section table cannot be built; falling back to the raw '
"'openings' text field.")
raw = (values.get('openings') if values else None) or ''
raw = raw.strip() if isinstance(raw, str) else str(raw).strip()
if raw:
blocks.append({'kind': 'sentence',
'text': 'Openings were entered as plain text and '
'cannot be shown as the grouped table yet: '
'{0}'.format(raw)})
else:
blocks.append({'kind': 'sentence',
'text': 'No openings, reveals, or solid zones '
'entered.'})
return blocks
# Every Section Properties row Eriksson's own page prints. Only the first
# three (`_DERIVABLE_PROPS`) are ever computed here -- see the module
# docstring for why the rest are refused rather than guessed.
_DERIVABLE_PROPS = ('Area, in²', 'Width, in', 'Height, in')
_OMITTED_PROPS = ('Cx, in', 'Cy, in', 'Izz, in⁴', 'Iyy, in⁴',
'Izy, in⁴', 'Sb, in³', 'St, in³')
def _section_properties_column(values, problems):
"""The Section Properties kv list -- Area / Width / Height ONLY.
Both come straight from what the user entered (panel width, panel
thickness) with no engineering judgement in between, so they are the
only rows honest to print. `Height` here is Eriksson's name for the
section's own depth through the panel, i.e. the panel THICKNESS -- not
the panel's long dimension, which the Panel Geometry block above already
calls "Height" in its own different sense. Printing both under the same
word in two different places on the same page is exactly Eriksson's own
layout, confusing as that is; relabelling one of them would stop this
matching the page the owner asked for.
"""
col = [{'kind': 'heading', 'text': 'Section Properties', 'level': 3}]
width_ft = _num(values.get('width_ft'))
thickness_in = _num(values.get('thickness_in'))
if width_ft is not None and width_ft > 0 and \
thickness_in is not None and thickness_in > 0:
width_in = width_ft * 12.0
area_in2 = width_in * thickness_in
rows = [('Area, in²', _fmt_num(area_in2)),
('Width, in', _fmt_num(width_in)),
('Height, in', _fmt_num(thickness_in))]
col.append({'kind': 'kv', 'rows': rows})
problems.append(
'{0} are not shown: deriving them honestly needs an axis '
'convention and, wherever openings are present, a '
'net-of-openings deduction this module does not compute -- a '
'guessed section modulus is worse than none.'
.format(', '.join(_OMITTED_PROPS)))
else:
col.append({'kind': 'sentence',
'text': 'Section properties are waiting on Panel '
'width and Panel thickness; nothing is shown '
'until both are entered.'})
problems.append(
'{0} could not be derived -- Panel width and/or Panel '
'thickness is missing.'.format(
', '.join(_DERIVABLE_PROPS + _OMITTED_PROPS)))
return col
def _drawings_row(problems):
"""The Side Elevation / Front Elevation / Section Properties row.
Both drawings name `source: 'elevation'` -- see the module docstring's
"WHY THE FRONT/SIDE ELEVATION SPLIT IS HONEST ABOUT ITS OWN LIMIT" for
why that is today's correct, if unsatisfying, answer rather than a name
this module invents to look like two different pictures.
"""
problems.append(
"Side Elevation and Front Elevation both use panelsketch."
'elevation(), which currently draws the side and front views '
'together as ONE picture (per that function\'s own docstring) '
'rather than as two separate drawings; until panelsketch is split '
'in two, both captions below show the same combined image.')
side_col = [{'kind': 'drawing', 'source': 'elevation',
'caption': 'Side Elevation', 'w': 260, 'h': 520}]
front_col = [{'kind': 'drawing', 'source': 'elevation',
'caption': 'Front Elevation', 'w': 420, 'h': 520}]
return side_col, front_col
# ------------------------------------------------------------------ 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})
side_col, front_col = _drawings_row(problems)
props_col = _section_properties_column(values, problems)
blocks.append({'kind': 'columns',
'columns': [side_col, front_col, props_col]})
blocks.append({'kind': 'space', 'h': 8})
blocks.append({'kind': 'drawing', 'source': 'section',
'caption': 'Cross Section', 'w': 900, 'h': 260})
return blocks, problems