@floless/app 0.61.0 → 0.63.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.
@@ -17,7 +17,8 @@
17
17
  import * as THREE from 'three';
18
18
  import { OrbitControls } from 'three/addons/OrbitControls.js';
19
19
  import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
20
- import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom } from './steel-3d-core.js';
20
+ import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom, planeBasis, projectToPlane, planeFrom3Points, vecToPlane } from './steel-3d-core.js';
21
+ import { gridGeometry, gridCandidates3d, mmPerPx } from './grid-core.js';
21
22
 
22
23
  let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
23
24
  let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
@@ -36,6 +37,7 @@ let soloGroups = new Set(); // profile keys isolated via the leg
36
37
  let isolatedIds = null; // Tekla "isolate selected": Set of ids shown exclusively, or null for off
37
38
  let connHidden = new Set(); // explicit per-PART hide (legend connection rows) — lets a shared part-kind (weld/nut) hide per-connection by id, not per group
38
39
  let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
40
+ let triad = null; // world-axis triad { renderer, scene, cam, group } — passive X/Y/Z readout
39
41
  const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
40
42
  const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
41
43
  const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
@@ -150,6 +152,7 @@ function init(canvas, theApi) {
150
152
  window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
151
153
  ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
152
154
  initCube();
155
+ initTriad();
153
156
  // Resize once more on the next frame: the stage may still be laying out when init() runs (the
154
157
  // designer flagged a first-render size mismatch as the common rough edge here).
155
158
  requestAnimationFrame(resize);
@@ -181,6 +184,7 @@ function loop() {
181
184
  renderer.autoClear = true; renderer.clippingPlanes = saved;
182
185
  }
183
186
  if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
187
+ if (triad) { syncTriad(); triad.renderer.render(triad.scene, triad.cam); }
184
188
  }
185
189
 
186
190
  const V = (x, y, z) => new THREE.Vector3(x, y, z);
@@ -452,6 +456,7 @@ function buildFromScene(sc) {
452
456
  dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
453
457
  renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
454
458
  buildGrid(box);
459
+ buildStructGrid();
455
460
  buildRefLines();
456
461
  if (isolatedIds) { isolatedIds = new Set([...isolatedIds].filter((id) => meshById.has(id))); if (!isolatedIds.size) { isolatedIds = null; if (api && api.onIsolateChange) api.onIsolateChange(false); } } // drop ids an edit removed; if none survive, exit isolation + refresh the host's Show-all button
457
462
  if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
@@ -493,6 +498,97 @@ function buildRefLines() {
493
498
  }
494
499
  function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
495
500
 
501
+ // ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
502
+ // them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
503
+ // wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
504
+ // the contract via api.getGrid() (grid-core owns parsing/geometry); allCandidates() feeds the same
505
+ // grid into snapping, plan-locked.
506
+ let structGridGroup = null, gridLabelGroup = null;
507
+ const gridTexCache = new Map(); // 'b:LABEL' bubble / 't:LABEL' tag -> CanvasTexture (kept until dispose)
508
+
509
+ function allCandidates(excludeId) {
510
+ const base = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm(), excludeId);
511
+ try { return base.concat(gridCandidates3d(api.getGrid ? api.getGrid() : null, api.ptPerFt())); }
512
+ catch { return base; } // a malformed hand-edited grid must never break member dragging
513
+ }
514
+
515
+ function gridBubbleTexture(label) {
516
+ const key = 'b:' + label;
517
+ if (gridTexCache.has(key)) return gridTexCache.get(key);
518
+ const S = 128, cv = document.createElement('canvas'); cv.width = cv.height = S;
519
+ const g = cv.getContext('2d');
520
+ g.beginPath(); g.arc(S / 2, S / 2, S / 2 - 5, 0, Math.PI * 2);
521
+ g.fillStyle = '#0f172a'; g.fill();
522
+ g.lineWidth = 5; g.strokeStyle = '#64748b'; g.stroke();
523
+ g.fillStyle = '#e2e8f0'; g.font = 'bold ' + (label.length > 2 ? 44 : 58) + 'px system-ui';
524
+ g.textAlign = 'center'; g.textBaseline = 'middle';
525
+ g.fillText(label, S / 2, S / 2 + 3);
526
+ const tex = new THREE.CanvasTexture(cv);
527
+ gridTexCache.set(key, tex);
528
+ return tex;
529
+ }
530
+ function gridTagTexture(label) {
531
+ const key = 't:' + label;
532
+ if (gridTexCache.has(key)) return gridTexCache.get(key);
533
+ const W = 256, H = 80, cv = document.createElement('canvas'); cv.width = W; cv.height = H;
534
+ const g = cv.getContext('2d');
535
+ g.beginPath(); g.roundRect(4, 4, W - 8, H - 8, 14);
536
+ g.fillStyle = '#0f172a'; g.fill();
537
+ g.lineWidth = 4; g.strokeStyle = '#64748b'; g.stroke();
538
+ g.fillStyle = '#94a3b8'; g.font = 'bold 40px system-ui';
539
+ g.textAlign = 'center'; g.textBaseline = 'middle';
540
+ g.fillText(label, W / 2, H / 2 + 2);
541
+ const tex = new THREE.CanvasTexture(cv);
542
+ gridTexCache.set(key, tex);
543
+ return tex;
544
+ }
545
+ function clearStructGrid() {
546
+ for (const [grp, parent] of [[structGridGroup, scene], [gridLabelGroup, overlayScene]]) {
547
+ if (!grp) continue;
548
+ for (const o of [...grp.children]) { grp.remove(o); if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); } // sprite materials are per-sprite; textures stay cached
549
+ if (parent) parent.remove(grp);
550
+ }
551
+ structGridGroup = gridLabelGroup = null;
552
+ }
553
+ function buildStructGrid() {
554
+ clearStructGrid();
555
+ if (!api || !api.getGrid || !scene) return;
556
+ let g = null, geo = null;
557
+ try { g = api.getGrid(); if (!g || g.on === false) return; geo = gridGeometry(g, api.ptPerFt()); }
558
+ catch (e) { console.warn('[steel-3d] grid skipped (bad grid data)', e); return; }
559
+ if (!geo.v.length && !geo.h.length) return;
560
+ const k = mmPerPx(api.ptPerFt());
561
+ const mmX = (px) => px * k, mmY = (px) => -px * k; // display → scene (Y flips)
562
+ const levels = geo.zs.length ? geo.zs : [{ mm: 0, label: '0' }];
563
+ const pts = [];
564
+ for (const lv of levels) {
565
+ const z = lv.mm - 25; // sit just under the steel at the level (no z-fight/burial)
566
+ for (const l of geo.v) pts.push(V(mmX(l.x), mmY(l.lo), z), V(mmX(l.x), mmY(l.hi), z));
567
+ for (const l of geo.h) pts.push(V(mmX(l.lo), mmY(l.y), z), V(mmX(l.hi), mmY(l.y), z));
568
+ }
569
+ structGridGroup = new THREE.Group();
570
+ const seg = new THREE.LineSegments(
571
+ new THREE.BufferGeometry().setFromPoints(pts),
572
+ new THREE.LineDashedMaterial({ color: 0x64748b, dashSize: 450, gapSize: 220, transparent: true, opacity: 0.85 }),
573
+ );
574
+ seg.computeLineDistances();
575
+ structGridGroup.add(seg);
576
+ scene.add(structGridGroup);
577
+ gridLabelGroup = new THREE.Group();
578
+ const span = Math.max((geo.x1 - geo.x0) * k, (geo.y1 - geo.y0) * k, 1);
579
+ const dia = Math.min(Math.max(span * 0.035, 400), 3000); // bubble ⌀ scales with the grid, clamped sane
580
+ const z0 = levels[0].mm;
581
+ const sprite = (tex, x, y, z, w, h) => {
582
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
583
+ s.position.set(x, y, z); s.scale.set(w, h, 1); s.renderOrder = 994;
584
+ gridLabelGroup.add(s);
585
+ };
586
+ for (const l of geo.v) { sprite(gridBubbleTexture(l.label), mmX(l.x), mmY(l.lo), z0, dia, dia); sprite(gridBubbleTexture(l.label), mmX(l.x), mmY(l.hi), z0, dia, dia); }
587
+ for (const l of geo.h) { sprite(gridBubbleTexture(l.label), mmX(l.lo), mmY(l.y), z0, dia, dia); sprite(gridBubbleTexture(l.label), mmX(l.hi), mmY(l.y), z0, dia, dia); }
588
+ if (geo.zs.length > 1) for (const lv of geo.zs) sprite(gridTagTexture(lv.label), mmX(geo.x0), mmY(geo.y1), lv.mm, dia * 2.4, dia * 0.75); // level tags up the near corner
589
+ overlayScene.add(gridLabelGroup);
590
+ }
591
+
496
592
  // The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
497
593
  // to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
498
594
  // model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
@@ -659,6 +755,7 @@ function rebuildClipPlanes(c) { // recompute a clip's .planes from its ed
659
755
  }
660
756
  function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
661
757
  if (m && dimMode3d) toggleDimTool();
758
+ if (m) { if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); cmClear3d(); drClear3d(); } // clip shares the left-click — disarm Move/Copy + Add so their picks don't steal it (onDown routes them first)
662
759
  clipMode = (m === 'plane' || m === 'box') ? m : null;
663
760
  clipBoxDraft = null; setClipPreview(null);
664
761
  if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
@@ -981,6 +1078,7 @@ function onKey(e) {
981
1078
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
982
1079
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
983
1080
  if (pending && pending.clipDrag && e.key === 'Escape') { e.preventDefault(); const p = pending; if (p.plane) p.clip.point = p.prePoint; else p.clip.box.copy(p.preBox); rebuildClipPlanes(p.clip); applyClips(); renderClipGizmo(); pending = dragging = null; if (controls) controls.enabled = true; if (readout) readout.style.display = 'none'; return; } // Esc mid-drag → undo the handle move
1081
+ if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
984
1082
  if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
985
1083
  if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
986
1084
  if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
@@ -994,7 +1092,7 @@ function onKey(e) {
994
1092
  if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
995
1093
  }
996
1094
  // Named-view shortcuts mirror the ViewCube faces (parity with AWARE viewer-3d): T/F/R/B/L snap to an ortho view.
997
- if (VIEW_KEYS[k] && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); applyView(VIEW_KEYS[k]); return; }
1095
+ if (VIEW_KEYS[k] && !cmActive() && !wpMode && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); applyView(VIEW_KEYS[k]); return; } // while Move/Copy or a set-plane pick is armed, F belongs to the axis force — not the Front view (review finding)
998
1096
  const a = ARROWS[e.key]; if (!a) return;
999
1097
  const [hx, hy] = a;
1000
1098
  if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
@@ -1005,7 +1103,7 @@ function onKey(e) {
1005
1103
  function toggleDimTool() {
1006
1104
  dimMode3d = !dimMode3d;
1007
1105
  if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
1008
- else { if (clipMode) setClipMode(null); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick (they share the left-click + Esc); shows the crosshair before the first mousemove
1106
+ else { if (clipMode) setClipMode(null); if (wpMode) armWorkPlanePick(wpMode); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); drClear3d(); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick, a set-plane pick, Move/Copy AND Add-member (all share the left-click + Esc)
1009
1107
  reflectDimBar();
1010
1108
  return dimMode3d;
1011
1109
  }
@@ -1414,6 +1512,46 @@ function initCube() {
1414
1512
  });
1415
1513
  cube = { renderer: cr, scene: cs, cam: cc, mesh };
1416
1514
  }
