Files
willtools-demo/WillTools.extension/lib/vibe/pa_reinforcement.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:32:15 -05:00

1437 lines
62 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".
WHY REBAR STOPPED BEING FREE TEXT (owner, 2026-09-04): "rebar 怎么可能是打字
上去? 而不是进入 model 的? 你这样计算模型怎么接收这个数据?" -- typed prose can
never feed a calculation, so this page no longer renders `ps_rows` / `mild_as`
as text boxes. Strands and mild bars are each a `strand_row` / `mild_bar`
TABLE now, one row per strand group / bar callout, each cell its own value
under `<table>.<n>.<col>` with `<table>.<n>.present` == 'yes' marking a row as
real (see `row_ids` / `row_key` below) -- the same shape
`panelanalysisform.load_combo_row_ids` already uses for the load-combination
tables, generalised to a name (`table`) this module chooses rather than one
baked into that function. Rendered as an `editable_table` block per table:
schema only (the table name, its column specs, an add-row label) -- no `rows`
in the block itself, because the renderer reads the live rows straight out of
the form's `values` dict the same way it reads any other field, and a second
copy of the same data here would just be a second place for it to go stale.
`page(values, reads=None, know=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', 'editable_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.
Every 'sentence' block carries `'quiet': True` (owner, 2026-09-04: "Helper
prose becomes a quiet sentence") -- the renderer routes those into the one
collapsed Notes panel instead of printing them inline, so this page can be
honest about every empty/degraded state without turning into a wall of grey
text.
`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 -- Aps
for that row is left out of the summary and `problems` names the size.
* Strand horizontal position (cgx) and both eccentricities (ex, ey) are
never printed IN THIS PAGE'S OWN SUMMARY -- cgx stays "not shown". A
count-weighted cgx over rows with no horizontal data would dress up an
assumption as a fact. Depth (cgy) IS printed, because depth-to-CG is
exactly what the user types per strand row -- a count-weighted average
of real entered numbers, not an invented one. What the summary DOES
show, per row, is the strand positions themselves -- `Strand x
positions`, one feet-inches value per strand -- but ONLY when that
row's own `chain` cell resolves them (issue #23 item 9a, 2026-09-07);
a row with no chain reads 'not entered'. Those are real typed numbers,
not an invented cgx, so showing them does not break the rule above.
(The DRAWING, `pa_sketch_reinf.section`, is a separate module with its
own separate contract -- it draws a real cgx/CG marker when the form
supplies actual per-strand x positions via a resolved `chain` cell or
the legacy `strand_x_in` / `strand_columns_in` lists; that is real data
this page's own summary table has no cgx COLUMN for, not a change to
the rule above.)
* Horizontal strand spacing is not a column on the `strand_row` table, so
nothing here implies it is known. (Owner, 2026-09-07, issue #23 item
9a: it was to become one -- a dimension chain from the strand rail, one
cell per strand -- which needs a row whose cell count follows its own
`count`, a shape no block kind rendered then. DONE 2026-09-07: the
`strand_row` table now carries a `chain` column, one `;`/`,`-separated
length per cell (`2";2";2";2"` == four strands at 2, 4, 6, 8 in from
the rail). `strand_positions()` below is the parse/validate/convert
rule; a row whose chain does not resolve is named in `problems`, never
guessed, and `pa_sketch_reinf` consumes the resolved positions.)
WHY `row_ids` / `row_key` ARE READ DEFENSIVELY: a sibling agent owns
`vibe.panelanalysisform` and is restructuring it, at the same time this file
was written, to add generic row-table helpers under exactly these two names
(the same `<table>.<row>.<col>` key shape `load_combo_row_ids` already uses,
generalised). Until that lands, `row_ids` / `row_key` below fall back to a
LOCAL implementation of the identical key arithmetic -- guarded, not
required, so this page works before, during and after that sibling's change
lands. Likewise every other read off `panelanalysisform` (field specs, the
strand-area table) goes through a guarded accessor that turns "not there yet"
into a `problems` sentence, never a traceback.
No Revit import, no WPF import, no `panelsketch` import. This module DOES
import `vibe.pa_sketch_reinf` (guarded) for exactly one thing -- the
`aspect` clamp formula on the drawing block below -- so the ratio the drawing
block asks for and the ratio the drawing module actually letterboxes to can
never drift apart; that import is one-directional (`pa_sketch_reinf` does not
know this module exists) and carries no Revit/WPF of its own, so it costs
nothing on either Python. 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.
"""
try:
from vibe import panelanalysisform as pf
except ImportError: # sibling mid-restructure
pf = None
try:
from vibe import pa_sketch_reinf as psr
except ImportError:
psr = None
# --------------------------------------------------------------- constants
_STRAND_TABLE = 'strand_row'
_MILD_TABLE = 'mild_bar'
# The strand-size vocabulary is `panelanalysisform.STRAND_DIA_CHOICES` --
# ONE list shared with the scalar `ps_dia_in` field, so the two dropdowns
# can never offer different sizes. '1/2 SP' (the 1/2" special, 0.167 in²)
# is its own entry beside plain '1/2' (0.153 in²) -- owner, 2026-09-07,
# issue #23 item 9d: a different product, never a spelling of the other.
# The local list is the fallback for the sibling-mid-restructure case the
# module docstring describes, kept identical (a guard test pins that).
_STRAND_DIA_CHOICES_FALLBACK = ['3/8', '7/16', '1/2', '1/2 SP', '9/16',
'0.6', '0.7']
def _strand_dia_choices():
if pf is not None and hasattr(pf, 'STRAND_DIA_CHOICES'):
try:
return list(pf.STRAND_DIA_CHOICES)
except Exception:
pass
return list(_STRAND_DIA_CHOICES_FALLBACK)
_STRAND_COLUMNS = [
{'col': 'count', 'label': 'Qty', 'editor': 'int'},
# No '(in)' suffix on this label: pa_pagespec splits a trailing
# '(unit)' into the column's `unit`, and app.js then formats the cell
# as ONE length -- '2;2;2;2' rendered as 2" (first headless render,
# 2026-09-07). The chain is text with several lengths in it.
{'col': 'chain',
'label': 'Location chain from rail, inches: 2;2;2;2',
'editor': 'text'},
{'col': 'depth_in', 'label': 'Depth to CG (in)', 'editor': 'num'},
{'col': 'dia_in', 'label': 'Dia (in)', 'editor': 'choice',
'choices': _strand_dia_choices()},
{'col': 'pull_lb', 'label': 'Initial pull (lb)', 'editor': 'num'},
]
# Owner ruling, 2026-09-07 (issue #23, item 9h): one row per interrupt --
# a station along the length where reinforcement is cut and development
# restarts. `x_ft` is measured from the panel's start, the same datum the
# rebar rows' From/To and the opening table use.
_INTERRUPT_TABLE = 'interrupt'
_INTERRUPT_COLUMNS = [
{'col': 'x_ft', 'label': 'Position along length (ft)', 'editor': 'num'},
{'col': 'cuts', 'label': 'Cuts', 'editor': 'choice',
'choices': ['All', 'Strands', 'Rebar', 'Mesh']},
]
_MILD_COLUMNS = [
{'col': 'size', 'label': 'Bar size', 'editor': 'choice',
'choices': ['#3', '#4', '#5', '#6', '#7', '#8', '#9', '#10', '#11']},
{'col': 'count', 'label': 'Qty', 'editor': 'int'},
{'col': 'depth_in', 'label': 'Depth to CG (in)', 'editor': 'num'},
{'col': 'direction', 'label': 'Direction', 'editor': 'choice',
'choices': ['Longitudinal', 'Transverse']},
{'col': 'from_ft', 'label': 'From (ft)', 'editor': 'num'},
{'col': 'to_ft', 'label': 'To (ft)', 'editor': 'num'},
{'col': 'spacing_in', 'label': 'Spacing (in)', 'editor': 'num'},
]
# Scalar fields kept on the page beside the two tables (owner, 2026-09-04:
# name every one of these explicitly, so a future edit here cannot quietly
# drop one the way a re-derived list could).
_STRAND_RAIL_KEYS = ('strand_rail_location', 'strand_rail_offset_in')
# `ps_pull_lb` ("Initial pull per strand (default)") left this page on
# 2026-09-07 (issue #23, item 9e): every strand row carries its own pull
# and the rows are the only source of it. `ps_count` is still listed but
# renders as a read-only kv row (the rows' sum) as soon as any strand row
# exists -- item 9b of the same ruling -- and as an editable field only
# while the table is empty.
_STRAND_SCALAR_KEYS = ('ps_count', 'ps_dia_in', 'ps_loss_pct')
_MESH_FIELD_KEYS = ('mesh', 'mesh_layers', 'mesh_depth_in')
# Issue #23 item 9g (2026-09-07): the plant knowledge tree's `mesh:` section
# (the sample_plant layer -- its standard mesh sheet M9, dated 2023-07-18)
# carries one dict per stocked WWF style, grouped under five named sections:
# four structural homes plus the "not in the standard sheet but still
# stocked" list, whose entries name the stocking plant in `plant_only`. The
# order here is the dropdown's grouping order; the section's own metadata
# keys (`dated`, `d_number_meaning`, ...) are scalars, not dicts, so the
# isinstance guard in `plant_mesh_styles` never mistakes one for a section.
_MESH_KNOW_SECTIONS = ('flat_sheets', 'dt_stem', 'metrodeck_stem', 'beam',
'not_in_standard_but_stocked')
# panelanalysisform.FIELDS' 'kind' -> the generic 'editor' vocabulary this
# page speaks. Nothing here is 'check' -- every Reinforcement scalar field is
# free text, a number, or (mesh_layers, once declared) an int.
_EDITOR_OF_KIND = {'text': 'text', 'num': 'num', 'int': 'num'}
def _sentence(text):
"""A quiet 'sentence' block -- see the module docstring: every sentence
this page emits is helper prose, routed into the collapsed Notes panel
rather than printed inline."""
return {'kind': 'sentence', 'text': text, 'quiet': True}
def _spec_of(key):
"""`panelanalysisform.FIELDS`' raw dict for `key`, or None.
None covers every way this can fail to have the answer: `pf` failed to
import, `_spec` is not there (mid-restructure), or `key` genuinely is not
declared -- all three are "not present", never an error.
"""
if pf is None:
return None
try:
return pf._spec(key)
except Exception:
return None
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 = _spec_of(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)
# --------------------------------------------------------- row-table helpers
def _local_row_key(table, n, col):
return '{0}.{1}.{2}'.format(table, n, col)
def _local_row_ids(table, values):
"""Every row id present for `table` in `values`, ascending, as ints --
matching `panelanalysisform.row_ids`'s own return shape exactly, so a
caller sees the same type whether this delegated or fell back.
A row exists when `<table>.<n>.present` == 'yes' -- the form adds a row
by writing its keys, so the keys ARE the record of what rows exist (the
same discipline `panelanalysisform.load_combo_row_ids` documents for its
own, table-specific version of this).
"""
values = values or {}
prefix = '{0}.'.format(table)
suffix = '.present'
ids = []
for key in values:
if not hasattr(key, 'startswith') or not hasattr(key, 'endswith'):
continue # a garbage/non-str key
if not key.startswith(prefix) or not key.endswith(suffix):
continue
n_txt = key[len(prefix):-len(suffix)]
if not n_txt or '.' in n_txt:
continue
if values.get(key) not in ('yes', True):
continue
try:
n = int(n_txt)
except (TypeError, ValueError):
continue
ids.append(n)
ids.sort()
return ids
def row_key(table, n, col):
"""The values-dict key for one row's cell -- delegates to
`panelanalysisform.row_key` once the sibling restructure adds it, falls
back to the identical local arithmetic until then (see module docstring).
"""
if pf is not None and hasattr(pf, 'row_key'):
try:
return pf.row_key(table, n, col)
except Exception:
pass
return _local_row_key(table, n, col)
def row_ids(table, values):
"""Every row id for `table` in `values` -- see `row_key`'s docstring for
the same delegate-then-fallback discipline."""
if pf is not None and hasattr(pf, 'row_ids'):
try:
return pf.row_ids(table, values)
except Exception:
pass
return _local_row_ids(table, values)
def _cell(values, table, n, col):
return (values or {}).get(row_key(table, n, col))
def _num_cell(values, table, n, col):
"""(float_or_None, error_or_None) for one row's numeric cell."""
raw = _cell(values, table, n, col)
if raw is None:
return None, None
if isinstance(raw, (int, float)):
return float(raw), None
text = raw.strip() if hasattr(raw, 'strip') else str(raw).strip()
if not text:
return None, None
try:
return float(text), None
except (TypeError, ValueError):
return None, 'is not a number ({0!r})'.format(text)
def _int_cell(values, table, n, col):
val, err = _num_cell(values, table, n, col)
if err:
return None, err
if val is None:
return None, None
return int(val), None
def _text_cell(values, table, n, col):
raw = _cell(values, table, n, col)
if raw is None:
return ''
if not hasattr(raw, 'strip'):
try:
raw = str(raw)
except Exception:
return ''
return raw.strip()
def _strand_area(dia_text):
"""(area_in2_or_None, error_or_None) -- guarded `strand_area_in2`."""
if not dia_text:
return None, None
if pf is None or not hasattr(pf, 'strand_area_in2'):
return None, (
"strand size {0!r}: panelanalysisform is not available -- its "
"area cannot be looked up".format(dia_text))
try:
return pf.strand_area_in2(dia_text)
except Exception as exc:
return None, 'strand size {0!r}: lookup failed ({1})'.format(
dia_text, exc)
def _strand_dia_area_map():
"""{choice_text: area_in2} for EVERY `_strand_dia_choices()` entry --
the `dia_in` map a live Aps kv row ships to the page (docs/pa-web-spec
.md §2, "kv"; issue #23, 2026-09-07) so the browser can re-sum Aps when
a row's diameter changes to ANY size the dropdown offers. A choice
whose area cannot be looked up maps to 0.0 -- that only happens when
panelanalysisform itself is unreachable (the guarded path above), and
then the summary kv carrying this map is never emitted anyway, because
the same lookup failure on a present row made `any_unknown_area`."""
area_map = {}
for dia in _strand_dia_choices():
area, _area_err = _strand_area(dia)
area_map[dia] = area if area is not None else 0.0
return area_map
def _fmt_in(value):
"""A decimal-inch value -> the short feet-inches text the summary and
geometry use (`2.0` -> `2"`, `6.5` -> `6 1/2"`, `18.0` -> `1'-6"`).
Matches how `pa_units.feet_str` reads a decimal-feet value (that helper
is in inches here, so it is not reused directly)."""
negative = value < 0.0
value = abs(value)
whole_in = int(value)
frac = value - whole_in
frac_text = ''
if frac > 1e-9:
eighths = int(round(frac * 8.0))
if eighths >= 8:
whole_in += eighths // 8
eighths = eighths % 8
if eighths:
halves = {1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2',
5: '5/8', 6: '3/4', 7: '7/8'}
frac_text = ' ' + halves[eighths]
if whole_in >= 12:
whole_ft = whole_in // 12
whole_in = whole_in % 12
text = u"{0}'-{1}{2}\"".format(whole_ft, whole_in, frac_text)
else:
text = u'{0}{1}"'.format(whole_in, frac_text)
return u'-' + text if negative else text
# ------------------------------------------------------------------ strand x
#
# Issue #23 item 9a (owner, 2026-09-07): a strand row's horizontal position
# is entered as a DIMENSION CHAIN from the strand rail -- one `;`/`-separated
# length per strand, each measured rail-to-strand, so `2";2";2";2"` puts
# four strands at 2, 4, 6, 8 in from the rail ("位置就是累加-offset"). The
# parse/validate/mirror rule lives HERE (the page) because the same numbers
# feed the page's own summary; `pa_sketch_reinf` imports this one helper and
# its shared length parser rather than duplicating either.
def _chain_split(text):
"""['2"', '2"', '2"', '2"'] from '2";2",2"; 2"'. Splits on `;` or `,`,
strips each cell -- anything else (a stray `x`) stays inside one cell
so the length parser can refuse it BY NAME instead of it vanishing."""
out = []
for raw in (text or '').replace(',', ';').split(';'):
cell = raw.strip()
if cell:
out.append(cell)
return out
def _decimal_of(text):
"""A plain non-negative decimal number -> float, or None."""
t = (text or '').strip()
if not t:
return None
try:
v = float(t)
except (TypeError, ValueError):
return None
if v < 0:
return None
return v
def _fraction_of(text):
"""'3/4' -> 0.75, or None when it is not a clean fraction."""
t = (text or '').strip()
if not t or '/' not in t:
return None
num, _sep, den = t.partition('/')
num = num.strip()
den = den.strip()
try:
n = float(num)
d = float(den)
except (TypeError, ValueError):
return None
if n < 0 or d <= 0:
return None
return n / d
def _inches_of(text):
"""'2', '2.5', '1/4', '2 1/4' -> inches as a float, or None."""
t = (text or '').strip()
if not t:
return 0.0
if ' ' in t:
parts = t.split()
if len(parts) != 2:
return None
whole = _decimal_of(parts[0])
frac = _fraction_of(parts[1])
if whole is None or frac is None:
return None
return whole + frac
if '/' in t:
return _fraction_of(t)
return _decimal_of(t)
def parse_length_in(text):
"""A single typed length -> inches, or None if it cannot be read.
The shared parser for the `strand_row.chain` cells: a plain number
('2' -> 2.0, read as inches, the way every other inch cell on this
page reads a bare number), an inches spelling ('2"', '2in', '2 1/2"',
'1/4"'), or feet-inches ('0\\'-2"' -> 2.0, "1'-6\"" -> 18.0). Typed
quotes/primes are normalised first. An empty string is None -- a chain
never contains blanks, so a blank cell is an error upstream. Never
raises; anything not read is refused (None), never guessed.
"""
if text is None:
return None
s = str(text).strip()
if not s:
return None
s = (s.replace(u'\u2019', "'").replace(u'\u201c', '"')
.replace(u'\u201d', '"').replace(u'\u2032', "'")
.replace(u'\u2033', '"').replace(u'\u00a0', ' ')
.replace('IN', 'in').replace('In', 'in').lower())
if "'" in s:
feet_text, _sep, rest = s.partition("'")
feet = _decimal_of(feet_text)
if feet is None:
return None
rest = rest.strip()
if rest.startswith('-'):
rest = rest[1:].strip()
if not rest:
return None
if rest.endswith('"'):
rest = rest[:-1].strip()
elif rest.endswith('in'):
rest = rest[:-2].strip()
if not rest:
return None
inches = _inches_of(rest)
if inches is None:
return None
return feet * 12.0 + inches
t = s
if t.endswith('"'):
t = t[:-1].strip()
elif t.endswith('in'):
t = t[:-2].strip()
if not t:
return None
return _inches_of(t)
def parse_chain_lengths(chain_text, row_count, row_label='strand row'):
"""([length_in, ...] | None, problem | None) for a `chain` cell.
Splits `chain_text` on `;`/`,` and parses every cell with the shared
`parse_length_in`; validates the cell count against `row_count`. A
blank chain is `([], None)` -- an empty chain is not an error (the
caller decides a blank means "unknown position"). Each length is the
rail-to-strand gap for that one cell (NOT cumulative) -- the parsed
list `pa_sketch_reinf` carries per row as `chain_in` so a future
drawing (issue #23 item 9i) can draw the chain itself. Never raises;
anything not read is refused by name.
"""
cells = _chain_split(chain_text or '')
if row_count is not None and len(cells) != int(row_count):
return None, ('{0}: chain has {1} cells for {2} strands'
.format(row_label, len(cells), int(row_count)))
lengths = []
for i, cell in enumerate(cells):
v = parse_length_in(cell)
if v is None or v < 0:
return None, ('{0}: chain cell {1} ({2!r}) is not a readable '
'length'.format(row_label, i + 1, cell))
lengths.append(v)
return lengths, None
def strand_positions(row_count, chain_text, rail_location,
rail_offset_in, width_in, row_label='strand row'):
"""(list_of_x_in_from_panel_left | None, problem | None) for one strand
row's `chain` cell -- issue #23 item 9a (owner, 2026-09-07).
`chain_text` is `count` `;`/`,`-separated lengths, each the gap from
the strand rail to that strand ("位置就是累加-offset"). Returns the
cumulative sums -- `2";2";2";2"` -> [2, 4, 6, 8] measured from the
rail -- minus `rail_offset_in` (0 when blank), expressed from the
PANEL'S LEFT edge: direct when the rail is 'left', mirrored
(`width_in - x`) when the rail is 'right'. An unreadable/blank chain
is not this helper's failure -- the caller treats a blank chain as
"unknown position" BEFORE calling (see `_strand_table_rows`).
`row_label` names the row in every problem sentence ('strand row',
'strand row 3', ...).
Never guesses: a chain that does not resolve returns `(None, problem)`
-- entry count != row count, a cell that will not parse, or a position
that lands outside the panel (`< 0` or `> width_in`; the strand index
and the offending value are named). `width_in` missing or <= 0 is a
problem (there is nothing honest to measure against); `rail_location`
is matched the same way the drawing's own rail label matches it
('left'/'right' substring, case-insensitive; anything else is not a
rail side and is reported).
"""
if row_count is None:
return None, '{0}: no count to match a chain against'.format(
row_label)
count = int(row_count) if row_count else 0
if count <= 0:
return None, '{0}: count is not a positive number'.format(row_label)
if not width_in or width_in <= 0:
return None, ('{0}: the panel width is not entered -- a chain '
'cannot be placed'.format(row_label))
lengths, err = parse_chain_lengths(chain_text or '', count,
row_label=row_label)
if err:
return None, err
rail = (rail_location or '').strip().lower()
if 'left' in rail:
left_datum = True
elif 'right' in rail:
left_datum = False
else:
return None, ("{0}: the strand rail location is not 'left' or "
"'right' -- a chain cannot be placed".format(
row_label))
try:
offset = float(rail_offset_in) if rail_offset_in not in (None, '') \
else 0.0
except (TypeError, ValueError):
offset = 0.0
if offset < 0 or offset > width_in:
return None, ('{0}: the rail offset ({1!r}) is outside the panel '
'width'.format(row_label, rail_offset_in))
positions = []
running = 0.0
for i, v in enumerate(lengths):
running += v
x = running - offset
if not left_datum:
x = width_in - x
if x < 0 or x > width_in:
return None, ('{0}: strand {1} would sit at {2:.3f} in from '
'the left, outside the panel width of {3:.3f} '
'in'.format(row_label, i + 1, x, width_in))
positions.append(x)
return positions, None
# ------------------------------------------------------------------ strands
def _strand_table_rows(values, problems):
"""[{'id', 'count', 'chain', 'depth_in', 'dia_in', 'pull_lb'}, ...]
read off the `strand_row` table. A cell that will not parse as a
number is reported and treated as blank -- never dropped, never
guessed. `chain` is the RAW cell text ('' when blank); resolving it to
positions is the summary block's job (`strand_positions`), so the same
rows can also be handed to the drawing, which reports its own chain
problems."""
rows = []
for n in row_ids(_STRAND_TABLE, values):
count, cerr = _int_cell(values, _STRAND_TABLE, n, 'count')
if cerr:
problems.append('strand row {0}: count {1}'.format(n, cerr))
depth, derr = _num_cell(values, _STRAND_TABLE, n, 'depth_in')
if derr:
problems.append('strand row {0}: depth to CG {1}'.format(n, derr))
dia = _text_cell(values, _STRAND_TABLE, n, 'dia_in') or None
pull, perr = _num_cell(values, _STRAND_TABLE, n, 'pull_lb')
if perr:
problems.append('strand row {0}: initial pull {1}'.format(n, perr))
chain = _text_cell(values, _STRAND_TABLE, n, 'chain')
rows.append({'id': n, 'count': count, 'chain': chain,
'depth_in': depth, 'dia_in': dia, 'pull_lb': pull})
return rows
def _strand_block(values, problems):
"""The whole Strands sub-block: rail fields, the editable table, the
scalar fields, then the Aps/cgy summary or the empty-case sentence.
"""
blocks = [{'kind': 'heading', 'text': 'Strands', 'level': 2}]
rail_fields = []
for key in _STRAND_RAIL_KEYS:
fb = _field_block(key, problems)
if fb is not None:
rail_fields.append([fb])
if rail_fields:
blocks.append({'kind': 'columns', 'columns': rail_fields})
blocks.append({
'kind': 'editable_table', 'table': _STRAND_TABLE,
'columns': _STRAND_COLUMNS, 'add_label': 'Add strand row',
})
rows = _strand_table_rows(values, problems)
for key in _STRAND_SCALAR_KEYS:
if key == 'ps_count' and rows:
# Owner, 2026-09-07 (issue #23, 9b): with rows present the
# count is the rows' sum, shown, never typed -- a kv row, not
# a field, so nothing can be entered out of step with the
# table. The row carries a `live` spec (item 9 of the same
# issue: the summary must follow the rows IN THE BROWSER as
# they are edited, not only on the next build/Run) -- the
# server value here is only the initial display.
total = sum((r['count'] or 0) for r in rows)
blocks.append({'kind': 'kv', 'rows': [
('Strand count', '{0} (sum of the strand rows above)'
.format(total),
{'table': _STRAND_TABLE, 'terms': ['count'],
'format': '%d'})]})
continue
fb = _field_block(key, problems)
if fb is None:
continue
blocks.append(fb)
if not rows:
blocks.append(_sentence('Design contains no prestressing'))
return blocks
default_dia = _text_of(values, 'ps_dia_in')
aps = 0.0
total_count = 0
depth_weighted = 0.0
depth_known_count = 0
any_unknown_area = False
# Total IP (owner, 2026-09-07, issue #23 item 9c): the sum of every
# row's count x its own initial pull -- the same shape Aps is summed
# in below, and the same figure `panellift.model.Prestress.
# jacking_stress_ksi` sums for its f_pi audit. A row with no pull of
# its own leaves the total unknown rather than under-counting.
total_pull = 0.0
any_unknown_pull = False
for row in rows:
dia_text = row['dia_in'] or default_dia
count = row['count'] or 0
if row['pull_lb'] is None:
any_unknown_pull = True
else:
total_pull += count * row['pull_lb']
if not dia_text:
any_unknown_area = True
problems.append(
'strand row {0} has no diameter entered (per-row or the '
'default above) -- its area could not be found'
.format(row['id']))
else:
area, area_err = _strand_area(dia_text)
if area_err:
problems.append(area_err)
if area is None:
any_unknown_area = True
else:
aps += count * area
total_count += count
if row['depth_in'] is not None:
depth_weighted += count * row['depth_in']
depth_known_count += count
# Issue #23 item 9a (2026-09-07): every row's own `chain` cell is a
# dimension chain from the strand rail -- one length per strand
# ("位置就是累加-offset"). Validate each ENTERED chain here (the same
# `strand_positions` rule `pa_sketch_reinf.section_geometry` runs) so a
# chain that does not resolve is named in `problems` on the page, never
# guessed; the resolved positions feed the 'Strand x positions' kv row
# below. A row with a blank chain is "unknown position" -- today's
# even-spread fallback -- and gets no problem here (that is the quiet
# fallback sentence's job, further down).
width_ft, _ = _num_of(values, 'width_ft')
width_in = width_ft * 12.0 if width_ft and width_ft > 0 else None
rail_loc = _text_of(values, 'strand_rail_location')
rail_off = _text_of(values, 'strand_rail_offset_in')
per_row_x = [] # parallel to `rows`
for row in rows:
if not row['chain']:
per_row_x.append(None)
continue
label = 'strand row {0}'.format(row['id'])
positions, perr = strand_positions(
row['count'], row['chain'], rail_loc, rail_off, width_in,
row_label=label)
if perr:
problems.append(perr)
per_row_x.append(None)
else:
per_row_x.append(positions)
if total_count > 0 and not any_unknown_area:
cgy = (depth_weighted / float(depth_known_count)
if depth_known_count > 0 else None)
# `Strand x positions` -- the per-row resolved chain positions
# (inches from the panel's LEFT edge), formatted feet-inches;
# 'not entered' when no row carries a resolvable chain. A row whose
# chain failed to resolve is already named in `problems` above, so
# it is simply left out of this list, never shown as a guess.
fmt_rows = []
for i, positions in enumerate(per_row_x):
if positions is None:
continue
r = rows[i]
fmt_rows.append('row {0}: {1}'.format(
r['id'], ', '.join(_fmt_in(x) for x in positions)))
strand_x_value = '; '.join(fmt_rows) if fmt_rows \
else 'not entered'
# Aps and Total IP are the two numbers a user edits into being row
# by row, so both rows carry a `live` spec (docs/pa-web-spec.md §2,
# "kv"; issue #23, 2026-09-07): the browser re-sums them over the
# strand_row table as rows are edited/added/removed. These server
# values are the initial display; the kv rows without a 3rd element
# (cgy and the rest) stay plain two-column readouts.
kv_rows = [
('Aps, in²', ('%.4f' % aps).rstrip('0').rstrip('.'),
{'table': _STRAND_TABLE,
'terms': ['count',
{'col': 'dia_in', 'map': _strand_dia_area_map()}],
'format': '%.4f',
'unknown': 'not shown -- a strand row\'s diameter has no '
'known area'}),
('Total IP, lb (sum of initial pull)',
('%.1f' % total_pull).rstrip('0').rstrip('.')
if not any_unknown_pull
else 'not shown -- a strand row is missing its initial pull',
{'table': _STRAND_TABLE, 'terms': ['count', 'pull_lb'],
'format': '%.1f',
'unknown': 'not shown -- a strand row is missing its initial '
'pull'}),
('cgy, in (depth to CG)',
('%.4f' % cgy).rstrip('0').rstrip('.') if cgy is not None
else 'not shown -- a strand row is missing its depth'),
('Strand x positions', strand_x_value),
('cgx, in', 'not shown -- horizontal strand position is not '
'a column on the strand table, only an assumption '
'made 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 -- a count-weighted cgx would need every strand\'s '
'horizontal position collected here, and per-strand positions '
'are only entered per row when that row\'s chain is filled')
elif total_count > 0:
problems.append(
'strand summary (Aps, cgy) could not be computed for every row '
'-- see the per-row problems above')
# The drawing spreads some strand dots evenly across the width for
# want of a real per-strand x -- an honest, expected degradation
# (2026-09-04), so it is a QUIET sentence here rather than a
# `problems` entry (`pa_sketch_reinf.section`'s own `problems` is
# reserved for genuine failures now; see that module's docstring).
# `used_fallback_strand_x` is the guarded delegate so this page asks
# the drawing's OWN question rather than re-deriving the length-match /
# chain-resolution rule -- absent/broken `psr` degrades to silence,
# never a crash.
if psr is not None and hasattr(psr, 'used_fallback_strand_x'):
try:
if psr.used_fallback_strand_x(values):
blocks.append(_sentence(
'The cross-section below spreads at least one row of '
'these strands evenly across the width -- a strand row '
'with no location chain from the rail (and no '
'strand_x_in read off the model) has no honest '
'horizontal position, so it cannot show those strands\' '
'real positions or a true cgx.'))
except Exception:
pass
return blocks
# --------------------------------------------------------------- rebar/mesh
def _mild_table_rows(values, problems):
"""[{'id','size','count','depth_in','direction','from_ft','to_ft',
'spacing_in'}, ...] read off the `mild_bar` table."""
rows = []
for n in row_ids(_MILD_TABLE, values):
size = _text_cell(values, _MILD_TABLE, n, 'size') or None
count, cerr = _int_cell(values, _MILD_TABLE, n, 'count')
if cerr:
problems.append('rebar row {0}: count {1}'.format(n, cerr))
depth, derr = _num_cell(values, _MILD_TABLE, n, 'depth_in')
if derr:
problems.append('rebar row {0}: depth to CG {1}'.format(n, derr))
direction = _text_cell(values, _MILD_TABLE, n, 'direction') or None
from_ft, ferr = _num_cell(values, _MILD_TABLE, n, 'from_ft')
if ferr:
problems.append('rebar row {0}: from (ft) {1}'.format(n, ferr))
to_ft, terr = _num_cell(values, _MILD_TABLE, n, 'to_ft')
if terr:
problems.append('rebar row {0}: to (ft) {1}'.format(n, terr))
spacing, serr = _num_cell(values, _MILD_TABLE, n, 'spacing_in')
if serr:
problems.append('rebar row {0}: spacing (in) {1}'.format(n, serr))
rows.append({'id': n, 'size': size, 'count': count,
'depth_in': depth, 'direction': direction,
'from_ft': from_ft, 'to_ft': to_ft,
'spacing_in': spacing})
return rows
def _rebar_block(values, problems):
blocks = [{'kind': 'heading', 'text': 'Rebar', 'level': 2}]
blocks.append({
'kind': 'editable_table', 'table': _MILD_TABLE,
'columns': _MILD_COLUMNS, 'add_label': 'Add rebar row',
})
rows = _mild_table_rows(values, problems)
if not rows:
blocks.append(_sentence('Design contains no rebar'))
return blocks
# A row with no depth to CG is not drawn on the cross-section -- an
# honest, expected gap (the drawing never guesses a cover), so it is a
# QUIET sentence here rather than red text inside the canvas or a
# `problems` entry (2026-09-04; `pa_sketch_reinf.section`'s own
# `problems` is reserved for genuine failures now).
for row in rows:
if row['depth_in'] is None:
blocks.append(_sentence(
'Rebar row {0} ({1}): no depth to CG entered -- not drawn '
'on the cross-section.'.format(row['id'],
row['size'] or 'bar')))
return blocks
def _wwf(text):
"""(resolved_or_None, error_or_None) -- guarded
`panelanalysisform.wwf_designation`."""
if pf is None or not hasattr(pf, 'wwf_designation'):
return None, ('mesh designation {0!r}: panelanalysisform cannot '
'resolve it (no wwf_designation)'.format(text))
try:
return pf.wwf_designation(text)
except Exception as exc:
return None, 'mesh designation {0!r}: lookup failed ({1})'.format(
text, exc)
def _fmt4(v):
return ('%.4f' % v).rstrip('0').rstrip('.')
# ------------------------------------------------- plant's own mesh styles
#
# Issue #23 item 9g, second half (2026-09-07): the owner asked "9g 需要的厂知
# 识现在没有嘛?" -- it exists: the sample_plant knowledge layer carries a `mesh:`
# section (its standard mesh sheet M9, dated 2023-07-18) listing the styles
# the plant itself stocks. This page turns those into the designation
# field's dropdown, on top of the free text that was always there.
def _mesh_is_var(spacing):
"""Whether one `<long_sp>` / `<trans_sp>` token means 'variable' -- the
sheet writes 'Var' when the spacing is not a fixed number."""
try:
return spacing.strip().lower() == 'var'
except Exception:
return False
def _mesh_size_parts(size_text):
"""`(long_sp, trans_sp, long_wire, trans_wire, sheet_size)` parsed from
one stocked `size` string, or None when it does not parse.
The sheet's grammar (per the sample_plant layer, mesh sheet M9) is
'<long_sp> x <trans_sp> - <long_wire> / <trans_wire> <sheet width>
(+a,+b) x <sheet length> (c,d)' -- e.g. '4 x 4 - D3 / D3 96"
(+2,+2) x 11\'-10" (1,1)'. The two spaces between the wire pair and the
sheet size are the only thing that separates the parseable head from
the human sheet description; the head is all this page ever parses.
"""
if not hasattr(size_text, 'partition'):
return None
spacing, dash, wire = size_text.partition(' - ')
if not dash:
return None
sp = [tok.strip() for tok in spacing.split('x')]
if len(sp) != 2 or not sp[0] or not sp[1]:
return None
long_wire, slash, rest = wire.partition('/')
long_wire = long_wire.strip()
rest = rest.strip()
if not slash or not long_wire or not rest:
return None
trans_wire, sheet = rest, None
if ' ' in rest:
trans_wire, _, sheet = rest.partition(' ')
trans_wire = trans_wire.strip()
sheet = sheet.strip() or None
if not trans_wire:
return None
return (sp[0], sp[1], long_wire, trans_wire, sheet)
def _mesh_designation_of(size_text):
"""`(designation_or_None, convertible)` for one stocked `size` string.
`designation` rebuilds the parseable short form the resolver accepts,
'<long_sp>x<trans_sp>-<long_wire>x<trans_wire>' (e.g. '4x4-D3xD3',
where the D-number is hundredths of a square inch -- D3 = 0.03 in²,
the public rule in knowledge/catalogue.yaml `catalogue.wwf`, applied
by the existing parser). `convertible` is False when either spacing is
'Var' (variable -- not a number, so no area can be derived) or the
size does not parse at all; the caller keeps such a code in the list,
marked, rather than dropping it."""
parts = _mesh_size_parts(size_text)
if parts is None:
return None, False
long_sp, trans_sp, long_wire, trans_wire, _sheet = parts
designation = '{0}x{1}-{2}x{3}'.format(long_sp, trans_sp, long_wire,
trans_wire)
return designation, not (_mesh_is_var(long_sp) or _mesh_is_var(trans_sp))
def _mesh_sheet_size(size_text):
"""The human sheet description one stocked `size` string carries after
its wire pair (e.g. '96" (+2,+2) x 11\'-10" (1,1)'), or None. Dropdown
label prose only -- never parsed."""
parts = _mesh_size_parts(size_text)
if parts is None:
return None
return parts[4]
def plant_mesh_styles(know):
"""Every WWF style the plant's own `mesh:` knowledge section stocks, as
a list of one dict per designation code:
`{code, designation, size_text, used_in, section, plant_only,
convertible}`. `designation` is the code's short parseable form built
from its stocked `size` string (see `_mesh_designation_of`);
`convertible` is False when a spacing is 'Var' or the size does not
parse -- such styles STAY in the list, marked, never dropped. `used_in`
is the section's stock-use sentence (or None); `plant_only` names the
plant that alone stocks a style in `not_in_standard_but_stocked` (or
None). Pure function of `know`: `know` None, or without a `mesh` dict
section, answers [] -- the page then keeps the plain text box it always
had. Never raises."""
styles = []
if not isinstance(know, dict):
return styles
mesh = know.get('mesh')
if not isinstance(mesh, dict):
return styles
for section in _MESH_KNOW_SECTIONS:
bucket = mesh.get(section)
if not isinstance(bucket, dict):
continue
# Codes sorted so the dropdown is identical on every interpreter
# (IronPython 2.7 dicts do not preserve insertion order).
for code in sorted(bucket):
entry = bucket[code]
if not isinstance(entry, dict):
continue
size_text = entry.get('size')
designation, convertible = _mesh_designation_of(size_text)
styles.append({
'code': code, 'designation': designation,
'size_text': size_text, 'used_in': entry.get('used_in'),
'section': section,
'plant_only': entry.get('plant_only'),
'convertible': convertible,
})
return styles
def _mesh_choices(styles):
"""One dropdown option dict per stocked style, then the trailing custom
option. Each option's VALUE is the plant's designation code -- the plain
string kept in `values['mesh']` -- and its LABEL is human prose:
'<code> -- <designation> (<sheet size>; <used_in>)', plus
' [<plant> only]' when the style is stocked by one plant alone. The last
option, '(custom -- type a designation)' (value ''), is the dropdown
saying it is a shortcut, not a straitjacket: any text that is not a
plant code still parses as a free designation."""
options = []
for style in styles:
head = style['designation'] or style['size_text'] or 'unreadable'
label = '{0} -- {1}'.format(style['code'], head)
bits = []
sheet = _mesh_sheet_size(style['size_text'])
if sheet:
bits.append(sheet)
if style['used_in']:
bits.append(style['used_in'])
if bits:
label += ' ({0})'.format('; '.join(bits))
if style['plant_only']:
label += ' [{0} only]'.format(style['plant_only'])
options.append({'value': style['code'], 'label': label})
options.append({'value': '', 'label': '(custom -- type a designation)'})
return options
def _mesh_plant_text(style):
"""The 'Plant style' kv value for a chosen code: '<code> (<used_in>)',
or just the code when the section carries no use sentence."""
if style['used_in'] is None:
return style['code']
return '{0} ({1})'.format(style['code'], style['used_in'])
def _mesh_style_problem(style):
"""The `problems` sentence for a chosen stocked style that cannot be
converted to an area -- names the code and the reason."""
if style['designation'] is None:
reason = 'its size cannot be read as spacing and wire sizes'
else:
reason = 'its spacing is variable (Var), not a fixed size'
return ('Mesh designation {0}: the plant stocks this style, but {1} -- '
'its spacing and wire area are not shown (never a guessed '
'area).').format(style['code'], reason)
def _mesh_summary_rows(resolved):
"""The three resolved kv rows under the 'Plant style' row: wire spacing,
wire area and As per foot each way -- all real parsed numbers, never a
guess."""
return [
('Wire spacing, in (long x trans)',
'{0} x {1}'.format(_fmt4(resolved['spacing_long_in']),
_fmt4(resolved['spacing_trans_in']))),
('Wire area, in² each (long / trans)',
'{0} ({1}) / {2} ({3})'.format(
_fmt4(resolved['wire_area_long_in2']), resolved['wire_long'],
_fmt4(resolved['wire_area_trans_in2']), resolved['wire_trans'])),
('As per ft, in²/ft (long / trans), one layer',
'{0} / {1}'.format(_fmt4(resolved['as_long_in2_per_ft']),
_fmt4(resolved['as_trans_in2_per_ft']))),
]
def _mesh_block(values, problems, know):
"""Mesh as a STRUCTURED group (owner, 2026-09-07, issue #23 item 9g):
the designation field, the layer count, the depth to CG, then a kv
summary that resolves the designation to wire spacing, wire area and
area per foot each way. A designation that does not parse is named in
`problems` and the summary is withheld -- never a guessed area.
When `know` carries the plant's own `mesh:` section (the sample_plant
layer's stocked WWF styles, standard mesh sheet M9, dated 2023-07-18 --
the answer to the owner's "9g 需要的厂知识现在没有嘛?"), the designation
field becomes a DROPDOWN of those stocked styles while free text
remains: an option's VALUE is the plant's designation code, which stays
in `values['mesh']` as the plain string it has always been, and a
stored code is resolved through its style's short designation before
parsing -- any other text parses exactly as before. A chosen style that
cannot be converted ('Var' spacing, or an unreadable size) is named in
`problems` with the reason and only the 'Plant style' row is shown.
`know` None, or without a `mesh` section, keeps the field exactly the
plain text box it was before -- no choices, no 'Plant style' row."""
blocks = [{'kind': 'heading', 'text': 'Mesh', 'level': 2}]
styles = plant_mesh_styles(know)
by_code = dict((s['code'], s) for s in styles)
fields = []
for key in _MESH_FIELD_KEYS:
fb = _field_block(key, problems)
if fb is not None:
if key == 'mesh' and styles:
fb['editor'] = 'choice'
fb['choices'] = _mesh_choices(styles)
fields.append([fb])
if fields:
blocks.append({'kind': 'columns', 'columns': fields})
text = _text_of(values, 'mesh')
if not text:
blocks.append(_sentence('Design contains no mesh'))
return blocks
style = by_code.get(text)
if style is not None:
# A stocked plant style was chosen: resolve through its short
# designation. A non-convertible one is named and withheld -- the
# spacing/area rows are never guessed from a 'Var' spacing.
rows = [('Plant style', _mesh_plant_text(style))]
if not style['convertible']:
problems.append(_mesh_style_problem(style))
blocks.append({'kind': 'kv', 'rows': rows})
return blocks
resolved, err = _wwf(style['designation'])
if err:
problems.append(err)
return blocks
if resolved is None:
return blocks
rows.extend(_mesh_summary_rows(resolved))
else:
# Free text -- a custom designation. With the plant's style list in
# front of the user, the summary says so; without it (no `mesh` in
# `know`) this is exactly the pre-dropdown behaviour.
resolved, err = _wwf(text)
if err:
problems.append(err)
return blocks
if resolved is None:
return blocks
rows = [('Plant style', 'custom designation')] if styles else []
rows.extend(_mesh_summary_rows(resolved))
blocks.append({'kind': 'kv', 'rows': rows})
if _text_of(values, 'mesh_depth_in') == '':
blocks.append(_sentence(
'Mesh depth to CG not entered -- the cross-section draws the '
'mesh at mid-depth (or mid-wythe) until it is.'))
return blocks
def _interrupt_rows(values, problems):
"""[{'id', 'x_ft', 'cuts'}, ...] read off the `interrupt` table."""
rows = []
for n in row_ids(_INTERRUPT_TABLE, values):
x_ft, xerr = _num_cell(values, _INTERRUPT_TABLE, n, 'x_ft')
if xerr:
problems.append('interrupt row {0}: position {1}'.format(n, xerr))
cuts = _text_cell(values, _INTERRUPT_TABLE, n, 'cuts') or None
rows.append({'id': n, 'x_ft': x_ft, 'cuts': cuts})
return rows
def _interrupts_block(values, problems):
"""The Interrupts sub-block: an editable table, one row per interrupt
(owner, 2026-09-07, issue #23 item 9h) -- a position along the length
where strands / rebar / mesh are cut and development restarts.
With no rows the sentence is 'Design contains no interrupts': the
table exists and is empty, which is a judged answer now, unlike the
could-not-judge placeholder this replaced (before 2026-09-07 the form
had no field at all). What an interrupt DOES to the development
length is the companion engine issue's job; this page only collects
where they are and what they cut, and says so."""
blocks = [{'kind': 'heading', 'text': 'Interrupts', 'level': 2}]
blocks.append({
'kind': 'editable_table', 'table': _INTERRUPT_TABLE,
'columns': _INTERRUPT_COLUMNS, 'add_label': 'Add interrupt',
})
rows = _interrupt_rows(values, problems)
if not rows:
blocks.append(_sentence('Design contains no interrupts'))
return blocks
for row in rows:
if row['x_ft'] is None:
blocks.append(_sentence(
'Interrupt row {0}: no position entered.'.format(row['id'])))
if not row['cuts']:
blocks.append(_sentence(
'Interrupt row {0}: does not say what it cuts -- pick '
'All, Strands, Rebar or Mesh.'.format(row['id'])))
blocks.append(_sentence(
'Interrupts are collected here only; the solve does not yet '
'restart development length at them.'))
return blocks
# ------------------------------------------------------------------ drawing
_ASPECT_MIN = 0.12
_ASPECT_MAX = 0.4
_ASPECT_DEFAULT = 0.2
# A TYPICAL "MORE aspect" figure -- the LOCAL FALLBACK's own extra, used
# only on the rare path where `pa_sketch_reinf.aspect_for` cannot be
# reached at all (module missing, or it raised). The real, per-`values`
# number is `psr.aspect_for(values)` itself -- see `_section_aspect` --
# which folds in THIS schedule's own legend size rather than guessing a
# flat constant (2026-09-04); this fallback constant matches that module's
# own `ASPECT_EXTRA` (a guard test keeps the two from drifting apart).
_ASPECT_EXTRA_DEFAULT = 0.22
# Outline plan aspect clamps -- the reinforcement plan is a plan (length x
# width), the same family the Concrete Extents outline uses; the extra band
# is reserved for the plan's own dimension-chain labels below the rect.
_OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, _OUTLINE_ASPECT_DEFAULT = \
0.12, 0.5, 0.3
_OUTLINE_ASPECT_EXTRA = 0.10
def _ratio_clamp(numerator, denominator, lo, hi, default):
"""`numerator/denominator` clamped to `[lo, hi]`, or `default` when
either is missing / non-positive (nothing to divide against)."""
if not numerator or numerator <= 0 or not denominator or denominator <= 0:
return default
ratio = numerator / float(denominator)
if ratio < lo:
return lo
if ratio > hi:
return hi
return ratio
def _outline_aspect(values):
"""The FULL aspect the reinforcement PLAN block asks for: the panel's
plan proportion (width / length), clamped, PLUS the fixed label-band
extra (issue #23 item 9i). The plan has no legend like the section's, so
no `aspect_for`-style per-`values` band is needed here -- a flat extra
is the honest ceiling, matching how the Concrete Extents outline folds
its own label rows in. Never raises."""
width, _ = _num_of(values, 'width_ft')
length, _ = _num_of(values, 'length_ft')
return _ratio_clamp(
width, length, _OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX,
_OUTLINE_ASPECT_DEFAULT) + _OUTLINE_ASPECT_EXTRA
def _section_aspect(values):
"""The FULL aspect the drawing block asks for -- delegates to
`pa_sketch_reinf.aspect_for(values)`, which folds in the section's own
thickness/width ratio AND this particular `values`' own fixed
title/bottom/legend bands (a busy schedule gets a correspondingly
taller canvas instead of a flat guess -- see that module's own
docstring's LAYOUT section, amendment 3). Falls back to `clamp_aspect()
+ ASPECT_EXTRA` (a flat, legend-blind estimate) when `aspect_for` is
not reachable, and to an identical local formula when `pa_sketch_reinf`
cannot be imported at all -- never raises."""
if psr is not None and hasattr(psr, 'aspect_for'):
try:
return psr.aspect_for(values)
except Exception:
pass
extra = _ASPECT_EXTRA_DEFAULT
if psr is not None:
extra = getattr(psr, 'ASPECT_EXTRA', _ASPECT_EXTRA_DEFAULT)
if psr is not None and hasattr(psr, 'clamp_aspect'):
try:
return psr.clamp_aspect(values.get('width_ft'),
values.get('thickness_in')) + extra
except Exception:
pass
width, _ = _num_of(values, 'width_ft')
thickness, _ = _num_of(values, 'thickness_in')
if not width or width <= 0 or not thickness or thickness <= 0:
return _ASPECT_DEFAULT + extra
aspect = (thickness / 12.0) / width
if aspect < _ASPECT_MIN:
aspect = _ASPECT_MIN
if aspect > _ASPECT_MAX:
aspect = _ASPECT_MAX
return aspect + extra
def _drawing_block(values):
"""TWO stacked `drawing` blocks (issue #23 item 9i, owner ruling): the
reinforcement page now carries a PAIR of drawings like Concrete Extents'
own outline+section pair -- the plan view (`source: 'outline'`) first,
full width, then the existing cross-section (`source: 'section'`)
BELOW it, also full width, exactly the stacked shape
`pa_extents._extents_drawing_blocks()` uses (never side by side). The
plan gives every reinforcement type its own group with its own
continuous dimension chain from its own reference (strands from the
rail, bars, mesh -- see `pa_sketch_reinf.outline_geometry`); the
section stays the cross-section it always was.
Both name `module: 'pa_sketch_reinf'` (the sibling module that owns the
actual geometry functions `outline_geometry` / `section_geometry`) --
this function only builds the two block dicts the renderer reads to size
each canvas and decide what to redraw on it, never draws anything
itself. Both `depends_on` watch every input either drawing reads
(strand/mild row tables, the strand rail datum, mesh, and the panel's
real length/width/thickness), so an edit anywhere re-triggers both.
"""
return [
{
'kind': 'drawing', 'module': 'pa_sketch_reinf', 'source': 'outline',
'full_width': True, 'aspect': _outline_aspect(values),
'caption': 'Reinforcement plan -- strands from the rail, bars, '
'mesh',
'depends_on': ['strand_row', 'mild_bar',
'strand_rail_location', 'strand_rail_offset_in',
'mesh', 'mesh_layers', 'mesh_depth_in',
'thickness_in', 'width_ft', 'length_ft'],
},
{
'kind': 'drawing', 'module': 'pa_sketch_reinf', 'source': 'section',
'full_width': True, 'aspect': _section_aspect(values),
'caption': 'Reinforcement cross-section',
'depends_on': ['strand_row', 'mild_bar',
'strand_rail_location', 'strand_rail_offset_in',
'mesh', 'mesh_layers', 'mesh_depth_in',
'thickness_in', 'width_ft', 'length_ft',
'section_type'],
},
]
# ------------------------------------------------------------------- public
def page(values, reads=None, know=None):
"""(blocks, problems) for the Reinforcement section of the input form.
`values` is the form's current string-valued dict, plus the `strand_row`
/ `mild_bar` table cells under `<table>.<n>.<col>` (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. `know` is the
optional merged knowledge tree (`vibe.knowledge.load()`'s own shape),
used only by the mesh group: its `mesh:` section (the plant's OWN
stocked WWF styles -- sample_plant layer, standard sheet M9, dated
2023-07-18) turns the designation field into a dropdown of those styles
while free text remains (`_mesh_block`); `None`, or a tree without that
section, leaves the field the plain text box it always was.
`vibe.pa_pagespec._load_page` passes `know` through only when it detects
this function declares the parameter, so a two-argument caller keeps
working unchanged. 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, know))
blocks.append({'kind': 'rule'})
blocks.extend(_interrupts_block(values, problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.extend(_drawing_block(values))
return blocks, problems