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.
1437 lines
62 KiB
Python
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
|