@floless/app 0.56.0 → 0.58.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.
@@ -32,7 +32,9 @@ const EP_PX = 4; // end-dot radius in screen px (scre
32
32
  let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
33
33
  let displayMode = 'solid'; // solid | wire | xray
34
34
  const groupHidden = new Set(); // profile keys hidden via the legend
35
- let soloGroup = null; // profile key isolated via the legend (dbl-click)
35
+ let soloGroups = new Set(); // profile keys isolated via the legend (dbl-click; Ctrl/Shift add to the set)
36
+ let isolatedIds = null; // Tekla "isolate selected": Set of ids shown exclusively, or null for off
37
+ let connHidden = new Set(); // explicit per-PART hide (legend connection rows) — lets a shared part-kind (weld/nut) hide per-connection by id, not per group
36
38
  let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
37
39
  const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
38
40
  const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
@@ -56,12 +58,18 @@ let dimPreviewLine = null; // reused preview line (point 1 → cursor)
56
58
  let dimLabelHost = null; // fixed-position container for label overlays
57
59
  const dimLabelPool = []; // reused <div> labels, one per placed dim, positioned in the loop
58
60
  let dimCandidates3d = null; // snap candidates cached for the active placement gesture
61
+ // ---- derived dimension overlays (the legend's DIMENSIONS toggles: bolt pitch / edge clearance / cope) ----
62
+ let overlayDimsGroup = null; // THREE.Group of derived-dim lines (recomputed from the scene parts, not persisted)
63
+ const overlayLabelPool = []; // reused <div> label chips for the overlay dims (share dimLabelHost, positioned in the loop)
64
+ let dimParts = []; // the dim-relevant scene elements retained from the last build (plates + copes) to derive overlays from
65
+ const DIM_TICK = 8; // mm: length of the 45° CAD terminator tick — shared by placed dims (refreshDims) + derived overlays (cadDim)
59
66
 
60
67
  function init(canvas, theApi) {
61
68
  if (renderer) return; // once
62
69
  canvasEl = canvas; api = theApi;
63
70
  renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
64
71
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
72
+ renderer.localClippingEnabled = true; // enable clip planes/boxes + the work area (Tekla-style sectioning) — driven via renderer.clippingPlanes (applyClips)
65
73
  scene = new THREE.Scene();
66
74
  scene.background = new THREE.Color(0x020817); // match the app --bg (the mode change reads from the geometry, not a tint)
67
75
  perspCam = new THREE.PerspectiveCamera(50, 1, 1, 50_000_000);
@@ -70,16 +78,26 @@ function init(canvas, theApi) {
70
78
  orthoCam.up.set(0, 0, 1);
71
79
  camera = perspCam;
72
80
  controls = new OrbitControls(camera, canvas);
73
- controls.enableDamping = true;
81
+ controls.enableDamping = false; // CAD feel: rotate/pan stop dead on release — NO post-release inertia/drift
82
+ controls.zoomSpeed = 1.3; // a little snappier so the wheel reaches a connection detail in fewer ticks
74
83
  // CAD-style mouse map (matches AWARE viewer-3d): LEFT is OURS (pick / drag / box-select — NOT
75
84
  // orbit), RIGHT-drag orbits, MIDDLE-drag pans, wheel zooms. This removes the old ambiguity where
76
85
  // a left-drag could mean either "orbit" or "move a member" — the source of "how do I drag in 3D?".
77
86
  controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE };
78
87
  controls.zoomToCursor = true; // Tekla-style: the mouse position is the centre of zooming
88
+ // CAD re-pivot: on every orbit/zoom/pan START (OrbitControls fires 'start' for all three — incl. the wheel,
89
+ // BEFORE applying the gesture), move the orbit target to the DEPTH of whatever is under the cursor. Kept ON
90
+ // the view axis so the view never jumps — only the pivot depth changes, which is what makes the wheel converge
91
+ // on a connection detail in a few ticks and orbit/pan scale to it, instead of pivoting around a stale framed
92
+ // centre far away. ponytail: on-axis depth re-pivot, not a true off-axis orbit-about-cursor (OrbitControls
93
+ // always keeps the pivot screen-centred — that would need a custom control); add that only if this isn't enough.
94
+ controls.addEventListener('start', repivotToCursor);
95
+ canvas.addEventListener('wheel', onWheelHover, { capture: true, passive: true }); // keep the cursor pos fresh for the wheel-triggered re-pivot (capture → runs before OrbitControls' wheel handler)
79
96
  scene.add(new THREE.AmbientLight(0xffffff, 0.65));
80
97
  const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(0.4, -1, 1.2); scene.add(key);
81
98
  const fill = new THREE.DirectionalLight(0xffffff, 0.25); fill.position.set(-1, 0.6, 0.4); scene.add(fill);
82
99
  root = new THREE.Group(); scene.add(root);
100
+ overlayScene = new THREE.Scene(); // clip/work-area gizmos render here in a 2nd unclipped pass (see loop)
83
101
  epGroup = new THREE.Group(); scene.add(epGroup);
84
102
  // ONE geometry + two materials shared by every endpoint dot (rebuilt often on hover/selection —
85
103
  // sharing avoids allocating/leaking a geometry+material per dot each rebuild).
@@ -107,6 +125,7 @@ function init(canvas, theApi) {
107
125
  // 3D dimension tool: a group of placed-dim lines (over the steel) + a live preview line + an HTML
108
126
  // overlay host for the midpoint labels (positioned each frame so they face the camera as screen text).
109
127
  dims3dGroup = new THREE.Group(); scene.add(dims3dGroup);
128
+ overlayDimsGroup = new THREE.Group(); scene.add(overlayDimsGroup); // derived dim overlays (bolt pitch / edge clearance / cope), toggled from the legend
110
129
  dimPreviewLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee }));
111
130
  dimPreviewLine.material.depthTest = false; dimPreviewLine.renderOrder = 997; dimPreviewLine.visible = false; scene.add(dimPreviewLine);
112
131
  dimLabelHost = document.createElement('div');
@@ -151,8 +170,16 @@ function loop() {
151
170
  rafId = requestAnimationFrame(loop);
152
171
  controls.update();
153
172
  sizeEndpoints();
173
+ sizeClipHandles();
154
174
  positionDimLabels();
175
+ positionOverlayLabels();
155
176
  renderer.render(scene, camera);
177
+ if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
178
+ const saved = renderer.clippingPlanes;
179
+ renderer.clippingPlanes = EMPTY_CLIPS; renderer.autoClear = false;
180
+ renderer.render(overlayScene, camera);
181
+ renderer.autoClear = true; renderer.clippingPlanes = saved;
182
+ }
156
183
  if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
157
184
  }
158
185
 
