@floless/app 0.78.0 → 0.80.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.
@@ -53093,7 +53093,7 @@ function appVersion() {
53093
53093
  return resolveVersion({
53094
53094
  isSea: isSea2(),
53095
53095
  sqVersionXml: readSqVersionXml(),
53096
- define: true ? "0.78.0" : void 0,
53096
+ define: true ? "0.80.0" : void 0,
53097
53097
  pkgVersion: readPkgVersion()
53098
53098
  });
53099
53099
  }
@@ -53103,7 +53103,7 @@ function resolveChannel(s) {
53103
53103
  return "dev";
53104
53104
  }
53105
53105
  function appChannel() {
53106
- return resolveChannel({ isSea: isSea2(), define: true ? "0.78.0" : void 0 });
53106
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.80.0" : void 0 });
53107
53107
  }
53108
53108
 
53109
53109
  // workflow-update.ts
@@ -60191,23 +60191,38 @@ function weightOf(contract, profile) {
60191
60191
  var isMfMark = (p) => !!p && /(^|[^A-Z])MF($|[^A-Z])/i.test(p);
60192
60192
  var len = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
60193
60193
  function redundantDupIds(members) {
60194
- const key = (m) => {
60194
+ const foot = (m) => {
60195
60195
  if (!m.wp || m.wp.length < 2) return null;
60196
60196
  const r = (p) => `${Math.round(p[0] / 3)},${Math.round(p[1] / 3)}`;
60197
60197
  const a = r(m.wp[0]), b = r(m.wp[1]);
60198
60198
  return a < b ? `${a}|${b}` : `${b}|${a}`;
60199
60199
  };
60200
- const groups = /* @__PURE__ */ new Map();
60200
+ const byFoot = /* @__PURE__ */ new Map();
60201
60201
  for (const m of members) {
60202
- const k = key(m);
60202
+ const k = foot(m);
60203
60203
  if (!k) continue;
60204
- (groups.get(k) ?? groups.set(k, []).get(k)).push(m);
60204
+ (byFoot.get(k) ?? byFoot.set(k, []).get(k)).push(m);
60205
60205
  }
60206
60206
  const out = /* @__PURE__ */ new Set();
60207
- for (const grp of groups.values()) {
60208
- if (grp.length < 2) continue;
60209
- grp.sort((a, b) => dupRank(b) - dupRank(a));
60207
+ const keepBest = (grp) => {
60208
+ if (grp.length < 2) return;
60209
+ grp.sort((a, b) => dupRank(b) - dupRank(a) || (dupElev(b) !== "na" ? 1 : 0) - (dupElev(a) !== "na" ? 1 : 0));
60210
60210
  for (let i = 1; i < grp.length; i++) out.add(grp[i].id);
60211
+ };
60212
+ for (const grp of byFoot.values()) {
60213
+ if (grp.length < 2) continue;
60214
+ const explicit = new Set(grp.map(dupElev).filter((s) => s !== "na"));
60215
+ if (explicit.size <= 1) {
60216
+ keepBest(grp);
60217
+ continue;
60218
+ }
60219
+ const bySig = /* @__PURE__ */ new Map();
60220
+ for (const m of grp) {
60221
+ const s = dupElev(m);
60222
+ if (s === "na") continue;
60223
+ (bySig.get(s) ?? bySig.set(s, []).get(s)).push(m);
60224
+ }
60225
+ for (const sub of bySig.values()) keepBest(sub);
60211
60226
  }
60212
60227
  return out;
60213
60228
  }
@@ -60217,6 +60232,11 @@ function dupRank(m) {
60217
60232
  if (m.profile && m.profile.trim() !== "") s += 1;
60218
60233
  return s;
60219
60234
  }
60235
+ function dupElev(m) {
60236
+ const q = (v) => typeof v === "number" && isFinite(v) ? Math.round(v) : null;
60237
+ const sig = m.role === "column" ? [q(m.col?.tos), q(m.col?.bos)] : [q(m.ends?.[0]?.tos), q(m.ends?.[1]?.tos)];
60238
+ return sig.every((v) => v == null) ? "na" : sig.map((v) => v == null ? "" : v).join(":");
60239
+ }
60220
60240
  function elevationAssumed(m) {
60221
60241
  if (m.role === "column") return m.col?.tosDef !== false;
60222
60242
  const ends = m.ends ?? [];
@@ -98,6 +98,19 @@ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
98
98
  return [...set].sort((a, b) => a - b);
99
99
  }
100
100
 
101
+ // Snap a raw elevation (mm) to the nearest level datum { z, label } within `tol`, returning the snapped
102
+ // value + its source, or the raw value when nothing is in range. Nearest wins. Pure — backs the Mode B
103
+ // column-base pick (the levels come from elevationLevels()). Malformed datums are skipped.
104
+ export function snapElevation(z, levels, tol) {
105
+ let best = null, bestD = tol;
106
+ for (const lv of levels || []) {
107
+ if (!lv || typeof lv.z !== 'number' || !isFinite(lv.z)) continue;
108
+ const d = Math.abs(z - lv.z);
109
+ if (d <= bestD) { bestD = d; best = lv; }
110
+ }
111
+ return best ? { z: best.z, snapped: true, label: best.label ?? null } : { z, snapped: false, label: null };
112
+ }
113
+
101
114
  // Lower wins when two candidates are both within tolerance. Grid intersections rank with member
102
115
  // intersections (columns land on them); grid lines below member centerlines.
103
116
  const PRECEDENCE = { vertex: 0, intersection: 1, 'grid-int': 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
@@ -17,7 +17,7 @@
17
17
  import * as THREE from 'three';
18
18
  import { OrbitControls } from 'three/addons/OrbitControls.js';
19
19
  import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
20
- import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom, planeBasis, projectToPlane, planeFrom3Points, vecToPlane } from './steel-3d-core.js';
20
+ import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, snapElevation, dim3dGeom, planeBasis, projectToPlane, planeFrom3Points, vecToPlane } from './steel-3d-core.js';
21
21
  import { gridGeometry, gridCandidates3d, mmPerPx } from './grid-core.js';
22
22
 
23
23
  let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
@@ -31,6 +31,10 @@ let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged
31
31
  let refGroup = null; let refLineOn = false; // optional reference (work) lines between each member's end points
32
32
  let insertMode = false; // armed "place a vectored detail" pick (mirrors clipMode)
33
33
  let insertPending = null; // {name,url} — the detail the editor queued for the next placement click
34
+ let basePickCol = null; // Mode B: armed "trim column to base" pick — the target column id, or null
35
+ let basePickLevels = null; // [{ z, label }] level datums the base pick snaps its elevation to
36
+ const ELEV_SNAP_PX = 10; // screen-pixel snap radius for the base-pick elevation (world tol via pxToWorldAt, so it's consistent at any zoom)
37
+ const BASE_PICK_HIT_PX = 40; // the base pick only commits when the click is within this many px of the column axis (rejects an accidental empty-canvas click)
34
38
  let labelsOnFlag = false; // member mark/id overlay labels toggle (a readability aid)
35
39
  let memberLabelHost = null; // fixed-position container for the member labels (positioned each frame)
36
40
  const memberLabelPool = []; // reused <div> labels, one per member, positioned in the loop
@@ -631,6 +635,57 @@ function setInsertMode(on, pending) {
631
635
  if (api && api.onInsertModeChange) api.onInsertModeChange(insertMode);
632
636
  }
633
637
  function insertModeOn() { return insertMode; }
638
+
639
+ // Mode B: arm a "trim column to base" pick on `colId`. The level datums are derived from the OTHER members'
640
+ // rendered top/bottom (world mm, so no display→mm conversion), each labelled by its member mark, so the pick
641
+ // snaps the base to a real framing level. A one-shot left-click hands the picked base elevation to the editor
642
+ // via api.onBasePick. Mirrors setInsertMode.
643
+ function setBasePickMode(colId) {
644
+ basePickCol = colId || null;
645
+ basePickLevels = null;
646
+ if (basePickCol) {
647
+ setInsertMode(false); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); setClipMode(null);
648
+ root.updateMatrixWorld(true);
649
+ basePickLevels = [];
650
+ const _b = new THREE.Box3();
651
+ for (const [id, m] of meshById) {
652
+ // MEMBER meshes only (their id is a plain mark): a ':' marks a connection part (`joint:part`) or a
653
+ // detail (`det:…`), so those are excluded — the base must snap to framing levels, not the moving
654
+ // base-plate/anchor/weld hardware of the very connection being re-seated.
655
+ if (id === basePickCol || !m.visible || String(id).includes(':')) continue;
656
+ _b.setFromObject(m); if (_b.isEmpty()) continue;
657
+ basePickLevels.push({ z: Math.round(_b.max.z), label: id + ' top' }, { z: Math.round(_b.min.z), label: id + ' base' });
658
+ }
659
+ }
660
+ if (canvasEl) canvasEl.style.cursor = basePickCol ? 'crosshair' : 'default';
661
+ if (!basePickCol) { if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
662
+ if (api && api.onBasePickModeChange) api.onBasePickModeChange(basePickModeOn());
663
+ }
664
+ function basePickModeOn() { return !!basePickCol; }
665
+ // A point along the target column's VERTICAL axis at the cursor, elevation snapped to the level datums.
666
+ // Intersect the pick ray with a vertical plane through the column axis facing the camera — so a click
667
+ // anywhere near the (thin) column reads as an elevation on its reference line. Returns { z, snapped, label, x, y }.
668
+ function basePickPoint(cx, cy) {
669
+ const col = meshById.get(basePickCol); if (!col) return null;
670
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
671
+ const box = new THREE.Box3().setFromObject(col);
672
+ const ax = (box.min.x + box.max.x) / 2, ay = (box.min.y + box.max.y) / 2;
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 toCam = new THREE.Vector3(camera.position.x - ax, camera.position.y - ay, 0);
677
+ if (toCam.lengthSq() < 1e-6) toCam.set(1, 0, 0);
678
+ toCam.normalize();
679
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(toCam, new THREE.Vector3(ax, ay, 0));
680
+ const g = new THREE.Vector3();
681
+ if (!raycaster.ray.intersectPlane(plane, g)) return null;
682
+ // The plane is infinite, so gate the pick to clicks NEAR the column axis (else an accidental empty-canvas
683
+ // click would commit a trim). g lies on the plane through the axis; its horizontal offset from the axis is
684
+ // how far to the side the cursor is.
685
+ if (Math.hypot(g.x - ax, g.y - ay) > pxToWorldAt(BASE_PICK_HIT_PX, g)) return null;
686
+ const s = snapElevation(g.z, basePickLevels, pxToWorldAt(ELEV_SNAP_PX, g)); // screen-scaled tol so the snap window is ~constant on-screen
687
+ return { z: s.z, snapped: s.snapped, label: s.label, x: ax, y: ay };
688
+ }
634
689
  // The placement raycast: a member face under the cursor gives the point + its basis + the member id to
635
690
  // anchor to; empty space drops the detail on the ground plane (z=0) with a default vertical orientation.
636
691
  function insertPick(cx, cy) {
@@ -1348,6 +1403,7 @@ function onKey(e) {
1348
1403
  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
1349
1404
  if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
1350
1405
  if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
1406
+ if (basePickCol && e.key === 'Escape') { e.preventDefault(); setBasePickMode(null); if (api && api.toast) api.toast('Trim cancelled'); return; } // Esc disarms the Mode B column-base pick
1351
1407
  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
1352
1408
  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)
1353
1409
  if (e.key === 'Escape' && !dimMode3d && !cmActive() && ascendConn()) { e.preventDefault(); return; } // Esc ascends the connection drill: part → whole → nothing
@@ -2341,6 +2397,7 @@ function onDown(e) {
2341
2397
  return; }
2342
2398
  if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
2343
2399
  if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
2400
+ if (basePickCol) { e.stopPropagation(); const r = basePickPoint(e.clientX, e.clientY); if (r) { if (api && api.onBasePick) api.onBasePick(r); setBasePickMode(null); } return; } // Mode B: commit + disarm only on a valid on-column pick; an off-column click is ignored (stays armed) — Esc cancels
2344
2401
  if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); setInsertMode(false); return; } // armed: place the queued detail at the pick, then disarm (one shot)
2345
2402
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
2346
2403
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
@@ -2620,6 +2677,17 @@ function onHoverMove(e) {
2620
2677
  hoverRAF = requestAnimationFrame(() => {
2621
2678
  hoverRAF = 0;
2622
2679
  if (!lastHoverXY || pending || dragging || boxSel) return;
2680
+ if (basePickCol) { // Mode B armed: reticle on the column axis + a live elevation readout
2681
+ const p = basePickPoint(lastHoverXY[0], lastHoverXY[1]);
2682
+ if (p) {
2683
+ showMarker([p.x, p.y, p.z], p.snapped ? 'end' : 'dot');
2684
+ canvasEl.style.cursor = p.snapped ? 'none' : 'crosshair';
2685
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(p.z) : (p.z / 304.8).toFixed(2) + ' ft';
2686
+ readout._type.textContent = p.snapped ? ' · ' + (p.label || 'level') : ' ↕';
2687
+ readout.style.left = (lastHoverXY[0] + 14) + 'px'; readout.style.top = (lastHoverXY[1] + 14) + 'px'; readout.style.display = 'block';
2688
+ } else { marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'crosshair'; }
2689
+ return;
2690
+ }
2623
2691
  if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
2624
2692
  canvasEl.style.cursor = 'crosshair';
2625
2693
  if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
@@ -2805,6 +2873,7 @@ window.Steel3DView = {
2805
2873
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
2806
2874
  setRefLine, refLine: () => refLineOn,
2807
2875
  setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
2876
+ setBasePickMode, basePickMode: basePickModeOn, // arm/query the Mode B column-base pick (level-snapped elevation)
2808
2877
  selectWholeConn, clearConnSel, ascendConn, connContext, connEnvelopeOn: () => !!connEnvelope, // Connection Components (Slice A): whole-select / drill / ascend + test probes
2809
2878
  setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2810
2879
  syncMemberLabels, // editor calls after a mark/id edit to refresh labels
@@ -398,20 +398,21 @@
398
398
  #m3dLegend .lrow.clip .clab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
399
399
  #m3dLegend .lrow.clip .lx{margin-left:0} /* the label's flex:1 already pushes On/Off + × to the right */
400
400
  #m3dLegend .lrow.clip.sel{border-left:2px solid var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
401
- #m3dLegend .lrow.clip.sel .clab{color:var(--text)}
402
- #m3dLegend .lrow.clip.sel .lsw{box-shadow:0 0 0 1.5px #f8fafc} /* white ring on the selected clip's swatch — mirrors the 3D endpoint ring */
403
- #m3dLegend .cpill{font-size:9px;line-height:1;padding:2px 6px;border-radius:9px;border:1px solid #475569;background:#334155;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;flex:none;box-shadow:none;cursor:pointer}
404
- #m3dLegend .cpill.on{background:var(--brand);border-color:var(--brand);color:#fff}
405
- #m3dLegend .cpill:hover{border-color:#64748b}
401
+ #m3dLegend .lrow.clip.sel .clab{color:var(--text)} /* selection is shown by the row-level .sel styling above (brand left-border + tint); the box is the enable toggle now */
406
402
  #m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;gap:1px;max-height:40%;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
407
403
  #m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px;max-width:230px;white-space:normal} /* wrap-guard: the hint never drives the panel wider than the rows, so it can't clip on any font */
408
- #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap}
404
+ #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:default;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap} /* only the left-hand box toggles now (it sets cursor:pointer); the row still isolates on dbl-click + right-clicks for the menu */
409
405
  #m3dLegend .lrow:hover{background:#33415580}
410
- #m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
406
+ #m3dLegend .lrow.off{opacity:.4}
407
+ /* hidden/off (or a disabled clip) → the box goes HOLLOW (outline of its own colour); the row also dims. No grayscale — the empty box already signals "hidden". */
408
+ #m3dLegend .lrow.off .lsw,#m3dLegend .lrow.dim.dimoff .lsw{background:transparent;box-shadow:inset 0 0 0 1.6px var(--sw)}
411
409
  #m3dLegend .lrow.solo{background:rgba(59,130,246,.12)} /* in the isolated set (Explorer-style multi-select highlight) */
412
- #m3dLegend .lrow.dim .lsw{background:transparent;border:1.5px solid #67e8f9} /* dim-overlay rows: outline swatch (an annotation layer, not a part) */
413
- #m3dLegend .lrow.dim.dimoff{opacity:.55} #m3dLegend .lrow.dim.dimoff .lsw{opacity:.35} /* off = a normal resting choice, not a hidden-part warning gentler than .off, swatch stays cyan (no grayscale) */
414
- #m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
410
+ #m3dLegend .lrow.lsel{box-shadow:inset 2px 0 0 var(--brand)} /* selected in 3D brand left-bar (distinct from .solo's isolate tint; the two can coexist) */
411
+ #m3dLegend .lrow.dim .lsw{--sw:#67e8f9} /* dim overlays are cyansame filled(on)/hollow(off) box as every other row */
412
+ #m3dLegend .lrow.dim.dimoff{opacity:.55} /* off = a normal resting choice, gentler than .off; the box hollows via the shared rule above */
413
+ #m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none;background:var(--sw,#94a3b8);cursor:pointer} /* the visibility box: filled(--sw)=shown; the .off/.dimoff rule above hollows it when hidden */
414
+ #m3dLegend .lsw:hover{box-shadow:0 0 0 2px rgba(255,255,255,.25)}
415
+ #m3dLegend .lrow.off .lsw:hover,#m3dLegend .lrow.dim.dimoff .lsw:hover{box-shadow:inset 0 0 0 1.6px var(--sw),0 0 0 2px rgba(255,255,255,.25)}
415
416
  #m3dLegend .lsec{color:#475569;font-size:10px;letter-spacing:.06em;text-transform:uppercase;margin:6px 0 2px;padding:0 4px}
416
417
  /* member grouping: By profile / By type toggle + collapsible type categories (Phase 1) */
417
418
  #m3dLegend .lmode{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;height:24px;margin-bottom:6px;flex:none}
@@ -434,6 +435,24 @@
434
435
  #m3dLegend .lrow.flash{background:rgba(59,130,246,.12)}
435
436
  .leg-drag-ghost{position:fixed;pointer-events:none;z-index:70;background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:3px 8px;display:flex;align-items:center;gap:7px;font:12px system-ui;color:var(--text);width:200px;opacity:.88;box-shadow:0 4px 16px rgba(0,0,0,.6)}
436
437
  #m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
438
+ /* Objects-list SEARCH — narrows the member/connection rows as you type; never the 3D scene, never Dims/Grid/Clip.
439
+ Built from the same tokens as #propPop .ppsearch (no new vocabulary); sits between the mode toggle and the hint. */
440
+ #m3dLegend .lsearch{display:flex;align-items:center;gap:6px;height:26px;margin-bottom:6px;padding:0 8px;background:var(--bg);border:1px solid var(--line);border-radius:6px;flex:none}
441
+ #m3dLegend .lsearch:focus-within{border-color:var(--brand)}
442
+ #m3dLegend .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
443
+ #m3dLegend .lsico svg{display:block}
444
+ #m3dLegend .lsearch input{flex:1;min-width:0;width:auto;height:auto;background:transparent;border:0;outline:none;color:var(--text);font:12px system-ui;padding:0}
445
+ #m3dLegend .lsearch input::placeholder{color:var(--mut)}
446
+ #m3dLegend .lsearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden} /* clear — reuses the .lrow .lx delete-glyph recipe */
447
+ #m3dLegend .lsearch.has .lsx{visibility:visible}
448
+ #m3dLegend .lsearch .lsx:hover{color:#fecaca;background:#7f1d1d}
449
+ #m3dLegend .lrow.qhide,#m3dLegend .cat-hdr.qhide{display:none} /* filtered OUT by search → gone (distinct from .off = hidden-in-3D, which only dims the swatch) */
450
+ #m3dLegend .lsempty{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
451
+ /* Show-all reset bar — panel-local entry to showAllGroups(); shown only when something is hidden/isolated. Clones the
452
+ .lsearch full-width box recipe; brand border on hover only (a one-shot action, not a mode → no solid fill). */
453
+ #m3dLegend .lreset{display:none;align-items:center;justify-content:center;gap:6px;height:26px;margin-bottom:6px;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--text);font:12px system-ui;cursor:pointer;flex:none;user-select:none}
454
+ #m3dLegend .lreset.show{display:flex}
455
+ #m3dLegend .lreset:hover{border-color:var(--brand);background:#1a2740}
437
456
  #m3dCube{position:absolute;right:12px;top:56px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))} /* top-right (Revit-style), below the toolbar row */
438
457
  /* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
439
458
  (pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
@@ -922,6 +941,16 @@ function retargetTos(m,srcDef,dstDef){const d=dstDef-srcDef;ensureMeta(m);
922
941
  // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
923
942
  // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
924
943
  let defaultTOS=198, addProfile='';
944
+ let basePickColId=null; // Mode B: the column whose base an armed 3D level-pick retargets
945
+ const MIN_COL_STUB_IN=12; // never trim a column below a 1' stub (keeps bos < tos)
946
+ // Mode B: arm the 3D column-base pick on `colId` (a level-snapped elevation → col.bos). 3D-view only.
947
+ function armBaseTrim(colId){
948
+ if(!view3d){toast('Open the 3D view to pick the column base point');return;}
949
+ if(!(window.Steel3DView&&window.Steel3DView.setBasePickMode))return;
950
+ basePickColId=colId;
951
+ window.Steel3DView.setBasePickMode(colId);
952
+ toast('Click a point along '+colId+' — the base snaps to framing levels · Esc cancels');
953
+ }
925
954
  function syncDefaults(){for(const m of P.members){ensureMeta(m);
926
955
  if(m.role==='column'){if(m.col.tosDef!==false&&defaultTOS!=null)m.col.tos=defaultTOS;if(m.col.bos==null)m.col.bos=0;}
927
956
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
@@ -1331,12 +1360,29 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
1331
1360
  if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
1332
1361
  c.rfi=(_wt(c.profile)==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
1333
1362
  // --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
1334
- function dupKey(m){const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
1363
+ function dupFoot(m){if(!m||!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
1364
+ // Elevation signature. Two members at the SAME footprint but a DIFFERENT explicit top-of-steel are different
1365
+ // objects (a beam stacked over a beam); an UNSET elevation is a wildcard — it dedupes against whatever's at that
1366
+ // footprint (a genuine double-read that lost its callout on one copy). tos is rounded to the inch: real callout
1367
+ // elevations are whole values so sub-inch rounding boundaries don't arise; levels differ by feet, so a coarser
1368
+ // tolerance isn't needed. MIRROR of server/steel-confidence.ts dupElev — keep the two in sync.
1369
+ function dupElev(m){const q=v=>(typeof v==='number'&&isFinite(v))?Math.round(v):null;
1370
+ const sig=(m&&m.role==='column')?[q(m.col&&m.col.tos),q(m.col&&m.col.bos)]:[q(m&&m.ends&&m.ends[0]&&m.ends[0].tos),q(m&&m.ends&&m.ends[1]&&m.ends[1].tos)];
1371
+ return sig.every(v=>v==null)?'na':sig.map(v=>v==null?'':v).join(':');}
1335
1372
  function dupScore(m){let s=0;if(_wt(m.profile)!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
1336
- function redundantDups(){const g={};for(const m of P.members){(g[dupKey(m)]=g[dupKey(m)]||[]).push(m);}
1337
- const out=[];for(const k in g){const grp=g[k];if(grp.length<2)continue;
1338
- grp.sort((a,b)=>dupScore(b)-dupScore(a));for(let i=1;i<grp.length;i++)out.push(grp[i].id);} // keep [0], rest redundant
1373
+ // Coincident-member dedupe the redundant ids. Group by 2D footprint; within a footprint: ≤1 distinct EXPLICIT
1374
+ // elevation ⇒ all copies of one member ⇒ keep the best (highest rankFn; an elevation-tagged copy wins ties), rest
1375
+ // redundant; ≥2 explicit elevations ⇒ distinct levels ⇒ keep the best per level, unset copies kept (ambiguous).
1376
+ // MIRROR of server/steel-confidence.ts redundantDupIds — keep in sync.
1377
+ function dedupeFootprintIds(members,rankFn){const byFoot={};for(const m of members){const k=dupFoot(m);if(!k)continue;(byFoot[k]=byFoot[k]||[]).push(m);}
1378
+ const out=[],keepBest=grp=>{if(grp.length<2)return;grp.sort((a,b)=>rankFn(b)-rankFn(a)||((dupElev(b)!=='na')-(dupElev(a)!=='na')));for(let i=1;i<grp.length;i++)out.push(grp[i].id);};
1379
+ for(const k in byFoot){const grp=byFoot[k];if(grp.length<2)continue;
1380
+ const exp=new Set(grp.map(dupElev).filter(s=>s!=='na'));
1381
+ if(exp.size<=1){keepBest(grp);continue;}
1382
+ const bySig={};for(const m of grp){const s=dupElev(m);if(s==='na')continue;(bySig[s]=bySig[s]||[]).push(m);}
1383
+ for(const s in bySig)keepBest(bySig[s]);}
1339
1384
  return out;}
1385
+ function redundantDups(){return dedupeFootprintIds(P.members,dupScore);}
1340
1386
  // --- merge collinear chords: same-profile, end-to-end, STRAIGHT beam runs → one member each.
1341
1387
  // The skew read breaks a chord into collinear sub-segments at every rung; this rejoins each run.
1342
1388
  // MIRROR of server/steel-merge.ts (the tested source of truth) — keep the two in lock-step.
@@ -1455,7 +1501,7 @@ function render(){
1455
1501
  s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
1456
1502
  if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
1457
1503
  svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle();
1458
- if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
1504
+ if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);refreshLegendSel();} // keep the 3D highlight + legend selection in sync; selecting a member clears any clip selection (exclusive)
1459
1505
  try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
1460
1506
  syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
1461
1507
  }
@@ -1702,12 +1748,14 @@ function panel(){
1702
1748
  <div class=divrow><hr></div>
1703
1749
  <div class="row f" style="gap:6px;flex-wrap:wrap">
1704
1750
  <button class=ghostw id=cmpEdit data-tip="Edit this connection's parameters on ${esc(j.main)}">✎ Edit parameters on ${esc(j.main)} →</button>
1751
+ ${isBP?`<button class=ghostw id=cmpTrim data-tip="Pick a point on ${esc(j.main)} — trim or extend the column so its base seats there (snaps to framing levels)">⭶ Trim / Extend column to base…</button>`:''}
1705
1752
  <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
1706
1753
  <button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
1707
1754
  </div>`;
1708
1755
  const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1709
1756
  {const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
1710
1757
  {const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
1758
+ {const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
1711
1759
  {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1712
1760
  {const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
1713
1761
  return;
@@ -1865,7 +1913,7 @@ function panel(){
1865
1913
  ${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','1"','mm')}${pFld('weldLeg','Weld leg','5/16"','mm')}
1866
1914
  <div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
1867
1915
  ${pFld('boltCols','Bolt columns','2','')}${pFld('boltRows','Bolt rows','2','')}${pFld('boltDia','Bolt ⌀','1"','mm')}${pFld('embedment','Embedment','auto','mm')}${pFld('grout','Grout','auto','mm')}
1868
- <div class="row f"><button class="ghostw" id=bpRemove data-tip="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
1916
+ <div class="row f"><button class="ghostw" id=bpTrim data-tip="Pick a point on ${esc(m.id)} — trim or extend the column so its base seats there (snaps to framing levels)">⭶ Trim / Extend column to base…</button><button class="ghostw" id=bpRemove data-tip="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
1869
1917
  // This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
1870
1918
  // per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
1871
1919
  const spjs=col?[]:[0,1].map(e=>({e,j:(C.joints||[]).find(j=>j&&j.kind==='shear-plate'&&j.main===m.id&&j.at==='end'+e)})).filter(x=>x.j);
@@ -1941,7 +1989,8 @@ function panel(){
1941
1989
  else delete bpj.params[key]; // empty / invalid / ≤0 → drop the override so the engine falls back to its default
1942
1990
  bpj.source='user';});};}; // through edit() → the param change is undoable; editing also makes the plate user-owned (survives the auto-detail "Clear")
1943
1991
  ['plateWidth','plateDepth','thickness','boltDia','weldLeg','embedment','grout'].forEach(k=>wireBp(k,false));['boltCols','boltRows'].forEach(k=>wireBp(k,true));
1944
- {const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}} // Remove is undoable
1992
+ {const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}
1993
+ {const tb=document.getElementById('bpTrim');if(tb)tb.onclick=()=>armBaseTrim(m.id);}} // Remove is undoable; Trim arms the 3D base pick
1945
1994
  }else{
1946
1995
  wireTos('tosA',m.ends[0]);
1947
1996
  document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
@@ -3036,6 +3085,23 @@ const view3dApi={
3036
3085
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
3037
3086
  onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
3038
3087
  onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
3088
+ onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
3089
+ onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
3090
+ const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
3091
+ const tos=(m.col.tos!=null?m.col.tos:defaultTOS); // inches (col.bos/tos are inches; memberGeometry ×25.4 → mm)
3092
+ if(tos==null){toast('Set the column top (TOS) first');return;}
3093
+ const want=p.z/25.4, cap=tos-MIN_COL_STUB_IN;
3094
+ const bosIn=Math.min(want, cap); // world mm → inches; clamp so the column keeps a min stub (bos<tos)
3095
+ const clamped=want>cap+0.02; // the pick was above the min-stub cap → we seated at the cap, not the picked level
3096
+ const cur=(m.col.bos!=null?m.col.bos:0);
3097
+ if(Math.abs(bosIn-cur)<0.02){toast('Column base already at that level');return;}
3098
+ const dir=bosIn<cur?'extended':'trimmed'; // lower base = longer column = extended; higher = trimmed
3099
+ edit(()=>{m.col.bos=bosIn;}); // set BOS only (leave tosDef — the top isn't edited); the base plate re-seats at the new base via expandBasePlate, same undo
3100
+ // Report the ACTUAL result: a snapped level when honored, else the ft-in elevation; call out the clamp so a
3101
+ // pick above the min stub isn't misreported as landing on the snapped level.
3102
+ const where=clamped?(fmtFtIn(bosIn)+' (clamped to a 1′ minimum stub)'):(p.snapped?(p.label||'a level'):fmtFtIn(bosIn));
3103
+ toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
3104
+ },
3039
3105
  onInsertPlace:(pick,pending)=>{
3040
3106
  if(pending&&pending.kind==='connection'&&pending.connection){
3041
3107
  const conn=pending.connection;const rc=conn.recipe;
@@ -3137,9 +3203,10 @@ async function detailRequest(intent,place,note){
3137
3203
  body:JSON.stringify({appId:APP_ID,project:PROJECT||undefined,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
3138
3204
  toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
3139
3205
  }catch(_){toast('Could not queue the request');}}
3140
- // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
3141
- // double-click isolate mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
3142
- let leg3dClickT=null,legendAnchor=null;
3206
+ // Build the 3D legend overlay from the live scene groups (per profile). Click the BOX to show/hide (filled =
3207
+ // shown, hollow = hidden); click a row (its name) to SELECT the object[s] in 3D Ctrl/Cmd adds/removes, Shift
3208
+ // ranges; double-click a row to isolate. leg3dClickT defers the plain row-click so a dbl-click isolates instead.
3209
+ let leg3dClickT=null,legendAnchor=null,legendSelAnchor=null;
3143
3210
  // Explorer-style multi-isolate on dbl-click of a legend group row: plain = isolate just this group; Ctrl = toggle
3144
3211
  // it in/out of the isolated set; Shift = the contiguous range from the anchor row to this one (in displayed order).
3145
3212
  function legendIsolate(k,e){
@@ -3196,6 +3263,10 @@ function profileKeyOf(m){return (m&&m.profile||'').trim().toUpperCase();} // ma
3196
3263
  function categoryOfProfile(profKey){for(const m of (P.members||[]))if(profileKeyOf(m)===profKey)return memberTypeOf(m);return 'beam';} // a profile-group's category = the type of its member(s)
3197
3264
  let legendMode=(localStorage.getItem('floless.legendMode')==='type')?'type':'profile';
3198
3265
  let collapsedCats=new Set((()=>{try{return JSON.parse(localStorage.getItem('floless.legendCollapsed')||'[]');}catch{return [];}})());
3266
+ let legendQuery=''; // transient objects-list search filter (members + connections only) — NOT persisted
3267
+ // While a search is active, object categories (member types + connections) render EXPANDED so a match inside a
3268
+ // manually-collapsed category still surfaces — WITHOUT mutating the persisted collapsedCats.
3269
+ function catForceOpen(cat){return !!legendQuery&&(MEMBER_TYPES.some(t=>t.k===cat)||/^conn-/.test(cat));}
3199
3270
  function saveLegendPrefs(){try{localStorage.setItem('floless.legendMode',legendMode);localStorage.setItem('floless.legendCollapsed',JSON.stringify([...collapsedCats]));}catch{}}
3200
3271
  // Drag a typed member row onto another type category to retype it. Pointer Events (NOT the HTML5 drag API,
3201
3272
  // which paints a white browser ghost on Windows). A 6px threshold tells a drag from the row's click(hide) /
@@ -3232,21 +3303,27 @@ const DIM_LABEL=Object.fromEntries(DIM_CATS);
3232
3303
  // edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
3233
3304
  const DIM_CONN=[{ct:'base-plate',label:'Base-plate',cats:['base_plate','anchor_depth']},{ct:'shear-plate',label:'Shear-plate',cats:['bolt_pitch','edge_clearance','cope_size']}];
3234
3305
  function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
3306
+ if(!host._ctxWired){host._ctxWired=true;host.addEventListener('contextmenu',e=>e.preventDefault());} // right-click does nothing now (menu removed) — suppress the native OS menu so it never leaks over the dark theme
3235
3307
  const groups=window.Steel3DView.getGroups();host.replaceChildren();
3236
3308
  if(!groups.length){host.style.display='none';return;}
3237
- const hint=document.createElement('div');hint.className='lhint';hint.textContent='click hide/show · dbl-click isolate · Ctrl/Shift multi';host.appendChild(hint);
3309
+ const hint=document.createElement('div');hint.className='lhint';hint.textContent='click to select · box = show/hide (selection) · dbl-click = isolate (selection) · Ctrl/Shift to multi-select';host.appendChild(hint);
3238
3310
  const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
3239
3311
  if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.dataset.tip='Drag onto another type';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);} // handle = the only drag initiator; swallow its own clicks so it never toggles the row
3240
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
3312
+ const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',g.color);sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';
3241
3313
  row.append(sw,document.createTextNode(g.label));
3242
- row.addEventListener('click',()=>{if(row._dragging)return;clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
3243
- row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);legendIsolate(g.key,e);});
3314
+ // Show/hide lives on the BOX; dbl-click a row isolates. When the row is part of a multi-selection, the box toggles
3315
+ // ALL selected together and the dbl-click isolates the whole selection. stopPropagation on the box so a dbl-click
3316
+ // landing on it doesn't also fire row-isolate.
3317
+ sw.addEventListener('click',e=>{e.stopPropagation();legendBoxToggle(row);});
3318
+ sw.addEventListener('dblclick',e=>e.stopPropagation());
3319
+ row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);if(row.classList.contains('lsel'))legendIsolateSel();else legendIsolate(g.key,e);});
3320
+ row.addEventListener('click',e=>legendRowClick(e,row)); // click the name to SELECT (Ctrl/Cmd add · Shift range); plain click is deferred so a dbl-click isolates instead
3244
3321
  if(draggable)wireRowDrag(row,g);
3245
3322
  host.appendChild(row);return row;};
3246
3323
  // A collapsible legend category: chevron (collapse) + tri-state master on/off (■/□/◪) + label + count.
3247
3324
  // getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
3248
3325
  const buildCatHeader=(cat,label,count,opts)=>{opts=opts||{};const hdr=document.createElement('div');hdr.className='cat-hdr'+(opts.empty?' empty':'')+(opts.sub?' sub':'');hdr.dataset.cat=cat;hdr._getState=opts.getState;
3249
- const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:collapsedCats.has(cat)?'':''});
3326
+ const catOpen=!collapsedCats.has(cat)||catForceOpen(cat);const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:catOpen?'':''});
3250
3327
  const tog=Object.assign(document.createElement('span'),{className:'cat-tog'});tog.dataset.tip=opts.toggleTitle||('Show / hide all '+label.toLowerCase());if(opts.empty||!opts.onToggle)tog.style.display='none';
3251
3328
  const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
3252
3329
  const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
@@ -3266,12 +3343,28 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3266
3343
  mode.appendChild(b);}
3267
3344
  host.insertBefore(mode,host.firstChild);
3268
3345
  }
3346
+ if(members.length||conns.length){ // SEARCH box — narrows object rows (members + connections); shown whenever there are objects to filter
3347
+ const sb=document.createElement('div');sb.className='lsearch'+(legendQuery?' has':'');
3348
+ const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');
3349
+ const NS='http://www.w3.org/2000/svg',svg=document.createElementNS(NS,'svg'); // magnifier built via DOM (no innerHTML), stroked with currentColor so it inherits --mut
3350
+ svg.setAttribute('viewBox','0 0 16 16');svg.setAttribute('width','12');svg.setAttribute('height','12');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.6');svg.setAttribute('stroke-linecap','round');
3351
+ const cir=document.createElementNS(NS,'circle');cir.setAttribute('cx','7');cir.setAttribute('cy','7');cir.setAttribute('r','4.5');
3352
+ const lin=document.createElementNS(NS,'line');lin.setAttribute('x1','10.6');lin.setAttribute('y1','10.6');lin.setAttribute('x2','14');lin.setAttribute('y2','14');
3353
+ svg.append(cir,lin);ico.append(svg);
3354
+ const inp=document.createElement('input');inp.id='legSearch';inp.type='text';inp.placeholder='Search objects…';inp.autocomplete='off';inp.value=legendQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search objects in the list');
3355
+ const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
3356
+ inp.addEventListener('input',()=>onLegendSearchInput(inp.value));
3357
+ inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';onLegendSearchInput('');}else{inp.blur();}}});
3358
+ clr.addEventListener('click',()=>{if(!inp.value&&!legendQuery)return;inp.value='';onLegendSearchInput('');inp.focus();});
3359
+ sb.append(ico,inp,clr);
3360
+ host.insertBefore(sb,hint);
3361
+ }
3269
3362
  if(legendMode==='type'&&members.length){ // group the profile-rows under their member-type categories
3270
3363
  const byCat=new Map(MEMBER_TYPES.map(t=>[t.k,[]]));
3271
3364
  for(const g of members){(byCat.get(categoryOfProfile(g.key))||byCat.get('beam')).push(g);}
3272
3365
  for(const {k,label} of MEMBER_TYPES){const gs=byCat.get(k)||[],keys=gs.map(g=>g.key);
3273
3366
  host.appendChild(buildCatHeader(k,label,gs.length,{empty:!gs.length,getState:()=>grpState(keys),onToggle:()=>grpToggle(keys),toggleTitle:'Show / hide all '+label.toLowerCase()+'s'}));
3274
- if(!collapsedCats.has(k))for(const g of gs)addRow(g,true,true);}
3367
+ if(!collapsedCats.has(k)||catForceOpen(k))for(const g of gs)addRow(g,true,true);}
3275
3368
  } else for(const g of members)addRow(g);
3276
3369
  if(conns.length){ // group connection PARTS under their joint (Phase 2): each part-kind a row, hidden per-id
3277
3370
  if(members.length){host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));}
@@ -3290,10 +3383,11 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3290
3383
  getState:()=>{const h=hiddenSet(),n=allIds.filter(id=>h.has(id)).length;return n===0?'on':(n===allIds.length?'off':'mixed');},
3291
3384
  onToggle:()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(allIds,allIds.every(id=>!h.has(id)));}, // all-on → hide all; else show all
3292
3385
  toggleTitle:'Show / hide the '+label.toLowerCase()+' connection'}));
3293
- if(!collapsedCats.has(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
3386
+ if(!collapsedCats.has(ck)||catForceOpen(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
3294
3387
  const row=document.createElement('div');row.className='lrow typed';row.dataset.connkey=ck+':'+grp;row._ids=ids;
3295
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=m.color;row.append(sw,document.createTextNode(m.label));
3296
- row.addEventListener('click',()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(ids,!ids.every(id=>h.has(id)));refresh3DLegend();}); // click toggles just this connection's parts of this kind
3388
+ const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',m.color);sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';row.append(sw,document.createTextNode(m.label));
3389
+ sw.addEventListener('click',e=>{e.stopPropagation();legendBoxToggle(row);});sw.addEventListener('dblclick',e=>e.stopPropagation()); // box toggles this connection's parts (or the whole selection when this row is selected)
3390
+ row.addEventListener('click',e=>legendRowClick(e,row)); // click the row to select these connection parts (Ctrl/Shift multi)
3297
3391
  host.appendChild(row);}
3298
3392
  }
3299
3393
  }
@@ -3303,11 +3397,12 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3303
3397
  // isolate), and a gentler off-state — off is a normal resting choice here, not a hidden-part warning.
3304
3398
  const ov=C.dim_overlays||{};
3305
3399
  const addDimRow=(cat,label,sub)=>{const row=document.createElement('div');row.className='lrow dim typed'+(sub?' sub':'');row.dataset.dim=cat;
3306
- const sw=document.createElement('span');sw.className='lsw';
3400
+ const sw=document.createElement('span');sw.className='lsw';sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';sw.setAttribute('aria-checked',String(ov[cat]!==false));
3307
3401
  row.append(sw,document.createTextNode(label));
3308
3402
  row.classList.toggle('dimoff',ov[cat]===false);
3309
- // toggle the overlay; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
3310
- row.addEventListener('click',()=>{const on=C.dim_overlays[cat]!==false;C.dim_overlays[cat]=!on;row.classList.toggle('dimoff',on);scheduleSave();refreshOverlayDims3d();});
3403
+ // toggle the overlay from the BOX; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
3404
+ sw.addEventListener('click',e=>{e.stopPropagation();const on=C.dim_overlays[cat]!==false;C.dim_overlays[cat]=!on;row.classList.toggle('dimoff',on);sw.setAttribute('aria-checked',String(!on));scheduleSave();refreshOverlayDims3d();});
3405
+ sw.addEventListener('dblclick',e=>e.stopPropagation());
3311
3406
  host.appendChild(row);};
3312
3407
  if(members.length||conns.length)host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
3313
3408
  const dimState=()=>{const on=DIM_CATS.filter(([k])=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===DIM_CATS.length?'on':'mixed');};
@@ -3315,7 +3410,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3315
3410
  const dcState=cats=>{const on=cats.filter(k=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===cats.length?'on':'mixed');};
3316
3411
  const dcToggle=cats=>{const anyOn=cats.some(k=>C.dim_overlays[k]!==false);for(const k of cats)C.dim_overlays[k]=!anyOn;scheduleSave();refreshOverlayDims3d();build3DLegend();};
3317
3412
  host.appendChild(buildCatHeader('dims','Dimensions',DIM_CATS.length,{getState:dimState,onToggle:dimToggle,toggleTitle:'Show / hide all dimension overlays'}));
3318
- if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click: show / hide'}));
3413
+ if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click the box to show / hide'}));
3319
3414
  for(const dc of DIM_CONN){const ck='dims-'+dc.ct; // middle category: overlays grouped by connection
3320
3415
  host.appendChild(buildCatHeader(ck,dc.label,dc.cats.length,{sub:true,getState:()=>dcState(dc.cats),onToggle:()=>dcToggle(dc.cats),toggleTitle:'Show / hide all '+dc.label.toLowerCase()+' dimensions'}));
3321
3416
  if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
@@ -3326,10 +3421,10 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3326
3421
  if(typeof P!=='undefined'&&P&&P.grid){
3327
3422
  host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
3328
3423
  const grow=document.createElement('div');grow.className='lrow dim';grow.dataset.tip='Show / hide the structural grid (2D + 3D)';
3329
- const gsw=document.createElement('span');gsw.className='lsw';gsw.style.borderColor='#64748b';
3424
+ const gsw=document.createElement('span');gsw.className='lsw';gsw.style.setProperty('--sw','#64748b');gsw.setAttribute('role','checkbox');gsw.dataset.tip='Show / hide';gsw.setAttribute('aria-checked',String(gridOn()));
3330
3425
  grow.append(gsw,document.createTextNode('Grid lines'));
3331
3426
  grow.classList.toggle('dimoff',!gridOn());
3332
- grow.addEventListener('click',()=>gridSetVisible(!gridOn()));
3427
+ gsw.addEventListener('click',e=>{e.stopPropagation();gridSetVisible(!gridOn());});gsw.addEventListener('dblclick',e=>e.stopPropagation());
3333
3428
  host.appendChild(grow);
3334
3429
  }
3335
3430
  // CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
@@ -3343,22 +3438,22 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3343
3438
  if(collapsedCats.has('clip')){/* collapsed → no rows */}
3344
3439
  else if(!clips.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'(no clips)'}));}
3345
3440
  else for(const c of clips){
3346
- // Three separate zones (no fighting): swatch/label = SELECT (reveals its 3D drag handles), On/Off pill = ENABLE, × = DELETE.
3347
- const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':''); // enable state is shown by the On/Off pill, not by dimming the row
3348
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.dataset.tip='Select show its drag handles in 3D'; // box = lighter blue, plane = brand blue
3441
+ // Box = ENABLE / disable (filled = cutting, hollow = off); label = SELECT (click) / RENAME (dbl-click); × = DELETE.
3442
+ const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':'')+(c.enabled?'':' off'); // disabled .off hollows the box + dims the row, like a hidden part
3443
+ const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',c.kind==='box'?'#93c5fd':'#3b82f6');sw.setAttribute('role','checkbox');sw.setAttribute('aria-checked',String(!!c.enabled));sw.dataset.tip='Enable / disable this clip'; // box = lighter blue, plane = brand blue
3349
3444
  const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.dataset.tip='Click to select · double-click to rename';
3350
- const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.dataset.tip='Enable / disable this clip';
3351
3445
  const x=document.createElement('span');x.className='lx';x.textContent='×';x.dataset.tip='Delete this clip';
3352
- row.append(sw,lab,tog,x);
3353
- sw.addEventListener('click',e=>{e.stopPropagation();clipSelect(c.id,e);}); // Ctrl/Shift = multi-select (same as parts/dims)
3446
+ row.append(sw,lab,x);
3447
+ sw.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);}); // box toggles enable; selecting (which reveals the 3D drag handles) is the label's job
3448
+ sw.addEventListener('dblclick',e=>e.stopPropagation());
3354
3449
  let clipClickT=null;
