# -*- 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}