@floless/app 0.73.0 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +334 -209
- package/dist/web/app.css +62 -1
- package/dist/web/index.html +5 -0
- package/dist/web/steel-3d-view.js +46 -22
- package/dist/web/steel-editor.html +75 -30
- package/dist/web/workspaces.js +139 -0
- package/package.json +1 -1
package/dist/web/app.css
CHANGED
|
@@ -3312,7 +3312,7 @@ body {
|
|
|
3312
3312
|
/* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
|
|
3313
3313
|
/* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
|
|
3314
3314
|
UA display:none — these guards keep hidden winning so nothing leaks across modes. */
|
|
3315
|
-
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden] { display:none !important; }
|
|
3315
|
+
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden], .ws-history[hidden] { display:none !important; }
|
|
3316
3316
|
.mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
|
|
3317
3317
|
overflow:hidden; background:var(--surface-2); flex:none; }
|
|
3318
3318
|
.mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
|
|
@@ -3428,3 +3428,64 @@ body {
|
|
|
3428
3428
|
background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted); cursor:pointer; transition:all .15s; }
|
|
3429
3429
|
.ecard .e-act button:hover:not(:disabled) { color:var(--text); border-color:var(--accent-dim); background:var(--surface); }
|
|
3430
3430
|
.ecard .e-act button:disabled { opacity:0.6; cursor:not-allowed; }
|
|
3431
|
+
|
|
3432
|
+
/* ── Workspaces ▸ History step (shell-rendered pane; the project's version ledger). Ports the
|
|
3433
|
+
mockup's table.hist into app tokens. `position:relative` anchors the rollback confirm popover. */
|
|
3434
|
+
.ws-history { flex:1; min-height:0; position:relative; overflow-y:auto; padding:16px 20px;
|
|
3435
|
+
scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
|
|
3436
|
+
.ws-history::-webkit-scrollbar { width:10px; }
|
|
3437
|
+
.ws-history::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:5px; }
|
|
3438
|
+
.ws-history::-webkit-scrollbar-track { background:transparent; }
|
|
3439
|
+
.ws-history .hist-note { font-size:12px; color:var(--text-muted); margin:0 0 14px; }
|
|
3440
|
+
.ws-history .hist-note b { color:var(--text); font-weight:600; }
|
|
3441
|
+
/* Working-copy banner — sibling of .ws-exports-gate; NEUTRAL (accent-dim rail, never a danger
|
|
3442
|
+
color): "unapproved" is an absence-of-signature, not an error. Names the Exports-lock consequence. */
|
|
3443
|
+
.hist-working { margin:0 0 14px; padding:9px 13px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
|
3444
|
+
background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--accent-dim);
|
|
3445
|
+
border-radius:6px; font-size:12px; color:var(--text-muted); }
|
|
3446
|
+
.hist-working b { color:var(--text); font-weight:600; }
|
|
3447
|
+
.hist-working button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3448
|
+
.hist-working button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3449
|
+
/* Empty state — an inline-action banner (never a bare header row, which reads as broken/loading). */
|
|
3450
|
+
.hist-empty { margin:6px 0 0; padding:11px 14px; background:var(--surface-2); border:1px solid var(--border);
|
|
3451
|
+
border-left:3px solid var(--accent-dim); border-radius:6px; font-size:12.5px; color:var(--text-muted); }
|
|
3452
|
+
.hist-empty b { color:var(--text); }
|
|
3453
|
+
.hist-empty button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3454
|
+
.hist-empty button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3455
|
+
|
|
3456
|
+
table.hist { width:100%; border-collapse:collapse; font-size:12.5px; }
|
|
3457
|
+
table.hist th { text-align:left; font-size:10px; text-transform:uppercase; letter-spacing:.1em; color:var(--text-dim);
|
|
3458
|
+
font-weight:600; padding:0 12px 8px; border-bottom:1px solid var(--border); white-space:nowrap; }
|
|
3459
|
+
table.hist td { padding:11px 12px; border-bottom:1px solid var(--border); color:var(--text); vertical-align:middle; }
|
|
3460
|
+
table.hist td.change { line-height:1.45; }
|
|
3461
|
+
table.hist tr:hover td { background:var(--surface-2); }
|
|
3462
|
+
table.hist tr.current td { background:var(--accent-soft); }
|
|
3463
|
+
.hist .ver { font-family:var(--mono); font-size:11.5px; color:var(--text-dim); }
|
|
3464
|
+
.hist .ver.cur { color:var(--accent-bright); }
|
|
3465
|
+
.hist .htime { color:var(--text-muted); white-space:nowrap; }
|
|
3466
|
+
.hist .hby { display:inline-flex; align-items:center; gap:7px; white-space:nowrap; }
|
|
3467
|
+
.hist .avatar { width:20px; height:20px; border-radius:50%; flex:none; display:inline-flex; align-items:center;
|
|
3468
|
+
justify-content:center; font-size:8.5px; font-weight:700; letter-spacing:.02em; }
|
|
3469
|
+
.hist .avatar.you { background:var(--surface-3); border:1px solid var(--border-strong); color:var(--text); }
|
|
3470
|
+
.hist .avatar.ai { background:var(--accent-dim); color:var(--text); }
|
|
3471
|
+
/* Gate badge — the signed sign-off, its own column so a long change message never buries it.
|
|
3472
|
+
"Model" (accent, signed) vs "unsigned" (dim/bordered — an absence of signature, not a warning). */
|
|
3473
|
+
.hist .gate-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:9.5px; font-weight:600;
|
|
3474
|
+
text-transform:uppercase; letter-spacing:.05em; background:transparent; border:1px solid var(--border-strong); color:var(--text-dim); }
|
|
3475
|
+
.hist .gate-badge.model { background:var(--accent-soft); border-color:var(--accent-dim); color:var(--accent-bright); }
|
|
3476
|
+
.hist .btn-mini { background:none; border:1px solid var(--border-strong); color:var(--text-muted); border-radius:6px;
|
|
3477
|
+
padding:4px 10px; font-size:11px; cursor:pointer; white-space:nowrap; }
|
|
3478
|
+
.hist .btn-mini:hover { color:var(--text); border-color:var(--accent); }
|
|
3479
|
+
|
|
3480
|
+
/* Rollback confirm — a styled anchored popover (NEVER a native dialog); names the Exports re-lock. */
|
|
3481
|
+
.hist-confirm { position:absolute; z-index:20; width:264px; padding:12px 13px; background:var(--surface-3);
|
|
3482
|
+
border:1px solid var(--border-strong); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); }
|
|
3483
|
+
.hist-confirm .hc-title { font-size:12.5px; font-weight:600; color:var(--text); margin-bottom:5px; }
|
|
3484
|
+
.hist-confirm .hc-body { font-size:11.5px; color:var(--text-muted); line-height:1.45; margin-bottom:11px; }
|
|
3485
|
+
.hist-confirm .hc-act { display:flex; gap:8px; justify-content:flex-end; }
|
|
3486
|
+
.hist-confirm .hc-go { background:var(--accent); border:1px solid var(--accent); color:white; font-weight:600;
|
|
3487
|
+
border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
|
|
3488
|
+
.hist-confirm .hc-go:hover { background:var(--accent-bright); border-color:var(--accent-bright); box-shadow:0 0 14px var(--accent-glow); }
|
|
3489
|
+
.hist-confirm .hc-cancel { background:none; border:1px solid var(--border-strong); color:var(--text-muted);
|
|
3490
|
+
border-radius:6px; padding:5px 11px; font-size:11.5px; cursor:pointer; }
|
|
3491
|
+
.hist-confirm .hc-cancel:hover { color:var(--text); border-color:var(--accent); }
|
package/dist/web/index.html
CHANGED
|
@@ -168,6 +168,7 @@
|
|
|
168
168
|
<button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
|
|
169
169
|
<button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
|
|
170
170
|
<button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
|
|
171
|
+
<button type="button" data-step="history" role="tab" aria-selected="false">History</button>
|
|
171
172
|
</div>
|
|
172
173
|
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
173
174
|
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
@@ -176,6 +177,10 @@
|
|
|
176
177
|
<!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
|
|
177
178
|
contract. workspaces.js fills it on step-switch (renderExports). -->
|
|
178
179
|
<div class="ws-exports" id="ws-exports" hidden></div>
|
|
180
|
+
<!-- History = a SHELL-rendered pane (not an iframe): the project's version ledger.
|
|
181
|
+
workspaces.js fills it on step-switch (renderHistory). Rollback is per-row (a header
|
|
182
|
+
↺ Rollback verb was cut in design review — no selection context; per-row is unambiguous). -->
|
|
183
|
+
<div class="ws-history" id="ws-history" hidden></div>
|
|
179
184
|
</div>
|
|
180
185
|
<div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
|
|
181
186
|
<div class="fav-bar" id="fav-bar">
|
|
@@ -1862,7 +1862,7 @@ const CYCLE_TOL_PX = 8;
|
|
|
1862
1862
|
function resetCycle() { cycleAnchor = null; cycleIds = []; cycleIdx = 0; }
|
|
1863
1863
|
function clickSelect(cx, cy, ctrl) {
|
|
1864
1864
|
let hits = []; try { hits = pickAllAt(cx, cy); } catch { hits = []; }
|
|
1865
|
-
if (!hits.length) { resetCycle(); clearConnSel(); return; }
|
|
1865
|
+
if (!hits.length) { resetCycle(); if (!ctrl) clearConnSel(); return; } // empty → deselect (clears any connection too); Ctrl/Shift+empty-click preserves the selection (parity with 2D + the additive intent — a near-miss shouldn't wipe it)
|
|
1866
1866
|
if (ctrl) { resetCycle(); resetConnState(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest RAW part (leaves connection mode)
|
|
1867
1867
|
const same = cycleAnchor && Math.hypot(cx - cycleAnchor[0], cy - cycleAnchor[1]) <= CYCLE_TOL_PX
|
|
1868
1868
|
&& cycleIds.length === hits.length && cycleIds.every((v, i) => v === hits[i]);
|
|
@@ -2356,9 +2356,9 @@ function onDown(e) {
|
|
|
2356
2356
|
const geo = m ? memberGeometry(m, ppf, dtos) : null;
|
|
2357
2357
|
const mesh = meshById.get(id);
|
|
2358
2358
|
const planeZ = geo ? (geo.line[0][2] + geo.line[1][2]) / 2 : 0;
|
|
2359
|
-
const ctrl = e.ctrlKey || e.metaKey;
|
|
2359
|
+
const ctrl = e.ctrlKey || e.metaKey, add = ctrl || e.shiftKey; // ctrl gates the copy gesture; ctrl OR shift toggles selection on a click (Tekla)
|
|
2360
2360
|
pending = {
|
|
2361
|
-
id, ctrl, ppf, planeZ,
|
|
2361
|
+
id, ctrl, add, ppf, planeZ,
|
|
2362
2362
|
copy: ctrl, copyIds: ctrl ? (selIds.has(id) ? [...selIds] : [id]) : null, // Ctrl+drag copies: the selection if this member is in it, else just it
|
|
2363
2363
|
grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
|
|
2364
2364
|
origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
|
|
@@ -2470,24 +2470,39 @@ function onBoxMove(e) {
|
|
|
2470
2470
|
const x = Math.min(e.clientX, boxSel.x), y = Math.min(e.clientY, boxSel.y);
|
|
2471
2471
|
rubber.style.left = x + 'px'; rubber.style.top = y + 'px';
|
|
2472
2472
|
rubber.style.width = Math.abs(e.clientX - boxSel.x) + 'px'; rubber.style.height = Math.abs(e.clientY - boxSel.y) + 'px';
|
|
2473
|
+
rubber.style.borderStyle = (e.clientX >= boxSel.x) ? 'solid' : 'dashed'; // Tekla affordance: L→R = window (solid), R→L = crossing (dashed)
|
|
2473
2474
|
rubber.style.display = 'block';
|
|
2474
2475
|
}
|
|
2475
|
-
//
|
|
2476
|
-
|
|
2476
|
+
// --- Tekla-style area select: point/segment vs screen rect [x0,y0,x1,y1]. window = both ends enclosed
|
|
2477
|
+
// (L→R drag); crossing = the member's screen footprint touches the rect (R→L drag). Same math the 2D marquee uses. ---
|
|
2478
|
+
function _inRect(p, r) { return p[0] >= r[0] && p[0] <= r[2] && p[1] >= r[1] && p[1] <= r[3]; }
|
|
2479
|
+
function _ccw(a, b, c) { return (c[1] - a[1]) * (b[0] - a[0]) - (b[1] - a[1]) * (c[0] - a[0]); }
|
|
2480
|
+
function _segSeg(a, b, c, d) { return (_ccw(a, c, d) > 0) !== (_ccw(b, c, d) > 0) && (_ccw(a, b, c) > 0) !== (_ccw(a, b, d) > 0); }
|
|
2481
|
+
function _rectHit(p0, p1, r) { if (_inRect(p0, r) || _inRect(p1, r)) return true; const c = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]; for (let i = 0; i < 4; i++) if (_segSeg(p0, p1, c[i], c[(i + 1) % 4])) return true; return false; }
|
|
2482
|
+
// every VISIBLE member whose projected centreline meets the drag rect — window: both ends inside · crossing: touches
|
|
2483
|
+
function membersInRect(x0, y0, x1, y1, windowMode) {
|
|
2477
2484
|
const rect = canvasEl.getBoundingClientRect();
|
|
2478
|
-
const
|
|
2485
|
+
const r = [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)];
|
|
2486
|
+
const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
|
|
2487
|
+
const memById = new Map(members().map((m) => [m.id, m]));
|
|
2488
|
+
const toScreen = (p) => { const w = new THREE.Vector3(p[0], p[1], p[2]).project(camera); return { s: [rect.left + (w.x * 0.5 + 0.5) * rect.width, rect.top + (-w.y * 0.5 + 0.5) * rect.height], behind: w.z > 1 }; };
|
|
2479
2489
|
const out = [];
|
|
2480
2490
|
for (const [id, m] of meshById) {
|
|
2481
2491
|
if (!m.visible || m.userData.derived) continue; // derived connection parts aren't member-selectable (mirrors pickAt; dbl-click-zoom deliberately includes them)
|
|
2482
|
-
const
|
|
2483
|
-
|
|
2484
|
-
if (
|
|
2492
|
+
const mem = memById.get(id); if (!mem) continue;
|
|
2493
|
+
let a, b; try { const g = memberGeometry(mem, ppf, dtos); a = toScreen(g.line[0]); b = toScreen(g.line[1]); } catch { continue; }
|
|
2494
|
+
if (a.behind || b.behind) { // an endpoint is behind the camera → the projected segment is unreliable; fall back to the member-centre test (old behaviour, no regression for members straddling the camera plane)
|
|
2495
|
+
const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
|
|
2496
|
+
if (_inRect([rect.left + (w.x * 0.5 + 0.5) * rect.width, rect.top + (-w.y * 0.5 + 0.5) * rect.height], r)) out.push(id);
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
if (windowMode ? (_inRect(a.s, r) && _inRect(b.s, r)) : _rectHit(a.s, b.s, r)) out.push(id);
|
|
2485
2500
|
}
|
|
2486
2501
|
return out;
|
|
2487
2502
|
}
|
|
2488
|
-
// Connection Components whose
|
|
2489
|
-
//
|
|
2490
|
-
function connsInRect(x0, y0, x1, y1) {
|
|
2503
|
+
// Connection Components whose screen footprint (projected bbox) meets a marquee rect — so area-select picks
|
|
2504
|
+
// up connections, not just members. window: the whole footprint enclosed · crossing: it overlaps the rect.
|
|
2505
|
+
function connsInRect(x0, y0, x1, y1, windowMode) {
|
|
2491
2506
|
const rect = canvasEl.getBoundingClientRect();
|
|
2492
2507
|
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
|
2493
2508
|
const conns = new Set();
|
|
@@ -2495,9 +2510,16 @@ function connsInRect(x0, y0, x1, y1) {
|
|
|
2495
2510
|
const out = [];
|
|
2496
2511
|
for (const conn of conns) {
|
|
2497
2512
|
const b = connBox(conn); if (b.isEmpty()) continue;
|
|
2498
|
-
|
|
2499
|
-
const sx = rect.left + (
|
|
2500
|
-
if (
|
|
2513
|
+
let sx0 = Infinity, sy0 = Infinity, sx1 = -Infinity, sy1 = -Infinity, behind = false;
|
|
2514
|
+
for (let i = 0; i < 8; i++) { const v = new THREE.Vector3(i & 1 ? b.max.x : b.min.x, i & 2 ? b.max.y : b.min.y, i & 4 ? b.max.z : b.min.z).project(camera); if (v.z > 1) { behind = true; break; } const sx = rect.left + (v.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-v.y * 0.5 + 0.5) * rect.height; sx0 = Math.min(sx0, sx); sy0 = Math.min(sy0, sy); sx1 = Math.max(sx1, sx); sy1 = Math.max(sy1, sy); }
|
|
2515
|
+
if (behind) { // a bbox corner is behind the camera → the screen AABB is unreliable; fall back to the connection-centre test (old behaviour)
|
|
2516
|
+
const w = b.getCenter(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
|
|
2517
|
+
const cx = rect.left + (w.x * 0.5 + 0.5) * rect.width, cy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
|
|
2518
|
+
if (cx >= lo.x && cx <= hi.x && cy >= lo.y && cy <= hi.y) out.push(conn);
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
const hit = windowMode ? (sx0 >= lo.x && sx1 <= hi.x && sy0 >= lo.y && sy1 <= hi.y) : (sx0 <= hi.x && sx1 >= lo.x && sy0 <= hi.y && sy1 >= lo.y);
|
|
2522
|
+
if (hit) out.push(conn);
|
|
2501
2523
|
}
|
|
2502
2524
|
return out;
|
|
2503
2525
|
}
|
|
@@ -2509,12 +2531,14 @@ function onUp(e) {
|
|
|
2509
2531
|
const bs = boxSel; boxSel = null;
|
|
2510
2532
|
if (bs) { // empty-space gesture: drag = box-select, click = clear selection
|
|
2511
2533
|
if (bs.moved) { resetCycle();
|
|
2512
|
-
const
|
|
2513
|
-
const
|
|
2514
|
-
|
|
2515
|
-
|
|
2534
|
+
const windowMode = e.clientX >= bs.x; // Tekla: drag L→R = window (fully enclose); R→L = crossing (touch)
|
|
2535
|
+
const additive = e.ctrlKey || e.metaKey || e.shiftKey; // Ctrl/Shift → toggle the framed set into the current selection (add/remove), else replace
|
|
2536
|
+
const memberIds = membersInRect(bs.x, bs.y, e.clientX, e.clientY, windowMode);
|
|
2537
|
+
const connIds = connsInRect(bs.x, bs.y, e.clientX, e.clientY, windowMode);
|
|
2538
|
+
if (!additive && !memberIds.length && connIds.length === 1) selectWholeConn(connIds[0]); // a lone connection framed (no modifier) → full component select (envelope + inspector), same as a click
|
|
2539
|
+
else { resetConnState(); const ids = [...memberIds, ...connIds.flatMap((c) => connChildIds(c))]; if (api && api.onSelectMany) api.onSelectMany(ids, additive); } // members and/or several connections → multi-select (replace, or Ctrl/Shift-toggle) INCLUDING the connections' parts
|
|
2516
2540
|
}
|
|
2517
|
-
else clickSelect(e.clientX, e.clientY, e.ctrlKey || e.metaKey); // click in empty space → cycle-pick (may land on a derived part) or clear
|
|
2541
|
+
else clickSelect(e.clientX, e.clientY, e.ctrlKey || e.metaKey || e.shiftKey); // click in empty space → cycle-pick (may land on a derived part) or clear
|
|
2518
2542
|
downXY = null; return;
|
|
2519
2543
|
}
|
|
2520
2544
|
const p = pending, wasDragging = dragging; pending = dragging = null;
|
|
@@ -2545,8 +2569,8 @@ function onUp(e) {
|
|
|
2545
2569
|
return;
|
|
2546
2570
|
}
|
|
2547
2571
|
if (!wasDragging) {
|
|
2548
|
-
if (p.id && !p.geo && !p.epDrag && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, p.
|
|
2549
|
-
clickSelect(e.clientX, e.clientY, p.
|
|
2572
|
+
if (p.id && !p.geo && !p.epDrag && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, p.add); return; } // a blocked drag (drag-move/copy OFF) acts on the GRABBED member, not whatever's under the release point (Ctrl/Shift toggles it)
|
|
2573
|
+
clickSelect(e.clientX, e.clientY, p.add); return; // click → cycle-pick: member, or a derived part stacked on it at a joint (Ctrl/Shift+click = additive select)
|
|
2550
2574
|
}
|
|
2551
2575
|
if (p.copy) { // Ctrl+drag → commit a copy at the plan delta (the editor clones + selects); the ghost was the preview
|
|
2552
2576
|
if (p.delta && !(p.delta[0] === 0 && p.delta[1] === 0) && api && api.onCopyDrag3d) api.onCopyDrag3d(p.copyIds, p.delta[0], p.delta[1]);
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
circle.handle{fill:var(--bg);stroke:#f8fafc;stroke-width:3;cursor:grab}
|
|
52
52
|
line.seg{stroke:#475569;stroke-width:2;opacity:0;cursor:crosshair} body.add line.seg{opacity:.5}
|
|
53
53
|
line.seg:hover{stroke:#fbbf24;opacity:1;stroke-width:4}
|
|
54
|
-
rect.marquee{fill:rgba(59,130,246,.08);stroke:var(--brand);stroke-width:1;stroke-dasharray:5 4;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
54
|
+
rect.marquee{fill:rgba(59,130,246,.08);stroke:var(--brand);stroke-width:1;stroke-dasharray:5 4;vector-effect:non-scaling-stroke;pointer-events:none} /* dashed = crossing (R→L, touch) */
|
|
55
|
+
rect.marquee.window{stroke-dasharray:none} /* solid = window (L→R, fully enclose) — Tekla-style affordance */
|
|
55
56
|
.legend span{display:inline-flex;align-items:center;gap:4px;margin:0 8px 4px 0;white-space:nowrap}
|
|
56
57
|
.sw{width:12px;height:12px;border-radius:2px;display:inline-block}
|
|
57
58
|
.swc{width:14px;height:14px;padding:0;border:1px solid #475569;border-radius:3px;cursor:pointer;background:none;flex:none;-webkit-appearance:none;appearance:none}
|
|
@@ -1440,7 +1441,7 @@ function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"
|
|
|
1440
1441
|
if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
|
|
1441
1442
|
function stats(){
|
|
1442
1443
|
document.getElementById('mc').textContent=P.members.length;
|
|
1443
|
-
let w=0,rfi=0; for(const m of P.members){const wpf=_wt(m.profile); if(wpf==null)rfi++; else w+=
|
|
1444
|
+
let w=0,rfi=0; for(const m of P.members){const wpf=_wt(m.profile); if(wpf==null)rfi++; else w+=_lenFt(ensureMeta(m))*wpf;}
|
|
1444
1445
|
document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
|
|
1445
1446
|
}
|
|
1446
1447
|
// Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
|
|
@@ -1685,7 +1686,7 @@ function panel(){
|
|
|
1685
1686
|
return;
|
|
1686
1687
|
}}
|
|
1687
1688
|
const arr=selArr();
|
|
1688
|
-
if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" data-tip="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl+click</b> to add/remove; drag an empty area to <b>box-select</b> (Ctrl adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
|
|
1689
|
+
if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" data-tip="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl</b>/<b>Shift+click</b> to add/remove; drag an empty area to <b>box-select</b> — <b>left→right</b> grabs fully-enclosed, <b>right→left</b> grabs touched (Ctrl/Shift adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
|
|
1689
1690
|
'<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
|
|
1690
1691
|
'<div class=hint style="margin:0 0 6px;font-size:11px">Level <b>'+esc(fmtFtIn(defaultTOS))+'</b> ('+esc(P.sheet)+'). '+((P.tos_callouts&&P.tos_callouts.length)?'Per-zone T.O. STEEL callouts from the drawing applied to each member; ':'')+'ends ticked <b>default</b> follow the level.</div>'+
|
|
1691
1692
|
'<div class=elab>Default TOS</div><input id=defTos inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="'+esc(fmtFtIn(defaultTOS))+'"><span class=edec>'+esc(fmtDecIn(defaultTOS))+'</span>'+
|
|
@@ -1699,9 +1700,9 @@ function panel(){
|
|
|
1699
1700
|
arr.forEach(ensureMeta);
|
|
1700
1701
|
const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
|
|
1701
1702
|
const allBeam=cols.length===0, allCol=beams.length===0;
|
|
1702
|
-
const totalL=arr.reduce((t,m)=>t+
|
|
1703
|
+
const totalL=arr.reduce((t,m)=>t+_lenFt(m),0).toFixed(1);
|
|
1703
1704
|
const allKnown=arr.every(m=>_wt(m.profile)!=null);
|
|
1704
|
-
const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+
|
|
1705
|
+
const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+_lenFt(m)*_wt(m.profile),0)):null;
|
|
1705
1706
|
const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
|
|
1706
1707
|
const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
|
|
1707
1708
|
const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
|
|
@@ -1764,7 +1765,7 @@ function panel(){
|
|
|
1764
1765
|
wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
|
|
1765
1766
|
{const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});}}
|
|
1766
1767
|
return;}
|
|
1767
|
-
const m=ensureMeta(arr[0]), wpf=_wt(m.profile), L=(
|
|
1768
|
+
const m=ensureMeta(arr[0]), wpf=_wt(m.profile), L=_lenFt(m).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
|
|
1768
1769
|
const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
|
|
1769
1770
|
const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
|
|
1770
1771
|
const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
|
|
@@ -1823,7 +1824,7 @@ function panel(){
|
|
|
1823
1824
|
<div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" data-tip="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
|
|
1824
1825
|
<div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
|
|
1825
1826
|
<div class="seg2 mtype" id=mTypeS style="margin-top:6px" data-tip="Member type — drives legend grouping (independent of the Beam/Column geometry above)">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${memberTypeOf(m)===t.k?'on':''}">${t.label}</button>`).join('')}</div>
|
|
1826
|
-
<div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(
|
|
1827
|
+
<div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(_lenFt(m)*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
|
|
1827
1828
|
<div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} data-tip="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
|
|
1828
1829
|
${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
|
|
1829
1830
|
${elev}
|
|
@@ -1981,7 +1982,21 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
|
|
|
1981
1982
|
// checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
|
|
1982
1983
|
// go through the same imperial formatters the side pane uses; the contract stays canonical metric.
|
|
1983
1984
|
function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
|
|
1984
|
-
|
|
1985
|
+
// True 3D member length in feet = straight-line distance between the member's two end NODES, exactly as the 3D
|
|
1986
|
+
// scene places them — so it's correct for sloped members (braces, ramped beams, battered columns), not just the
|
|
1987
|
+
// plan projection. Plan XY is in plan px (÷ppf→ft); elevations (TOS/BOS) are in inches (÷12→ft). Elevation is
|
|
1988
|
+
// default-aware (dTos = the plan datum a default-follow end/top rides; a null column base sits on the 0 datum).
|
|
1989
|
+
// Shared by the display (_lenFt, active-plan default) and the BOM (_mTons, per-plan default) so the two agree.
|
|
1990
|
+
function _len3dFt(m,ppf,dTos){
|
|
1991
|
+
const a=m.wp&&m.wp[0], b=m.wp&&m.wp[1]; if(!a||!b)return 0;
|
|
1992
|
+
const p=ppf>0?ppf:1, dx=(b[0]-a[0])/p, dy=(b[1]-a[1])/p; // plan run, feet
|
|
1993
|
+
const eff=o=>o?(o.tosDef!==false?dTos:o.tos):null; // effective elevation (inches), same precedence as _effTos: a default-follow end rides the datum dTos; only an explicit (tosDef===false) end uses its stored TOS
|
|
1994
|
+
let z0,z1;
|
|
1995
|
+
if(m.role==='column'){z0=(m.col&&m.col.bos!=null)?m.col.bos:0; z1=eff(m.col);} // bottom (BOS) → top (TOS)
|
|
1996
|
+
else{z0=eff(m.ends&&m.ends[0]); z1=eff(m.ends&&m.ends[1]);} // start TOS → end TOS
|
|
1997
|
+
const dz=((z1==null?0:z1)-(z0==null?0:z0))/12; // vertical rise, feet
|
|
1998
|
+
return Math.hypot(dx,dy,dz);}
|
|
1999
|
+
function _lenFt(m){return _len3dFt(m,FT,defaultTOS);}
|
|
1985
2000
|
const PROP_DEFS=[
|
|
1986
2001
|
{key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
|
|
1987
2002
|
{key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
|
|
@@ -2599,19 +2614,19 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
2599
2614
|
return;}}
|
|
2600
2615
|
if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
|
|
2601
2616
|
if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
|
|
2602
|
-
if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
|
|
2603
|
-
// Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl
|
|
2604
|
-
//
|
|
2605
|
-
if(!
|
|
2606
|
-
const canDrag=dragMoveOn; // the toggle gates BOTH gestures: left-drag=move and Ctrl+drag=copy (Ctrl+CLICK still toggles selection below — a click, not a drag)
|
|
2607
|
-
const srcIds=(
|
|
2617
|
+
if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),add=(ctrl||e.shiftKey),p=toSvg(e);
|
|
2618
|
+
// Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl/Shift defer
|
|
2619
|
+
// their select to up — a Ctrl-DRAG copies, a Shift-DRAG moves (Shift also ortho-locks the move to H/V), and an additive-CLICK toggles selection (Tekla: Ctrl or Shift).
|
|
2620
|
+
if(!add&&!selIds.has(id)){selIds=new Set([id]);render();}
|
|
2621
|
+
const canDrag=dragMoveOn; // the toggle gates BOTH gestures: left-drag=move and Ctrl+drag=copy (Ctrl/Shift+CLICK still toggles selection below — a click, not a drag)
|
|
2622
|
+
const srcIds=(add&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // Ctrl/Shift on an unselected member acts on just it; else the whole selection
|
|
2608
2623
|
const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
|
|
2609
2624
|
buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
|
|
2610
|
-
drag={type:'grab',copy:ctrl,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
|
|
2625
|
+
drag={type:'grab',copy:ctrl,add,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
|
|
2611
2626
|
svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // always capture: even an unarmed grab tracks the gesture so a blocked drag can teach the toggle
|
|
2612
2627
|
if((t===svg||t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e); // t===svg → drag on bare canvas (no raster) still box-selects
|
|
2613
|
-
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
2614
|
-
drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
2628
|
+
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee window');svg.appendChild(rc);
|
|
2629
|
+
drag={type:'marquee',x0:p.x,y0:p.y,sx0:e.clientX,window:true,add:(e.ctrlKey||e.metaKey||e.shiftKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
2615
2630
|
svg.addEventListener('pointermove',e=>{
|
|
2616
2631
|
if(csaxisMode){csPrev(e);return;}
|
|
2617
2632
|
if(dimMode){dimPrev(e);return;}
|
|
@@ -2656,6 +2671,8 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2656
2671
|
else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
|
|
2657
2672
|
drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
|
|
2658
2673
|
if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
|
|
2674
|
+
drag.window=(e.clientX>=drag.sx0); // Tekla: drag L→R = window (enclose); R→L = crossing (touch)
|
|
2675
|
+
drag.rect.classList.toggle('window',drag.window); // solid rect = window, dashed = crossing (visual affordance)
|
|
2659
2676
|
drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
|
|
2660
2677
|
if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
|
|
2661
2678
|
if(!drag.moved){
|
|
@@ -2665,7 +2682,8 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2665
2682
|
if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
|
|
2666
2683
|
const ns=new Set(),ci=[];
|
|
2667
2684
|
for(const it of drag.items){const src=byId(it.id);if(!src)continue;const c=cloneMember(src);P.members.push(c);ns.add(c.id);ci.push({id:c.id,o0:c.wp[0].slice(),o1:c.wp[1].slice()});}
|
|
2668
|
-
selIds=ns;drag.items=ci;buildSnap(ns);render();}
|
|
2685
|
+
selIds=ns;drag.items=ci;buildSnap(ns);render();} // select the clones + snap them to the sources/others; render shows them (drag continues via updateLine)
|
|
2686
|
+
else if(drag.add&&!selIds.has(drag.clickId)){selIds=new Set([drag.clickId]);render();}} // Shift/Ctrl-drag on an unselected member → move just it (select so state matches the gesture)
|
|
2669
2687
|
if(!drag.armed)return;
|
|
2670
2688
|
let dx=rawdx,dy=rawdy;
|
|
2671
2689
|
if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
|
|
@@ -2700,13 +2718,20 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
2700
2718
|
const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
|
|
2701
2719
|
if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
|
|
2702
2720
|
drag=null;render();return;}
|
|
2703
|
-
if(drag.type==='marquee'){
|
|
2704
|
-
|
|
2705
|
-
|
|
2721
|
+
if(drag.type==='marquee'){const r=drag.cur,win=drag.window!==false,add=drag.add;
|
|
2722
|
+
if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){
|
|
2723
|
+
const memHit=m=>win?(inRect(m.wp[0],r)&&inRect(m.wp[1],r)):rectHit(m.wp[0],m.wp[1],r); // window: both ends enclosed · crossing: touched
|
|
2724
|
+
const mIds=P.members.filter(memHit).map(m=>m.id), dIds=[];
|
|
2725
|
+
if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot); // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
|
|
2726
|
+
const hit=win?(inRect(g.p1,r)&&inRect(g.p2,r)):(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r));
|
|
2727
|
+
if(hit)dIds.push(d.id);}
|
|
2728
|
+
if(add){for(const id of mIds)selIds.has(id)?selIds.delete(id):selIds.add(id);for(const id of dIds)selDimIds.has(id)?selDimIds.delete(id):selDimIds.add(id);} // Ctrl/Shift → toggle each framed object (add/remove)
|
|
2729
|
+
else{selIds.clear();selDimIds.clear();for(const id of mIds)selIds.add(id);for(const id of dIds)selDimIds.add(id);}
|
|
2730
|
+
}else if(!add){selIds.clear();selDimIds.clear();} // a click (no drag) on empty space clears — unless a modifier is held
|
|
2706
2731
|
drag.rect.remove();drag=null;render();return;}
|
|
2707
2732
|
if(drag.type==='grab'){
|
|
2708
2733
|
if(!drag.moved){ // no drag past threshold → it was a click
|
|
2709
|
-
if(drag.
|
|
2734
|
+
if(drag.add){selIds.has(drag.clickId)?selIds.delete(drag.clickId):selIds.add(drag.clickId);} // Ctrl/Shift+click toggles selection (deferred from down)
|
|
2710
2735
|
drag=null;render();return;}
|
|
2711
2736
|
if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
|
|
2712
2737
|
drag=null;render();
|
|
@@ -2871,7 +2896,10 @@ const view3dApi={
|
|
|
2871
2896
|
copesByMember=cm;partsById=pb;return sc;}, // cache resolved parts by id (real dims for the part Inspector) AND cope labels per member; placed details ride in as image elements
|
|
2872
2897
|
|
|
2873
2898
|
onSelect:(id,additive)=>{selDimIds.clear();sel3dDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
|
|
2874
|
-
onSelectMany:(ids)=>{selDimIds.clear();sel3dDimIds.clear();
|
|
2899
|
+
onSelectMany:(ids,additive)=>{selDimIds.clear();sel3dDimIds.clear();
|
|
2900
|
+
if(additive){for(const id of (ids||[]))selIds.has(id)?selIds.delete(id):selIds.add(id);} // Ctrl/Shift + 3D area-select → toggle each framed object into the current selection (add/remove), like Tekla
|
|
2901
|
+
else selIds=new Set(ids||[]);
|
|
2902
|
+
if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
|
|
2875
2903
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
2876
2904
|
snapEnabled:()=>snapEnabled, // the persistent running-snaps (⋯ menu → Snapping) — 3D honours the same on/off set
|
|
2877
2905
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
@@ -3369,7 +3397,7 @@ stage.addEventListener('pointerdown',e=>{if(e.button!==1)return;e.preventDefault
|
|
|
3369
3397
|
stage.addEventListener('pointermove',e=>{if(!pan)return;stage.scrollLeft=pan.sl-(e.clientX-pan.x);stage.scrollTop=pan.st-(e.clientY-pan.y);});
|
|
3370
3398
|
stage.addEventListener('pointerup',()=>{if(pan){pan=null;stage.style.cursor='';}});
|
|
3371
3399
|
document.getElementById('exp').onclick=()=>{const out={pt_per_ft:FT,elev_units:'inch',members:P.members.map(m=>{ensureMeta(m);return {profile:m.profile,workpoint:m.wp,role:m.role,rfi:_wt(m.profile)==null,
|
|
3372
|
-
length_ft:+(
|
|
3400
|
+
length_ft:+_lenFt(m).toFixed(1),weight_plf:_wt(m.profile), // columns measure vertical height (TOS−BOS), not their ~0 plan-point distance
|
|
3373
3401
|
ends:m.role==='column'?undefined:m.ends, column:m.role==='column'?m.col:undefined};})};
|
|
3374
3402
|
const b=new Blob([JSON.stringify(out,null,1)],{type:'application/json'});
|
|
3375
3403
|
const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='contract_edited.json';a.click();};
|
|
@@ -3572,7 +3600,7 @@ function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();retu
|
|
|
3572
3600
|
const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
|
|
3573
3601
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
3574
3602
|
g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
|
|
3575
|
-
list.map((m,i)=>{ensureMeta(m);const L=(
|
|
3603
|
+
list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
|
|
3576
3604
|
return `<tr><td><span class=rfichip>${i+1}</span></td>
|
|
3577
3605
|
<td><input class=combo data-src=profiles data-mid="${esc(m.id)}" value="${esc(m.profile||'')}" placeholder="e.g. W16X26" autocomplete=off style="width:124px"></td>
|
|
3578
3606
|
<td>${m.role==='column'?'Column':'Beam'}</td><td>${L} ft</td><td class=rea>${esc(rfiReason(m))}</td>
|
|
@@ -3618,10 +3646,8 @@ function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
|
|
|
3618
3646
|
const isd=dup.has(m.id);if(isd)F.push({key:'dup',label:'Duplicate',state:'fail',detail:'coincident with a kept member — deleting dedupes the BOM'});
|
|
3619
3647
|
if(m.verified===true)F.push({key:'verified',label:'Verified',state:'ok',detail:'human-confirmed'});
|
|
3620
3648
|
const band=m.verified===true?'verified':isd?'low':(sched||asm)?'med':'high';return {band,factors:F};}
|
|
3621
|
-
function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;
|
|
3622
|
-
|
|
3623
|
-
else{if(!m.wp||m.wp.length<2)return 0;ft=Math.hypot(m.wp[0][0]-m.wp[1][0],m.wp[0][1]-m.wp[1][1])/(ptPerFt>0?ptPerFt:1);}
|
|
3624
|
-
return ft*plf/2000;}
|
|
3649
|
+
function _mTons(m,ptPerFt,dTos){const plf=_wt(m.profile);if(plf==null)return 0;
|
|
3650
|
+
return _len3dFt(m,ptPerFt,dTos)*plf/2000;} // true 3D length × lb/ft — same helper the inspector/top-bar use, so BOM and display agree
|
|
3625
3651
|
function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
|
|
3626
3652
|
const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
|
|
3627
3653
|
function _scoreConnections(){
|
|
@@ -3649,7 +3675,7 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
|
|
|
3649
3675
|
const _conn=_scoreConnections();
|
|
3650
3676
|
const known=new Set();for(const p of plans)if(p.sheet)known.add(p.sheet.toUpperCase());for(const s of Object.keys(C.detail_bubbles||{}))known.add(s.toUpperCase());
|
|
3651
3677
|
plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
|
|
3652
|
-
for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft),factors:r.factors});}
|
|
3678
|
+
for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft,plan.default_tos),factors:r.factors});}
|
|
3653
3679
|
for(const d of (plan.details||[])){const sh=_detSheet(d.text);const ok=sh!=null&&known.has(sh.toUpperCase());byDetail.push({text:d.text||'',planIdx:pi,sheet:plan.sheet||'',band:ok?'high':'low',reason:ok?('references '+sh+' (in the set)'):sh?('references '+sh+' — not found in the set'):'no sheet reference parsed'});}});
|
|
3654
3680
|
const roll=(arr,cw)=>{const c=_cnt0();let n=0,d=0,t=0;for(const m of arr){c[m.band]++;if(m.band==='rfi')continue;const w=cw?1:m.tons;n+=w*BANDW[m.band];d+=w;t+=m.tons;}return {score:d>0?Math.round(n/d*100):null,tons:t,counts:c};};
|
|
3655
3681
|
const dRoll=arr=>{const c=_cnt0();let n=0,d=0;for(const x of arr){c[x.band]++;n+=BANDW[x.band];d++;}return {score:d>0?Math.round(n/d*100):null,tons:0,counts:c};};
|
|
@@ -3949,6 +3975,25 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
|
|
|
3949
3975
|
snapEnabled.end=true;const x4=snap(1,1);ok(x4.kind==='end','re-enabling Endpoint restores it');
|
|
3950
3976
|
const ax2=snap(2,40);ok(ax2.x===0,'with Endpoint on, axis-align still pulls to the endpoint column');
|
|
3951
3977
|
Object.assign(snapEnabled,se);P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
|
|
3978
|
+
// 11) member length — true 3D distance between the two end nodes: level beam = plan run, plumb column = TOS−BOS
|
|
3979
|
+
// height (its plan points coincide → 0 plan distance), and a SLOPED member picks up its elevation rise.
|
|
3980
|
+
{const sf=FT,st=defaultTOS;FT=12;defaultTOS=198;
|
|
3981
|
+
ok(near(_lenFt({role:'beam',wp:[[0,0],[120,0]]}),10),'_lenFt level beam = plan run (no TOS delta)');
|
|
3982
|
+
ok(near(_lenFt({role:'column',wp:[[0,0],[0,0]],col:{bos:0,tos:120,tosDef:false}}),10),'_lenFt plumb column = |TOS−BOS| height (not ~0 plan distance)');
|
|
3983
|
+
ok(near(_lenFt({role:'column',wp:[[5,5],[5,5]],col:{bos:12,tos:null,tosDef:true}}),(198-12)/12),'_lenFt column default TOS follows the level');
|
|
3984
|
+
// sloped brace: 3 ft plan run + 3 ft TOS rise → true length √(3²+3²), NOT the 3 ft plan projection
|
|
3985
|
+
ok(near(_lenFt({role:'beam',wp:[[0,0],[36,0]],ends:[{tos:0,tosDef:false},{tos:36,tosDef:false}]}),Math.hypot(3,0,3)),'_lenFt sloped beam = true 3D length (plan run + TOS rise)');
|
|
3986
|
+
// battered column: 4 ft plan lean + 12 ft rise → √(4²+12²), not the bare height
|
|
3987
|
+
ok(near(_lenFt({role:'column',wp:[[0,0],[48,0]],col:{bos:0,tos:144,tosDef:false}}),Math.hypot(4,0,12)),'_lenFt battered column includes the plan lean');
|
|
3988
|
+
// _mTons (BOM) shares the SAME 3D helper → agrees with the inspector (no BOM-vs-display divergence), incl. default-follow
|
|
3989
|
+
const dc={role:'column',profile:'W10X49',col:{bos:0,tos:null,tosDef:true}};
|
|
3990
|
+
ok(near(_mTons(dc,10,198), _lenFt(dc)*49/2000),'_mTons shares _len3dFt with the display (agree even for a default-follow column)');
|
|
3991
|
+
FT=sf;defaultTOS=st;}
|
|
3992
|
+
// 12) Tekla area-select — window (L→R drag) = fully enclosed; crossing (R→L drag) = touched
|
|
3993
|
+
{const r=[0,0,10,10],win=(a,b)=>inRect(a,r)&&inRect(b,r),crs=(a,b)=>rectHit(a,b,r);
|
|
3994
|
+
ok(win([2,2],[8,8])&&crs([2,2],[8,8]),'fully-inside member: window and crossing both pick it');
|
|
3995
|
+
ok(!win([5,5],[50,5])&&crs([5,5],[50,5]),'straddling member: crossing picks it, window does not');
|
|
3996
|
+
ok(!win([20,20],[30,30])&&!crs([20,20],[30,30]),'outside member: neither picks it');}
|
|
3952
3997
|
const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (frame + snap + transform math)';
|
|
3953
3998
|
console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
|
|
3954
3999
|
})();}
|