3355
3450
  lab.addEventListener('click',e=>{e.stopPropagation();clearTimeout(clipClickT);const ev={ctrlKey:e.ctrlKey,metaKey:e.metaKey,shiftKey:e.shiftKey};clipClickT=setTimeout(()=>clipSelect(c.id,ev),200);}); // deferred so a double-click (rename) can cancel the select
3356
3451
  lab.addEventListener('dblclick',e=>{e.stopPropagation();e.preventDefault();clearTimeout(clipClickT);startClipRename(c,lab);});
3357
- tog.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);});
3358
3452
  x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
3359
3453
  host.appendChild(row);
3360
3454
  }
3361
- host.style.display='flex';refresh3DLegend();}
3455
+ {const rst=Object.assign(document.createElement('div'),{className:'lreset',id:'m3dLegendReset',textContent:'Show all'});rst.setAttribute('role','button');rst.dataset.tip='Restore every hidden / isolated object in this panel';rst.addEventListener('click',legendReset);host.insertBefore(rst,host.firstChild);} // Show-all reset — panel's first child (above the mode toggle); visibility set by updateLegendReset (via refresh3DLegend)
3456
+ host.style.display='flex';refresh3DLegend();applyLegendFilter();refreshLegendSel();}
3362
3457
  // The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
3363
3458
  // "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
