# -*- coding: utf-8 -*- """The Panel Analysis elevation sketch -- pure geometry, both Pythons, no Revit, no WPF. WHY THIS EXISTS: the Panel Analysis input form (`panelanalysisform.py`) pops one collapsible section per selected panel, and today it is all text boxes. Eriksson Wall -- the vendor tool this feature is modelled on -- draws the panel elevation right beside its inputs (`docs/eriksson-wall-method.md` A.8.4: "Cover-sheet strand/reinforcing patterns and stripping-pick geometry ... each p.1 ... dimensioned drawings"), so the engineer can SEE that what they typed matches the piece instead of trusting a column of numbers. The owner asked for that drawing here. THE SEAM: the WPF rendering (a `Canvas`, brushes, a `Polygon` for the support triangles) is the button'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 -- the caller only has to walk the list and paint each one onto a `Canvas` of whatever size it happens to have. That keeps this file importable and testable on plain CPython the way the rest of the Revit-free `vibe` is (see `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 (`math` is not even needed -- plain arithmetic and `re` for the supports text). THE INPUT is `values`, the SAME dict `panelanalysisform.py` reads from the form -- string-valued, one key per `panelanalysisform.FIELDS` row, still in whatever state the user has typed it to (including blank). This module does NOT import `panelanalysisform` -- duplicating the handful of parsing rules it needs (a float, a comma/whitespace list of floats) keeps this file a leaf with zero project imports, which matters more here than avoiding a few lines of duplication: `panelanalysisform.py` is free to change its parsing rules for reasons that have nothing to do with how a panel is drawn, and a leaf module cannot be broken by a change three layers away. Fields used, verbatim from `panelanalysisform.FIELDS`: length_ft the panel's length/height (ft) -- the SPAN direction; this is the axis `supports_ft` positions are measured along (FIELDS: "Support positions along length"), so it is drawn as the drawing's horizontal axis. width_ft the panel's width (ft) -- drawn as the vertical axis. thickness_in cannot be shown geometrically in an elevation (a flat drawing has no third dimension); printed as text instead. supports_ft free text, comma/whitespace-separated positions in feet along `length_ft`. `values['supports']`, a pre-parsed list of numbers (what `panelanalysisform.validate` produces), is accepted too and preferred when present, so a caller that already validated the form does not have to re-render text. openings free text ("count / description" per FIELDS) -- see below. mark, design_number used only to label the sketch, if present; never required. MISSING INPUT IS THE HEADLINE STATE, not a crash and not a default. The form pre-fills what the model could read and leaves the rest genuinely BLANK (`panelanalysisform`'s whole reason for existing); a drawing module that invented a rectangle for a blank field would be lying with a picture instead of with a number, which is worse, because a picture reads as more trustworthy than a text box. So: `length_ft` or `width_ft` missing, blank, non-numeric or <= 0 makes `sketch()` return `([], problems)` -- NO panel rectangle, nothing letterboxed, nothing guessed -- with one plain sentence per such field saying which input the drawing is waiting on. Everything else (a missing thickness, an out-of-range support, unparsed openings prose) still draws what it safely can and reports the rest through `problems` alongside real primitives; only the two geometry-defining fields are a hard stop, because there is no honest rectangle to draw without them. A support outside `[0, length_ft]` is never silently dropped -- it is real data the user typed and dropping it would hide a genuine input error. It is reported in `problems` AND still drawn, clamped to the nearest panel end, with its primitive carrying `'flagged': True` so the caller can render it in a different colour. OPENINGS: this module defines exactly ONE drawable pattern, and it draws nothing else -- ever. That pattern is: none. `openings` is free "count / description" prose (FIELDS: "Openings (count / description)") with no position or size in it at all, so there is no text shape in that field that could honestly become a rectangle at a specific place on the panel -- drawing one anyway would put a hole in a spot nobody said it was at, which is a lie a reader cannot detect by looking. So any non-blank `openings` text always produces a `text` primitive (`role: 'openings-note'`) saying openings were described but are not drawn, never a rectangle. The only parsing done on it is a best-effort leading integer (`'2 openings, ...' -> 2`) purely to phrase that note ("2 opening(s) described..." vs quoting the raw text); it never gates whether something is drawn, because nothing here ever is. PRIMITIVES are plain dicts, coordinates already in pixels, origin top-left, y increasing downward (WPF's `Canvas` convention), every one of them clamped so it lies inside `[0, width_px] x [0, height_px]` -- dimension text and support markers included, which is the whole reason `sketch()` takes a margin into account rather than filling the box edge-to-edge. The vocabulary: {'kind': 'rect', 'x','y','w','h', 'role': 'panel'} the panel elevation itself, exactly one per successful sketch, drawn to scale (`w / h == length_ft / width_ft`) and letterboxed inside the box with the aspect ratio preserved -- never stretched. {'kind': 'support', 'x','y', 'role': 'support', 'flagged': bool} one per support, at its true proportional position along the panel's bottom edge; `flagged` is True when the typed position fell outside `[0, length_ft]` and had to be clamped to draw at all. {'kind': 'text', 'x','y', 'text', 'role': 'support-label', 'anchor': 'middle'} the support's value in feet, printed under its marker. {'kind': 'line', 'x1','y1','x2','y2', 'role': 'dimension'} the length and width dimension lines (one each). {'kind': 'text', 'x','y', 'text', 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical' (width only)} the length/width values with units; the width one carries `orientation: 'vertical'` as a hint that the caller may want to rotate the glyph the way the width dimension line is drawn (vertical). {'kind': 'text', 'x','y', 'text', 'role': 'annotation', 'anchor': 'middle'} the thickness, printed as text because an elevation cannot show it geometrically -- 'Thickness: not entered' when the field is blank, never a faked value. {'kind': 'text', 'x','y', 'text', 'role': 'openings-note', 'anchor': 'middle'} see OPENINGS above. {'kind': 'text', 'x','y', 'text', 'role': 'note', 'anchor': 'middle'} minor non-blocking notices (currently: no supports entered). {'kind': 'text', 'x','y', 'text', 'role': 'title', 'anchor': 'start'} the piece mark / design number, if either was given. `sketch()` never mutates the `values` dict it is handed -- it only ever calls `.get()` on it. """ import re _MARGIN_FRACTION = 0.15 # of the shorter box side _MARGIN_MIN_PX = 8.0 _MARGIN_MAX_PX = 60.0 # ------------------------------------------------------------------ parsing # Deliberately small and independent of `panelanalysisform`'s parsing -- # see the module docstring for why this module does not import it. def _num(value): """A float, or None. Accepts a number already, or text; blank -> None.""" if value is None: return None if isinstance(value, bool): # bool is an int subclass; not a number here return None if isinstance(value, (int, float)): return float(value) text = str(value).strip() if not text: return None try: return float(text) except (TypeError, ValueError): return None def _supports_of(values): """The support positions, in feet, as a plain list. [] if none given. Prefers `values['supports']` (a pre-parsed list, the shape `panelanalysisform.validate` produces) when it is a list; otherwise reads `values['supports_ft']` as comma/whitespace-separated text, the same split `panelanalysisform.parse_supports` uses. A token that will not parse as a number is dropped rather than raising -- this module draws what it can and leaves validation-grade error text to the form. """ raw = values.get('supports') if isinstance(raw, (list, tuple)): out = [] for item in raw: v = _num(item) if v is not None: out.append(v) return out text = values.get('supports_ft') if text is None: return [] text = str(text).strip() if not text: return [] out = [] for tok in re.split(r'[,\s]+', text): if not tok: continue v = _num(tok) if v is not None: out.append(v) return out def _openings_count(text): """A leading integer read off the openings text, or None. Phrasing only -- see the module docstring: this never decides whether anything is drawn, because nothing about openings is ever drawn. """ if not text: return None text = str(text).strip() m = re.match(r'^(\d+)\b', text) if not m: return None try: return int(m.group(1)) except (TypeError, ValueError): return None # ------------------------------------------------------------------ layout def _margin(width_px, height_px): m = min(width_px, height_px) * _MARGIN_FRACTION if m < _MARGIN_MIN_PX: m = _MARGIN_MIN_PX if m > _MARGIN_MAX_PX: m = _MARGIN_MAX_PX return m def _clamp(v, lo, hi): if v < lo: return lo if v > hi: return hi return v def _clamp_primitive(prim, width_px, height_px): """Pulls one primitive's coordinates back inside the box, in place. The layout math already aims everything inside the margin, but a tiny box (a caller-supplied `width_px`/`height_px` smaller than the margin budget) or a support clamped at a panel end can still land a coordinate a hair outside 0..width_px / 0..height_px; this is the one place that guarantees the invariant rather than trusting the arithmetic above it. """ kind = prim.get('kind') if kind == 'rect': x = _clamp(prim['x'], 0.0, width_px) y = _clamp(prim['y'], 0.0, height_px) w = prim['w'] h = prim['h'] if x + w > width_px: w = width_px - x if y + h > height_px: h = height_px - y if w < 0.0: w = 0.0 if h < 0.0: h = 0.0 prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h elif kind == 'line': prim['x1'] = _clamp(prim['x1'], 0.0, width_px) prim['y1'] = _clamp(prim['y1'], 0.0, height_px) prim['x2'] = _clamp(prim['x2'], 0.0, width_px) prim['y2'] = _clamp(prim['y2'], 0.0, height_px) else: if 'x' in prim: prim['x'] = _clamp(prim['x'], 0.0, width_px) if 'y' in prim: prim['y'] = _clamp(prim['y'], 0.0, height_px) return prim # --------------------------------------------------------------------- API def sketch(values, width_px, height_px): """(primitives, problems) -- the panel elevation as drawing primitives. `values` is the form's dict (see the module docstring for the keys read). `width_px` / `height_px` is the Canvas size the caller has to fit inside; the drawing is letterboxed into it preserving `length_ft / width_ft`. `problems` carries one plain-English sentence per issue found, whether or not it was survivable: - `length_ft` / `width_ft` missing, unreadable or <= 0 -- FATAL, this is the only case where `primitives` comes back `[]`. - a support outside `[0, length_ft]` -- non-fatal, still drawn flagged. - `thickness_in` missing -- non-fatal, annotated as "not entered". - no supports at all -- non-fatal, the elevation still draws. Nothing here is a defaulted value standing in for a real one; every problem names the field it is about. """ values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) if length_ft is None or length_ft <= 0: problems.append( 'Length / height (ft) is missing or not a positive number; the ' 'panel elevation 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 panel ' 'elevation is waiting on it.') if problems: return [], problems thickness_in = _num(values.get('thickness_in')) supports = _supports_of(values) openings_text = values.get('openings') mark = (values.get('mark') or '').strip() if values.get('mark') else '' design_number = ((values.get('design_number') or '').strip() if values.get('design_number') else '') primitives = [] margin = _margin(width_px, height_px) avail_w = width_px - 2.0 * margin avail_h = height_px - 2.0 * margin if avail_w < 1.0: avail_w = 1.0 if avail_h < 1.0: avail_h = 1.0 panel_aspect = length_ft / width_ft box_aspect = avail_w / avail_h if panel_aspect >= box_aspect: draw_w = avail_w draw_h = draw_w / panel_aspect else: draw_h = avail_h draw_w = draw_h * panel_aspect ox = margin + (avail_w - 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'}) # ---- title (mark / design number), only if the form gave one if mark or design_number: bits = [b for b in (mark, design_number) if b] primitives.append({'kind': 'text', 'x': 4.0, 'y': 4.0, 'text': ' / '.join(bits), 'role': 'title', 'anchor': 'start'}) # ---- supports, at their true proportional position along the length if not supports: problems.append( 'No support positions entered; the elevation cannot show them ' 'yet.') for pos in supports: flagged = pos < 0.0 or pos > length_ft if flagged: problems.append( 'Support at {0:.2f} ft is outside the panel length (0 to ' '{1:.2f} ft); shown clamped to the nearest end.' .format(pos, length_ft)) clamped = pos if clamped < 0.0: clamped = 0.0 elif clamped > length_ft: clamped = length_ft frac = clamped / length_ft sx = ox + frac * draw_w sy = oy + draw_h primitives.append({'kind': 'support', 'x': sx, 'y': sy, 'role': 'support', 'flagged': flagged}) primitives.append({'kind': 'text', 'x': sx, 'y': sy + 14.0, 'text': '{0:.2f} ft'.format(pos), 'role': 'support-label', 'anchor': 'middle'}) # ---- length dimension, below the panel dim_y = oy + draw_h + 30.0 primitives.append({'kind': 'line', 'x1': ox, 'y1': dim_y, 'x2': ox + draw_w, 'y2': dim_y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': dim_y + 12.0, 'text': 'L = {0:.2f} ft'.format(length_ft), 'role': 'dimension', 'anchor': 'middle'}) # ---- width dimension, left of the panel dim_x = ox - 30.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': 'W = {0:.2f} ft'.format(width_ft), 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical'}) # ---- thickness -- an elevation cannot show it geometrically if thickness_in is None: t_text = 'Thickness: not entered' problems.append( 'Thickness (in) is missing; noted on the drawing but not shown ' '-- an elevation cannot show thickness geometrically.') else: t_text = 'Thickness: {0:.2f} in'.format(thickness_in) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': max(margin / 2.0, 10.0), 'text': t_text, 'role': 'annotation', 'anchor': 'middle'}) # ---- openings -- never drawn as geometry; see module docstring if openings_text is not None and str(openings_text).strip(): n = _openings_count(openings_text) if n is not None: note = ('{0} opening(s) described on the form but not drawn ' '(position / size are not given in a drawable form).' .format(n)) else: note = ('Openings described on the form ({0!r}) are not drawn ' '-- position and size are not in a recognised form.' .format(str(openings_text).strip())) problems.append(note) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': oy + draw_h / 2.0, 'text': note, 'role': 'openings-note', 'anchor': 'middle'}) primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems # ============================================================= extension === # Everything below is additive. `sketch()` above is UNCHANGED -- same code, # same primitives, same problems -- because the button that renders these # primitives calls it today and nothing here may move that ground. The four # functions below are new entry points, same contract (pixel coords, origin # top-left, y down, everything clamped inside the box, scale-true or not # drawn, missing input is a first-class reported state, `values` is never # mutated -- see the module docstring above for the discipline all of this # inherits). # # NEW KEYS READ, all optional, all "not entered" when absent (see # `panelanalysisform.validate()`'s output dict): # openings_rows -> [{'type','pos_along','pos_along_from','length', # 'pos_cross','pos_cross_from','width','pos_vert', # 'height','label'}]. `type` is 'Opening', 'Reveal' or # 'Solid Zone'. See `_parse_opening_row` for the full # datum-resolution contract and the axis-assignment # assumption spelled out there -- it is a judgement call, # not something this module can verify on its own, and it # is flagged as such rather than asserted quietly. # strand_rows -> [{'count','depth_in','pull_pct'}], depth from the # panel bottom (the rail, in casting terms). `pull_pct` # is None when absent. # weight_lb, face_lifters, edge_lifters (free text), stages (list of # stage-name strings). # # LIFTER / DUNNAGE POSITIONS ARE NEVER DRAWN. `face_lifters` / `edge_lifters` # are free text and carry no position or size. Scattering a triangle/bar # glyph across the plan for them would be inventing a position nobody typed, # which the module docstring above already rules out for openings; the same # rule applies here with no exception. `plan()` below still draws the LEGEND # (the shape/fill key is static, not tied to any one marker's position) and # reports the free text as a note; the marker vocabulary (`role` in # `face-lifter` / `edge-lifter` / `dunnage`, each carrying `stage_fill`) is # documented and ready to receive row-shaped position data the day a future # agent adds it -- wire it in next to `_parse_opening_row` below. # ============================================================================ def drawing_display_scale(w, h, max_w=360.0, max_h=520.0): """The FIXED shrink factor a drawing's own declared `w` x `h` pixel size gets before it is displayed, so an oversized drawing (some are declared up to 900x260) fits an ordinary form column without the caller having to know the column width. WHY THIS IS PLAIN ARITHMETIC HERE AND NOT A WPF `Viewbox` ANY MORE: the button used to wrap each drawing's `Canvas` in a `Viewbox`, which computes its OWN shrink factor from whatever space WPF hands it at MEASURE time. The page's Ctrl+wheel zoom (`show_form`'s `ScaleTransform` as a `LayoutTransform` on the whole page) works by DEFLATING the available size it passes down to its children by `1 / scale` before measuring them, then INFLATING the resulting size back up by `scale` before reporting it to ITS OWN parent -- so everything under it renders `scale` times bigger, which is exactly how the surrounding text and boxes visibly zoom. But a `Viewbox` fits itself to WHATEVER space it is offered -- so at zoom=2 it is offered HALF the column width, shrinks the drawing to fit that HALVED width, and the ancestor transform then blows the (now half-sized) result back up by 2 -- net effect: exactly the pre-zoom pixel size, every time. The deflate and the inflate cancel EXACTLY for anything whose own rendered size is a function of "how much space am I given", which a `Viewbox` is by definition. Ordinary text does not have this problem because a `TextBlock`'s glyph size does not depend on the space it is offered (it wraps instead), so it has nothing to cancel. The fix is to make the shrink a FIXED transform instead of an adaptive one: compute the factor here, off the drawing's own w/h -- a plain number that does not change no matter what space WPF later offers -- and have the caller apply it as an ordinary `ScaleTransform`. A fixed transform on a descendant composes MULTIPLICATIVELY with the ancestor's zoom transform (like any two nested transforms), instead of being an adaptive function that the ancestor's own deflate/inflate step cancels out. Never grows a drawing already smaller than the target -- only ever shrinks (`min(1.0, ...)`), same intent as the `Viewbox`'s old `StretchDirection.DownOnly`: a small, precise drawing should not be blown up into something blurry just because there is spare column width. Uniform (same factor on both axes), matching `Stretch.Uniform`'s old aspect-preserving behaviour. `w` / `h` <= 0 (or missing) is not this function's business to judge -- it returns 1.0 (no shrink) rather than raising or dividing by zero; the caller already has a canvas of that declared size regardless of whether the size makes sense. """ try: w = float(w) h = float(h) except (TypeError, ValueError): return 1.0 if w <= 0.0 or h <= 0.0: return 1.0 return min(1.0, float(max_w) / w, float(max_h) / h) def _letterbox(a_ft, b_ft, avail_w, avail_h): """draw_w, draw_h -- `a_ft` x `b_ft` scaled uniformly (same px/ft on both axes) to fit inside `avail_w` x `avail_h`, preserving aspect ratio.""" aspect = a_ft / b_ft box_aspect = avail_w / avail_h if aspect >= box_aspect: draw_w = avail_w draw_h = draw_w / aspect else: draw_h = avail_h draw_w = draw_h * aspect return draw_w, draw_h _OPENING_ROLE_BY_TYPE = {'Opening': 'opening', 'Reveal': 'reveal', 'Solid Zone': 'solid-zone'} def _resolve_axis_range(pos, extent, total, frm, far_name): """(lo, hi) in ft from the NEAR edge of a `total`-long axis, given a position/extent that may have been typed measured from the FAR edge instead (`frm == far_name`). `None` when `pos` or `extent` is missing, or `extent` is non-positive -- never a guessed span.""" if pos is None or extent is None or extent <= 0.0: return None if frm == far_name: lo = total - pos - extent else: lo = pos return lo, lo + extent def _parse_opening_row(item, index, length_ft, width_ft, thickness_ft): """(row, problems) for one `openings_rows` entry. AXIS ASSIGNMENT (a judgement call, not verifiable from this module -- flagged in the caller's report, not just here): pos_along / length / pos_along_from (start/end) -> the length_ft axis (horizontal in every drawing). High confidence: 'along' unambiguously means along the panel's length. pos_vert / height (always measured from the BOTTOM -- there is no `_from` field for it, matching the `strand_rows.depth_in` convention already in this module) -> the width_ft axis (vertical in the front elevation; the same physical dimension is the "down the page" axis in `plan()`, because a wall panel's width_ft IS its height once erect and the panel's true footprint width while it is being handled flat -- same number, two drawings). pos_cross / width / pos_cross_from (left/right) -> the THROUGH-THICKNESS axis (horizontal in the side elevation and the cross-section slice, where the two faces of the panel are naturally "left" and "right"). Medium confidence -- this is the one call in this module that cannot be checked against anything else already in the codebase. A row missing `pos_along`/`length` cannot be placed on ANY drawing and is reported once, by index, with no primitive. Missing `pos_vert`/ `height` or `pos_cross`/`width` narrows what CAN be drawn (front/plan vs. side/cross-section respectively) without blocking the rest -- each caller checks the axis it needs and reports separately when it is absent, so one row can appear correctly in one drawing and be reported as not-drawn in another.""" if not isinstance(item, dict): return None, ['Opening row {0} is not a recognised row; skipped.' .format(index)] type_ = str(item.get('type') or 'Opening').strip() or 'Opening' label = str(item.get('label') or '').strip() problems = [] pos_along = _num(item.get('pos_along')) length_ext = _num(item.get('length')) along_from = item.get('pos_along_from') or 'start' along = _resolve_axis_range(pos_along, length_ext, length_ft, along_from, 'end') if along is None: problems.append( '{0} row {1} ({2}) has no along-length position/extent ' 'entered; not drawn on any view.' .format(type_, index, label or 'unlabeled')) return None, problems pos_vert = item.get('pos_vert') height_ext = item.get('height') vert = _resolve_axis_range( _num(pos_vert) if pos_vert is not None else None, _num(height_ext) if height_ext is not None else None, width_ft, 'start', 'end') # bottom-referenced only; no _from field pos_cross = item.get('pos_cross') width_ext = item.get('width') cross_from = item.get('pos_cross_from') or 'left' cross = None if thickness_ft is not None and thickness_ft > 0.0: cross = _resolve_axis_range( _num(pos_cross) if pos_cross is not None else None, _num(width_ext) if width_ext is not None else None, thickness_ft, cross_from, 'right') row = {'type': type_, 'role': _OPENING_ROLE_BY_TYPE.get(type_, 'opening'), 'label': label, 'index': index, 'along': along, 'vert': vert, 'cross': cross} return row, problems def _openings_rows(values, length_ft, width_ft, thickness_ft=None): """(rows, problems). `rows` is `None` when `values['openings_rows']` is not a list (caller should fall back to the free-text `openings` note where one exists); otherwise a list of parsed rows, one per entry that at least resolved an along-length position (see `_parse_opening_row`). """ raw = values.get('openings_rows') if not isinstance(raw, list): return None, [] rows = [] problems = [] for i, item in enumerate(raw): row, row_problems = _parse_opening_row( item, i + 1, length_ft, width_ft, thickness_ft) problems.extend(row_problems) if row is not None: rows.append(row) return rows, problems def _complement_segments(ranges, total): """(complement, covered) -- `covered` is `ranges` clamped to `[0, total]`, sorted and merged; `complement` is what is left over (the material NOT covered). Used both ways: as gap segments for a dimension chain between openings (front elevation) and as the solid segments of a cross-section slice once the covered ranges are punched out as voids.""" clean = [] for lo, hi in ranges: lo = _clamp(lo, 0.0, total) hi = _clamp(hi, 0.0, total) if hi > lo: clean.append((lo, hi)) clean.sort() covered = [] for lo, hi in clean: if covered and lo <= covered[-1][1]: covered[-1] = (covered[-1][0], max(covered[-1][1], hi)) else: covered.append((lo, hi)) complement = [] prev = 0.0 for lo, hi in covered: if lo > prev: complement.append((prev, lo)) prev = max(prev, hi) if total > prev: complement.append((prev, total)) return complement, covered def _opening_rect_primitives(rows, length_ft, width_ft, ox, oy, draw_w, draw_h): """Draws each row's `along` x `vert` extent to scale inside an already-laid-out length_ft x width_ft box (front elevation or plan -- both use this same axis pair). A row with `vert is None` cannot be placed on THIS drawing (it may still be usable on the side/cross-section view, which needs `cross` instead) and is reported, not guessed.""" primitives = [] problems = [] for row in rows: if row['vert'] is None: problems.append( '{0} row {1} ({2}) has no vertical position/extent entered; ' 'not drawn on this view.' .format(row['type'], row['index'], row['label'] or 'unlabeled')) continue x0, x1 = row['along'] y0, y1 = row['vert'] flagged = (x0 < 0.0 or y0 < 0.0 or x1 > length_ft or y1 > width_ft) if flagged: problems.append( '{0} {1} ({2}) falls partly or wholly outside the panel; ' 'shown clamped and flagged.' .format(row['type'], row['index'], row['label'] or 'unlabeled')) cx0 = _clamp(x0, 0.0, length_ft) cx1 = _clamp(x1, 0.0, length_ft) cy0 = _clamp(y0, 0.0, width_ft) cy1 = _clamp(y1, 0.0, width_ft) if cx1 <= cx0 or cy1 <= cy0: problems.append( '{0} {1} is entirely outside the panel; not drawn.' .format(row['type'], row['index'])) continue px_x = ox + (cx0 / length_ft) * draw_w px_w = ((cx1 - cx0) / length_ft) * draw_w px_y = oy + draw_h - (cy1 / width_ft) * draw_h px_h = ((cy1 - cy0) / width_ft) * draw_h primitives.append({'kind': 'rect', 'x': px_x, 'y': px_y, 'w': px_w, 'h': px_h, 'role': row['role'], 'flagged': flagged}) if row['label']: primitives.append({'kind': 'text', 'x': px_x + px_w / 2.0, 'y': px_y + px_h / 2.0, 'text': row['label'], 'role': row['role'] + '-label', 'anchor': 'middle'}) return primitives, problems def _gap_dimension_chain(rows, total, x1_fn, x2_fn, label_fmt, role='dimension'): """Dimension-line + text primitives for the gaps BETWEEN rows' `along` extents (front elevation's bottom chain / the vertical `vert` chain use this with different axis mappings supplied by the caller through `x1_fn`/`x2_fn`, which turn a (lo, hi) ft segment into pixel endpoints). Only rows with a resolved extent on the relevant axis contribute.""" primitives = [] if not rows: return primitives gaps, _covered = _complement_segments(rows, total) for lo, hi in gaps: if hi - lo <= 0.0: continue x1, y1 = x1_fn(lo) x2, y2 = x2_fn(hi) primitives.append({'kind': 'line', 'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, 'role': role}) mx = (x1 + x2) / 2.0 my = (y1 + y2) / 2.0 primitives.append({'kind': 'text', 'x': mx, 'y': my, 'text': label_fmt.format(hi - lo), 'role': role, 'anchor': 'middle'}) return primitives def _strand_rows_of(values): """(rows, problems). `rows` is `None` when `values['strand_rows']` is not a list; otherwise a list of `{'count','depth_in','pull_pct','index'}`. A row with no readable `depth_in` is dropped and reported -- there is no honest depth to draw it at. `count` is `None` when absent or unreadable (drawn as a single centred dot, reported). `pull_pct` is `None` when absent, never defaulted.""" raw = values.get('strand_rows') if not isinstance(raw, list): return None, [] rows = [] problems = [] for i, item in enumerate(raw): if not isinstance(item, dict): problems.append( 'Strand row {0} is not a recognised row; skipped.' .format(i + 1)) continue depth = _num(item.get('depth_in')) if depth is None: problems.append( 'Strand row {0} has no depth (in) entered; not drawn.' .format(i + 1)) continue count_raw = item.get('count') count_n = None if count_raw is not None: try: count_n = int(count_raw) except (TypeError, ValueError): count_n = None pull_raw = item.get('pull_pct') pull_n = _num(pull_raw) if pull_raw is not None else None rows.append({'count': count_n, 'depth_in': depth, 'pull_pct': pull_n, 'index': i + 1}) return rows, problems def _title_primitive(mark, design_number): mark = (mark or '').strip() if mark else '' design_number = (design_number or '').strip() if design_number else '' if not (mark or design_number): return None bits = [b for b in (mark, design_number) if b] return {'kind': 'text', 'x': 4.0, 'y': 4.0, 'text': ' / '.join(bits), 'role': 'title', 'anchor': 'start'} # --------------------------------------------------------------------- API def elevation(values, width_px, height_px): """(primitives, problems) -- FOUR drawings, matching the vendor reference this feature is modelled on: FRONT elevation + SIDE elevation + SECTION PROPERTIES (top row), and a CROSS SECTION slice (full width, below). `length_ft` / `width_ft` missing or non-positive is the hard stop for the whole function -- `([], problems)`, nothing letterboxed. Each of the other three degrades independently when ITS input is missing: side elevation and section properties both need `thickness_in`; the cross section needs it too, for the slice height. FRONT: the same picture `sketch()` draws, plus openings/reveals/solid zones drawn to scale from `openings_rows` (new row shape -- see the module note above `_parse_opening_row`), plus a dimension chain down the left (the gaps between rows' vertical extents) and across the bottom (the gaps between rows' along-length extents), drawn only when there is at least one row with the needed axis resolved. SIDE: thickness against the SAME px/ft scale as the front's width axis -- unchanged from the first cut of this function. SECTION PROPERTIES: computed for the GROSS rectangular section width_ft(in) x thickness_in -- Area, Cx, Cy, Izz, Iyy, Izy, Sb, St. Openings are NOT deducted (their effect varies along the length; a single set of properties cannot honestly represent that), and this module says so once in `problems` rather than pretending precision it does not have. Omitted entirely (no primitives) when `thickness_in` is missing -- there is nothing honest to compute. CROSS SECTION: a thin length_ft x thickness_ft slice with `Opening` rows punched out as literal gaps (drawn as separate solid segments, nothing filling the gap). `Reveal` / `Solid Zone` rows do not create a gap here (a reveal is partial-depth, not a through-cut; a solid zone by definition removes nothing) -- their count is reported instead so nothing is silently dropped.""" values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) if length_ft is None or length_ft <= 0: problems.append( 'Length / height (ft) is missing or not a positive number; the ' 'panel elevation 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 panel ' 'elevation is waiting on it.') if problems: return [], problems thickness_in = _num(values.get('thickness_in')) thickness_ft = thickness_in / 12.0 if thickness_in and thickness_in > 0 else None supports = _supports_of(values) rows, row_problems = _openings_rows(values, length_ft, width_ft, thickness_ft) problems.extend(row_problems) primitives = [] margin = _margin(width_px, height_px) avail_w_total = width_px - 2.0 * margin avail_h_total = height_px - 2.0 * margin if avail_w_total < 2.0: avail_w_total = 2.0 if avail_h_total < 1.0: avail_h_total = 1.0 gap = max(margin * 0.5, 10.0) # ---- vertical split: top row (front + side + section properties), # bottom band (cross section) have_thickness = thickness_ft is not None if have_thickness: top_h = avail_h_total * 0.62 cross_h = avail_h_total - top_h - gap else: top_h = avail_h_total cross_h = 0.0 problems.append( 'Thickness (in) is missing or not a positive number; the side ' 'elevation, section properties and cross section are all ' 'waiting on it.') # ---- horizontal split of the top row: front / side / section props props_alloc = avail_w_total * 0.22 if have_thickness else 0.0 side_alloc = avail_w_total * 0.20 if have_thickness else 0.0 front_alloc = avail_w_total - props_alloc - side_alloc - 2.0 * gap if front_alloc < 1.0: front_alloc = 1.0 draw_w, draw_h = _letterbox(length_ft, width_ft, front_alloc, top_h) ox = margin + (front_alloc - draw_w) / 2.0 oy = margin + (top_h - draw_h) / 2.0 scale = draw_w / length_ft # px/ft; == draw_h / width_ft primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, 'h': draw_h, 'role': 'panel'}) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': oy + draw_h + 46.0, 'text': 'Front Elevation', 'role': 'caption', 'anchor': 'middle'}) title = _title_primitive(values.get('mark'), values.get('design_number')) if title: primitives.append(title) if not supports: problems.append( 'No support positions entered; the elevation cannot show them ' 'yet.') for pos in supports: flagged = pos < 0.0 or pos > length_ft if flagged: problems.append( 'Support at {0:.2f} ft is outside the panel length (0 to ' '{1:.2f} ft); shown clamped to the nearest end.' .format(pos, length_ft)) clamped = _clamp(pos, 0.0, length_ft) frac = clamped / length_ft sx = ox + frac * draw_w sy = oy + draw_h primitives.append({'kind': 'support', 'x': sx, 'y': sy, 'role': 'support', 'flagged': flagged}) primitives.append({'kind': 'text', 'x': sx, 'y': sy + 14.0, 'text': '{0:.2f} ft'.format(pos), 'role': 'support-label', 'anchor': 'middle'}) primitives.append({'kind': 'text', 'x': sx, 'y': sy + 26.0, 'text': '{0:.3f}L'.format(frac), 'role': 'support-fraction', 'anchor': 'middle'}) dim_y = oy + draw_h + 30.0 primitives.append({'kind': 'line', 'x1': ox, 'y1': dim_y, 'x2': ox + draw_w, 'y2': dim_y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': dim_y + 12.0, 'text': 'L = {0:.2f} ft'.format(length_ft), 'role': 'dimension', 'anchor': 'middle'}) dim_x = ox - 30.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': 'W = {0:.2f} ft'.format(width_ft), 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical'}) if rows is None: openings_text = values.get('openings') if openings_text is not None and str(openings_text).strip(): n = _openings_count(openings_text) if n is not None: note = ('{0} opening(s) described on the form but not ' 'drawn (position / size are not given in a ' 'drawable form).'.format(n)) else: note = ('Openings described on the form ({0!r}) are not ' 'drawn -- position and size are not in a ' 'recognised form.' .format(str(openings_text).strip())) problems.append(note) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': oy + draw_h / 2.0, 'text': note, 'role': 'openings-note', 'anchor': 'middle'}) else: opening_prims, opening_problems = _opening_rect_primitives( rows, length_ft, width_ft, ox, oy, draw_w, draw_h) 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: primitives.extend(_gap_dimension_chain( along_ranges, length_ft, lambda lo: (ox + (lo / length_ft) * draw_w, dim_y + 26.0), lambda hi: (ox + (hi / length_ft) * draw_w, dim_y + 26.0), '{0:.2f} ft')) if vert_ranges: chain_x = dim_x - 30.0 primitives.extend(_gap_dimension_chain( 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')) # ---- side elevation: thickness against the SAME width_ft scale side_x0 = ox + draw_w + gap if not have_thickness: pass # already reported once above; nothing honest to draw else: side_draw_h = draw_h side_draw_w = thickness_ft * scale side_ox = side_x0 side_oy = oy primitives.append({'kind': 'rect', 'x': side_ox, 'y': side_oy, 'w': side_draw_w, 'h': side_draw_h, 'role': 'panel-side'}) primitives.append({'kind': 'text', 'x': side_ox + max(side_draw_w, 40.0) / 2.0, 'y': side_oy + side_draw_h + 46.0, 'text': 'Side Elevation', 'role': 'caption', 'anchor': 'middle'}) t_dim_y = side_oy + side_draw_h + 20.0 primitives.append({'kind': 'line', 'x1': side_ox, 'y1': t_dim_y, 'x2': side_ox + side_draw_w, 'y2': t_dim_y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': side_ox + side_draw_w / 2.0, 'y': t_dim_y + 12.0, 'text': 't = {0:.2f} in'.format(thickness_in), 'role': 'dimension', 'anchor': 'middle'}) # ---- section properties: gross rectangle width_ft(in) x thickness_in props_x0 = side_ox + max(side_draw_w, 40.0) + gap w_in = width_ft * 12.0 h_in = thickness_in area = w_in * h_in cx = w_in / 2.0 cy = h_in / 2.0 izz = w_in * (h_in ** 3) / 12.0 iyy = h_in * (w_in ** 3) / 12.0 izy = 0.0 sb = izz / (h_in / 2.0) st = sb props = [ ('Area, in²', area), ('Width, in', w_in), ('Height, in', h_in), ('Cx, in', cx), ('Cy, in', cy), ('Izz, in⁴', izz), ('Iyy, in⁴', iyy), ('Izy, in⁴', izy), ('Sb, in³', sb), ('St, in³', st), ] props_y = oy for name, val in props: primitives.append({'kind': 'text', 'x': props_x0, 'y': props_y, 'text': '{0}: {1:.2f}'.format(name, val), 'role': 'section-property', 'anchor': 'start'}) props_y += 18.0 primitives.append({'kind': 'text', 'x': props_x0 + max(props_alloc - gap, 10.0) / 2.0, 'y': props_y + 28.0, 'text': 'Section Properties', 'role': 'caption', 'anchor': 'middle'}) problems.append( 'Section properties are for the gross rectangular section ' '(width x thickness) with openings NOT deducted -- their ' 'effect varies along the length and a single set of numbers ' 'cannot represent that honestly.') t_label = ('Thickness: {0:.2f} in'.format(thickness_in) if thickness_in is not None else 'Thickness: not entered') primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': max(margin / 2.0, 10.0), 'text': t_label, 'role': 'annotation', 'anchor': 'middle'}) # ---- cross section: length_ft x thickness_ft slice, Opening rows as gaps if have_thickness and cross_h > 1.0: cross_oy0 = margin + top_h + gap cross_draw_w, cross_draw_h = _letterbox( length_ft, thickness_ft, avail_w_total, cross_h) cross_ox = margin + (avail_w_total - cross_draw_w) / 2.0 cross_oy = cross_oy0 + (cross_h - cross_draw_h) / 2.0 opening_along = [r['along'] for r in (rows or []) if r['type'] == 'Opening'] solids, _covered = _complement_segments(opening_along, length_ft) \ if opening_along else ([(0.0, length_ft)], []) for lo, hi in solids: sx = cross_ox + (lo / length_ft) * cross_draw_w sw = ((hi - lo) / length_ft) * cross_draw_w primitives.append({'kind': 'rect', 'x': sx, 'y': cross_oy, 'w': sw, 'h': cross_draw_h, 'role': 'panel-crosssection'}) primitives.append({'kind': 'line', 'x1': cross_ox, 'y1': cross_oy - 14.0, 'x2': cross_ox + cross_draw_w, 'y2': cross_oy - 14.0, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': cross_ox + cross_draw_w / 2.0, 'y': cross_oy - 26.0, 'text': 'L = {0:.2f} ft'.format(length_ft), 'role': 'dimension', 'anchor': 'middle'}) primitives.append({'kind': 'text', 'x': cross_ox + cross_draw_w + 14.0, 'y': cross_oy + cross_draw_h / 2.0, 'text': 't = {0:.2f} in'.format(thickness_in), 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical'}) primitives.append({'kind': 'text', 'x': cross_ox + cross_draw_w / 2.0, 'y': cross_oy + cross_draw_h + 20.0, 'text': 'Cross Section', 'role': 'caption', 'anchor': 'middle'}) n_other = len([r for r in (rows or []) if r['type'] != 'Opening']) if n_other: problems.append( '{0} reveal/solid-zone row(s) are not shown as gaps on the ' 'cross section (a reveal is partial-depth and a solid zone ' 'removes nothing, so neither is a through-cut here).' .format(n_other)) primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems # fill vocabulary for plan()'s lifter/dunnage legend and, once row-shaped # position data exists, for each marker's `stage_fill` field -- verbatim # names, so a caller can map each one to a distinct swatch. STAGE_FILLS = ('Not Specified', 'Stripping', 'Erection', 'Stripping + Erection', 'Transportation') def plan(values, width_px, height_px): """(primitives, problems) -- the panel PLAN view ("Lifter and Dunnage Detail"): outline to scale, openings/reveals/solid zones to scale, centroid marked (area-weighted against `Opening` rows only -- a reveal is partial-depth and a solid zone removes nothing, so neither reduces the area), weight and a 3-axis centroid block printed. A static LEGEND (shape key + the five `STAGE_FILLS` names, each as its own swatch) is always drawn -- it explains the vocabulary even before any marker uses it. Lifter/dunnage MARKER positions are still never invented -- there is no row-shaped position data for them in the contract yet, only free text (`face_lifters` / `edge_lifters`) -- so `face_lifters` / `edge_lifters` become a `lifters-note` instead of a scattered glyph. The marker vocabulary (`role` in `face-lifter` / `edge-lifter` / `dunnage`, each carrying a `stage_fill` from `STAGE_FILLS`) is defined and ready for row-shaped data the day it exists.""" values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) if length_ft is None or length_ft <= 0: problems.append( 'Length / height (ft) is missing or not a positive number; ' 'the plan view 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 plan ' 'view is waiting on it.') if problems: return [], problems weight_lb = _num(values.get('weight_lb')) thickness_in = _num(values.get('thickness_in')) primitives = [] margin = _margin(width_px, height_px) avail_w = width_px - 2.0 * margin avail_h = height_px - 2.0 * margin if avail_w < 1.0: avail_w = 1.0 if avail_h < 1.0: avail_h = 1.0 legend_w = min(avail_w * 0.22, 160.0) panel_avail_w = max(avail_w - legend_w - max(margin * 0.5, 10.0), 1.0) draw_w, draw_h = _letterbox(length_ft, width_ft, panel_avail_w, avail_h) ox = margin + legend_w + max(margin * 0.5, 10.0) + \ (panel_avail_w - 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-plan'}) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': max(margin / 2.0, 10.0), 'text': 'Lifter and Dunnage Detail', 'role': 'caption', 'anchor': 'middle'}) title = _title_primitive(values.get('mark'), values.get('design_number')) if title: primitives.append(title) rows, row_problems = _openings_rows(values, length_ft, width_ft, None) problems.extend(row_problems) if rows: opening_prims, opening_problems = _opening_rect_primitives( rows, length_ft, width_ft, ox, oy, draw_w, draw_h) primitives.extend(opening_prims) problems.extend(opening_problems) # centroid: geometric center minus each `Opening` row's area-weighted # contribution -- a Reveal is partial-depth and a Solid Zone removes # nothing, so neither one reduces the plan-view area. total_area = length_ft * width_ft mx = total_area * (length_ft / 2.0) mz = total_area * (width_ft / 2.0) net_area = total_area if rows: for row in rows: if row['type'] != 'Opening' or row['vert'] is None: continue x0, x1 = row['along'] y0, y1 = row['vert'] cx0 = _clamp(x0, 0.0, length_ft) cx1 = _clamp(x1, 0.0, length_ft) cy0 = _clamp(y0, 0.0, width_ft) cy1 = _clamp(y1, 0.0, width_ft) if cx1 <= cx0 or cy1 <= cy0: continue area = (cx1 - cx0) * (cy1 - cy0) mx -= area * ((cx0 + cx1) / 2.0) mz -= area * ((cy0 + cy1) / 2.0) net_area -= area if net_area > 0.0: cg_x_ft = mx / net_area cg_z_ft = mz / net_area else: cg_x_ft = length_ft / 2.0 cg_z_ft = width_ft / 2.0 problems.append( 'Openings account for the whole panel area; centroid shown at ' 'the panel geometric center instead.') cg_px_x = ox + (cg_x_ft / length_ft) * draw_w cg_px_y = oy + draw_h - (cg_z_ft / width_ft) * draw_h primitives.append({'kind': 'marker', 'x': cg_px_x, 'y': cg_px_y, 'role': 'centroid', 'glyph': 'cross'}) primitives.append({'kind': 'text', 'x': cg_px_x, 'y': cg_px_y - 12.0, 'text': 'CG', 'role': 'annotation', 'anchor': 'middle'}) # ---- weight / centroid block (right of the panel) block_x = ox + draw_w + max(margin * 0.5, 10.0) block_y = oy if weight_lb is None: w_text = 'Panel Weight: not entered' problems.append( 'Weight (lb) is not entered; shown as not entered on the plan.') else: w_text = 'Panel Weight: {0:.1f} k'.format(weight_lb / 1000.0) primitives.append({'kind': 'text', 'x': block_x, 'y': block_y, 'text': w_text, 'role': 'annotation', 'anchor': 'start'}) primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 18.0, 'text': 'Centroid,', 'role': 'annotation', 'anchor': 'start'}) primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 36.0, 'text': 'X: {0:.2f} ft from start'.format(cg_x_ft), 'role': 'annotation', 'anchor': 'start'}) if thickness_in is not None and thickness_in > 0: primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 54.0, 'text': 'Y: {0:.2f} ft from bottom' .format(thickness_in / 24.0), 'role': 'annotation', 'anchor': 'start'}) else: primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 54.0, 'text': 'Y: not entered (thickness unknown)', 'role': 'annotation', 'anchor': 'start'}) problems.append( 'Thickness (in) is not entered; the through-thickness centroid ' '(Y) cannot be shown and assumes a solid section when it is.') primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 72.0, 'text': 'Z: {0:.2f} ft from left'.format(cg_z_ft), 'role': 'annotation', 'anchor': 'start'}) # ---- legend: Shapes column + Fills column, left of the panel leg_x = margin leg_y = margin primitives.append({'kind': 'text', 'x': leg_x, 'y': leg_y, 'text': 'Shapes', 'role': 'legend-heading', 'anchor': 'start'}) primitives.append({'kind': 'text', 'x': leg_x, 'y': leg_y + 16.0, 'text': '△ Face Lifter', 'role': 'legend-glyph', 'anchor': 'start'}) primitives.append({'kind': 'text', 'x': leg_x, 'y': leg_y + 32.0, 'text': '‖ Dunnage', 'role': 'legend-glyph', 'anchor': 'start'}) fill_y = leg_y + 56.0 primitives.append({'kind': 'text', 'x': leg_x, 'y': fill_y, 'text': 'Fills', 'role': 'legend-heading', 'anchor': 'start'}) for i, fill_name in enumerate(STAGE_FILLS): row_y = fill_y + 16.0 + i * 16.0 primitives.append({'kind': 'swatch', 'x': leg_x, 'y': row_y, 'role': 'legend-fill', 'fill': fill_name}) primitives.append({'kind': 'text', 'x': leg_x + 16.0, 'y': row_y, 'text': fill_name, 'role': 'legend-fill-label', 'anchor': 'start'}) # ---- lifters / dunnage: position is never invented (see module note) face_text = values.get('face_lifters') edge_text = values.get('edge_lifters') bits = [] if face_text is not None and str(face_text).strip(): bits.append('face lifters (△): {0}'.format(str(face_text).strip())) if edge_text is not None and str(edge_text).strip(): bits.append('edge lifters (○): {0}'.format(str(edge_text).strip())) if bits: note = ('Lifter positions are not entered on the form, so they are ' 'not drawn on the plan (a guessed position would be wrong): ' + '; '.join(bits) + '.') problems.append(note) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': oy + draw_h + 20.0, 'text': note, 'role': 'lifters-note', 'anchor': 'middle'}) stages = values.get('stages') if isinstance(stages, list) and stages: stg_text = 'Stages: ' + ', '.join([str(s) for s in stages]) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': oy + draw_h + (36.0 if bits else 20.0), 'text': stg_text, 'role': 'note', 'anchor': 'middle'}) problems.append( 'Stage names were entered, but no lifter/dunnage row carries a ' 'position yet, so nothing can be tied to a stage_fill; the ' 'stage names are listed as text only.') primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems def section(values, width_px, height_px): """(primitives, problems) -- the reinforcement CROSS-SECTION (panel outline to scale in width x thickness, strand rows as centred dot groups at their true depth from the bottom/rail, a dimension chain from the bottom to each row) plus, when `length_ft` is also given, a full-length elevation showing each strand row as a line at its true depth across the whole length. `width_ft` / `thickness_in` missing or non-positive is the hard stop -- there is no honest cross-section without both. Horizontal position within a row is NEVER invented: `strand_rows` carries only `depth_in`, not a horizontal spacing, so every row is drawn as a group centred on the panel's horizontal midline, never spread across the true width as if position were known. Likewise no debond length is in the data contract, so the full-length view draws each strand full length and says so in `problems` rather than implying it knows where bonding stops.""" values = values or {} problems = [] width_ft = _num(values.get('width_ft')) thickness_in = _num(values.get('thickness_in')) if width_ft is None or width_ft <= 0: problems.append( 'Width (ft) is missing or not a positive number; the ' 'reinforcement cross-section is waiting on it.') if thickness_in is None or thickness_in <= 0: problems.append( 'Thickness (in) is missing or not a positive number; the ' 'reinforcement cross-section is waiting on it.') if problems: return [], problems thickness_ft = thickness_in / 12.0 length_ft = _num(values.get('length_ft')) # optional -- full-length view only primitives = [] margin = _margin(width_px, height_px) avail_w = width_px - 2.0 * margin avail_h_total = height_px - 2.0 * margin if avail_w < 1.0: avail_w = 1.0 if avail_h_total < 1.0: avail_h_total = 1.0 gap = max(margin * 0.5, 10.0) have_length = length_ft is not None and length_ft > 0 if have_length: cross_alloc_h = avail_h_total * 0.45 elev_alloc_h = avail_h_total - cross_alloc_h - gap else: cross_alloc_h = avail_h_total elev_alloc_h = 0.0 problems.append( 'Length (ft) is not entered; the full-length strand elevation ' 'is not drawn, only the cross-section.') draw_w, draw_h = _letterbox(width_ft, thickness_ft, avail_w, cross_alloc_h) ox = margin + (avail_w - draw_w) / 2.0 oy = margin + (cross_alloc_h - draw_h) / 2.0 primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, 'h': draw_h, 'role': 'panel-section'}) # -16, not -10: a caption drawn -10px above the outline's top edge, at # this text's own ~13px line height, still puts its BOTTOM 3px inside # the outline -- the exact "text overlaps the drawing" the owner named. # -16 clears it with a couple of pixels to spare. primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': max(oy - 16.0, 0.0), 'text': 'CROSS-SECTION', 'role': 'note', 'anchor': 'middle'}) # A Sandwich draws its layers to scale: the bottom wythe at the rail, # the insulation band, the top wythe -- each labelled with its # thickness. Only when the three add up to the panel thickness (within # 1/8 in); a layering that does not fit the thickness is not one to # draw, so the section then stays one outline and a problem says why. section_type = str(values.get('section_type') or '').strip().lower() sandwich_drawn = False if section_type == 'sandwich': top_in = _num(values.get('wythe_top_in')) ins_in = _num(values.get('insulation_in')) bot_in = _num(values.get('wythe_bottom_in')) layers = (top_in, ins_in, bot_in) if any(v is None or v <= 0 for v in layers): problems.append( 'Sandwich section: the wythe / insulation / 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: sandwich_drawn = True 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'}) scale = draw_h / thickness_ft # px/ft along thickness bottom_y = oy + draw_h # panel bottom == the rail rows, row_problems = _strand_rows_of(values) problems.extend(row_problems) if not rows: problems.append('No strand data entered; the cross-section is ' 'drawn with no strands.') rows = [] for row in rows: depth_in = row['depth_in'] depth_ft = depth_in / 12.0 flagged = depth_ft < 0.0 or depth_ft > thickness_ft if flagged: problems.append( 'Strand row {0} at {1:.2f} in from the bottom is outside ' 'the panel thickness (0 to {2:.2f} in); shown clamped and ' 'flagged.'.format(row['index'], depth_in, thickness_in)) clamped_depth_ft = _clamp(depth_ft, 0.0, thickness_ft) y = bottom_y - clamped_depth_ft * scale dim_x = ox - 20.0 - (row['index'] - 1) * 14.0 primitives.append({'kind': 'line', 'x1': dim_x, 'y1': bottom_y, 'x2': dim_x, 'y2': y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': dim_x - 4.0, 'y': (bottom_y + y) / 2.0, 'text': '{0:.2f} in'.format(depth_in), 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical'}) count = row['count'] if row['count'] and row['count'] > 0 else 1 if row['count'] is None: problems.append( 'Strand row {0} has no count entered; drawn as a single ' 'centred dot at its depth.'.format(row['index'])) dot_count = count if count <= 20 else 20 if count > 20: problems.append( 'Strand row {0} has {1} strands; only 20 dots drawn ' '(the label carries the true count) to keep the drawing ' 'readable.'.format(row['index'], count)) spacing = 6.0 total_w = (dot_count - 1) * spacing start_x = ox + draw_w / 2.0 - total_w / 2.0 for i in range(dot_count): primitives.append({'kind': 'dot', 'x': start_x + i * spacing, 'y': y, 'role': 'strand', 'flagged': flagged}) label_bits = [] if row['count'] is not None: label_bits.append('{0} strand(s)'.format(row['count'])) label_bits.append('@ {0:.2f} in'.format(depth_in)) if row['pull_pct'] is not None: label_bits.append('{0:.0f}% pull'.format(row['pull_pct'])) # OUTSIDE the outline, to the right, at the row's own depth height -- # centred-on-the-dot used to put this text ON TOP OF the outline (and # the dots) whenever a row sat near the top of the section, exactly # the overlap the owner reported. A further offset when the sandwich # layers are also drawn keeps this out of THEIR label column (which # sits at the same `ox + draw_w + 6` x) instead of stacking on it. label_x = ox + draw_w + (100.0 if sandwich_drawn else 10.0) primitives.append({'kind': 'text', 'x': label_x, 'y': y - 5.0, 'text': ', '.join(label_bits), 'role': 'strand-label', 'anchor': 'start'}) if rows: problems.append( 'Strand horizontal spacing was not entered; each row is drawn ' 'as a centred group at its true depth, not at real positions ' 'across the width.') if have_length and rows: elev_oy = margin + cross_alloc_h + gap elev_draw_w, elev_draw_h = _letterbox( length_ft, thickness_ft, avail_w, elev_alloc_h) elev_ox = margin + (avail_w - elev_draw_w) / 2.0 elev_oy2 = elev_oy + (elev_alloc_h - elev_draw_h) / 2.0 primitives.append({'kind': 'rect', 'x': elev_ox, 'y': elev_oy2, 'w': elev_draw_w, 'h': elev_draw_h, 'role': 'panel-elevation-thin'}) primitives.append({'kind': 'text', 'x': elev_ox + elev_draw_w / 2.0, 'y': max(elev_oy2 - 16.0, 0.0), 'text': 'STRANDS -- FULL LENGTH ' '(debond not entered)', 'role': 'note', 'anchor': 'middle'}) elev_bottom = elev_oy2 + elev_draw_h elev_scale = elev_draw_h / thickness_ft for row in rows: depth_ft = _clamp(row['depth_in'] / 12.0, 0.0, thickness_ft) ly = elev_bottom - depth_ft * elev_scale primitives.append({'kind': 'line', 'x1': elev_ox, 'y1': ly, 'x2': elev_ox + elev_draw_w, 'y2': ly, 'role': 'strand-line'}) problems.append( 'Debond regions are not entered; strands are drawn full ' 'length, which assumes fully bonded unless the design says ' 'otherwise.') primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems def weight_diagram(values, width_px, height_px): """(primitives, problems) -- the beam-sketch load diagram: a horizontal beam to scale over `length_ft`, support markers at their true proportional position, the CG marked (area-weighted against any valid `openings_rows`, else the panel mid-length), and dimensioned moment arms from each panel end to the CG and from each support to the CG. `length_ft` missing or non-positive is the hard stop. A missing `weight_lb` does not block the geometry -- the CG's X position comes from panel/opening geometry, not from the weight's magnitude -- but is reported and printed as 'not entered' rather than a faked pound value.""" values = values or {} problems = [] length_ft = _num(values.get('length_ft')) if length_ft is None or length_ft <= 0: problems.append( 'Length / height (ft) is missing or not a positive number; ' 'the weight diagram is waiting on it.') return [], problems width_ft = _num(values.get('width_ft')) weight_lb = _num(values.get('weight_lb')) supports = _supports_of(values) primitives = [] margin = _margin(width_px, height_px) avail_w = width_px - 2.0 * margin avail_h = height_px - 2.0 * margin if avail_w < 1.0: avail_w = 1.0 if avail_h < 1.0: avail_h = 1.0 beam_w = avail_w beam_x0 = margin beam_y = margin + avail_h * 0.45 primitives.append({'kind': 'line', 'x1': beam_x0, 'y1': beam_y, 'x2': beam_x0 + beam_w, 'y2': beam_y, 'role': 'beam'}) title = _title_primitive(values.get('mark'), values.get('design_number')) if title: primitives.append(title) if not supports: problems.append( 'No support positions entered; the weight diagram cannot show ' 'them yet.') support_data = [] # (typed pos, clamped pos, pixel x) for pos in supports: flagged = pos < 0.0 or pos > length_ft if flagged: problems.append( 'Support at {0:.2f} ft is outside the panel length (0 to ' '{1:.2f} ft); shown clamped to the nearest end.' .format(pos, length_ft)) clamped = _clamp(pos, 0.0, length_ft) sx = beam_x0 + (clamped / length_ft) * beam_w support_data.append((pos, clamped, sx)) primitives.append({'kind': 'support', 'x': sx, 'y': beam_y, 'role': 'support', 'flagged': flagged}) primitives.append({'kind': 'text', 'x': sx, 'y': beam_y + 14.0, 'text': '{0:.2f} ft'.format(pos), 'role': 'support-label', 'anchor': 'middle'}) # ---- CG position along the length rows, row_problems = _openings_rows(values, length_ft, width_ft, None) problems.extend(row_problems) cg_x_ft = length_ft / 2.0 opening_rows = [r for r in (rows or []) if r['type'] == 'Opening'] if opening_rows and width_ft is not None and width_ft > 0: total_area = length_ft * width_ft mx = total_area * (length_ft / 2.0) net_area = total_area for row in opening_rows: if row['vert'] is None: continue x0, x1 = row['along'] y0, y1 = row['vert'] cx0 = _clamp(x0, 0.0, length_ft) cx1 = _clamp(x1, 0.0, length_ft) cy0 = _clamp(y0, 0.0, width_ft) cy1 = _clamp(y1, 0.0, width_ft) if cx1 <= cx0 or cy1 <= cy0: continue area = (cx1 - cx0) * (cy1 - cy0) mx -= area * ((cx0 + cx1) / 2.0) net_area -= area if net_area > 0.0: cg_x_ft = mx / net_area else: problems.append( 'Openings account for the whole panel area; CG shown at ' 'panel mid-length instead.') elif opening_rows and (width_ft is None or width_ft <= 0): problems.append( 'Openings are entered but width (ft) is not; the CG shown ' 'assumes uniform weight along the length (openings not ' 'accounted for).') cg_x = beam_x0 + (cg_x_ft / length_ft) * beam_w cg_y = beam_y primitives.append({'kind': 'marker', 'x': cg_x, 'y': cg_y - 20.0, 'role': 'cg', 'glyph': 'diamond'}) primitives.append({'kind': 'line', 'x1': cg_x, 'y1': cg_y - 20.0, 'x2': cg_x, 'y2': cg_y, 'role': 'cg-lead'}) if weight_lb is None: w_text = 'Weight: not entered' problems.append( 'Weight (lb) is not entered; shown as not entered on the ' 'diagram.') else: w_text = 'Weight: {0:.0f} lb'.format(weight_lb) primitives.append({'kind': 'text', 'x': cg_x, 'y': cg_y - 32.0, 'text': w_text, 'role': 'annotation', 'anchor': 'middle'}) # ---- moment arms: panel ends to CG dim_y = beam_y + 34.0 primitives.append({'kind': 'line', 'x1': beam_x0, 'y1': dim_y, 'x2': cg_x, 'y2': dim_y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': (beam_x0 + cg_x) / 2.0, 'y': dim_y + 12.0, 'text': '{0:.2f} ft'.format(cg_x_ft), 'role': 'dimension', 'anchor': 'middle'}) dim_y2 = dim_y + 26.0 primitives.append({'kind': 'line', 'x1': cg_x, 'y1': dim_y2, 'x2': beam_x0 + beam_w, 'y2': dim_y2, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': (cg_x + beam_x0 + beam_w) / 2.0, 'y': dim_y2 + 12.0, 'text': '{0:.2f} ft'.format(length_ft - cg_x_ft), 'role': 'dimension', 'anchor': 'middle'}) # ---- moment arms: each support to CG for idx, (pos, clamped, sx) in enumerate(support_data): arm_ft = abs(clamped - cg_x_ft) ady = beam_y - 20.0 - idx * 14.0 lo, hi = (sx, cg_x) if sx <= cg_x else (cg_x, sx) primitives.append({'kind': 'line', 'x1': lo, 'y1': ady, 'x2': hi, 'y2': ady, 'role': 'moment-arm'}) primitives.append({'kind': 'text', 'x': (lo + hi) / 2.0, 'y': ady - 8.0, 'text': '{0:.2f} ft'.format(arm_ft), 'role': 'moment-arm-label', 'anchor': 'middle'}) primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems # =========================================================== extents_* === # The Concrete Extents section (`vibe.pa_extents`) does not want `elevation`'s # supports/side-view/section-properties, and it does not want `section`'s # strand dots and labels -- Concrete Extents collects no reinforcement data # of its own, but `values` is the WHOLE form's dict, and `section()` draws # whatever `strand_rows` it finds regardless of which page asked for the # picture. Calling `section()` from the Concrete Extents page meant that a # panel with reinforcement entered under a DIFFERENT tab showed strand dots # and overlapping labels on a page that never asked to see them -- the # "looks like garbage" the owner reported, 2026-09-03. These two functions # are the plain, honest picture that page actually asked for: the member's # own concrete edge, its openings, and nothing it did not enter here. # ============================================================================ def extents_outline(values, width_px, height_px): """(primitives, problems) -- the Concrete Extents "outline" drawing: the panel's own length x width edge, TWO overall dimensions (length along the bottom, width along the left), both OUTSIDE the outline, and any openings/reveals/solid-zones drawn to scale with a dimension chain locating each one along the length (bottom) and across the width (left) -- reusing `_opening_rect_primitives` / `_gap_dimension_chain` exactly as `elevation()`'s front view does. No supports, no side view, no section properties: "just draw the member's concrete edge" (owner, 2026-09-03), not the full handling picture `elevation()` draws for the Structural Model page. `length_ft` / `width_ft` missing or non-positive is the hard stop, same rule as every other drawing in this module.""" values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _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 [], problems primitives = [] margin = _margin(width_px, height_px) avail_w = width_px - 2.0 * margin avail_h = height_px - 2.0 * margin if avail_w < 1.0: avail_w = 1.0 if avail_h < 1.0: avail_h = 1.0 # Room reserved OUTSIDE the outline for the two overall dimensions and, # when there are openings, their locating chains one step further out -- # the owner's "dimension chains OUTSIDE the outline with an offset". dim_room = 48.0 draw_w, draw_h = _letterbox(length_ft, width_ft, max(avail_w - dim_room, 1.0), max(avail_h - dim_room, 1.0)) ox = margin + dim_room + (avail_w - dim_room - draw_w) / 2.0 oy = margin + (avail_h - dim_room - draw_h) / 2.0 primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, 'h': draw_h, 'role': 'panel'}) title = _title_primitive(values.get('mark'), values.get('design_number')) if title: primitives.append(title) # ---- two overall dimensions, OUTSIDE the outline dim_y = oy + draw_h + 20.0 primitives.append({'kind': 'line', 'x1': ox, 'y1': dim_y, 'x2': ox + draw_w, 'y2': dim_y, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': dim_y + 12.0, 'text': 'L = {0:.2f} ft'.format(length_ft), 'role': 'dimension', 'anchor': 'middle'}) 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': 'W = {0:.2f} ft'.format(width_ft), 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical'}) # ---- openings/reveals/solid-zones, to scale, plus locating chains rows, row_problems = _openings_rows(values, length_ft, width_ft, None) problems.extend(row_problems) if rows: opening_prims, opening_problems = _opening_rect_primitives( rows, length_ft, width_ft, ox, oy, draw_w, draw_h) 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: primitives.extend(_gap_dimension_chain( along_ranges, length_ft, lambda lo: (ox + (lo / length_ft) * draw_w, dim_y + 26.0), lambda hi: (ox + (hi / length_ft) * draw_w, dim_y + 26.0), '{0:.2f} ft')) if vert_ranges: chain_x = dim_x - 30.0 primitives.extend(_gap_dimension_chain( 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')) else: openings_text = values.get('openings') if openings_text is not None and str(openings_text).strip(): note = ('Openings/reveals were entered as plain text, not the ' 'structured rows this drawing needs to locate them: ' '{0!r}'.format(str(openings_text).strip())) problems.append(note) primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems def extents_section(values, width_px, height_px): """(primitives, problems) -- the Concrete Extents "section" drawing: the panel's width x thickness outline, a thickness dimension OUTSIDE the outline (to the left), and, for a Sandwich section, the wythe / insulation layers drawn to scale with their thickness labelled OUTSIDE the outline (to the right) -- the same layer geometry `section()` draws for the Reinforcement page, WITHOUT `section()`'s strand dots and labels, which Concrete Extents has no reinforcement data of its own to draw and does not want (see the module note above this function). `width_ft` / `thickness_in` missing or non-positive is the hard stop -- same rule as every other drawing in this module.""" values = values or {} problems = [] width_ft = _num(values.get('width_ft')) thickness_in = _num(values.get('thickness_in')) if width_ft is None or width_ft <= 0: problems.append( 'Width (ft) is missing or not a positive number; the 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 ' 'section is waiting on it.') if problems: return [], problems thickness_ft = thickness_in / 12.0 primitives = [] margin = _margin(width_px, height_px) avail_w = width_px - 2.0 * margin avail_h = height_px - 2.0 * margin if avail_w < 1.0: avail_w = 1.0 if avail_h < 1.0: avail_h = 1.0 # Room reserved OUTSIDE the outline: the thickness dimension to the # left, the (possible) sandwich layer labels to the right -- kept in # separate columns so the two never land on each other. left_room = 40.0 right_room = 90.0 draw_w, draw_h = _letterbox( 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'}) section_type = str(values.get('section_type') or '').strip().lower() if section_type == 'sandwich': top_in = _num(values.get('wythe_top_in')) ins_in = _num(values.get('insulation_in')) bot_in = _num(values.get('wythe_bottom_in')) layers = (top_in, ins_in, bot_in) if any(v is None or v <= 0 for v in layers): problems.append( 'Sandwich section: the wythe / insulation / wythe ' 'thicknesses are not all entered; the 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'}) # ---- thickness dimension, OUTSIDE the outline, to the LEFT (the wythe # labels, when drawn, own the right side -- see `right_room` above) 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'}) primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems