WillTools 2026-09-09 -- clean snapshot of the development repo for review
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:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,802 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the "Structural Model" section, laid out.
WHY THIS EXISTS: the owner rejected the Panel Analysis form three times as
unreadable ("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02,
"全部给我做成普通人能看懂的样子" -- make every part of it something an ordinary
person can understand. `page()` builds the Structural Model section
(restraints, bearing, concentrated loads, wind, and the collapsed
seismic/temperature/soil/additional-self-weight/design-strips families) as
a small fixed vocabulary of BLOCK dicts a generic WPF renderer consumes.
2026-09-04 REWRITE, owner rulings on today's headless render:
* "restrain & support 的那个示意图,本质上是一个 freebody,用一条直线来代替
member,同时显示 restrain 的位置,以及 loading" -- the picture is now a
FREE-BODY (`kind: 'drawing'`, `module: 'pa_sketch_freebody'`), not the
four-panel `panelsketch.elevation()` this section used to reuse. That old
drawing block is GONE from this page; the free-body replaces it (see the
'kind of drawing has 3 tiny sub-drawings + text overlapping' complaint
below).
* "restrain 现在为啥变成了不可编辑了?只是让你预先输入,不是让你直接锁死啊"
-- Restraints is now an EDITABLE TABLE (`kind: 'editable_table'`,
`table: 'restraint'`), not the two dropdown/checkbox fields
(`restraint_foundation_connection` / `restraint_floor_tie`) this page used
to render. Those two fields are GONE; a restraint's type (including
'Floor tie') is now a per-row column, added with the table's own
Add-row button.
* Concentrated loads: was a "none entered" sentence with no editor. Now an
EDITABLE TABLE (`table: 'conc_load'`) -- label / elevation / offset /
dead / live, one row per point load, Add-row button.
* "既然要用户直接输入 wind pressure/suction,那就别要前面那些东西了。前面那些
应该在 project-config 里面,从这里移除。添加一个按钮,按照 project-config
计算,还是用户手动输入。...现在就只做用户手动输入的部分。手动输入不仅仅是
windload,还有 apply 在什么位置,从哪个 elevation 到哪个 elevation,多大
的宽度" -- every ASCE input (speed, exposure, risk, enclosure, edition,
both chapter dropdowns) is GONE from this page; that computation belongs
in project-config, not here. In its place: one `wind_mode` choice field
('Manual' / 'From project config') and an EDITABLE TABLE (`table:
'wind_zone'`) of manually-typed pressure zones -- label, from/to
elevation, width, pressure, suction. Only 'Manual' is built; 'From
project config' renders a quiet sentence saying so, nothing computed.
* "seismic, temp, soil 这几个板块先折叠掉" -- Seismic, Temperature Loads and
Soil Loads (the existing exterior/interior columns, together, as ONE
named block the way the owner named it) are each a `kind: 'section'`
with `collapsed: True`.
* SAME-DAY FOLLOW-UP, on the free-body render itself: the member printed
as a faint band with its end labels clipped at the canvas edge, tiny
restraint symbols, and ~40% of the canvas blank above the member --
`pa_sketch_freebody.py` (a sibling module, not this one) now packs its
rows tight from the top instead of centring on a fraction of the
canvas height; see its own docstring. Two things land here instead:
the OLD `weight_diagram` drawing block under Additional Self Weight is
GONE -- the free-body above already draws every point load and the
member, so a second small "point loads and self weight" picture
repeating the same data was exactly the leftover-old-drawing complaint
-- and Additional Self Weight / Design Strips (neither built) are now
each a `kind: 'section'` with `collapsed: True`, same as Seismic /
Temperature / Soil.
* SECOND SAME-DAY FOLLOW-UP, after the first fix rendered live: the
restraint symbols were still nearly invisible (a renderer gap in the
WPF button, not this page -- see `pa_sketch_freebody`'s RESTRAINT
SYMBOLS note) and roughly 45% of the canvas sat empty BELOW the
restraint labels/dimension chain now that the top was fixed. The
drawing block's `aspect` here is no longer a flat `0.22` --
`_freebody_aspect(values)` shrinks it for a diagram with few or no
concentrated-load/wind-zone rows (own docstring has the formula) so
a simple diagram gets a shorter canvas instead of a taller one with
dead space at the bottom.
* Grey helper prose stays out of the inline flow: every explanatory
sentence below (what a block is FOR, not its live state) carries
`quiet: True` so the renderer routes it into the collapsed Notes panel
instead of printing it inline. A functional state sentence -- "Design
contains no releases.", "No concentrated point loads entered..." -- is
the direct answer to a question the user asked by leaving a field blank,
not helper prose, and stays inline (it is one sentence either way, so it
never trips the "no inline non-quiet helper prose longer than one
sentence" gate regardless).
* Section properties, if this page carried them at all, would be a small
`kind: 'kv'` block -- never text drawn on a canvas. This page does not
compute or show them; that stayed with whichever tab already draws the
panel's gross geometry.
TWO STANDING RULES FROM CLAUDE.md, section 3, both still load-bearing here:
* "Could not judge" and "judged, found nothing" must never look alike.
Every optional free-text block below (Releases, both Soil Loads,
Additional Self Weight, Design Strips) still uses `_optional_note`: the
key existing in `values` but blank means the user was asked and
answered "none"; the key missing from `values` entirely means this form
has never asked yet.
* Nothing here computes wind or seismic. The owner ruled it explicitly,
2026-09-02, and again today for wind specifically -- every wind/seismic/
temperature field or table is collected and rendered, never computed,
never defaulted.
WHERE THE LAYOUT COMES FROM: Eriksson Wall's own printed "Structural Model"
page for the STRUCTURE and generic field names only -- no project, client,
licensee, engineer, job number, file name or date from it appears anywhere
below; every label is written from scratch in plain engineering English.
See CLAUDE.md's "No client or job information in the repo. Ever."
READING `vibe.panelanalysisform` DEFENSIVELY: that module owns the actual
key names and is being restructured by another agent in parallel with this
one. `page()` imports it, reads its `FIELDS` list once to find out which
plain fields it already knows about, and for every plain field this section
wants that it does NOT find there, it renders the field anyway (with a
label and hint written locally, right here) and adds one sentence to
`problems` naming the key -- never a raised exception, never a guess passed
off as the shared field model's answer.
`row_ids(table, values)` / `row_key(table, n, col)` are the CONTRACT this
page and `pa_sketch_freebody.py` share for the three row-tables
(`restraint`, `conc_load`, `wind_zone`): one row lives at
`'<table>.<n>.<col>'`, and `'<table>.<n>.present' == 'yes'` marks the row as
existing. `vibe.panelanalysisform` is expected to land the real
`row_ids`/`row_key` eventually (it does not today -- checked at import
time); until then this module implements the identical key arithmetic
locally under the same two names, so the day the shared version lands,
nothing here has to change.
Standard library only, no Revit, no WPF, no `panelsketch` import -- the
`kind: 'drawing'` block below only NAMES a `vibe.pa_sketch_freebody` entry
point as its `source`; a sibling agent owns what that module draws.
IronPython 2.7 throughout: no f-strings, `'{0}'.format(...)` everywhere,
`# -*- coding: utf-8 -*-` up top, no `pathlib` / `dataclasses` / `typing`.
"""
import re
# --------------------------------------------------------------------------
# Small helpers shared by every block below. None of these touch `values` --
# they only ever read it, and `page()` never assigns into a caller-owned
# dict, so the same `values` a caller hands in comes back untouched.
# --------------------------------------------------------------------------
def _text(value):
"""Coerce whatever sits in `values` to a string, defensively.
`values` is documented as string-valued, but this form is fed by a
live model read and a WPF text box on one side and a mid-refactor
field model on the other -- a stray non-string (an `int` typed
straight into a dict during a test, a `None` a half-written prefill
left behind) must degrade to an empty string, never raise.
"""
if value is None:
return ''
try:
value.strip # duck-typed string check -- works for str/unicode both
return value
except AttributeError:
try:
return str(value)
except Exception:
return ''
def _known_field_keys(paf):
"""The set of keys `vibe.panelanalysisform.FIELDS` already defines.
Empty set (never an exception) if the module failed to import or its
`FIELDS` list could not be read -- the caller has already logged why in
`problems` by the time this runs, and an empty set makes every field
below fall through to its local stand-in label, which is the correct
degrade rather than a crash.
"""
if paf is None:
return set()
try:
return set(f['key'] for f in paf.FIELDS)
except Exception:
return set()
def _field(paf, known, problems, key, required, editor, choices=None,
fallback_label=None, fallback_symbol='', fallback_hint='',
what=''):
"""One `kind: 'field'` block, sourced from `panelanalysisform` when it
already knows `key`, and from the fallback text passed in when it does
not -- appending one `problems` sentence in that second case, naming
the key and what it is for, so the field-model agent has a punch list
rather than a form that silently drifted from `panelanalysisform`.
"""
if key in known:
label = paf.label_of(key)
symbol = paf.symbol_of(key) or None
hint = paf.help_of(key) or None
else:
label = fallback_label or key
symbol = fallback_symbol or None
hint = fallback_hint or None
problems.append(
"'{0}' ({1}) has no entry yet in vibe.panelanalysisform -- "
'shown with a stand-in label until the field model adds it.'
.format(key, what or fallback_label or key))
return {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
def _sentence(text):
return {'kind': 'sentence', 'text': text}
def _quiet(text):
"""A grey helper sentence -- explains what a block is FOR, never its
live state. The renderer routes anything carrying `quiet: True` into
the one collapsed Notes panel instead of the inline flow (owner,
2026-09-04: keep the grey helper prose out of the way)."""
return {'kind': 'sentence', 'text': text, 'quiet': True}
def _section(title, blocks, collapsed=True):
return {'kind': 'section', 'title': title, 'collapsed': collapsed,
'blocks': blocks}
def _optional_note(values, key, blank_sentence, missing_sentence, problems,
what):
"""(state, text) for one optional free-text block.
Three states, and the whole point of this helper is that the first two
never look alike:
'missing' -- `key` is not in `values` at all. This form has not asked
the question yet; `text` is `missing_sentence` and one
line is appended to `problems` naming the gap. This is
"could not judge", never "judged, found nothing".
'blank' -- `key` is in `values` but empty/whitespace. The user was
asked and the honest answer is none; `text` is
`blank_sentence`. This IS "judged, found nothing".
'filled' -- `key` has real text; `text` is that text, stripped, for
the caller to parse into a table.
"""
if key not in values:
problems.append(
"'{0}' ({1}) is not collected by this form yet -- rendered as "
"unknown, never silently as \"none\"."
.format(key, what))
return 'missing', missing_sentence
text = _text(values.get(key)).strip()
if not text:
return 'blank', blank_sentence
return 'filled', text
def _rows_from_lines(text):
"""[[id, col, col, ...], ...] -- one row per non-blank line, comma-split.
A DISPLAY parse, not a validating one: a line that does not split into
the expected shape is shown with whatever columns it did produce rather
than refused, because this table exists to show the user what they
typed back at them.
"""
rows = []
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
cols = [p.strip() for p in line.split(',')]
rows.append([str(i)] + cols)
return rows
def _table_from_text(text, headers):
return {'kind': 'table', 'headers': headers, 'header_groups': None,
'rows': _rows_from_lines(text), 'row_key': None}
# --------------------------------------------------------------------------
# row_ids / row_key -- the shared row-table contract (see module docstring).
# Guards the `vibe.panelanalysisform` import and falls back to the identical
# key arithmetic locally, under the same two names, so nothing here has to
# change the day the shared helpers land.
# --------------------------------------------------------------------------
def row_key(table, n, col):
"""The `values` key for one row's cell: '<table>.<n>.<col>'."""
try:
from vibe import panelanalysisform as paf
return paf.row_key(table, n, col)
except (ImportError, AttributeError):
return '{0}.{1}.{2}'.format(table, n, col)
def row_ids(table, values):
"""Every PRESENT row id in one row-table, ascending, as INT ids.
A row counts as present when its `present` cell reads 'yes'
(case-insensitive); a row whose `present` cell is missing, blank, or
anything else ('no', a removed row) is not returned. This mirrors
`vibe.panelanalysisform.row_ids` / `_row_ids_any` exactly (landed
2026-09-04, id type and all) so the fallback path below behaves
identically to the real one it stands in for until import succeeds.
"""
try:
from vibe import panelanalysisform as paf
return list(paf.row_ids(table, values))
except (ImportError, AttributeError):
pass
values = values or {}
prefix = '{0}.'.format(table)
candidates = set()
for key in values:
if not isinstance(key, str) and not isinstance(key, type(u'')):
continue
if not key.startswith(prefix):
continue
n_txt = key[len(prefix):].split('.', 1)[0]
try:
n = int(n_txt)
except (TypeError, ValueError):
continue
if n > 0:
candidates.add(n)
out = [n for n in candidates
if str(values.get(row_key(table, n, 'present')) or '')
.strip().lower() == 'yes']
out.sort()
return out
def _editable_table(table, columns, add_label, empty_state=None):
"""One `editable_table` block. `empty_state`, when given, is
`{'basis_keys': [...], 'text': ...}`: what the page prints under the
table while it has NO rows -- the first `basis[<key>]` sentence the
model read left (the read's own reason, e.g. `supports_basis`), else
`text`. A blank table with no sentence reads as "nothing here", which
for a read that silently found nothing is the wrong answer (issue #22,
items 5 and 11; CLAUDE.md's could-not-judge vs judged-empty rule)."""
block = {'kind': 'editable_table', 'table': table, 'columns': columns,
'add_label': add_label}
if empty_state:
block['empty_state'] = dict(empty_state)
return block
_FREEBODY_ASPECT_BASE = 0.16
_FREEBODY_ASPECT_STEP = 0.03
_FREEBODY_ASPECT_MAX = 0.28
def _freebody_aspect(values):
"""The free-body drawing's `aspect` -- 0.16 baseline, +0.03 per
non-empty `conc_load` / `wind_zone` row, capped at 0.28.
Coordinator, live headless render, 2026-09-04: a diagram with no
concentrated loads and no wind zones has nothing to draw in its top
two rows (see `pa_sketch_freebody`'s LAYOUT ROWS), so handing it the
same fixed 0.22 aspect a busier diagram needs left roughly 45% of the
canvas empty -- the fix is a SHORTER canvas for a simple diagram, not
a taller one stretched to fill a fixed height. Two loads/wind rows
combined (a typical single load + single wind zone) lands back on the
old 0.22 by construction, so nothing already-tuned regresses.
"""
n = len(row_ids('conc_load', values)) + len(row_ids('wind_zone', values))
aspect = _FREEBODY_ASPECT_BASE + _FREEBODY_ASPECT_STEP * n
if aspect > _FREEBODY_ASPECT_MAX:
aspect = _FREEBODY_ASPECT_MAX
return aspect
def _col(col, label, editor, choices=None):
return {'col': col, 'label': label, 'editor': editor,
'choices': choices}
_SUPPORT_COLUMNS = [
_col('x_ft', 'Position along length (ft)', 'num'),
]
_RESTRAINT_COLUMNS = [
_col('elevation_ft', 'Elevation (ft)', 'num'),
_col('type', 'Type', 'choice', choices=['Pin', 'Fixed', 'Free',
'Floor tie']),
_col('gravity', 'Gravity restraint', 'check'),
_col('thermal', 'Thermal restraint', 'check'),
_col('stiffness_kin', 'Stiffness (k/in)', 'num'),
]
_CONC_LOAD_COLUMNS = [
_col('label', 'Label', 'text'),
_col('elevation_ft', 'Elevation (ft)', 'num'),
_col('offset_in', 'Offset (in)', 'num'),
_col('dead_k', 'Dead (k)', 'num'),
_col('live_k', 'Live (k)', 'num'),
]
_WIND_ZONE_COLUMNS = [
_col('label', 'Label', 'text'),
_col('from_elev_ft', 'From elevation (ft)', 'num'),
_col('to_elev_ft', 'To elevation (ft)', 'num'),
_col('width_ft', 'Width (ft)', 'num'),
_col('pressure_psf', 'Pressure (psf)', 'num'),
_col('suction_psf', 'Suction (psf)', 'num'),
]
# --------------------------------------------------------------------------
# page()
# --------------------------------------------------------------------------
def page(values, reads=None):
"""(blocks, problems) for the Structural Model section.
`values` is the form's current string-valued dict, keyed by
`vibe.panelanalysisform` field names plus the three row-tables' keys
(see the module docstring's `row_ids`/`row_key` contract). Never
mutated: every access below is `values.get(...)` or `key in values`,
nothing is assigned into it. `reads` is accepted for symmetry with the
rest of the form but nothing here reads it yet.
Never raises. A field the shared field model does not know yet, an
unreadable `panelanalysisform`, or `values` missing a key entirely all
degrade to a `problems` sentence plus a reasonable stand-in in the
layout -- never an exception, never a fabricated value.
"""
values = values if values is not None else {}
problems = []
blocks = []
paf = None
try:
from vibe import panelanalysisform as paf
except Exception as exc:
problems.append(
'could not import vibe.panelanalysisform ({0}); every field '
'below is shown with a locally-written label instead of the '
'shared field model.'.format(exc))
known = _known_field_keys(paf)
blocks.append({'kind': 'heading', 'text': 'Structural Model', 'level': 1})
blocks.append(_quiet(
'How this panel is held up, tied back and loaded once it is '
'standing in place, separate from its geometry and how it is '
'handled before it gets there.'))
# --------------------------------------------------------- Restraints
blocks.append({'kind': 'heading', 'text': 'Restraints', 'level': 2})
blocks.append(_quiet(
'Where the panel is connected to the foundation, and then, going '
'up, where it is tied back to each floor as the building is built '
'around it. Add one row per restraint elevation; gravity '
'restraint means that tie also carries the panel\'s own weight at '
'that point, and thermal restraint means it stops the panel from '
'bowing out of plane if one face gets hotter than the other.'))
blocks.append(_editable_table(
'restraint', _RESTRAINT_COLUMNS, 'Add restraint',
empty_state={
'basis_keys': ['restraints', 'supports'],
'text': 'No restraints read from the model and none entered '
'yet -- the read left no reason, so this is '
'could-not-judge, not "no restraints".'}))
blocks.append({'kind': 'heading', 'text': 'Releases', 'level': 3})
blocks.append(_quiet(
'Any restraint above that is deliberately freed in one direction '
'-- for example a tie that carries the weight but is left free to '
'slide sideways.'))
rel_state, rel_text = _optional_note(
values, 'releases',
blank_sentence='Design contains no releases.',
missing_sentence='Releases are not collected on this form yet -- '
'this is unknown, not "no releases".',
problems=problems, what='deliberately-freed restraint directions')
if rel_state == 'filled':
blocks.append(_table_from_text(rel_text, ['ID', 'Release']))
else:
blocks.append(_sentence(rel_text))
blocks.append({'kind': 'rule'})
# -------------------------------------------------------------- Bearing
# Owner ruling, 2026-09-05: support positions are an EDITABLE ROW TABLE
# (`support`, one column `x_ft`), the same mechanism as `restraint` /
# `conc_load` -- replacing the free-text `supports_ft` box. That FIELDS
# entry stays declared and is read only as a fallback for a panel saved
# before this table existed (see `panelanalysisform.validate`'s support
# derivation); it is no longer rendered here.
blocks.append({'kind': 'heading', 'text': 'Bearing for this analysis',
'level': 2})
blocks.append(_quiet(
'Which positions actually hold the panel up for THIS run, measured '
"along its length from the start -- the analysis needs at least "
'two, and they do not have to be every restraint listed above.'))
blocks.append(_editable_table(
'support', _SUPPORT_COLUMNS, 'Add support',
empty_state={
'basis_keys': ['supports', 'restraints'],
'text': 'No support positions read from the model and none '
'entered yet -- the read left no reason, so this is '
'could-not-judge, not "no supports". The analysis '
'needs at least two rows here.'}))
blocks.append({'kind': 'space', 'h': 8})
# ---------------------------------------------------- Concentrated Loads
blocks.append({'kind': 'heading', 'text': 'Concentrated Loads',
'level': 2})
blocks.append(_quiet(
'Extra point loads hung on the panel once it is standing -- a '
'ledger, a beam pocket, attached equipment -- and where along the '
'wall each one acts. A positive offset pulls toward the outside '
'face of the wall. Add one row per point load.'))
blocks.append(_editable_table('conc_load', _CONC_LOAD_COLUMNS,
'Add load'))
blocks.append({'kind': 'rule'})
# -------------------------------------------------------- Load Options
blocks.append({'kind': 'heading', 'text': 'Load Options', 'level': 2})
blocks.append(_field(
paf, known, problems, 'load_opt_pattern_live', False, 'check',
fallback_label='Pattern live loads for flexure',
fallback_hint='Whether live load is applied in a checkerboard '
'pattern (only some spans loaded at once) when '
'checking bending, instead of everywhere at once at '
'the same time. Leave unchecked if unsure.',
what='whether live load is patterned for the bending check'))
blocks.append(_field(
paf, known, problems, 'load_opt_selfweight_ecc_in', False, 'num',
fallback_label='Self-weight eccentricity', fallback_symbol='in',
fallback_hint="How far the panel's own weight is assumed to act "
'off its centerline. Leave blank to assume it acts '
'right on the centerline.',
what='the self-weight eccentricity'))
blocks.append(_field(
paf, known, problems, 'load_opt_offset_from', False, 'choice',
choices=['Deflection Section', 'Top in Form', 'Bottom in Form'],
fallback_label='Offset measured from',
fallback_hint='What point the point-load offsets above are '
"measured from. Leave blank to assume the panel's "
'own deflection section.',
what='what point-load offsets are measured from'))
blocks.append({'kind': 'rule'})
# ------------------------------------------------------------- Wind
#
# 2026-09-04: every ASCE input this section used to render (speed,
# exposure, risk category, enclosure, code edition, both chapter
# dropdowns) is GONE -- the owner ruled that computation belongs in
# project-config, not this page. `wind_mode` names the choice; only
# 'Manual' is built today, so a quiet sentence says so rather than a
# button that does nothing.
blocks.append({'kind': 'heading', 'text': 'Wind', 'level': 2})
blocks.append(_field(
paf, known, problems, 'wind_mode', False, 'choice',
choices=['Manual', 'From project config'],
fallback_label='Wind pressures come from',
fallback_hint='Whether wind pressure/suction is typed in directly '
'below, or (once built) computed from the '
'project-config wind parameters.',
what='whether wind pressures are typed in or computed'))
blocks.append(_quiet(
"'From project config' is not built yet -- wind pressures and "
'suctions have to be entered manually below regardless of this '
'choice.'))
blocks.append(_quiet(
'One row per wind zone: where it starts and ends up the panel '
'(elevation, not a fraction), how wide a strip of the wall it '
'covers, and the pressure and suction typed in directly, in psf. '
'Nothing here computes a pressure -- every number is typed in.'))
blocks.append(_editable_table('wind_zone', _WIND_ZONE_COLUMNS,
'Add wind zone'))
blocks.append({'kind': 'rule'})
# ----------------------------------------------------------- Seismic
seismic_blocks = [
{'kind': 'heading', 'text': 'Seismic', 'level': 2},
_quiet(
'Nothing in this tool computes a seismic force yet either -- '
'the same later-stage engine as wind. Shown here so it is not '
'lost, not used by the analysis.'),
_field(paf, known, problems, 'seismic_cs', False, 'num',
what='the seismic coefficient, as a percent of dead load'),
_field(
paf, known, problems, 'seismic_zone_location', False, 'text',
fallback_label='Seismic zone location(s)',
fallback_hint='Which part of the panel height the seismic '
'load applies over (e.g. full height). Not yet '
'used by the solve.',
what='where along the panel the seismic load applies'),
_field(
paf, known, problems, 'seismic_apply_to', False, 'choice',
choices=['None', 'Out-of-plane', 'In-plane', 'Both'],
fallback_label='Apply seismic multiplier to',
fallback_hint='Which loads the seismic coefficient multiplies '
'(out-of-plane, in-plane, or both). Not yet used '
'by the solve.',
what='what the seismic coefficient is applied to'),
]
sz_state, sz_text = _optional_note(
values, 'seismic_zone_rows',
blank_sentence='No seismic load zones entered.',
missing_sentence='Seismic load zones (start/end position, load '
'percentage) are not collected on this form yet '
'-- this is unknown, not "no seismic zones".',
problems=problems, what='seismic load zones along the height')
if sz_state == 'filled':
seismic_blocks.append(
_table_from_text(sz_text,
['ID', 'Start / end / seismic load % (as typed)']))
else:
seismic_blocks.append(_sentence(sz_text))
blocks.append(_section('Seismic', seismic_blocks, collapsed=True))
# ------------------------------------------------------- Temperature
temp_blocks = [
{'kind': 'heading', 'text': 'Temperature Loads', 'level': 2},
_quiet(
'How much hotter or colder one face of the panel gets than '
'the other, and how stiff the concrete is treated as being '
'while it moves. Not yet used by the solve.'),
]
temp_fields = (
('temp_ext_f', 'Exterior temperature', 'deg F'),
('temp_int_f', 'Interior temperature', 'deg F'),
('temp_elasticity_mult', 'Thermal elasticity multiplier', ''),
('temp_alpha_t', 'Coefficient of thermal expansion', u'αT × 10⁻⁶'),
)
for key, flabel, fsym in temp_fields:
temp_blocks.append(_field(
paf, known, problems, key, False, 'num',
fallback_label=flabel, fallback_symbol=fsym,
fallback_hint='Not yet used by the solve.',
what=flabel.lower()))
blocks.append(_section('Temperature Loads', temp_blocks, collapsed=True))
# ------------------------------------------------------------ Soil
#
# Owner named this ONE block ("seismic, temp, soil"), even though it
# has always held two sub-columns (exterior / interior face) -- kept as
# a single collapsed section with the same `columns` layout inside,
# not split into two sections.
ext_col = [{'kind': 'heading', 'text': 'Exterior Soil Loads',
'level': 3}]
es_state, es_text = _optional_note(
values, 'ext_soil_loads',
blank_sentence='Design contains no exterior soil loads.',
missing_sentence='Exterior soil loads are not collected on this '
'form yet -- this is unknown, not "no exterior '
'soil loads".',
problems=problems, what='soil pressing on the outside face')
ext_col.append(_sentence(es_text))
if es_state == 'filled':
ext_col.append(_field(
paf, known, problems, 'ext_soil_elevation_in', False, 'num',
fallback_label='Elevation', fallback_symbol='in',
fallback_hint='Where the exterior soil load starts, measured '
'up from the bottom of the panel. Not yet used '
'by the solve.', what='exterior soil elevation'))
ext_col.append(_field(
paf, known, problems, 'ext_soil_surcharge_psf', False, 'num',
fallback_label='Surcharge', fallback_symbol='psf',
fallback_hint='How much extra pressure the soil (plus '
'anything sitting on top of it) puts on the '
'panel. Not yet used by the solve.',
what='exterior soil surcharge pressure'))
int_col = [{'kind': 'heading', 'text': 'Interior Soil Loads',
'level': 3}]
is_state, is_text = _optional_note(
values, 'int_soil_loads',
blank_sentence='Design contains no interior soil loads.',
missing_sentence='Interior soil loads are not collected on this '
'form yet -- this is unknown, not "no interior '
'soil loads".',
problems=problems, what='soil pressing on the inside face')
int_col.append(_sentence(is_text))
if is_state == 'filled':
int_col.append(_field(
paf, known, problems, 'int_soil_elevation_in', False, 'num',
fallback_label='Elevation', fallback_symbol='in',
fallback_hint='Where the interior soil load starts, measured '
'up from the bottom of the panel. Not yet used '
'by the solve.', what='interior soil elevation'))
int_col.append(_field(
paf, known, problems, 'int_soil_surcharge_psf', False, 'num',
fallback_label='Surcharge', fallback_symbol='psf',
fallback_hint='How much extra pressure the soil (plus '
'anything sitting on top of it) puts on the '
'panel. Not yet used by the solve.',
what='interior soil surcharge pressure'))
soil_blocks = [
{'kind': 'heading', 'text': 'Soil Loads', 'level': 2},
{'kind': 'columns', 'columns': [ext_col, int_col]},
]
blocks.append(_section('Soil Loads', soil_blocks, collapsed=True))
blocks.append({'kind': 'rule'})
# ------------------------------------------------- Additional self weight
#
# 2026-09-04: neither this nor Design Strips below is built (nothing in
# the solve reads either yet), and the old `weight_diagram` drawing this
# block used to show is GONE -- the free-body above already draws every
# point load and the member itself; a second, tiny "point loads and self
# weight" picture repeating a subset of the same thing was exactly the
# "old drawing still on the page" the owner's headless render flagged.
# Collapsed the same way Seismic/Temperature/Soil are, so an unbuilt
# block is not sitting open and prominent in the inline flow.
asw_blocks = [{'kind': 'heading', 'text': 'Additional Self Weight',
'level': 2}]
asw_state, asw_text = _optional_note(
values, 'addl_sw_magnitude_plf',
blank_sentence='No additional self weight entered -- the '
"analysis uses only the panel's own concrete "
'weight.',
missing_sentence='Additional self weight is not collected on '
'this form yet -- this is unknown, not "none".',
problems=problems, what='extra weight added over part of the panel')
if asw_state == 'missing':
asw_blocks.append(_sentence(asw_text))
else:
if asw_state == 'blank':
asw_blocks.append(_sentence(asw_text))
asw_blocks.append(_field(
paf, known, problems, 'addl_sw_start_in', False, 'num',
fallback_label='Start from bottom', fallback_symbol='in',
fallback_hint='Where the extra weight starts, measured up '
'from the bottom of the panel. Not yet used by '
'the solve.', what='additional self weight start'))
asw_blocks.append(_field(
paf, known, problems, 'addl_sw_end_in', False, 'num',
fallback_label='End from top', fallback_symbol='in',
fallback_hint='Where the extra weight ends, measured down '
'from the top of the panel. Not yet used by '
'the solve.', what='additional self weight end'))
asw_blocks.append(_field(
paf, known, problems, 'addl_sw_magnitude_plf', False, 'num',
fallback_label='Magnitude', fallback_symbol='plf',
fallback_hint='How much extra weight per foot of height is '
'added over that stretch. Leave blank for none. '
'Not yet used by the solve.',
what='additional self weight magnitude'))
blocks.append(_section('Additional Self Weight', asw_blocks,
collapsed=True))
blocks.append({'kind': 'rule'})
# -------------------------------------------------------- Design Strips
ds_blocks = [
{'kind': 'heading', 'text': 'Design Strips', 'level': 2},
_quiet(
'Whether the panel is analysed as one member across its full '
'width, or split into narrower strips first.'),
]
ds_blocks.append(_field(
paf, known, problems, 'design_strips_method', False, 'choice',
choices=['Single Member', 'Design Strips'],
fallback_label='Analysis method',
fallback_hint='Whether the whole cross section is analysed as one '
'member, or split into design strips. Blank uses '
'the whole cross section. Not yet used by the '
'solve.', what='the design-strip analysis method'))
ds_state, ds_text = _optional_note(
values, 'design_strips_rows',
blank_sentence='No design strips are defined; the entire cross '
'section will be used.',
missing_sentence='Design strips are not collected on this form '
'yet -- this is unknown, not "no strips".',
problems=problems, what='the design strip boundaries')
if ds_state == 'filled':
ds_blocks.append(_table_from_text(ds_text, ['ID', 'Strip (as typed)']))
else:
ds_blocks.append(_sentence(ds_text))
blocks.append(_section('Design Strips', ds_blocks, collapsed=True))
blocks.append({'kind': 'rule'})
# --------------------------------------------------------- Free-body
#
# Owner, 2026-09-04: "restrain & support 的那个示意图,本质上是一个
# freebody" -- one horizontal line standing in for the member, the
# restraints at their true positions, and the loads drawn on it. This
# replaces the old `source: 'elevation'` drawing (panel front/side/
# section-properties/cross-section) this page used to reuse here --
# that drawing overlapped its own sub-panels and had nothing to do
# with restraints or loading; it is gone from this page.
#
# Owner, 2026-09-07 (issue #22, item 6b): "把 free body 的图挪到页面的
# 最底下" -- it is the LAST block on this page, after every collapsed
# section, so everything it draws is entered above it. `_freebody_last`
# in `test_pa_structural.py` pins that.
#
# `aspect` is no longer a flat 0.22 -- see `_freebody_aspect`'s own
# docstring: a diagram with few or no loads/wind zones gets a shorter
# canvas instead of a fixed-height one with empty space at the bottom.
blocks.append({'kind': 'drawing', 'module': 'pa_sketch_freebody',
'source': 'freebody', 'full_width': True,
'aspect': _freebody_aspect(values),
'caption': 'Free body: restraints and loads',
'depends_on': ['restraint', 'support', 'conc_load',
'wind_zone', 'bottom_elevation_ft',
'top_elevation_ft', 'supports_ft']})
return blocks, problems