WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s

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.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+952
View File
@@ -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
};
})();
+19
View File
@@ -0,0 +1,19 @@
<meta charset="utf-8">
<title>Panel Analysis</title>
<link rel="stylesheet" href="willtools.css">
<div class="app-shell">
<div class="toolbar">
<div class="toolbar-title">Panel Analysis<span id="pa-mark" class="pa-mark"></span></div>
<div class="toolbar-spacer"></div>
<button id="btn-run" class="btn btn-primary">Run</button>
<button id="btn-close" class="btn">Close</button>
</div>
<div id="banner" class="banner" hidden></div>
<div id="spec-problems" class="spec-problems" hidden></div>
<div class="tabs" id="tabs"></div>
<div class="pages" id="pages"></div>
</div>
<pre id="errors" hidden></pre>
<script src="units.js"></script>
<script src="draw.js"></script>
<script src="app.js"></script>
+246
View File
@@ -0,0 +1,246 @@
// Feet-inches formatting and parsing -- the browser twin of
// `panellift/src/panellift/units.py`. Same rules, same rounding, same
// accepted input forms; kept as a byte-for-byte port on purpose so a value
// typed on the page and the same value printed by the CPython report never
// disagree. See units.py's own module docstring for the owner's ruling this
// exists to satisfy (2026-09-05: every length on any front-end or results
// page is feet-inches, rounded to the nearest 1/8", never a decimal).
//
// Plain ES2017, no modules -- loaded with a bare <script src="units.js">
// the way the rest of this page's vanilla JS is (see EDIT_JS in
// tools/panelanalysis.py). Exposes window.Units = {fmtFtIn, fmtIn,
// parseLength, parseLengthIn}.
(function () {
'use strict';
function gcd(a, b) {
a = Math.abs(a);
b = Math.abs(b);
while (b) {
var t = b;
b = a % b;
a = t;
}
return a || 1;
}
// A reduced "n/d" fraction string, mirroring Python's `fractions.Fraction`
// reduction (e.g. 4/8 -> "1/2").
function fracString(num, den) {
var g = gcd(num, den);
return (num / g) + '/' + (den / g);
}
// Decimal feet -> "8'-4 1/2\"", rounded to the nearest 1/precision inch.
// null/undefined/NaN -> '-'. Negative values keep the sign in front of the
// whole string ("-0'-6\""), never inside it. A remainder that rounds up to
// a full inch carries into the inch count, and 12 inches carries into the
// foot count.
function fmtFtIn(feet, precision) {
precision = precision || 8;
if (feet === null || feet === undefined || isNaN(feet)) {
return '-';
}
var sign = feet < 0 ? '-' : '';
var value = Math.abs(feet);
var wholeFt = Math.floor(value);
// work in units of 1/precision inch so the carry logic is exact integer
// arithmetic, never a float comparison against 12.0 or 1.0.
var unitsTotal = Math.round((value - wholeFt) * 12.0 * precision);
var inch = Math.floor(unitsTotal / precision);
var rem = unitsTotal - inch * precision;
if (inch >= 12) {
wholeFt += Math.floor(inch / 12);
inch = inch % 12;
}
var out = wholeFt + "'-" + inch;
if (rem) {
out += ' ' + fracString(rem, precision);
}
return sign + out + '"';
}
// Decimal inches -> "6 3/8\"" -- for thicknesses, depths, offsets,
// diameters and anything else already spoken of in inches, never feet.
// null/undefined/NaN -> '-'.
function fmtIn(inches, precision) {
precision = precision || 8;
if (inches === null || inches === undefined || isNaN(inches)) {
return '-';
}
var sign = inches < 0 ? '-' : '';
var value = Math.abs(inches);
var wholeIn = Math.floor(value);
var units = Math.round((value - wholeIn) * precision);
if (units >= precision) {
wholeIn += 1;
units = 0;
}
var out = '' + wholeIn;
if (units) {
out += ' ' + fracString(units, precision);
}
return sign + out + '"';
}
// ------------------------------------------------------------ parsing
// A leading sign, held separately from the feet/inch markers below because
// a hyphen INSIDE the string (the "8'-4" separator, not a minus) must
// never be read as negation -- only a hyphen before the first digit is a
// sign.
var SIGN_RE = /^-\s*/;
// "8", optionally decimal, followed by a foot mark, followed by whatever
// is left (which may be empty, or an inch remainder with its own quote).
var FEET_RE = /^(\d+(?:\.\d+)?)\s*'\s*([\s\S]*)$/;
// A hyphen used as the visual separator between a whole inch and a
// fraction ("4-1/2") rather than as a sign -- only recognised with a digit
// already on its left, so a leading "-" (the "8'-6\"" separator right
// after the foot mark) is stripped as its own case, not caught here.
var HYPHEN_FRAC_RE = /(\d)\s*-\s*(\d+\s*\/\s*\d+)/g;
var MIXED_RE = /^(\d+)\s+(\d+)\s*\/\s*(\d+)$/;
var FRAC_RE = /^(\d+)\s*\/\s*(\d+)$/;
var DEC_RE = /^(\d+(?:\.\d+)?)$/;
function badLength(orig) {
return new Error('not a length: ' + orig);
}
// num/den, or badLength(orig) for a 0 denominator -- a typed "1/0" is bad
// input, not a licence to silently hand back Infinity/NaN the way plain
// JS division would.
function safeDiv(num, den, orig) {
if (den === 0) {
throw badLength(orig);
}
return num / den;
}
function normalizeQuotes(s) {
return s.replace(/’/g, "'").replace(/‘/g, "'")
.replace(/”/g, '"').replace(/“/g, '"');
}
// Parse what follows a foot mark (or the whole string, for a bare inch
// value): an optional leading '-' separator, a whole number and/or a
// fraction, an optional trailing '"'. Returns inches as a number.
function parseInchRemainder(rest, orig, allowEmpty) {
rest = rest.trim();
if (rest.slice(-1) === '"') {
rest = rest.slice(0, -1).trim();
}
if (rest.slice(0, 1) === '-') {
// the "8'-6" separator, not a sign -- the sign (if any) was already
// consumed before the foot mark was even found.
rest = rest.slice(1).trim();
}
if (rest === '') {
if (allowEmpty) {
return 0.0;
}
throw badLength(orig);
}
rest = rest.replace(HYPHEN_FRAC_RE, '$1 $2');
var m = MIXED_RE.exec(rest);
if (m) {
return parseFloat(m[1]) +
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
}
m = FRAC_RE.exec(rest);
if (m) {
return safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
}
m = DEC_RE.exec(rest);
if (m) {
return parseFloat(m[1]);
}
throw badLength(orig);
}
// No `'` and no trailing `"` anywhere -- a plain number, or a bare
// fraction / mixed number, read in `defaultUnit`.
function parseBareNumber(s, orig, defaultUnit) {
var value;
var m = DEC_RE.exec(s);
if (m) {
value = parseFloat(m[1]);
} else {
var s2 = s.replace(HYPHEN_FRAC_RE, '$1 $2');
m = MIXED_RE.exec(s2);
if (m) {
value = parseFloat(m[1]) +
safeDiv(parseFloat(m[2]), parseFloat(m[3]), orig);
} else {
m = FRAC_RE.exec(s2);
if (m) {
value = safeDiv(parseFloat(m[1]), parseFloat(m[2]), orig);
} else {
throw badLength(orig);
}
}
}
return defaultUnit === 'ft' ? value * 12.0 : value;
}
// The shared parser. Returns inches, given how a bare unmarked number
// should be read ('ft' or 'in').
function parseCoreIn(text, defaultUnit) {
if (text === null || text === undefined) {
throw badLength(text);
}
if (typeof text === 'number') {
return defaultUnit === 'ft' ? text * 12.0 : text;
}
var orig = String(text);
var s = orig.trim();
if (s === '') {
throw badLength(orig);
}
s = normalizeQuotes(s);
var negative = false;
var signMatch = SIGN_RE.exec(s);
if (signMatch) {
negative = true;
s = s.slice(signMatch[0].length).trim();
}
if (s === '') {
throw badLength(orig);
}
var totalIn;
var feetMatch = FEET_RE.exec(s);
if (feetMatch) {
var feet = parseFloat(feetMatch[1]);
totalIn = feet * 12.0 + parseInchRemainder(feetMatch[2], orig, true);
} else if (s.slice(-1) === '"') {
totalIn = parseInchRemainder(s, orig, false);
} else {
totalIn = parseBareNumber(s, orig, defaultUnit);
}
return negative ? -totalIn : totalIn;
}
// Parse any of the accepted forms and return inches. A bare number with
// no `'` or `"` anywhere is read in `defaultUnit` ('in' by default here).
function parseLengthIn(text, defaultUnit) {
return parseCoreIn(text, defaultUnit || 'in');
}
// Parse any of the accepted forms and return decimal feet. A bare number
// with no `'` or `"` anywhere is read in `defaultUnit` ('ft' by default,
// per the owner's ruling).
function parseLength(text, defaultUnit) {
return parseCoreIn(text, defaultUnit || 'ft') / 12.0;
}
window.Units = {
fmtFtIn: fmtFtIn,
fmtIn: fmtIn,
parseLength: parseLength,
parseLengthIn: parseLengthIn
};
})();
+475
View File
@@ -0,0 +1,475 @@
/* WillTools shared front-end look -- Panel Analysis's new 前端 today, the
shell every future tool (Beam Analysis, Connection Analysis) and every
results page adopts next. Reference: Eriksson Wall's own input dialogs --
plain engineering software, dense but tidy. Light theme, one accent
colour, system font stack, no build step, no framework. */
:root {
--bg: #f3f4f6;
--panel-bg: #ffffff;
--border: #d7dae0;
--border-strong: #b7bcc6;
--text: #1b1e23;
--text-muted: #6b7280;
--text-faint: #9aa1ac;
--accent: #2f6fed;
--accent-dark: #1f56c9;
--accent-contrast: #ffffff;
--error: #d33333;
--error-bg: #fdecec;
--warn: #b45309;
--warn-bg: #fff7ed;
--row-stripe: #fafbfc;
--row-hover: #eef2ff;
--radius: 4px;
--focus-ring: 0 0 0 3px rgba(47, 111, 237, 0.28);
color-scheme: light;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
font-size: 13px;
line-height: 1.45;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
/* --------------------------------------------------------------- layout */
.app-shell {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
}
.pa-mark {
font-weight: 400;
color: var(--text-muted);
margin-left: 6px;
}
.toolbar-spacer { flex: 1 1 auto; }
.btn {
appearance: none;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text);
border-radius: var(--radius);
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
}
.btn:hover { background: #f4f5f7; }
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.btn-primary {
background: var(--accent);
border-color: var(--accent-dark);
color: var(--accent-contrast);
font-weight: 600;
}
.btn-primary:hover { background: var(--accent-dark); }
.banner {
flex: 0 0 auto;
background: var(--error-bg);
color: var(--error);
border-bottom: 1px solid #f3c2c2;
padding: 8px 14px;
font-size: 12.5px;
}
.banner[hidden] { display: none; }
/* Two faces, never alike: amber = input still needed (the user's to fill),
red = something broke (a bug). The bare `.banner` is red so an
unlabelled failure is loud, never calm. */
.banner.banner--input {
background: var(--warn-bg);
color: var(--warn);
border-bottom-color: #f2d9b6;
}
.banner.banner--crash {
background: var(--error-bg);
color: var(--error);
border-bottom-color: #f3c2c2;
}
.spec-problems {
flex: 0 0 auto;
background: var(--warn-bg);
color: var(--warn);
border-bottom: 1px solid #f2d9b6;
padding: 6px 14px;
font-size: 12px;
}
.tabs {
display: flex;
gap: 2px;
background: var(--panel-bg);
border-bottom: 1px solid var(--border);
padding: 0 10px;
flex: 0 0 auto;
overflow-x: auto;
}
.tab {
appearance: none;
border: none;
background: transparent;
padding: 10px 14px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 3px solid transparent;
white-space: nowrap;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--text);
font-weight: 600;
border-bottom-color: var(--accent);
}
.pages {
flex: 1 1 auto;
overflow-y: auto;
background: var(--bg);
}
.page-panel {
display: none;
padding: 18px 20px 60px;
max-width: 1180px;
margin: 0 auto;
}
.page-panel.active { display: block; }
/* -------------------------------------------------------------- headings */
h1.pa-h, h2.pa-h, h3.pa-h {
margin: 18px 0 8px;
font-weight: 600;
color: var(--text);
}
h1.pa-h { font-size: 17px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
h2.pa-h { font-size: 14.5px; margin-top: 20px; }
h3.pa-h { font-size: 13.5px; color: var(--text-muted); }
hr.pa-rule {
border: none;
border-top: 1px solid var(--border);
margin: 14px 0;
}
.pa-space { display: block; }
/* -------------------------------------------------------------- columns */
.pa-columns {
display: flex;
gap: 22px;
align-items: flex-start;
}
.pa-col {
flex: 1 1 0;
min-width: 0;
}
/* ---------------------------------------------------------------- field */
.field {
margin-bottom: 10px;
max-width: 420px;
}
.field.full-width { max-width: none; }
.field-label-row {
display: flex;
align-items: baseline;
gap: 5px;
margin-bottom: 3px;
}
.field-label {
font-size: 12.5px;
color: var(--text-muted);
}
.field.required .field-label::after {
content: " *";
color: var(--error);
}
.field-symbol {
font-size: 11px;
color: var(--text-faint);
}
.field-input {
width: 100%;
padding: 5px 7px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
}
.field-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
textarea.field-input { resize: vertical; min-height: 44px; }
.field-input.has-error {
border-color: var(--error);
background: var(--error-bg);
}
.field-error-msg {
color: var(--error);
font-size: 11px;
margin-top: 2px;
}
.field-check-row {
display: flex;
align-items: center;
gap: 6px;
}
.field-check-row .field-label { margin: 0; }
/* "not used by the solve" (docs/pa-web-spec.md, unused_by_solve) --
collected only; muted so it can never pass for a live input */
.unused-by-solve .field-input,
tr.unused-by-solve .field-input { opacity: 0.55; }
.unused-by-solve .field-label,
tr.unused-by-solve td:first-child { color: var(--text-muted); }
.unused-tag {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
border: 1px dashed var(--text-faint);
border-radius: 3px;
color: var(--text-muted);
font-size: 10px;
line-height: 15px;
white-space: nowrap;
}
/* row-table empty state + table-level error (issue #22) */
.table-empty-state {
color: var(--text-muted);
font-size: 12px;
font-style: italic;
margin: 4px 0;
}
.editable-table-wrap.has-error table.pa-editable {
outline: 2px solid var(--error);
background: var(--error-bg);
}
.table-error-msg {
color: var(--error);
font-size: 11px;
margin: 2px 0;
}
/* provenance */
.prov-icon {
display: inline-block;
width: 13px;
height: 13px;
line-height: 13px;
text-align: center;
border-radius: 50%;
font-size: 10px;
color: var(--accent);
cursor: help;
border: 1px solid var(--accent);
}
/* ------------------------------------------------------------ sentence */
.sentence { margin: 6px 0; color: var(--text); }
.sentence.emph { font-weight: 600; }
/* ------------------------------------------------------------------ kv */
table.kv-table { border-collapse: collapse; margin: 6px 0 12px; }
table.kv-table td { padding: 3px 10px 3px 0; vertical-align: top; font-size: 12.5px; }
table.kv-table td:first-child { color: var(--text-muted); white-space: nowrap; }
/* --------------------------------------------------------------- tables */
table.pa-table, table.pa-editable, table.pa-matrix, table.pa-lc {
border-collapse: collapse;
width: 100%;
margin: 6px 0 14px;
font-size: 12.5px;
}
table.pa-table th, table.pa-table td,
table.pa-editable th, table.pa-editable td,
table.pa-matrix th, table.pa-matrix td,
table.pa-lc th, table.pa-lc td {
border: 1px solid var(--border);
padding: 4px 6px;
text-align: left;
vertical-align: middle;
}
table.pa-table th, table.pa-editable th, table.pa-matrix th, table.pa-lc th {
background: #eef1f5;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
table.pa-table tbody tr:nth-child(even),
table.pa-editable tbody tr:nth-child(even) {
background: var(--row-stripe);
}
table.pa-table td input.field-input,
table.pa-editable td input.field-input,
table.pa-editable td select.field-input,
table.pa-matrix td input.field-input,
table.pa-matrix td select.field-input,
table.pa-lc td input.field-input {
padding: 3px 5px;
min-width: 44px;
}
table.pa-matrix td.na-cell {
text-align: center;
color: var(--text-faint);
}
table.pa-matrix th, table.pa-matrix td:first-child {
white-space: normal;
}
td.row-remove { text-align: center; width: 28px; }
.row-remove-btn, .row-check {
cursor: pointer;
color: var(--text-muted);
border: none;
background: none;
font-size: 14px;
}
.row-remove-btn:hover { color: var(--error); }
.add-row-btn {
margin: 2px 0 14px;
}
/* -------------------------------------------------------------- legend */
.legend {
display: flex;
gap: 18px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-muted);
margin: 4px 0 10px;
}
.legend-group-title { font-weight: 600; margin-right: 6px; }
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
.legend-glyph { font-size: 13px; }
/* ------------------------------------------------------------- drawing */
.drawing-block {
margin: 10px 0 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-bg);
padding: 8px 10px;
}
.drawing-caption {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.drawing-host { width: 100%; }
.drawing-problems {
font-size: 11px;
color: var(--warn);
margin-top: 4px;
}
.drawing-problems:empty { display: none; }
/* -------------------------------------------------------------- details */
details.pa-section {
margin: 10px 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel-bg);
}
details.pa-section > summary {
cursor: pointer;
padding: 8px 10px;
font-weight: 600;
font-size: 13px;
list-style: none;
}
details.pa-section > summary::-webkit-details-marker { display: none; }
details.pa-section > summary::before {
content: "▸ ";
color: var(--text-muted);
}
details.pa-section[open] > summary::before { content: "▾ "; }
details.pa-section > .pa-section-body { padding: 4px 12px 12px; }
details.pa-notes {
margin: 20px 0 4px;
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
background: #fbfbfc;
}
details.pa-notes > summary {
cursor: pointer;
padding: 7px 10px;
color: var(--text-muted);
font-size: 12.5px;
list-style: none;
}
details.pa-notes > summary::-webkit-details-marker { display: none; }
.pa-notes-body { padding: 2px 14px 12px; font-size: 12px; color: var(--text-muted); }
.pa-notes-body h4 { margin: 8px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .03em; color: var(--text-faint); }
.pa-notes-body ul { margin: 0 0 6px; padding-left: 18px; }
.pa-notes-body li { margin-bottom: 3px; }
.unknown-block {
background: #fff3cd;
border: 1px solid #ffe08a;
border-radius: var(--radius);
padding: 6px 10px;
font-size: 12px;
margin: 6px 0;
}
#errors {
position: fixed;
left: 0; right: 0; bottom: 0;
max-height: 30vh;
overflow: auto;
margin: 0;
background: #2b0000;
color: #ffd9d9;
font-size: 11px;
padding: 8px 10px;
z-index: 999;
white-space: pre-wrap;
}
#errors[hidden] { display: none; }