// SVG drawings for Panel Analysis's five geometry blocks, plus the pure // `sectionAt()` cut-profile function, reimplemented byte-for-byte from // `vibe.pa_sketch_extents.section_at()` per docs/pa-web-spec.md section 6 -- // the two must always agree, not just on the panels covered by whatever // tests happen to exist. // // Everything here works off real-unit numbers (ft, in) straight out of the // spec's `geometry` dict -- no pixels arrive from the server, all layout and // scaling happens in here. Plain ES2017, no modules, no dependencies other // than window.Units (units.js, loaded first). (function () { 'use strict'; var NS = 'http://www.w3.org/2000/svg'; // --------------------------------------------------------- small helpers function safeNum(value) { if (value === null || value === undefined) return null; if (typeof value === 'boolean') return null; if (typeof value === 'number') return isNaN(value) ? null : value; var s = String(value).trim(); if (s === '') return null; var n = parseFloat(s); return isNaN(n) ? null : n; } function clamp(v, lo, hi) { if (v < lo) return lo; if (v > hi) return hi; return v; } function el(name, attrs, parent) { var e = document.createElementNS(NS, name); if (attrs) { Object.keys(attrs).forEach(function (k) { if (attrs[k] !== null && attrs[k] !== undefined) { e.setAttribute(k, attrs[k]); } }); } if (parent) parent.appendChild(e); return e; } function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } // A nominal-coordinate SVG root sized by the block's own aspect ratio -- // width always "fills the container" via width:100%, height follows from // aspect the way every drawing block in the spec declares it. function svgRoot(container, aspect, nominalW) { nominalW = nominalW || 1000; var h = Math.max(60, Math.round(nominalW * (aspect || 0.3))); clear(container); // `aspect-ratio` (not a bare height:auto SVG attribute -- Chrome does // NOT reliably derive height from viewBox alone on a replaced // element, and falls back to a tall default box, letterboxing the // drawing) is what actually makes the rendered box track the block's // own aspect as the container's width changes. var svg = el('svg', { viewBox: '0 0 ' + nominalW + ' ' + h, style: 'display:block;width:100%;aspect-ratio:' + nominalW + '/' + h + ';background:#fff' }, container); return { svg: svg, W: nominalW, H: h }; } // Text with an opaque background patch behind it (drawn AFTER the text is // measured, so it never overlaps a crossing line or another label). function label(svg, x, y, text, opts) { opts = opts || {}; var t = el('text', { x: x, y: y, 'text-anchor': opts.anchor || 'middle', 'font-size': opts.size || 15, fill: opts.fill || '#1b1e23', 'font-family': 'inherit' }, svg); t.textContent = text; if (opts.rotate) { t.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')'); } if (opts.bg !== false) { var box; try { box = t.getBBox(); } catch (e) { box = null; } if (box) { var pad = 2; var rect = el('rect', { x: box.x - pad, y: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, fill: opts.bgFill || '#fff', opacity: opts.bgOpacity === undefined ? 0.85 : opts.bgOpacity }); svg.insertBefore(rect, t); if (opts.rotate) { rect.setAttribute('transform', 'rotate(' + opts.rotate + ' ' + x + ' ' + y + ')'); } } } return t; } // A horizontal dimension string: line + end ticks + centred text. // `below` puts the text under the line instead of above it. function dimH(svg, x1, x2, y, text, opts) { opts = opts || {}; var tick = opts.tick || 6; var g = el('g', { class: 'dim' }, svg); el('line', { x1: x1, y1: y - tick, x2: x1, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g); el('line', { x1: x2, y1: y - tick, x2: x2, y2: y + tick, stroke: '#8a8f98', 'stroke-width': 1 }, g); el('line', { x1: x1, y1: y, x2: x2, y2: y, stroke: '#8a8f98', 'stroke-width': 1 }, g); label(g, (x1 + x2) / 2, opts.below ? y + 16 : y - 6, text, { size: opts.size || 13 }); return g; } // A vertical dimension string: line + end ticks + rotated centred text. function dimV(svg, y1, y2, x, text, opts) { opts = opts || {}; var tick = opts.tick || 6; var g = el('g', { class: 'dim' }, svg); el('line', { x1: x - tick, y1: y1, x2: x + tick, y2: y1, stroke: '#8a8f98', 'stroke-width': 1 }, g); el('line', { x1: x - tick, y1: y2, x2: x + tick, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g); el('line', { x1: x, y1: y1, x2: x, y2: y2, stroke: '#8a8f98', 'stroke-width': 1 }, g); var mx = opts.left ? x - 10 : x + 10; label(g, mx, (y1 + y2) / 2, text, { size: opts.size || 13, rotate: -90 }); return g; } function ftIn(v) { return window.Units.fmtFtIn(v); } function inch(v) { return window.Units.fmtIn(v); } // A cheap text-width estimate (no DOM measurement needed) for staggering // labels BEFORE they are drawn -- good enough to keep a long label like // 'Floor tie @ 13\'-2 1/2"' from being scored as if it were as narrow as // a short one, which is what let two such labels print on top of each // other despite `stagger()` "succeeding". function estWidth(text, size) { return String(text).length * size * 0.58; } // Greedy label-collision avoidance: given item extents [{x0,x1}], returns // a stacking "level" per item (0, 1, 2, ...) such that no two items on the // same level overlap within `gap`. Used to stagger restraint / lifter // labels that would otherwise sit on top of one another. function stagger(items, gap) { gap = gap || 4; var levels = []; // each: last x1 placed on that level var out = new Array(items.length); var order = items.map(function (it, i) { return i; }) .sort(function (a, b) { return items[a].x0 - items[b].x0; }); order.forEach(function (i) { var it = items[i]; var lvl = 0; while (lvl < levels.length && levels[lvl] > it.x0 - gap) lvl++; levels[lvl] = it.x1; out[i] = lvl; }); return out; } // One continuous dimension chain's cells: consecutive `[lo, hi]` spans (in // inches-from-left) starting at `railPosIn`, each labelled with the chain // length as typed (`cs[i]`). Shared by the reinforcement plan and section // so the two can never disagree about the chain itself (issue #23 item 9i). function chainCellsFromRail(xs, cs, railPosIn) { var cells = []; var prev = railPosIn; for (var i = 0; i < xs.length; i++) { var x = safeNum(xs[i]); if (x === null) x = prev; cells.push({ lo: Math.min(prev, x), hi: Math.max(prev, x), label: cs[i] }); prev = x; } return cells; } // ------------------------------------------------------- section-at-cut function mergeRanges(ranges) { var clean = ranges.filter(function (r) { return r[1] > r[0]; }) .sort(function (a, b) { return a[0] - b[0]; }); var merged = []; clean.forEach(function (r) { if (merged.length && r[0] <= merged[merged.length - 1][1]) { merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], r[1]); } else { merged.push([r[0], r[1]]); } }); return merged; } var FALLBACK_LENGTH_FT = 1.0; var FALLBACK_WIDTH_FT = 0.3; // Reimplements vibe.pa_sketch_extents.section_at() -- see // docs/pa-web-spec.md section 6. Never throws. function sectionAt(geometry, xFt) { geometry = (geometry && typeof geometry === 'object') ? geometry : {}; var problems = []; var lengthFt = safeNum(geometry.length_ft); if (lengthFt === null || lengthFt <= 0) lengthFt = FALLBACK_LENGTH_FT; var widthFt = safeNum(geometry.width_ft); if (widthFt === null || widthFt <= 0) widthFt = FALLBACK_WIDTH_FT; var thicknessIn = safeNum(geometry.thickness_in); var x = safeNum(xFt); if (x === null) { problems.push('x_ft is not a number; using the panel midpoint.'); x = lengthFt / 2.0; } x = clamp(x, 0, lengthFt); var rawRanges = []; (geometry.openings || []).forEach(function (row) { if (!row || typeof row !== 'object') return; var type = String(row.type || 'opening').trim().toLowerCase(); if (type !== 'opening' && type !== '') return; var along = row.along; if (!along || along.length !== 2 || along[0] === null || along[1] === null) return; var lo = along[0], hi = along[1]; if (!(lo <= x && x <= hi)) return; var vert = row.vert; if (!vert || vert.length !== 2 || vert[0] === null || vert[1] === null) { problems.push('opening ' + JSON.stringify(row.label || row.index) + ' crosses this station but has no across-width position entered; ' + 'not punched into the section.'); return; } rawRanges.push([Number(vert[0]), Number(vert[1])]); }); var voids = mergeRanges(rawRanges); return { x_ft: x, width_ft: widthFt, thickness_in: thicknessIn, voids: voids, problems: problems }; } // -------------------------------------------------------------- outline // Renders the panel outline (Concrete Extents drawing 1) with openings // and a draggable cut line. `cutFrac` is the initial 0..1 fraction; // `cb.onDrag(frac)` fires continuously while dragging, `cb.onDragEnd(frac)` // once on release -- both mouse and touch, via Pointer Events. function renderOutline(container, geometry, cutFrac, cb, aspect) { geometry = geometry || {}; cb = cb || {}; aspect = aspect || 0.32; var root = svgRoot(container, aspect); var svg = root.svg, W = root.W, H = root.H; var lengthFt = geometry.length_ft || FALLBACK_LENGTH_FT; var widthFt = geometry.width_ft || FALLBACK_WIDTH_FT; var marginL = 60, marginR = 20, marginTop = 34, marginBottom = 24; // The degraded-placeholder note sits above the overall-length dimension // string -- give it its own row instead of sharing the dimension's, or // the two labels print on top of each other (both centred on the same // x, both near y=14). if (geometry.degraded) marginTop += 16; var drawW = W - marginL - marginR; var drawH = H - marginTop - marginBottom; var scale = drawW / lengthFt; var rectH = Math.min(drawH, widthFt * scale); var rectY = marginTop + (drawH - rectH) / 2; function xAt(ft) { return marginL + ft * scale; } function yAt(vertFt) { return rectY + rectH - vertFt * scale; } // vert measured from bottom if (geometry.degraded) { label(svg, W / 2, 14, 'No length/width entered yet -- placeholder shown', { size: 12, fill: '#b45309' }); } // panel outline el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg); // openings (geometry.openings || []).forEach(function (op) { if (!op.along) return; var x0 = xAt(op.along[0]), x1 = xAt(op.along[1]); var type = (op.type || '').toLowerCase(); if (op.vert) { var y0 = yAt(op.vert[1]), y1 = yAt(op.vert[0]); el('rect', { x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0), fill: type === 'opening' ? '#ffffff' : (type === 'reveal' ? '#eef2ff' : '#f1f5f9'), stroke: '#334155', 'stroke-dasharray': type === 'reveal' ? '4,3' : null, 'stroke-width': 1.2 }, svg); } else { // no across-width position: show a dashed full-height band + note el('rect', { x: x0, y: rectY, width: Math.max(1, x1 - x0), height: rectH, fill: 'none', stroke: '#b45309', 'stroke-dasharray': '3,3', 'stroke-width': 1 }, svg); label(svg, (x0 + x1) / 2, rectY + rectH / 2, '?', { size: 11, fill: '#b45309' }); } if (op.label) { label(svg, (x0 + x1) / 2, (op.vert ? yAt(op.vert[1]) : rectY) - 4, op.label, { size: 10 }); } }); // overall dimensions dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt)); dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true }); // cut line var cutX = marginL + clamp(cutFrac, 0, 1) * drawW; var lineGroup = el('g', { style: 'cursor:ew-resize' }, svg); var hit = el('rect', { x: cutX - 8, y: rectY - 6, width: 16, height: rectH + 12, fill: 'transparent' }, lineGroup); var cutLine = el('line', { x1: cutX, y1: rectY - 6, x2: cutX, y2: rectY + rectH + 6, stroke: '#d33', 'stroke-width': 2 }, lineGroup); el('polygon', { points: [cutX - 5, rectY - 6, cutX + 5, rectY - 6, cutX, rectY - 6 + 8].join(' '), fill: '#d33' }, lineGroup); function moveTo(clientX) { var box = svg.getBoundingClientRect(); var svgX = (clientX - box.left) / box.width * W; var frac = clamp((svgX - marginL) / drawW, 0, 1); var px = marginL + frac * drawW; cutLine.setAttribute('x1', px); cutLine.setAttribute('x2', px); hit.setAttribute('x', px - 8); return frac; } var dragging = false; hit.addEventListener('pointerdown', function (ev) { dragging = true; try { hit.setPointerCapture(ev.pointerId); } catch (e) { /* ignore */ } ev.preventDefault(); }); hit.addEventListener('pointermove', function (ev) { if (!dragging) return; var frac = moveTo(ev.clientX); if (cb.onDrag) cb.onDrag(frac); }); function endDrag(ev) { if (!dragging) return; dragging = false; var frac = moveTo(ev.clientX); if (cb.onDragEnd) cb.onDragEnd(frac); } hit.addEventListener('pointerup', endDrag); hit.addEventListener('pointercancel', endDrag); return { setCutFrac: function (frac) { moveTo(marginL + clamp(frac, 0, 1) * drawW + svg.getBoundingClientRect().left); } }; } // -------------------------------------------------------------- section function renderSection(container, sec, values, aspect) { sec = sec || { width_ft: FALLBACK_WIDTH_FT, thickness_in: 8, voids: [] }; aspect = aspect || 0.16; var root = svgRoot(container, aspect); var svg = root.svg, W = root.W, H = root.H; var widthFt = sec.width_ft || FALLBACK_WIDTH_FT; var thicknessIn = sec.thickness_in || 8; var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 46; var drawW = W - marginL - marginR; var drawH = H - marginTop - marginBottom; var scale = drawW / widthFt; var rectH = Math.min(drawH, (thicknessIn / 12) * scale * 4); // exaggerate thin strips if (rectH < 10) rectH = 10; var rectY = marginTop + (drawH - rectH) / 2; function xAt(ft) { return marginL + ft * scale; } // draw solid segments, punching voids var voids = (sec.voids || []).slice().sort(function (a, b) { return a[0] - b[0]; }); var cursor = 0; var segs = []; voids.forEach(function (v) { if (v[0] > cursor) segs.push([cursor, v[0]]); cursor = Math.max(cursor, v[1]); }); if (cursor < widthFt) segs.push([cursor, widthFt]); segs.forEach(function (s) { el('rect', { x: xAt(s[0]), y: rectY, width: Math.max(0, xAt(s[1]) - xAt(s[0])), height: rectH, fill: '#e2e8f0', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg); }); voids.forEach(function (v) { label(svg, (xAt(v[0]) + xAt(v[1])) / 2, rectY + rectH / 2, 'open', { size: 10, fill: '#94a3b8' }); }); dimH(svg, marginL, marginL + drawW, rectY + rectH + 18, ftIn(widthFt), { below: true }); dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true }); label(svg, W - marginR, marginTop - 8, 'cut at ' + ftIn(sec.x_ft), { anchor: 'end', size: 11, fill: '#6b7280' }); if (sec.thickness_in && (thicknessIn / 12) * scale * 4 > drawH + 2) { label(svg, marginL, marginTop - 8, '(vertically exaggerated)', { anchor: 'start', size: 10, fill: '#6b7280' }); } } // ------------------------------------------------------------ freebody // A classic beam-style free-body diagram: the member drawn as ONE // horizontal line regardless of its true (vertical) orientation, with // restraints, point loads and wind-zone bands positioned along it by // elevation. Matches the wide/short aspect every drawing block declares. function renderFreebody(container, geom, aspect) { geom = geom || {}; aspect = aspect || 0.24; var root = svgRoot(container, aspect); var svg = root.svg, W = root.W, H = root.H; var bot = geom.bottom_elevation_ft, top = geom.top_elevation_ft; if (bot === undefined || bot === null) bot = 0; if (top === undefined || top === null || top <= bot) top = bot + 1; var marginL = 40, marginR = 40, marginTop = 60, marginBottom = 70; var drawW = W - marginL - marginR; var lineY = marginTop + (H - marginTop - marginBottom) / 2; var scale = drawW / (top - bot); function xAt(elev) { return marginL + (elev - bot) * scale; } el('line', { x1: marginL, y1: lineY, x2: marginL + drawW, y2: lineY, stroke: '#1b1e23', 'stroke-width': 3 }, svg); dimH(svg, marginL, marginL + drawW, H - 14, ftIn(top - bot) + ' overall', { below: false, size: 11 }); // restraints, ABOVE the line (owner ruling, 2026-09-05: supports below, // restraints above), staggered by ESTIMATED LABEL WIDTH (not a fixed // guess) so a long "Floor tie @ 13'-2 1/2"" label never overlaps its // neighbour the way a fixed half-width would on a tightly spaced run. // Reserves a fixed ~34px band directly above the line; point loads // (below) start their own arrows further up still, clear of this band. var restraints = geom.restraints || []; var rTexts = restraints.map(function (r) { return (r.type || '?') + ' @ ' + ftIn(r.elevation_ft); }); var rItems = restraints.map(function (r, i) { var x = xAt(r.elevation_ft); var w = estWidth(rTexts[i], 10); return { x0: x - w / 2, x1: x + w / 2 }; }); var rLevels = stagger(rItems, 6); restraints.forEach(function (r, i) { var x = xAt(r.elevation_ft); var y0 = lineY; var lvl = rLevels[i]; var sym = el('g', {}, svg); var type = (r.type || '').toLowerCase(); if (type === 'pin') { el('polygon', { points: [x - 7, y0 - 14, x + 7, y0 - 14, x, y0].join(' '), fill: '#fff', stroke: '#1b1e23' }, sym); } else if (type === 'fixed') { el('line', { x1: x - 9, y1: y0 - 10, x2: x + 9, y2: y0 - 10, stroke: '#1b1e23', 'stroke-width': 3 }, sym); for (var h = -8; h <= 8; h += 4) { el('line', { x1: x + h, y1: y0 - 10, x2: x + h - 4, y2: y0 - 18, stroke: '#1b1e23', 'stroke-width': 1 }, sym); } } else if (type === 'floor tie') { el('circle', { cx: x, cy: y0 - 10, r: 5, fill: '#fff', stroke: '#1b1e23' }, sym); el('line', { x1: x, y1: y0, x2: x, y2: y0 - 5, stroke: '#1b1e23' }, sym); } else { // Free / unknown el('circle', { cx: x, cy: y0 - 8, r: 3, fill: '#94a3b8' }, sym); } label(svg, x, lineY - 34 - lvl * 14, (r.type || '?') + ' @ ' + ftIn(r.elevation_ft), { size: 10 }); }); // supports (bearing positions for THIS analysis), BELOW the line -- // filled triangles, apex on the line, base below, one label each. // Owner ruling, 2026-09-05: `support` table x_ft positions, already // converted to this drawing's elevation axis by // `pa_sketch_freebody._support_rows` (bottom_elevation_ft + x_ft). var supports = geom.supports || []; var sTexts = supports.map(function (s) { return 'Support @ ' + ftIn(s.elevation_ft); }); var sItems = supports.map(function (s, i) { var x = xAt(s.elevation_ft); var w = estWidth(sTexts[i], 10); return { x0: x - w / 2, x1: x + w / 2 }; }); var sLevels = stagger(sItems, 6); supports.forEach(function (s, i) { var x = xAt(s.elevation_ft); var lvl = sLevels[i]; el('polygon', { points: [x - 7, lineY + 14, x + 7, lineY + 14, x, lineY].join(' '), fill: '#1b1e23', stroke: '#1b1e23' }, svg); label(svg, x, lineY + 34 + lvl * 14, sTexts[i], { size: 10, fill: '#1b1e23' }); }); // point loads, above the line -- pushed clear of the restraint band. var loads = geom.conc_loads || []; var lTexts = loads.map(function (ld) { return (ld.label || 'P') + ': D=' + (ld.dead_k == null ? '-' : ld.dead_k) + 'k L=' + (ld.live_k == null ? '-' : ld.live_k) + 'k'; }); var lItems = loads.map(function (ld, i) { var x = xAt(ld.elevation_ft); var w = estWidth(lTexts[i], 10); return { x0: x - w / 2, x1: x + w / 2 }; }); var lLevels = stagger(lItems, 6); var loadTop = restraints.length ? 60 : 40; loads.forEach(function (ld, i) { var x = xAt(ld.elevation_ft); var lvl = lLevels[i]; var topY = lineY - loadTop - lvl * 26; el('line', { x1: x, y1: topY, x2: x, y2: lineY - 4, stroke: '#2f6fed', 'stroke-width': 2, 'marker-end': null }, svg); el('polygon', { points: [x - 4, lineY - 10, x + 4, lineY - 10, x, lineY - 2].join(' '), fill: '#2f6fed' }, svg); label(svg, x, topY - 6, lTexts[i], { size: 10, fill: '#2f6fed' }); }); // wind zones, distributed arrows over their extent, below loads / above line var zones = geom.wind_zones || []; zones.forEach(function (z, zi) { var x0 = xAt(Math.min(z.from_elev_ft, z.to_elev_ft)); var x1 = xAt(Math.max(z.from_elev_ft, z.to_elev_ft)); var y = marginTop + 10 + zi * 16; el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#16a34a', 'stroke-width': 1.5 }, svg); var steps = Math.max(2, Math.round((x1 - x0) / 40)); for (var s = 0; s <= steps; s++) { var ax = x0 + (x1 - x0) * (s / steps); el('line', { x1: ax, y1: y - 8, x2: ax, y2: y, stroke: '#16a34a', 'stroke-width': 1 }, svg); } var pieces = []; if (z.pressure_psf !== null && z.pressure_psf !== undefined) pieces.push('p=' + z.pressure_psf + ' psf'); if (z.suction_psf !== null && z.suction_psf !== undefined) pieces.push('s=' + z.suction_psf + ' psf'); label(svg, (x0 + x1) / 2, y - 12, (z.label || 'Wind') + (pieces.length ? ' ' + pieces.join(', ') : '') + (z.flagged ? ' (reversed)' : ''), { size: 10, fill: '#16a34a' }); }); if ((!restraints.length) && (!supports.length) && (!loads.length) && (!zones.length)) { label(svg, W / 2, lineY + 20, 'Nothing to draw yet -- add restraints, supports, loads or wind zones.', { size: 11, fill: '#6b7280' }); } } // ------------------------------------------------------ lifter/dunnage function renderLifterDunnage(container, geom, aspect) { geom = geom || {}; aspect = aspect || 0.26; var root = svgRoot(container, aspect); var svg = root.svg, W = root.W, H = root.H; if (geom.length_ft === null || geom.length_ft === undefined || geom.width_ft === null || geom.width_ft === undefined) { label(svg, W / 2, H / 2, 'Enter length and width to see lifter positions.', { size: 12, fill: '#6b7280' }); return; } var lengthFt = geom.length_ft, widthFt = geom.width_ft; var marginL = 40, marginR = 40, marginTop = 40, marginBottom = 30; var drawW = W - marginL - marginR; var drawH = H - marginTop - marginBottom; var scale = drawW / lengthFt; var rectH = Math.min(drawH, widthFt * scale); var rectY = marginTop + (drawH - rectH) / 2; function xAt(ft) { return marginL + ft * scale; } function yAt(vertFt) { return rectY + rectH - vertFt * scale; } el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f8fafc', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg); (geom.openings || []).forEach(function (op) { if (!op.along || !op.vert) return; el('rect', { x: xAt(op.along[0]), y: yAt(op.vert[1]), width: Math.max(1, xAt(op.along[1]) - xAt(op.along[0])), height: Math.max(1, yAt(op.vert[0]) - yAt(op.vert[1])), fill: '#ffffff', stroke: '#94a3b8', 'stroke-dasharray': '3,2' }, svg); }); (geom.dunnage_positions_ft || []).forEach(function (d) { var x = xAt(d); el('line', { x1: x, y1: rectY - 6, x2: x, y2: rectY + rectH + 6, stroke: '#a855f7', 'stroke-dasharray': '5,3', 'stroke-width': 1.5 }, svg); }); (geom.face_lifters || []).forEach(function (fl) { var x = xAt(fl.x_ft), y = rectY + rectH * fl.frac; el('circle', { cx: x, cy: y, r: 6, fill: '#1b1e23' }, svg); }); // An 'end' lifter sits at a fixed x (a panel end) and is spread across // the width by `frac`; a 'side' lifter sits at a fixed y (one of the // long edges, named by `side`) and is spread along the length by // `x_ft`. Conflating the two (deciding y from `side` alone) collapsed // every 'end' lifter at the same end onto one point, since their // distinguishing coordinate -- `frac` -- was never read for them. (geom.edge_lifters || []).forEach(function (el2) { var x, y, horiz; if (el2.kind === 'end') { x = xAt(el2.x_ft); y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5); horiz = true; // marker points along the length, toward the panel interior } else { x = xAt(el2.x_ft); if (el2.side === 'top') { y = rectY; } else if (el2.side === 'bottom') { y = rectY + rectH; } else { y = rectY + rectH * (el2.frac != null ? el2.frac : 0.5); } horiz = false; } var inward = el2.x_ft > (geom.length_ft || 0) / 2 ? -1 : 1; var downward = (el2.side === 'top') ? 1 : (el2.side === 'bottom' ? -1 : (y < rectY + rectH / 2 ? 1 : -1)); var pts = horiz ? [x, y - 6, x, y + 6, x + inward * 10, y].join(' ') : [x - 6, y, x + 6, y, x, y + downward * 10].join(' '); el('polygon', { points: pts, fill: el2.ambiguous ? '#f59e0b' : '#2f6fed' }, svg); }); if (geom.centroid && geom.centroid.x_ft !== null && geom.centroid.x_ft !== undefined && geom.centroid.z_ft !== null && geom.centroid.z_ft !== undefined) { var cx = xAt(geom.centroid.x_ft), cy = yAt(geom.centroid.z_ft); el('line', { x1: cx - 8, y1: cy, x2: cx + 8, y2: cy, stroke: '#d33', 'stroke-width': 1.5 }, svg); el('line', { x1: cx, y1: cy - 8, x2: cx, y2: cy + 8, stroke: '#d33', 'stroke-width': 1.5 }, svg); el('circle', { cx: cx, cy: cy, r: 9, fill: 'none', stroke: '#d33', 'stroke-width': 1 }, svg); label(svg, cx, cy - 12, 'CG', { size: 10, fill: '#d33' }); } dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt)); dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true }); } // --------------------------------------------------- reinforcement.outline // The Reinforcement page's PLAN view (issue #23 item 9i): the panel // rectangle (length x width), strand lines along the length at their // across-width x_in, the rail datum, and a per-group continuous dimension // chain for strands (from the rail) and bars (from their own basis) plus a // light hatched mesh sheet. No cut line here -- this is the plan half of // the outline+section pair, not Concrete Extents' draggable outline. function renderReinforcementOutline(container, geom, aspect) { geom = geom || {}; aspect = aspect || 0.44; var root = svgRoot(container, aspect); var svg = root.svg, W = root.W, H = root.H; var lengthFt = safeNum(geom.length_ft); var widthFt = safeNum(geom.width_ft); if (lengthFt === null || lengthFt <= 0 || widthFt === null || widthFt <= 0) { label(svg, W / 2, H / 2, 'Enter length and width to see the reinforcement plan.', { size: 12, fill: '#6b7280' }); return; } var widthIn = widthFt * 12; var marginL = 110, marginR = 28, marginTop = 40, marginBottom = 48; var drawW = W - marginL - marginR; var drawH = H - marginTop - marginBottom; var scale = drawW / lengthFt; var rectH = Math.min(drawH, widthFt * scale); var rectY = marginTop + (drawH - rectH) / 2; function xAt(ft) { return marginL + ft * scale; } function yAtIn(inches) { return rectY + rectH - (inches / 12) * scale; } el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.5 }, svg); dimH(svg, marginL, marginL + drawW, marginTop - 14, ftIn(lengthFt)); dimV(svg, rectY, rectY + rectH, marginL - 14, ftIn(widthFt), { left: true }); var rail = geom.rail; var strands = geom.strands || []; var bars = geom.bars || []; var mesh = geom.mesh; // Strands -- thin lines along the length at their across-width position // x_in (inches from the panel's left edge). A row with no resolved x_in // spreads evenly across the width, the same fallback the section uses. strands.forEach(function (s) { var count = (s.count && s.count > 0) ? s.count : 1; var xs = s.x_in; if (xs && xs.length) { xs.forEach(function (x) { el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#1b1e23', 'stroke-width': 1.4 }, svg); }); } else { for (var i = 0; i < count; i++) { var x = widthIn * (i + 1) / (count + 1); el('line', { x1: marginL, y1: yAtIn(x), x2: marginL + drawW, y2: yAtIn(x), stroke: '#94a3b8', 'stroke-width': 1, 'stroke-dasharray': '3,3' }, svg); } } }); // Rail -- a dashed line at the rail side, offset by rail.offset_in. if (rail && rail.location) { var railPos = rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0); railPos = Math.max(0, Math.min(widthIn, railPos)); var ry = yAtIn(railPos); el('line', { x1: marginL, y1: ry, x2: marginL + drawW, y2: ry, stroke: '#b45309', 'stroke-width': 1.6, 'stroke-dasharray': '6,4' }, svg); label(svg, marginL + drawW + 4, ry - 4, 'rail (' + rail.location + ')', { anchor: 'start', size: 9, fill: '#b45309' }); } // Strand group chain -- vertical, from the rail outward, one cell per // chain_in length, on its own column left of the rectangle. var chainRow = null; for (var i = 0; i < strands.length; i++) { if (strands[i] && strands[i].chain_in && strands[i].chain_in.length && strands[i].x_in && strands[i].x_in.length) { chainRow = strands[i]; break; } } var strandChainX = marginL - 42; if (chainRow && rail && rail.location) { label(svg, strandChainX, rectY - 6, 'strands', { anchor: 'middle', size: 10, fill: '#1b1e23' }); var cells = chainCellsFromRail(chainRow.x_in, chainRow.chain_in, rail.location === 'right' ? (widthIn - (rail.offset_in || 0)) : (rail.offset_in || 0)); cells.forEach(function (c) { dimV(svg, yAtIn(c.hi), yAtIn(c.lo), strandChainX, inch(c.label), { size: 9 }); }); } // Bars -- longitudinal bars run along the length (horizontal lines at // their depth_in); transverse bars run across the width (vertical lines // at their from_ft station). var longitBars = [], transBars = []; bars.forEach(function (b) { if (b.depth_in === null || b.depth_in === undefined) return; if ((b.direction || '').toLowerCase().indexOf('trans') === 0) transBars.push(b); else longitBars.push(b); }); longitBars.forEach(function (b) { var y = yAtIn(b.depth_in); var x0 = (b.from_ft !== null && b.from_ft !== undefined) ? xAt(b.from_ft) : marginL; var x1 = (b.to_ft !== null && b.to_ft !== undefined) ? xAt(b.to_ft) : marginL + drawW; el('line', { x1: x0, y1: y, x2: x1, y2: y, stroke: '#2f6fed', 'stroke-width': 1.4 }, svg); }); transBars.forEach(function (b) { var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft : ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2); el('line', { x1: xAt(xv), y1: rectY, x2: xAt(xv), y2: rectY + rectH, stroke: '#2f6fed', 'stroke-width': 1.2, 'stroke-dasharray': '5,3' }, svg); }); // Bar group chain -- from each bar's own reference. Longitudinal bars // measure their depth up from the panel's bottom edge (a vertical chain // on its own column); transverse bars measure their station along the // length (a horizontal chain stacked below the rectangle). var barChainX = marginL - 74; if (longitBars.length) { label(svg, barChainX, rectY - 6, 'bars', { anchor: 'middle', size: 10, fill: '#2f6fed' }); } longitBars.forEach(function (b) { dimV(svg, yAtIn(b.depth_in), rectY + rectH, barChainX, (b.size || 'bar') + ' longit.', { size: 9 }); }); transBars.forEach(function (b, ti) { var xv = (b.from_ft !== null && b.from_ft !== undefined) ? b.from_ft : ((b.to_ft !== null && b.to_ft !== undefined) ? b.to_ft : lengthFt / 2); var by = rectY + rectH + 20 + ti * 16; if (ti === 0 && !longitBars.length) { label(svg, marginL, by - 4, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' }); } dimH(svg, marginL, xAt(xv), by, (b.size || 'bar') + ' transv.', { size: 9, below: true }); }); // Mesh -- a light hatched sheet over the panel, labelled once. if (mesh && mesh.designation) { var pid = 'reinout-hatch'; var defs = el('defs', {}, svg); var pat = el('pattern', { id: pid, patternUnits: 'userSpaceOnUse', width: 7, height: 7 }, defs); el('path', { d: 'M0 7 L7 0', stroke: '#94a3b8', 'stroke-width': 0.8 }, pat); el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: 'url(#' + pid + ')', opacity: 0.22 }, svg); var meshText = 'mesh: ' + mesh.designation; if (mesh.layers !== null && mesh.layers !== undefined) meshText += ' \u00d7 ' + mesh.layers; if (mesh.depth_in !== null && mesh.depth_in !== undefined) meshText += ' @ ' + inch(mesh.depth_in); label(svg, marginL + 6, rectY + 14, meshText, { anchor: 'start', size: 9, fill: '#334155' }); } } // --------------------------------------------------- reinforcement.section function renderReinforcementSection(container, geom, aspect) { geom = geom || {}; aspect = aspect || 0.28; var root = svgRoot(container, aspect); var svg = root.svg, W = root.W, H = root.H; var widthFt = geom.width_ft || FALLBACK_WIDTH_FT; var thicknessIn = geom.thickness_in || 8; var marginL = 60, marginR = 20, marginTop = 24, marginBottom = 128; var drawW = W - marginL - marginR; var drawH = H - marginTop - marginBottom; var scaleX = drawW / widthFt; var exaggerate = Math.max(1, Math.min(6, drawH / ((thicknessIn / 12) * scaleX || 1))); var rectH = (thicknessIn / 12) * scaleX * exaggerate; if (rectH > drawH) { exaggerate *= drawH / rectH; rectH = drawH; } var rectY = marginTop + (drawH - rectH) / 2; function xAt(ft) { return marginL + ft * scaleX; } function xAtIn(inches) { return marginL + (inches / 12) * scaleX; } function yAtDepth(depthIn) { return rectY + (depthIn / 12) * scaleX * exaggerate; } el('rect', { x: marginL, y: rectY, width: drawW, height: rectH, fill: '#f4f6fb', stroke: '#1b1e23', 'stroke-width': 1.2 }, svg); // strands: filled circles, x from strand_x_in flattened in strand order var xs = geom.strand_x_in; var flatIdx = 0; var strandItems = []; (geom.strands || []).forEach(function (s) { for (var i = 0; i < (s.count || 0); i++) { var xIn; if (xs && !geom.used_fallback_strand_x && xs.length > flatIdx) { xIn = xs[flatIdx]; } else { xIn = null; // filled in after we know the total count } strandItems.push({ xIn: xIn, depth: s.depth_in, dia: s.dia_in, id: s.id }); flatIdx++; } }); if (geom.used_fallback_strand_x || !xs) { var n = strandItems.length; strandItems.forEach(function (it, i) { it.xIn = n > 1 ? (widthFt * 12) * (i + 0.5) / n : (widthFt * 12) / 2; }); } strandItems.forEach(function (it) { var x = xAt(it.xIn / 12), y = yAtDepth(it.depth); el('circle', { cx: x, cy: y, r: 5, fill: '#1b1e23' }, svg); }); if (geom.used_fallback_strand_x) { label(svg, marginL, rectY + rectH + 112, '(strand positions spread evenly -- no explicit across-width layout entered)', { anchor: 'start', size: 10, fill: '#6b7280' }); } // mild bars -- owner ruling 2026-09-05, matching `pa_sketch_reinf.py`'s // own (already-correct) WPF picture: a LONGITUDINAL bar runs along the // panel's LENGTH, so this cross-section (cut across the WIDTH) sees it // END-ON -- one open circle per bar, at its own across-width position // (the row's own `spacing_in` pitch when it is a real number, else // spread evenly). A TRANSVERSE bar runs ACROSS the width, so the same // cut sees it ALONG its own length -- one dashed line spanning the // full section width at its depth, labelled once (e.g. '#4 @ 19" // transv.'), never a row of circles the way this used to draw it // (backwards from the physical picture -- see the fix's own note). // Two bars whose depths sit within a fraction of an inch of each other // (common: a top mat and a bottom mat of two different sizes) can land // within a few px once exaggerated -- cascade the label sideways in // that case, computed on DEPTH-SORTED groups (never original array // order, which can interleave the two directions and miss an adjacent // pair, or falsely flag two non-adjacent ones) and kept separate per // direction since they print on opposite sides of the strip. var allBars = geom.bars || []; function isTransverse(b) { return (b.direction || '').toLowerCase().indexOf('trans') === 0; } function cascadeOffsets(list) { var withY = list.map(function (b) { return { id: b.id, y: yAtDepth(b.depth_in) }; }) .sort(function (a, b) { return a.y - b.y; }); var offsets = {}; var lastY = null, c = 0; withY.forEach(function (it) { c = (lastY !== null && Math.abs(it.y - lastY) < 11) ? c + 1 : 0; lastY = it.y; offsets[it.id] = c * 34; }); return offsets; } var transverseOffsets = cascadeOffsets(allBars.filter(isTransverse)); var longitOffsets = cascadeOffsets(allBars.filter(function (b) { return !isTransverse(b); })); allBars.forEach(function (b) { var y = yAtDepth(b.depth_in); if (isTransverse(b)) { el('line', { x1: marginL + 4, y1: y, x2: marginL + drawW - 4, y2: y, stroke: '#2f6fed', 'stroke-width': 1, 'stroke-dasharray': '6,3' }, svg); var spacingText = b.spacing_in ? ' @ ' + Math.round(b.spacing_in * 100) / 100 + '"' : ''; label(svg, marginL + drawW - 6 - (transverseOffsets[b.id] || 0), y - 4, (b.size || '') + spacingText + ' transv.', { anchor: 'end', size: 9, fill: '#2f6fed' }); } else { var count = b.count && b.count > 0 ? b.count : 1; var dotCount = Math.min(count, 20); var xs = []; var innerW = drawW - 8; if (b.spacing_in && b.spacing_in > 0) { var pitchPx = (b.spacing_in / 12) * scaleX; for (var x = marginL + 4 + pitchPx; x < marginL + 4 + innerW && xs.length < dotCount; x += pitchPx) xs.push(x); } if (!xs.length) { for (var i = 0; i < dotCount; i++) xs.push(marginL + 4 + innerW * (i + 1) / (dotCount + 1)); } xs.forEach(function (cx) { el('circle', { cx: cx, cy: y, r: 4, fill: '#fff', stroke: '#2f6fed', 'stroke-width': 1.4 }, svg); }); label(svg, marginL + 4 + (longitOffsets[b.id] || 0), y - 6, (b.size || '') + ' longit.', { anchor: 'start', size: 9, fill: '#2f6fed' }); } }); // Per-group dimension chains along the section's width (issue #23 item // 9i): strands from the rail, bars from their own basis, stacked on // their own rows below the overall width dimension so they never overlap. var widthInSec = widthFt * 12; var chainRow = null; for (var ci = 0; ci < (geom.strands || []).length; ci++) { var sr = geom.strands[ci]; if (sr && sr.chain_in && sr.chain_in.length && sr.x_in && sr.x_in.length) { chainRow = sr; break; } } if (chainRow) { var cxs = chainRow.x_in, ccs = chainRow.chain_in; var railPos = (cxs[0] <= cxs[cxs.length - 1]) ? (cxs[0] - ccs[0]) : (cxs[0] + ccs[0]); railPos = Math.max(0, Math.min(widthInSec, railPos)); label(svg, marginL, rectY + rectH + 44, 'strands', { anchor: 'start', size: 10, fill: '#1b1e23' }); chainCellsFromRail(cxs, ccs, railPos).forEach(function (c) { dimH(svg, xAtIn(c.lo), xAtIn(c.hi), rectY + rectH + 48, inch(c.label), { size: 9, below: true }); }); } var longitBarRows = allBars.filter(function (b) { return !isTransverse(b); }); var transBarRows = allBars.filter(isTransverse); var barChainY = rectY + rectH + 74; var barRow = 0; if (longitBarRows.length || transBarRows.length) { label(svg, marginL, barChainY, 'bars', { anchor: 'start', size: 10, fill: '#2f6fed' }); } longitBarRows.forEach(function (b) { if (!b.spacing_in || b.spacing_in <= 0) return; var x1 = Math.min(marginL + drawW, xAtIn(b.spacing_in)); dimH(svg, marginL, x1, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' longit.', { size: 9, below: true }); barRow += 1; }); transBarRows.forEach(function (b) { dimH(svg, marginL, marginL + drawW, barChainY + 4 + barRow * 16, (b.size || 'bar') + ' transv.', { size: 9, below: true }); barRow += 1; }); dimH(svg, marginL, marginL + drawW, rectY + rectH + 16, ftIn(widthFt), { below: true }); dimV(svg, rectY, rectY + rectH, marginL - 14, inch(thicknessIn), { left: true }); if (exaggerate > 1.05) { label(svg, marginL, marginTop - 8, '(depth exaggerated ' + exaggerate.toFixed(1) + '×)', { anchor: 'start', size: 10, fill: '#6b7280' }); } label(svg, marginL + drawW, marginTop - 8, '● strand ○ bar', { anchor: 'end', size: 10, fill: '#6b7280' }); } window.Draw = { sectionAt: sectionAt, renderOutline: renderOutline, renderSection: renderSection, renderFreebody: renderFreebody, renderLifterDunnage: renderLifterDunnage, renderReinforcementOutline: renderReinforcementOutline, renderReinforcementSection: renderReinforcementSection, _stagger: stagger, svgRoot: svgRoot, label: label, dimH: dimH, dimV: dimV, ftIn: ftIn, inch: inch, el: el, clear: clear, safeNum: safeNum, clamp: clamp, mergeRanges: mergeRanges, estWidth: estWidth }; })();