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

366 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the Reinforcement section's block builder.
WHY THIS EXISTS: the Panel Analysis input form used to show reinforcement as
one raw eccentricity number in an unlabelled box, and the owner has now said
three times it is unreadable ("太丑了", "你这么输入用户上哪儿能看懂", and
finally "全部给我做成普通人能看懂的样子" -- make every part of it something an
ordinary person can understand). This module rebuilds the Reinforcement half
of that form to read like Eriksson Wall's own printed page (strands, rebar,
mesh, interrupts, then the drawn cross-section), while staying an honest INPUT
form rather than a finished report: every optional block that is empty says so
in a sentence, exactly the way Eriksson prints "Design contains no rebar" /
"no mesh" / "no prestressing" rather than leaving a blank space that could
mean either "nothing here" or "nobody has typed it yet".
`page(values, reads=None)` returns `(blocks, problems)` and never raises.
`blocks` is a flat list of dicts drawn from a fixed, generic vocabulary (see
the button's WPF renderer) -- 'heading', 'columns', 'field', 'table', 'kv',
'sentence', 'drawing', 'rule', 'space'. An unrecognised `kind` renders as
nothing, so adding a new one here without also teaching the renderer is a
silent no-op, not a crash -- worth remembering while extending this file.
`problems` is the sentence list the button shows above the section: every
field key this module could not find in `vibe.panelanalysisform`, every
strand size with no known area, and every summary quantity (cgx, ex, ey) that
this module refuses to compute rather than invent.
WHAT THIS MODULE WILL NOT DO, on purpose, following `panelanalysisform`'s own
rule for `strand_area_in2` (a strand size it does not recognise is refused BY
NAME, never guessed, because an invented area is a fabricated capacity):
* A strand size with no published area never gets a made-up one -- the
table shows the size, refuses the area, and `problems` names the size.
* Strand horizontal position (cgx) and both eccentricities (ex, ey) are
never printed. The per-strand `y` `_prestress_from` computes is an even
spread across the panel WIDTH assumed for the Stage-0 solve, not a
position the user typed or the model measured, so presenting it as cgx
would dress up an assumption as a fact. Depth (cgy) IS printed, because
depth-to-CG is exactly what the user types per strand group -- a
count-weighted average of real entered numbers, not an invented one.
* Horizontal strand spacing is not collected anywhere on this form, so no
table here carries a "Spacing" column or any other layout that implies
it is known -- Eriksson's own rebar table has one; this module's strand
table deliberately does not, matching the real page 27 strand table
(which has none either) rather than the rebar table on the same PDF.
WHY `strand_rows` IS READ DEFENSIVELY: a sibling agent is restructuring
`panelanalysisform` to replace the single `ps_ecc_in` box with structured
strand-group rows, exposed on `values['strand_rows']` as
`[{'count', 'depth_in', 'pull_pct'}, ...]`. That key does not exist on
`values` in every revision of that module while the restructure is in
flight, so its absence is read as "waiting on input", never as an error --
this module falls back to parsing the raw `ps_rows` text itself with
`panelanalysisform.parse_strand_rows` (the same parser `validate()` uses) and
says in `problems` that it had to.
No Revit import, no WPF import, no `panelsketch` import -- only
`vibe.panelanalysisform`, for field names/labels/hints and the strand-area
table, exactly as the rest of this input form does. Must run on IronPython
2.7 in the button (`vibe.helpers` never reaches this module) AND on plain
CPython under `WillTools.extension/tests/conftest.py`'s import trick: no
f-strings, no typing, no dataclasses.
"""
from vibe import panelanalysisform as pf
# --------------------------------------------------------------- constants
# Section/field keys this module owns, all declared under the 'Reinforcement'
# section in panelanalysisform.FIELDS. Kept as a list (not re-derived from
# FIELDS by section name) so a key panelanalysisform drops or renames shows up
# as an explicit MISSING in `problems` instead of just vanishing from the page.
_STRAND_FIELD_KEYS = ('strand_rail_location', 'strand_rail_offset_in',
'ps_dia_in', 'ps_pull_lb', 'ps_count', 'ps_loss_pct')
_REBAR_FIELD_KEYS = ('mild_as',)
_MESH_FIELD_KEYS = ('mesh',)
# panelanalysisform has no field for interrupts at all yet -- not "blank",
# genuinely absent. Kept as its own name (rather than an empty tuple reused
# for "no fields") so the one `problems` sentence that explains it is written
# once, beside the constant it is about.
_INTERRUPTS_MISSING = 'interrupts'
# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this
# page speaks. Nothing here is 'check' or 'choice' -- every Reinforcement
# field is free text or a number.
_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'}
def _field_block(key, problems):
"""A 'field' block for a panelanalysisform key, or None + a MISSING note.
Reads the label/symbol/hint/required-ness straight off panelanalysisform
so this page can never drift from the form it is rendering -- a rename in
that module shows up here as a MISSING problem instead of a stale label.
"""
spec = pf._spec(key)
if spec is None:
problems.append(
"field {0!r} is not declared in panelanalysisform -- this page "
"cannot show it (MISSING)".format(key))
return None
editor = _EDITOR_OF_KIND.get(spec['kind'], 'text')
return {
'kind': 'field', 'key': key, 'label': spec['label'],
'symbol': spec['symbol'] or None, 'hint': spec['help'] or None,
'required': spec['required'], 'editor': editor, 'choices': None,
}
def _text_of(values, key):
"""The current string in `values` for `key`, '' if absent/None.
`values` is meant to be string-valued, but this page must never raise on
a caller's mistake -- a non-string value (an int typed straight into a
dict in a test, a stray object) is coerced rather than crashed on.
"""
raw = values.get(key)
if raw is None:
return ''
if not hasattr(raw, 'strip'):
try:
raw = str(raw)
except Exception:
return ''
return raw.strip()
def _num_of(values, key):
"""(float_or_None, error_or_None) for a numeric field's current text."""
text = _text_of(values, key)
if not text:
return None, None
try:
return float(text), None
except (TypeError, ValueError):
return None, 'is not a number ({0!r})'.format(text)
# ------------------------------------------------------------------ strands
def _strand_rows_from(values, problems):
"""[{'count', 'depth_in', 'pull_pct'}, ...] read defensively.
Prefers the structured `values['strand_rows']` a sibling agent is adding;
falls back to parsing the raw `ps_rows` text with panelanalysisform's own
parser (the same one `validate()` uses) when that key is absent, and says
so in `problems` -- never treats the absence as an error by itself.
Genuinely blank input (`strand_rows` absent AND `ps_rows` blank) returns
`[]` with no problem: "nobody has typed any strand groups yet" is a real,
quiet state, not a fault.
"""
raw = values.get('strand_rows')
if isinstance(raw, list):
rows = []
for entry in raw:
try:
count = int(entry['count'])
depth_in = float(entry['depth_in'])
except (KeyError, TypeError, ValueError):
problems.append(
"a strand_rows entry from the form could not be read "
"(missing or non-numeric count/depth_in) -- skipped it "
"rather than guessing its numbers")
continue
pull_raw = entry.get('pull_pct') if hasattr(entry, 'get') else None
pull_pct = None
if pull_raw is not None:
try:
pull_pct = float(pull_raw)
except (TypeError, ValueError):
problems.append(
"a strand_rows entry's pull_pct could not be read "
"({0!r}) -- treated as not given".format(pull_raw))
rows.append({'count': count, 'depth_in': depth_in,
'pull_pct': pull_pct})
return rows
# Fall back to the raw text field this page also renders as a 'field'
# block below, so the user always has somewhere to type even while the
# structured key is not yet on `values`.
text = _text_of(values, 'ps_rows')
if not text:
return []
rows, parse_problems = pf.parse_strand_rows(text)
if parse_problems:
problems.append(
"structured strand_rows was not supplied by the form; tried to "
"read the strand-group text instead and could not: " +
'; '.join(parse_problems))
return []
if rows:
problems.append(
"structured strand_rows was not supplied by the form yet; "
"parsed the raw strand-group text (ps_rows) instead")
return rows
def _strand_block(values, problems):
"""The whole Strands sub-block: rail fields, then table or sentence.
Matches the real page's own shape: the Rail Location / Rail Offset pair
prints unconditionally, and what follows is either the strand table or
the single sentence "Design contains no prestressing" -- never a table
with a header row and no data, and never a blank space.
"""
blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}]
rail_fields = []
for key in ('strand_rail_location', 'strand_rail_offset_in'):
fb = _field_block(key, problems)
if fb is not None:
rail_fields.append([fb])
if rail_fields:
blocks.append({'kind': 'columns', 'columns': rail_fields})
rows = _strand_rows_from(values, problems)
if not rows:
blocks.append({'kind': 'sentence', 'text':
'Design contains no prestressing'})
return blocks
for key in ('ps_count', 'ps_dia_in', 'ps_pull_lb', 'ps_loss_pct'):
fb = _field_block(key, problems)
if fb is not None:
blocks.append(fb)
dia_text = _text_of(values, 'ps_dia_in')
area, area_err = pf.strand_area_in2(dia_text if dia_text else None)
if area_err:
problems.append(area_err)
pull_lb_text = _text_of(values, 'ps_pull_lb')
headers = ['Size', 'Area (in²)', 'Diameter', 'Quantity', '% Pull',
'Depth to CG (in)']
table_rows = []
for row in rows:
size_cell = dia_text if dia_text else '?'
area_cell = ('%.4f' % area).rstrip('0').rstrip('.') if area is not \
None else '?'
if row['pull_pct'] is not None:
pull_cell = ('%g%%' % row['pull_pct'])
elif pull_lb_text:
pull_cell = 'default pull ({0} lb)'.format(pull_lb_text)
else:
pull_cell = 'default pull (not entered)'
depth_cell = ('%g"' % row['depth_in'])
table_rows.append([size_cell, area_cell, size_cell,
str(row['count']), pull_cell, depth_cell])
blocks.append({'kind': 'table', 'headers': headers,
'header_groups': None, 'rows': table_rows,
'row_key': None})
# Strand summary. Aps and cgy are real derived numbers (a count-weighted
# sum/average of what the user actually typed); cgx/ex/ey are refused --
# see the module docstring for why.
if area is not None:
total_count = sum(r['count'] for r in rows)
if total_count > 0:
aps = total_count * area
cgy = sum(r['count'] * r['depth_in'] for r in rows) / \
float(total_count)
kv_rows = [
('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.')),
('cgy, in (depth to CG)',
('%.4f' % cgy).rstrip('0').rstrip('.')),
('cgx, in', 'not shown -- horizontal strand position is not '
'collected on this form, only assumed for the '
'solve'),
('ex, in', 'not shown -- see cgx above'),
('ey, in', 'not shown -- needs a defined section centroid, '
'not derived by this page'),
]
blocks.append({'kind': 'kv', 'rows': kv_rows})
problems.append(
'strand summary: cgx and both eccentricities (ex, ey) are '
'not computed -- horizontal strand position on this panel '
'is an assumption made for the solve, not a real input, and '
'printing it as cgx would dress up that assumption as a '
'measured fact')
else:
problems.append(
'strand summary (Aps, cgy) could not be computed -- the strand '
'area is unknown (see the strand-size problem above)')
return blocks
# --------------------------------------------------------------- rebar/mesh
def _rebar_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}]
fb = _field_block('mild_as', problems)
if fb is not None:
blocks.append(fb)
if not _text_of(values, 'mild_as'):
blocks.append({'kind': 'sentence', 'text':
'Design contains no rebar'})
return blocks
def _mesh_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}]
fb = _field_block('mesh', problems)
if fb is not None:
blocks.append(fb)
if not _text_of(values, 'mesh'):
blocks.append({'kind': 'sentence', 'text':
'Design contains no mesh'})
return blocks
def _interrupts_block(problems):
"""The Interrupts sub-block -- no field exists on the form for this yet.
This is deliberately NOT "Design contains no interrupts": that sentence
would assert a judged, empty answer, and nothing here has judged
anything -- panelanalysisform simply has no key to read. "Could not
judge" and "judged, found nothing" must never look alike (CLAUDE.md
section 3), so this prints the honest could-not-judge sentence and names
the gap in `problems` for whoever is meant to add the field.
"""
problems.append(
"interrupts: panelanalysisform has no field for interrupts yet "
"(MISSING) -- this page cannot say whether the design has any")
return [
{'kind': 'heading', 'text': 'Interrupts', 'level': 2},
{'kind': 'sentence', 'text':
'Could not judge -- this form does not yet collect interrupts '
'data'},
]
# ------------------------------------------------------------------- public
def page(values, reads=None):
"""(blocks, problems) for the Reinforcement section of the input form.
`values` is the form's current string-valued dict (plus, once the
sibling restructure lands, a `strand_rows` list -- see the module
docstring). `reads` is accepted for signature parity with the rest of
this form's per-section builders; nothing under Reinforcement is
prefilled from the model (`panelanalysisform.PREFILLABLE` does not
include any of these keys), so it is not read here. Never raises --
every failure becomes a sentence in `problems` instead.
"""
values = values or {}
problems = []
blocks = [{'kind': 'heading', 'text': 'Reinforcement', 'level': 1}]
blocks.extend(_strand_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_rebar_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_mesh_block(values, problems))
blocks.append({'kind': 'rule'})
blocks.extend(_interrupts_block(problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.append({
'kind': 'drawing', 'source': 'section',
'caption': 'Reinforcement Cross-Section', 'w': 480, 'h': 360,
})
return blocks, problems