@floless/app 0.62.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.
@@ -98,8 +98,9 @@ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
98
98
  return [...set].sort((a, b) => a - b);
99
99
  }
100
100
 
101
- // Lower wins when two candidates are both within tolerance.
102
- const PRECEDENCE = { vertex: 0, intersection: 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
101
+ // Lower wins when two candidates are both within tolerance. Grid intersections rank with member
102
+ // intersections (columns land on them); grid lines below member centerlines.
103
+ const PRECEDENCE = { vertex: 0, intersection: 1, 'grid-int': 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
103
104
 
104
105
  function closestOnSeg(p, a, b) {
105
106
  const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
@@ -113,7 +114,9 @@ function closestOnSeg(p, a, b) {
113
114
  function candidatePoint(c, dragged) {
114
115
  if (c.type === 'vertex' || c.type === 'intersection' || c.type === 'midpoint' || c.type === 'level') return c.p; // fixed points
115
116
  if (c.type === 'vertical-axis') return [c.p[0], c.p[1], dragged[2]]; // lock plan X/Y to the axis, keep dragged Z
116
- return closestOnSeg(dragged, c.a, c.b); // centerline / grid-line
117
+ if (c.type === 'grid-int') return [c.p[0], c.p[1], dragged[2]]; // grid steers PLAN only — never yanks the elevation
118
+ if (c.type === 'grid-line') { const q = closestOnSeg(dragged, c.a, c.b); return [q[0], q[1], dragged[2]]; }
119
+ return closestOnSeg(dragged, c.a, c.b); // centerline
117
120
  }
118
121
 
119
122
  /**
@@ -136,6 +139,49 @@ export function snapPoint(dragged, candidates, toScreen, tolPx) {
136
139
  return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
137
140
  }
138
141
 
142
+ // ---- working-plane math (CAD copy/move epic §6) — pure vector helpers, scene-mm space ----
143
+ const _dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
144
+ const _cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
145
+ const _norm = (a) => { const L = Math.hypot(a[0], a[1], a[2]); return L > 1e-12 ? [a[0] / L, a[1] / L, a[2] / L] : null; };
146
+
147
+ /**
148
+ * Orthonormal in-plane basis {u,v} for a plane normal. xHint (default world-X) projects onto the
149
+ * plane as U; when the hint is ~parallel to the normal it falls back to world-Y so U never
150
+ * degenerates. v = n × u keeps the frame right-handed (u × v = n).
151
+ */
152
+ export function planeBasis(normal, xHint) {
153
+ const n = _norm(normal) || [0, 0, 1];
154
+ let u = null;
155
+ for (const hint of [xHint, [1, 0, 0], [0, 1, 0]]) {
156
+ if (!hint) continue;
157
+ const d = _dot(hint, n);
158
+ u = _norm([hint[0] - d * n[0], hint[1] - d * n[1], hint[2] - d * n[2]]);
159
+ if (u) break;
160
+ }
161
+ return { u, v: _cross(n, u) };
162
+ }
163
+
164
+ /** Project p onto the plane (origin,normal): p − ((p−o)·n̂)n̂ — the nearest on-plane point. */
165
+ export function projectToPlane(p, origin, normal) {
166
+ const n = _norm(normal) || [0, 0, 1];
167
+ const d = (p[0] - origin[0]) * n[0] + (p[1] - origin[1]) * n[1] + (p[2] - origin[2]) * n[2];
168
+ return [p[0] - d * n[0], p[1] - d * n[1], p[2] - d * n[2]];
169
+ }
170
+
171
+ /** Plane from 3 picked points (Tekla flow): origin=a, xAxis toward b, normal = x̂ × (c−a). null when degenerate. */
172
+ export function planeFrom3Points(a, b, c) {
173
+ const xAxis = _norm([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
174
+ if (!xAxis) return null;
175
+ const normal = _norm(_cross(xAxis, [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
176
+ if (!normal) return null;
177
+ return { origin: [a[0], a[1], a[2]], xAxis, normal };
178
+ }
179
+
180
+ /** World vector → components along the plane basis: {du,dv,dn}. Reconstruct via du·u+dv·v+dn·n. */
181
+ export function vecToPlane(vec, u, v, n) {
182
+ return { du: _dot(vec, u), dv: _dot(vec, v), dn: _dot(vec, n) };
183
+ }
184
+
139
185
  /**
140
186
  * 3D dimension geometry (pure). a,b are scene mm [x,y,z]. axis ∈ free|x|y|z.
141
187
  * free → the straight segment a→b (valueMm = full 3D distance); x|y|z →
@@ -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
@@ -455,6 +456,7 @@ function buildFromScene(sc) {
455
456
  dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
456
457
  renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
457
458
  buildGrid(box);
459
+ buildStructGrid();
458
460
  buildRefLines();
459
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
460
462
  if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
@@ -496,6 +498,97 @@ function buildRefLines() {
496
498
  }
497
499
  function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
498
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
+
499
592
  // The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
500
593
  // to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
501
594
  // model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
@@ -662,6 +755,7 @@ function rebuildClipPlanes(c) { // recompute a clip's .planes from its ed
662
755
  }
663
756
  function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
664
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)
665
759
  clipMode = (m === 'plane' || m === 'box') ? m : null;
666
760
  clipBoxDraft = null; setClipPreview(null);
667
761
  if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
@@ -984,6 +1078,7 @@ function onKey(e) {
984
1078
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
985
1079
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
986
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
987
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
988
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)
989
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)
@@ -997,7 +1092,7 @@ function onKey(e) {
997
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
998
1093
  }
999
1094
  // Named-view shortcuts mirror the ViewCube faces (parity with AWARE viewer-3d): T/F/R/B/L snap to an ortho view.
1000
- 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)
1001
1096
  const a = ARROWS[e.key]; if (!a) return;
1002
1097
  const [hx, hy] = a;
1003
1098
  if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
@@ -1008,7 +1103,7 @@ function onKey(e) {
1008
1103
  function toggleDimTool() {
1009
1104
  dimMode3d = !dimMode3d;
1010
1105
  if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
1011
- 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)
1012
1107
  reflectDimBar();
1013
1108
  return dimMode3d;
1014
1109
  }
@@ -1561,6 +1656,234 @@ function rayToVerticalPlane(cx, cy, thru) {
1561
1656
  }
1562
1657
  const members = () => (api && api.getMembers && api.getMembers()) || [];
1563
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
+ }
1564
1887
  // world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
1565
1888
  // BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
1566
1889
  // world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
@@ -1586,7 +1909,7 @@ function dimPointAt(e, anchor) {
1586
1909
  let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
1587
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; } }
1588
1911
  const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
1589
- if (!dimCandidates3d) dimCandidates3d = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
1912
+ if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
1590
1913
  const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
1591
1914
  return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
1592
1915
  }
@@ -1596,6 +1919,19 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
1596
1919
 
1597
1920
  function onDown(e) {
1598
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
1599
1935
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
1600
1936
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
1601
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
@@ -1630,7 +1966,7 @@ function onDown(e) {
1630
1966
  id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
1631
1967
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
1632
1968
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
1633
- candidates: snapCandidates(members(), ppf, dtos, id),
1969
+ candidates: allCandidates(id),
1634
1970
  levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
1635
1971
  mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
1636
1972
  };
@@ -1661,7 +1997,7 @@ function startEndpointGrab(ep, e) {
1661
1997
  pending = {
1662
1998
  epDrag: true, id: ep.id, end: ep.end, ppf,
1663
1999
  planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
1664
- candidates: snapCandidates(members(), ppf, dtos, ep.id),
2000
+ candidates: allCandidates(ep.id),
1665
2001
  newPt: [g.line[ep.end][0], g.line[ep.end][1]],
1666
2002
  };
1667
2003
  dragEp = { id: ep.id, end: ep.end };
@@ -1799,10 +2135,26 @@ function closestOnSeg3(p, a, b) {
1799
2135
  function onHoverMove(e) {
1800
2136
  lastHoverXY = [e.clientX, e.clientY];
1801
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
1802
2139
  if (hoverRAF || pending || dragging || boxSel) return;
1803
2140
  hoverRAF = requestAnimationFrame(() => {
1804
2141
  hoverRAF = 0;
1805
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; }
1806
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)
1807
2159
  if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
1808
2160
  canvasEl.style.cursor = 'crosshair';
@@ -1837,7 +2189,13 @@ function updateStatusChip() {
1837
2189
  const rect = canvasEl.getBoundingClientRect();
1838
2190
  let txt = '';
1839
2191
  const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
1840
- 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';
1841
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';
1842
2200
  else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
1843
2201
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
@@ -1853,6 +2211,8 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
1853
2211
  function hide() {
1854
2212
  if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
1855
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
1856
2216
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
1857
2217
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
1858
2218
  }
@@ -1883,6 +2243,9 @@ function dispose() {
1883
2243
  for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
1884
2244
  epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
1885
2245
  dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
2246
+ clearStructGrid();
2247
+ for (const tex of gridTexCache.values()) tex.dispose();
2248
+ gridTexCache.clear();
1886
2249
  clearRoot();
1887
2250
  if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
1888
2251
  clearClipGizmo(); setClipPreview(null); overlayScene = null;
@@ -1911,6 +2274,7 @@ function debug() {
1911
2274
  target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
1912
2275
  near: camera?.near, far: camera?.far,
1913
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,
1914
2278
  };
1915
2279
  }
1916
2280
  // test helper: the current material colour + emissive of a member (verifies deselect restores colour)
@@ -1936,10 +2300,15 @@ window.Steel3DView = {
1936
2300
  init, show, hide, rebuild, setSelection, isReady, dispose,
1937
2301
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
1938
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
1939
2305
  toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
1940
2306
  setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
1941
2307
  isolateSelected, clearIsolation, isIsolated,
1942
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; },
1943
2312
  toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
1944
2313
  refreshOverlayDims,
1945
2314
  overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests