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