@floless/app 0.62.0 → 0.64.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
@@ -279,6 +280,43 @@ function placePlate(mesh, el) {
279
280
  mesh.position.set(...(el.center || [0, 0, 0]));
280
281
  }
281
282
 
283
+ // Signed area of a closed 2D polygon (shoelace) — used to reject a zero-area/collinear footprint.
284
+ function shoelaceArea(pts) {
285
+ let a = 0;
286
+ for (let i = 0, n = pts.length; i < n; i++) {
287
+ const p = pts[i], q = pts[(i + 1) % n];
288
+ a += p[0] * q[1] - q[0] * p[1];
289
+ }
290
+ return a / 2;
291
+ }
292
+
293
+ // A vertical extrusion of an arbitrary world-XY footprint between two Z values — the multi-view
294
+ // reconstruction's generic body (a plate, wall or slab read from a plan + elevation; see
295
+ // server/views-to-scene.ts). Same ExtrudeGeometry recipe as placePlate, but the outline is the
296
+ // element's own polygon in WORLD coordinates and the sweep is always +Z, so no basis rotation.
297
+ function placeExtrusion(mesh, el) {
298
+ const fp = el.footprint || [];
299
+ // Reject a degenerate outline (fewer than 3 vertices, or a zero-area collinear loop by the
300
+ // shoelace test) — ExtrudeGeometry on it produces a NaN/empty body, not an honest shape.
301
+ if (fp.length < 3 || Math.abs(shoelaceArea(fp)) < 1e-6) { mesh.geometry = new THREE.BufferGeometry(); return; }
302
+ const shape = new THREE.Shape();
303
+ shape.moveTo(fp[0][0], fp[0][1]);
304
+ for (let i = 1; i < fp.length; i++) shape.lineTo(fp[i][0], fp[i][1]);
305
+ shape.closePath();
306
+ for (const h of el.holes || []) { // optional opening loops (same world-XY polygon form)
307
+ if (!h || h.length < 3) continue;
308
+ const hole = new THREE.Path();
309
+ hole.moveTo(h[0][0], h[0][1]);
310
+ for (let i = 1; i < h.length; i++) hole.lineTo(h[i][0], h[i][1]);
311
+ hole.closePath();
312
+ shape.holes.push(hole);
313
+ }
314
+ const z0 = Math.min(el.from || 0, el.to || 0), z1 = Math.max(el.from || 0, el.to || 0);
315
+ mesh.geometry = new THREE.ExtrudeGeometry(shape, { depth: Math.max(z1 - z0, 0.5), bevelEnabled: false });
316
+ mesh.quaternion.identity();
317
+ mesh.position.set(0, 0, z0); // the shape carries world XY; lift the slab to its bottom face
318
+ }
319
+
282
320
  // A cylinder between two points (anchor rod / bolt shank).