3364
3459
  function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
@@ -3380,10 +3475,81 @@ function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.
3380
3475
  function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
3381
3476
  const state=hdr._getState();tog.dataset.state=state;tog.textContent=state==='on'?'■':(state==='off'?'□':'◪');}
3382
3477
  function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden),solo=new Set(st.solo);
3383
- document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(solo.size>0&&!solo.has(k)));r.classList.toggle('solo',solo.size>0&&solo.has(k));}); // PROFILE rows only (data-key); .off = hidden or outside the isolated set; .solo = inside it (Explorer-style highlight)
3478
+ document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;const off=hidden.has(k)||(solo.size>0&&!solo.has(k));r.classList.toggle('off',off);r.classList.toggle('solo',solo.size>0&&solo.has(k));const sw=r.querySelector('.lsw');if(sw)sw.setAttribute('aria-checked',String(!off));}); // PROFILE rows (data-key); .off = hidden or outside the isolated set → box hollows; .solo = inside it (Explorer-style highlight)
3384
3479
  const ch=new Set(window.Steel3DView.connHiddenIds?window.Steel3DView.connHiddenIds():[]); // per-part connection hide
3385
- document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];r.classList.toggle('off',ids.length>0&&ids.every(id=>ch.has(id)));});
3386
- document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);} // refresh the type-category master toggles too
3480
+ document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];const off=ids.length>0&&ids.every(id=>ch.has(id));r.classList.toggle('off',off);const sw=r.querySelector('.lsw');if(sw)sw.setAttribute('aria-checked',String(!off));});
3481
+ document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);updateLegendReset();} // refresh the type-category master toggles + the show-all reset bar's visibility
3482
+ // ── Objects-list SELECTION — click a row (its name) to select its object[s] in 3D; Ctrl/Cmd add/remove, Shift range ──
3483
+ // Plain click is deferred (leg3dClickT) so a dbl-click can isolate instead; a modified click selects immediately
3484
+ // (so rapid Ctrl-clicking several rows doesn't lose one to the shared timer). Box clicks stopPropagation, so they never reach here.
3485
+ function legendRowClick(e,row){if(row&&row._dragging)return;const mods={ctrl:e.ctrlKey||e.metaKey,shift:e.shiftKey};clearTimeout(leg3dClickT);if(mods.ctrl||mods.shift)legendSelect(row,mods);else leg3dClickT=setTimeout(()=>legendSelect(row,mods),220);}
3486
+ function legendSelect(row,mods){
3487
+ if(!row)return;const ids=legRowIds(row);if(!ids.length)return;
3488
+ if(mods&&mods.shift&&legendSelAnchor&&document.body.contains(legendSelAnchor)){ // Shift → union every VISIBLE object row from the anchor to here (a search-hidden row can't be range-selected)
3489
+ const rows=[...document.querySelectorAll('#m3dLegend .lrow[data-key]:not(.qhide),#m3dLegend .lrow[data-connkey]:not(.qhide)')];
3490
+ const i0=rows.indexOf(legendSelAnchor),i1=rows.indexOf(row);
3491
+ if(i0>=0&&i1>=0){const next=new Set();rows.slice(Math.min(i0,i1),Math.max(i0,i1)+1).forEach(r=>legRowIds(r).forEach(id=>next.add(id)));selIds=next;selDimIds.clear();sel3dDimIds.clear();render();return;}
3492
+ }
3493
+ if(mods&&mods.ctrl){const next=new Set(selIds);const all=ids.every(id=>next.has(id));ids.forEach(id=>all?next.delete(id):next.add(id));selIds=next;} // Ctrl → toggle this group in/out of the selection
3494
+ else selIds=new Set(ids); // plain → replace the selection
3495
+ legendSelAnchor=row;selDimIds.clear();sel3dDimIds.clear();render();
3496
+ }
3497
+ // A row lights up (.lsel) when EVERY object it represents is selected — so a legend click that selects the whole group shows it. Synced from render()'s 3D block + build3DLegend.
3498
+ function refreshLegendSel(){const host=document.getElementById('m3dLegend');if(!host||host.style.display==='none')return;
3499
+ host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]').forEach(r=>{const ids=legRowIds(r);r.classList.toggle('lsel',ids.length>0&&ids.every(id=>selIds.has(id)));});}
3500
+ // Click a row's BOX → show/hide. If that row is part of the current selection, the box acts on EVERY selected row
3501
+ // (the clicked row's current state drives the direction). Members toggle by group key, connections by part id.
3502
+ function legendBoxToggle(row){if(!row||!window.Steel3DView)return;
3503
+ const rows=row.classList.contains('lsel')?[...document.querySelectorAll('#m3dLegend .lrow.lsel')]:[row];
3504
+ const willHide=!row.classList.contains('off'); // one direction for all: hide if the clicked box was shown, else show
3505
+ const keys=[],ids=[];for(const r of rows){if(r.dataset.connkey)ids.push(...(r._ids||[]));else if(r.dataset.key)keys.push(r.dataset.key);}
3506
+ if(keys.length&&window.Steel3DView.setGroupsHidden)window.Steel3DView.setGroupsHidden(keys,willHide);
3507
+ if(ids.length&&window.Steel3DView.setIdsHidden)window.Steel3DView.setIdsHidden(ids,willHide);
3508
+ refresh3DLegend();}
3509
+ // Dbl-click a SELECTED row → isolate the whole selection (all selected profile groups, Explorer-style solo). Only
3510
+ // reachable from a member row's dbl-click (connection rows have no dbl-click), so the double-clicked member's own
3511
+ // key is always present — no empty-keys path.
3512
+ function legendIsolateSel(){if(!window.Steel3DView)return;
3513
+ const keys=[...document.querySelectorAll('#m3dLegend .lrow.lsel[data-key]')].map(r=>r.dataset.key);
3514
+ if(keys.length){window.Steel3DView.setSoloGroups(keys);refresh3DLegend();}}
3515
+ // "Show all" reset — a panel-local door onto showAllGroups() (which already clears box-hides, solo, isolate AND
3516
+ // per-connection hides in one call, then refreshes). Does NOT clear the search filter — the search box's own × owns that.
3517
+ function legendReset(){if(!window.Steel3DView)return;window.Steel3DView.showAllGroups();if(window.Steel3DView.clearIsolation)window.Steel3DView.clearIsolation();}
3518
+ // Show the reset bar only when 3D visibility is non-default (something hidden / solo'd / isolated). Indifferent to search.
3519
+ function updateLegendReset(){const b=document.getElementById('m3dLegendReset');if(!b||!window.Steel3DView)return;
3520
+ const st=window.Steel3DView.groupState();
3521
+ const filtered=(st.hidden&&st.hidden.length>0)||(st.solo&&st.solo.length>0)||(window.Steel3DView.isIsolated&&window.Steel3DView.isIsolated())||(window.Steel3DView.connHiddenIds&&window.Steel3DView.connHiddenIds().length>0);
3522
+ b.classList.toggle('show',filtered);}
3523
+ // ── Objects-list SEARCH ──────────────────────────────────────────────────────────────────────────────────────
3524
+ // Search narrows the MEMBER + CONNECTION rows only (never Dimensions/Grid/Clip, never the 3D scene). Crossing
3525
+ // empty↔non-empty rebuilds once (so collapsed categories force-expand and their matches can surface); refining
3526
+ // within an active query is a cheap show/hide pass that keeps the input focused.
3527
+ function onLegendSearchInput(q){
3528
+ const prev=legendQuery;legendQuery=q;
3529
+ if((!!prev)!==(!!q)){build3DLegend();const inp=document.getElementById('legSearch');if(inp){inp.focus();try{inp.setSelectionRange(inp.value.length,inp.value.length);}catch(_){}}}
3530
+ else applyLegendFilter();
3531
+ }
3532
+ // Show/hide object rows by label; hide object categories left with no visible child; toggle the "no matches" line.
3533
+ function applyLegendFilter(){
3534
+ const host=document.getElementById('m3dLegend');if(!host)return;
3535
+ const q=(legendQuery||'').trim().toLowerCase();
3536
+ const old=host.querySelector('.lsempty');if(old)old.remove();
3537
+ const rows=[...host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]')];
3538
+ if(!q){rows.forEach(r=>r.classList.remove('qhide'));host.querySelectorAll('.cat-hdr.qhide').forEach(h=>h.classList.remove('qhide'));return;}
3539
+ let any=false;
3540
+ rows.forEach(r=>{const hit=(r.textContent||'').toLowerCase().includes(q);r.classList.toggle('qhide',!hit);if(hit)any=true;});
3541
+ host.querySelectorAll('.cat-hdr').forEach(h=>{const cat=h.dataset.cat||'';
3542
+ if(!(MEMBER_TYPES.some(t=>t.k===cat)||/^conn-/.test(cat)))return; // leave the Dimensions/Clip headers untouched
3543
+ let vis=false;
3544
+ for(let n=h.nextElementSibling;n&&!n.classList.contains('cat-hdr')&&!n.classList.contains('lsec')&&!n.classList.contains('ldiv');n=n.nextElementSibling){
3545
+ if((n.matches('.lrow[data-key]')||n.matches('.lrow[data-connkey]'))&&!n.classList.contains('qhide')){vis=true;break;}}
3546
+ h.classList.toggle('qhide',!vis);});
3547
+ if(!any){const hint=host.querySelector('.lhint');const e=Object.assign(document.createElement('div'),{className:'lsempty',textContent:'No objects match “'+legendQuery.trim()+'”.'});
3548
+ if(hint&&hint.nextSibling)host.insertBefore(e,hint.nextSibling);else host.appendChild(e);}
3549
+ }
3550
+ // Resolve a row to the member/connection ids it represents. A member row's data-key is a profile key; a connection
3551
+ // row carries its part ids on row._ids. Used by legend click-to-select + the box/isolate SELECTION actions below.
3552
+ function legRowIds(row){if(!row)return [];if(row.dataset.connkey)return (row._ids||[]).slice();const k=row.dataset.key;if(!k)return [];return (P.members||[]).filter(m=>profileKeyOf(m)===k).map(m=>m.id);}
3387
3553
  let bar3dWired=false;
