# -*- 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 _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'}) primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': max(oy - 10.0, 0.0), 'text': 'CROSS-SECTION', 'role': 'note', 'anchor': 'middle'}) 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'])) label_y = y - 10.0 if y - 10.0 > oy else y + 10.0 primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0, 'y': label_y, 'text': ', '.join(label_bits), 'role': 'strand-label', 'anchor': 'middle'}) 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 - 10.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