1515
+
1516
+ // ---- World-axis triad (Tekla-style) — a passive bottom-right gizmo showing where world X/Y/Z point.
1517
+ // Same sync-to-camera pattern as the ViewCube, but pointer-events:none: orientation CHANGES stay the
1518
+ // cube's job; this only reads out. Colors are the CAD convention (X red, Y green, Z blue = --brand).
1519
+ const TRIAD_AXES = [['X', '#ef4444', [1, 0, 0]], ['Y', '#22c55e', [0, 1, 0]], ['Z', '#3b82f6', [0, 0, 1]]];
1520
+ function triadTip(label, color, pos) { // free-standing colored letter with a dark halo — legible over any geometry, no disc
1521
+ const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
1522
+ g.font = 'bold 46px ui-sans-serif,system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
1523
+ g.lineWidth = 8; g.lineJoin = 'round'; g.strokeStyle = 'rgba(2,8,23,.9)'; g.strokeText(label, 32, 34);
1524
+ g.fillStyle = color; g.fillText(label, 32, 34);
1525
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c) }));
1526
+ s.position.copy(pos); s.scale.setScalar(0.85);
1527
+ return s;
1528
+ }
1529
+ function initTriad() {
1530
+ const host = document.getElementById('m3dAxes'); if (!host) return;
1531
+ const PX = 78;
1532
+ const tr = new THREE.WebGLRenderer({ antialias: true, alpha: true });
1533
+ tr.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); tr.setSize(PX, PX);
1534
+ host.appendChild(tr.domElement);
1535
+ const ts = new THREE.Scene();
1536
+ const tc = new THREE.OrthographicCamera(-2.1, 2.1, 2.1, -2.1, 0.1, 20); tc.position.set(0, 0, 5);
1537
+ const g = new THREE.Group();
1538
+ const Y = new THREE.Vector3(0, 1, 0); // Cylinder/ConeGeometry's own axis
1539
+ for (const [label, color, dir] of TRIAD_AXES) {
1540
+ const d = new THREE.Vector3(dir[0], dir[1], dir[2]);
1541
+ const mat = new THREE.MeshBasicMaterial({ color });
1542
+ const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.05, 8), mat);
1543
+ shaft.quaternion.setFromUnitVectors(Y, d);
1544
+ shaft.position.copy(d).multiplyScalar(0.525);
1545
+ const head = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.34, 12), mat); // arrowhead at the shaft end
1546
+ head.quaternion.copy(shaft.quaternion);
1547
+ head.position.copy(d).multiplyScalar(1.22);
1548
+ g.add(shaft, head, triadTip(label, color, d.clone().multiplyScalar(1.62)));
1549
+ }
1550
+ g.add(new THREE.Mesh(new THREE.SphereGeometry(0.1, 12, 8), new THREE.MeshBasicMaterial({ color: 0xe2e8f0 }))); // origin dot
1551
+ ts.add(g);
1552
+ triad = { renderer: tr, scene: ts, cam: tc, group: g };
1553
+ }
1554
+ function syncTriad() { triad.group.quaternion.copy(camera.quaternion).invert(); }
1417
1555
  // Mirror the scene from the main camera's direction (the FRONT face turns toward the viewer in a
1418
1556
  // front view, etc.) by orienting the cube by the inverse of the camera's world rotation.
1419
1557
  function syncCube() { cube.mesh.quaternion.copy(camera.quaternion).invert(); }
@@ -1518,6 +1656,234 @@ function rayToVerticalPlane(cx, cy, thru) {
1518
1656
  }
1519
1657
  const members = () => (api && api.getMembers && api.getMembers()) || [];
1520
1658
  const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