@@ -335,6 +362,34 @@ function clearRoot() {
335
362
  // member is left uncoped rather than breaking the whole scene.
336
363
  const _csgEval = new Evaluator();
337
364
  _csgEval.useGroups = false; // one solid out → the member's single material applies
365
+
366
+ // The 2D cut profile (u-v plane, centred) as a rectangle with ONLY the re-entrant corner filleted — the
367
+ // inner end (+u, away from the beam tip) on the web side (`reentrantV` sign in v), the corner AISC requires
368
+ // radiused on a cope. Corners elsewhere stay square so the cope mouth + outer flange edges read crisp.
369
+ function roundedCutGeometry(W, D, T, r, reentrantV) {
370
+ const hw = W / 2, hd = D / 2;
371
+ const rr = Math.max(0, Math.min(r, Math.min(hw, hd) - 0.1));
372
+ const pts = [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]]; // CCW: BL, BR, TR, TL
373
+ const rx = hw, ry = reentrantV * hd; // the re-entrant corner to fillet
374
+ const shape = new THREE.Shape();
375
+ shape.moveTo(pts[0][0], pts[0][1]); // BL — never the rounded corner
376
+ const N = pts.length;
377
+ for (let i = 1; i <= N; i++) {
378
+ const cur = pts[i % N], prev = pts[(i - 1 + N) % N], next = pts[(i + 1) % N];
379
+ if (rr > 0 && i < N && Math.abs(cur[0] - rx) < 1e-6 && Math.abs(cur[1] - ry) < 1e-6) {
380
+ const dl = Math.hypot(cur[0] - prev[0], cur[1] - prev[1]) || 1;
381
+ const ol = Math.hypot(next[0] - cur[0], next[1] - cur[1]) || 1;
382
+ shape.lineTo(cur[0] - ((cur[0] - prev[0]) / dl) * rr, cur[1] - ((cur[1] - prev[1]) / dl) * rr);
383
+ shape.quadraticCurveTo(cur[0], cur[1], cur[0] + ((next[0] - cur[0]) / ol) * rr, cur[1] + ((next[1] - cur[1]) / ol) * rr);
384
+ } else {
385
+ shape.lineTo(cur[0], cur[1]);
386
+ }
387
+ }
388
+ const g = new THREE.ExtrudeGeometry(shape, { depth: T, bevelEnabled: false, curveSegments: 8 });
389
+ g.translate(0, 0, -T / 2); // extrude runs 0..T along local n → centre it
390
+ return g;
391
+ }
392
+
338
393
  function applyCopes(mesh, cuts) {
339
394
  try {
340
395
  mesh.updateMatrix(); // local TRS (root is identity → local == world mm)
@@ -342,14 +397,21 @@ function applyCopes(mesh, cuts) {
342
397
  let result = new Brush(mesh.geometry); result.updateMatrixWorld(true); // member-local, identity
343
398
  for (const cut of cuts) {
344
399
  const pad = 0.6; // over-cut so coplanar faces don't leave slivers
345
- const g = new THREE.BoxGeometry(Math.max(cut.width || 1, 1) + pad, Math.max(cut.depth || 1, 1) + pad, Math.max(cut.thickness || 1, 1) + pad);
400
+ const W = Math.max(cut.width || 1, 1) + pad, D = Math.max(cut.depth || 1, 1) + pad, Th = Math.max(cut.thickness || 1, 1) + pad;
346
401
  const u = V(...(cut.uDir || [1, 0, 0])).normalize(), vv = V(...(cut.vDir || [0, 1, 0])).normalize();
347
402
  const n = new THREE.Vector3().crossVectors(u, vv).normalize();
348
403
  const world = new THREE.Matrix4().makeBasis(u, vv, n); world.setPosition(cut.center[0], cut.center[1], cut.center[2]);
349
- const b = new Brush(g);
350
- invMember.clone().multiply(world).decompose(b.position, b.quaternion, b.scale); // cut member-local
351
- b.updateMatrixWorld(true);
352
- result = _csgEval.evaluate(result, b, SUBTRACTION);
404
+ const localM = invMember.clone().multiply(world); // cut → member-local
405
+ const mkBrush = (geom) => { const b = new Brush(geom); localM.decompose(b.position, b.quaternion, b.scale); b.updateMatrixWorld(true); return b; };
406
+ const rounded = cut.radius > 0 && !!cut.reentrantV; // re-entrant fillet (AISC); else a square cut
407
+ let g = rounded ? roundedCutGeometry(W, D, Th, cut.radius, cut.reentrantV) : new THREE.BoxGeometry(W, D, Th);
408
+ try {
409
+ result = _csgEval.evaluate(result, mkBrush(g), SUBTRACTION);
410
+ } catch (e) {
411
+ if (!rounded) throw e;
412
+ g.dispose(); g = new THREE.BoxGeometry(W, D, Th); // square-cut fallback when the fillet fights CSG
413
+ result = _csgEval.evaluate(result, mkBrush(g), SUBTRACTION);
414
+ }
353
415
  g.dispose();
354
416
  }
355
417
  mesh.geometry.dispose();
@@ -384,8 +446,15 @@ function buildFromScene(sc) {
384
446
  box.expandByObject(mesh);
385
447
  }
386
448
  sceneBox = box.clone();
449
+ // Retain the dim-relevant parts (plates + copes) so the legend's DIMENSIONS overlays can be derived
450
+ // from the resolved geometry — fin-plate holes give bolt pitch/edge clearance, cuts give cope size.
451
+ // (computeOverlayDims scopes the plate path to group 'shear-plate' — base/washer plates are skipped there.)
452
+ dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
453
+ renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
387
454
  buildGrid(box);
388
455
  buildRefLines();
456
+ if (isolatedIds) { isolatedIds = new Set([...isolatedIds].filter((id) => meshById.has(id))); if (!isolatedIds.size) { isolatedIds = null; if (api && api.onIsolateChange) api.onIsolateChange(false); } } // drop ids an edit removed; if none survive, exit isolation + refresh the host's Show-all button
457
+ if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
389
458
  applyGroupVisibility(); applyDisplayMode();
390
459
  built = true;
391
460
  if (fitPending) { fitCamera(box); fitPending = false; } // fit only on entering 3D (rebuild(true)) — NOT on every edit
@@ -462,7 +531,7 @@ function fitCamera(box, dir) {
462
531
  perspCam.position.copy(c).addScaledVector(d, dist);
463
532
  orthoCam.position.copy(c).addScaledVector(d, dist);
464
533
  const r = box.getBoundingSphere(new THREE.Sphere()).radius || dist;
465
- const near = Math.max(dist / 1000, 1), far = dist + r * 4;
534
+ const near = Math.max(dist / 2000, 0.5), far = dist + r * 4; // small near so the wheel can zoom right up to a connection detail without clipping
466
535
  perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
467
536
  orthoCam.near = near; orthoCam.far = far; orthoCam.zoom = 1;
468
537
  orthoBaseH = Math.max(extU, extR / aspect) * 1.15; // tight, aspect-preserving box fit (no ortho under-frame)
@@ -470,6 +539,25 @@ function fitCamera(box, dir) {
470
539
  controls.update();
471
540
  }
472
541
 
542
+ // CAD re-pivot (wired to OrbitControls' 'start' — fires for orbit/pan/wheel before the gesture is applied):
543
+ // set the orbit/zoom target to the DEPTH of whatever visible mesh is under the cursor, ON the camera's view
544
+ // axis. On-axis keeps the camera orientation (the camera was already looking along this axis), so there's no
545
+ // jump — only the pivot distance changes, so the next orbit rotates about that point's depth and the wheel/pan
546
+ // scale to it. Empty space → keep the current pivot (Fit / Home re-centre the model).
547
+ function onWheelHover(e) { lastHoverXY = [e.clientX, e.clientY]; } // capture-phase wheel → fresh cursor pos for the re-pivot (named so dispose can remove it)
548
+ function repivotToCursor() {
549
+ if (!controls || !camera || !canvasEl || !lastHoverXY || !raycaster) return;
550
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
551
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return;
552
+ const ndc = new THREE.Vector2(((lastHoverXY[0] - rect.left) / rect.width) * 2 - 1, -((lastHoverXY[1] - rect.top) / rect.height) * 2 + 1);
553
+ raycaster.setFromCamera(ndc, camera);
554
+ const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
555
+ if (!hits.length) return; // nothing under the cursor → leave the pivot put
556
+ const fwd = camera.getWorldDirection(new THREE.Vector3());
557
+ const depth = hits[0].point.clone().sub(camera.position).dot(fwd); // hit distance along the view axis
558
+ if (depth > 1e-3) controls.target.copy(camera.position).addScaledVector(fwd, depth);
559
+ }
560
+
473
561
  let rebuildSeq = 0;
474
562
  async function rebuild(fit = false) {
475
563
  if (!api) return;
@@ -479,6 +567,7 @@ async function rebuild(fit = false) {
479
567
  if (seq !== rebuildSeq) return; // a newer rebuild superseded this one → drop the stale scene (no out-of-order apply)
480
568
  if (sc) buildFromScene(sc);
481
569
  refreshDims(); // persisted dims appear + survive edit-rebuilds (re-coloured for the current selection)
570
+ try { refreshOverlayDims(); } catch (e) { console.warn('dim overlays skipped (bad part)', e); } // a malformed part drops its overlay, never the whole rebuild (mirrors applyCopes' containment)
482
571
  }
483
572
 
484
573
  // ---- Persp/Ortho + Solid/Wire/X-ray + group visibility (parity with the AWARE viewer) ----
@@ -511,14 +600,348 @@ function applyDisplayMode() {
511
600
  function applyGroupVisibility() {
512
601
  for (const m of meshById.values()) {
513
602
  const k = m.userData && m.userData.group;
514
- m.visible = !groupHidden.has(k) && (soloGroup === null || soloGroup === k);
603
+ const byGroup = !groupHidden.has(k) && (soloGroups.size === 0 || soloGroups.has(k));
604
+ const byIso = isolatedIds === null || isolatedIds.has(m.userData.id); // isolate-selected: only the isolated ids show
605
+ m.visible = byGroup && byIso && !connHidden.has(m.userData.id); // connHidden: per-part legend hide (connection rows split a shared part-kind by connection)
515
606
  }
516
607
  }
517
- function toggleGroup(k) { if (groupHidden.has(k)) groupHidden.delete(k); else groupHidden.add(k); soloGroup = null; applyGroupVisibility(); rebuildEndpoints(); }
518
- function soloToggle(k) { soloGroup = soloGroup === k ? null : k; if (soloGroup) groupHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); }
519
- function showAllGroups() { groupHidden.clear(); soloGroup = null; applyGroupVisibility(); rebuildEndpoints(); }
520
- function groupState() { return { hidden: [...groupHidden], solo: soloGroup }; }
608
+ function toggleGroup(k) { if (groupHidden.has(k)) groupHidden.delete(k); else groupHidden.add(k); soloGroups.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); }
609
+ // Hide/show a SET of groups in one pass (a legend type-category master toggle) deterministic (not a flip),
610
+ // so "hide all" / "show all" for the category is exact. Clears solo, like toggleGroup.
611
+ function setGroupsHidden(keys, hidden) { for (const k of (keys || [])) { if (hidden) groupHidden.add(k); else groupHidden.delete(k); } soloGroups.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); }
612
+ // Hide/show specific PART ids (a legend connection row / connection master). Per-id so a connection's own
613
+ // nuts/welds toggle independently of the other connection's (the part-kind group is shared across joints).
614
+ function setIdsHidden(ids, hidden) { for (const id of (ids || [])) { if (hidden) connHidden.add(id); else connHidden.delete(id); } applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); }
615
+ // Isolate an explicit SET of profile groups (the editor's legend builds it from Ctrl/Shift dbl-clicks, Explorer-style).
616
+ function setSoloGroups(keys) { soloGroups = new Set((keys || []).filter((k) => k != null)); if (soloGroups.size) groupHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); }
617
+ function soloToggle(k) { setSoloGroups(soloGroups.size === 1 && soloGroups.has(k) ? [] : [k]); } // plain dbl-click: isolate just this group (or clear if it's already the only one)
618
+ function showAllGroups() { groupHidden.clear(); soloGroups.clear(); isolatedIds = null; connHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); if (api && api.onIsolateChange) api.onIsolateChange(false); }
619
+ function groupState() { return { hidden: [...groupHidden], solo: [...soloGroups] }; }
620
+ // Tekla "isolate selected": show ONLY the currently-selected parts (a snapshot taken now — distinct from
621
+ // soloToggle, which isolates a whole PROFILE group from the legend). clearIsolation / showAllGroups restore all.
622
+ function isolateSelected() { if (!selIds.size) return false; isolatedIds = new Set(selIds); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); updateStatusChip(); if (api && api.onIsolateChange) api.onIsolateChange(true); return true; }
623
+ function clearIsolation() { if (isolatedIds === null) return; isolatedIds = null; applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); updateStatusChip(); if (api && api.onIsolateChange) api.onIsolateChange(false); }
624
+ function isIsolated() { return isolatedIds !== null; }
521
625
  function getGroups() { return sceneGroups.map((g) => ({ ...g })); }
626
+
627
+ // ---- clip planes / clip boxes (Tekla-style sectioning) ----
628
+ // three.js clips a fragment whose SIGNED DISTANCE to a Plane is negative — so the normal side is KEPT. With the
629
+ // default union behaviour a fragment is removed if it's clipped by ANY plane → kept only on the keep-side of ALL
630
+ // of them. So a clip plane = 1 plane (keep the normal side); a clip box = 6 INWARD planes (keep inside). They all
631
+ // live in renderer.clippingPlanes (GLOBAL): clips survive scene rebuilds (renderer-level, not per-material) and
632
+ // clip the grid/dims/markers too, like Tekla. The ViewCube has its own renderer → it's never clipped.
633
+ let overlayScene = null; // clip/work-area gizmos live here, rendered in a 2nd UNCLIPPED pass so a clip never sections its own (or another's) handles
634
+ let clips = []; // each clip stores its SOURCE geom (plane: n+point; box: Box3) + derived .planes
635
+ let workArea = null; // the single work area (a clip box with a visible boundary) — set by the work-area controls
636
+ let clipSeq = 0, clipMode = null; // clipMode: 'plane' (click a face) | 'box' (2-corner draw) | null
637
+ let selectedClipIds = new Set(); // the clips whose outline + drag handles show in 3D (multi-select like parts/dims)
638
+ let clipBoxDraft = null; // { a:[x,y] } during a 2-point clip-box draw
639
+ let clipGizmo = null; // THREE.Group: the selected clip's outline + drag handles
640
+ let clipPreview = null; // THREE.Box3Helper while drawing a clip box
641
+ const EMPTY_CLIPS = Object.freeze([]);
642
+ const CLIP_PLANE_COLOR = 0x3b82f6, CLIP_BOX_COLOR = 0x93c5fd; // brand blue (plane) / lighter blue (box) — match the legend swatches
643
+ function applyClips() {
644
+ if (!renderer) return;
645
+ const active = clips.filter((c) => c.enabled).flatMap((c) => c.planes);
646
+ if (workArea && workArea.enabled) active.push(...workArea.planes); // the work area sections the view too
647
+ renderer.clippingPlanes = active.length ? active : EMPTY_CLIPS;
648
+ }
649
+ // 6 inward planes (keep INSIDE) for an axis-aligned Box3 → a section/clip box.
650
+ function boxToPlanes(b) {
651
+ return [
652
+ new THREE.Plane(new THREE.Vector3(-1, 0, 0), b.max.x), new THREE.Plane(new THREE.Vector3(1, 0, 0), -b.min.x),
653
+ new THREE.Plane(new THREE.Vector3(0, -1, 0), b.max.y), new THREE.Plane(new THREE.Vector3(0, 1, 0), -b.min.y),
654
+ new THREE.Plane(new THREE.Vector3(0, 0, -1), b.max.z), new THREE.Plane(new THREE.Vector3(0, 0, 1), -b.min.z),
655
+ ];
656
+ }
657
+ function rebuildClipPlanes(c) { // recompute a clip's .planes from its editable source geometry (after a handle drag)
658
+ c.planes = c.kind === 'box' ? boxToPlanes(c.box) : [new THREE.Plane().setFromNormalAndCoplanarPoint(c.n, c.point)];
659
+ }
660
+ function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
661
+ if (m && dimMode3d) toggleDimTool();
662
+ clipMode = (m === 'plane' || m === 'box') ? m : null;
663
+ clipBoxDraft = null; setClipPreview(null);
664
+ if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
665
+ updateStatusChip();
666
+ if (api && api.onClipModeChange) api.onClipModeChange(clipMode);
667
+ return clipMode;
668
+ }
669
+ function clipModeOn() { return clipMode; }
670
+ // A clip plane from a clicked surface (screen px): keep the camera-far side. Selects the new clip. Stays armed.
671
+ function addClipPlaneAtScreen(cx, cy) {
672
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
673
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
674
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
675
+ raycaster.setFromCamera(ndc, camera);
676
+ const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false)[0];
677
+ if (!hit || !hit.face) return null; // missed a face → no plane (the user can click again)
678
+ const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize(); // face normal → world
679
+ if (n.dot(camera.position.clone().sub(hit.point)) > 0) n.negate(); // point AWAY from the camera → keep the FAR side, cut away what's between you and the clicked surface (reveal the section)
680
+ if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
681
+ const id = 'clip' + (++clipSeq);
682
+ const c = { id, kind: 'plane', enabled: true, n: n.clone(), point: hit.point.clone(), planes: [], label: 'Plane ' + clipSeq };
683
+ rebuildClipPlanes(c); clips.push(c);
684
+ applyClips(); selectClip(id); if (api && api.onClipsChange) api.onClipsChange();
685
+ return id;
686
+ }
687
+ function addClipBoxFromBox(box) { // commit a clip box from an explicit Box3 (the 2-corner draw) — selects it
688
+ if (!box || box.isEmpty()) return null;
689
+ if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
690
+ const id = 'clip' + (++clipSeq);
691
+ const c = { id, kind: 'box', enabled: true, box: box.clone(), planes: [], label: 'Box ' + clipSeq };
692
+ rebuildClipPlanes(c); clips.push(c);
693
+ applyClips(); selectClip(id); if (api && api.onClipsChange) api.onClipsChange();
694
+ return id;
695
+ }
696
+ function addClipBox(pad = 150) { // convenience: a clip box from the selection's bounds (or the whole model)
697
+ const box = new THREE.Box3();
698
+ if (selIds.size) for (const id of selIds) { const m = meshById.get(id); if (m && m.visible) box.expandByObject(m); }
699
+ if (box.isEmpty()) box.copy(sceneBox);
700
+ if (box.isEmpty()) return null;
701
+ box.expandByScalar(pad);
702
+ return addClipBoxFromBox(box);
703
+ }
704
+ function toggleClip(id, on) { const c = clips.find((x) => x.id === id); if (!c) return; if (api && api.beginClipEdit) api.beginClipEdit(); c.enabled = on === undefined ? !c.enabled : !!on; applyClips(); renderClipGizmo(); if (api && api.onClipsChange) api.onClipsChange(); }
705
+ // Rename a clip. Returns false if the name is empty or already used by ANOTHER clip (case-insensitive) — the
706
+ // caller (legend inline edit) keeps the old name + warns. Undoable.
707
+ function renameClip(id, name) {
708
+ const c = clips.find((x) => x.id === id); if (!c) return false;
709
+ const nm = String(name || '').trim(); if (!nm) return false;
710
+ if (clips.some((x) => x.id !== id && x.label.toLowerCase() === nm.toLowerCase())) return false; // name taken
711
+ if (api && api.beginClipEdit) api.beginClipEdit();
712
+ c.label = nm; if (api && api.onClipsChange) api.onClipsChange();
713
+ return true;
714
+ }
715
+ function removeClip(id) { if (!clips.some((c) => c.id === id)) return; if (api && api.beginClipEdit) api.beginClipEdit(); clips = clips.filter((c) => c.id !== id); selectedClipIds.delete(id); applyClips(); renderClipGizmo(); if (api && api.onClipsChange) api.onClipsChange(); }
716
+ function clearClips() { if (!clips.length) { if (api && api.onClipsChange) api.onClipsChange(); return; } if (api && api.beginClipEdit) api.beginClipEdit(); clips = []; selectedClipIds.clear(); applyClips(); renderClipGizmo(); if (api && api.onClipsChange) api.onClipsChange(); }
717
+ function getClips() { return clips.map((c) => ({ id: c.id, kind: c.kind, enabled: c.enabled, label: c.label, selected: selectedClipIds.has(c.id), size: c.box ? [c.box.max.x - c.box.min.x, c.box.max.y - c.box.min.y, c.box.max.z - c.box.min.z].map((v) => Math.round(v)) : null, planeConst: c.kind === 'plane' && c.planes[0] ? Math.round(c.planes[0].constant) : null })); }
718
+ function setSelectedClips(ids) { selectedClipIds = new Set((ids || []).filter((id) => clips.some((c) => c.id === id))); renderClipGizmo(); if (api && api.onClipsChange) api.onClipsChange(); } // multi-select (the editor builds the set from Ctrl/Shift clicks)
719
+ function selectClip(id) { setSelectedClips(id ? [id] : []); }
720
+ function selectedClips() { return [...selectedClipIds]; }
721
+ function deleteSelectedClips() { if (!selectedClipIds.size) return; if (api && api.beginClipEdit) api.beginClipEdit(); clips = clips.filter((c) => !selectedClipIds.has(c.id)); selectedClipIds.clear(); applyClips(); renderClipGizmo(); if (api && api.onClipsChange) api.onClipsChange(); } // Del key
722
+ // ---- undo/redo: clips + work area are view-state (not in the contract), so the editor's snapshot grabs this
723
+ // serialized form and restores it via setClipState. Each manipulation calls api.beginClipEdit() FIRST (pushes a
724
+ // pre-edit snapshot); the editor's Ctrl+Z/Y then restores clips alongside the contract.
725
+ function clipState() {
726
+ return {
727
+ clips: clips.map((c) => c.kind === 'box'
728
+ ? { id: c.id, kind: 'box', enabled: c.enabled, label: c.label, box: { min: c.box.min.toArray(), max: c.box.max.toArray() } }
729
+ : { id: c.id, kind: 'plane', enabled: c.enabled, label: c.label, n: c.n.toArray(), point: c.point.toArray() }),
730
+ workArea: workArea ? { enabled: workArea.enabled, box: { min: workArea.box.min.toArray(), max: workArea.box.max.toArray() } } : null,
731
+ selected: [...selectedClipIds], seq: clipSeq,
732
+ };
733
+ }
734
+ function setClipState(s) {
735
+ s = s || { clips: [], workArea: null, selected: [], seq: clipSeq };
736
+ clips = (s.clips || []).map((d) => {
737
+ const c = d.kind === 'box'
738
+ ? { id: d.id, kind: 'box', enabled: d.enabled, label: d.label, box: new THREE.Box3(new THREE.Vector3(...d.box.min), new THREE.Vector3(...d.box.max)), planes: [] }
739
+ : { id: d.id, kind: 'plane', enabled: d.enabled, label: d.label, n: new THREE.Vector3(...d.n), point: new THREE.Vector3(...d.point), planes: [] };
740
+ rebuildClipPlanes(c); return c;
741
+ });
742
+ if (s.workArea) { const b = new THREE.Box3(new THREE.Vector3(...s.workArea.box.min), new THREE.Vector3(...s.workArea.box.max)); workArea = { enabled: s.workArea.enabled, box: b, planes: boxToPlanes(b) }; }
743
+ else workArea = null;
744
+ selectedClipIds = new Set(s.selected || []);
745
+ if (typeof s.seq === 'number') clipSeq = Math.max(clipSeq, s.seq);
746
+ applyClips(); renderClipGizmo(); renderWorkArea();
747
+ if (api && api.onClipsChange) api.onClipsChange();
748
+ }
749
+
750
+ // ---- clip gizmo: the selected clip's outline + draggable handles (a plane move-arrow, or 6 box face handles) ----
751
+ function clearClipGizmo() {
752
+ if (!clipGizmo) return;
753
+ for (const o of [...clipGizmo.children]) { clipGizmo.remove(o); if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); mm.forEach((m) => m.dispose()); }
754
+ if (overlayScene) overlayScene.remove(clipGizmo); clipGizmo = null;
755
+ }
756
+ const FACE_AXES = [{ axis: 'x', sign: 1 }, { axis: 'x', sign: -1 }, { axis: 'y', sign: 1 }, { axis: 'y', sign: -1 }, { axis: 'z', sign: 1 }, { axis: 'z', sign: -1 }];
757
+ function renderClipGizmo() {
758
+ clearClipGizmo();
759
+ if (!selectedClipIds.size || !overlayScene) return;
760
+ clipGizmo = new THREE.Group(); overlayScene.add(clipGizmo);
761
+ // The SAME manipulator for a clip plane AND each clip-box face: a translucent disc lying in the plane/face +
762
+ // a normal arrow (stem + cone) showing the drag direction. Grab the disc or the arrow to slide it along `normal`.
763
+ const addHandle = (anchor, normal, ud, color) => {
764
+ const disc = new THREE.Mesh(new THREE.CircleGeometry(1, 32), new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.32, side: THREE.DoubleSide }));
765
+ disc.material.depthTest = false; disc.renderOrder = 998; disc.position.copy(anchor); disc.quaternion.setFromUnitVectors(_ZA, normal); disc.userData = { clipHandle: true, disc: true, ...ud }; clipGizmo.add(disc);
766
+ const stem = new THREE.Line(new THREE.BufferGeometry().setFromPoints([anchor.clone(), anchor.clone()]), new THREE.LineBasicMaterial({ color })); stem.material.depthTest = false; stem.renderOrder = 998; stem.userData = { clipStem: true, baseHp: anchor.clone(), normal: normal.clone() }; clipGizmo.add(stem);
767
+ const cone = new THREE.Mesh(new THREE.ConeGeometry(0.5, 1.5, 18), new THREE.MeshBasicMaterial({ color })); cone.material.depthTest = false; cone.renderOrder = 999; cone.quaternion.setFromUnitVectors(_YA, normal); cone.userData = { clipHandle: true, arrow: true, baseHp: anchor.clone(), normal: normal.clone(), ...ud }; clipGizmo.add(cone);
768
+ };
769
+ for (const c of clips) { // a gizmo for EACH selected clip (the handles carry clipId so a drag knows which one)
770
+ if (!selectedClipIds.has(c.id)) continue;
771
+ if (c.kind === 'box') {
772
+ const hb = new THREE.Box3Helper(c.box, new THREE.Color(CLIP_BOX_COLOR)); hb.material.depthTest = false; hb.renderOrder = 996; clipGizmo.add(hb);
773
+ const ctr = c.box.getCenter(new THREE.Vector3());
774
+ for (const f of FACE_AXES) {
775
+ const p = ctr.clone(); p[f.axis] = f.sign > 0 ? c.box.max[f.axis] : c.box.min[f.axis]; // face centre
776
+ const nf = new THREE.Vector3(f.axis === 'x' ? f.sign : 0, f.axis === 'y' ? f.sign : 0, f.axis === 'z' ? f.sign : 0); // OUTWARD face normal = drag axis
777
+ addHandle(p, nf, { face: f, clipId: c.id }, CLIP_BOX_COLOR);
778
+ }
779
+ } else {
780
+ const ctr = sceneBox.getCenter(new THREE.Vector3());
781
+ const d = c.n.dot(ctr) + c.planes[0].constant, hp = ctr.clone().addScaledVector(c.n, -d); // model centre projected onto the plane
782
+ const r = (sceneBox.getSize(new THREE.Vector3()).length() * 0.28) || 800; // a moderate plane "patch" around the handle — shows the plane without spanning the whole view
783
+ const u = (Math.abs(c.n.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(1, 0, 0)).cross(c.n).normalize(), v = c.n.clone().cross(u).normalize();
784
+ const sq = [hp.clone().addScaledVector(u, r).addScaledVector(v, r), hp.clone().addScaledVector(u, -r).addScaledVector(v, r), hp.clone().addScaledVector(u, -r).addScaledVector(v, -r), hp.clone().addScaledVector(u, r).addScaledVector(v, -r)];
785
+ const outline = new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(sq), new THREE.LineBasicMaterial({ color: CLIP_PLANE_COLOR })); outline.material.depthTest = false; outline.renderOrder = 996; clipGizmo.add(outline);
786
+ addHandle(hp, c.n, { plane: true, clipId: c.id }, CLIP_PLANE_COLOR); // disc-in-plane + normal arrow (same manipulator as the box faces)
787
+ }
788
+ }
789
+ sizeClipHandles();
790
+ }
791
+ function sizeClipHandles() { // screen-constant handles (like the endpoint dots): box pads ~10px; plane disc ~14px + an arrow offset ~34px off the plane
792
+ if (!clipGizmo) return;
793
+ for (const h of clipGizmo.children) {
794
+ const ud = h.userData; if (!ud) continue;
795
+ if (ud.arrow) { const off = pxToWorldAt(34, ud.baseHp); h.position.copy(ud.baseHp).addScaledVector(ud.normal, off); h.scale.setScalar(pxToWorldAt(11, h.position)); }
796
+ else if (ud.disc) h.scale.setScalar(pxToWorldAt(14, h.position));
797
+ else if (ud.clipHandle) h.scale.setScalar(pxToWorldAt(10, h.position)); // box face pads
798
+ else if (ud.clipStem) h.geometry.setFromPoints([ud.baseHp, ud.baseHp.clone().addScaledVector(ud.normal, pxToWorldAt(34, ud.baseHp))]); // stem from the plane to the cone
799
+ }
800
+ }
801
+ function setClipPreview(box) { // ghost wireframe while drawing a clip box (null clears it)
802
+ if (clipPreview) { if (overlayScene) overlayScene.remove(clipPreview); clipPreview.geometry.dispose(); clipPreview.material.dispose(); clipPreview = null; }
803
+ if (!box || box.isEmpty() || !overlayScene) return;
804
+ clipPreview = new THREE.Box3Helper(box.clone(), new THREE.Color(CLIP_BOX_COLOR)); clipPreview.material.depthTest = false; clipPreview.renderOrder = 997; overlayScene.add(clipPreview);
805
+ }
806
+ // Screen-space nearest clip handle (mesh) within a px tolerance, or null — like pickEndpoint.
807
+ function pickClipHandle(cx, cy) {
808
+ if (!clipGizmo) return null;
809
+ camera.updateMatrixWorld();
810
+ const rect = canvasEl.getBoundingClientRect(), px = cx - rect.left, py = cy - rect.top;
811
+ let best = null, bestD = 16;
812
+ for (const h of clipGizmo.children) {
813
+ if (!h.userData || !h.userData.clipHandle) continue;
814
+ const v = h.position.clone().project(camera); if (v.z > 1) continue;
815
+ const sx = (v.x * 0.5 + 0.5) * rect.width, sy = (-v.y * 0.5 + 0.5) * rect.height, dd = Math.hypot(sx - px, sy - py);
816
+ if (dd < bestD) { bestD = dd; best = h; }
817
+ }
818
+ return best;
819
+ }
820
+ // test helper: the selected clip's handles projected to client px (so an E2E can drag a specific one)
821
+ function clipHandlesScreen() {
822
+ if (!clipGizmo || !canvasEl) return [];
823
+ camera.updateMatrixWorld();
824
+ const rect = canvasEl.getBoundingClientRect(), out = [];
825
+ for (const h of clipGizmo.children) {
826
+ if (!h.userData || !h.userData.clipHandle) continue;
827
+ const v = h.position.clone().project(camera);
828
+ out.push({ plane: !!h.userData.plane, axis: h.userData.face ? h.userData.face.axis : null, sign: h.userData.face ? h.userData.face.sign : 0, x: rect.left + (v.x * 0.5 + 0.5) * rect.width, y: rect.top + (-v.y * 0.5 + 0.5) * rect.height, behind: v.z > 1 });
829
+ }
830
+ return out;
831
+ }
832
+ // t along the line P + t*u (u unit) at the point closest to the cursor ray — drives handle dragging.
833
+ function lineClosestT(P, u, ray) {
834
+ const w0 = P.clone().sub(ray.origin), b = u.dot(ray.direction), e = ray.direction.dot(w0), d = u.dot(w0), cc = ray.direction.dot(ray.direction);
835
+ const denom = cc - b * b; if (Math.abs(denom) < 1e-6) return d; // line ∥ ray → fall back to projecting w0 on u
836
+ return (b * e - cc * d) / denom;
837
+ }
838
+ function rayAt(cx, cy) {
839
+ const rect = canvasEl.getBoundingClientRect();
840
+ raycaster.setFromCamera(new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1), camera);
841
+ return raycaster.ray;
842
+ }
843
+ function startClipDrag(h, e) { // h = the picked handle mesh; set up a drag along its axis
844
+ const c = clips.find((x) => x.id === h.userData.clipId); if (!c) { controls.enabled = true; return; }
845
+ const u = h.userData.plane ? c.n.clone() : new THREE.Vector3(h.userData.face.axis === 'x' ? 1 : 0, h.userData.face.axis === 'y' ? 1 : 0, h.userData.face.axis === 'z' ? 1 : 0);
846
+ const lineP = h.position.clone(), t0 = lineClosestT(lineP, u, rayAt(e.clientX, e.clientY));
847
+ pending = h.userData.plane
848
+ ? { clipDrag: true, clip: c, plane: true, u, lineP, t0, basePoint: c.point.clone(), prePoint: c.point.clone() }
849
+ : { clipDrag: true, clip: c, face: h.userData.face, u, lineP, t0, startCoord: h.userData.face.sign > 0 ? c.box.max[h.userData.face.axis] : c.box.min[h.userData.face.axis], preBox: c.box.clone() };
850
+ dragging = null;
851
+ }
852
+ function onMoveClip(e) {
853
+ if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
854
+ if (!dragging && api && api.beginClipEdit) api.beginClipEdit(); // first real move → snapshot the pre-drag state for undo
855
+ dragging = pending;
856
+ const c = pending.clip, t = lineClosestT(pending.lineP, pending.u, rayAt(e.clientX, e.clientY)), delta = t - pending.t0;
857
+ if (pending.plane) { c.point = pending.basePoint.clone().addScaledVector(pending.u, delta); }
858
+ else {
859
+ const f = pending.face; let val = pending.startCoord + delta;
860
+ if (f.sign > 0) c.box.max[f.axis] = Math.max(val, c.box.min[f.axis] + 1); // keep min < max
861
+ else c.box.min[f.axis] = Math.min(val, c.box.max[f.axis] - 1);
862
+ }
863
+ rebuildClipPlanes(c); applyClips(); renderClipGizmo();
864
+ if (readout) { // live readout near the cursor: the box dimension along the drag axis, or the plane's offset moved
865
+ readout._dist.textContent = (pending.plane ? (delta / FT_MM) : ((c.box.max[pending.face.axis] - c.box.min[pending.face.axis]) / FT_MM)).toFixed(2) + ' ft';
866
+ readout._type.textContent = pending.plane ? ' ⟂' : ' ' + pending.face.axis.toUpperCase();
867
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
868
+ }
869
+ }
870
+ // 3-step clip-box draw: click the two FOOTPRINT corners on the floor, then move up/down to PULL the height and
871
+ // click to commit (CAD "rectangle then extrude"). clipBoxDraft: {a} → {a,b} → committed.
872
+ const clipBoxFloorZ = () => (sceneBox.isEmpty() ? 0 : sceneBox.min.z);
873
+ function clipBoxFrom(a, b, zlo, ztop) {
874
+ return new THREE.Box3(new THREE.Vector3(Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.min(zlo, ztop)), new THREE.Vector3(Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.max(zlo, ztop)));
875
+ }
876
+ // the cursor's height above the floor — the closest point on the vertical line through the footprint centre to
877
+ // the cursor ray (works in any non-top view; a top view can't define a height so it stays ~flat there).
878
+ function clipBoxHeightAt(cx, cy) {
879
+ const zlo = clipBoxFloorZ(), a = clipBoxDraft.a, b = clipBoxDraft.b;
880
+ const P = new THREE.Vector3((a[0] + b[0]) / 2, (a[1] + b[1]) / 2, zlo);
881
+ return zlo + Math.max(lineClosestT(P, new THREE.Vector3(0, 0, 1), rayAt(cx, cy)), 1);
882
+ }
883
+ function onClipBoxClick(e) {
884
+ const zlo = clipBoxFloorZ();
885
+ if (!clipBoxDraft) { // corner 1
886
+ const g = rayToPlane(e.clientX, e.clientY, zlo); if (!g) return;
887
+ clipBoxDraft = { a: [g[0], g[1]], b: null }; updateStatusChip(); return;
888
+ }
889
+ if (!clipBoxDraft.b) { // corner 2 → footprint set; enter height mode
890
+ const g = rayToPlane(e.clientX, e.clientY, zlo); if (!g) return;
891
+ if (Math.abs(g[0] - clipBoxDraft.a[0]) < 1 && Math.abs(g[1] - clipBoxDraft.a[1]) < 1) return; // ignore a same-point 2nd click
892
+ clipBoxDraft.b = [g[0], g[1]]; updateStatusChip(); return;
893
+ }
894
+ const box = clipBoxFrom(clipBoxDraft.a, clipBoxDraft.b, zlo, clipBoxHeightAt(e.clientX, e.clientY)); // 3rd click → commit the height
895
+ clipBoxDraft = null; setClipPreview(null); setClipMode(null);
896
+ if (box.min.x < box.max.x && box.min.y < box.max.y && box.min.z < box.max.z) addClipBoxFromBox(box);
897
+ }
898
+ function clipBoxPreviewAt(cx, cy) { // live preview: a flat footprint rectangle while picking corners, then the pulled height
899
+ if (clipMode !== 'box' || !clipBoxDraft) return;
900
+ const zlo = clipBoxFloorZ();
901
+ if (!clipBoxDraft.b) {
902
+ const g = rayToPlane(cx, cy, zlo); if (!g) { setClipPreview(null); return; }
903
+ setClipPreview(clipBoxFrom(clipBoxDraft.a, [g[0], g[1]], zlo, zlo)); // flat rectangle on the floor
904
+ } else {
905
+ setClipPreview(clipBoxFrom(clipBoxDraft.a, clipBoxDraft.b, zlo, clipBoxHeightAt(cx, cy)));
906
+ }
907
+ }
908
+
909
+ // ---- working area (Tekla-style) ----
910
+ // One box that bounds the view: shown as a wireframe cuboid AND it sections the model (parts outside it hide),
911
+ // composing with any clip planes/boxes through applyClips (workArea.planes are added when enabled). "Set to all
912
+ // objects" = the model bounds; "define from selection" = a padded box around the selection.
913
+ let workAreaHelper = null; // THREE.Box3Helper wireframe for the work area (recreated on each box change)
914
+ function renderWorkArea() {
915
+ if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
916
+ if (!workArea || !workArea.enabled || workArea.box.isEmpty() || !overlayScene) return;
917
+ workAreaHelper = new THREE.Box3Helper(workArea.box, new THREE.Color(0x3b82f6));
918
+ workAreaHelper.material.depthTest = false; workAreaHelper.renderOrder = 995; // unclipped overlay pass keeps it visible through any clip
919
+ overlayScene.add(workAreaHelper);
920
+ }
921
+ function setWorkAreaBox(box) {
922
+ if (!box || box.isEmpty()) return false;
923
+ if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
924
+ workArea = { enabled: true, planes: boxToPlanes(box), box: box.clone() };
925
+ applyClips(); renderWorkArea();
926
+ if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
927
+ return true;
928
+ }
929
+ function workAreaSetAll() { return setWorkAreaBox(sceneBox.clone()); } // "set work area to all objects" = model bounds
930
+ function workAreaFromSelection(pad = 150) { // define a new work area around the selection
931
+ const box = new THREE.Box3(); for (const id of selIds) { const m = meshById.get(id); if (m && m.visible) box.expandByObject(m); }
932
+ if (box.isEmpty()) return false; box.expandByScalar(pad); return setWorkAreaBox(box);
933
+ }
934
+ function workAreaToggle(on) {
935
+ if (!workArea) return on === false ? false : workAreaSetAll(); // first toggle-on with no box → bound the whole model
936
+ if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
937
+ workArea.enabled = on === undefined ? !workArea.enabled : !!on;
938
+ applyClips(); renderWorkArea();
939
+ if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
940
+ return workArea.enabled;
941
+ }
942
+ function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
943
+ function workAreaState() { return workArea ? { on: workArea.enabled } : null; }
944
+
522
945
  function frameAll() { fitCamera(sceneBox); }
523
946
  const VIEWS = { top: [0, 0, 1], bottom: [0, 0, -1], front: [0, -1, 0], back: [0, 1, 0], right: [1, 0, 0], left: [-1, 0, 0], iso: [0.55, -0.8, 0.5] };
524
947
  function applyView(name) { const d = VIEWS[name]; if (d) fitCamera(sceneBox, new THREE.Vector3(d[0], d[1], d[2])); }
@@ -553,9 +976,13 @@ function panView(dx, dy) { // dx/dy in "steps" along the camera's screen right/u
553
976
  camera.position.add(move); controls.target.add(move); controls.update();
554
977
  }
555
978
  const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
979
+ const VIEW_KEYS = { t: 'top', f: 'front', r: 'right', b: 'back', l: 'left' }; // single-key ortho views, mirrors the ViewCube + AWARE viewer-3d
556
980
  function onKey(e) {
557
981
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
558
982
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
983
+ 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
984
+ if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
985
+ 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)
559
986
  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)
560
987
  const k = e.key.toLowerCase();
561
988
  // Don't touch the dim tool while a member gesture (drag / box-select) owns the shared marker/readout —
@@ -566,6 +993,8 @@ function onKey(e) {
566
993
  if (k === 'escape') { e.preventDefault(); if (dimDraft3d) { dimDraft3d = null; dimPreviewLine.visible = false; readout.style.display = 'none'; } else toggleDimTool(); return; } // Esc: cancel the in-progress dim, else exit the tool
567
994
  if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
568
995
  }
