8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1389 lines
64 KiB
Python
1389 lines
64 KiB
Python
# -*- 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,
|
||
}
|