# -*- coding: utf-8 -*- """The Panel Analysis Reinforcement tab's own cross-section drawing -- pure geometry, both Pythons, no Revit, no WPF, zero project imports. WHY THIS IS A NEW MODULE AND NOT `panelsketch.section()`: the owner looked at that drawing and ruled it wrong, 2026-09-04 -- "reinforcing 的 reinforcement cross-section 也没有正确的绘制出来" ("the reinforcement cross-section still isn't drawn correctly"). `panelsketch.section()` reads the OLD `strand_rows` shape (`{'count','depth_in','pull_pct'}`, sourced by parsing the free-text `ps_rows` field), draws every row centred on the panel's horizontal midline because that shape carries no x position at all, and knows nothing of mild bars or mesh. `pa_reinforcement.py`'s sibling module replaced that free text with structured `strand_row` / `mild_bar` TABLES (owner, same ruling: "rebar 怎么可能是打字上去? ... 你这样计算模型怎么接收这个数据?" -- reinforcement has to be rows a calculation can actually read), so this module reads THAT shape instead of the old one -- and reading a different data shape from a DIFFERENT source is reason enough for a new function; bolting a second reading convention onto `panelsketch.section()` would leave that function answering to two different callers with two different ideas of what `values` means. `panelsketch.py` itself is not touched by this change; it is free to keep serving whatever still calls `section()`. THE INPUT is `values`, the SAME dict `panelanalysisform.py` reads from the form. Fields used here, verbatim: width_ft, thickness_in REQUIRED -- the section rectangle. Missing or <= 0 is the hard stop: `([], problems)`, no rectangle, nothing letterboxed, nothing guessed (same rule `panelsketch.py` documents for its own `length_ft`/`width_ft`). section_type, wythe_top_in, insulation_in, wythe_bottom_in a Sandwich section (`section_type` == 'Sandwich', case-insensitive) draws its three layers to scale instead of one outline, the same rule and the same 1/8" tolerance `panelsketch.section()` already uses. strand_rail_location labels the rail datum side ('left'/'right' in the text -> that edge; anything else, or blank, -> centred / not labelled). strand_row..count / .depth_in / .dia_in / .pull_lb one dot GROUP per row, at true depth from the bottom (the rail, in casting terms). A row missing `depth_in` has no honest y to draw at and is reported, not drawn -- it never falls back to a guessed cover. The ONLY per-row label drawn AT the section (the right margin) -- see LAYOUT below. strand_x_in, strand_columns_in OPTIONAL flat list of per-strand x positions (inches from the section's left edge), read defensively (checked in that order, the first that is a list of the right LENGTH -- one entry per strand across every row, in row order -- wins). When present, strands are drawn at their real x and the CG's cgx is a real number; when absent, every row's dots are spread evenly across the width instead, and cgx is reported as not shown -- exactly the same "an assumption is not a fact" discipline `panelsketch.py` documents throughout. mild_bar..size / .count / .depth_in / .direction / .from_ft / .to_ft / .spacing_in A row with a blank `depth_in` is simply not drawn (`pa_reinforcement.page()` is where that becomes a quiet sentence). Otherwise: `direction` == 'Transverse' draws ONE thin horizontal line across the full section width at the row's depth -- a bar running across the panel's width is seen ALONG ITS LENGTH in this cut, so a single line is the honest picture regardless of how many bars the row's `count` names. Anything else (including blank -- 'Longitudinal' is the form's own default) draws small dots END-ON instead, spread evenly across the width or, when `spacing_in` is a real pitch, at that pitch. NEVER labelled at the section itself -- see LAYOUT. mesh, mesh_layers a non-blank `mesh` draws one dashed line at mid-depth, or -- for a Sandwich section whose layers resolved AND `mesh_layers` reads >= 2 (or is blank, the "probably one per wythe" default for a sandwich) -- one dashed line per wythe, at that wythe's own mid-depth. NEVER labelled on the line -- see LAYOUT. LAYOUT (2026-09-04 rewrite, then twice more amended the same day off two further live-render findings -- see each amendment's own note below). The canvas stacks three bands, top to bottom: title row (20 px) -- 'REINFORCEMENT CROSS-SECTION', centred, plus '(vertical scale ×n)' when the section's depth axis is stretched beyond its true proportion -- see AMENDMENT 2. the section itself -- scaled to fill whatever height is left after the title row, the bottom band and the legend, and the full width minus a FIXED left margin (`_LEFT_MARGIN`, 70 px) and a wider FIXED right margin (`_RIGHT_MARGIN`, 130 px) -- neither grows with row count. The width label sits directly under the rect's own left corner; the rail label sits below that. Only strand rows get a per-row label here: a short depth call-out in the left margin, a short qty/dia/depth label in the right margin, one line per row, at that row's own y -- never stacked in lanes. the LEGEND row(s) -- every drawn GROUP (each strand row, each mild bar row that is drawn, mesh, the strand-group CG) gets exactly one line here, symbol first (● strand, — transverse bar, · longitudinal bar, mesh, + CG), laid out LEFT TO RIGHT IN COLUMNS (capped at `_LEGEND_MAX_COLS` -- see AMENDMENT 3) and wrapping to a new legend row only when a row's columns fill up. This is where every group's full description lives -- nothing is ever labelled a second time inside the section rectangle itself. `pa_reinforcement.py`'s drawing block asks `aspect_for(values)` for the FULL aspect it should request -- `clamp_aspect()`'s true section proportion plus the fixed title/bottom bands plus THIS `values`' own legend band (see AMENDMENT 3), so the canvas the renderer builds always has room for all three bands without `pa_reinforcement.py` having to know anything about legend rows, margins or vertical scale itself. ORIGINAL FINDING (2026-09-04): a canvas clamped to `aspect=0.12` for a thin, wide panel left ~140 px total, five bar-row labels stacked on top of each other, strand labels and the CG marker clipped at the right edge, the width label cut at the bottom -- fixed by the three-band layout above. AMENDMENT 1 (`strand_x_in` is INCHES, not feet): a live render with `width_ft=8.375` and `strand_x_in` values up to 94.5 piled every strand dot at the right edge -- `explicit_x[i] / width_ft` divided an inches value by a feet one, so every fraction clamped to 1.0. Fixed by converting once, in `width_in = width_ft * 12.0`, and dividing by THAT everywhere a `strand_x_in` value is turned into a fraction of the width (dot x, CG cgx) -- see `_explicit_strand_x`'s own docstring, which also now DROPS (never clamps) a candidate list carrying any value more than 1 in beyond `width_in`, falling back to spread-evenly exactly as an absent list would. AMENDMENT 2 (thin sections were a mostly-empty sliver): a very thin, wide panel's TRUE-to-scale `draw_h` can be a fraction of a generously tall band, drawn small and centred with the band mostly empty above and below it. `section()` now amplifies the vertical scale alone (never `draw_w`, never the true thickness/width the depth axis maps against) up to `_MAX_VSCALE`, enough that the rect fills at least `_MIN_BAND_FRACTION` of its band -- and ANNOUNCES the distortion in the title row rather than letting a stretched drawing pass as to-scale. AMENDMENT 3 (a busy legend was clipped, so was the last legend row): a schedule with many groups was clipped on two sides at once -- the right-margin strand labels ran past a margin sized for a shorter label, and a flat `ASPECT_EXTRA` guessed too little canvas height for a legend with more rows than it assumed. Both are fixed together: `_RIGHT_MARGIN` widened to fit a full "qty × dia @ depth" label, legend columns capped at `_LEGEND_MAX_COLS` so a wide canvas cannot shrink each column below what an entry needs, and `aspect_for(values)` -- exported from THIS module, `pa_reinforcement.py`'s own `_section_aspect` now calls it -- computes the legend's real row count for THIS `values` and folds its real pixel height into the requested aspect, so a busy schedule asks for a taller canvas instead of guessing a flat constant. `section(values, width_px, height_px)` returns `(primitives, problems)` and NEVER RAISES -- every problem becomes a plain sentence, and every field this module cannot honestly place is left off the drawing rather than guessed. `primitives` reuses the SAME vocabulary `panelsketch.py` and the button's `_draw_primitive` already speak (plain dicts, pixel coordinates, origin top-left, y increasing downward, everything clamped inside `[0, width_px] x [0, height_px]`): `rect`, `line`, `text`, `dot`, `marker` (glyph 'cross' / 'diamond'). No new primitive `kind` is introduced -- an unrecognised one would just be silently skipped by the renderer (see `_draw_primitive`'s own docstring), which would make a drawing element quietly vanish instead of degrading loudly, so this module draws the CG as a `dot` (the circle) plus a `marker` (the cross) at the same point rather than inventing a "crossed-circle" kind. New `role` strings ARE introduced (`bar`, `mesh`, `rail-label`, `legend`, `dimension`) -- an unrecognised role just falls back to the renderer's default grey, which is a cosmetic gap, not a functional one, and is the renderer's (a sibling's) to close by extending its own role/brush tables, not this module's to work around. ZERO TOP-LEVEL PROJECT IMPORTS, on purpose, mirroring `panelsketch.py`'s own stated reason for not importing `panelanalysisform`: a sibling agent is restructuring that module at the same time this file was written, and a leaf module cannot be broken by a change three layers away. So the small pieces this module needs from elsewhere are duplicated here, deliberately: * `row_ids` / `row_key` -- the same `..` / `
.. present` key arithmetic `pa_reinforcement.py` also implements (see that module's docstring for why the shape exists and where it is headed). * `_STRAND_AREA_IN2` -- the same PUBLISHED nominal strand areas `panelanalysisform._STRAND_AREA_IN2` carries (0.153 in² for a 1/2" strand and so on): these are documented industry constants, not a guess, so copying them here is not the "invented capacity" that module's own docstring warns against -- inventing a NUMBER NOT IN THAT TABLE would be. A diameter not in this table draws with no Aps contribution and a named problem, same as `pa_reinforcement.py`'s own summary does. The ONE deliberate exception is the location-chain rule (issue #23 item 9a, 2026-09-07): `section_geometry` / `used_fallback_strand_x` reach `pa_reinforcement.strand_positions` / `parse_length_in` for a row's `chain` cell -- the page's own summary and the drawing must resolve a chain IDENTICALLY or the numbers would drift. That import is FUNCTION-LOCAL and guarded, never at module scope, precisely because `pa_reinforcement` imports THIS module at the top (for `aspect_for` / `used_fallback_strand_x`): a top-level import here would be circular, and a guarded on-demand import degrades a missing/broken sibling to "chain unresolved" in `problems` instead of a crash. `section()` (the pixel drawing) does not consume chains and so never enters the cycle. Must run on IronPython 2.7 in the button (no f-strings, no typing, no dataclasses, no `pathlib`) AND on plain CPython under `WillTools.extension/ tests/conftest.py`'s import trick. """ # --------------------------------------------------------------- constants _ASPECT_MIN = 0.12 _ASPECT_MAX = 0.4 _ASPECT_DEFAULT = 0.2 # A TYPICAL "MORE aspect" figure (canvas height / width) on TOP of # `clamp_aspect()`'s own [0.12, 0.4] -- kept only as the LOCAL FALLBACK # `pa_reinforcement._section_aspect` uses on the rare path where # `aspect_for` itself is not reachable (see that function's own docstring, # and the module docstring's LAYOUT section). The REAL number a live # `values` needs is `aspect_for(values)`, which folds in that particular # schedule's own legend size instead of guessing a flat constant. ASPECT_EXTRA = 0.22 # Fixed-pixel layout bands -- see the module docstring's LAYOUT section. # None of these grow with STRAND row count any more (2026-09-04): a # schedule with many rows gets more LEGEND ROWS, never a wider margin or a # taller title. The right margin is wider than the left (2026-09-04, # live-render finding: a "qty × dia @ depth" strand label needs more room # than the left margin's short depth call-out, and 60 px was clipping it). _TITLE_H = 20.0 _LEFT_MARGIN = 70.0 _RIGHT_MARGIN = 130.0 _BOTTOM_H = 32.0 # width label + rail label, under the rect _LEGEND_ROW_H = 14.0 _LEGEND_COL_W = 200.0 _LEGEND_PAD = 6.0 # Legend columns are CAPPED at 3 (2026-09-04) -- a wide canvas dividing # into many narrow columns was clipping the longer entries ("12 × #4 # transverse @ 1.50 in, 36 in o.c."); 3 columns keeps each one wide enough # to fit, and `aspect_for` assumes this same cap when it estimates the # legend's row count ahead of knowing the real canvas width. _LEGEND_MAX_COLS = 3 # The reference width `aspect_for` assumes when converting the legend's # PIXEL height into a fraction of width (an aspect ratio has no pixel # width of its own to divide by until the renderer picks one) -- matches # the width this module's own tests, and typical real renders, use. _ASPECT_REF_WIDTH_PX = 700.0 # A THIN section (the drawn rect using less than this fraction of its # vertical band under a TRUE-to-scale letterbox) gets its vertical scale # amplified beyond the horizontal one, up to `_MAX_VSCALE`, so a thin wide # panel is not a ~50 px sliver in a mostly-empty canvas (2026-09-04 # live-render finding). This is a DELIBERATE, ANNOUNCED distortion -- see # `section()`'s own title-row text -- never a change to the true thickness/ # width the depth axis maps against. _MIN_BAND_FRACTION = 0.6 _MAX_VSCALE = 4.0 _STRAND_TABLE = 'strand_row' _MILD_TABLE = 'mild_bar' # Published nominal strand areas, in² -- see the module docstring for why # duplicating THESE PARTICULAR constants (not inventing new ones) is honest. _STRAND_AREA_IN2 = { '0.375': 0.085, '3/8': 0.085, '0.4375': 0.115, '7/16': 0.115, '0.5': 0.153, '1/2': 0.153, '0.52': 0.167, '1/2 SP': 0.167, # the 1/2" special (issue #23, 9d) '0.5625': 0.192, '9/16': 0.192, '0.6': 0.217, '3/5': 0.217, '0.7': 0.294, } # ------------------------------------------------------------------ parsing def _num(value): """A float, or None. Accepts a number already, or text; blank -> None.""" if value is None: return None if isinstance(value, bool): # bool is an int subclass; not a number here return None if isinstance(value, (int, float)): return float(value) text = str(value).strip() if not text: return None try: return float(text) except (TypeError, ValueError): return None def _text(value): if value is None: return None if not hasattr(value, 'strip'): try: value = str(value) except Exception: return None value = value.strip() return value or None # --------------------------------------------------------- row-table helpers # Identical arithmetic to `pa_reinforcement.row_ids` / `row_key` -- see the # module docstring for why this is duplicated rather than imported. def row_key(table, n, col): return '{0}.{1}.{2}'.format(table, n, col) def row_ids(table, values): """Every present row id for `table`, ascending, as ints -- the same shape `panelanalysisform.row_ids` returns (this module does not import that function -- see the module docstring -- but matches its contract so a value read here never needs a type coercion a caller of THAT function would not also need).""" 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 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 _cell(values, table, n, col): return (values or {}).get(row_key(table, n, col)) def _strand_rows(values): """([{'id','count','depth_in','dia_in','pull_lb','chain'}, ...], problems). Only rows with a readable `depth_in` are returned -- there is no honest y to draw the rest at. A row with no readable `count` is kept (drawn as a single dot, count 1) and reported, matching `panelsketch.py`'s own `_strand_rows_of` rule. `chain` is the raw `Location chain from rail` cell ('' when blank); resolving it is `section_geometry`'s job. """ rows = [] problems = [] for n in row_ids(_STRAND_TABLE, values): depth = _num(_cell(values, _STRAND_TABLE, n, 'depth_in')) if depth is None: problems.append( 'Strand row {0} has no depth to CG entered; not drawn.' .format(n)) continue count = _num(_cell(values, _STRAND_TABLE, n, 'count')) count = int(count) if count is not None else None dia = _text(_cell(values, _STRAND_TABLE, n, 'dia_in')) pull = _num(_cell(values, _STRAND_TABLE, n, 'pull_lb')) chain = _text(_cell(values, _STRAND_TABLE, n, 'chain')) rows.append({'id': n, 'count': count, 'depth_in': depth, 'dia_in': dia, 'pull_lb': pull, 'chain': chain}) return rows, problems def _mild_rows(values): """[{'id','size','count','depth_in','direction','spacing_in', 'from_ft','to_ft'}, ...] -- every row, including one with a blank depth (the caller simply does not draw that one; `pa_reinforcement.page()` is where its absence becomes a quiet sentence). `from_ft`/`to_ft` are the row's own length-axis position basis (the `mild_bar` table's From/To cells) -- read so both a plan (`outline_geometry`) and the section can give a bar row its own independent dimension chain (issue #23 item 9i).""" rows = [] for n in row_ids(_MILD_TABLE, values): depth = _num(_cell(values, _MILD_TABLE, n, 'depth_in')) count = _num(_cell(values, _MILD_TABLE, n, 'count')) count = int(count) if count is not None else None size = _text(_cell(values, _MILD_TABLE, n, 'size')) direction = _text(_cell(values, _MILD_TABLE, n, 'direction')) spacing = _num(_cell(values, _MILD_TABLE, n, 'spacing_in')) from_ft = _num(_cell(values, _MILD_TABLE, n, 'from_ft')) to_ft = _num(_cell(values, _MILD_TABLE, n, 'to_ft')) rows.append({'id': n, 'size': size, 'count': count, 'depth_in': depth, 'direction': direction, 'spacing_in': spacing, 'from_ft': from_ft, 'to_ft': to_ft}) return rows def _is_transverse(direction): """True for 'Transverse' (case-insensitive, prefix match so 'trans' typed by hand still counts); everything else -- including blank -- is Longitudinal, the form's own default (`MILD_BAR_DIRECTION_CHOICES[0]` in `panelanalysisform.py`).""" if not direction: return False return str(direction).strip().lower().startswith('trans') def _explicit_strand_x(values, total_count, width_in): """The flat per-strand x list (INCHES from the rail/left edge), if `values` carries one of the right length AND every value is honestly inside the panel -- `strand_x_in` checked first, then `strand_columns_in`, read defensively (a non-list, or a list of the wrong length, is simply not used). `width_in` is the panel width IN INCHES (`width_ft * 12`) -- a live render (2026-09-04) piled every strand dot at the right edge because the caller was dividing an inches list by `width_ft`, a number roughly 12x too small, so every fraction clamped to 1.0. Passing the already- converted inches value here (rather than converting inside this function from a `width_ft` it would have to trust unchecked) keeps the unit conversion in exactly ONE place -- `section()`'s own `width_in`. A candidate list with any value more than 1 in beyond `width_in` is not a clamping matter -- it is DROPPED entirely (the whole list refused, not silently clamped to the edge) and the caller falls back to spread evenly, same as an absent/wrong-length list; `width_in` missing or <= 0 skips this particular check (nothing to validate against) rather than refusing everything. """ if total_count <= 0: return None for key in ('strand_x_in', 'strand_columns_in'): raw = values.get(key) if not isinstance(raw, (list, tuple)): continue xs = [] for v in raw: n = _num(v) if n is not None: xs.append(n) if len(xs) != total_count: continue if width_in and width_in > 0 and any(x > width_in + 1.0 for x in xs): continue return xs return None def used_fallback_strand_x(values): """True when `section()`/`section_geometry()` spread some strand dots evenly across the width for want of a real per-strand x -- the exact same check those functions make internally, exposed so `pa_reinforcement.page()` can ask the identical question (to build its own quiet sentence) without re-deriving the length-match / in-range / chain-resolution rule. False when there are no strand rows at all -- nothing to spread -- matching `section()`'s own `if rows and explicit_x is None` gate. A real per-strand x is either the legacy flat `strand_x_in` / `strand_columns_in` list (`_explicit_strand_x`) OR a `chain` cell on every row that resolves via `pa_reinforcement.strand_positions` (issue #23 item 9a, 2026-09-07 -- the chain is now a real position source, so an all-chained schedule is not "spread evenly"). A MIXED schedule -- some rows chained, some blank -- still spreads the blank rows, so it reports True (the geometry's own `problems` name exactly which rows are blank). `pa_reinforcement` is imported on demand (see `section_geometry`); an import failure leaves the legacy-`strand_x_in`- only answer, the safe "it is spread" direction. """ rows, _problems = _strand_rows(values) if not rows: return False total = sum((r['count'] if r['count'] and r['count'] > 0 else 1) for r in rows) width_ft = _num(values.get('width_ft')) width_in = width_ft * 12.0 if width_ft and width_ft > 0 else None if _explicit_strand_x(values, total, width_in) is not None: return False try: from vibe import pa_reinforcement as _par except Exception: return True rail_loc = values.get('strand_rail_location') rail_off = values.get('strand_rail_offset_in') for r in rows: if not (r.get('chain') or '').strip(): return True # a blank row is spread if r.get('count') is None: return True positions, err = _par.strand_positions( r['count'], r['chain'], rail_loc, rail_off, width_in, row_label='strand row {0}'.format(r['id'])) if err or positions is None: return True return False def _strand_area(dia_text): if not dia_text: return None return _STRAND_AREA_IN2.get(str(dia_text).strip()) # ------------------------------------------------------------------ layout def _clamp(v, lo, hi): if v < lo: return lo if v > hi: return hi return v def _letterbox(a, b, avail_w, avail_h): """draw_w, draw_h -- `a` x `b` scaled uniformly to fit inside `avail_w` x `avail_h`, preserving the aspect ratio (same helper `panelsketch.py` keeps under this exact name).""" aspect = a / b box_aspect = avail_w / avail_h if aspect >= box_aspect: draw_w = avail_w draw_h = draw_w / aspect else: draw_h = avail_h draw_w = draw_h * aspect return draw_w, draw_h def _clamp_primitive(prim, width_px, height_px): kind = prim.get('kind') if kind == 'rect': x = _clamp(prim['x'], 0.0, width_px) y = _clamp(prim['y'], 0.0, height_px) w = prim['w'] h = prim['h'] if x + w > width_px: w = width_px - x if y + h > height_px: h = height_px - y if w < 0.0: w = 0.0 if h < 0.0: h = 0.0 prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h elif kind == 'line': prim['x1'] = _clamp(prim.get('x1', 0.0), 0.0, width_px) prim['y1'] = _clamp(prim.get('y1', 0.0), 0.0, height_px) prim['x2'] = _clamp(prim.get('x2', 0.0), 0.0, width_px) prim['y2'] = _clamp(prim.get('y2', 0.0), 0.0, height_px) else: if 'x' in prim: prim['x'] = _clamp(prim['x'], 0.0, width_px) if 'y' in prim: prim['y'] = _clamp(prim['y'], 0.0, height_px) return prim def clamp_aspect(width_ft, thickness_in): """thickness/width as a canvas aspect ratio, clamped to [0.12, 0.4]. This is ONLY the section rectangle's own true-to-scale proportion -- `aspect_for(values)` is what a caller actually wants (it adds the title/bottom/legend bands' own real height on top of this, for THIS `values`' own legend size -- see that function and the module docstring's LAYOUT section). This function's return value is never changed by that addition, so "how tall is the rect, to scale" and "how much fixed chrome does the canvas need" stay two separate questions. Missing/non-positive input is not this function's business to judge -- it returns the same default `section()` would draw at, never raises, never divides by zero. """ w = _num(width_ft) t = _num(thickness_in) if not w or w <= 0 or not t or t <= 0: return _ASPECT_DEFAULT a = (t / 12.0) / w if a < _ASPECT_MIN: return _ASPECT_MIN if a > _ASPECT_MAX: return _ASPECT_MAX return a def _legend_entry_count(values): """How many legend lines `section(values, ...)` would draw -- one per strand row, one per mild-bar row that HAS a depth (an undrawn row gets no legend line either), one for mesh if non-blank, one for the strand-group CG if any strand row names a usable quantity. Computed off the SAME reads `section()` itself uses (`_strand_rows`, `_mild_rows`, the `mesh` field), so this can never drift from what `section()` actually draws -- it does not rebuild the legend TEXT (which needs strand-area lookups `aspect_for` has no business doing), only counts entries.""" rows, _problems = _strand_rows(values) mild_rows = _mild_rows(values) n = len(rows) n += sum(1 for r in mild_rows if r['depth_in'] is not None) if _text(values.get('mesh')): n += 1 if rows: total = sum((r['count'] if r['count'] and r['count'] > 0 else 1) for r in rows) if total > 0: n += 1 return n def aspect_for(values): """The FULL aspect (canvas height / width) `pa_reinforcement.py`'s drawing block should ask for: `clamp_aspect()`'s true section proportion PLUS the fixed title/bottom bands PLUS THIS `values`' own legend band, sized off how many legend lines its strand/bar/mesh/CG groups actually need (`_legend_entry_count`, capped at `_LEGEND_MAX_COLS` columns per row -- the same cap `section()` itself applies). A schedule with many rows gets a correspondingly taller canvas instead of a fixed guess (2026-09-04 -- the previous flat `ASPECT_EXTRA` constant clipped the last legend row on a busy schedule). The legend's PIXEL height is converted to a fraction of width against `_ASPECT_REF_WIDTH_PX` -- an aspect ratio has no real canvas width to divide by until the renderer picks one, so this is necessarily an ESTIMATE against a typical width; `section()` itself always sizes the legend correctly against whatever REAL `width_px` it receives, regardless of what this function assumed. Never raises. """ base = clamp_aspect(values.get('width_ft'), values.get('thickness_in')) n_entries = _legend_entry_count(values) legend_rows_n = 0 if n_entries: legend_rows_n = (n_entries + _LEGEND_MAX_COLS - 1) \ // _LEGEND_MAX_COLS legend_h = (legend_rows_n * _LEGEND_ROW_H + _LEGEND_PAD) if n_entries \ else 0.0 extra = (_TITLE_H + _BOTTOM_H + legend_h) / _ASPECT_REF_WIDTH_PX return base + extra # ------------------------------------------------------------------ legend def _strand_legend_entry(row, default_dia_text): dia_text = row['dia_in'] or default_dia_text or '?' qty_text = str(row['count']) if row['count'] is not None else '1' text = u'{0} × {1}″ strand @ {2:.2f} in'.format( qty_text, dia_text, row['depth_in']) return (u'●', text) # ● def _bar_legend_entry(row): label = row['size'] or 'bar' qty_text = str(row['count']) if row['count'] is not None else '1' transverse = _is_transverse(row['direction']) dir_word = 'transverse' if transverse else 'longitudinal' text = u'{0} × {1} {2} @ {3:.2f} in'.format( qty_text, label, dir_word, row['depth_in']) if row['spacing_in']: text += u', {0:.2f} in o.c.'.format(row['spacing_in']) symbol = u'—' if transverse else u'·' # — or · return (symbol, text) def _mesh_legend_entry(mesh_text): return (u'- -', u'Mesh: {0}'.format(mesh_text)) def _cg_legend_entry(text): return (u'+', text) # --------------------------------------------------------------------- API def section(values, width_px, height_px): """(primitives, problems) -- the Reinforcement tab's cross-section, laid out as title row / section (full width minus fixed side margins, scaled to the remaining height) / legend row(s) -- see the module docstring's LAYOUT section for the rationale (2026-09-04 rewrite, fixing a live render where a thin wide panel's clamped-aspect canvas left every label stacked or clipped). `problems` is reserved for GENUINE failures -- the section rectangle could not be drawn at all, a Sandwich panel's layers do not add up, a row's depth falls outside the panel thickness. A degraded-but-honest choice this function makes on its own (dots spread evenly for want of `strand_x_in`, a depth-less mild bar row left undrawn) is NOT a `problems` entry (2026-09-04); the sibling `pa_reinforcement.page()` is where that fact becomes the page's own quiet sentence, computed independently off the same `values` (this module still takes zero project imports -- see the module docstring). `width_ft` / `thickness_in` missing or <= 0 is the only FATAL case: `([], problems)`, nothing drawn. Every other missing input narrows what is drawn without blocking the rest -- see the module docstring's field list for exactly what each one gates. Never raises, never mutates `values`. """ values = values or {} problems = [] width_ft = _num(values.get('width_ft')) thickness_in = _num(values.get('thickness_in')) if width_ft is None or width_ft <= 0: problems.append( 'Panel width (ft) is missing or not a positive number; the ' 'reinforcement cross-section is waiting on it.') if thickness_in is None or thickness_in <= 0: problems.append( 'Panel thickness (in) is missing or not a positive number; ' 'the reinforcement cross-section is waiting on it.') if problems: return [], problems thickness_ft = thickness_in / 12.0 width_in = width_ft * 12.0 # `strand_x_in` is inches, not feet -- # see `_explicit_strand_x`'s own # docstring for the bug this avoids. width_px = float(width_px) height_px = float(height_px) rows, row_problems = _strand_rows(values) problems.extend(row_problems) mild_rows = _mild_rows(values) mesh_text = _text(values.get('mesh')) default_dia_text = _text(values.get('ps_dia_in')) total_strand_count = sum((r['count'] if r['count'] and r['count'] > 0 else 1) for r in rows) explicit_x = _explicit_strand_x(values, total_strand_count, width_in) \ if rows else None # Dots spread evenly for want of a real per-strand x is an honest, # expected degradation, not a `problems` entry (2026-09-04) -- # `used_fallback_strand_x(values)` is how `pa_reinforcement.page()` asks # the same question for its own quiet sentence. # ---- section_type / sandwich resolution (needed for the CG/Aps loop # below and the rect itself; computed before layout since it changes no # geometry, only which layers draw). section_type = str(values.get('section_type') or '').strip().lower() is_sandwich = section_type == 'sandwich' top_in = ins_in = bot_in = None sandwich_ok = False if is_sandwich: top_in = _num(values.get('wythe_top_in')) ins_in = _num(values.get('insulation_in')) bot_in = _num(values.get('wythe_bottom_in')) layers = (top_in, ins_in, bot_in) if any(v is None or v <= 0 for v in layers): problems.append( 'Sandwich section: the wythe / insulation thicknesses are ' 'not all entered; drawn as one outline.') elif abs(sum(layers) - thickness_in) > 0.125: problems.append( 'Sandwich section: {0:.2f} + {1:.2f} + {2:.2f} in do not ' 'add up to the {3:.2f} in thickness; drawn as one outline.' .format(top_in, ins_in, bot_in, thickness_in)) else: sandwich_ok = True # ---- legend entries: one per drawn group, symbol first -- see the # module docstring's LAYOUT section. Built BEFORE pixel layout so the # legend band's own height (which depends only on entry COUNT) can be # carved out of `height_px` before the section rect is sized. legend = [] for row in rows: legend.append(_strand_legend_entry(row, default_dia_text)) for row in mild_rows: if row['depth_in'] is None: continue legend.append(_bar_legend_entry(row)) if mesh_text: legend.append(_mesh_legend_entry(mesh_text)) # The CG entry's text is computed later (it needs `draw_w`/`ox` for # `cgx_in` when real strand x's are known) but its PRESENCE in the # legend -- hence the legend's row count, hence the layout -- is # decided here: exactly when `rows` names at least one usable strand. cg_total_n = sum((r['count'] if r['count'] and r['count'] > 0 else 1) for r in rows) if rows and cg_total_n > 0: legend.append(_cg_legend_entry('')) # placeholder; filled below elif rows: problems.append( 'Strand rows have no usable quantity; the group CG could not ' 'be computed.') if not rows: problems.append( 'No strand rows entered; the cross-section is drawn with no ' 'strands.') # Columns capped at `_LEGEND_MAX_COLS` (2026-09-04) -- see that # constant's own comment; `aspect_for` assumes this same cap. n_cols = min(_LEGEND_MAX_COLS, max(1, int(width_px // _LEGEND_COL_W))) legend_rows_n = 0 if legend: legend_rows_n = (len(legend) + n_cols - 1) // n_cols legend_h = (legend_rows_n * _LEGEND_ROW_H + _LEGEND_PAD) if legend \ else 0.0 # ---- pixel layout: title / section / legend, fixed margins (the right # one wider than the left -- see `_RIGHT_MARGIN`'s own comment). See # the module docstring's LAYOUT section for why none of this grows # with STRAND row count any more. avail_w = width_px - _LEFT_MARGIN - _RIGHT_MARGIN if avail_w < 1.0: avail_w = 1.0 remaining_h = height_px - _TITLE_H - _BOTTOM_H - legend_h if remaining_h < 1.0: remaining_h = 1.0 draw_w, draw_h = _letterbox(width_ft, thickness_ft, avail_w, remaining_h) # A THIN section's TRUE-to-scale `draw_h` can be a sliver of a # generously tall `remaining_h` band (2026-09-04 live-render finding: # ~50 px inside a ~300 px canvas, half of it empty above the rect). # The vertical scale is amplified beyond the horizontal one -- up to # `_MAX_VSCALE` -- until the rect fills at least `_MIN_BAND_FRACTION` # of the band, capped so it never overflows the band either. `draw_w` # is NEVER touched by this -- only the depth axis stretches, and the # title row announces exactly how much so nobody reads the drawing as # to-scale in both directions. vscale = 1.0 min_h = _MIN_BAND_FRACTION * remaining_h if 0.0 < draw_h < min_h: vscale = min(_MAX_VSCALE, min_h / draw_h) new_h = draw_h * vscale if new_h > remaining_h: new_h = remaining_h vscale = new_h / draw_h draw_h = new_h ox = _LEFT_MARGIN + (avail_w - draw_w) / 2.0 oy = _TITLE_H + (remaining_h - draw_h) / 2.0 bottom_y = oy + draw_h scale_per_ft = draw_h / thickness_ft # px per ft of depth (post-vscale) def depth_y(depth_in, flagged_out=None): depth_ft = depth_in / 12.0 flagged = depth_ft < 0.0 or depth_ft > thickness_ft clamped_ft = _clamp(depth_ft, 0.0, thickness_ft) if flagged_out is not None: flagged_out.append(flagged) return bottom_y - clamped_ft * scale_per_ft primitives = [] title_text = 'REINFORCEMENT CROSS-SECTION' if vscale > 1.001: title_text += u' (vertical scale ×{0:.1f})'.format(vscale) primitives.append({'kind': 'text', 'x': width_px / 2.0, 'y': _TITLE_H - 6.0, 'text': title_text, 'role': 'note', 'anchor': 'middle'}) # ---- concrete section: one outline, or Sandwich wythes/insulation sandwich_drawn = False if is_sandwich and sandwich_ok: sandwich_drawn = True px_per_in = draw_h / thickness_in y_cursor = bottom_y for name, layer_in, role in ( ('Bottom wythe', bot_in, 'wythe'), ('Insulation', ins_in, 'insulation'), ('Top wythe', top_in, 'wythe')): layer_h = layer_in * px_per_in y_cursor -= layer_h primitives.append({'kind': 'rect', 'x': ox, 'y': y_cursor, 'w': draw_w, 'h': layer_h, 'role': role}) primitives.append({ 'kind': 'text', 'x': ox + draw_w + 4.0, 'y': y_cursor + layer_h / 2.0 - 6.0, 'text': '{0}: {1:.2f} in'.format(name, layer_in), 'role': 'layer-label', 'anchor': 'start'}) if not sandwich_drawn: primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, 'h': draw_h, 'role': 'panel-section'}) # ---- width label, under the rect's own LEFT corner (2026-09-04 -- it # used to be a per-strand-x dimension chain across the whole bottom, # which is what was getting clipped/cut off; the panel's overall width # is the one number that belongs here, and it always fits inside the # fixed `_BOTTOM_H` band because it never grows with row count). primitives.append({'kind': 'text', 'x': ox, 'y': bottom_y + 14.0, 'text': '{0:.2f} in'.format(width_in), 'role': 'dimension', 'anchor': 'start'}) # ---- rail datum label -- below the width label, >= 12 px apart so the # two can never collide regardless of how wide either one renders. rail_loc = _text(values.get('strand_rail_location')) if rail_loc: key = rail_loc.lower() if 'left' in key: rx = ox elif 'right' in key: rx = ox + draw_w else: rx = ox + draw_w / 2.0 primitives.append({'kind': 'text', 'x': rx, 'y': bottom_y + 28.0, 'text': 'RAIL ({0})'.format(rail_loc), 'role': 'rail-label', 'anchor': 'middle'}) else: problems.append( 'Strand rail location is not entered; the rail datum side is ' 'not labelled on the drawing.') # ---- strand rows: dots, plus the ONLY per-row canvas labels this # drawing still carries -- a short depth call-out in the LEFT margin # and a short qty/dia/depth label in the RIGHT margin, one line per # row at that row's own y (never stacked in lanes; the full # description of every row also lives in the legend below). flat_index = 0 for row in rows: depth_in = row['depth_in'] flagged_out = [] y = depth_y(depth_in, flagged_out) flagged = flagged_out[0] if flagged: problems.append( 'Strand row {0} at {1:.2f} in from the bottom is outside ' 'the panel thickness (0 to {2:.2f} in); shown clamped and ' 'flagged.'.format(row['id'], depth_in, thickness_in)) count = row['count'] if row['count'] and row['count'] > 0 else 1 if row['count'] is None: problems.append( 'Strand row {0} has no quantity entered; drawn as a ' 'single dot at its depth.'.format(row['id'])) dot_count = count if count <= 20 else 20 if count > 20: problems.append( 'Strand row {0} has {1} strands; only 20 dots drawn (the ' 'label carries the true count).'.format(row['id'], count)) xs = [] if explicit_x is not None: for i in range(dot_count): idx = flat_index + i if idx < len(explicit_x) and width_in: frac = _clamp(explicit_x[idx] / width_in, 0.0, 1.0) xs.append(ox + frac * draw_w) if not xs: spacing = draw_w / float(count + 1) xs = [ox + spacing * (i + 1) for i in range(dot_count)] for x in xs: primitives.append({'kind': 'dot', 'x': x, 'y': y, 'role': 'strand', 'flagged': flagged}) # left-margin depth call-out primitives.append({'kind': 'line', 'x1': ox - 8.0, 'y1': y, 'x2': ox, 'y2': y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': ox - 12.0, 'y': y + 4.0, 'text': '{0:.2f} in'.format(depth_in), 'role': 'dimension', 'anchor': 'end'}) # right-margin qty/dia/depth label dia_text = row['dia_in'] or default_dia_text or '?' qty_text = str(row['count']) if row['count'] is not None else \ str(count) primitives.append({ 'kind': 'text', 'x': ox + draw_w + 4.0, 'y': y - 5.0, 'text': u'{0} × {1}″ @ {2:.2f} in'.format( qty_text, dia_text, depth_in), 'role': 'strand-label', 'anchor': 'start'}) flat_index += count # ---- mild bars: a Transverse row draws as ONE thin horizontal line # across the full section width at its depth (seen along its length -- # see the module docstring); anything else (Longitudinal, or blank) # draws small dots END-ON, evenly spread or at their own `spacing_in` # pitch. NEVER labelled at the section itself -- the legend carries # the description. px_per_in_x = (draw_w / width_in) if width_in else 0.0 for row in mild_rows: if row['depth_in'] is None: continue flagged_out = [] y = depth_y(row['depth_in'], flagged_out) flagged = flagged_out[0] if flagged: problems.append( 'Rebar row {0} at {1:.2f} in from the bottom is outside ' 'the panel thickness (0 to {2:.2f} in); shown clamped and ' 'flagged.'.format(row['id'], row['depth_in'], thickness_in)) if _is_transverse(row['direction']): primitives.append({'kind': 'line', 'x1': ox, 'y1': y, 'x2': ox + draw_w, 'y2': y, 'role': 'bar', 'flagged': flagged}) continue count = row['count'] if row['count'] and row['count'] > 0 else 1 dot_count = count if count <= 20 else 20 xs = [] spacing_in = row['spacing_in'] if spacing_in and spacing_in > 0 and px_per_in_x > 0: pitch_px = spacing_in * px_per_in_x x = ox + pitch_px while x < ox + draw_w and len(xs) < dot_count: xs.append(x) x += pitch_px if not xs: spacing_px = draw_w / float(count + 1) xs = [ox + spacing_px * (i + 1) for i in range(dot_count)] for x in xs: primitives.append({'kind': 'dot', 'x': x, 'y': y, 'size': 3, 'role': 'bar', 'flagged': flagged}) # ---- mesh: one dashed line, or one per wythe for a resolved sandwich. # No text on or near the line -- its description lives in the legend. if mesh_text: layers_n = _num(values.get('mesh_layers')) layers_n = int(layers_n) if layers_n and layers_n >= 1 else None if sandwich_drawn and (layers_n is None or layers_n >= 2): bot_mid_in = bot_in / 2.0 top_mid_in = thickness_in - top_in / 2.0 for depth_in in (bot_mid_in, top_mid_in): y = depth_y(depth_in) primitives.append({'kind': 'line', 'x1': ox, 'y1': y, 'x2': ox + draw_w, 'y2': y, 'role': 'mesh', 'dashed': True}) else: y = depth_y(thickness_in / 2.0) primitives.append({'kind': 'line', 'x1': ox, 'y1': y, 'x2': ox + draw_w, 'y2': y, 'role': 'mesh', 'dashed': True}) # ---- strand-group CG: dot (circle) + cross marker in the section -- # its Aps/cg TEXT lives in the legend (built above as a placeholder; # filled in now that `ox`/`draw_w`/`depth_y` are known). Falls back to # `ps_dia_in` for a row with no `dia_in` of its own, mirroring # `pa_reinforcement._strand_block`'s own fallback -- a row that leaves # its diameter to the form's single default still contributes a real # Aps here instead of reading "unrecognised strand size". if rows and cg_total_n > 0: aps = 0.0 any_unknown_area = False weighted_depth = 0.0 for row in rows: area = _strand_area(row['dia_in'] or default_dia_text) count = row['count'] if row['count'] and row['count'] > 0 \ else 1 if area is None: any_unknown_area = True else: aps += count * area weighted_depth += count * row['depth_in'] cgy_in = weighted_depth / float(cg_total_n) cg_y = depth_y(cgy_in) have_cgx = explicit_x is not None and len(explicit_x) >= cg_total_n if have_cgx: cgx_in = sum(explicit_x[:cg_total_n]) / float(cg_total_n) cg_x = ox + _clamp(cgx_in / width_in, 0.0, 1.0) * draw_w \ if width_in else ox + draw_w / 2.0 else: cgx_in = None cg_x = ox + draw_w / 2.0 aps_text = ('{0:.3f} in²'.format(aps) if not any_unknown_area else 'unknown (unrecognised strand size)') if have_cgx: cg_text = u'CG: Aps = {0}, cg = ({1:.2f} in, {2:.2f} in)' \ .format(aps_text, cgx_in, cgy_in) else: cg_text = u'CG: Aps = {0}, cg depth = {1:.2f} in (x unknown)' \ .format(aps_text, cgy_in) primitives.append({'kind': 'dot', 'x': cg_x, 'y': cg_y, 'role': 'cg'}) primitives.append({'kind': 'marker', 'x': cg_x, 'y': cg_y, 'glyph': 'cross', 'role': 'cg'}) # Fill in the placeholder legend entry appended earlier. for idx in range(len(legend) - 1, -1, -1): if legend[idx][0] == u'+': legend[idx] = (u'+', cg_text) break if any_unknown_area: problems.append( 'strand-group CG: Aps could not be fully computed -- at ' 'least one row has a diameter this drawing does not ' 'recognise.') # ---- legend row(s): every group, one line each, left to right in # columns, wrapping to a new legend row only when a row's columns fill # up -- see the module docstring's LAYOUT section. if legend: legend_top = height_px - legend_h col_w = width_px / float(n_cols) for idx, (symbol, text) in enumerate(legend): col = idx % n_cols line = idx // n_cols x = col * col_w + 6.0 y = legend_top + _LEGEND_PAD + line * _LEGEND_ROW_H + 9.0 primitives.append({ 'kind': 'text', 'x': x, 'y': y, 'text': u'{0} {1}'.format(symbol, text), 'role': 'legend', 'anchor': 'start'}) primitives = [_clamp_primitive(p, width_px, height_px) for p in primitives] return primitives, problems # --------------------------------------------------- real-unit geometry (ft) # # Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no # canvas size, no Revit -- built from this module's own real-unit row # readers (`_strand_rows`, `_mild_rows`, `_explicit_strand_x`), the SAME # ones `section()` itself calls, so the JSON and the still-live WPF picture # can never disagree about where a strand or a bar sits. # `_real_geometry(values)` is the one shared parse both drawings consume # (issue #23 item 9i): the section reshapes it straight through, the new # reinforcement `outline` plan reshapes it into plan coordinates. `section_geometry` # stays the section drawing's own name so callers and pagespec keep working. def section_geometry(values): """The `reinforcement.section` drawing's geometry -- real feet / inches, no pixels. Exactly `_real_geometry(values)`'s own return (see that function's docstring for the per-row `chain_in`/`x_in` contract); this thin wrapper exists so a caller of the section keeps the name it always had while the outline plan (`outline_geometry`) reuses the SAME parse rather than re-reading the rows a second time. Never raises; `values` is read-only. """ return _real_geometry(values) def _real_geometry(values): """`{'width_ft', 'thickness_in', 'strands': [...], 'strand_x_in': [...] | None, 'used_fallback_strand_x': bool, 'bars': [...], 'problems': [...]}` -- real feet / inches, no pixels. `strands` is exactly `_strand_rows()`'s own row shape (`id` / `count` / `depth_in` / `dia_in` / `pull_lb`; only rows with a readable depth are included, matching that function's own rule), plus two per-row keys a drawing of the chain itself (issue #23 item 9i) reads: * `chain_in` -- the row's `Location chain from rail` cell resolved to a per-strand list of inches FROM THE RAIL (the cumulative chain, e.g. `2";2";2";2"` -> [2, 4, 6, 8]), or None when the row has no chain or its chain does not resolve; * `x_in` -- the per-strand positions measured from the PANEL'S LEFT edge (`strand_positions()`'s own output: the cumulative chain minus the rail offset, mirrored when the rail is right), or None when the row has no chain / it did not resolve. `strand_x_in`, when not `None`, is the flat per-strand across-width position in inches from the left, in the SAME order `section()` draws its dots in. A row whose `chain` cell RESOLVES supplies its own positions from that chain (`strand_positions`, shared with `pa_reinforcement.page()`), which therefore take precedence over the legacy flat `values['strand_x_in']` / `['strand_columns_in']` list (`_explicit_strand_x`); when only some rows carry chains the two are merged per-row, and a row with neither keeps the even-spread fallback (`used_fallback_strand_x: True` then, as today). A row whose chain does NOT resolve is named in `problems` and contributes nothing to the flat list -- never a guessed position. `bars` is `_mild_rows()`'s own row shape (`id` / `size` / `count` / `depth_in` / `direction` / `spacing_in`), including rows with a blank depth -- the caller decides whether an undrawable bar is worth naming, the same way `pa_reinforcement.page()` already does for the table. Never raises; `values` is read-only. """ values = values or {} problems = [] width_ft = _num(values.get('width_ft')) thickness_in = _num(values.get('thickness_in')) strand_rows, p = _strand_rows(values) problems.extend(p) bar_rows = _mild_rows(values) width_in = width_ft * 12.0 if width_ft and width_ft > 0 else None rail_loc = values.get('strand_rail_location') rail_offset = values.get('strand_rail_offset_in') # `pa_reinforcement` is imported ON DEMAND, inside this function, never # at module scope: `pa_reinforcement` itself imports THIS module at the # top (`psr`), so a top-level import here would be circular. `section()` # (the pixel drawing) does not consume chains, so the cycle is only # ever entered on this real-unit path, and only when a `chain` cell is # actually present. `pa_reinforcement` is import-safe under both Pythons # (no Revit/WPF at module scope), and the import is wrapped so a # sibling mid-edit degrades to "chain unavailable", never a crash. par_module = None try: from vibe import pa_reinforcement as _par par_module = _par except Exception: par_module = None def _resolve_chain(row): """Resolve one row's `chain` cell. `chain_in` is the PARSED chain -- one rail-to-strand length per cell, exactly as typed (`2";2";2";2"` -> [2, 2, 2, 2]) -- for a future drawing of the chain itself (issue #23 item 9i); `x_in` is the per-strand position from the PANEL'S LEFT edge (`strand_positions()`'s output: cumulative chain minus the rail offset, mirrored when the rail is right -- "位置就是累加-offset", owner 2026-09-07). (None, None) when the row has no chain or its chain cannot be resolved (the problem is appended here).""" if not (row.get('chain') or '').strip(): return None, None if row.get('count') is None: problems.append( 'Strand row {0} has a location chain but no count to match ' 'it against.'.format(row['id'])) return None, None if par_module is None: problems.append( 'Strand row {0}: its location chain cannot be resolved -- ' 'vibe.pa_reinforcement is not available.'.format(row['id'])) return None, None lengths, err = par_module.parse_chain_lengths( row['chain'], row['count'], row_label='strand row {0}'.format(row['id'])) if err: problems.append(err) return None, None positions, err = par_module.strand_positions( row['count'], row['chain'], rail_loc, rail_offset, width_in) if err: problems.append(err) return None, None return lengths, positions # Each row's own geometry: `chain_in` (rail-relative, for the drawing # of the chain itself -- issue #23 item 9i) and `x_in` (panel-left). # A row with neither chain nor x_in is left for the caller to spread # evenly, exactly as `section()` falls back today. flat_x = [] have_flat = False for row in strand_rows: chain_in, x_in = _resolve_chain(row) row['chain_in'] = chain_in row['x_in'] = x_in if x_in is not None: flat_x.extend(x_in) have_flat = True # The top-level `strand_x_in` flat list keeps its documented contract: # one entry per strand across EVERY row, or None. Chain rows therefore # only produce that list when EVERY row resolved a chain (the list is # then the rows' concatenated chain positions); a mixed schedule -- some # rows chained, some not -- returns `strand_x_in: None` with # `used_fallback_strand_x: True`, names the chain-less rows in # `problems` (the page turns that into its own quiet fallback # sentence), and lets the caller place the chained rows from their own # per-row `x_in` while spreading the rest. With NO chain anywhere the # legacy `values['strand_x_in']` / `['strand_columns_in']` list is read # exactly as before (`_explicit_strand_x`), so nothing that worked # before this change changes. total_count = sum((r['count'] if r.get('count') and r['count'] > 0 else 1) for r in strand_rows) resolved = [r for r in strand_rows if r.get('x_in') is not None] missing = [r for r in strand_rows if r.get('x_in') is None] explicit_x = None if resolved and not missing: have_flat = have_flat and len(flat_x) == total_count elif resolved and missing: have_flat = False flat_x = [] ids = ', '.join(str(r['id']) for r in missing) problems.append( 'strand rows {0}: no location chain entered -- those strands ' 'are spread evenly across the width (the rows with chains are ' 'placed from their own).'.format(ids)) elif total_count > 0: explicit_x = _explicit_strand_x(values, total_count, width_in) if explicit_x is not None: flat_x = list(explicit_x) have_flat = True used_fallback = total_count > 0 and not have_flat return { 'width_ft': width_ft, 'thickness_in': thickness_in, 'strands': strand_rows, 'strand_x_in': flat_x if have_flat else None, 'used_fallback_strand_x': used_fallback, 'bars': bar_rows, 'problems': problems, } def outline_geometry(values): """The `reinforcement.outline` PLAN drawing's geometry -- real feet / inches, no pixels (issue #23 item 9i): every reinforcement type is its own group with its own continuous dimension chain from its own reference, and this plan is the "two drawings" half of the pair the reinforcement page now carries (paired with the existing `section`). REUSES the section's own parse (`_real_geometry`), never re-reads the rows -- same strands (`chain_in` / `x_in` resolved identically), same bars, same problems. The plan just reshapes that shared parse into plan coordinates and adds the mesh + rail summary the section does not draw: * `strands` is per row `{'row_id', 'x_in', 'chain_in', 'count', 'dia_in', 'depth_in'}` -- `x_in` is the per-strand across-width position in inches from the PANEL'S LEFT edge (the cumulative chain minus the rail offset, mirrored when the rail is right -- the same `_real_geometry` numbers the section draws with), so the plan can lay the strand lines along the length at those x's and draw ONE continuous dimension chain along the width edge from the rail outward using `chain_in` (the rail-to-strand lengths, as typed). * `bars` is per row `{'row_id', 'size', 'count', 'depth_in', 'direction', 'spacing_in', 'from_ft', 'to_ft'}` -- the bar rows' own position basis (`from_ft`/`to_ft` on the length axis, `depth_in` on the width axis) so the plan draws each bar group with its OWN chain from ITS OWN reference, never shared with the strand chain. * `rail` is `{'location': 'left'|'right'|None, 'offset_in': float}` from `strand_rail_location` / `strand_rail_offset_in` (the rail datum the strand chains run from), and is `None` when the location is blank. * `mesh` is `{'designation', 'layers', 'depth_in'}`, or `None` when the mesh field is blank -- a null mesh is "no mesh", never a zero-size guess; `layers`/`depth_in` are whatever the form carries (may be `None` when un-entered). `length_ft` / `width_ft` are read defensively (may be `None` -- the plan rectangle needs them, so a caller shows the `problems` sentences instead of an honest-to-nothing drawing). Never raises; `values` is read-only. """ values = values or {} geo = _real_geometry(values) problems = list(geo['problems']) width_ft = geo['width_ft'] length_ft = _num(values.get('length_ft')) rail_loc_text = _text(values.get('strand_rail_location')) or '' rail_loc_key = rail_loc_text.strip().lower() if 'left' in rail_loc_key: rail_location = 'left' elif 'right' in rail_loc_key: rail_location = 'right' else: rail_location = None rail_offset = _num(values.get('strand_rail_offset_in')) rail = {'location': rail_location, 'offset_in': rail_offset if rail_offset is not None else 0.0} \ if rail_location else None strands = [] for s in geo['strands']: strands.append({ 'row_id': s['id'], 'x_in': s.get('x_in'), 'chain_in': s.get( 'chain_in'), 'count': s['count'], 'dia_in': s['dia_in'], 'depth_in': s['depth_in'], }) bars = [] for b in geo['bars']: bars.append({ 'row_id': b['id'], 'size': b.get('size'), 'count': b.get('count'), 'depth_in': b.get('depth_in'), 'direction': b.get('direction'), 'spacing_in': b.get( 'spacing_in'), 'from_ft': b.get('from_ft'), 'to_ft': b.get('to_ft'), }) mesh_text = _text(values.get('mesh')) if mesh_text: layers = _num(values.get('mesh_layers')) mesh = { 'designation': mesh_text, 'layers': int(layers) if layers is not None else None, 'depth_in': _num(values.get('mesh_depth_in')), } else: mesh = None return { 'length_ft': length_ft, 'width_ft': width_ft, 'rail': rail, 'strands': strands, 'bars': bars, 'mesh': mesh, 'problems': problems, }