996
+ // Named-view shortcuts mirror the ViewCube faces (parity with AWARE viewer-3d): T/F/R/B/L snap to an ortho view.
997
+ if (VIEW_KEYS[k] && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); applyView(VIEW_KEYS[k]); return; }
569
998
  const a = ARROWS[e.key]; if (!a) return;
570
999
  const [hx, hy] = a;
571
1000
  if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
@@ -576,7 +1005,7 @@ function onKey(e) {
576
1005
  function toggleDimTool() {
577
1006
  dimMode3d = !dimMode3d;
578
1007
  if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
579
- else { controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // show the crosshair on arm, before the first mousemove
1008
+ else { if (clipMode) setClipMode(null); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick (they share the left-click + Esc); shows the crosshair before the first mousemove
580
1009
  reflectDimBar();
581
1010
  return dimMode3d;
582
1011
  }
@@ -607,7 +1036,7 @@ function onDblClick(e) {
607
1036
  if (mesh.geometry && !mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
608
1037
  const s = mesh.geometry && mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getSize(new THREE.Vector3()) : V(400, 400, 400);
609
1038
  const sect = Math.max(40, Math.min(s.x, s.y, s.z)); // the part's smallest extent ≈ a section / plate scale
610
- const half = Math.min(Math.max(sect * 3, 300), 2500); // a detail-scale zoom box (clamped sane)
1039
+ const half = Math.min(Math.max(sect * 3, 120), 2500); // a detail-scale zoom box floor lowered so dbl-clicking a connection zooms in tight
611
1040
  const viewDir = camera.position.clone().sub(controls.target).normalize(); // keep the current view orientation
612
1041
  fitCamera(new THREE.Box3().setFromCenterAndSize(p, new THREE.Vector3(half * 2, half * 2, half * 2)), viewDir.lengthSq() > 0.5 ? viewDir : undefined);
613
1042
  }
@@ -670,20 +1099,53 @@ function sizeEndpoints() {
670
1099
  }
671
1100
 
672
1101
  // ---- placed 3D dimensions ----
673
- // Rebuild the placed-dim lines from the editor's dims3d (the single source of truth). Lines render
674
- // depthTest:false so they're never buried; the selected dim draws amber. Labels are HTML overlays
675
- // (pool below), so only the line geometry is rebuilt here.
1102
+ // 45° CAD terminator ticks at each end of a placed dim line (p1,p2 world arrays). The tick lies in the plane
1103
+ // of the line and world-up perpendicular biased to vertical (or world-X when the line is itself vertical) —
1104
+ // so a user-drawn dim reads like the derived overlays' ticks. Returns the 2 tick segments' endpoints (4 pts).
1105
+ function dimTicks(p1, p2) {
1106
+ const a = new THREE.Vector3(...p1), b = new THREE.Vector3(...p2), dir = b.clone().sub(a);
1107
+ if (dir.lengthSq() < 1e-9) return [];
1108
+ dir.normalize();
1109
+ let perp = new THREE.Vector3(0, 0, 1).addScaledVector(dir, -dir.z); // world-up with its along-line component removed
1110
+ if (perp.lengthSq() < 1e-6) perp = new THREE.Vector3(1, 0, 0).addScaledVector(dir, -dir.x); // line is vertical → bias the tick to world-X
1111
+ const t = dir.clone().add(perp.normalize()).normalize().multiplyScalar(DIM_TICK / 2);
1112
+ return [a.clone().sub(t), a.clone().add(t), b.clone().sub(t), b.clone().add(t)];
1113
+ }
1114
+ // Rebuild the placed-dim lines from the editor's dims3d (the single source of truth). Each is the user-drawn
1115
+ // segment + 45° end ticks (CAD style, matching the derived overlays); depthTest:false so they're never
1116
+ // buried; the selected dim draws amber. Labels are HTML overlays (pool below), so only geometry is rebuilt.
1117
+ // With isolation / solo / legend-hide active, a placed dim hides when an endpoint has no VISIBLE mesh at it —
1118
+ // it would otherwise measure to isolated-away geometry (placed dims store only two world points, no member
1119
+ // ref, so visibility is inferred from what's rendered there). ponytail: O(dims · visibleMeshes) per refresh,
1120
+ // only while something is hidden — fine at model scale; cache mesh AABBs if it ever bites on huge models.
1121
+ const _epV = new THREE.Vector3(), _epBox = new THREE.Box3();
1122
+ function endpointUnseen(p) {
1123
+ if (!p) return false;
1124
+ _epV.set(p[0], p[1], p[2]);
1125
+ for (const m of meshById.values()) {
1126
+ if (m.visible === false) continue;
1127
+ _epBox.setFromObject(m);
1128
+ if (_epBox.distanceToPoint(_epV) <= 2) return false; // a visible mesh sits at this endpoint → seen
1129
+ }
1130
+ return true;
1131
+ }
1132
+ function dimHiddenByIsolation(d) {
1133
+ if (isolatedIds === null && soloGroups.size === 0 && groupHidden.size === 0) return false; // nothing hidden → every dim shows
1134
+ return endpointUnseen(d.a) || endpointUnseen(d.b);
1135
+ }
676
1136
  function refreshDims() {
677
1137
  if (!dims3dGroup || !api || !api.getDims3d) return;
678
1138
  dimCandidates3d = null; // a rebuild may follow a member edit → don't keep stale snap targets for the next click
679
1139
  for (const c of [...dims3dGroup.children]) { dims3dGroup.remove(c); c.geometry.dispose(); c.material.dispose(); }
680
1140
  const dims = api.getDims3d() || [];
681
- const sel = api.selDim3d ? api.selDim3d() : null;
1141
+ const sel = new Set(api.selDim3d ? api.selDim3d() : []); // selDim3d now returns the selected-id ARRAY (multi-select)
682
1142
  if (dims3dVisibleFlag) {
683
1143
  for (const d of dims) {
1144
+ if (dimHiddenByIsolation(d)) continue; // isolated away → no line (the label is hidden in syncDimLabels)
684
1145
  const g = dim3dGeom(d.a, d.b, d.axis);
685
- const col = d.id === sel ? 0xf59e0b : 0x67e8f9; // amber (#f59e0b) when selected, else muted cyan-300 — a quieter tint of the 0x22d3ee preview/2D-dim cyan (intentional, not a stray value)
686
- const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]), new THREE.LineBasicMaterial({ color: col }));
1146
+ const col = sel.has(d.id) ? 0xf59e0b : 0x67e8f9; // amber (#f59e0b) when selected, else muted cyan-300 — a quieter tint of the 0x22d3ee preview/2D-dim cyan (intentional, not a stray value)
1147
+ const pts = [new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2), ...dimTicks(g.p1, g.p2)]; // the dim line + a 45° tick at each end
1148
+ const line = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: col }));
687
1149
  line.material.depthTest = false; line.renderOrder = 996; line.userData.dimId = d.id;
688
1150
  dims3dGroup.add(line);
689
1151
  }
@@ -694,20 +1156,33 @@ function refreshDims() {
694
1156
  function syncDimLabels(dims) {
695
1157
  while (dimLabelPool.length < dims.length) {
696
1158
  const el = document.createElement('div');
697
- el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer;background:var(--panel,#0f172a);color:var(--text,#f8fafc);border:1px solid var(--line,#1e293b);border-radius:4px;padding:2px 6px;font:12px system-ui;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5)';
698
- el.addEventListener('pointerdown', (e) => { e.stopPropagation(); const id = el._dimId; if (api && api.onSelectDim3d) { api.onSelectDim3d(id); refreshDims(); } });
1159
+ el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer;background:var(--panel,#0f172a);color:var(--text,#f8fafc);border:1px solid var(--line,#1e293b);border-left:2px solid #67e8f9;border-radius:4px;padding:2px 6px;font:12px system-ui;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5)'; // cyan left accent (amber when selected) ties the chip to its dim line — matches the derived overlays
1160
+ el.addEventListener('pointerdown', (e) => { e.stopPropagation(); const id = el._dimId; if (api && api.onSelectDim3d) { api.onSelectDim3d(id, { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey }); refreshDims(); } }); // Ctrl/Shift = multi-select (same as parts)
699
1161
  dimLabelHost.appendChild(el); dimLabelPool.push(el);
700
1162
  }
1163
+ const selSet = new Set(api.selDim3d ? api.selDim3d() : []);
701
1164
  for (let i = 0; i < dimLabelPool.length; i++) {
702
1165
  const el = dimLabelPool[i], d = dims[i];
703
- if (!d || !dims3dVisibleFlag) { el.style.display = 'none'; el._dimId = null; el._mid = null; continue; }
1166
+ if (!d || !dims3dVisibleFlag || dimHiddenByIsolation(d)) { el.style.display = 'none'; el._dimId = null; el._mid = null; continue; } // isolated-away dims hide their label too
704
1167
  el._dimId = d.id;
705
1168
  const g = dim3dGeom(d.a, d.b, d.axis);
706
1169
  el.textContent = api.fmtLen ? api.fmtLen(g.valueMm) : (g.valueMm / 304.8).toFixed(2) + ' ft';
707
- el.style.borderColor = d.id === (api.selDim3d && api.selDim3d()) ? '#f59e0b' : 'var(--line,#1e293b)';
1170
+ const seld = selSet.has(d.id);
1171
+ el.style.borderColor = seld ? '#f59e0b' : 'var(--line,#1e293b)';
1172
+ el.style.borderLeftColor = seld ? '#f59e0b' : '#67e8f9'; // keep the cyan left accent unless selected (then the whole border goes amber)
708
1173
  el.style.display = 'block'; el._mid = g.mid;
709
1174
  }
710
1175
  }
1176
+ // A 3D point is "clipped" when it's on the cut-away side of ANY active clip plane/box (global clipping uses the
1177
+ // union rule, so a fragment outside any plane is removed). Used to hide a dim's HTML label when its anchor is cut.
1178
+ const _clipPt = new THREE.Vector3();
1179
+ function isPointClipped(p) {
1180
+ const planes = renderer && renderer.clippingPlanes;
1181
+ if (!planes || !planes.length || !p) return false;
1182
+ _clipPt.set(p[0], p[1], p[2]);
1183
+ for (let i = 0; i < planes.length; i++) if (planes[i].distanceToPoint(_clipPt) < 0) return true;
1184
+ return false;
1185
+ }
711
1186
  // project each visible label's 3D midpoint → screen px (called from the render loop). Hidden when the
712
1187
  // midpoint is behind the camera (project z>1).
713
1188
  function positionDimLabels() {
@@ -715,6 +1190,7 @@ function positionDimLabels() {
715
1190
  dimLabelHost.style.display = 'block';
716
1191
  for (const el of dimLabelPool) {
717
1192
  if (el.style.display === 'none' || !el._mid) continue;
1193
+ if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; } // a dim whose anchor is sectioned away by a clip is cut too
718
1194
  const [mx, my, mz] = el._mid;
719
1195
  const v = new THREE.Vector3(mx, my, mz).project(camera);
720
1196
  // hide when the midpoint is behind the camera (z>1) OR outside the canvas frustum (|x|,|y|>1):
@@ -728,6 +1204,179 @@ function positionDimLabels() {
728
1204
  }
729
1205
  }
730
1206
 
1207
+ // ---- derived dimension overlays (the legend's DIMENSIONS toggles) ----
1208
+ // Recompute the bolt-pitch / edge-clearance / cope-size dims from the retained scene parts as proper CAD
1209
+ // linear dimensions — witness/extension lines off the feature + an OFFSET dimension line + 45° end ticks + a
1210
+ // label chip sitting over the line's midpoint (the chip's panel reads as text-on-line). Emitted only when the
1211
+ // per-category toggle is on (api.getDimOverlays) AND the annotated part is visible. Pure geometry off the
1212
+ // resolved parts: plate.center/uDir/vDir + holes give the bolt grid & plate edges; a `cut` box gives the
1213
+ // cope size. Each category offsets to a DIFFERENT side so several read uncluttered, and the whole dim draws
1214
+ // depthTest:false at a high renderOrder so it sits on top of the steel.
1215
+
1216
+ // One CAD linear dimension for the span A→B (Vector3), pushed perpendicular by the world vector `off`.
1217
+ // Returns the witness/extension segments + the dimension-line+tick segments (each a flat list where every 2
1218
+ // points = one line segment) and the label anchor (the offset midpoint, ON the dimension line). Terminators
1219
+ // are 45° architectural ticks (steel-detail convention — crisper than filled arrows at this scale).
1220
+ function cadDim(A, B, off) {
1221
+ const span = B.clone().sub(A), sl = span.length(); if (sl < 1e-6) return null; // zero span → no dimension
1222
+ const md = span.multiplyScalar(1 / sl); // unit along the span (= the dim line, since the offset is forced perpendicular)
1223
+ const o = off.clone().addScaledVector(md, -off.dot(md)); // force the offset perpendicular to the span (a dim line runs parallel to what it measures) → the 45° tick stays a true bisector
1224
+ const olen = o.length(); if (olen < 1e-6) return null; // no usable perpendicular offset → skip
1225
+ const od = o.multiplyScalar(1 / olen);
1226
+ const GAP = 4, OVER = 6; // mm: witness gap off the feature, witness overshoot past the dim line
1227
+ const a2 = A.clone().addScaledVector(od, olen), b2 = B.clone().addScaledVector(od, olen); // the offset dimension-line endpoints
1228
+ const tick = md.clone().add(od).normalize().multiplyScalar(DIM_TICK / 2); // a 45° tick — od ⟂ md, so (md+od) bisects them; normalised to DIM_TICK
1229
+ const witness = [], dim = [], w = (p, q) => witness.push([p.x, p.y, p.z], [q.x, q.y, q.z]), d = (p, q) => dim.push([p.x, p.y, p.z], [q.x, q.y, q.z]);
1230
+ w(A.clone().addScaledVector(od, GAP), a2.clone().addScaledVector(od, OVER)); // witness line at A (small gap off the part → just past the dim line)
1231
+ w(B.clone().addScaledVector(od, GAP), b2.clone().addScaledVector(od, OVER)); // witness line at B
1232
+ d(a2, b2); // the dimension line
1233
+ d(a2.clone().sub(tick), a2.clone().add(tick)); d(b2.clone().sub(tick), b2.clone().add(tick)); // 45° ticks at each end
1234
+ return { witness, dim, labelPos: a2.clone().add(b2).multiplyScalar(0.5).toArray() };
1235
+ }
1236
+
1237
+ function computeOverlayDims() {
1238
+ const ov = (api && api.getDimOverlays) ? (api.getDimOverlays() || {}) : {};
1239
+ const on = (c) => ov[c] !== false; // a category is ON unless EXPLICITLY false — matches the legend row's .dimoff (===false) + the all-on default, so a missing/unknown key shows rather than silently suppressing
1240
+ if (!on('bolt_pitch') && !on('edge_clearance') && !on('cope_size') && !on('base_plate') && !on('anchor_depth')) return [];
1241
+ const fmt = (mm) => (api && api.fmtLen ? api.fmtLen(mm) : (mm / 304.8).toFixed(2) + ' ft');
1242
+ const vis = (id) => { const m = meshById.get(id); return !m || m.visible !== false; }; // a legend-hidden part suppresses its dims
1243
+ const fin3 = (a) => Array.isArray(a) && a.length === 3 && a.every(Number.isFinite); // a structurally-present-but-malformed center/dir is skipped like a no-dim part — never throws out of rebuild()
1244
+ const out = [];
1245
+ const dim = (A, B, off, label, cat) => { const g = cadDim(A, B, off); if (g) out.push({ witness: g.witness, dim: g.dim, labelPos: g.labelPos, label, cat }); }; // cadDim returns null for a degenerate span/offset
1246
+ // A chain of CAD dims along ordered local coords `cs` (→ world via `mk`), all pushed by `off`. ≥4 EQUAL
1247
+ // gaps collapse to one "N @ x" (the AISC array convention for long runs); otherwise each gap is its own
1248
+ // plain-length link — how detailers chain bolt pitch / anchor gauge (no "1 @ …" noise on a single space).
1249
+ const chain = (cs, mk, off, cat) => {
1250
+ if (!Array.isArray(cs) || cs.length < 2) return;
1251
+ const gaps = []; for (let i = 1; i < cs.length; i++) gaps.push(cs[i] - cs[i - 1]);
1252
+ const equal = gaps.every((g) => Math.abs(g - gaps[0]) < 0.5); // equal within 0.5 mm
1253
+ if (gaps.length >= 4 && equal) { dim(mk(cs[0]), mk(cs[cs.length - 1]), off, `${gaps.length} @ ${fmt(gaps[0])}`, cat); return; }
1254
+ for (let i = 1; i < cs.length; i++) dim(mk(cs[i - 1]), mk(cs[i]), off, fmt(cs[i] - cs[i - 1]), cat);
1255
+ };
1256
+ // Insert the member centerline (local 0) as a chain break so an anchor gauge reads bolt→CL→bolt, like a
1257
+ // real anchor-bolt detail (AB-series) — but ONLY for a SYMMETRIC grid: it must straddle 0 with no bolt on
1258
+ // it AND the outer lines must mirror about 0 (cs[0] ≈ -cs[last]). An off-centre pattern keeps a plain
1259
+ // bolt→bolt chain (a CL break there would dimension to a centerline the bolts aren't symmetric about).
1260
+ const splitAtCL = (cs) => (cs.length >= 2 && cs[0] < -0.5 && cs[cs.length - 1] > 0.5
1261
+ && Math.abs(cs[0] + cs[cs.length - 1]) < 0.5 && cs.every((c) => Math.abs(c) > 0.5)
1262
+ ? [...cs, 0].sort((a, b) => a - b) : cs);
1263
+ for (const el of dimParts) {
1264
+ if (!el || !fin3(el.center) || !fin3(el.uDir) || !fin3(el.vDir)) continue;
1265
+ const C = V(...el.center), u = V(...el.uDir).normalize(), vv = V(...el.vDir).normalize();
1266
+ const P = (uu, vd) => C.clone().addScaledVector(u, uu).addScaledVector(vv, vd); // plate-local (u,v) → world Vector3
1267
+ if (el.kind === 'plate' && el.group === 'shear-plate') { // fin plates only — base/washer plates also carry a holes array but their "bolts" are anchors, not a shear-bolt group
1268
+ if (!vis(el.id)) continue;
1269
+ const holes = (Array.isArray(el.holes) ? el.holes : []).filter((h) => h && isFinite(h.u) && isFinite(h.v));
1270
+ if (!holes.length) continue;
1271
+ const us = [...new Set(holes.map((h) => Math.round(h.u * 100) / 100))].sort((a, b) => a - b); // distinct bolt columns (u)
1272
+ const vs = [...new Set(holes.map((h) => Math.round(h.v * 100) / 100))].sort((a, b) => a - b); // distinct bolt rows (v)
1273
+ const uMid = (us[0] + us[us.length - 1]) / 2, vMid = (vs[0] + vs[vs.length - 1]) / 2;
1274
+ const vTop = vs[vs.length - 1], uFar = us[us.length - 1];
1275
+ // Each category's dim line is pushed ~30-40 mm clear of the bolt grid, to a DIFFERENT side so several
1276
+ // read uncluttered: pitch → +u (free/beam edge), gauge → +v (above), edge distances → -u/-v, cope → outer.
1277
+ if (on('bolt_pitch')) {
1278
+ // vertical pitch as a chain — to the free (beam-side) edge (+u); each space its own plain link
1279
+ chain(vs, (vd) => P(uMid, vd), u.clone().multiplyScalar(38), 'bolt_pitch');
1280
+ // horizontal gauge (multi-column only) ABOVE the group (+v), opposite the eh end-distance (-v) so they don't stack
1281
+ chain(us, (uu) => P(uu, vMid), vv.clone().multiplyScalar(38), 'bolt_pitch');
1282
+ }
1283
+ if (on('edge_clearance') && el.width > 0 && el.depth > 0) {
1284
+ const hw = el.width / 2, hd = el.depth / 2;
1285
+ // edge distances → the welded-edge side, opposite the pitch dim. The symmetric BOTTOM ev is omitted
1286
+ // (the engine centres the bolt column, so top == bottom) — show one ev + the end distance.
1287
+ dim(P(uMid, vTop), P(uMid, hd), u.clone().multiplyScalar(-30), `ev ${fmt(hd - vTop)}`, 'edge_clearance');
1288
+ dim(P(uFar, vMid), P(hw, vMid), vv.clone().multiplyScalar(-30), `eh ${fmt(hw - uFar)}`, 'edge_clearance'); // end distance to the free edge, dropped below the plate
1289
+ }
1290
+ } else if (el.kind === 'plate' && el.group === 'base-plate') { // base plate — anchor-rod grid (holes) on a horizontal plate; same derivation as the fin plate, relabelled for anchors
1291
+ if ((!on('base_plate') && !on('anchor_depth')) || !vis(el.id) || !(el.width > 0) || !(el.depth > 0)) continue;
1292
+ const holes = (Array.isArray(el.holes) ? el.holes : []).filter((h) => h && isFinite(h.u) && isFinite(h.v));
1293
+ if (!holes.length) continue;
1294
+ const us = [...new Set(holes.map((h) => Math.round(h.u * 100) / 100))].sort((a, b) => a - b);
1295
+ const vs = [...new Set(holes.map((h) => Math.round(h.v * 100) / 100))].sort((a, b) => a - b);
1296
+ const uMid = (us[0] + us[us.length - 1]) / 2, vMid = (vs[0] + vs[vs.length - 1]) / 2;
1297
+ const hw = el.width / 2, uHi = us[us.length - 1], OFF = 35; // OFF: mm the anchor dims clear the grid (moderate, like the fin-plate offsets)
1298
+ if (on('base_plate')) {
1299
+ // Anchor gauge as a centerline-broken chain on two perpendicular sides (AB-series convention): the
1300
+ // u-spacing below the grid (-v), the v-gauge to the side (+u) — plain bolt→CL links, no "N @". The
1301
+ // perpendicular placement + the values disambiguate the two axes without a non-standard text label.
1302
+ chain(splitAtCL(us), (uu) => P(uu, vMid), vv.clone().multiplyScalar(-OFF), 'base_plate');
1303
+ chain(splitAtCL(vs), (vd) => P(uMid, vd), u.clone().multiplyScalar(OFF), 'base_plate');
1304
+ dim(P(uHi, vMid), P(hw, vMid), vv.clone().multiplyScalar(OFF), `e ${fmt(hw - uHi)}`, 'base_plate'); // outer anchor → the +u plate edge (+v side)
1305
+ }
1306
+ // Anchor projection/grout/embedment — a VERTICAL chain on an outer rod (elevation-detail style): PROJ (rod
1307
+ // top → B.O. plate), GROUT (B.O. plate → T.O. concrete, only when a grout pad is scheduled), EMBED (T.O.
1308
+ // concrete → rod bottom). Only when a real embedment is scheduled (else the rod is a meaningless stub). The
1309
+ // depth geometry is resolved by the engine onto the plate's meta.anchor.
1310
+ const an = el.meta && el.meta.anchor;
1311
+ if (on('anchor_depth') && an && an.embedSet && fin3([an.rodTopZ, an.rodBotZ, an.plateBotZ]) && Number.isFinite(an.concreteTopZ)) {
1312
+ const Pc = P(us[0], vs[0]); // an outer (corner) anchor's world XY
1313
+ const Z = (z) => V(Pc.x, Pc.y, z);
1314
+ const off = u.clone().multiplyScalar(-60); // push the vertical chain clear of the column (⟂ to the Z span)
1315
+ dim(Z(an.rodTopZ), Z(an.plateBotZ), off, `PROJ ${fmt(an.projMm)}`, 'anchor_depth');
1316
+ if (an.groutMm > 0.5) dim(Z(an.plateBotZ), Z(an.concreteTopZ), off, `GROUT ${fmt(an.groutMm)}`, 'anchor_depth');
1317
+ dim(Z(an.concreteTopZ), Z(an.rodBotZ), off, `EMBED ${fmt(an.embedMm)}`, 'anchor_depth');
1318
+ }
1319
+ } else if (el.kind === 'cut') {
1320
+ if (!on('cope_size') || !vis(el.member)) continue;
1321
+ const hw = (el.width || 0) / 2, hd = (el.depth || 0) / 2;
1322
+ if (!(hw > 0 && hd > 0)) continue;
1323
+ const webV = (el.reentrantV || -1) >= 0 ? 1 : -1; // the web side; push the dim to the OUTER (flange/void) side, clear of the beam body
1324
+ dim(P(-hw, -webV * hd), P(hw, -webV * hd), vv.clone().multiplyScalar(-webV * 22), `${fmt(el.width)} × ${fmt(el.depth)}`, 'cope_size'); // cope size (length × depth in one chip), on the outer void side
1325
+ }
1326
+ }
1327
+ return out;
1328
+ }
1329
+ function refreshOverlayDims() {
1330
+ if (!overlayDimsGroup) return;
1331
+ for (const c of [...overlayDimsGroup.children]) { overlayDimsGroup.remove(c); c.geometry.dispose(); c.material.dispose(); }
1332
+ const specs = computeOverlayDims();
1333
+ const wpts = [], dpts = [];
1334
+ for (const s of specs) { for (const p of s.witness) wpts.push(new THREE.Vector3(p[0], p[1], p[2])); for (const p of s.dim) dpts.push(new THREE.Vector3(p[0], p[1], p[2])); }
1335
+ // Two batches, both depthTest:false so the whole dim sits on top of the steel (the user's ask): the
1336
+ // witness/extension lines recede at 0.55 opacity, the dimension line + ticks read full-strength above them.
1337
+ const addBatch = (pts, opacity, order) => {
1338
+ if (!pts.length) return;
1339
+ const mat = new THREE.LineBasicMaterial({ color: 0x67e8f9, transparent: opacity < 1, opacity });
1340
+ mat.depthTest = false;
1341
+ const ls = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts), mat);
1342
+ ls.renderOrder = order; overlayDimsGroup.add(ls);
1343
+ };
1344
+ addBatch(wpts, 0.55, 997);
1345
+ addBatch(dpts, 1, 998);
1346
+ syncOverlayLabels(specs);
1347
+ }
1348
+ // One themed chip per LABELLED overlay dim (witness-only lines carry no chip). Passive (pointer-events:none) —
1349
+ // overlay dims aren't selectable, unlike the placed dims — and share dimLabelHost (positioned each frame).
1350
+ function syncOverlayLabels(specs) {
1351
+ const labeled = specs.filter((s) => s.label);
1352
+ while (overlayLabelPool.length < labeled.length) {
1353
+ const el = document.createElement('div');
1354
+ el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:var(--panel,#0f172a);color:var(--text,#f8fafc);border:1px solid var(--line,#1e293b);border-left:2px solid #67e8f9;border-radius:4px;padding:2px 6px;font:12px system-ui;white-space:nowrap;box-shadow:0 0 0 1px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.5)'; // cyan left accent ties the chip to its dim line; 1px dark ring stops it blending into a shadowed plate face
1355
+ if (dimLabelHost) dimLabelHost.appendChild(el);
1356
+ overlayLabelPool.push(el);
1357
+ }
1358
+ for (let i = 0; i < overlayLabelPool.length; i++) {
1359
+ const el = overlayLabelPool[i], s = labeled[i];
1360
+ if (!s) { el.style.display = 'none'; el._mid = null; continue; }
1361
+ el.textContent = s.label;
1362
+ el._mid = s.labelPos;
1363
+ el.style.display = 'block';
1364
+ }
1365
+ }
1366
+ function positionOverlayLabels() {
1367
+ if (!dimLabelHost || !canvasEl || canvasEl.style.display === 'none') return;
1368
+ const rect = canvasEl.getBoundingClientRect();
1369
+ for (const el of overlayLabelPool) {
1370
+ if (el.style.display === 'none' || !el._mid) continue;
1371
+ if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; } // overlay dim cut away with its geometry
1372
+ const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
1373
+ if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
1374
+ el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
1375
+ el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
1376
+ el.style.visibility = 'visible';
1377
+ }
1378
+ }
1379
+
731
1380
  // ---- ViewCube (parity with AWARE viewer-3d) — a small labelled cube; a face click snaps the view ----
732
1381
  const CUBE_FACES = [
733
1382
  { label: 'RIGHT', view: 'right' }, { label: 'LEFT', view: 'left' }, // +X, -X
@@ -780,6 +1429,46 @@ function pickAt(cx, cy) {
780
1429
  lastPick = hits.length ? hits[0].object.userData.id : null;
781
1430
  return lastPick;
782
1431
  }
1432
+ // Raycast ALL visible meshes — members AND derived connection parts — at a client point → ids
1433
+ // front-to-back (nearest first), deduped. Backs click-to-cycle so a small plate/bolt/weld stacked on a
1434
+ // beam at a joint is reachable without a modifier (D). pickAt stays members-only — the down-gesture
1435
+ // (drag / box-select / geo-edit) and the dim-tool plane pick; hover uses pickAllAt so derived parts get the select cursor.
1436
+ function pickAllAt(cx, cy) {
1437
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
1438
+ const rect = canvasEl.getBoundingClientRect();
1439
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
1440
+ raycaster.setFromCamera(ndc, camera);
1441
+ const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
1442
+ const ids = [];
1443
+ for (const h of hits) { const id = h.object.userData.id; if (id && !ids.includes(id)) ids.push(id); }
1444
+ return ids;
1445
+ }
1446
+ // Click selection with cycle-through-overlaps: a plain click selects the nearest thing under the cursor
1447
+ // (incl. a derived part); clicking the SAME spot again steps to the next overlapping mesh (plate → beam …)
1448
+ // so a joint's stacked parts are all reachable. Ctrl+click toggles the nearest (no cycle). Drag (move/box/
1449
+ // endpoint) never routes here, so member drag/edit is unchanged.
1450
+ let cycleAnchor = null, cycleIds = [], cycleIdx = 0;
1451
+ const CYCLE_TOL_PX = 8;
1452
+ function resetCycle() { cycleAnchor = null; cycleIds = []; cycleIdx = 0; }
1453
+ function clickSelect(cx, cy, ctrl) {
1454
+ let hits = []; try { hits = pickAllAt(cx, cy); } catch { hits = []; }
1455
+ if (!hits.length) { resetCycle(); if (api && api.onSelect) api.onSelect(null, !!ctrl); return; }
1456
+ if (ctrl) { resetCycle(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest
1457
+ const same = cycleAnchor && Math.hypot(cx - cycleAnchor[0], cy - cycleAnchor[1]) <= CYCLE_TOL_PX
1458
+ && cycleIds.length === hits.length && cycleIds.every((v, i) => v === hits[i]);
1459
+ if (same) cycleIdx = (cycleIdx + 1) % hits.length; else { cycleIds = hits; cycleIdx = 0; cycleAnchor = [cx, cy]; }
1460
+ const pick = hits[cycleIdx], grp = boltGroupOf(pick); // a bolt → select the whole bolt ARRAY (a pattern, not one bolt)
1461
+ if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); }
1462
+ else if (api && api.onSelect) api.onSelect(pick, false);
1463
+ }
1464
+ // A bolt/head/nut id → all bolt-group part ids of the same joint (the connection's bolt array); else just [id].
1465
+ function boltGroupOf(id) {
1466
+ const ci = id.indexOf(':'); if (ci < 0) return [id];
1467
+ const jid = id.slice(0, ci);
1468
+ if (!/^(bolt|head|nut)/.test(id.slice(ci + 1))) return [id];
1469
+ const ids = [...meshById.keys()].filter((k) => { const c = k.indexOf(':'); return c >= 0 && k.slice(0, c) === jid && /^(bolt|head|nut)/.test(k.slice(c + 1)); });
1470
+ return ids.length ? ids : [id];
1471
+ }
783
1472
  // The (currently shown) end-node dot nearest the cursor within a screen tolerance → { id, end } or
784
1473
  // null. Screen-space (not a raycast) so the small dots are easy to grab at any zoom. Dots win over
785
1474
  // the member body, letting you grab one end to stretch it.
@@ -864,6 +1553,9 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
864
1553
 
865
1554
  function onDown(e) {
866
1555
  if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
1556
+ if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
1557
+ if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
1558
+ 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
867
1559
  if (dimMode3d) { // 2-click dimension placement (not a drag): pick A, then B → place
868
1560
  e.stopPropagation(); controls.enabled = true;
869
1561
  const r = dimPointAt(e, dimDraft3d && dimDraft3d.a); if (!r) return;
@@ -953,6 +1645,7 @@ function onMoveEndpoint(e) {
953
1645
  function onMove(e) {
954
1646
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // 3D inactive — ignore window pointer events
955
1647
  if (boxSel) { onBoxMove(e); return; }
1648
+ if (pending && pending.clipDrag) { onMoveClip(e); return; } // dragging a clip handle (plane offset / box face)
956
1649
  if (!pending || pending.geo) return;
957
1650
  if (pending.epDrag) { onMoveEndpoint(e); return; }
958
1651
  if (!pending.grab || !pending.origMm) return;
@@ -1008,14 +1701,15 @@ function onUp(e) {
1008
1701
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; boxSel = pending = dragging = null; if (controls) controls.enabled = true; return; } // 3D hidden mid-gesture → drop stale gesture state (no resume on re-show)
1009
1702
  const bs = boxSel; boxSel = null;
1010
1703
  if (bs) { // empty-space gesture: drag = box-select, click = clear selection
1011
- if (bs.moved) { const ids = membersInRect(bs.x, bs.y, e.clientX, e.clientY); if (api && api.onSelectMany) api.onSelectMany(ids); }
1012
- else if (api && api.onSelect) api.onSelect(null, false);
1704
+ if (bs.moved) { resetCycle(); const ids = membersInRect(bs.x, bs.y, e.clientX, e.clientY); if (api && api.onSelectMany) api.onSelectMany(ids); }
1705
+ else clickSelect(e.clientX, e.clientY, e.ctrlKey || e.metaKey); // click in empty space → cycle-pick (may land on a derived part) or clear
1013
1706
  downXY = null; return;
1014
1707
  }
1015
1708
  const p = pending, wasDragging = dragging; pending = dragging = null;
1016
1709
  controls.enabled = true;
1017
1710
  const moved = downXY ? Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) : Infinity; downXY = null;
1018
1711
  if (!p) return;
1712
+ if (p.clipDrag) return; // a clip handle drag is applied live during onMoveClip; nothing to commit on up (controls re-enabled above)
1019
1713
  if (p.epDrag) { // end-node drag → write just that endpoint's wp; a click (no drag) just selects the member
1020
1714
  if (wasDragging && p.newPt && api && api.onMoveEndpoint) { if (api.onSelect) api.onSelect(p.id, false); api.onMoveEndpoint(p.id, p.end, planPointToWp(p.newPt, p.ppf)); }
1021
1715
  else if (api && api.onSelect) api.onSelect(p.id, false);
@@ -1038,7 +1732,7 @@ function onUp(e) {
1038
1732
  }
1039
1733
  return;
1040
1734
  }
1041
- if (!wasDragging) { if (api && api.onSelect) api.onSelect(p.id, p.ctrl); return; } // member click → select
1735
+ if (!wasDragging) { clickSelect(e.clientX, e.clientY, p.ctrl); return; } // click → cycle-pick: member, or a derived part stacked on it at a joint
1042
1736
  if (p.mode === 'vertical') { // elevation drag → write T.O.S (inches)
1043
1737
  if (p.dz && Math.abs(p.dz) > 1e-6) {
1044
1738
  if (api && api.onSelect) api.onSelect(p.id, false);
@@ -1066,6 +1760,7 @@ function onHoverMove(e) {
1066
1760
  hoverRAF = requestAnimationFrame(() => {
1067
1761
  hoverRAF = 0;
1068
1762
  if (!lastHoverXY || pending || dragging || boxSel) return;
1763
+ if (clipMode) { canvasEl.style.cursor = 'crosshair'; if (clipMode === 'box') clipBoxPreviewAt(lastHoverXY[0], lastHoverXY[1]); return; } // armed clip pick: crosshair (+ live box preview while drawing)
1069
1764
  if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
1070
1765
  canvasEl.style.cursor = 'crosshair';
1071
1766
  const ev = { clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt };
@@ -1080,6 +1775,7 @@ function onHoverMove(e) {
1080
1775
  } else { dimPreviewLine.visible = false; if (!dimDraft3d) readout.style.display = 'none'; }
1081
1776
  return;
1082
1777
  }
1778
+ if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(lastHoverXY[0], lastHoverXY[1]); } catch { ch = null; } if (ch) { canvasEl.style.cursor = 'move'; return; } } // over a clip handle → move cursor
1083
1779
  let ep = null; try { ep = pickEndpoint(lastHoverXY[0], lastHoverXY[1]); } catch { ep = null; }
1084
1780
  if (ep) { // over an end node → ring + move cursor; keep its member's dots shown
1085
1781
  hoverEp = ep; canvasEl.style.cursor = 'move';
@@ -1087,9 +1783,10 @@ function onHoverMove(e) {
1087
1783
  updateStatusChip(); return;
1088
1784
  }
1089
1785
  hoverEp = null;
1090
- let id = null; try { id = pickAt(lastHoverXY[0], lastHoverXY[1]); } catch { id = null; }
1091
- canvasEl.style.cursor = geoMode() ? 'crosshair' : (id ? 'grab' : 'default');
1092
- if (id !== hoverId) { hoverId = id; rebuildEndpoints(); updateStatusChip(); }
1786
+ let top = null; try { top = pickAllAt(lastHoverXY[0], lastHoverXY[1])[0] || null; } catch { top = null; }
1787
+ const topMesh = top ? meshById.get(top) : null, isPart = !!(topMesh && topMesh.userData.derived);
1788
+ canvasEl.style.cursor = geoMode() ? 'crosshair' : (top ? (isPart ? 'pointer' : 'grab') : 'default'); // derived part → pointer (select-only); member → grab (draggable)
1789
+ if (top !== hoverId) { hoverId = top; rebuildEndpoints(); updateStatusChip(); }
1093
1790
  });
1094
1791
  }
1095
1792
  function updateStatusChip() {
@@ -1097,8 +1794,11 @@ function updateStatusChip() {
1097
1794
  const rect = canvasEl.getBoundingClientRect();
1098
1795
  let txt = '';
1099
1796
  const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
1100
- if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
1797
+ if (clipMode === 'plane') txt = 'Click a face to set a clip plane · Esc to cancel';
1798
+ else if (clipMode === 'box') txt = !clipBoxDraft ? 'Click the first floor corner of the clip box · Esc to cancel' : !clipBoxDraft.b ? 'Click the opposite floor corner · Esc to cancel' : 'Move up/down to set the height, then click · Esc to step back';
1799
+ else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
1101
1800
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
1801
+ else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
1102
1802
  else txt = 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
1103
1803
  hoverChip.textContent = txt;
1104
1804
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
@@ -1106,9 +1806,10 @@ function updateStatusChip() {
1106
1806
  hoverChip.style.display = canvasEl.style.display === 'none' ? 'none' : 'block';
1107
1807
  }
1108
1808
 
1109
- function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); refreshDims(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
1809
+ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); refreshDims(); refreshOverlayDims(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
1110
1810
  function hide() {
1111
1811
  if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
1812
+ if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
1112
1813
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
1113
1814
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
1114
1815
  }
@@ -1118,7 +1819,8 @@ function dispose() {
1118
1819
  if (rafId) cancelAnimationFrame(rafId);
1119
1820
  if (hoverRAF) cancelAnimationFrame(hoverRAF);
1120
1821
  if (ro) ro.disconnect();
1121
- if (canvasEl) { canvasEl.removeEventListener('pointerdown', onDown, true); canvasEl.removeEventListener('pointermove', onHoverMove); canvasEl.removeEventListener('dblclick', onDblClick); }
1822
+ if (canvasEl) { canvasEl.removeEventListener('pointerdown', onDown, true); canvasEl.removeEventListener('pointermove', onHoverMove); canvasEl.removeEventListener('dblclick', onDblClick); canvasEl.removeEventListener('wheel', onWheelHover, true); }
1823
+ if (controls) controls.removeEventListener('start', repivotToCursor);
1122
1824
  window.removeEventListener('pointermove', onMove);
1123
1825
  window.removeEventListener('pointerup', onUp);
1124
1826
  window.removeEventListener('keydown', onKey);
@@ -1130,11 +1832,16 @@ function dispose() {
1130
1832
  }
1131
1833
  if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
1132
1834
  if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
1835
+ if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines
1836
+ overlayLabelPool.length = 0; dimParts = [];
1133
1837
  if (dimPreviewLine && scene) scene.remove(dimPreviewLine);
1134
1838
  for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
1135
1839
  epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
1136
- dims3dGroup = dimPreviewLine = null;
1840
+ dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
1137
1841
  clearRoot();
1842
+ if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
1843
+ clearClipGizmo(); setClipPreview(null); overlayScene = null;
1844
+ clips = []; workArea = null; clipMode = null; selectedClipIds.clear(); clipBoxDraft = null; // clips live on the renderer; drop them with the renderer
1138
1845
  if (renderer) renderer.dispose();
1139
1846
  renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
1140
1847
  }
@@ -1150,7 +1857,7 @@ function debug() {
1150
1857
  ids: [...meshById.keys()].slice(0, 12),
1151
1858
  geomTypes: [...meshById.values()].reduce((h, m) => { const t = m.geometry.type; h[t] = (h[t] || 0) + 1; return h; }, {}),
1152
1859
  lastPick, projection: projection(), mode: displayMode,
1153
- hidden: [...groupHidden], solo: soloGroup, visible: [...meshById.values()].filter((m) => m.visible).length,
1860
+ hidden: [...groupHidden], solo: [...soloGroups], isolated: isolatedIds ? [...isolatedIds] : null, visible: [...meshById.values()].filter((m) => m.visible).length,
1154
1861
  endpoints: epGroup ? epGroup.children.length : 0,
1155
1862
  endpointColors: epGroup ? epGroup.children.map((c) => '#' + c.material.color.getHexString()) : [],
1156
1863
  hoverId, hoverEp, dragEp, refLine: refLineOn,
@@ -1158,6 +1865,7 @@ function debug() {
1158
1865
  camPos: camera ? [camera.position.x, camera.position.y, camera.position.z] : null,
1159
1866
  target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
1160
1867
  near: camera?.near, far: camera?.far,
1868
+ clips: getClips(), clipPlaneCount: renderer && renderer.clippingPlanes ? renderer.clippingPlanes.length : 0, clipMode, workArea: workAreaState(), selectedClips: [...selectedClipIds],
1161
1869
  };
1162
1870
  }
1163
1871
  // test helper: the current material colour + emissive of a member (verifies deselect restores colour)
@@ -1178,13 +1886,22 @@ function centerOf(id) {
1178
1886
  const w = m.getWorldPosition(new THREE.Vector3()).project(camera); const rect = canvasEl.getBoundingClientRect();
1179
1887
  return { x: rect.left + (w.x * 0.5 + 0.5) * rect.width, y: rect.top + (-w.y * 0.5 + 0.5) * rect.height };
1180
1888
  }
1889
+ function worldOf(id) { const m = meshById.get(id); if (!m) return null; const w = m.getWorldPosition(new THREE.Vector3()); return [w.x, w.y, w.z]; } // test helper: a rendered part's world position
1181
1890
  window.Steel3DView = {
1182
1891
  init, show, hide, rebuild, setSelection, isReady, dispose,
1183
1892
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
1184
1893
  setRefLine, refLine: () => refLineOn,
1185
- toggleGroup, soloToggle, showAllGroups, groupState, getGroups,
1894
+ toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
1895
+ setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
1896
+ isolateSelected, clearIsolation, isIsolated,
1897
+ workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
1186
1898
  toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
1899
+ refreshOverlayDims,
1900
+ overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests
1901
+ overlayLabelTexts: () => overlayLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent),
1187
1902
  dims3dCount: () => (api && api.getDims3d ? (api.getDims3d() || []).length : 0),
1903
+ selDims3dCount: () => (api && api.selDim3d ? (api.selDim3d() || []).length : 0), // test helper: how many 3D dims are selected
1188
1904
  dim3dLabelOf: (i) => { const el = dimLabelPool[i]; if (!el || el.style.display === 'none') return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, text: el.textContent }; },
1189
- debug, probe, screenOf, centerOf,
1905
+ debug, probe, screenOf, centerOf, worldOf, clipHandlesScreen,
1906
+ pointClipped: (p) => isPointClipped(p), // test helper: is a world point on the cut-away side of any active clip?
1190
1907
  };