1659
+
1660
+ // ---- working plane (spec §6): session view-state grounding ALL 3D picks. null = the active plan's
1661
+ // level (horizontal at default TOS) — logically always present, only DRAWN when explicitly set. ----
1662
+ let workPlane = null; // {origin:[x,y,z]mm, normal, xAxis} | null
1663
+ let wpMode = null; // 'face' | '3pt' — armed set-plane pick mode
1664
+ let wpDraft = null; // 3pt picks collected so far
1665
+ let wpVisible = true; // "Show plane" toggle (applies to an explicit plane)
1666
+ let wpGroup = null;
1667
+ function effectiveWP() { return workPlane || { origin: [0, 0, api ? api.defaultTosMm() : 0], normal: [0, 0, 1], xAxis: [1, 0, 0] }; }
1668
+ function renderWorkPlane() {
1669
+ if (wpGroup) { scene.remove(wpGroup); wpGroup.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); wpGroup = null; }
1670
+ if (!workPlane || !wpVisible) return; // the implicit level plane is not drawn — the 2D plan already communicates it
1671
+ const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis);
1672
+ const CELL = 1524, N = 12, half = (CELL * N) / 2; // 12 × 12 cells at 5 ft — a work patch, not an infinite plane
1673
+ const g = new THREE.Group();
1674
+ const verts = [];
1675
+ for (let i = 0; i <= N; i++) { const o = -half + i * CELL; verts.push(o, -half, 0, o, half, 0, -half, o, 0, half, o, 0); }
1676
+ const grid = new THREE.BufferGeometry(); grid.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
1677
+ g.add(new THREE.LineSegments(grid, new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.28 })));
1678
+ const fill = new THREE.Mesh(new THREE.PlaneGeometry(CELL * N, CELL * N), new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.09, side: THREE.DoubleSide, depthWrite: false }));
1679
+ g.add(fill);
1680
+ const ax = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(half, 0, 0)]), new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.9 }));
1681
+ ax.renderOrder = 996; g.add(ax); // the brighter ray = the plane's local X
1682
+ const ay = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, half / 2, 0)]), new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.55 }));
1683
+ ay.renderOrder = 996; g.add(ay); // the shorter, dimmer ray = local Y (tells the axes apart on a skewed plane)
1684
+ const od = new THREE.Mesh(new THREE.SphereGeometry(60, 12, 8), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
1685
+ g.add(od); // origin dot
1686
+ g.applyMatrix4(new THREE.Matrix4().makeBasis(new THREE.Vector3(...u), new THREE.Vector3(...v), new THREE.Vector3(...wp.normal)));
1687
+ g.position.set(wp.origin[0], wp.origin[1], wp.origin[2]);
1688
+ wpGroup = g; scene.add(g); // on `scene` (like epGroup) so rebuild() never wipes it; not in meshById → picking ignores it
1689
+ }
1690
+ function reflectWpBar() { const b = document.getElementById('m3dWp'); if (b) { b.classList.toggle('on', !!wpMode || !!workPlane);
1691
+ const kind = workPlane && workPlane.kind ? { face: 'Face', p3: '3pt', xy: 'XY', xz: 'XZ', yz: 'YZ' }[workPlane.kind] || '' : '';
1692
+ b.textContent = wpMode ? '◇ Plane ✕' : (kind ? '◇ Plane: ' + kind + ' ▾' : '◇ Plane ▾'); } } // the label reads the active plane without opening the menu (ux review)
1693
+ function armWorkPlanePick(mode) { if (wpMode === mode) { wpMode = null; wpDraft = null; } else { wpMode = mode; wpDraft = null; if (dimMode3d) toggleDimTool(); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); cmClear3d(); drClear3d(); } marker.visible = false; canvasEl.style.cursor = wpMode ? 'crosshair' : 'default'; reflectWpBar(); } // a set-plane pick shares the left-click — disarm Move/Copy + Add (their onDown routes run first)
1694
+ function setWorkPlanePrincipal(kind, offsetMm) {
1695
+ const off = offsetMm || 0;
1696
+ const def = { xy: { normal: [0, 0, 1], xAxis: [1, 0, 0] }, xz: { normal: [0, 1, 0], xAxis: [1, 0, 0] }, yz: { normal: [1, 0, 0], xAxis: [0, 1, 0] } }[kind];
1697
+ if (!def) return;
1698
+ workPlane = { origin: def.normal.map((c) => c * off), normal: def.normal, xAxis: def.xAxis, kind };
1699
+ wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar();
1700
+ }
1701
+ function clearWorkPlane() { workPlane = null; wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar(); }
1702
+ function toggleWorkPlaneVisible(on) { wpVisible = on === undefined ? !wpVisible : !!on; renderWorkPlane(); }
1703
+ function workPlaneInfo() { return { set: !!workPlane, mode: wpMode, visible: wpVisible, plane: workPlane ? { origin: [...workPlane.origin], normal: [...workPlane.normal], xAxis: [...workPlane.xAxis] } : null }; }
1704
+ // face pick: the clip tool's raycast, minus the camera-side flip — the plane lands ON the face.
1705
+ function wpFacePick(cx, cy) {
1706
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
1707
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
1708
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
1709
+ raycaster.setFromCamera(ndc, camera);
1710
+ const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false)[0];
1711
+ if (!hit || !hit.face) return null;
1712
+ const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
1713
+ const b = planeBasis([n.x, n.y, n.z]);
1714
+ return { origin: [hit.point.x, hit.point.y, hit.point.z], normal: [n.x, n.y, n.z], xAxis: b.u, kind: 'face' };
1715
+ }
1716
+ // ray → the working plane (general orientation) → [x,y,z] mm, or null
1717
+ function rayToWP(cx, cy, wp) {
1718
+ camera.updateMatrixWorld();
1719
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
1720
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
1721
+ raycaster.setFromCamera(ndc, camera);
1722
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(new THREE.Vector3(...wp.normal), new THREE.Vector3(...wp.origin));
1723
+ const hit = new THREE.Vector3();
1724
+ return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
1725
+ }
1726
+
1727
+ // ---- 3D Move/Copy (spec §3+§6): the EDITOR owns the tool state (cmState via api); this side owns
1728
+ // picking/preview in scene mm against the working plane, and hands commit offsets back. ----
1729
+ let tfDraft = null; // {base:[mm], vA:[mm]|null}
1730
+ let tfLastShift = false; // shiftKey mirror for the rAF-synthetic hover event
1731
+ let tfLast = null; // last constrained preview point (HUD distance direction)
1732
+ let tfLastClient = null; // last pointer client [x,y] (HUD placement)
1733
+ let tfAxis = 'free'; // 'free' | 'u' | 'v' — X/Y force along the plane basis
1734
+ let tfCandidates = null;
1735
+ let tfGhost = null, tfRubber = null, tfRubberA = null, tfGhostMat = null;
1736
+ const cmActive = () => (api && api.cmState ? api.cmState() : null);
1737
+ const cmHasBase = () => !!tfDraft;
1738
+ const cmLastClient = () => tfLastClient;
1739
+ function setCmAxis(a) { tfAxis = a === 'x' ? 'u' : a === 'y' ? 'v' : 'free'; }
1740
+ function cmClear3d() { tfDraft = null; tfCandidates = null; tfAxis = 'free'; tfPreviewClear(); if (canvasEl) canvasEl.style.cursor = 'default'; } // callable BEFORE init() (editor boot) — every touched handle is guarded
1741
+ function cmEscape() { if (!tfDraft) return false; if (tfDraft.vA) { tfDraft.vA = null; tfPreviewClear(); return true; } tfDraft = null; tfPreviewClear(); return true; }
1742
+ function tfDisposeGhost() { if (!tfGhost) return; if (scene) scene.remove(tfGhost); for (const c of tfGhost.children) { if (c.userData._ownGeom && c.geometry) c.geometry.dispose(); } tfGhost = null; } // clones share the source geometry/material; only the >600 fallback BoxGeometry is per-ghost → dispose those (flagged _ownGeom) so the preview can't leak
1743
+ function tfPreviewClear() { tfDisposeGhost(); if (tfRubber) tfRubber.visible = false; if (tfRubberA) tfRubberA.visible = false; if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
1744
+ // snapped pick projected onto the working plane (Tekla: snaps win, then land on the plane)
1745
+ function wpPointAt(e) {
1746
+ const wp = effectiveWP();
1747
+ const hit = rayToWP(e.clientX, e.clientY, wp); if (!hit) return null;
1748
+ if (!e.altKey) {
1749
+ if (!tfCandidates) tfCandidates = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
1750
+ const r = snapPoint(hit, tfCandidates, toScreen, SNAP_TOL_PX);
1751
+ if (r.candidate) return { p: projectToPlane(r.snapped, wp.origin, wp.normal), snap: true, type: r.candidate.type };
1752
+ }
1753
+ return { p: hit, snap: false, type: null };
1754
+ }
1755
+ // Shift = dominant in-plane axis; X/Y force follows the plane basis (like the 2D local frame)
1756
+ function tfConstrain(p, e) {
1757
+ if (!tfDraft) return p;
1758
+ const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis), b = tfDraft.base;
1759
+ const d = [p[0] - b[0], p[1] - b[1], p[2] - b[2]], c = vecToPlane(d, u, v, wp.normal);
1760
+ let ax = tfAxis;
1761
+ if (e && e.shiftKey) ax = Math.abs(c.du) >= Math.abs(c.dv) ? 'u' : 'v';
1762
+ if (ax === 'u') return [b[0] + c.du * u[0], b[1] + c.du * u[1], b[2] + c.du * u[2]];
1763
+ if (ax === 'v') return [b[0] + c.dv * v[0], b[1] + c.dv * v[1], b[2] + c.dv * v[2]];
1764
+ return p;
1765
+ }
1766
+ // mirror of the editor's offset builders (mm side): counts INCLUDE the original, grid skips (0,0)
1767
+ function tfOffsetsFor(v, st) {
1768
+ const lin = (vec, n) => { const o = []; for (let k = 1; k <= n; k++) o.push([vec[0] * k, vec[1] * k, vec[2] * k]); return o; };
1769
+ if (st.array) {
1770
+ if (!tfDraft.vA) return lin(v, Math.max(0, st.countA - 1));
1771
+ const o = []; for (let i = 0; i < st.countA; i++) for (let j = 0; j < st.countB; j++) { if (!i && !j) continue; o.push([tfDraft.vA[0] * i + v[0] * j, tfDraft.vA[1] * i + v[1] * j, tfDraft.vA[2] * i + v[2] * j]); } return o;
1772
+ }
1773
+ return st.tool === 'copy' ? lin(v, st.count) : [v];
1774
+ }
1775
+ function tfGhostBuild(offs) {
1776
+ tfDisposeGhost(); // dispose the previous rebuild's own geometries (the >600 boxes) before dropping it
1777
+ tfGhost = new THREE.Group();
1778
+ if (!tfGhostMat) { tfGhostMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.3, depthWrite: false }); }
1779
+ const sel = [...selIds].map((id) => meshById.get(id)).filter((m) => m && m.visible);
1780
+ if (sel.length * offs.length <= 600) {
1781
+ for (const off of offs) for (const m of sel) {
1782
+ const c = new THREE.Mesh(m.geometry, tfGhostMat); // shares the source geometry — nothing to dispose
1783
+ c.applyMatrix4(m.matrixWorld); c.position.x += off[0]; c.position.y += off[1]; c.position.z += off[2];
1784
+ tfGhost.add(c);
1785
+ }
1786
+ } else {
1787
+ const bb = new THREE.Box3(); for (const m of sel) bb.expandByObject(m);
1788
+ const size = bb.getSize(new THREE.Vector3()), ctr = bb.getCenter(new THREE.Vector3());
1789
+ const boxGeom = new THREE.BoxGeometry(size.x, size.y, size.z); // ONE shared box for all fallback ghosts this rebuild
1790
+ for (const off of offs) { const h = new THREE.Mesh(boxGeom, tfGhostMat); h.position.set(ctr.x + off[0], ctr.y + off[1], ctr.z + off[2]); tfGhost.add(h); }
1791
+ if (tfGhost.children[0]) tfGhost.children[0].userData._ownGeom = true; // dispose the shared box once via the first child on the next clear
1792
+ }
1793
+ scene.add(tfGhost);
1794
+ }
1795
+ function tfPreview(e) {
1796
+ const st = cmActive(); if (!st || !tfDraft) return;
1797
+ const r = wpPointAt(e); if (!r) return;
1798
+ const p = tfConstrain(r.p, e);
1799
+ tfLast = p; tfLastClient = [e.clientX, e.clientY];
1800
+ if (r.snap && tfAxis === 'free' && !e.shiftKey) { marker.position.set(p[0], p[1], p[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
1801
+ const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
1802
+ tfGhostBuild(tfOffsetsFor(v, st));
1803
+ if (!tfRubber) { tfRubber = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee })); tfRubber.material.depthTest = false; tfRubber.renderOrder = 997; scene.add(tfRubber); }
1804
+ tfRubber.geometry.setFromPoints([new THREE.Vector3(...tfDraft.base), new THREE.Vector3(...p)]);
1805
+ tfRubber.visible = true;
1806
+ if (st.array && tfDraft.vA) {
1807
+ if (!tfRubberA) { tfRubberA = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee, transparent: true, opacity: 0.35 })); tfRubberA.material.depthTest = false; tfRubberA.renderOrder = 997; scene.add(tfRubberA); }
1808
+ tfRubberA.geometry.setFromPoints([new THREE.Vector3(...tfDraft.base), new THREE.Vector3(tfDraft.base[0] + tfDraft.vA[0], tfDraft.base[1] + tfDraft.vA[1], tfDraft.base[2] + tfDraft.vA[2])]);
1809
+ tfRubberA.visible = true;
1810
+ }
1811
+ const L = Math.hypot(v[0], v[1], v[2]);
1812
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft';
1813
+ readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : (st.tool === 'copy' && !st.array && st.count > 1 ? ' ×' + st.count : '');
1814
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
1815
+ }
1816
+ function tfClick(e) {
1817
+ const st = cmActive(); if (!st) return false;
1818
+ if (!selIds.size) { // selection drained while armed (undo/delete) — end the tool and let the click SELECT instead
1819
+ if (api && api.toast) api.toast('Selection is empty — ' + (st.tool === 'move' ? 'Move' : 'Copy') + ' ended');
1820
+ if (api && api.disarmTransform) api.disarmTransform();
1821
+ return false;
1822
+ }
1823
+ const r = wpPointAt(e); if (!r) return true;
1824
+ const p = tfConstrain(r.p, e);
1825
+ tfLastClient = [e.clientX, e.clientY];
1826
+ if (!tfDraft) { tfDraft = { base: p, vA: null }; tfCandidates = null; tfLast = p; return true; }
1827
+ const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
1828
+ if (Math.hypot(v[0], v[1], v[2]) < 5) return true; // ~zero pick (5 mm) — ignore, like the 2D 0.5 px guard
1829
+ if (st.array && !tfDraft.vA) { tfDraft.vA = v; return true; }
1830
+ const offs = tfOffsetsFor(v, st);
1831
+ if (!offs.length) { if (api && api.toast) api.toast(st.array ? 'A 1 × 1 array places no copies — raise a count' : 'Nothing to place'); tfDraft = null; tfPreviewClear(); return true; }
1832
+ api.onTransform3d(offs);
1833
+ tfDraft = null; tfPreviewClear();
1834
+ return true;
1835
+ }
1836
+ // HUD hand-off: the editor parses (inches), we turn it into a plane-space vector and run the same flow.
1837
+ function cmHudApply(pv) {
1838
+ const st = cmActive(); if (!st || !tfDraft) return { err: 'Pick a base point first' };
1839
+ const wp = effectiveWP(), { u, v: vv } = planeBasis(wp.normal, wp.xAxis);
1840
+ let v;
1841
+ if (pv.comp) { const IN = 25.4, du = pv.comp[0] * IN, dv = pv.comp[1] * IN, dn = (pv.comp[2] || 0) * IN;
1842
+ v = [du * u[0] + dv * vv[0] + dn * wp.normal[0], du * u[1] + dv * vv[1] + dn * wp.normal[1], du * u[2] + dv * vv[2] + dn * wp.normal[2]]; }
1843
+ else { if (!tfLast) return { err: 'Move the mouse to aim, or type dx,dy' };
1844
+ const dir = [tfLast[0] - tfDraft.base[0], tfLast[1] - tfDraft.base[1], tfLast[2] - tfDraft.base[2]];
1845
+ const L = Math.hypot(dir[0], dir[1], dir[2]); if (L < 1e-6) return { err: 'Move the mouse to aim, or type dx,dy' };
1846
+ const d = pv.dist * 25.4; v = [dir[0] / L * d, dir[1] / L * d, dir[2] / L * d]; }
1847
+ if (Math.hypot(v[0], v[1], v[2]) < 5) return { err: 'Zero distance — type a non-zero value' };
1848
+ if (st.array && !tfDraft.vA) { tfDraft.vA = v; tfPreviewClear(); return { ok: true, staged: true }; }
1849
+ const offs = tfOffsetsFor(v, st);
1850
+ if (!offs.length) { tfDraft = null; tfPreviewClear(); return { err: st.array ? 'A 1 × 1 array places no copies — raise a count' : 'Nothing to place' }; }
1851
+ api.onTransform3d(offs);
1852
+ tfDraft = null; tfPreviewClear();
1853
+ return { ok: true };
1854
+ }
1855
+
1856
+ // ---- draw a new member on the working plane (spec §7): two plane picks → api.onAddMember3d (scene
1857
+ // mm); the editor maps them to wp + per-end TOS and materializes a beam or (near-vertical) column. ----
1858
+ let drDraft = null; // first pick, scene mm
1859
+ let drRubber = null;
1860
+ const addActive = () => !!(api && api.addModeActive && api.addModeActive());
1861
+ function drClear3d() { drDraft = null; if (drRubber) drRubber.visible = false; if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; } // callable pre-init (all handles guarded)
1862
+ function drShiftLock(p, e) { // Shift = ortho along the plane's local axes (same basis as Move/Copy)
1863
+ if (!e || !e.shiftKey || !drDraft) return p;
1864
+ const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis), d = [p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]], c = vecToPlane(d, u, v, wp.normal);
1865
+ return Math.abs(c.du) >= Math.abs(c.dv) ? [drDraft[0] + c.du * u[0], drDraft[1] + c.du * u[1], drDraft[2] + c.du * u[2]] : [drDraft[0] + c.dv * v[0], drDraft[1] + c.dv * v[1], drDraft[2] + c.dv * v[2]];
1866
+ }
1867
+ function drClick(e) {
1868
+ const r = wpPointAt(e); if (!r) return;
1869
+ if (!drDraft) { drDraft = r.p; return; }
1870
+ const p = drShiftLock(r.p, e);
1871
+ if (Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]) < 50) return; // ~<2in — ignore a doubled click
1872
+ if (api && api.onAddMember3d) api.onAddMember3d(drDraft, p);
1873
+ drDraft = null; tfCandidates = null; // the new member is a snap target for the NEXT one — invalidate the shared snap cache
1874
+ if (drRubber) drRubber.visible = false; readout.style.display = 'none'; // stays armed for the next member
1875
+ }
1876
+ function drPreview(e) {
1877
+ const r = wpPointAt(e); if (!r) return;
1878
+ if (r.snap) { marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
1879
+ if (!drDraft) { readout.style.display = 'none'; return; }
1880
+ const p = drShiftLock(r.p, e);
1881
+ if (!drRubber) { drRubber = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee })); drRubber.material.depthTest = false; drRubber.renderOrder = 997; scene.add(drRubber); }
1882
+ drRubber.geometry.setFromPoints([new THREE.Vector3(...drDraft), new THREE.Vector3(...p)]); drRubber.visible = true;
1883
+ const L = Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]);
1884
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft'; readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : '';
1885
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
1886
+ }
1521
1887
  // world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
1522
1888
  // BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
1523
1889
  // world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
@@ -1543,7 +1909,7 @@ function dimPointAt(e, anchor) {
1543
1909
  let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
1544
1910
  if (id) { const m = members().find((x) => x.id === id); if (m) { const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm()); planeZ = (g.line[0][2] + g.line[1][2]) / 2; } }
1545
1911
  const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
1546
- if (!dimCandidates3d) dimCandidates3d = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
1912
+ if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
1547
1913
  const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
1548
1914
  return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
1549
1915
  }
@@ -1553,6 +1919,19 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
1553
1919
 
1554
1920
  function onDown(e) {
1555
1921
  if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
1922
+ if (wpMode === 'face') { e.stopPropagation(); const r = wpFacePick(e.clientX, e.clientY); if (r) { workPlane = r; wpMode = null; canvasEl.style.cursor = 'default'; renderWorkPlane(); reflectWpBar(); if (api && api.toast) api.toast('Working plane set on the face'); } return; } // armed: one face click sets the plane
1923
+ if (wpMode === '3pt') { e.stopPropagation();
1924
+ const r = dimPointAt(e); if (!r) return;
1925
+ wpDraft = wpDraft || []; wpDraft.push(r.p);
1926
+ marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true;
1927
+ if (wpDraft.length === 3) { const wp = planeFrom3Points(wpDraft[0], wpDraft[1], wpDraft[2]);
1928
+ wpDraft = null; wpMode = null; marker.visible = false; canvasEl.style.cursor = 'default';
1929
+ if (wp) { wp.kind = 'p3'; workPlane = wp; renderWorkPlane(); if (api && api.toast) api.toast('Working plane set from 3 points'); }
1930
+ else if (api && api.toast) api.toast('Points are collinear — plane not set');
1931
+ reflectWpBar(); }
1932
+ return; }
1933
+ if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
1934
+ if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
1556
1935
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
1557
1936
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
1558
1937
  if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(e.clientX, e.clientY); } catch { ch = null; } if (ch) { e.stopPropagation(); controls.enabled = false; downXY = [e.clientX, e.clientY]; startClipDrag(ch, e); return; } } // grab a clip handle (plane dot / box face) → drag it
@@ -1587,7 +1966,7 @@ function onDown(e) {
1587
1966
  id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
1588
1967
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
1589
1968
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
1590
- candidates: snapCandidates(members(), ppf, dtos, id),
1969
+ candidates: allCandidates(id),
1591
1970
  levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
1592
1971
  mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
1593
1972
  };
@@ -1618,7 +1997,7 @@ function startEndpointGrab(ep, e) {
1618
1997
  pending = {
1619
1998
  epDrag: true, id: ep.id, end: ep.end, ppf,
1620
1999
  planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
1621
- candidates: snapCandidates(members(), ppf, dtos, ep.id),
2000
+ candidates: allCandidates(ep.id),
1622
2001
  newPt: [g.line[ep.end][0], g.line[ep.end][1]],
1623
2002
  };
1624
2003
  dragEp = { id: ep.id, end: ep.end };
@@ -1756,10 +2135,26 @@ function closestOnSeg3(p, a, b) {
1756
2135
  function onHoverMove(e) {
1757
2136
  lastHoverXY = [e.clientX, e.clientY];
1758
2137
  dimLastAlt = e.altKey; // remembered so the rAF body can rebuild a synthetic event
2138
+ tfLastShift = e.shiftKey; // same for the Move/Copy ortho lock
1759
2139
  if (hoverRAF || pending || dragging || boxSel) return;
1760
2140
  hoverRAF = requestAnimationFrame(() => {
1761
2141
  hoverRAF = 0;
1762
2142
  if (!lastHoverXY || pending || dragging || boxSel) return;
2143
+ if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
2144
+ canvasEl.style.cursor = 'crosshair';
2145
+ if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2146
+ if (r && !(wpDraft && wpDraft.length)) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } }
2147
+ updateStatusChip(); return; }
2148
+ if (addActive()) { // Add-member armed: snap marker + rubber preview on the working plane
2149
+ canvasEl.style.cursor = 'crosshair';
2150
+ drPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
2151
+ updateStatusChip(); return; }
2152
+ if (cmActive()) { // Move/Copy armed: previews ride the working plane
2153
+ canvasEl.style.cursor = 'crosshair';
2154
+ if (tfDraft) tfPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
2155
+ else { const r = wpPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2156
+ if (r && r.snap) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false; }
2157
+ updateStatusChip(); return; }
1763
2158
  if (clipMode) { canvasEl.style.cursor = 'crosshair'; if (clipMode === 'box') clipBoxPreviewAt(lastHoverXY[0], lastHoverXY[1]); return; } // armed clip pick: crosshair (+ live box preview while drawing)
1764
2159
  if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
1765
2160
  canvasEl.style.cursor = 'crosshair';
@@ -1794,7 +2189,13 @@ function updateStatusChip() {
1794
2189
  const rect = canvasEl.getBoundingClientRect();
1795
2190
  let txt = '';
1796
2191
  const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
1797
- if (clipMode === 'plane') txt = 'Click a face to set a clip plane · Esc to cancel';
2192
+ if (wpMode === 'face') txt = 'Click a member face the working plane lands on it · Esc to cancel';
2193
+ else if (wpMode === '3pt') txt = ['Click the plane origin (snapped) · Esc to cancel', 'Click the local-X direction point · Esc to cancel', 'Click a point on the plane’s Y side · Esc to cancel'][(wpDraft || []).length] || '';
2194
+ else if (addActive()) txt = drDraft ? 'Click the member end · Shift straight · Esc drops the start' : 'Draw a member on the working plane — click the start point · Esc exits';
2195
+ else if (cmActive()) { const st = cmActive(), lock = tfAxis === 'u' ? ' · X locked' : tfAxis === 'v' ? ' · Y locked' : '';
2196
+ txt = (!tfDraft ? ((st.tool === 'move' ? 'Move' : (st.array ? 'Copy array' : 'Copy')) + ' — click the base point on the working plane · type after the pick for an exact value')
2197
+ : (st.array && !tfDraft.vA ? 'Click the direction-A point — or type its spacing' : st.array ? 'Click the direction-B point — the grid previews live' : 'Click the destination — X/Y lock to the plane axes · Esc cancels')) + lock; }
2198
+ else if (clipMode === 'plane') txt = 'Click a face to set a clip plane · Esc to cancel';
1798
2199
  else if (clipMode === 'box') txt = !clipBoxDraft ? 'Click the first floor corner of the clip box · Esc to cancel' : !clipBoxDraft.b ? 'Click the opposite floor corner · Esc to cancel' : 'Move up/down to set the height, then click · Esc to step back';
1799
2200
  else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
1800
2201
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
@@ -1810,6 +2211,8 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
1810
2211
  function hide() {
1811
2212
  if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
1812
2213
  if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
2214
+ if (wpMode) { wpMode = null; wpDraft = null; if (marker) marker.visible = false; reflectWpBar(); } // drop an in-progress set-plane pick — else a stale face/3pt pick steals the first click on return
2215
+ cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
1813
2216
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
1814
2217
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
1815
2218
  }
@@ -1826,10 +2229,12 @@ function dispose() {
1826
2229
  window.removeEventListener('keydown', onKey);
1827
2230
  for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
1828
2231
  dimLabelHost = null; dimLabelPool.length = 0;
1829
- if (cube) {
1830
- cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
1831
- cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
2232
+ for (const w of [cube, triad]) { // both mini-widgets own a WebGL context — leak one and re-init eventually hits the browser's context cap
2233
+ if (!w) continue;
2234
+ w.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
2235
+ w.renderer.dispose(); if (w.renderer.domElement.parentNode) w.renderer.domElement.parentNode.removeChild(w.renderer.domElement);
1832
2236
  }
2237
+ cube = triad = null;
1833
2238
  if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
1834
2239
  if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
1835
2240
  if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines
@@ -1838,6 +2243,9 @@ function dispose() {
1838
2243
  for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
1839
2244
  epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
1840
2245
  dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
2246
+ clearStructGrid();
2247
+ for (const tex of gridTexCache.values()) tex.dispose();
2248
+ gridTexCache.clear();
1841
2249
  clearRoot();
1842
2250
  if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
1843
2251
  clearClipGizmo(); setClipPreview(null); overlayScene = null;
@@ -1866,6 +2274,7 @@ function debug() {
1866
2274
  target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
1867
2275
  near: camera?.near, far: camera?.far,
1868
2276
  clips: getClips(), clipPlaneCount: renderer && renderer.clippingPlanes ? renderer.clippingPlanes.length : 0, clipMode, workArea: workAreaState(), selectedClips: [...selectedClipIds],
2277
+ workPlane: workPlaneInfo(), tfHasBase: !!tfDraft, tfGhosts: tfGhost ? tfGhost.children.length : 0, drHasStart: !!drDraft,
1869
2278
  };
1870
2279
  }
1871
2280
  // test helper: the current material colour + emissive of a member (verifies deselect restores colour)
@@ -1891,10 +2300,15 @@ window.Steel3DView = {
1891
2300
  init, show, hide, rebuild, setSelection, isReady, dispose,
1892
2301
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
1893
2302
  setRefLine, refLine: () => refLineOn,
2303
+ refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
2304
+ gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
1894
2305
  toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
1895
2306
  setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
1896
2307
  isolateSelected, clearIsolation, isIsolated,
1897
2308
  workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
2309
+ armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
2310
+ cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
2311
+ drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
1898
2312
  toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
1899
2313
  refreshOverlayDims,
1900
2314
  overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests