Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
2026-09-09 08:35:15 -05:00

953 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <svg>
// 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
};
})();