283
321
  function placeBar(mesh, a, b, r, radial) {
284
322
  const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
@@ -330,6 +368,7 @@ function placeWasher(mesh, el) {
330
368
  function placeElement(mesh, el) {
331
369
  switch (el.kind || 'box') {
332
370
  case 'box': placeMember(mesh, el.from, el.to, el); return true;
371
+ case 'extrusion': placeExtrusion(mesh, el); return true;
333
372
  case 'plate': placePlate(mesh, el); return true;
334
373
  case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
335
374
  case 'nut': placeNut(mesh, el); return true;
@@ -455,6 +494,7 @@ function buildFromScene(sc) {
455
494
  dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
456
495
  renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
457
496
  buildGrid(box);
497
+ buildStructGrid();
458
498
  buildRefLines();
459
499
  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
500
  if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
@@ -496,6 +536,97 @@ function buildRefLines() {
496
536
  }
497
537
  function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
498
538
 
539
+ // ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
540
+ // them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
541
+ // wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
542
+ // the contract via api.getGrid() (grid-core owns parsing/geometry); allCandidates() feeds the same
543
+ // grid into snapping, plan-locked.
544
+ let structGridGroup = null, gridLabelGroup = null;
545
+ const gridTexCache = new Map(); // 'b:LABEL' bubble / 't:LABEL' tag -> CanvasTexture (kept until dispose)
546
+
547
+ function allCandidates(excludeId) {
548
+ const base = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm(), excludeId);
549
+ try { return base.concat(gridCandidates3d(api.getGrid ? api.getGrid() : null, api.ptPerFt())); }
550
+ catch { return base; } // a malformed hand-edited grid must never break member dragging
551
+ }
552
+
553
+ function gridBubbleTexture(label) {
554
+ const key = 'b:' + label;
555
+ if (gridTexCache.has(key)) return gridTexCache.get(key);
556
+ const S = 128, cv = document.createElement('canvas'); cv.width = cv.height = S;
557
+ const g = cv.getContext('2d');
558
+ g.beginPath(); g.arc(S / 2, S / 2, S / 2 - 5, 0, Math.PI * 2);
559
+ g.fillStyle = '#0f172a'; g.fill();
560
+ g.lineWidth = 5; g.strokeStyle = '#64748b'; g.stroke();
561
+ g.fillStyle = '#e2e8f0'; g.font = 'bold ' + (label.length > 2 ? 44 : 58) + 'px system-ui';
562
+ g.textAlign = 'center'; g.textBaseline = 'middle';
563
+ g.fillText(label, S / 2, S / 2 + 3);
564
+ const tex = new THREE.CanvasTexture(cv);
565
+ gridTexCache.set(key, tex);
566
+ return tex;
567
+ }
568
+ function gridTagTexture(label) {
569
+ const key = 't:' + label;
570
+ if (gridTexCache.has(key)) return gridTexCache.get(key);
571
+ const W = 256, H = 80, cv = document.createElement('canvas'); cv.width = W; cv.height = H;
572
+ const g = cv.getContext('2d');
573
+ g.beginPath(); g.roundRect(4, 4, W - 8, H - 8, 14);
574
+ g.fillStyle = '#0f172a'; g.fill();
575
+ g.lineWidth = 4; g.strokeStyle = '#64748b'; g.stroke();
576
+ g.fillStyle = '#94a3b8'; g.font = 'bold 40px system-ui';
577
+ g.textAlign = 'center'; g.textBaseline = 'middle';
578
+ g.fillText(label, W / 2, H / 2 + 2);
579
+ const tex = new THREE.CanvasTexture(cv);
580
+ gridTexCache.set(key, tex);
581
+ return tex;
582
+ }
583
+ function clearStructGrid() {
584
+ for (const [grp, parent] of [[structGridGroup, scene], [gridLabelGroup, overlayScene]]) {
585
+ if (!grp) continue;
586
+ 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
587
+ if (parent) parent.remove(grp);
588
+ }
589
+ structGridGroup = gridLabelGroup = null;
590
+ }
591
+ function buildStructGrid() {
592
+ clearStructGrid();
593
+ if (!api || !api.getGrid || !scene) return;
594
+ let g = null, geo = null;
595
+ try { g = api.getGrid(); if (!g || g.on === false) return; geo = gridGeometry(g, api.ptPerFt()); }
596
+ catch (e) { console.warn('[steel-3d] grid skipped (bad grid data)', e); return; }
597
+ if (!geo.v.length && !geo.h.length) return;
598
+ const k = mmPerPx(api.ptPerFt());
599
+ const mmX = (px) => px * k, mmY = (px) => -px * k; // display → scene (Y flips)
600
+ const levels = geo.zs.length ? geo.zs : [{ mm: 0, label: '0' }];
601
+ const pts = [];
602
+ for (const lv of levels) {
603
+ const z = lv.mm - 25; // sit just under the steel at the level (no z-fight/burial)
604
+ for (const l of geo.v) pts.push(V(mmX(l.x), mmY(l.lo), z), V(mmX(l.x), mmY(l.hi), z));
605
+ for (const l of geo.h) pts.push(V(mmX(l.lo), mmY(l.y), z), V(mmX(l.hi), mmY(l.y), z));
606
+ }
607
+ structGridGroup = new THREE.Group();
608
+ const seg = new THREE.LineSegments(
609
+ new THREE.BufferGeometry().setFromPoints(pts),
610
+ new THREE.LineDashedMaterial({ color: 0x64748b, dashSize: 450, gapSize: 220, transparent: true, opacity: 0.85 }),
611
+ );
612
+ seg.computeLineDistances();
613
+ structGridGroup.add(seg);
614
+ scene.add(structGridGroup);
615
+ gridLabelGroup = new THREE.Group();
616
+ const span = Math.max((geo.x1 - geo.x0) * k, (geo.y1 - geo.y0) * k, 1);
617
+ const dia = Math.min(Math.max(span * 0.035, 400), 3000); // bubble ⌀ scales with the grid, clamped sane
618
+ const z0 = levels[0].mm;
619
+ const sprite = (tex, x, y, z, w, h) => {
620
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
621
+ s.position.set(x, y, z); s.scale.set(w, h, 1); s.renderOrder = 994;
622
+ gridLabelGroup.add(s);
623
+ };
624
+ 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); }
625
+ 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); }
626
+ 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
627
+ overlayScene.add(gridLabelGroup);
628
+ }
629
+
499
630
  // The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
