# -*- 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 `..` with `
..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 `
..` 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 `
..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 `` / `` 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 ' x - / (+a,+b) x (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, 'x-x' (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: ' -- (; )', plus ' [ 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: ' ()', 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 `
..` (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