Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

995 lines
49 KiB
Python

# -*- coding: utf-8 -*-
"""The Concrete Extents drawings -- pure geometry, both Pythons, no Revit,
no WPF. Two entry points, `outline()` and `section()`, are what
`vibe.pa_extents.page()`'s two `drawing` blocks name (`module:
'pa_sketch_extents'`) -- see that module's "WHY THE TWO DRAWINGS ARE NOW
STACKED" docstring note for the owner ruling (2026-09-04) this exists to
satisfy: the outline drawn bigger, the section moved BELOW it instead of
beside it, and a cut line on the outline that the section redraws around --
"跟做核磁一样" (like an MRI, scrubbing through slices).
THE SEAM is the same one `panelsketch.py` draws for itself: the WPF side
(the `Canvas`, the brushes, and the actual DRAGGING of the cut line) is the
renderer's job, not this module's. This module's job stops at a flat list of
drawing PRIMITIVES in a normalised, already-in-pixels coordinate space
(origin top-left, y down, the same vocabulary `panelsketch.py`'s own module
docstring documents), plus a `problems` list of plain-English sentences for
anything it could not draw -- the caller only walks the list and paints each
primitive, or reads a `problems` sentence and shows it. That keeps this file
importable and testable on plain CPython (`tests/conftest.py`) and runnable
under IronPython 2.7 inside Revit: no f-strings, no `pathlib`, no
`dataclasses`, no typing, no `enum`, standard library only.
WHY `outline()` NO LONGER DELEGATES TO `panelsketch.extents_outline()`
(owner headless-render finding, 2026-09-04, on a 37.58 x 8.375 ft x 8 in
panel): that function LETTERBOXES the panel rectangle -- it fits the
picture to whichever of width/height is tighter, preserving aspect ratio --
which is exactly wrong for this drawing's own container. The container's
height comes from a small `aspect` ratio (`pa_extents._ratio_clamp`, roughly
`width_ft / length_ft`) applied to whatever width the renderer's `Canvas`
ends up with, so the box handed to the drawing function is ALREADY close to
the panel's own true proportions; `extents_outline()`'s own margin +
`dim_room` accounting (subtracted from an already-short `avail_h` a second
time) was enough to flip which axis is "tighter" and letterbox to HEIGHT
instead, leaving the panel at roughly half the canvas's width -- precisely
the "面板只占了显示窗口宽度的一半" the owner found. `panelsketch.py` is not
being changed to fix this (`extents_outline()` still letterboxes, and other
callers may want that) -- `outline()` below draws its own picture instead,
following the OWNER'S GENERAL RULE that already governs every OTHER
full-width drawing in this codebase (`pa_sketch_handling.lifter_dunnage()`,
`pa_sketch_reinf`'s `full_width` sections): "画图的时候,panel 的长度总要占满
整个显示窗口的宽度" -- the drawable width is ALWAYS the panel's full width,
never letterboxed down for height's sake. `section()` below is UNCHANGED --
it already fills its own width; see its own docstring note.
Because that means `outline()` no longer needs `extents_outline()`'s several
hundred lines of layout math, it draws directly: the panel rectangle spans
the full available width (inside a left/right margin -- see
`_outline_margins()`), the overall W dimension on the left, the overall L
label at the panel's bottom-LEFT (not centred -- see "WHERE THE CUT AND L
LABELS SIT" below), and openings/reveals/solid-zones to scale with their own
locating dimension chains (along-length from the top, across-width from the
left). It still REUSES, rather than re-derives, the two pieces of
`panelsketch` that are pure row-geometry with no letterboxing opinion of
their own: `_parse_opening_row()` (via `_openings_rows()`) resolves one
opening row's axes, the same axis-assignment judgement call documented on
that function itself; `_opening_rect_primitives()` / `_gap_dimension_chain()`
turn resolved rows into primitives given an already-placed `(ox, oy, draw_w,
draw_h)` box -- exactly the "do not re-derive" discipline this module
already followed for `section()`. `_title_primitive()` is reused the same
way. None of the three carries a letterboxing opinion; only
`extents_outline()` itself does, and that is the one function `outline()` no
longer calls.
WHERE THE CUT AND L LABELS SIT (owner finding, same headless render): the
station label ("cut @ 18.8 ft") and the overall length label ("L = 37.58
ft") both used to render CENTRED on the cut line's own x position -- which,
at the cut's default midpoint, is the panel's horizontal centre, so both
labels sat directly ON the red cut line they were meant to be read beside.
Fixed two ways, permanently rather than only for the default midpoint:
`_cut_marker_primitives()` anchors the station label `'start'` and offsets
it `_CUT_LABEL_OFFSET_PX` (14px, comfortably past the 12px the tests check)
to the RIGHT of the tick, never centred on it; and the L label is drawn at
the panel's bottom-LEFT corner (`anchor: 'start'`, `x: ox`), not centred
under the panel, so it does not sit under the cut line at its most common
resting place (the midpoint) either. A full-width leader line is still
drawn under the panel so the label still reads as dimensioning the whole
span, only the TEXT moved.
OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER for the cut line's own
pixel position -- read this before touching the WPF half. `outline()` places
the draggable line's tick at `x = geo['x0'] + cut_frac * (geo['x1'] -
geo['x0'])`, where `geo = outline_geometry(values, width_px, height_px)`
(the same call `outline()` itself makes, so the static tick this module
draws and the live draggable line the renderer draws can never disagree).
`outline_geometry()` needs no Revit, no WPF, and -- unlike every primitive
function in this module -- no `panelsketch` either: the box it returns
(`{'x0': left_px, 'x1': right_px, 'y0': top_px, 'y1': bottom_px}`, the
on-canvas panel rectangle's own left/right/top/bottom in pixels) depends
only on `width_px` / `height_px` and, for the vertical axis only,
`length_ft` / `width_ft` (falling back to a stand-in ratio, same as
`pa_extents._OUTLINE_ASPECT_DEFAULT`, when those are not entered yet -- see
`outline_geometry()`'s own docstring). **REPORT TO THE RENDERER OWNER:**
`PanelAnalysis.pushbutton/script.py`'s `_add_cutline()` currently computes
the draggable line's x as `x = frac * w` -- the bare canvas width, with NO
margin offset at all (and the drag handler's `_move()` reads it back the
same way, `frac2 = nx / entry['w']`). That was already wrong before this
change (the old `extents_outline()` picture was drawn inset from the
canvas edges too, just by different, letterboxed amounts), and it is wrong
after this change as well: the panel rectangle's left edge is at
`geo['x0']`, not `0`, and its right edge is at `geo['x1']`, not `w`. The fix
on that side is `x = geo['x0'] + frac * (geo['x1'] - geo['x0'])` for drawing
and `frac2 = (nx - geo['x0']) / (geo['x1'] - geo['x0'])` for the drag
readback, both calling `pa_sketch_extents.outline_geometry(values, w, h)` to
get `geo` -- this module exposes exactly that pair (`x0`, `x1`) for this
reason. Not fixed here: that file is WPF, outside this agent's ownership,
and the rule for this checkout is "no bridge, don't touch the renderer" --
this docstring note IS the report.
THE IMPORT IS GUARDED because `panelsketch.py` is a SIBLING agent's file.
`outline()` still needs three of its row/primitive helpers for openings
(`_openings_rows`, `_opening_rect_primitives`, `_gap_dimension_chain`) and
one for the title (`_title_primitive`) -- an import failure, or any of those
four missing, is "not available yet", never a crash: `outline()` still draws
the panel rectangle, the L/W labels and the cut marker (none of which need
`panelsketch` at all any more) and reports, in `problems`, that openings
could not be drawn. `section()` is unchanged and still hard-degrades to a
single centred note when `panelsketch` (or one of ITS several helpers) is
missing -- see that function's own docstring, carried over from before this
rewrite.
MISSING GEOMETRY IS NEVER A BLANK CANVAS HERE, unlike `panelsketch`'s own
`sketch()` / `elevation()` / `extents_outline()` / `extents_section()`,
which return `([], problems)` when `length_ft` / `width_ft` / `thickness_in`
is missing and leave the caller to decide what an empty list means. This
module's two entry points are always on screen (the block contract has no
"don't show this yet" option), so a hard stop here is a single `text`
primitive that SAYS what is missing, in the middle of the canvas, rather
than nothing at all -- `_center_note()` below is the one place that shape is
built, so every caller of it looks the same.
THE CUT FRACTION, `values['_cut_frac']`, is a plain string (or a number,
either is accepted) between 0 and 1 -- the fraction of `length_ft` the cut
line sits at, `0.5` (the panel's midpoint) when the form has never carried
one. It is form data like anything else the renderer's `current_values()`
reads back off the page -- this module never invents a default other than
0.5, and never rejects a value outside `[0, 1]`; it clamps, the same
"present but flagged" discipline `panelsketch.py`'s own support-clamping
uses, minus the flag: a cut line has no "outside the panel" meaning to flag
(dragging it past an end is simply dragging it to that end).
"""
try:
from vibe import panelsketch
except ImportError:
panelsketch = None
# --------------------------------------------------------------- utilities
def _cut_fraction(values):
"""The cut line's position as a fraction of the length, in `[0, 1]`.
`values['_cut_frac']` missing, blank, or unreadable -> the midpoint,
0.5 -- the same "waiting to be dragged" starting point the block
contract's own `cutline: {'default': 0.5}` declares to the renderer, so
the very first draw (before the user has ever touched the line) matches
what this function falls back to on every later bad value too."""
values = values or {}
raw = values.get('_cut_frac')
if raw is None:
return 0.5
try:
frac = float(raw)
except (TypeError, ValueError):
return 0.5
if frac < 0.0:
return 0.0
if frac > 1.0:
return 1.0
return frac
def _center_note(width_px, height_px, text):
"""The one-primitive fallback canvas: a centred `text` note, nothing
else. Used whenever this module cannot draw the real picture yet --
see the module docstring's "MISSING GEOMETRY IS NEVER A BLANK CANVAS"
note. `width_px` / `height_px` of 0 (or unreadable) still produces a
primitive at (0, 0) rather than raising -- there is no invariant this
function needs to protect by refusing to run."""
try:
w = float(width_px)
except (TypeError, ValueError):
w = 0.0
try:
h = float(height_px)
except (TypeError, ValueError):
h = 0.0
return [{'kind': 'text', 'x': w / 2.0, 'y': h / 2.0, 'text': text,
'role': 'note', 'anchor': 'middle'}]
# ------------------------------------------------------- outline geometry
#
# Pure pixel math, no `panelsketch` -- see the module docstring's
# "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER" note. This is the
# ONE place the outline's on-canvas box is computed; `outline()` calls it
# for its own primitives so the static picture and the live draggable line
# can never disagree about where the panel rectangle actually is.
_OUTLINE_MARGIN_FRACTION = 0.12 # of the shorter canvas side, same
# formula `panelsketch._margin()` /
# `pa_sketch_handling._margin()` use
_OUTLINE_MARGIN_MIN_PX = 8.0
_OUTLINE_MARGIN_MAX_PX = 40.0
_OUTLINE_LEFT_EXTRA_PX = 40.0 # on top of the base margin, left only --
# room for the W dimension chain's line
# AND its rotated/stacked label (owner:
# "~60 px"; base margin (8-40) + this 40
# lands in that neighbourhood for every
# canvas size this drawing actually gets)
# Live-render finding, 2026-09-04 (coordinator): the panel rectangle's
# bottom edge sat on the canvas bottom and the "L = ..." label was clipped
# away entirely -- the container's height (from `pa_extents`'s `aspect`) had
# no room reserved for the title/cut-station label ABOVE the panel or the L
# label/dimension chain BELOW it. Top and bottom now carry their own FIXED
# pixel floors, independent of the shared base margin `m`: `_outline_margins`
# still shrinks them together with everything else only if the canvas is too
# small to hold even these floors (the tiny-canvas test cases), never
# otherwise. `pa_extents._OUTLINE_LABEL_ROWS_ASPECT` is the matching change
# on the container-sizing side -- the two are meant to be read together.
_OUTLINE_TOP_MIN_PX = 26.0 # title row + cut-station label
_OUTLINE_BOTTOM_MIN_PX = 34.0 # L label + its dimension/leader chain
_OUTLINE_ASPECT_DEFAULT = 0.3 # width_ft / length_ft stand-in before
# real geometry exists -- mirrors
# `pa_extents._OUTLINE_ASPECT_DEFAULT`,
# so the box this returns before the
# user has typed length/width is still a
# reasonable shape, not a fresh guess
# this module owns independently of that
# one.
def _outline_margins(width_px, height_px):
"""(left, right, top, bottom) px margins around the outline's panel
rectangle. `left` is the widest -- it alone has to hold the W dimension
chain's line and its rotated/stacked label (see
`_OUTLINE_LEFT_EXTRA_PX`); `right` / `top` / `bottom` are the same base
margin `panelsketch._margin()` computes for every OTHER drawing in this
codebase (12% of the shorter canvas side, clamped 8..40px), holding one
thing each -- the cut station label and, when there are openings, their
along-length chain (top); the L label and its leader line (bottom); the
end of an opening's across-width chain (right).
All four SHRINK TOGETHER, proportionally, whenever the canvas itself is
smaller than their combined budget -- capped at 90% of each axis so a
tiny canvas (the `(20, 15)` case
`tests/test_pa_sketch_extents.py::test_outline_stays_inside_box`
exercises) still leaves the panel a positive, drawable size instead of
the margins alone consuming the whole box."""
width_px = float(width_px)
height_px = float(height_px)
m = min(width_px, height_px) * _OUTLINE_MARGIN_FRACTION
if m < _OUTLINE_MARGIN_MIN_PX:
m = _OUTLINE_MARGIN_MIN_PX
if m > _OUTLINE_MARGIN_MAX_PX:
m = _OUTLINE_MARGIN_MAX_PX
left, right = m + _OUTLINE_LEFT_EXTRA_PX, m
top = max(m, _OUTLINE_TOP_MIN_PX)
bottom = max(m, _OUTLINE_BOTTOM_MIN_PX)
h_budget = max(width_px * 0.9, 4.0)
if left + right > h_budget:
scale = h_budget / (left + right)
left *= scale
right *= scale
v_budget = max(height_px * 0.9, 4.0)
if top + bottom > v_budget:
scale = v_budget / (top + bottom)
top *= scale
bottom *= scale
return left, right, top, bottom
def outline_geometry(values, width_px, height_px):
"""`{'x0': left_px, 'x1': right_px, 'y0': top_px, 'y1': bottom_px}` --
the pixel box the outline's panel rectangle occupies (or WILL occupy,
even before `length_ft` / `width_ft` are entered -- see below). This is
the renderer's half of the cut-line contract: the draggable tick's x is
`x0 + cut_frac * (x1 - x0)`, exactly what `outline()`'s own
`_cut_marker_primitives()` computes, so the two can never disagree. See
the module docstring's "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE
RENDERER" note for the exact formula the WPF side needs and the report
on what it currently does instead.
`x0` / `x1` depend only on `width_px` and the margins `_outline_margins`
computes from `width_px` / `height_px` -- the panel ALWAYS spans the
full available width (see the module docstring's delegation note), so
they need no `length_ft` / `width_ft` at all and are just as valid
before the user has entered any geometry as after. `y0` / `y1` are
scale-true to `width_ft / length_ft` when both are entered and
positive; otherwise `_OUTLINE_ASPECT_DEFAULT` stands in, the same
"still a reasonable shape, never a guess this function owns" rule
`_center_note()`'s callers use for the rest of this module.
THE DRAWN HEIGHT NEVER OVERFLOWS THE CANVAS (2026-09-04 live-render
fix): `draw_h` is `min(avail_v, draw_w * width_ft / length_ft)`, where
`avail_v` is whatever is left of `height_px` after `_outline_margins`'s
top/bottom floors -- so the panel rectangle's bottom edge can never be
pinned to the canvas bottom (which used to crop the L label off the
canvas entirely; see `pa_extents._OUTLINE_LABEL_ROWS_ASPECT` for the
matching container-sizing fix, meant to keep the cap from actually
biting in the normal case). When the true-to-scale height DOES fit
(`avail_v` has slack), the panel is still centred vertically inside
that slack, same as before this fix. This is the one function that
computes the box; `outline()` calls it for its own primitives, so a
caller can trust this return matches what actually got drawn.
Never raises -- a `width_px` / `height_px` that will not parse as a
number is read as `0.0`, the same as `_center_note()` does."""
values = values or {}
try:
width_px = float(width_px)
except (TypeError, ValueError):
width_px = 0.0
try:
height_px = float(height_px)
except (TypeError, ValueError):
height_px = 0.0
left, right, top, bottom = _outline_margins(width_px, height_px)
x0 = left
x1 = max(width_px - right, x0)
draw_w = max(x1 - x0, 0.0)
length_ft = _safe_num(values.get('length_ft'))
width_ft = _safe_num(values.get('width_ft'))
if length_ft is not None and length_ft > 0 and width_ft is not None \
and width_ft > 0:
ratio = width_ft / length_ft
else:
ratio = _OUTLINE_ASPECT_DEFAULT
avail_v = max(height_px - top - bottom, 0.0)
draw_h = min(avail_v, draw_w * ratio)
y0 = top + max(0.0, (avail_v - draw_h) / 2.0)
y1 = y0 + draw_h
return {'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1}
_CUT_TICK_PX = 8.0
_CUT_LABEL_OFFSET_PX = 14.0 # > the 12px the tests check, to the RIGHT of
# the tick -- see the module docstring's
# "WHERE THE CUT AND L LABELS SIT" note.
def _cut_marker_primitives(geo, length_ft, cut_frac):
"""The tick + station-label primitives for the cut line, at its true
proportional position along the outline's own on-canvas box (`geo`,
from `outline_geometry()`). The renderer draws the DRAGGABLE line itself
(the block contract's `cutline` key is what tells it to); these three
primitives are what make the static PNG/host picture -- and this
module's own tests -- show the same cut position without a live drag
handler. `role: 'cutline-tick'` / `'cutline-label'` names it for the
renderer to colour red (small red tick, owner ruling, 2026-09-04) the
same way `role: 'flagged'` primitives elsewhere in `panelsketch` are
coloured by role rather than by an explicit colour key on the
primitive. The label is anchored `'start'` and offset
`_CUT_LABEL_OFFSET_PX` to the right of the tick -- NEVER centred on it,
see the module docstring."""
ox, oy = geo['x0'], geo['y0']
draw_w = geo['x1'] - geo['x0']
draw_h = geo['y1'] - geo['y0']
cx = ox + cut_frac * draw_w
station_ft = cut_frac * length_ft
tick = _CUT_TICK_PX
# Floor at 4.0, not 0.0 -- the coordinator's clearance rule (every
# primitive's y within [0, height_px] with >= 4px of daylight from
# either edge), same reasoning `_outline_margins`'s `_OUTLINE_TOP_MIN_PX`
# exists for. `oy` is never less than that margin, so this floor is a
# backstop, not the normal path.
label_y = max(oy - tick - 14.0, 4.0)
return [
{'kind': 'line', 'x1': cx, 'y1': max(oy - tick, 0.0), 'x2': cx,
'y2': oy, 'role': 'cutline-tick'},
{'kind': 'line', 'x1': cx, 'y1': oy + draw_h, 'x2': cx,
'y2': oy + draw_h + tick, 'role': 'cutline-tick'},
{'kind': 'text', 'x': cx + _CUT_LABEL_OFFSET_PX, 'y': label_y,
'text': 'cut @ {0:.1f} ft'.format(station_ft),
'role': 'cutline-label', 'anchor': 'start', 'font_size': 11},
]
# --------------------------------------------------------------------- API
def outline(values, width_px, height_px):
"""(primitives, problems) -- the panel outline: the panel rectangle to
scale (spanning the FULL available drawing width -- see the module
docstring's delegation note), the overall W dimension on the left, the
overall L label at the panel's bottom-left, openings/reveals/solid-zones
to scale with their own locating dimension chains (along-length from the
top, across-width from the left), and the cut line's tick + station
label on top of it. Never raises, and never returns an empty canvas.
`values` is the whole form's dict, read the same way every other drawing
function in this codebase reads it (`.get()` only, never mutated)."""
values = values or {}
problems = []
length_ft = _safe_num(values.get('length_ft'))
width_ft = _safe_num(values.get('width_ft'))
if length_ft is None or length_ft <= 0:
problems.append(
'Length (ft) is missing or not a positive number; the outline '
'is waiting on it.')
if width_ft is None or width_ft <= 0:
problems.append(
'Width (ft) is missing or not a positive number; the outline '
'is waiting on it.')
if problems:
return _center_note(width_px, height_px, problems[0]), problems
geo = outline_geometry(values, width_px, height_px)
ox, oy = geo['x0'], geo['y0']
draw_w = geo['x1'] - geo['x0']
draw_h = geo['y1'] - geo['y0']
primitives = [{'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w,
'h': draw_h, 'role': 'panel'}]
if panelsketch is not None:
title_fn = getattr(panelsketch, '_title_primitive', None)
if title_fn is not None:
try:
title = title_fn(values.get('mark'), values.get('design_number'))
except Exception:
title = None
if title:
primitives.append(title)
# ---- W dimension chain, left of the panel, rotated label (font >= 12)
dim_x = max(ox - 20.0, 2.0)
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy,
'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': max(dim_x - 14.0, 2.0),
'y': oy + draw_h / 2.0,
'text': 'W = {0:.2f} ft'.format(width_ft),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical', 'font_size': 12})
# ---- L label, bottom-LEFT under the panel (never centred -- see the
# module docstring's "WHERE THE CUT AND L LABELS SIT" note), with a
# full-width leader line so it still reads as dimensioning the whole
# span even though the text itself sits at the left corner.
leader_y = oy + draw_h + 6.0
primitives.append({'kind': 'line', 'x1': ox, 'y1': leader_y,
'x2': ox + draw_w, 'y2': leader_y,
'role': 'dimension'})
primitives.append({'kind': 'text', 'x': ox, 'y': leader_y + 8.0,
'text': 'L = {0:.2f} ft'.format(length_ft),
'role': 'dimension', 'anchor': 'start',
'font_size': 12})
# ---- openings/reveals/solid-zones, to scale, plus locating chains --
# reused row-geometry from `panelsketch`, see the module docstring.
if panelsketch is None:
problems.append(
'vibe.panelsketch could not be imported; openings/reveals/'
'solid zones cannot be drawn on the outline.')
else:
rows_fn = getattr(panelsketch, '_openings_rows', None)
rect_fn = getattr(panelsketch, '_opening_rect_primitives', None)
chain_fn = getattr(panelsketch, '_gap_dimension_chain', None)
if None in (rows_fn, rect_fn, chain_fn):
problems.append(
'vibe.panelsketch is missing one of _openings_rows / '
'_opening_rect_primitives / _gap_dimension_chain; openings '
'cannot be drawn on the outline.')
else:
try:
rows, row_problems = rows_fn(values, length_ft, width_ft, None)
except Exception as exc: # never take the page down
rows, row_problems = None, [
'panelsketch openings/reveals/solid zones raised: '
'{0}'.format(exc)]
problems.extend(row_problems)
if rows:
try:
opening_prims, opening_problems = rect_fn(
rows, length_ft, width_ft, ox, oy, draw_w, draw_h)
except Exception as exc:
opening_prims, opening_problems = [], [
'panelsketch opening rectangles raised: '
'{0}'.format(exc)]
primitives.extend(opening_prims)
problems.extend(opening_problems)
along_ranges = [r['along'] for r in rows
if r['vert'] is not None]
vert_ranges = [r['vert'] for r in rows
if r['vert'] is not None]
if along_ranges:
# from the START, along the TOP -- above the panel, not
# below it, so the bottom stays clear for the L label.
# Sits BELOW the cut-station label (closer to the panel
# edge) with its own 4px floor, same reasoning as
# `_cut_marker_primitives`'s `label_y`.
top_chain_y = max(oy - 18.0, 4.0)
primitives.extend(chain_fn(
along_ranges, length_ft,
lambda lo: (ox + (lo / length_ft) * draw_w,
top_chain_y),
lambda hi: (ox + (hi / length_ft) * draw_w,
top_chain_y),
'{0:.2f} ft'))
if vert_ranges:
# from the LEFT, along the left side -- further out than
# the W dimension chain itself.
chain_x = max(dim_x - 30.0, 2.0)
primitives.extend(chain_fn(
vert_ranges, width_ft,
lambda lo: (chain_x,
oy + draw_h - (lo / width_ft) * draw_h),
lambda hi: (chain_x,
oy + draw_h - (hi / width_ft) * draw_h),
'{0:.2f} ft'))
elif rows is None:
openings_text = values.get('openings')
if openings_text is not None and str(openings_text).strip():
problems.append(
'Openings/reveals were entered as plain text, not '
'the structured rows this drawing needs to locate '
'them: {0!r}'.format(str(openings_text).strip()))
# ---- the cut line's tick + station label, on top of everything else
primitives = primitives + _cut_marker_primitives(
geo, length_ft, _cut_fraction(values))
clamp = getattr(panelsketch, '_clamp_primitive', None) \
if panelsketch is not None else None
if clamp is not None:
primitives = [clamp(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
def section(values, width_px, height_px):
"""(primitives, problems) -- the cross-section AT the cut line's current
station: a full width_ft x thickness_in rectangle, a Sandwich section's
wythe/insulation layers (same layering `panelsketch.extents_section()`
draws), any `Opening` row whose along-length extent contains the cut
station drawn as a full-depth gap at its across-width position, any
`Reveal` row at the station drawn as a partial-depth notch cut in from
its recorded face, dimension labels (`t = ... in`, `W = ... ft`), the
station itself (below the section, not on it), and the net concrete area
at that station in square inches. `Solid Zone` rows are named in
`problems` when they fall at the station but never drawn as a gap -- a
solid zone removes nothing, by definition, the same rule `panelsketch.py`'s
own cross-section and plan functions already follow. UNCHANGED by the
2026-09-04 outline rewrite (see the module docstring) -- this drawing
already fills its own width and its station label already sits below the
section, not on it. Never raises, never returns an empty canvas."""
values = values or {}
if panelsketch is None:
return (_center_note(
width_px, height_px,
'panelsketch is not available; the cross-section cannot be '
'drawn.'),
["vibe.panelsketch could not be imported; the cross-section "
"is waiting on it."])
width_ft = _safe_num(values.get('width_ft'))
thickness_in = _safe_num(values.get('thickness_in'))
problems = []
if width_ft is None or width_ft <= 0:
problems.append(
'Width (ft) is missing or not a positive number; the '
'cross-section is waiting on it.')
if thickness_in is None or thickness_in <= 0:
problems.append(
'Thickness (in) is missing or not a positive number; the '
'cross-section is waiting on it.')
if problems:
return _center_note(width_px, height_px, problems[0]), problems
thickness_ft = thickness_in / 12.0
length_ft = _safe_num(values.get('length_ft'))
cut_frac = _cut_fraction(values)
station_ft = cut_frac * length_ft if length_ft is not None \
and length_ft > 0 else None
margin_fn = getattr(panelsketch, '_margin', None)
letterbox_fn = getattr(panelsketch, '_letterbox', None)
clamp_val = getattr(panelsketch, '_clamp', None)
clamp_prim = getattr(panelsketch, '_clamp_primitive', None)
parse_row = getattr(panelsketch, '_parse_opening_row', None)
if None in (margin_fn, letterbox_fn, clamp_val, clamp_prim, parse_row):
return (_center_note(
width_px, height_px,
'panelsketch is missing a helper this drawing needs; the '
'cross-section cannot be drawn.'),
['vibe.panelsketch is missing one of _margin / _letterbox / '
'_clamp / _clamp_primitive / _parse_opening_row.'])
primitives = []
margin = margin_fn(width_px, height_px)
avail_w = max(float(width_px) - 2.0 * margin, 1.0)
avail_h = max(float(height_px) - 2.0 * margin, 1.0)
# Room outside the outline: the thickness dimension to the left, a
# Sandwich section's wythe/insulation labels to the right -- the same
# split `panelsketch.extents_section()` uses for this identical picture.
left_room = 40.0
right_room = 90.0
draw_w, draw_h = letterbox_fn(
width_ft, thickness_ft,
max(avail_w - left_room - right_room, 1.0), avail_h)
ox = margin + left_room + (avail_w - left_room - right_room - draw_w) / 2.0
oy = margin + (avail_h - draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w,
'h': draw_h, 'role': 'panel-section'})
# ---- Sandwich wythes / insulation -- same rule as `panelsketch.
# extents_section()` / `panelsketch.section()`: only drawn as layers
# when all three thicknesses are entered AND add up to the panel
# thickness within 1/8 in; otherwise reported and left as one outline.
section_type = str(values.get('section_type') or '').strip().lower()
if section_type == 'sandwich':
top_in = _safe_num(values.get('wythe_top_in'))
ins_in = _safe_num(values.get('insulation_in'))
bot_in = _safe_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 / wythe '
'thicknesses are not all entered; the cross-section is '
'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:
px_per_in = draw_h / thickness_in
y_cursor = oy + draw_h
for name, layer_in, role in (('wythe', bot_in, 'wythe'),
('insulation', ins_in, 'insulation'),
('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 + 6.0,
'y': y_cursor + layer_h / 2.0 - 6.0,
'text': '{0} {1:.2f} in'
.format(name, layer_in),
'role': 'layer-label'})
# ---- openings / reveals AT the cut station
removed_in2 = 0.0
if station_ft is None:
problems.append(
'Length (ft) is not entered; openings and reveals cannot be '
'located along the panel to know which fall at this cut '
'station, so none are drawn here.')
else:
raw_rows = values.get('openings_rows')
if isinstance(raw_rows, list):
for i, item in enumerate(raw_rows):
row, row_problems = parse_row(
item, i + 1, length_ft, width_ft, thickness_ft)
problems.extend(row_problems)
if row is None:
continue
a0, a1 = row['along']
if not (a0 <= station_ft <= a1):
continue # this row does not reach the cut station
if row['type'] == 'Solid Zone':
problems.append(
'Solid Zone row {0} ({1}) is at the cut station but '
'removes nothing, by definition; not drawn as a '
'gap.'.format(row['index'], row['label'] or
'unlabeled'))
continue
if row['vert'] is None:
problems.append(
'{0} row {1} ({2}) is at the cut station but has '
'no across-width position/extent entered; not '
'drawn on the cross-section.'
.format(row['type'], row['index'],
row['label'] or 'unlabeled'))
continue
v0, v1 = row['vert']
cv0 = clamp_val(v0, 0.0, width_ft)
cv1 = clamp_val(v1, 0.0, width_ft)
if cv1 <= cv0:
continue
gx = ox + (cv0 / width_ft) * draw_w
gw = ((cv1 - cv0) / width_ft) * draw_w
if row['type'] == 'Opening':
# through -- the full thickness is gone over this width
primitives.append({'kind': 'rect', 'x': gx, 'y': oy,
'w': gw, 'h': draw_h,
'role': 'opening'})
removed_in2 += (cv1 - cv0) * 12.0 * thickness_in
else: # Reveal -- partial-depth, from the recorded face.
# `row['cross']` is the through-thickness axis
# `panelsketch._parse_opening_row()` resolves from
# `pos_cross` / `width` / `pos_cross_from` -- ALWAYS as
# an absolute (lo, hi) measured from the near/"left"
# face at 0 up to the far/"right" face at thickness_ft,
# regardless of which face it was typed from (that is
# what `_resolve_axis_range()` normalises). This module
# draws cross=0 at the TOP of the section rectangle and
# cross=thickness_ft at the bottom -- an arbitrary but
# consistent choice, inherited from that function's own
# "medium confidence" axis call, not re-litigated here.
if row['cross'] is None:
problems.append(
'{0} row {1} ({2}) is at the cut station but '
'has no through-thickness position/depth '
'entered; not drawn on the cross-section.'
.format(row['type'], row['index'],
row['label'] or 'unlabeled'))
continue
c0, c1 = row['cross']
cc0 = clamp_val(c0, 0.0, thickness_ft)
cc1 = clamp_val(c1, 0.0, thickness_ft)
if cc1 <= cc0:
continue
gy = oy + (cc0 / thickness_ft) * draw_h
gh = ((cc1 - cc0) / thickness_ft) * draw_h
primitives.append({'kind': 'rect', 'x': gx, 'y': gy,
'w': gw, 'h': gh, 'role': 'reveal'})
removed_in2 += (cv1 - cv0) * 12.0 * (cc1 - cc0) * 12.0
else:
openings_text = values.get('openings')
if openings_text is not None and str(openings_text).strip():
problems.append(
'Openings/reveals were entered as plain text, not the '
'structured rows this drawing needs to locate them at '
'the cut station.')
# ---- dimensions and labels, OUTSIDE the outline
dim_x = ox - 20.0
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy,
'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': dim_x - 12.0,
'y': oy + draw_h / 2.0,
'text': 't = {0:.2f} in'.format(thickness_in),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical', 'font_size': 12})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(oy - 16.0, 0.0),
'text': 'W = {0:.2f} ft'.format(width_ft),
'role': 'dimension', 'anchor': 'middle',
'font_size': 12})
# ---- the cut station label, BELOW the section -- never on it, and
# never on the outline's own cut line either (this is a different
# drawing, drawn once per station rather than dragged).
station_text = ('cut @ {0:.1f} ft'.format(station_ft)
if station_ft is not None
else 'cut station: length (ft) not entered')
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + 16.0, 'text': station_text,
'role': 'cutline-label', 'anchor': 'middle',
'font_size': 11})
gross_in2 = width_ft * 12.0 * thickness_in
net_in2 = gross_in2 - removed_in2
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + 32.0,
'text': u'Net area = {0:.1f} in²'
.format(net_in2),
'role': 'annotation', 'anchor': 'middle',
'font_size': 11})
primitives = [clamp_prim(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
# `panelsketch._num` is the same "a float, or None" reader this module would
# otherwise have to duplicate -- reused rather than re-derived, same as
# `_parse_opening_row` above, but resolved lazily (`_safe_num`, not a bare
# `panelsketch._num` alias) so a `panelsketch` import that is present but
# missing `_num` (mid-rewrite) degrades to a local copy instead of an
# `AttributeError` neither `outline()` nor `section()` would otherwise catch
# before it reached the caller.
def _safe_num(value):
fn = getattr(panelsketch, '_num', None) if panelsketch is not None \
else None
if fn is not None:
return fn(value)
if value is None:
return None
if isinstance(value, bool):
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
# --------------------------------------------------- real-unit geometry (ft)
#
# Added for the HTML-page rewrite (`vibe.pa_pagespec`): the two functions
# below are UNRELATED to `outline_geometry()` above despite the similar name
# -- that one returns a PIXEL box for one canvas size, purely so the WPF
# cutline and `outline()`'s own static tick agree. These return real feet /
# inches, independent of any canvas size, Revit, or WPF, so a page with
# neither can lay out its own picture and intersect its own cut line exactly
# the way `section()` does today. `outline_geometry_ft()` reuses
# `panelsketch._openings_rows()` for the openings list -- the same row parse
# `outline()` itself draws from (see the module docstring's "THE IMPORT IS
# GUARDED" note for why every call into `panelsketch` here is via `getattr`,
# never a bare attribute access) -- so the JSON and the still-live WPF
# picture can never disagree about what an opening row means.
_FALLBACK_LENGTH_FT = 1.0
_FALLBACK_WIDTH_FT = 0.3
def outline_geometry_ft(values):
"""Real-world panel outline + openings, in feet -- independent of any
canvas size. Never raises, and -- matching this module's own "MISSING
GEOMETRY IS NEVER A BLANK CANVAS" rule, applied to DATA rather than a
drawing -- never returns a zero-area outline: `length_ft` / `width_ft`
missing or non-positive falls back to a small stand-in rectangle,
flagged `'degraded': True` and named in `problems`, so a page opened
before either is typed still has a real (if placeholder) shape to draw.
{'length_ft': float, 'width_ft': float, 'thickness_in': float|None,
'degraded': bool,
'outline': [[0,0], [L,0], [L,W], [0,W]], # ft, closed implied
'openings': [{'type', 'role', 'label', 'index',
'along': [lo, hi] | None, # ft, length axis
'vert': [lo, hi] | None, # ft, width axis
'cross': [lo, hi] | None}, # ft, thru-thickness
...],
'problems': [str, ...]}
`values` is read only, via `.get(...)`, and never mutated.
"""
values = values or {}
problems = []
length_ft = _safe_num(values.get('length_ft'))
width_ft = _safe_num(values.get('width_ft'))
thickness_in = _safe_num(values.get('thickness_in'))
degraded = False
if length_ft is None or length_ft <= 0:
problems.append(
"'length_ft' is missing or not a positive number; showing a "
'placeholder outline.')
length_ft = _FALLBACK_LENGTH_FT
degraded = True
if width_ft is None or width_ft <= 0:
problems.append(
"'width_ft' is missing or not a positive number; showing a "
'placeholder outline.')
width_ft = _FALLBACK_WIDTH_FT
degraded = True
rows = []
if panelsketch is None:
if isinstance(values.get('openings_rows'), list) and \
values.get('openings_rows'):
problems.append(
'vibe.panelsketch could not be imported; openings could '
'not be resolved into geometry.')
else:
rows_fn = getattr(panelsketch, '_openings_rows', None)
if rows_fn is None:
problems.append(
'vibe.panelsketch is missing _openings_rows; openings '
'could not be resolved into geometry.')
else:
try:
raw_rows, row_problems = rows_fn(values, length_ft,
width_ft, None)
except Exception as exc: # never take the page down
raw_rows, row_problems = None, [
'openings could not be read: {0!r}'.format(exc)]
problems.extend(row_problems)
for r in (raw_rows or []):
rows.append({
'type': r.get('type'), 'role': r.get('role'),
'label': r.get('label'), 'index': r.get('index'),
'along': list(r['along']) if r.get('along') else None,
'vert': list(r['vert']) if r.get('vert') else None,
'cross': list(r['cross']) if r.get('cross') else None,
})
return {
'length_ft': length_ft, 'width_ft': width_ft,
'thickness_in': thickness_in, 'degraded': degraded,
'outline': [[0.0, 0.0], [length_ft, 0.0],
[length_ft, width_ft], [0.0, width_ft]],
'openings': rows, 'problems': problems,
}
def _clamp_num(v, lo, hi):
if v < lo:
return lo
if v > hi:
return hi
return v
def _merge_ranges(ranges):
"""Sorted, overlap-merged `[lo, hi)` pairs -- shared by `section_at()`
so two opening rows that overlap at one station read as one void."""
clean = sorted((lo, hi) for lo, hi in ranges if hi > lo)
merged = []
for lo, hi in clean:
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
else:
merged.append((lo, hi))
return merged
def section_at(geometry, x_ft):
"""The cross-section profile at along-length station `x_ft`, computed
from an `outline_geometry_ft()`-shaped dict ALONE -- no Revit, no
canvas, no `values`. THIS IS THE ALGORITHM THE HTML PAGE REIMPLEMENTS IN
JAVASCRIPT (`docs/pa-web-spec.md`'s "section-at-cut" section carries the
same steps in prose, and the two must be kept in step -- not just
agreeing on the panels this module's own tests happen to cover):
1. Clamp `x_ft` to `[0, geometry['length_ft']]`.
2. `voids` = the WIDTH-axis ranges (`[lo, hi]`, ft from the left) open
at this station: every opening row whose `type` is 'Opening' (a
Reveal or Solid Zone does not go through -- matches `section()`'s
own rule and `panelanalysisform`'s "'Opening' rows always go full
depth" note) whose `along` range covers `x_ft`, AND whose `vert`
range is known -- a row with `vert: None` cannot be localised
across the width and is reported in `problems` instead of guessed.
3. Merge overlapping/touching ranges and sort ascending, so two
openings that overlap at this station read as one void, never a
double-counted pair.
Returns `{'x_ft', 'width_ft', 'thickness_in', 'voids': [[lo, hi], ...],
'problems': [...]}`. Never raises: a malformed `geometry` (missing keys,
wrong types) degrades to the widest reasonable stand-in -- this runs on
every cutline drag, and a redrawn slice is not the place to surface a
Python-shaped error to a viewer.
"""
problems = []
geometry = geometry if isinstance(geometry, dict) else {}
length_ft = _safe_num(geometry.get('length_ft'))
if length_ft is None or length_ft <= 0:
length_ft = _FALLBACK_LENGTH_FT
width_ft = _safe_num(geometry.get('width_ft'))
if width_ft is None or width_ft <= 0:
width_ft = _FALLBACK_WIDTH_FT
thickness_in = _safe_num(geometry.get('thickness_in'))
x = _safe_num(x_ft)
if x is None:
problems.append('x_ft is not a number; using the panel midpoint.')
x = length_ft / 2.0
x = _clamp_num(x, 0.0, length_ft)
raw_ranges = []
for row in (geometry.get('openings') or []):
if not isinstance(row, dict):
continue
type_ = str(row.get('type') or 'opening').strip().lower()
if type_ not in ('opening', ''):
continue
along = row.get('along')
if not along or len(along) != 2 or along[0] is None or \
along[1] is None:
continue
lo, hi = along
if not (lo <= x <= hi):
continue
vert = row.get('vert')
if not vert or len(vert) != 2 or vert[0] is None or vert[1] is None:
problems.append(
"opening {0!r} crosses this station but has no across-"
'width position entered; not punched into the section.'
.format(row.get('label') or row.get('index')))
continue
raw_ranges.append((float(vert[0]), float(vert[1])))
voids = _merge_ranges(raw_ranges)
return {'x_ft': x, 'width_ft': width_ft, 'thickness_in': thickness_in,
'voids': [list(v) for v in voids], 'problems': problems}