3388
3554
  function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
3389
3555
  // Reflect the live projection / display mode into the Camera + Display dropdowns: tick the active menu item AND label the trigger button, so the current mode shows without opening the menu.
@@ -3884,10 +4050,9 @@ function _wt(profile){if(!profile)return null;
3884
4050
  if(WT){const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());if(h&&h[1]!=null)return h[1];}
3885
4051
  return _nominalPlf(profile);} // fall back to the lb/ft encoded in a standard designation
3886
4052
  const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
3887
- function _confDupIds(members){const g={};const key=m=>{if(!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;};
3888
- const rank=m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;};
3889
- for(const m of members){const k=key(m);if(!k)continue;(g[k]=g[k]||[]).push(m);}
3890
- const out=new Set();for(const k in g){const grp=g[k];if(grp.length<2)continue;grp.sort((a,b)=>rank(b)-rank(a));for(let i=1;i<grp.length;i++)out.add(grp[i].id);}return out;}
4053
+ // Elevation-aware coincident dedupe for the browser confidence panel — same footprint+wildcard-elevation logic as
4054
+ // redundantDups / the server, so the in-browser confidence report and the server score agree on stacked members.
4055
+ function _confDupIds(members){return new Set(dedupeFootprintIds(members,m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;}));}
3891
4056
  function _elevAssumed(m){if(m.role==='column')return !(m.col&&m.col.tosDef===false);const en=m.ends||[];if(!en.length)return true;return en.some(e=>e.tosDef!==false);}
3892
4057
  function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
3893
4058
  if(plf==null){F.push({key:'profile',label:'Profile',state:'fail',detail:(!m.profile||!m.profile.trim())?'no profile assigned':_isMf(m.profile)?('unresolved mark "'+m.profile+'" — not an AISC size'):('"'+m.profile+'" not in the AISC weight table')});return {band:'rfi',factors:F};}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.78.0",
3
+ "version": "0.80.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {