WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
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.
This commit is contained in:
@@ -0,0 +1,994 @@
|
||||
# -*- 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}
|
||||
Reference in New Issue
Block a user