Files
willtools-demo/WillTools.extension/lib/vibe/pa_sketch_reinf.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

1389 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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.<n>.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.<n>.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 `<table>.<n>.<col>` / `<table>.<n>.
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,
}