WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,952 @@
|
||||
// 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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user