500
631
  // to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
501
632
  // model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
@@ -662,6 +793,7 @@ function rebuildClipPlanes(c) { // recompute a clip's .planes from its ed
662
793
  }
663
794
  function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
664
795
  if (m && dimMode3d) toggleDimTool();
796
+ 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
797
  clipMode = (m === 'plane' || m === 'box') ? m : null;
666
798
  clipBoxDraft = null; setClipPreview(null);
667
799
  if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
@@ -984,6 +1116,7 @@ function onKey(e) {
984
1116
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
985
1117
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
986
1118
  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
1119
+ 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
1120
  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
1121
  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
1122
  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 +1130,7 @@ function onKey(e) {
997
1130
  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
1131
  }
999
1132
  // 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; }
1133
+ 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
1134
  const a = ARROWS[e.key]; if (!a) return;
1002
1135
  const [hx, hy] = a;
1003
1136
  if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
@@ -1008,7 +1141,7 @@ function onKey(e) {
1008
1141
  function toggleDimTool() {
1009
1142
  dimMode3d = !dimMode3d;
1010
1143
  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
1144
+ 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
1145
  reflectDimBar();
1013
1146
  return dimMode3d;
1014
1147
  }
@@ -1561,6 +1694,234 @@ function rayToVerticalPlane(cx, cy, thru) {
1561
1694
  }
1562
1695
  const members = () => (api && api.getMembers && api.getMembers()) || [];
1563
1696
  const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
1697
+
1698
+ // ---- working plane (spec §6): session view-state grounding ALL 3D picks. null = the active plan's
1699
+ // level (horizontal at default TOS) — logically always present, only DRAWN when explicitly set. ----
1700
+ let workPlane = null; // {origin:[x,y,z]mm, normal, xAxis} | null
1701
+ let wpMode = null; // 'face' | '3pt' — armed set-plane pick mode
1702
+ let wpDraft = null; // 3pt picks collected so far
1703
+ let wpVisible = true; // "Show plane" toggle (applies to an explicit plane)
1704
+ let wpGroup = null;
1705
+ function effectiveWP() { return workPlane || { origin: [0, 0, api ? api.defaultTosMm() : 0], normal: [0, 0, 1], xAxis: [1, 0, 0] }; }
1706
+ function renderWorkPlane() {
1707
+ if (wpGroup) { scene.remove(wpGroup); wpGroup.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); wpGroup = null; }
1708
+ if (!workPlane || !wpVisible) return; // the implicit level plane is not drawn — the 2D plan already communicates it
1709
+ const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis);
1710
+ const CELL = 1524, N = 12, half = (CELL * N) / 2; // 12 × 12 cells at 5 ft — a work patch, not an infinite plane
1711
+ const g = new THREE.Group();
1712
+ const verts = [];
1713
+ 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); }
1714
+ const grid = new THREE.BufferGeometry(); grid.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
1715
+ g.add(new THREE.LineSegments(grid, new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.28 })));
1716
+ 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 }));
1717
+ g.add(fill);
1718
+ 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 }));
1719
+ ax.renderOrder = 996; g.add(ax); // the brighter ray = the plane's local X
1720
+ 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 }));
1721
+ ay.renderOrder = 996; g.add(ay); // the shorter, dimmer ray = local Y (tells the axes apart on a skewed plane)
1722
+ const od = new THREE.Mesh(new THREE.SphereGeometry(60, 12, 8), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
1723
+ g.add(od); // origin dot
1724
+ g.applyMatrix4(new THREE.Matrix4().makeBasis(new THREE.Vector3(...u), new THREE.Vector3(...v), new THREE.Vector3(...wp.normal)));
1725
+ g.position.set(wp.origin[0], wp.origin[1], wp.origin[2]);
1726
+ wpGroup = g; scene.add(g); // on `scene` (like epGroup) so rebuild() never wipes it; not in meshById → picking ignores it
1727
+ }
1728
+ function reflectWpBar() { const b = document.getElementById('m3dWp'); if (b) { b.classList.toggle('on', !!wpMode || !!workPlane);
1729
+ const kind = workPlane && workPlane.kind ? { face: 'Face', p3: '3pt', xy: 'XY', xz: 'XZ', yz: 'YZ' }[workPlane.kind] || '' : '';
1730
+ b.textContent = wpMode ? '◇ Plane ✕' : (kind ? '◇ Plane: ' + kind + ' ▾' : '◇ Plane ▾'); } } // the label reads the active plane without opening the menu (ux review)
1731
+ 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)
1732
+ function setWorkPlanePrincipal(kind, offsetMm) {
1733
+ const off = offsetMm || 0;
1734
+ 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];
1735
+ if (!def) return;
1736
+ workPlane = { origin: def.normal.map((c) => c * off), normal: def.normal, xAxis: def.xAxis, kind };
1737
+ wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar();
1738
+ }
1739
+ function clearWorkPlane() { workPlane = null; wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar(); }
1740
+ function toggleWorkPlaneVisible(on) { wpVisible = on === undefined ? !wpVisible : !!on; renderWorkPlane(); }
1741
+ function workPlaneInfo() { return { set: !!workPlane, mode: wpMode, visible: wpVisible, plane: workPlane ? { origin: [...workPlane.origin], normal: [...workPlane.normal], xAxis: [...workPlane.xAxis] } : null }; }
1742
+ // face pick: the clip tool's raycast, minus the camera-side flip — the plane lands ON the face.
1743
+ function wpFacePick(cx, cy) {
1744
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
1745
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
1746
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
1747
+ raycaster.setFromCamera(ndc, camera);
1748
+ const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false)[0];
1749
+ if (!hit || !hit.face) return null;
1750
+ const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
1751
+ const b = planeBasis([n.x, n.y, n.z]);
1752
+ return { origin: [hit.point.x, hit.point.y, hit.point.z], normal: [n.x, n.y, n.z], xAxis: b.u, kind: 'face' };
1753
+ }
1754
+ // ray → the working plane (general orientation) → [x,y,z] mm, or null
1755
+ function rayToWP(cx, cy, wp) {
1756
+ camera.updateMatrixWorld();
1757
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
1758
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
1759
+ raycaster.setFromCamera(ndc, camera);
1760
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(new THREE.Vector3(...wp.normal), new THREE.Vector3(...wp.origin));
1761
+ const hit = new THREE.Vector3();
1762
+ return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
1763
+ }
1764
+
1765
+ // ---- 3D Move/Copy (spec §3+§6): the EDITOR owns the tool state (cmState via api); this side owns
1766
+ // picking/preview in scene mm against the working plane, and hands commit offsets back. ----
1767
+ let tfDraft = null; // {base:[mm], vA:[mm]|null}
1768
+ let tfLastShift = false; // shiftKey mirror for the rAF-synthetic hover event
1769
+ let tfLast = null; // last constrained preview point (HUD distance direction)
1770
+ let tfLastClient = null; // last pointer client [x,y] (HUD placement)
1771
+ let tfAxis = 'free'; // 'free' | 'u' | 'v' — X/Y force along the plane basis
1772
+ let tfCandidates = null;
1773
+ let tfGhost = null, tfRubber = null, tfRubberA = null, tfGhostMat = null;
1774
+ const cmActive = () => (api && api.cmState ? api.cmState() : null);
1775
+ const cmHasBase = () => !!tfDraft;
1776
+ const cmLastClient = () => tfLastClient;
1777
+ function setCmAxis(a) { tfAxis = a === 'x' ? 'u' : a === 'y' ? 'v' : 'free'; }
1778
+ 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
1779
+ function cmEscape() { if (!tfDraft) return false; if (tfDraft.vA) { tfDraft.vA = null; tfPreviewClear(); return true; } tfDraft = null; tfPreviewClear(); return true; }
1780
+ 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
1781
+ function tfPreviewClear() { tfDisposeGhost(); if (tfRubber) tfRubber.visible = false; if (tfRubberA) tfRubberA.visible = false; if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
1782
+ // snapped pick projected onto the working plane (Tekla: snaps win, then land on the plane)
1783
+ function wpPointAt(e) {
1784
+ const wp = effectiveWP();
1785
+ const hit = rayToWP(e.clientX, e.clientY, wp); if (!hit) return null;
1786
+ if (!e.altKey) {
1787
+ if (!tfCandidates) tfCandidates = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
1788
+ const r = snapPoint(hit, tfCandidates, toScreen, SNAP_TOL_PX);
1789
+ if (r.candidate) return { p: projectToPlane(r.snapped, wp.origin, wp.normal), snap: true, type: r.candidate.type };
1790
+ }
1791
+ return { p: hit, snap: false, type: null };
1792
+ }
1793
+ // Shift = dominant in-plane axis; X/Y force follows the plane basis (like the 2D local frame)
1794
+ function tfConstrain(p, e) {
1795
+ if (!tfDraft) return p;
1796
+ const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis), b = tfDraft.base;
1797
+ const d = [p[0] - b[0], p[1] - b[1], p[2] - b[2]], c = vecToPlane(d, u, v, wp.normal);
1798
+ let ax = tfAxis;
1799
+ if (e && e.shiftKey) ax = Math.abs(c.du) >= Math.abs(c.dv) ? 'u' : 'v';
1800
+ if (ax === 'u') return [b[0] + c.du * u[0], b[1] + c.du * u[1], b[2] + c.du * u[2]];
1801
+ if (ax === 'v') return [b[0] + c.dv * v[0], b[1] + c.dv * v[1], b[2] + c.dv * v[2]];
1802
+ return p;
1803
+ }
1804
+ // mirror of the editor's offset builders (mm side): counts INCLUDE the original, grid skips (0,0)
1805
+ function tfOffsetsFor(v, st) {
1806
+ 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; };
1807
+ if (st.array) {
1808
+ if (!tfDraft.vA) return lin(v, Math.max(0, st.countA - 1));
1809
+ 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;
1810
+ }
1811
+ return st.tool === 'copy' ? lin(v, st.count) : [v];
1812
+ }
1813
+ function tfGhostBuild(offs) {
1814
+ tfDisposeGhost(); // dispose the previous rebuild's own geometries (the >600 boxes) before dropping it
1815
+ tfGhost = new THREE.Group();
1816
+ if (!tfGhostMat) { tfGhostMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.3, depthWrite: false }); }
1817
+ const sel = [...selIds].map((id) => meshById.get(id)).filter((m) => m && m.visible);
1818
+ if (sel.length * offs.length <= 600) {
1819
+ for (const off of offs) for (const m of sel) {
1820
+ const c = new THREE.Mesh(m.geometry, tfGhostMat); // shares the source geometry — nothing to dispose
1821
+ c.applyMatrix4(m.matrixWorld); c.position.x += off[0]; c.position.y += off[1]; c.position.z += off[2];
1822
+ tfGhost.add(c);
1823
+ }
1824
+ } else {
1825
+ const bb = new THREE.Box3(); for (const m of sel) bb.expandByObject(m);
1826
+ const size = bb.getSize(new THREE.Vector3()), ctr = bb.getCenter(new THREE.Vector3());
1827
+ const boxGeom = new THREE.BoxGeometry(size.x, size.y, size.z); // ONE shared box for all fallback ghosts this rebuild
1828
+ 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); }
1829
+ if (tfGhost.children[0]) tfGhost.children[0].userData._ownGeom = true; // dispose the shared box once via the first child on the next clear
1830
+ }
1831
+ scene.add(tfGhost);
1832
+ }
1833
+ function tfPreview(e) {
1834
+ const st = cmActive(); if (!st || !tfDraft) return;
1835
+ const r = wpPointAt(e); if (!r) return;
1836
+ const p = tfConstrain(r.p, e);
1837
+ tfLast = p; tfLastClient = [e.clientX, e.clientY];
1838
+ 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;
1839
+ const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
1840
+ tfGhostBuild(tfOffsetsFor(v, st));
1841
+ 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); }
1842
+ tfRubber.geometry.setFromPoints([new THREE.Vector3(...tfDraft.base), new THREE.Vector3(...p)]);
1843
+ tfRubber.visible = true;
1844
+ if (st.array && tfDraft.vA) {
1845
+ 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); }
1846
+ 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])]);
1847
+ tfRubberA.visible = true;
1848
+ }
1849
+ const L = Math.hypot(v[0], v[1], v[2]);
1850
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft';
1851
+ readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : (st.tool === 'copy' && !st.array && st.count > 1 ? ' ×' + st.count : '');
1852
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
1853
+ }
1854
+ function tfClick(e) {
1855
+ const st = cmActive(); if (!st) return false;
1856
+ if (!selIds.size) { // selection drained while armed (undo/delete) — end the tool and let the click SELECT instead
1857
+ if (api && api.toast) api.toast('Selection is empty — ' + (st.tool === 'move' ? 'Move' : 'Copy') + ' ended');
1858
+ if (api && api.disarmTransform) api.disarmTransform();
1859
+ return false;
1860
+ }
1861
+ const r = wpPointAt(e); if (!r) return true;
1862
+ const p = tfConstrain(r.p, e);
1863
+ tfLastClient = [e.clientX, e.clientY];
1864
+ if (!tfDraft) { tfDraft = { base: p, vA: null }; tfCandidates = null; tfLast = p; return true; }
1865
+ const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
1866
+ if (Math.hypot(v[0], v[1], v[2]) < 5) return true; // ~zero pick (5 mm) — ignore, like the 2D 0.5 px guard
1867
+ if (st.array && !tfDraft.vA) { tfDraft.vA = v; return true; }
1868
+ const offs = tfOffsetsFor(v, st);
1869
+ 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; }
1870
+ api.onTransform3d(offs);
1871
+ tfDraft = null; tfPreviewClear();
1872
+ return true;
1873
+ }
1874
+ // HUD hand-off: the editor parses (inches), we turn it into a plane-space vector and run the same flow.
1875
+ function cmHudApply(pv) {
1876
+ const st = cmActive(); if (!st || !tfDraft) return { err: 'Pick a base point first' };
1877
+ const wp = effectiveWP(), { u, v: vv } = planeBasis(wp.normal, wp.xAxis);
1878
+ let v;
1879
+ if (pv.comp) { const IN = 25.4, du = pv.comp[0] * IN, dv = pv.comp[1] * IN, dn = (pv.comp[2] || 0) * IN;
1880
+ 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]]; }
1881
+ else { if (!tfLast) return { err: 'Move the mouse to aim, or type dx,dy' };
1882
+ const dir = [tfLast[0] - tfDraft.base[0], tfLast[1] - tfDraft.base[1], tfLast[2] - tfDraft.base[2]];
1883
+ 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' };
1884
+ const d = pv.dist * 25.4; v = [dir[0] / L * d, dir[1] / L * d, dir[2] / L * d]; }
1885
+ if (Math.hypot(v[0], v[1], v[2]) < 5) return { err: 'Zero distance — type a non-zero value' };
1886
+ if (st.array && !tfDraft.vA) { tfDraft.vA = v; tfPreviewClear(); return { ok: true, staged: true }; }
1887
+ const offs = tfOffsetsFor(v, st);
1888
+ if (!offs.length) { tfDraft = null; tfPreviewClear(); return { err: st.array ? 'A 1 × 1 array places no copies — raise a count' : 'Nothing to place' }; }
1889
+ api.onTransform3d(offs);
1890
+ tfDraft = null; tfPreviewClear();
1891
+ return { ok: true };
1892
+ }
1893
+
1894
+ // ---- draw a new member on the working plane (spec §7): two plane picks → api.onAddMember3d (scene
1895
+ // mm); the editor maps them to wp + per-end TOS and materializes a beam or (near-vertical) column. ----
1896
+ let drDraft = null; // first pick, scene mm
1897
+ let drRubber = null;
1898
+ const addActive = () => !!(api && api.addModeActive && api.addModeActive());
1899
+ 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)
1900
+ function drShiftLock(p, e) { // Shift = ortho along the plane's local axes (same basis as Move/Copy)
1901
+ if (!e || !e.shiftKey || !drDraft) return p;
1902
+ 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);
1903
+ 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]];
1904
+ }
1905
+ function drClick(e) {
1906
+ const r = wpPointAt(e); if (!r) return;
1907
+ if (!drDraft) { drDraft = r.p; return; }
1908
+ const p = drShiftLock(r.p, e);
1909
+ if (Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]) < 50) return; // ~<2in — ignore a doubled click
1910
+ if (api && api.onAddMember3d) api.onAddMember3d(drDraft, p);
1911
+ drDraft = null; tfCandidates = null; // the new member is a snap target for the NEXT one — invalidate the shared snap cache
1912
+ if (drRubber) drRubber.visible = false; readout.style.display = 'none'; // stays armed for the next member
1913
+ }
1914
+ function drPreview(e) {
1915
+ const r = wpPointAt(e); if (!r) return;
1916
+ 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;
1917
+ if (!drDraft) { readout.style.display = 'none'; return; }
1918
+ const p = drShiftLock(r.p, e);
1919
+ 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); }
1920
+ drRubber.geometry.setFromPoints([new THREE.Vector3(...drDraft), new THREE.Vector3(...p)]); drRubber.visible = true;
1921
+ const L = Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]);
1922
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft'; readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : '';
1923
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
1924
+ }
1564
1925
  // world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
