@floless/app 0.72.3 → 0.73.1
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 +2 -2
- package/dist/web/steel-3d-view.js +46 -22
- package/dist/web/steel-editor.html +154 -49
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.73.1" : void 0,
|
|
53026
53026
|
pkgVersion: readPkgVersion()
|
|
53027
53027
|
});
|
|
53028
53028
|
}
|
|
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
|
|
|
53032
53032
|
return "dev";
|
|
53033
53033
|
}
|
|
53034
53034
|
function appChannel() {
|
|
53035
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.73.1" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -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}
|
|
@@ -353,6 +354,15 @@
|
|
|
353
354
|
#propPop.connmode .ppfoot{display:none}
|
|
354
355
|
#propPop.connmode #ppClear{display:none}
|
|
355
356
|
#propPop.connmode .pprow{cursor:default;padding-left:14px} /* no checkbox → nudge the name so it doesn't float where the box was */
|
|
357
|
+
/* Selection tree: clickable object rows (connection nodes + parts + member leaves), reusing the legend's
|
|
358
|
+
chevron/swatch/selected-row vocabulary — click selects in the model (Ctrl toggle, Shift range). */
|
|
359
|
+
#propPop .pprow.trow{cursor:pointer;padding-left:6px}
|
|
360
|
+
#propPop .pprow.trow.ptrow{padding-left:24px} /* parts indent under their connection node */
|
|
361
|
+
#propPop .pprow .chev{width:14px;flex:none;color:var(--mut);text-align:center;font-size:9px;cursor:pointer}
|
|
362
|
+
#propPop .pprow .tsw{width:6px;height:6px;flex:none;border-radius:50%;background:var(--mut)} /* a round status dot — deliberately NOT a checkbox (label checkboxes are square inputs) */
|
|
363
|
+
#propPop .pprow.node{font-weight:500}
|
|
364
|
+
#propPop .pprow .cnt{color:var(--mut);font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
|
|
365
|
+
#propPop .pprow.selrow{border-left:2px solid var(--brand);background:rgba(59,130,246,.16)}
|
|
356
366
|
/* thin cluster divider in the 3D toolbar */
|
|
357
367
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
358
368
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -1431,7 +1441,7 @@ function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"
|
|
|
1431
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]);}}
|
|
1432
1442
|
function stats(){
|
|
1433
1443
|
document.getElementById('mc').textContent=P.members.length;
|
|
1434
|
-
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;}
|
|
1435
1445
|
document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
|
|
1436
1446
|
}
|
|
1437
1447
|
// Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
|
|
@@ -1453,6 +1463,37 @@ function connSelInfo(){
|
|
|
1453
1463
|
const whole=childIds.length>0&&childIds.every(id=>selIds.has(id));
|
|
1454
1464
|
return {conn,kind:j.kind,main:j.main,joint:j,childIds,whole,mode:whole?'whole':'part'};
|
|
1455
1465
|
}
|
|
1466
|
+
// ── Selection as a browsable tree (Properties popup): group the flat selIds into connections (each with
|
|
1467
|
+
// its parts) + loose members, so a MIXED selection (a connection + members, several connections) renders
|
|
1468
|
+
// as one tree instead of falling back to member-only. connChildIdsEditor = a connection's selectable parts
|
|
1469
|
+
// (copes excluded — subtractive, not in the 3D mesh set). ---
|
|
1470
|
+
function connChildIdsEditor(conn){return Object.keys(partsById||{}).filter(k=>{const e=partsById[k];return e&&e.conn===conn&&e.kind!=='cut';});}
|
|
1471
|
+
function selTree(){
|
|
1472
|
+
const conns=[],cmap={},members=[];
|
|
1473
|
+
for(const id of selIds){
|
|
1474
|
+
const el=(partsById||{})[id];
|
|
1475
|
+
if(el&&el.conn){ if(!cmap[el.conn]){const j=(C.joints||[]).find(x=>x&&x.id===el.conn);cmap[el.conn]={conn:el.conn,kind:j?j.kind:'',main:j?j.main:'',joint:j,childIds:connChildIdsEditor(el.conn)};conns.push(cmap[el.conn]);} }
|
|
1476
|
+
else if(P.members.some(m=>m.id===id)) members.push(id);
|
|
1477
|
+
}
|
|
1478
|
+
return {conns,members};
|
|
1479
|
+
}
|
|
1480
|
+
// Ctrl/Shift multi-select from a popup tree row → drives the model selection (same semantics as the objects
|
|
1481
|
+
// browser / onSelectDim3d). treeRowIds = the flat, in-display-order LEAF ids (expanded parts + members) for
|
|
1482
|
+
// Shift-range; treeAnchor = the range anchor; treeExpanded = which connection nodes are open (transient).
|
|
1483
|
+
let treeExpanded=new Set(), treeAnchor=null, treeRowIds=[];
|
|
1484
|
+
function treeSelectLeaf(id,mods){
|
|
1485
|
+
let next;
|
|
1486
|
+
if(mods&&mods.shift&&treeAnchor!=null&&treeRowIds.includes(treeAnchor)&&treeRowIds.includes(id)){const i0=treeRowIds.indexOf(treeAnchor),i1=treeRowIds.indexOf(id);next=treeRowIds.slice(Math.min(i0,i1),Math.max(i0,i1)+1);}
|
|
1487
|
+
else if(mods&&(mods.ctrl||mods.meta)){next=new Set(selIds);next.has(id)?next.delete(id):next.add(id);next=[...next];treeAnchor=id;}
|
|
1488
|
+
else{next=[id];treeAnchor=id;}
|
|
1489
|
+
selIds=new Set(next);selDimIds.clear();sel3dDimIds.clear();render();
|
|
1490
|
+
}
|
|
1491
|
+
function treeSelectConn(conn,mods){
|
|
1492
|
+
const cids=connChildIdsEditor(conn);
|
|
1493
|
+
if(mods&&(mods.ctrl||mods.meta)){const next=new Set(selIds);const all=cids.length&&cids.every(id=>next.has(id));cids.forEach(id=>all?next.delete(id):next.add(id));selIds=next;selDimIds.clear();sel3dDimIds.clear();treeAnchor=cids[0]||null;render();}
|
|
1494
|
+
else if(window.Steel3DView&&window.Steel3DView.selectWholeConn){window.Steel3DView.selectWholeConn(conn);treeAnchor=cids[0]||null;} // plain → whole connection (envelope + inspector), same as a 3D click
|
|
1495
|
+
else{selIds=new Set(cids);selDimIds.clear();sel3dDimIds.clear();treeAnchor=cids[0]||null;render();}
|
|
1496
|
+
}
|
|
1456
1497
|
// The floating breadcrumb over the 3D canvas: Model ▸ <Connection> [▸ <Part>]. Segments jump levels via the
|
|
1457
1498
|
// 3D view's own ascend/whole-select so the canvas selection + envelope stay in lockstep. 3D-only; hidden at root.
|
|
1458
1499
|
function updateConnCrumb(){
|
|
@@ -1645,7 +1686,7 @@ function panel(){
|
|
|
1645
1686
|
return;
|
|
1646
1687
|
}}
|
|
1647
1688
|
const arr=selArr();
|
|
1648
|
-
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>'+
|
|
1649
1690
|
'<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
|
|
1650
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>'+
|
|
1651
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>'+
|
|
@@ -1659,9 +1700,9 @@ function panel(){
|
|
|
1659
1700
|
arr.forEach(ensureMeta);
|
|
1660
1701
|
const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
|
|
1661
1702
|
const allBeam=cols.length===0, allCol=beams.length===0;
|
|
1662
|
-
const totalL=arr.reduce((t,m)=>t+
|
|
1703
|
+
const totalL=arr.reduce((t,m)=>t+_lenFt(m),0).toFixed(1);
|
|
1663
1704
|
const allKnown=arr.every(m=>_wt(m.profile)!=null);
|
|
1664
|
-
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;
|
|
1665
1706
|
const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
|
|
1666
1707
|
const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
|
|
1667
1708
|
const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
|
|
@@ -1724,7 +1765,7 @@ function panel(){
|
|
|
1724
1765
|
wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
|
|
1725
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};});}}
|
|
1726
1767
|
return;}
|
|
1727
|
-
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);
|
|
1728
1769
|
const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
|
|
1729
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):[];
|
|
1730
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>`;
|
|
@@ -1783,7 +1824,7 @@ function panel(){
|
|
|
1783
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>
|
|
1784
1825
|
<div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
|
|
1785
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>
|
|
1786
|
-
<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>
|
|
1787
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>
|
|
1788
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>`:''}
|
|
1789
1830
|
${elev}
|
|
@@ -1941,7 +1982,21 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
|
|
|
1941
1982
|
// checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
|
|
1942
1983
|
// go through the same imperial formatters the side pane uses; the contract stays canonical metric.
|
|
1943
1984
|
function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
|
|
1944
|
-
|
|
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);}
|
|
1945
2000
|
const PROP_DEFS=[
|
|
1946
2001
|
{key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
|
|
1947
2002
|
{key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
|
|
@@ -2022,7 +2077,7 @@ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabel
|
|
|
2022
2077
|
|
|
2023
2078
|
// ---- The floating Properties popup ----
|
|
2024
2079
|
let propPopPinned=false;
|
|
2025
|
-
let propPopConn=null; //
|
|
2080
|
+
let propPopConn=null; // truthy = connection/tree mode (selection involves a connection → renderConnTree); null = the member label-picker mode
|
|
2026
2081
|
function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
|
|
2027
2082
|
function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
2028
2083
|
el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
|
|
@@ -2037,7 +2092,12 @@ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
|
2037
2092
|
+`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
|
|
2038
2093
|
document.body.appendChild(el);
|
|
2039
2094
|
const list=el.querySelector('#ppList');
|
|
2040
|
-
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row
|
|
2095
|
+
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row)return;
|
|
2096
|
+
if(e.target.closest('.chev')&&row.dataset.conn){treeExpanded.has(row.dataset.conn)?treeExpanded.delete(row.dataset.conn):treeExpanded.add(row.dataset.conn);renderPropPop();return;} // chevron → expand/collapse the connection node
|
|
2097
|
+
const mods={ctrl:e.ctrlKey||e.metaKey,shift:e.shiftKey};
|
|
2098
|
+
if(row.dataset.conn){treeSelectConn(row.dataset.conn,mods);return;} // connection node → select the whole connection in the model
|
|
2099
|
+
if(row.dataset.tid){treeSelectLeaf(row.dataset.tid,mods);return;} // a part / member row → select it (Ctrl toggle, Shift range)
|
|
2100
|
+
if(row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // member label rows carry data-k
|
|
2041
2101
|
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row&&row.dataset.k)togglePropKey(row.dataset.k,cb.checked);});
|
|
2042
2102
|
el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
|
|
2043
2103
|
el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
|
|
@@ -2058,9 +2118,9 @@ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(
|
|
|
2058
2118
|
refreshPropLabels();}
|
|
2059
2119
|
// rebuild the popup contents against the current selection (chrome + rows), preserving row focus
|
|
2060
2120
|
function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
|
|
2061
|
-
if(propPopPinned){
|
|
2121
|
+
if(propPopPinned){propPopConn=selTree().conns.length?true:(selArr().length?null:propPopConn);} // a PINNED popup follows the selection: any connection involved → tree mode; pure members → label-picker
|
|
2062
2122
|
el.classList.toggle('connmode',!!propPopConn);
|
|
2063
|
-
if(propPopConn){
|
|
2123
|
+
if(propPopConn){renderConnTree(el);return;} // connection / mixed-selection tree view
|
|
2064
2124
|
const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
2065
2125
|
el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
|
|
2066
2126
|
el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
|
|
@@ -2090,7 +2150,7 @@ function openPropLabels(x,y){if(!selArr().length)return;propPopConn=null;const e
|
|
|
2090
2150
|
// `connmode`: the connection's props as read-only name/value rows, label controls hidden. Labeling
|
|
2091
2151
|
// connection props onto the model is a member-keyed pipeline → deferred; this is a VIEW, not a picker. ----
|
|
2092
2152
|
function connPropRows(cs){
|
|
2093
|
-
const j=cs.joint,isBP=cs.kind==='base-plate',pp=j.params||{};
|
|
2153
|
+
const j=cs.joint,isBP=cs.kind==='base-plate',pp=(j&&j.params)||{}; // tolerate a missing joint (orphaned part) rather than throw
|
|
2094
2154
|
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
2095
2155
|
const dim=mm=>(mm==null?'—':fmtFtIn(Number(mm)/25.4));
|
|
2096
2156
|
const cols=pp.boltCols||(isBP?2:1),rows=pp.boltRows||(isBP?2:3);
|
|
@@ -2106,26 +2166,41 @@ function connPropRows(cs){
|
|
|
2106
2166
|
{label:isBP?'Anchor count':'Bolt count', value:String(cols*rows)},
|
|
2107
2167
|
{label:'Parts', value:String(cs.childIds.length)},
|
|
2108
2168
|
];}
|
|
2109
|
-
function
|
|
2169
|
+
function openConnPop(x,y){propPopConn=true;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2110
2170
|
sr.value='';el.classList.add('open');renderPropPop();
|
|
2111
2171
|
if(propPopPinned){dockPropPop();}
|
|
2112
2172
|
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
2113
2173
|
setTimeout(()=>sr.focus(),0);}
|
|
2114
|
-
//
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2174
|
+
// Render the connection/tree view. Single connection → its property summary + an interactive parts list;
|
|
2175
|
+
// multiple connections and/or members → a tree of selectable rows (click = select in the model, Ctrl toggle,
|
|
2176
|
+
// Shift range). Re-derived from the live selection each call so it self-heals as the selection moves.
|
|
2177
|
+
function renderConnTree(el){
|
|
2178
|
+
const t=selTree();
|
|
2179
|
+
if(!t.conns.length){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
|
|
2118
2180
|
el.querySelector('#ppTitle').textContent='Properties';el.querySelector('#ppLabeled').textContent='';
|
|
2119
2181
|
el.querySelector('#ppScope').textContent='Right-click a connection';el.querySelector('#ppMeta').textContent='';
|
|
2120
2182
|
el.querySelector('#ppList').innerHTML='<div class=ppempty>No connection selected.</div>';return; }
|
|
2121
|
-
propPopConn=cs;
|
|
2122
2183
|
const q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
2123
|
-
|
|
2184
|
+
const single=t.conns.length===1&&!t.members.length;
|
|
2185
|
+
const kindName=k=>k==='base-plate'?'Base plate':k==='shear-plate'?'Shear plate':'Connection';
|
|
2186
|
+
const nSel=t.conns.length+t.members.length, rowMatch=s=>!q||String(s).toLowerCase().includes(q), sw='<span class=tsw></span>';
|
|
2187
|
+
el.querySelector('#ppTitle').textContent=single?('Properties · '+kindName(t.conns[0].kind)):('Properties · '+nSel+' objects');
|
|
2188
|
+
el.querySelector('#ppScope').textContent=single?('On '+t.conns[0].main):(t.conns.length+' connection'+(t.conns.length===1?'':'s')+(t.members.length?(' · '+t.members.length+' member'+(t.members.length===1?'':'s')):''));
|
|
2124
2189
|
el.querySelector('#ppLabeled').textContent='';
|
|
2125
|
-
|
|
2126
|
-
const all=connPropRows(
|
|
2127
|
-
|
|
2128
|
-
|
|
2190
|
+
treeRowIds=[];let html='';
|
|
2191
|
+
if(single){ const all=connPropRows(t.conns[0]).filter(r=>rowMatch(r.label)); // property summary (read-only) …
|
|
2192
|
+
html+=all.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join('');
|
|
2193
|
+
html+=`<div class=divrow><hr><span class=sect style="margin:0">Parts (${t.conns[0].childIds.length})</span><hr></div>`; } // … then the interactive parts
|
|
2194
|
+
for(const c of t.conns){
|
|
2195
|
+
const expanded=single||treeExpanded.has(c.conn), wholeSel=c.childIds.length&&c.childIds.every(id=>selIds.has(id));
|
|
2196
|
+
if(!single) html+=`<div class="pprow trow node${wholeSel?' selrow':''}" data-conn="${esc(c.conn)}"><span class=chev>${expanded?'▾':'▸'}</span>${sw}<span class=pn>${esc(kindName(c.kind)+' · '+c.main)}</span><span class=cnt>${c.childIds.length} parts</span></div>`;
|
|
2197
|
+
if(expanded) for(const id of c.childIds){ const e2=(partsById||{})[id],lbl=(e2&&e2.meta&&e2.meta.label)||id.slice(id.indexOf(':')+1); if(!rowMatch(lbl))continue; treeRowIds.push(id);
|
|
2198
|
+
html+=`<div class="pprow trow ptrow${selIds.has(id)?' selrow':''}" data-tid="${esc(id)}">${sw}<span class=pn>${esc(lbl)}</span></div>`; }
|
|
2199
|
+
}
|
|
2200
|
+
for(const id of t.members){ const m=byId(id),lbl=id+(m&&m.profile?(' · '+m.profile):''); if(!rowMatch(lbl))continue; treeRowIds.push(id);
|
|
2201
|
+
html+=`<div class="pprow trow mrow${selIds.has(id)?' selrow':''}" data-tid="${esc(id)}">${sw}<span class=pn>${esc(lbl)}</span></div>`; }
|
|
2202
|
+
el.querySelector('#ppList').innerHTML=html||'<div class=ppempty>No properties match your search.</div>';
|
|
2203
|
+
el.querySelector('#ppMeta').textContent=single?(t.conns[0].childIds.length+' parts'):(nSel+' selected');}
|
|
2129
2204
|
function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
|
|
2130
2205
|
const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
|
|
2131
2206
|
document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
|
|
@@ -2170,9 +2245,9 @@ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDef
|
|
|
2170
2245
|
document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
|
|
2171
2246
|
if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
|
|
2172
2247
|
if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
|
|
2173
|
-
if(mode==='sel'&&!cmTool&&!picking){
|
|
2174
|
-
if(
|
|
2175
|
-
if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}}
|
|
2248
|
+
if(mode==='sel'&&!cmTool&&!picking){
|
|
2249
|
+
if(selTree().conns.length){openConnPop(e.clientX,e.clientY);return;} // any connection in the selection → the connection / mixed-selection tree popup
|
|
2250
|
+
if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members only → the member label-picker popup
|
|
2176
2251
|
});
|
|
2177
2252
|
document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
|
|
2178
2253
|
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
@@ -2539,19 +2614,19 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
2539
2614
|
return;}}
|
|
2540
2615
|
if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
|
|
2541
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;}
|
|
2542
|
-
if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
|
|
2543
|
-
// Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl
|
|
2544
|
-
//
|
|
2545
|
-
if(!
|
|
2546
|
-
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)
|
|
2547
|
-
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
|
|
2548
2623
|
const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
|
|
2549
2624
|
buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
|
|
2550
|
-
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};
|
|
2551
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
|
|
2552
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
|
|
2553
|
-
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
2554
|
-
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();}});
|
|
2555
2630
|
svg.addEventListener('pointermove',e=>{
|
|
2556
2631
|
if(csaxisMode){csPrev(e);return;}
|
|
2557
2632
|
if(dimMode){dimPrev(e);return;}
|
|
@@ -2596,6 +2671,8 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2596
2671
|
else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
|
|
2597
2672
|
drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
|
|
2598
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)
|
|
2599
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;}
|
|
2600
2677
|
if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
|
|
2601
2678
|
if(!drag.moved){
|
|
@@ -2605,7 +2682,8 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2605
2682
|
if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
|
|
2606
2683
|
const ns=new Set(),ci=[];
|
|
2607
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()});}
|
|
2608
|
-
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)
|
|
2609
2687
|
if(!drag.armed)return;
|
|
2610
2688
|
let dx=rawdx,dy=rawdy;
|
|
2611
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)
|
|
@@ -2640,13 +2718,20 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
2640
2718
|
const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
|
|
2641
2719
|
if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
|
|
2642
2720
|
drag=null;render();return;}
|
|
2643
|
-
if(drag.type==='marquee'){
|
|
2644
|
-
|
|
2645
|
-
|
|
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
|
|
2646
2731
|
drag.rect.remove();drag=null;render();return;}
|
|
2647
2732
|
if(drag.type==='grab'){
|
|
2648
2733
|
if(!drag.moved){ // no drag past threshold → it was a click
|
|
2649
|
-
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)
|
|
2650
2735
|
drag=null;render();return;}
|
|
2651
2736
|
if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
|
|
2652
2737
|
drag=null;render();
|
|
@@ -2811,7 +2896,10 @@ const view3dApi={
|
|
|
2811
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
|
|
2812
2897
|
|
|
2813
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)
|
|
2814
|
-
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
|
|
2815
2903
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
2816
2904
|
snapEnabled:()=>snapEnabled, // the persistent running-snaps (⋯ menu → Snapping) — 3D honours the same on/off set
|
|
2817
2905
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
@@ -3309,7 +3397,7 @@ stage.addEventListener('pointerdown',e=>{if(e.button!==1)return;e.preventDefault
|
|
|
3309
3397
|
stage.addEventListener('pointermove',e=>{if(!pan)return;stage.scrollLeft=pan.sl-(e.clientX-pan.x);stage.scrollTop=pan.st-(e.clientY-pan.y);});
|
|
3310
3398
|
stage.addEventListener('pointerup',()=>{if(pan){pan=null;stage.style.cursor='';}});
|
|
3311
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,
|
|
3312
|
-
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
|
|
3313
3401
|
ends:m.role==='column'?undefined:m.ends, column:m.role==='column'?m.col:undefined};})};
|
|
3314
3402
|
const b=new Blob([JSON.stringify(out,null,1)],{type:'application/json'});
|
|
3315
3403
|
const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='contract_edited.json';a.click();};
|
|
@@ -3512,7 +3600,7 @@ function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();retu
|
|
|
3512
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;}
|
|
3513
3601
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
3514
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>'+
|
|
3515
|
-
list.map((m,i)=>{ensureMeta(m);const L=(
|
|
3603
|
+
list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
|
|
3516
3604
|
return `<tr><td><span class=rfichip>${i+1}</span></td>
|
|
3517
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>
|
|
3518
3606
|
<td>${m.role==='column'?'Column':'Beam'}</td><td>${L} ft</td><td class=rea>${esc(rfiReason(m))}</td>
|
|
@@ -3558,10 +3646,8 @@ function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
|
|
|
3558
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'});
|
|
3559
3647
|
if(m.verified===true)F.push({key:'verified',label:'Verified',state:'ok',detail:'human-confirmed'});
|
|
3560
3648
|
const band=m.verified===true?'verified':isd?'low':(sched||asm)?'med':'high';return {band,factors:F};}
|
|
3561
|
-
function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;
|
|
3562
|
-
|
|
3563
|
-
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);}
|
|
3564
|
-
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
|
|
3565
3651
|
function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
|
|
3566
3652
|
const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
|
|
3567
3653
|
function _scoreConnections(){
|
|
@@ -3589,7 +3675,7 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
|
|
|
3589
3675
|
const _conn=_scoreConnections();
|
|
3590
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());
|
|
3591
3677
|
plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
|
|
3592
|
-
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});}
|
|
3593
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'});}});
|
|
3594
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};};
|
|
3595
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};};
|
|
@@ -3889,6 +3975,25 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
|
|
|
3889
3975
|
snapEnabled.end=true;const x4=snap(1,1);ok(x4.kind==='end','re-enabling Endpoint restores it');
|
|
3890
3976
|
const ax2=snap(2,40);ok(ax2.x===0,'with Endpoint on, axis-align still pulls to the endpoint column');
|
|
3891
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');}
|
|
3892
3997
|
const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (frame + snap + transform math)';
|
|
3893
3998
|
console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
|
|
3894
3999
|
})();}
|