1565
1926
  // BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
1566
1927
  // world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
@@ -1586,7 +1947,7 @@ function dimPointAt(e, anchor) {
1586
1947
  let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
1587
1948
  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
1949
  const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
1589
- if (!dimCandidates3d) dimCandidates3d = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
1950
+ if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
1590
1951
  const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
1591
1952
  return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
1592
1953
  }
@@ -1596,6 +1957,19 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
1596
1957
 
1597
1958
  function onDown(e) {
1598
1959
  if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
1960
+ 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
1961
+ if (wpMode === '3pt') { e.stopPropagation();
1962
+ const r = dimPointAt(e); if (!r) return;
1963
+ wpDraft = wpDraft || []; wpDraft.push(r.p);
1964
+ marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true;
1965
+ if (wpDraft.length === 3) { const wp = planeFrom3Points(wpDraft[0], wpDraft[1], wpDraft[2]);
1966
+ wpDraft = null; wpMode = null; marker.visible = false; canvasEl.style.cursor = 'default';
1967
+ if (wp) { wp.kind = 'p3'; workPlane = wp; renderWorkPlane(); if (api && api.toast) api.toast('Working plane set from 3 points'); }
1968
+ else if (api && api.toast) api.toast('Points are collinear — plane not set');
1969
+ reflectWpBar(); }
1970
+ return; }
1971
+ if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
1972
+ 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
1973
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
1600
1974
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
1601
1975
  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 +2004,7 @@ function onDown(e) {
1630
2004
  id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
1631
2005
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
1632
2006
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
1633
- candidates: snapCandidates(members(), ppf, dtos, id),
2007
+ candidates: allCandidates(id),
1634
2008
  levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
1635
2009
  mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
1636
2010
  };
@@ -1661,7 +2035,7 @@ function startEndpointGrab(ep, e) {
1661
2035
  pending = {
1662
2036
  epDrag: true, id: ep.id, end: ep.end, ppf,
1663
2037
  planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
1664
- candidates: snapCandidates(members(), ppf, dtos, ep.id),
2038
+ candidates: allCandidates(ep.id),
1665
2039
  newPt: [g.line[ep.end][0], g.line[ep.end][1]],
1666
2040
  };
1667
2041
  dragEp = { id: ep.id, end: ep.end };
@@ -1799,10 +2173,26 @@ function closestOnSeg3(p, a, b) {
1799
2173
  function onHoverMove(e) {
1800
2174
  lastHoverXY = [e.clientX, e.clientY];
1801
2175
  dimLastAlt = e.altKey; // remembered so the rAF body can rebuild a synthetic event
2176
+ tfLastShift = e.shiftKey; // same for the Move/Copy ortho lock
1802
2177
  if (hoverRAF || pending || dragging || boxSel) return;
1803
2178
  hoverRAF = requestAnimationFrame(() => {
1804
2179
  hoverRAF = 0;
1805
2180
  if (!lastHoverXY || pending || dragging || boxSel) return;
2181
+ if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
2182
+ canvasEl.style.cursor = 'crosshair';
2183
+ if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2184
+ if (r && !(wpDraft && wpDraft.length)) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } }
2185
+ updateStatusChip(); return; }
2186
+ if (addActive()) { // Add-member armed: snap marker + rubber preview on the working plane
2187
+ canvasEl.style.cursor = 'crosshair';
2188
+ drPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
2189
+ updateStatusChip(); return; }
2190
+ if (cmActive()) { // Move/Copy armed: previews ride the working plane
2191
+ canvasEl.style.cursor = 'crosshair';
2192
+ if (tfDraft) tfPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
2193
+ else { const r = wpPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2194
+ if (r && r.snap) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false; }
2195
+ updateStatusChip(); return; }
1806
2196
  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
2197
  if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
1808
2198
  canvasEl.style.cursor = 'crosshair';
@@ -1837,12 +2227,20 @@ function updateStatusChip() {
1837
2227
  const rect = canvasEl.getBoundingClientRect();
1838
2228
  let txt = '';
1839
2229
  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';
2230
+ if (wpMode === 'face') txt = 'Click a member face the working plane lands on it · Esc to cancel';
2231
+ 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] || '';
2232
+ 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';
2233
+ else if (cmActive()) { const st = cmActive(), lock = tfAxis === 'u' ? ' · X locked' : tfAxis === 'v' ? ' · Y locked' : '';
2234
+ 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')
2235
+ : (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; }
2236
+ else if (clipMode === 'plane') txt = 'Click a face to set a clip plane · Esc to cancel';
1841
2237
  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
2238
  else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
1843
2239
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
1844
2240
  else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
1845
- else txt = 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
2241
+ // A read-only host (the vector 3D viewer) supplies its own idle hint the default speaks
2242
+ // editing verbs (move/stretch/elevate) that a viewer's no-op api would silently ignore.
2243
+ else txt = (api && typeof api.statusHint === 'function' && api.statusHint()) || 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
1846
2244
  hoverChip.textContent = txt;
1847
2245
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
1848
2246
  hoverChip.style.top = (rect.bottom - 30) + 'px';
@@ -1853,6 +2251,8 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
1853
2251
  function hide() {
1854
2252
  if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
1855
2253
  if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
2254
+ 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
2255
+ cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
1856
2256
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
1857
2257
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
1858
2258
  }
@@ -1883,6 +2283,9 @@ function dispose() {
1883
2283
  for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
1884
2284
  epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
1885
2285
  dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
2286
+ clearStructGrid();
2287
+ for (const tex of gridTexCache.values()) tex.dispose();
2288
+ gridTexCache.clear();
1886
2289
  clearRoot();
1887
2290
  if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
1888
2291
  clearClipGizmo(); setClipPreview(null); overlayScene = null;
@@ -1911,6 +2314,7 @@ function debug() {
1911
2314
  target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
1912
2315
  near: camera?.near, far: camera?.far,
1913
2316
  clips: getClips(), clipPlaneCount: renderer && renderer.clippingPlanes ? renderer.clippingPlanes.length : 0, clipMode, workArea: workAreaState(), selectedClips: [...selectedClipIds],
2317
+ workPlane: workPlaneInfo(), tfHasBase: !!tfDraft, tfGhosts: tfGhost ? tfGhost.children.length : 0, drHasStart: !!drDraft,
1914
2318
  };
1915
2319
  }
1916
2320
  // test helper: the current material colour + emissive of a member (verifies deselect restores colour)
@@ -1936,10 +2340,15 @@ window.Steel3DView = {
1936
2340
  init, show, hide, rebuild, setSelection, isReady, dispose,
1937
2341
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
1938
2342
  setRefLine, refLine: () => refLineOn,
2343
+ refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
2344
+ gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
1939
2345
  toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
1940
2346
  setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
1941
2347
  isolateSelected, clearIsolation, isIsolated,
1942
2348
  workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
2349
+ armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
2350
+ cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
2351
+ drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
1943
2352
  toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
1944
2353
  refreshOverlayDims,
1945
2354
  overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests