@floless/app 0.80.0 → 0.82.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.80.0" : void 0,
53096
+ define: true ? "0.82.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.80.0" : void 0 });
53106
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.82.0" : void 0 });
53107
53107
  }
53108
53108
 
53109
53109
  // workflow-update.ts
@@ -42,6 +42,7 @@
42
42
  "joints": { "type": "array", "items": { "$ref": "#/$defs/joint" }, "description": "Placed connections (base plates, …) the connection engine expands into real 3D parts (plate/bolt/weld/cut) bound to their members. A joint may cite a `connections[]` row via `conn` for its type/detail identity; `params` carries the geometry settings (engine defaults when absent)." },
43
43
  "dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." },
44
44
  "detail_placements": { "type": "array", "items": { "$ref": "#/$defs/detailPlacement" }, "description": "Draft-only placed 2D detail images (Slice 4 — insert a vectored detail into the model). Flat image planes the editor renders client-side against the model; metadata only — the image bytes live in custom_details. Model-global. NOT baked into the lock / 3D scene / IFC / BOM." },
45
+ "views": { "type": "array", "description": "Draft-only saved views (Views Organizer — up to 10 per model): each a named snapshot of the 3D viewpoint (camera + projection + display mode + clip boxes/planes + work area + the objects-list pane's persistent visibility/isolate state). Editor state, model-global. NOT baked into the lock / 3D scene / IFC / BOM.", "items": { "type": "object", "additionalProperties": true, "required": ["id", "name"], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "order": { "type": "number" }, "camera": { "type": "object", "additionalProperties": true }, "projection": { "enum": ["persp", "ortho"] }, "mode": { "enum": ["solid", "wire", "xray"] }, "clips": { "type": "object", "additionalProperties": true }, "objects": { "type": "object", "additionalProperties": true } } } },
45
46
  "prop_labels": {
46
47
  "type": "object",
47
48
  "additionalProperties": true,
@@ -26,7 +26,7 @@ let boxSel = null, rubber = null; // LEFT-drag-on-empty rubber-band mul
26
26
  let hoverId = null, hoverChip = null, hoverRAF = 0, lastHoverXY = null; // hover readout + cursor
27
27
  let epGroup = null; // yellow/magenta endpoint markers (start/end direction dots)
28
28
  let epGeom = null, epMatStart = null, epMatEnd = null; // shared across all endpoint dots (no per-rebuild churn)
29
- let epRing = null, epPreview = null; // white ring on the active end node + a rubber line while dragging it
29
+ let epRing = null, epPreview = null, epMovedLine = null; // white ring on the active end node + member rubber (epPreview) + the "moved" leader (epMovedLine) while dragging an end
30
30
  let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged end node
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)
@@ -41,6 +41,9 @@ const memberLabelPool = []; // reused <div> labels, one per member, posit
41
41
  let propLabelHost = null; // fixed-position container for the right-click property labels (positioned each frame)
42
42
  const propLabelPool = []; // reused multi-line <div> label chips, one per labelled member
43
43
  let propLabelSpec = null; // { labels:[{id, lines:[...]}], placement } pushed from the editor (owns the text); this view owns projection/placement
44
+ let selLenLabelHost = null; // fixed-position container for the on-select member-length labels (positioned each frame)
45
+ const selLenLabelPool = []; // reused <div> length chips, one per selected member
46
+ let selLenLabelSpec = null; // [{id, text}] pushed from the editor when "Show member length" is on (⋯ Display)
44
47
  const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
45
48
  let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
46
49
  let displayMode = 'solid'; // solid | wire | xray
@@ -137,6 +140,9 @@ function init(canvas, theApi) {
137
140
  // rubber preview line from the fixed end to the dragged end node
138
141
  epPreview = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x3b82f6 }));
139
142
  epPreview.material.depthTest = false; epPreview.renderOrder = 997; epPreview.visible = false; scene.add(epPreview);
143
+ // the "moved" leader while dragging an end: a cyan dashed line from where the end started to the cursor (the 3D echo of the 2D Move rubber). World-space dashes → computeLineDistances() after each setFromPoints.
144
+ epMovedLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineDashedMaterial({ color: 0x22d3ee, dashSize: 55, gapSize: 35 }));
145
+ epMovedLine.material.depthTest = false; epMovedLine.renderOrder = 998; epMovedLine.visible = false; scene.add(epMovedLine);
140
146
  raycaster = new THREE.Raycaster();
141
147
  // Snap marker: a camera-facing cyan RETICLE (crosshair ring + per-type inner glyph), not a bare dot —
142
148
  // a solid sphere vanished against the dark bg + coloured steel. Cyan #22d3ee matches the 2D snap marker;
@@ -165,6 +171,9 @@ function init(canvas, theApi) {
165
171
  document.body.appendChild(memberLabelHost);
166
172
  propLabelHost = document.createElement('div'); // right-click property labels — own host so they never clobber the mark/id pool
167
173
  propLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
174
+ selLenLabelHost = document.createElement('div'); // ⋯ Display → Show member length: on-select length chips (cyan dimension read)
175
+ selLenLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
176
+ document.body.appendChild(selLenLabelHost);
168
177
  document.body.appendChild(propLabelHost);
169
178
  // persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
170
179
  hoverChip = document.createElement('div');
@@ -211,6 +220,7 @@ function loop() {
211
220
  positionOverlayLabels();
212
221
  positionMemberLabels();
213
222
  positionPropLabels();
223
+ positionSelLenLabels();
214
224
  renderer.render(scene, camera);
215
225
  if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
216
226
  const saved = renderer.clippingPlanes;
@@ -790,6 +800,51 @@ function positionPropLabels() {
790
800
  }
791
801
  }
792
802
 
803
+ // ---- On-select member-length labels (⋯ Display → Show member length): the editor owns the TEXT (true 3D
804
+ // length, formatted) and pushes [{id, text}]; this view owns the anchor (member midpoint) + projection. Cyan
805
+ // chips = a live geometric read, distinct from the brand-blue property chips. Mirrors the property-label pool. ----
806
+ function setSelLenLabels(labels) {
807
+ selLenLabelSpec = (labels && Array.isArray(labels) && labels.length) ? labels : null;
808
+ syncSelLenLabels();
809
+ }
810
+ function syncSelLenLabels() {
811
+ if (!selLenLabelHost || !api) return;
812
+ const labels = selLenLabelSpec || [];
813
+ while (selLenLabelPool.length < labels.length) {
814
+ const el = document.createElement('div');
815
+ el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:var(--panel,#0f172a);color:#e2e8f0;border:1px solid #22d3ee;border-radius:4px;padding:1px 6px;font:11px system-ui;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,.4)'; // cyan border = dimension read (matches the 2D .mlenchip)
816
+ selLenLabelHost.appendChild(el); selLenLabelPool.push(el);
817
+ }
818
+ const byId = new Map(members().map((m) => [m.id, m]));
819
+ const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
820
+ for (let i = 0; i < selLenLabelPool.length; i++) {
821
+ const el = selLenLabelPool[i], L = labels[i], m = L ? byId.get(L.id) : null;
822
+ if (!m || !Array.isArray(m.wp) || m.wp.length < 2 || !L.text) { el.style.display = 'none'; el._mid = null; el._memberId = null; continue; }
823
+ const g = memberGeometry(m, ppf, dtos), a = g.line[0], b = g.line[1];
824
+ el._mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
825
+ el._memberId = m.id;
826
+ el.textContent = L.text;
827
+ el.style.display = 'block';
828
+ }
829
+ }
830
+ function positionSelLenLabels() {
831
+ if (!selLenLabelHost) return;
832
+ if (!selLenLabelSpec || (canvasEl && canvasEl.style.display === 'none')) { selLenLabelHost.style.display = 'none'; return; }
833
+ selLenLabelHost.style.display = 'block';
834
+ const rect = canvasEl.getBoundingClientRect();
835
+ for (const el of selLenLabelPool) {
836
+ if (el.style.display === 'none' || !el._mid) continue;
837
+ const mesh = meshById.get(el._memberId);
838
+ if (mesh && mesh.visible === false) { el.style.visibility = 'hidden'; continue; } // member legend-hidden / isolated away → hide its length too
839
+ if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; } // sectioned away by a clip
840
+ const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
841
+ if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
842
+ el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
843
+ el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
844
+ el.style.visibility = 'visible';
845
+ }
846
+ }
847
+
793
848
  // ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
794
849
  // them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
795
850
  // wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
@@ -1023,6 +1078,23 @@ function setSoloGroups(keys) { soloGroups = new Set((keys || []).filter((k) => k
1023
1078
  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)
1024
1079
  function showAllGroups() { groupHidden.clear(); soloGroups.clear(); isolatedIds = null; connHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); if (api && api.onIsolateChange) api.onIsolateChange(false); }
1025
1080
  function groupState() { return { hidden: [...groupHidden], solo: [...soloGroups] }; }
1081
+ // One serialisable snapshot of the objects-list pane's PERSISTENT visibility state — group hide/solo +
1082
+ // isolate-selected + per-part legend hides. This is what a saved view (Views Organizer) captures; it
1083
+ // deliberately excludes the transient legend search (`legendQuery`, editor-side, never persisted) and the
1084
+ // live selection. Versioned + read/applied wholesale so it stays robust as the pane's filtering grows.
1085
+ function objectsPaneState() { return { v: 1, hidden: [...groupHidden], solo: [...soloGroups], isolated: isolatedIds ? [...isolatedIds] : null, connHidden: [...connHidden] }; }
1086
+ // Restore an objectsPaneState() blob — sanitised to arrays (a partial/stale draft can't desync the scene
1087
+ // from the legend, same discipline as the editor's dim_overlays restore), then re-apply visibility with the
1088
+ // exact refresh tail showAllGroups uses.
1089
+ function applyObjectsPaneState(s) {
1090
+ if (!s || typeof s !== 'object') return;
1091
+ groupHidden.clear(); if (Array.isArray(s.hidden)) for (const k of s.hidden) groupHidden.add(k);
1092
+ soloGroups = new Set(Array.isArray(s.solo) ? s.solo : []);
1093
+ isolatedIds = (Array.isArray(s.isolated) && s.isolated.length) ? new Set(s.isolated) : null;
1094
+ connHidden = new Set(Array.isArray(s.connHidden) ? s.connHidden : []);
1095
+ applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims();
1096
+ if (api && api.onIsolateChange) api.onIsolateChange(isolatedIds !== null);
1097
+ }
1026
1098
  // Tekla "isolate selected": show ONLY the currently-selected parts (a snapshot taken now — distinct from
1027
1099
  // soloToggle, which isolates a whole PROFILE group from the legend). clearIsolation / showAllGroups restore all.
1028
1100
  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; }
@@ -1361,6 +1433,26 @@ function workAreaSetWhole(on) {
1361
1433
  }
1362
1434
  function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); refreshWorkAreaVis(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
1363
1435
  function workAreaState() { return workArea ? { on: workArea.enabled, whole: !!workArea.whole } : null; }
1436
+ // The camera viewpoint as plain data — position + orbit target + projection + the ortho frustum half-height
1437
+ // and zoom. A saved view restores it via setCameraState. (perspCam/orthoCam share position; orthoBaseH+zoom
1438
+ // size the ortho frustum.)
1439
+ function cameraState() { return { v: 1, pos: camera.position.toArray(), target: controls.target.toArray(), proj: projection(), orthoH: orthoBaseH, zoom: orthoCam.zoom }; }
1440
+ function setCameraState(s) {
1441
+ if (!s || !Array.isArray(s.pos) || !Array.isArray(s.target)) return;
1442
+ setProjection(s.proj === 'ortho' ? 'ortho' : 'persp'); // switch the active camera first (no-op if unchanged)
1443
+ controls.target.fromArray(s.target);
1444
+ camera.position.fromArray(s.pos);
1445
+ if (camera === orthoCam) { if (s.orthoH > 0) orthoBaseH = s.orthoH; orthoCam.zoom = s.zoom > 0 ? s.zoom : 1; reframeOrtho(); }
1446
+ // keep near/far clearing the whole scene from the restored spot (mirror fitCamera's sizing so a far-out
1447
+ // saved view doesn't clip the model behind the far plane).
1448
+ const sph = sceneBox.getBoundingSphere(new THREE.Sphere());
1449
+ const dist = camera.position.distanceTo(controls.target) || 1;
1450
+ const near = Math.max(dist / 2000, 0.5);
1451
+ const far = Math.max(dist + (sph.radius || dist) * 4, camera.position.distanceTo(sph.center) + (sph.radius || dist)) * 1.02;
1452
+ perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
1453
+ orthoCam.near = near; orthoCam.far = far; orthoCam.updateProjectionMatrix();
1454
+ controls.update();
1455
+ }
1364
1456
 
1365
1457
  function frameAll() { fitCamera(sceneBox); }
1366
1458
  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] };
@@ -2467,6 +2559,7 @@ function startEndpointGrab(ep, e) {
2467
2559
  epDrag: true, id: ep.id, end: ep.end, ppf,
2468
2560
  planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
2469
2561
  candidates: allCandidates(ep.id).filter((c) => candAllowed3d(c.type)), // member + grid snap targets, filtered by the running-snaps (⋯ menu → Snapping)
2562
+ orig: g.line[ep.end].slice(), // where this end started — the "moved" distance reference (the dragged end stays on planeZ, so orig[2] === planeZ)
2470
2563
  newPt: [g.line[ep.end][0], g.line[ep.end][1]],
2471
2564
  };
2472
2565
  dragEp = { id: ep.id, end: ep.end };
@@ -2475,18 +2568,24 @@ function startEndpointGrab(ep, e) {
2475
2568
  function onMoveEndpoint(e) {
2476
2569
  if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
2477
2570
  dragging = pending;
2478
- const hit = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!hit) return;
2571
+ const hit = rayToPlane(e.clientX, e.clientY, pending.planeZ);
2572
+ if (!hit) { epMovedLine.visible = false; epPreview.visible = false; marker.visible = false; readout.style.display = 'none'; return; } // ray parallel to the work-plane (edge-on) or canvas collapsed — park the overlay + readout, don't freeze them at a stale point
2479
2573
  const r = snapPoint([hit[0], hit[1], pending.planeZ], pending.candidates, toScreen, SNAP_TOL_PX);
2480
2574
  const np = r.candidate ? r.snapped : [hit[0], hit[1], pending.planeZ];
2481
2575
  pending.newPt = [np[0], np[1]];
2482
2576
  const dot = epGroup.children.find((c) => c.userData.epId === pending.id && c.userData.epEnd === pending.end);
2483
2577
  if (dot) dot.position.set(np[0], np[1], pending.planeZ);
2484
- const f = pending.fixed;
2578
+ const f = pending.fixed, o = pending.orig;
2485
2579
  epPreview.geometry.setFromPoints([new THREE.Vector3(f[0], f[1], f[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
2486
2580
  epPreview.visible = true;
2581
+ // "moved" leader: cyan dashed line from where the end started (o) to the new point — the 3D echo of the 2D Move rubber
2582
+ epMovedLine.geometry.setFromPoints([new THREE.Vector3(o[0], o[1], o[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
2583
+ epMovedLine.computeLineDistances(); epMovedLine.visible = true;
2487
2584
  if (r.candidate) showMarker(np, r.candidate.type); else marker.visible = false;
2488
- const len = Math.hypot(np[0] - f[0], np[1] - f[1]) / FT_MM;
2489
- readout._dist.textContent = len.toFixed(2) + ' ft'; readout._type.textContent = r.candidate ? ' · ' + r.candidate.type : '';
2585
+ const len = Math.hypot(np[0] - f[0], np[1] - f[1], pending.planeZ - f[2]) / FT_MM; // true 3D member length (matches the inspector's Length)
2586
+ const moved = Math.hypot(np[0] - o[0], np[1] - o[1]) / FT_MM; // in-plane distance the end has travelled ("same as Move")
2587
+ readout._dist.textContent = len.toFixed(2) + ' ft'; // primary: the member's live length
2588
+ readout._type.textContent = ' · moved ' + moved.toFixed(2) + ' ft' + (r.candidate ? ' · ' + r.candidate.type : ''); // secondary: how far the end moved (+ snap)
2490
2589
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
2491
2590
  }
2492
2591
 
@@ -2599,7 +2698,7 @@ function connsInRect(x0, y0, x1, y1, windowMode) {
2599
2698
 
2600
2699
  function onUp(e) {
2601
2700
  if (e.button === 2) rightDownXY = null; // end the click-vs-drag test (rightMoved keeps the verdict for the contextmenu that follows)
2602
- if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; clearCopyGhost(); dragEp = null; // always clear overlays
2701
+ if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; if (epMovedLine) epMovedLine.visible = false; clearCopyGhost(); dragEp = null; // always clear overlays
2603
2702
  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)
2604
2703
  const bs = boxSel; boxSel = null;
2605
2704
  if (bs) { // empty-space gesture: drag = box-select, click = clear selection
@@ -2792,6 +2891,8 @@ function dispose() {
2792
2891
  memberLabelHost = null; memberLabelPool.length = 0; labelsOnFlag = false; insertMode = false; insertPending = null;
2793
2892
  if (propLabelHost && propLabelHost.parentNode) propLabelHost.parentNode.removeChild(propLabelHost);
2794
2893
  propLabelHost = null; propLabelPool.length = 0; propLabelSpec = null;
2894
+ if (selLenLabelHost && selLenLabelHost.parentNode) selLenLabelHost.parentNode.removeChild(selLenLabelHost);
2895
+ selLenLabelHost = null; selLenLabelPool.length = 0; selLenLabelSpec = null;
2795
2896
  for (const w of [cube, triad]) { // both mini-widgets own a WebGL context — leak one and re-init eventually hits the browser's context cap
2796
2897
  if (!w) continue;
2797
2898
  w.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
@@ -2803,8 +2904,8 @@ function dispose() {
2803
2904
  if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines
2804
2905
  overlayLabelPool.length = 0; dimParts = [];
2805
2906
  if (dimPreviewLine && scene) scene.remove(dimPreviewLine);
2806
- for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
2807
- epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
2907
+ for (const o of [epRing, epPreview, epMovedLine, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
2908
+ epGeom = epMatStart = epMatEnd = epRing = epPreview = epMovedLine = refGroup = null;
2808
2909
  dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
2809
2910
  clearStructGrid();
2810
2911
  for (const tex of gridTexCache.values()) tex.dispose();
@@ -2878,12 +2979,16 @@ window.Steel3DView = {
2878
2979
  setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2879
2980
  syncMemberLabels, // editor calls after a mark/id edit to refresh labels
2880
2981
  setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
2982
+ setSelLenLabels, // on-select member-length labels: editor pushes [{id,text}] (⋯ Display "Show member length")
2881
2983
  propLabelTexts: () => propLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent), // visible property-label chips — for tests
2984
+ selLenLabelTexts: () => selLenLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent), // visible on-select length chips — for tests
2985
+ epScreen: (id) => { const m = members().find((x) => x.id === id); if (!m || !canvasEl) return null; const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm()); const rect = canvasEl.getBoundingClientRect(); const proj = (p) => { const v = new THREE.Vector3(p[0], p[1], p[2]).project(camera); return { x: rect.left + (v.x * 0.5 + 0.5) * rect.width, y: rect.top + (-v.y * 0.5 + 0.5) * rect.height }; }; return { a: proj(g.line[0]), b: proj(g.line[1]) }; }, // project a member's endpoints to viewport px — test probe for endpoint-drag tests
2882
2986
  refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
2883
2987
  gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
2884
2988
  toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
2885
2989
  setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
2886
2990
  isolateSelected, clearIsolation, isIsolated,
2991
+ cameraState, setCameraState, objectsPaneState, applyObjectsPaneState, // saved-view snapshot surface (Views Organizer)
2887
2992
  workAreaSetAll, workAreaFromSelection, workAreaToggle, workAreaSetWhole, clearWorkArea, workAreaState,
2888
2993
  armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
2889
2994
  cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
@@ -125,12 +125,13 @@
125
125
  #moreMenu #m3dInsert.on{color:var(--brand)}
126
126
  #moreMenu #m3dInsertMenu{left:auto;right:calc(100% + 4px);top:0}
127
127
  body:not(.v3d) #moreMenu #insWrap{display:none} /* Insert detail places into the 3D scene — hide it in 2D (needs 2 ids to beat the .m3dwrap.ins-in-menu display:block) */
128
- #moreMenu button.msnap{display:flex;align-items:center;gap:0}
129
- #moreMenu button.msnap.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
128
+ #moreMenu button.msnap,#moreMenu button.dtog{display:flex;align-items:center;gap:0} /* .dtog = the Display show/hide toggles, same slider switch as Snapping (.msnap) */
129
+ #moreMenu button.msnap.on,#moreMenu button.dtog.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
130
130
  #moreMenu .mck,.cmmenu .mck,.m3dmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows, the Move/Copy → Drag-to-move/copy toggle, and the Work-area toggles */
131
131
  #moreMenu .mck::after,.cmmenu .mck::after,.m3dmenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
132
- #moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
133
- #moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
132
+ #moreMenu button.msnap.on .mck,#moreMenu button.dtog.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
133
+ #moreMenu button.msnap.on .mck::after,#moreMenu button.dtog.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
134
+ #moreMenu button.dtog:disabled{opacity:.45;cursor:default} /* e.g. the grid toggle when the model has no grid */
134
135
  .m3dmenu button.wtog{display:flex;align-items:center;justify-content:flex-start;gap:0}
135
136
  .m3dmenu button.wtog.on{color:var(--text)} /* the slider carries the on-state — don't also brand the label text */
136
137
  #moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
@@ -206,6 +207,9 @@
206
207
  .cmarrow{fill:#22d3ee;pointer-events:none}
207
208
  .cmchip{fill:var(--panel);stroke:#22d3ee;opacity:.85;pointer-events:none} /* .85: reads "in progress", full opacity stays reserved for committed dims */
208
209
  .cmtx{fill:var(--text);text-anchor:middle;dominant-baseline:central;opacity:.85;pointer-events:none;font-family:system-ui}
210
+ .mlenlabels{pointer-events:none}
211
+ rect.mlenchip{fill:var(--panel);stroke:#22d3ee;stroke-width:1;vector-effect:non-scaling-stroke;opacity:.9} /* on-select member-length chips: a cyan dimension read (NOT the brand-blue property chips) */
212
+ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-family:system-ui;opacity:.9}
209
213
  #cmHud{position:fixed;z-index:70;display:none;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--brand);border-radius:8px;padding:6px 8px;box-shadow:0 6px 20px rgba(0,0,0,.55);font:12px system-ui;color:var(--mut)}
210
214
  #cmHud.err{border-color:#fca5a5}
211
215
  #cmHud input{width:110px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 7px;font:12px system-ui}
@@ -399,7 +403,24 @@
399
403
  #m3dLegend .lrow.clip .lx{margin-left:0} /* the label's flex:1 already pushes On/Off + × to the right */
400
404
  #m3dLegend .lrow.clip.sel{border-left:2px solid var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
401
405
  #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 */
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}
406
+ /* Panel shell (Objects | Views | Favourites tabs). The OUTER panel no longer scrolls — it caps the height and
407
+ lays out [tab strip][active body]; each tab body is its own scroll container (themed by the global * rule). */
408
+ #m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;max-height:52vh;min-width:210px;max-width:min(340px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:0;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
409
+ /* Tab strip — the .seg-group segmented look, full-width across the panel top; sticky so it never scrolls away. */
410
+ #m3dTabs{display:flex;flex:none;border-bottom:1px solid var(--line);padding:7px 8px 6px;gap:0}
411
+ #m3dTabs .m3dtab{flex:1;background:transparent;border:1px solid #475569;border-right-width:0;border-radius:0;color:var(--mut);font-size:11px;padding:5px 4px;cursor:pointer;box-shadow:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
412
+ #m3dTabs .m3dtab:first-child{border-top-left-radius:6px;border-bottom-left-radius:6px}
413
+ #m3dTabs .m3dtab:last-child{border-right-width:1px;border-top-right-radius:6px;border-bottom-right-radius:6px}
414
+ #m3dTabs .m3dtab:hover{color:var(--text);background:#334155}
415
+ #m3dTabs .m3dtab.on{background:var(--brand);border-color:var(--brand);color:#fff}
416
+ /* Tab bodies — only the active one shows; each scrolls independently. The Objects body carries the legend's own padding
417
+ (its children own their spacing), Views/Favourites get uniform padding. gap:1px keeps the old dense legend rhythm. */
418
+ #m3dLegend .m3dbody{display:none;flex-direction:column;gap:1px;overflow:auto;min-height:0}
419
+ #m3dLegend .m3dbody.on{display:flex;flex:1 1 auto} /* the active body fills the capped panel and scrolls (min-height:0 lets it shrink below content) */
420
+ #m3dLegendBody{padding:8px 10px}
421
+ #m3dViewsBody,#m3dFavBody{padding:8px 10px}
422
+ /* Favourites placeholder (a later slice wires its content). */
423
+ #m3dFavBody .favsoon{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
403
424
  #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 */
404
425
  #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 */
405
426
  #m3dLegend .lrow:hover{background:#33415580}
@@ -453,6 +474,58 @@
453
474
  #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
475
  #m3dLegend .lreset.show{display:flex}
455
476
  #m3dLegend .lreset:hover{border-color:var(--brand);background:#1a2740}
477
+ /* ── Views tab (Views Organizer) ─────────────────────────────────────────────────────────────────────────── */
478
+ /* Header row: primary "Save current view" split button + a 🔍 search toggle, all on one line. */
479
+ #vwHeadRow{display:flex;align-items:stretch;gap:6px;margin-bottom:6px;flex:none}
480
+ #vwSaveSplit{display:inline-flex;flex:1;min-width:0;border:1px solid var(--brand);border-radius:6px;overflow:hidden}
481
+ #vwSaveSplit button{border:0;border-radius:0;background:var(--brand);color:#fff;font-size:12px;box-shadow:none;padding:5px 8px}
482
+ #vwSaveSplit button:hover:not(:disabled){background:#2f6fe0}
483
+ #vwSaveBtn{flex:1;min-width:0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
484
+ #vwSaveMore{flex:none;width:24px;padding:0;border-left:1px solid rgba(255,255,255,.28)!important}
485
+ #vwSaveSplit button:disabled{opacity:.45;cursor:default;background:var(--brand)}
486
+ /* the split, disabled at the cap — a muted, non-branded look so it reads clearly as unavailable */
487
+ #vwSaveSplit.capped{border-color:var(--line)}
488
+ #vwSaveSplit.capped button{background:#334155;color:var(--mut)}
489
+ #vwSaveSplit.capped #vwSaveMore{border-left-color:rgba(148,163,184,.28)!important}
490
+ #vwSearchTog{flex:none;width:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;color:var(--mut)}
491
+ #vwSearchTog.on{color:var(--brand);border-color:var(--brand)}
492
+ #vwSearchTog svg{display:block}
493
+ /* the cap counter ("8/10") + the disabled reason line — visible, quiet, inline (not tooltip-only) */
494
+ #vwCount{flex:none;font-size:10px;color:var(--mut);text-align:right;font-variant-numeric:tabular-nums;margin-bottom:4px;display:none}
495
+ #vwCap{flex:none;font-size:10px;color:#fbbf24;line-height:1.4;margin-bottom:6px;display:none}
496
+ #vwCap.show{display:block} #vwCount.show{display:block}
497
+ /* search box — reuses the objects-list .lsearch recipe; hidden until the 🔍 toggle opens it */
498
+ #vwSearch{display:none;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}
499
+ #vwSearch.show{display:flex}
500
+ #vwSearch:focus-within{border-color:var(--brand)}
501
+ #vwSearch .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
502
+ #vwSearch 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}
503
+ #vwSearch input::placeholder{color:var(--mut)}
504
+ #vwSearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden}
505
+ #vwSearch.has .lsx{visibility:visible}
506
+ #vwSearch .lsx:hover{color:#fecaca;background:#7f1d1d}
507
+ /* a view row — mirrors the objects-list .lrow anatomy: [drag] [glyph] name … [✕][⋯]. Active = brand left bar + tint. */
508
+ #m3dViewsBody .vwrow{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:5px;cursor:pointer;user-select:none;white-space:nowrap}
509
+ #m3dViewsBody .vwrow:hover{background:#33415580}
510
+ #m3dViewsBody .vwrow.active{box-shadow:inset 2px 0 0 var(--brand);background:rgba(59,130,246,.16);padding-left:2px} /* shift content 2px so the inset brand bar doesn't crowd the leading drag-handle (matches the .lrow.clip.sel precedent) */
511
+ #m3dViewsBody .vwrow.active .vwname{color:var(--text)}
512
+ #m3dViewsBody .vwrow.flash{background:rgba(59,130,246,.12)}
513
+ #m3dViewsBody .vwrow.drop-target{outline:1px solid var(--brand);background:rgba(59,130,246,.18)}
514
+ #m3dViewsBody .vwrow .drag-handle{font-size:11px;color:var(--mut);cursor:grab;opacity:0;transition:opacity .1s;flex:none;padding:0 1px;line-height:1}
515
+ #m3dViewsBody .vwrow:hover .drag-handle{opacity:1}
516
+ #m3dViewsBody .vwglyph{flex:none;width:14px;text-align:center;color:var(--mut);font-size:12px;line-height:1} /* orientation glyph — a recognition aid, muted */
517
+ #m3dViewsBody .vwname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text)}
518
+ #m3dViewsBody .vwrow input.vwedit{flex:1;min-width:0;font:12px system-ui;background:#0b1220;color:var(--text);border:1px solid var(--brand);border-radius:4px;padding:0 4px;outline:none}
519
+ #m3dViewsBody .vwrow .vx{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;visibility:hidden;font-size:13px;line-height:1}
520
+ #m3dViewsBody .vwrow:hover .vx{visibility:visible}
521
+ #m3dViewsBody .vwrow .vx:hover{color:#fecaca;background:#7f1d1d}
522
+ #m3dViewsBody .vwrow .vdots{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;font-size:14px;line-height:1;cursor:pointer}
523
+ #m3dViewsBody .vwrow .vdots:hover{color:var(--text);background:#334155}
524
+ #m3dViewsBody .vwempty{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
525
+ #m3dViewsBody .vwnores{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
526
+ #m3dViewsBody .vwnores .pilllink{margin-left:4px}
527
+ /* the per-row ⋯ popup — reuses the .m3dmenu skin; positioned at the cursor (fixed) */
528
+ #vwRowMenu{position:fixed;left:0;top:0;z-index:45;min-width:160px}
456
529
  #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 */
457
530
  /* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
458
531
  (pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
@@ -519,9 +592,10 @@
519
592
  </div>
520
593
  <button class=msec-hdr data-sec=display aria-expanded=false data-tip="Show/hide plan layers, and edit grid lines">Display<span class=chev aria-hidden=true>▸</span></button>
521
594
  <div class=msec-body>
522
- <button id=dimToggleB data-tip="Show or hide all placed dimensions on the plan">Hide dimensions</button>
523
- <button id=calloutToggleB data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
524
- <button id=gridToggleB data-tip="Show or hide the grid lines in 2D and 3D">Hide grid</button>
595
+ <button id=dimToggleB class=dtog role=menuitemcheckbox aria-checked=true data-tip="Show or hide all placed dimensions on the plan"><span class=mck aria-hidden=true></span>Dimensions</button>
596
+ <button id=calloutToggleB class=dtog role=menuitemcheckbox aria-checked=true data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan"><span class=mck aria-hidden=true></span>Callouts</button>
597
+ <button id=gridToggleB class=dtog role=menuitemcheckbox aria-checked=false data-tip="Show or hide the grid lines in 2D and 3D"><span class=mck aria-hidden=true></span>Grid lines</button>
598
+ <button id=lenToggleB class=dtog role=menuitemcheckbox aria-checked=false data-tip="Show each selected member's length on the canvas"><span class=mck aria-hidden=true></span>Member length</button>
525
599
  <button id=gridEditB data-tip="Grid lines — a plan reference with structural bay spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
526
600
  </div>
527
601
  <button class=msec-hdr data-sec=detailing aria-expanded=false data-tip="Connection details, plates, frames, and inserted detail images">Detailing<span class=chev aria-hidden=true>▸</span></button>
@@ -585,6 +659,7 @@
585
659
  </div>
586
660
  </div>
587
661
  <button id=m3dIso data-tip="Isolate selected — hide everything else (Esc to exit)" style="display:none">Isolate</button>
662
+ <button id=m3dViewsBtn data-tip="Saved views — save, switch, and manage named viewpoints">Views</button>
588
663
  <span class=tb-sep></span>
589
664
  <!-- Display toggles: reference lines + mark labels (grouped into a menu, like Plane / Work area).
590
665
  The Dimension tool moved to the header so it lives in one place across 2D and 3D. -->
@@ -633,7 +708,16 @@
633
708
  </div>
634
709
  </div>
635
710
  </div>
636
- <div id=m3dLegend></div>
711
+ <div id=m3dLegend>
712
+ <div id=m3dTabs role=tablist aria-label="3D panel tabs">
713
+ <button class=m3dtab role=tab data-tab=objects aria-selected=true data-tip="Objects — show / hide, isolate, and search the model's parts">Objects</button>
714
+ <button class=m3dtab role=tab data-tab=views aria-selected=false data-tip="Saved views — save, switch, and manage named viewpoints">Views</button>
715
+ <button class=m3dtab role=tab data-tab=fav aria-selected=false data-tip="Favourite connections — save and re-apply detailed connections">Favourites</button>
716
+ </div>
717
+ <div id=m3dLegendBody class="m3dbody on" role=tabpanel aria-label=Objects></div>
718
+ <div id=m3dViewsBody class=m3dbody role=tabpanel aria-label=Views></div>
719
+ <div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites><div class=favsoon>Favourite connections — coming soon</div></div>
720
+ </div>
637
721
  <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
638
722
  <div id=m3dAxes></div>
639
723
  <div id=zoombar>
@@ -785,6 +869,7 @@ async function boot() {
785
869
  if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
786
870
  if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
787
871
  if(!C.dim_overlays||typeof C.dim_overlays!=='object'||Array.isArray(C.dim_overlays))C.dim_overlays={bolt_pitch:true,edge_clearance:true,cope_size:true,base_plate:true,anchor_depth:true}; // model-global legend DIMENSIONS toggles — all on by default
872
+ if(!Array.isArray(C.views))C.views=[]; // model-global saved views (Views Organizer): ensure an array in boot()'s OUTER scope (like dims3d/detail_placements above); the full sanitizeViews() runs inside main() where that helper lives (mirrors prop_labels)
788
873
  main(); // (C.prop_labels is normalised inside main(), right after the PROP_DEFS/sanitizePropLabels registry is defined — see ~"normalise the contract's incoming value" — since that helper isn't in scope out here in boot())
789
874
  // SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
790
875
  // We open our own EventSource to the same /api/events endpoint as the main app.
@@ -864,6 +949,7 @@ let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
864
949
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
865
950
  let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
866
951
  let calloutsVisible=true; // Workstream B: show/hide the clickable callout bubbles (details + section/elevation/detail-ref) on the plan in select mode
952
+ let showSelLen=(()=>{try{return localStorage.getItem('steel:selLen:v1')==='1';}catch(_){return false;}})(); // ⋯ Display → Show member length: paint each selected member's length on the canvas (opt-in, default off)
867
953
  let gridMode=false, gridPick=false; // Grid lines: panel-takeover editing mode + armed pick-origin click
868
954
  let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
869
955
  let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
@@ -1020,8 +1106,14 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
1020
1106
  else if(state==='dirty'){el.classList.add('dirty');el.textContent='Saving…';}
1021
1107
  else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
1022
1108
  else el.textContent=msg||'Auto-save on';}
1109
+ // Saved views (Views Organizer) — normalise an incoming array to clean, known-key entries. Robust to a
1110
+ // partial/stale server or localStorage draft (same discipline as dim_overlays): drops non-objects, mints a
1111
+ // missing id/name, clamps the display mode/projection to known values, keeps the opaque camera/clips/objects
1112
+ // snapshots as-is (activateView applies each defensively — a malformed sub-snapshot is caught, not fatal —
1113
+ // since the engine's setClipState does NOT re-sanitise its input), and caps the list at 10.
1114
+ function sanitizeViews(arr){if(!Array.isArray(arr))return [];const out=[];for(const v of arr){if(!v||typeof v!=='object')continue;const id=(typeof v.id==='string'&&v.id)?v.id:('v'+Math.random().toString(36).slice(2,9));const name=(typeof v.name==='string'&&v.name.trim())?v.name.slice(0,80):'View';out.push({v:1,id,name,order:typeof v.order==='number'?v.order:out.length,camera:(v.camera&&typeof v.camera==='object')?v.camera:null,projection:v.projection==='ortho'?'ortho':'persp',mode:['solid','wire','xray'].includes(v.mode)?v.mode:'solid',clips:(v.clips&&typeof v.clips==='object')?v.clips:null,objects:(v.objects&&typeof v.objects==='object')?v.objects:null});if(out.length>=10)break;}return out;}
1023
1115
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
1024
- custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, prop_labels:C.prop_labels, joints:C.joints, detail_placements:C.detail_placements,
1116
+ custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, prop_labels:C.prop_labels, joints:C.joints, detail_placements:C.detail_placements, views:C.views,
1025
1117
  plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null,grid:p.grid||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
1026
1118
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
1027
1119
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
@@ -1057,6 +1149,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
1057
1149
  if(d.dim_overlays&&typeof d.dim_overlays==='object'&&!Array.isArray(d.dim_overlays)){const o=d.dim_overlays;C.dim_overlays={bolt_pitch:o.bolt_pitch!==false,edge_clearance:o.edge_clearance!==false,cope_size:o.cope_size!==false,base_plate:o.base_plate!==false,anchor_depth:o.anchor_depth!==false};} // restore the legend DIMENSIONS toggles — sanitised to the known boolean keys (a corrupt/partial draft can't desync the legend from what's drawn; on unless explicitly false)
1058
1150
  if('prop_labels' in d)C.prop_labels=sanitizePropLabels(d.prop_labels); // restore canvas property-label display state (sanitised to known keys/placement)
1059
1151
  if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
1152
+ if(Array.isArray(d.views))C.views=sanitizeViews(d.views); // restore saved views (Views Organizer) from the local draft — sanitised to clean, capped entries
1060
1153
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
1061
1154
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
1062
1155
  function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
@@ -1237,7 +1330,7 @@ function gridDefaultOrigin(){const xs=[],ys=[];for(const m of ((P&&P.members)||[
1237
1330
  if(xs.length)return [Math.min(...xs),Math.max(...ys)]; // bottom-left of the steel
1238
1331
  return [(typeof X0==='number'?X0:0)+50,(typeof Y1==='number'?Y1:300)-50];} // empty plan → near the sheet corner
1239
1332
  function refresh3DGrid(){if(view3dReady&&window.Steel3DView&&window.Steel3DView.refreshGrid)window.Steel3DView.refreshGrid();}
1240
- function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.textContent=gridOn()?'Hide grid':'Show grid';}
1333
+ function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.classList.toggle('on',gridOn());b.setAttribute('aria-checked',String(gridOn()));}
1241
1334
  // ONE visibility switch, THREE surfaces: the grid panel's checkbox, the 3D legend's "Grid lines" row,
1242
1335
  // and the ⋯ menu item — all call this. grid.on is contract data, so the flip is undoable like every
1243
1336
  // other grid operation.
@@ -1499,8 +1592,9 @@ function render(){
1499
1592
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
1500
1593
  s+=renderDims();
1501
1594
  s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
1595
+ s+=renderSelLenLabels(); // on-select member-length chips (2D), gated by the ⋯ Display "Show member length" toggle
1502
1596
  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)
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();
1597
+ 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(); updLenToggle(); updDimToggle(); updCalloutToggle();
1504
1598
  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)
1505
1599
  try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
1506
1600
  syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
@@ -1530,6 +1624,9 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
1530
1624
  r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.ax)-w/2);r.setAttribute('y',cy-h/2);});
1531
1625
  plG.querySelectorAll('text.pltx').forEach(t=>{const show=(+t.dataset.mlen)*zoom>=PLABEL_MIN_PX;t.style.display=show?'':'none';t.setAttribute('y',(+t.dataset.ay)+(+t.dataset.off)/zoom);t.style.fontSize=(11/zoom)+'px';});
1532
1626
  propLabelsHidden=hid;updatePropHint();}} // hid counts hidden chip ROWS (>0 ⇒ note shows) — the note only needs the boolean
1627
+ {const mlG=svg.querySelector('g.mlenlabels');if(mlG){ // on-select length chips: same zoom-rescale + threshold as the property chips
1628
+ mlG.querySelectorAll('rect.mlenchip').forEach(r=>{const w=(+r.dataset.tw*6.4+12)/zoom,h=15/zoom,cx=(+r.dataset.mx)+(+r.dataset.nx)*(+r.dataset.base)/zoom,cy=(+r.dataset.my)+(+r.dataset.ny)*(+r.dataset.base)/zoom,show=(+r.dataset.mlen)*zoom>=PLABEL_MIN_PX;r.style.display=show?'':'none';r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',cx-w/2);r.setAttribute('y',cy-h/2);});
1629
+ mlG.querySelectorAll('text.mlentx').forEach(t=>{const show=(+t.dataset.mlen)*zoom>=PLABEL_MIN_PX;t.style.display=show?'':'none';t.setAttribute('x',(+t.dataset.mx)+(+t.dataset.nx)*(+t.dataset.base)/zoom);t.setAttribute('y',(+t.dataset.my)+(+t.dataset.ny)*(+t.dataset.base)/zoom);t.style.fontSize=(11/zoom)+'px';});}}
1533
1630
  const cg=svg.querySelector('g.csglyph');if(cg&&P.frame)cg.outerHTML=axisGlyphSvg(P.frame.o,P.frame.u,false);} // glyph is sized in 1/zoom → regenerate on zoom (like the dim chips)
1534
1631
  function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
1535
1632
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
@@ -2145,6 +2242,7 @@ function sanitizePropLabels(x){const o=(x&&typeof x==='object'&&!Array.isArray(x
2145
2242
  selected_only:o.selected_only===true,
2146
2243
  ids:Array.isArray(o.ids)?o.ids.filter(v=>typeof v==='string'):[]};}
2147
2244
  C.prop_labels=sanitizePropLabels(C.prop_labels); // normalise the contract's incoming value now (PROP_KEYS is initialised above; runs before main's bootstrap render + restoreSaved override)
2245
+ C.views=sanitizeViews(C.views); // normalise the incoming saved views now — inside main() where sanitizeViews is in scope (boot() only guaranteed an array); runs before the bootstrap render + restoreSaved override, same pattern as prop_labels above
2148
2246
  // the "Label: value" lines a member contributes for the currently-checked props (registry order; skips N/A + empty)
2149
2247
  function propLabelLinesFor(m){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];ensureMeta(m);
2150
2248
  const out=[];for(const def of PROP_DEFS){if(!pl.props.includes(def.key))continue;const raw=def.get(m);if(raw===undefined)continue;const t=def.fmt(raw);if(t==='')continue;out.push(def.label+': '+t);}return out;}
@@ -2165,6 +2263,23 @@ let propLabelsHidden=0;
2165
2263
  // A member's labels hide when its on-screen length < this (px) — the density guard (§5.5). Kept as one
2166
2264
  // constant so renderPropLabels (initial paint) and updateBadges (live on zoom) agree.
2167
2265
  const PLABEL_MIN_PX=24;
2266
+ // On-select member-length chips (2D): when the ⋯ Display "Show member length" toggle is on, paint each selected
2267
+ // member's true 3D length at its midpoint, nudged perpendicular off the line (fixed relative to member direction).
2268
+ // Cyan dimension chips; the member being end-dragged is skipped (its live overlay covers it); coincident midpoints
2269
+ // fan out along the perpendicular so duplicate/overlapping members stay readable. Rescaled live by updateBadges.
2270
+ function renderSelLenLabels(){if(!showSelLen)return '';const selM=selArr();if(!selM.length)return '';
2271
+ const drg=(drag&&drag.type==='end')?drag.id:null,grp={};
2272
+ for(const m of selM){if(m.id===drg)continue;const a=m.wp&&m.wp[0],b=m.wp&&m.wp[1];if(!a||!b)continue;
2273
+ const mid=[(a[0]+b[0])/2,(a[1]+b[1])/2],k=Math.round(mid[0]/8)+','+Math.round(mid[1]/8);(grp[k]=grp[k]||[]).push({m,a,b,mid});}
2274
+ let s='';
2275
+ for(const k in grp){const arr=grp[k],n=arr.length;arr.forEach((it,j)=>{const {m,a,b,mid}=it;
2276
+ const lenFt=_lenFt(m);if(!(lenFt>0)||!isFinite(lenFt))return; // skip degenerate/NaN geometry — never paint an empty "len " chip
2277
+ const dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1,nx=-dy/dl,ny=dx/dl,base=18+(j-(n-1)/2)*16,mlen=len(a,b);
2278
+ const txt='len '+fmtFtIn(lenFt*12),tw=txt.length,w=(tw*6.4+12)/zoom,hh=15/zoom,cx=mid[0]+nx*base/zoom,cy=mid[1]+ny*base/zoom;
2279
+ const show=mlen*zoom>=PLABEL_MIN_PX,hide=show?'':';display:none',dd=`data-mx="${mid[0]}" data-my="${mid[1]}" data-nx="${nx}" data-ny="${ny}" data-base="${base}" data-mlen="${mlen}"`;
2280
+ s+=`<rect class=mlenchip ${dd} data-tw="${tw}" x="${cx-w/2}" y="${cy-hh/2}" width="${w}" height="${hh}" rx="${3/zoom}" style="${hide}"/>`
2281
+ +`<text class=mlentx ${dd} x="${cx}" y="${cy}" style="font-size:${11/zoom}px${hide}">${esc(txt)}</text>`;});}
2282
+ return s?`<g class="mlenlabels">${s}</g>`:'';}
2168
2283
  function renderPropLabels(){const pl=C.prop_labels;propLabelsHidden=0;if(!pl||!pl.props.length)return '';
2169
2284
  const ms=propLabelMembers();if(!ms.length)return '';
2170
2285
  let hidden=0,s='';
@@ -2325,7 +2440,9 @@ function closePropPop(force){const el=document.getElementById('propPop');if(!el)
2325
2440
  const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
2326
2441
  document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
2327
2442
  // after every render the checked labels + the popup rows stay in sync with the (possibly changed) selection/geometry
2328
- function syncPropLabelsAfterRender(){updatePropHint();refreshPropLabels3d();if(propPopOpen())renderPropPop();}
2443
+ function refreshSelLen3d(){const V=window.Steel3DView;if(!V||!V.setSelLenLabels)return; // push on-select length labels to 3D (editor owns the text; 3D owns projection)
2444
+ V.setSelLenLabels(showSelLen?selArr().map(m=>{const ft=_lenFt(m);return (ft>0&&isFinite(ft))?{id:m.id,text:ft.toFixed(1)+' ft'}:null;}).filter(Boolean):null);} // drop degenerate/NaN lengths so 3D never shows "NaN ft"
2445
+ function syncPropLabelsAfterRender(){updatePropHint();refreshPropLabels3d();refreshSelLen3d();if(propPopOpen())renderPropPop();}
2329
2446
  function refreshPropLabels(){scheduleSave();render();} // render() → renderPropLabels() (2D) + syncPropLabelsAfterRender() (hint/3D/popup)
2330
2447
 
2331
2448
  // --- Tekla-style snap override (right-click): restrict snapping to ONE type for the current operation.
@@ -2522,6 +2639,24 @@ function cmRubSvg(a,c,dimmed){const L=Math.hypot(c[0]-a[0],c[1]-a[1]);if(L<1e-6)
2522
2639
  return `<line class=cmrub${dimmed?' style="opacity:.35"':''} x1="${a[0]}" y1="${a[1]}" x2="${c[0]}" y2="${c[1]}"/>`
2523
2640
  +`<path class=cmarrow${dimmed?' style="opacity:.35"':''} d="M ${c[0]} ${c[1]} L ${p1[0]} ${p1[1]} L ${p2[0]} ${p2[1]} Z"/>`
2524
2641
  +(dimmed?'':`<rect class=cmchip x="${mid[0]-cw/2/zoom}" y="${mid[1]-ch/2/zoom}" width="${cw/zoom}" height="${ch/zoom}" rx="${4/zoom}"/><text class=cmtx x="${mid[0]}" y="${mid[1]}" style="font-size:${12/zoom}px">${txt}</text>`);}
2642
+ // Live overlay while dragging a member END (drag.type==='end'): the Move-style cyan rubber from where the end
2643
+ // STARTED (orig→cursor, "moved …", primary) + the member's live LENGTH chip (anchored end→cursor, "len …",
2644
+ // recessed). Built with DOM nodes (textContent = XSS-safe) into #epPrevG; reuses cmRubSvg's cyan classes.
2645
+ function epSvgEl(tag,cls,attrs,txt){const e=document.createElementNS(SVGNS,tag);if(cls)e.setAttribute('class',cls);for(const k in attrs)e.setAttribute(k,attrs[k]);if(txt!=null)e.textContent=txt;return e;}
2646
+ function epPrevDraw(g,orig,anchor,cur,m){while(g.firstChild)g.removeChild(g.firstChild);
2647
+ const chip=(cx,cy,txt,dim)=>{const w=(txt.length*7+14)/zoom,hh=17/zoom,fs='font-size:'+(12/zoom)+'px';
2648
+ g.appendChild(epSvgEl('rect','cmchip',dim?{x:cx-w/2,y:cy-hh/2,width:w,height:hh,rx:4/zoom,style:'opacity:.6'}:{x:cx-w/2,y:cy-hh/2,width:w,height:hh,rx:4/zoom}));
2649
+ g.appendChild(epSvgEl('text','cmtx',{x:cx,y:cy,style:dim?fs+';opacity:.6':fs},txt));};
2650
+ const L=Math.hypot(cur[0]-orig[0],cur[1]-orig[1]);
2651
+ if(L>1e-6){const ux=(cur[0]-orig[0])/L,uy=(cur[1]-orig[1])/L,ah=10/zoom;
2652
+ const p1=[cur[0]-ah*ux+ah*.45*uy,cur[1]-ah*uy-ah*.45*ux],p2=[cur[0]-ah*ux-ah*.45*uy,cur[1]-ah*uy+ah*.45*ux];
2653
+ g.appendChild(epSvgEl('circle','cmrub',{fill:'none',cx:orig[0],cy:orig[1],r:epR()})); // ghost at the end's start
2654
+ g.appendChild(epSvgEl('line','cmrub',{x1:orig[0],y1:orig[1],x2:cur[0],y2:cur[1]}));
2655
+ g.appendChild(epSvgEl('path','cmarrow',{d:'M '+cur[0]+' '+cur[1]+' L '+p1[0]+' '+p1[1]+' L '+p2[0]+' '+p2[1]+' Z'}));
2656
+ chip((orig[0]+cur[0])/2,(orig[1]+cur[1])/2,'moved '+fmtFtIn(L/FT*12),false);} // primary — steered by the cursor
2657
+ const dx=cur[0]-anchor[0],dy=cur[1]-anchor[1],dl=Math.hypot(dx,dy)||1,nx=-dy/dl,ny=dx/dl,poff=18/zoom; // len chip: member midpoint, nudged perpendicular off the line
2658
+ const lenFt=_lenFt(m);if(lenFt>0&&isFinite(lenFt))chip((anchor[0]+cur[0])/2+nx*poff,(anchor[1]+cur[1])/2+ny*poff,'len '+fmtFtIn(lenFt*12),true);} // secondary — live member length, recessed (drawn last → on top); skip if degenerate
2659
+ function epPrevClear(){const g=document.getElementById('epPrevG');if(g)g.remove();}
2525
2660
  function cmClick(e){
2526
2661
  if(!selIds.size){toast('Selection is empty — '+(cmTool==='move'?'Move':'Copy')+' ended');disarmCm();render();return;} // selection drained while armed (undo/delete) — end the tool instead of collecting doomed picks
2527
2662
  const s=cmSnapAt(e);
@@ -2719,7 +2854,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
2719
2854
  if(tgt){geoMode=null;setGeo();snapEndMulti(selArr(),tgt[0],tgt[1]);} // miss (empty/own line) keeps the mode armed
2720
2855
  return;}
2721
2856
  if(t.classList.contains('handle')){const id=t.dataset.mid||[...selIds][0],m=byId(id),h=+t.dataset.h;if(!m)return;buildSnap(id);
2722
- drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
2857
+ drag={type:'end',h,id,anchor:m.wp[1-h].slice(),orig:m.wp[h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // anchor = the fixed end (length ref); orig = where this end started (moved ref)
2723
2858
  if(t.classList.contains('lblhot')){const prof=t.dataset.prof;
2724
2859
  if(picking&&pickKind==='profile'&&selIds.size===1){const id=[...selIds][0];picking=false;edit(()=>{const m=byId(id);m.profile=prof;m.rfi=(_wt(prof)==null);if(!profs.includes(prof)){profs.push(prof);profs.sort();}});return;}
2725
2860
  if(mode==='add'){addProfile=prof;const hi=document.getElementById('addProf');if(hi)hi.value=addProfile;const ph=document.getElementById('pickHint');if(ph){ph.classList.add('pick');setTimeout(()=>ph&&ph.classList.remove('pick'),450);}return;}
@@ -2823,8 +2958,9 @@ svg.addEventListener('pointermove',e=>{
2823
2958
  const m=byId(drag.id);if(!m)return;let x=p.x,y=p.y;
2824
2959
  if(e.shiftKey){[x,y]=orthoLock(drag.anchor[0],drag.anchor[1],x,y);snapClear();} // ortho endpoint — local frame when set, else screen H/V
2825
2960
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
2826
- m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
2827
- svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2961
+ m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);
2962
+ {let g=document.getElementById('epPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='epPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}epPrevDraw(g,drag.orig,drag.anchor,[x,y],m);}}); // live moved + length readout
2963
+ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();epPrevClear();
2828
2964
  if(drag.type==='gridline'){const wasBubble=drag.bubble,moved=drag.moved,pre=drag.pre;drag=null;gridReadoutHide();
2829
2965
  snapOnlyClear2d(); // a grid-line drag is one discrete operation → its single-shot snap override always reverts (grid-line drags can run while the grid panel is open, so don't gate on anyToolActive())
2830
2966
  if(!moved){if(wasBubble){setGridMode(true); // opens the panel (clears the logical selection). NO render() — replacing the bubble between the two clicks of a dbl-click would swallow the rename gesture…
@@ -2862,7 +2998,7 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2862
2998
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);
2863
2999
  if(!anyToolActive())snapOnlyClear2d(); // a select-mode drag WAS the operation → the override was single-shot
2864
3000
  drag=null;render();});
2865
- svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
3001
+ svg.addEventListener('pointercancel',()=>{epPrevClear();if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
2866
3002
  svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
2867
3003
  const at=document.elementFromPoint(e.clientX,e.clientY); // the pointer capture the drag takes retargets dblclick to the svg root — resolve the bubble by position, not e.target
2868
3004
  const gb=at&&at.closest&&at.closest('g.gridbubg');
@@ -2876,12 +3012,14 @@ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMod
2876
3012
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
2877
3013
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
2878
3014
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
2879
- function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
2880
- document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
2881
- function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
2882
- document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
3015
+ function updDimToggle(){const b=document.getElementById('dimToggleB');if(b){b.classList.toggle('on',dimsVisible);b.setAttribute('aria-checked',String(dimsVisible));}}
3016
+ document.getElementById('dimToggleB').onclick=e=>{e.stopPropagation();dimsVisible=!dimsVisible;updDimToggle();render();};
3017
+ function updLenToggle(){const b=document.getElementById('lenToggleB');if(b){b.classList.toggle('on',showSelLen);b.setAttribute('aria-checked',String(showSelLen));}}
3018
+ document.getElementById('lenToggleB').onclick=e=>{e.stopPropagation();showSelLen=!showSelLen;try{localStorage.setItem('steel:selLen:v1',showSelLen?'1':'0');}catch(_){}updLenToggle();render();}; // render() repaints the 2D chips + pushes labels to 3D
3019
+ function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b){b.classList.toggle('on',calloutsVisible);b.setAttribute('aria-checked',String(calloutsVisible));}}
3020
+ document.getElementById('calloutToggleB').onclick=e=>{e.stopPropagation();calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
2883
3021
  document.getElementById('gridEditB').onclick=()=>{setGridMode(!gridMode);render();};
2884
- document.getElementById('gridToggleB').onclick=()=>gridSetVisible(!gridOn());
3022
+ document.getElementById('gridToggleB').onclick=e=>{e.stopPropagation();gridSetVisible(!gridOn());};
2885
3023
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
2886
3024
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
2887
3025
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
@@ -2968,7 +3106,7 @@ function moreOpen(){return moreMenu.classList.contains('open');}
2968
3106
  function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
2969
3107
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
2970
3108
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
2971
- moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
3109
+ moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.dtog')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
2972
3110
  // "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
2973
3111
  // Every ⋯ section is a collapse/expand accordion (Snapping + Display + Detailing + …); state persists in localStorage.
2974
3112
  {const SEC_KEY='steelMoreSections';let openSecs;try{openSecs=new Set(JSON.parse(localStorage.getItem(SEC_KEY)||'[]'));}catch(e){openSecs=new Set();}
@@ -3291,6 +3429,230 @@ function retypeProfile(profKey,cat){if(!MTYPE_LABEL[cat])return;
3291
3429
  if(!targets.length||targets.every(m=>memberTypeOf(m)===cat))return; // already that type → no-op (no empty undo step)
3292
3430
  edit(()=>{for(const m of targets)m.memberType=cat;});
3293
3431
  setTimeout(()=>{const r=[...document.querySelectorAll('#m3dLegend .lrow[data-key]')].find(r=>r.dataset.key===profKey);if(r){r.classList.add('flash');setTimeout(()=>r.classList.remove('flash'),400);}},250);}
3432
+ // ════════════════════════════════════════════════════════════════════════════════════════════════════════════
3433
+ // Panel tabs (Objects | Views | Favourites) + the Views Organizer (Slice V1).
3434
+ // The floating panel #m3dLegend hosts three tab bodies; only the active one shows. build3DLegend renders the
3435
+ // Objects body; renderViewsTab renders the Views body from C.views[] (per-model, auto-saved via scheduleSave).
3436
+ // A saved view is an opaque, versioned snapshot: {v,id,name,order,camera,projection,mode,clips,objects}.
3437
+ // ════════════════════════════════════════════════════════════════════════════════════════════════════════════
3438
+ let legendTab=(()=>{const t=localStorage.getItem('floless.legendTab');return (t==='views'||t==='fav')?t:'objects';})();
3439
+ let legendTabsWired=false;
3440
+ function saveLegendTab(){try{localStorage.setItem('floless.legendTab',legendTab);}catch{}}
3441
+ // Switch the active tab: light the strip button, show that body, render it on demand, persist the choice.
3442
+ function setLegendTab(tab){if(tab!=='objects'&&tab!=='views'&&tab!=='fav')tab='objects';legendTab=tab;saveLegendTab();
3443
+ document.querySelectorAll('#m3dTabs .m3dtab').forEach(b=>{const on=b.dataset.tab===tab;b.classList.toggle('on',on);b.setAttribute('aria-selected',String(on));});
3444
+ const bodies={objects:'m3dLegendBody',views:'m3dViewsBody',fav:'m3dFavBody'};
3445
+ for(const [k,id] of Object.entries(bodies)){const el=document.getElementById(id);if(el)el.classList.toggle('on',k===tab);}
3446
+ if(tab==='views')renderViewsTab(); // (re)render the Views list when it becomes visible
3447
+ updateViewsBtn();
3448
+ }
3449
+ // The 3D-toolbar Views button lights (.on) while the panel is open on the Views tab.
3450
+ function updateViewsBtn(){const vb=document.getElementById('m3dViewsBtn');if(!vb)return;const p=document.getElementById('m3dLegend');vb.classList.toggle('on',!!(p&&p.style.display!=='none'&&legendTab==='views'));}
3451
+ // Show the whole panel (used on 3D entry + by the toolbar Views button); optionally force a tab.
3452
+ function showLegendPanel(tab){const p=document.getElementById('m3dLegend');if(!p)return;p.style.display='flex';setLegendTab(tab||legendTab);}
3453
+ // Wire the tab strip once (its buttons persist across build3DLegend rebuilds).
3454
+ function wireLegendTabs(){if(legendTabsWired)return;legendTabsWired=true;
3455
+ document.querySelectorAll('#m3dTabs .m3dtab').forEach(b=>b.addEventListener('click',()=>setLegendTab(b.dataset.tab)));
3456
+ }
3457
+ // ── Views state ──────────────────────────────────────────────────────────────────────────────────────────────
3458
+ const VIEWS_MAX=10;
3459
+ let viewsQuery=''; // transient Views-tab search filter — NOT persisted
3460
+ let viewsSearchOpen=false; // is the search input revealed?
3461
+ let activeViewId=null; // the view whose snapshot was last applied (persistent active affordance) — transient
3462
+ function viewsSorted(){return (C.views||[]).slice().sort((a,b)=>(a.order||0)-(b.order||0));}
3463
+ // Glyph: a small cube-face pictogram for the 6 direction presets (recognised by the view's name — the source of
3464
+ // truth that survives sanitizeViews), a generic camera otherwise. Muted, a recognition aid only.
3465
+ const VIEW_DIRS={top:1,bottom:1,front:1,back:1,left:1,right:1,iso:1};
3466
+ function viewDirOf(v){const n=((v&&v.name)||'').trim().toLowerCase();return VIEW_DIRS[n]?n:null;}
3467
+ function viewGlyph(v){const NS='http://www.w3.org/2000/svg',dir=viewDirOf(v);
3468
+ const svg=document.createElementNS(NS,'svg');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.3');svg.setAttribute('stroke-linejoin','round');svg.setAttribute('aria-hidden','true');
3469
+ if(dir){ // a small cube with the active face tinted — a direction pictogram (top/front/side highlighted)
3470
+ const cube=document.createElementNS(NS,'path');cube.setAttribute('d','M8 1.5 14 4.5 14 11 8 14.5 2 11 2 4.5 Z M8 1.5 8 8 M8 8 2 4.5 M8 8 14 4.5');cube.setAttribute('opacity','.65');svg.appendChild(cube);
3471
+ const face=document.createElementNS(NS,'path');face.setAttribute('fill','currentColor');face.setAttribute('stroke','none');face.setAttribute('opacity','.9');
3472
+ const F={top:'M8 1.5 14 4.5 8 8 2 4.5 Z',front:'M2 4.5 8 8 8 14.5 2 11 Z',back:'M8 8 14 4.5 14 11 8 14.5 Z',left:'M2 4.5 8 8 8 14.5 2 11 Z',right:'M8 8 14 4.5 14 11 8 14.5 Z',bottom:'M8 8 2 11 8 14.5 14 11 Z',iso:'M8 1.5 14 4.5 8 8 2 4.5 Z'};
3473
+ face.setAttribute('d',F[dir]||F.top);svg.appendChild(face);
3474
+ }else{ // a generic camera glyph
3475
+ const body=document.createElementNS(NS,'rect');body.setAttribute('x','2');body.setAttribute('y','5');body.setAttribute('width','12');body.setAttribute('height','8');body.setAttribute('rx','1.5');svg.appendChild(body);
3476
+ const lens=document.createElementNS(NS,'circle');lens.setAttribute('cx','8');lens.setAttribute('cy','9');lens.setAttribute('r','2.2');svg.appendChild(lens);
3477
+ const hump=document.createElementNS(NS,'path');hump.setAttribute('d','M5.5 5 6.5 3.5 9.5 3.5 10.5 5');svg.appendChild(hump);
3478
+ }
3479
+ return svg;}
3480
+ // A magnifier icon (matches the objects-list search icon) built via DOM (no innerHTML), currentColor-stroked.
3481
+ function magnifierSvg(){const NS='http://www.w3.org/2000/svg',svg=document.createElementNS(NS,'svg');
3482
+ 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');
3483
+ const cir=document.createElementNS(NS,'circle');cir.setAttribute('cx','7');cir.setAttribute('cy','7');cir.setAttribute('r','4.5');
3484
+ const lin=document.createElementNS(NS,'line');lin.setAttribute('x1','10.6');lin.setAttribute('y1','10.6');lin.setAttribute('x2','14');lin.setAttribute('y2','14');
3485
+ svg.append(cir,lin);return svg;}
3486
+ // ── Save current view + New from direction ───────────────────────────────────────────────────────────────────
3487
+ // Capture the FULL A1 snapshot from the live editor. name defaults to "View N"; a fresh id + order-at-top.
3488
+ function captureView(name){const V=window.Steel3DView;
3489
+ return {v:1,id:'v'+Math.random().toString(36).slice(2,9),name:name||('View '+((C.views||[]).length+1)),order:0,
3490
+ camera:V.cameraState?V.cameraState():null,projection:V.projection?V.projection():'persp',mode:V.mode?V.mode():'solid',
3491
+ clips:V.clipState?V.clipState():null,objects:V.objectsPaneState?V.objectsPaneState():null};}
3492
+ // Push a captured view to the TOP (order 0; bump the rest), cap-guarded, then persist + re-render + inline-rename it.
3493
+ function addView(v,{rename=true}={}){if(!Array.isArray(C.views))C.views=[];if(C.views.length>=VIEWS_MAX)return null;
3494
+ for(const x of C.views)x.order=(x.order||0)+1;v.order=0;C.views.push(v);activeViewId=v.id;scheduleSave();renderViewsTab();
3495
+ if(rename){const row=document.querySelector('#m3dViewsBody .vwrow[data-id="'+v.id+'"]');if(row)startViewRename(v,row);}
3496
+ return v;}
3497
+ function saveCurrentView(){if((C.views||[]).length>=VIEWS_MAX)return;addView(captureView());}
3498
+ // New from a direction preset: apply the clean preset camera, then capture (current mode; no clips/isolate baked —
3499
+ // captureView reads clipState/objectsPaneState, but from a clean preset these carry the current scene; the spec's
3500
+ // intent is a clean viewpoint, so we snapshot right after applyView which only moved the camera).
3501
+ function saveViewFromDirection(dir){const V=window.Steel3DView;if(!V||!V.applyView)return;
3502
+ const label=dir.charAt(0).toUpperCase()+dir.slice(1);
3503
+ V.applyView(dir);reflectProj();reflectMode();
3504
+ addView(captureView(label));}
3505
+ // ── Activate: apply a view's snapshot, then refresh the Objects tab so restored visibility shows ────────────────
3506
+ function activateView(v){const V=window.Steel3DView;if(!V||!v)return;
3507
+ // Apply each snapshot independently: a malformed opaque sub-snapshot (especially clips — setClipState does
3508
+ // NOT re-sanitise its input) must not throw and strand the remaining restores + the active-row marking. Each
3509
+ // failure is caught + counted so the user gets one honest toast instead of a silently half-applied view.
3510
+ let failed=0;
3511
+ const step=(fn,label)=>{try{fn();}catch(e){failed++;console.warn('view restore: '+label+' failed',e);}};
3512
+ if(v.camera&&V.setCameraState)step(()=>V.setCameraState(v.camera),'camera'); // setCameraState also restores projection
3513
+ else if(V.setProjection)step(()=>V.setProjection(v.projection==='ortho'?'ortho':'persp'),'projection');
3514
+ if(V.setDisplayMode)step(()=>V.setDisplayMode(v.mode||'solid'),'mode'); // display mode
3515
+ if(v.clips&&V.setClipState)step(()=>V.setClipState(v.clips),'clips'); // clip boxes + planes + work area
3516
+ if(v.objects&&V.applyObjectsPaneState)step(()=>V.applyObjectsPaneState(v.objects),'objects'); // objects-pane visibility/isolate
3517
+ activeViewId=v.id;
3518
+ step(()=>build3DLegend(),'legend'); // rebuild the Objects tab so its rows reflect the restored visibility
3519
+ reflectProj();reflectMode();
3520
+ renderViewsTab(); // re-mark the active row
3521
+ if(failed)toast('Some of “'+v.name+'” couldn’t be restored — the saved view may be from an older model.');
3522
+ }
3523
+ // ── Rename (inline; Enter/blur commit, Esc cancels; duplicate names auto-suffixed) ──────────────────────────────
3524
+ function uniqueViewName(name,exceptId){let base=(name||'View').trim().slice(0,80)||'View';const taken=new Set((C.views||[]).filter(v=>v.id!==exceptId).map(v=>v.name));
3525
+ if(!taken.has(base))return base;let i=2;while(taken.has(base+' '+i))i++;return base+' '+i;}
3526
+ function startViewRename(v,row){const nameEl=row.querySelector('.vwname');if(!nameEl)return;
3527
+ const inp=document.createElement('input');inp.className='vwedit';inp.value=v.name;
3528
+ nameEl.replaceWith(inp);inp.focus();inp.select();
3529
+ let done=false;
3530
+ const finish=save=>{if(done)return;done=true;
3531
+ if(save){const nm=uniqueViewName(inp.value,v.id);if(nm&&nm!==v.name){v.name=nm;scheduleSave();}}
3532
+ renderViewsTab();}; // commit or cancel → re-render restores the row (with the possibly-updated name/glyph)
3533
+ inp.addEventListener('keydown',ev=>{ev.stopPropagation();if(ev.key==='Enter'){ev.preventDefault();finish(true);}else if(ev.key==='Escape'){ev.preventDefault();finish(false);}});
3534
+ inp.addEventListener('blur',()=>finish(true));
3535
+ }
3536
+ // ── Row ⋯ menu: Rename / Duplicate / Update to current ──────────────────────────────────────────────────────────
3537
+ function updateViewToCurrent(v){const snap=captureView(v.name);snap.id=v.id;snap.order=v.order;v.camera=snap.camera;v.projection=snap.projection;v.mode=snap.mode;v.clips=snap.clips;v.objects=snap.objects;activeViewId=v.id;scheduleSave();renderViewsTab();toast('Updated “'+v.name+'” to the current view');}
3538
+ function duplicateView(v){if((C.views||[]).length>=VIEWS_MAX){toast(VIEWS_MAX+' of '+VIEWS_MAX+' views — delete one to duplicate');return;}
3539
+ const copy=JSON.parse(JSON.stringify(v));copy.id='v'+Math.random().toString(36).slice(2,9);copy.name=uniqueViewName(v.name+' copy');
3540
+ // insert right after the source in order
3541
+ for(const x of C.views)if((x.order||0)>(v.order||0))x.order=(x.order||0)+1;copy.order=(v.order||0)+1;C.views.push(copy);activeViewId=copy.id;scheduleSave();renderViewsTab();}
3542
+ function openViewRowMenu(v,anchorEl){closeViewRowMenu();
3543
+ const m=document.createElement('div');m.id='vwRowMenu';m.className='m3dmenu open';m.setAttribute('role','menu');
3544
+ const mk=(label,fn)=>{const b=document.createElement('button');b.type='button';b.textContent=label;b.setAttribute('role','menuitem');b.addEventListener('click',()=>{closeViewRowMenu();fn();});return b;};
3545
+ m.append(mk('Rename',()=>{const row=document.querySelector('#m3dViewsBody .vwrow[data-id="'+v.id+'"]');if(row)startViewRename(v,row);}),
3546
+ mk('Duplicate',()=>duplicateView(v)),
3547
+ mk('Update to current',()=>updateViewToCurrent(v)));
3548
+ document.body.appendChild(m);
3549
+ const r=anchorEl.getBoundingClientRect();const mw=m.offsetWidth||160,mh=m.offsetHeight||110;
3550
+ let x=r.right-mw,y=r.bottom+4;if(x<6)x=6;if(y+mh>innerHeight-6)y=r.top-mh-4;if(y<6)y=6;
3551
+ m.style.left=x+'px';m.style.top=y+'px';
3552
+ setTimeout(()=>document.addEventListener('mousedown',viewRowMenuOutside,true),0);
3553
+ }
3554
+ function viewRowMenuOutside(e){const m=document.getElementById('vwRowMenu');if(m&&!m.contains(e.target))closeViewRowMenu();}
3555
+ function closeViewRowMenu(){const m=document.getElementById('vwRowMenu');if(m)m.remove();document.removeEventListener('mousedown',viewRowMenuOutside,true);}
3556
+ // ── Delete (immediate + undo toast that re-inserts at the original order) ────────────────────────────────────────
3557
+ function deleteView(v){const idx=(C.views||[]).findIndex(x=>x.id===v.id);if(idx<0)return;const removed=C.views[idx];const order=removed.order;
3558
+ C.views.splice(idx,1);if(activeViewId===v.id)activeViewId=null;scheduleSave();renderViewsTab();
3559
+ // Undo takes precedence over the soft 10-cap: restoring a view the user just deleted is them reversing their
3560
+ // own action, so re-add unconditionally (a transient 11th is fine — the cap only gates NEW saves). Refusing
3561
+ // here would make the Undo affordance silently destroy data it promised to restore.
3562
+ undoToast('Deleted “'+removed.name+'”',()=>{if(!Array.isArray(C.views))C.views=[];removed.order=order;C.views.push(removed);scheduleSave();renderViewsTab();});
3563
+ }
3564
+ // ── Reorder (pointer-drag a row; the drag-handle is the initiator — same recipe as the objects-list rows) ─────────
3565
+ function wireViewRowDrag(row,v){const handle=row.querySelector('.drag-handle');if(!handle)return;
3566
+ handle.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation();
3567
+ const sx=e.clientX,sy=e.clientY;let started=false,target=null;
3568
+ const move=ev=>{if(!started){if(Math.hypot(ev.clientX-sx,ev.clientY-sy)<6)return;started=true;row._dragging=true;}
3569
+ const elp=document.elementFromPoint(ev.clientX,ev.clientY);const over=elp&&elp.closest?elp.closest('#m3dViewsBody .vwrow'):null;
3570
+ if(over!==target){if(target)target.classList.remove('drop-target');target=(over&&over!==row)?over:null;if(target)target.classList.add('drop-target');}};
3571
+ const up=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);
3572
+ if(target)target.classList.remove('drop-target');
3573
+ const dropId=started&&target?target.dataset.id:null;setTimeout(()=>{row._dragging=false;},0);
3574
+ if(dropId&&dropId!==v.id)reorderView(v.id,dropId);};
3575
+ document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);});}
3576
+ // Move `dragId` to just before `beforeId` (its slot), then re-number order 0..n so it persists densely.
3577
+ function reorderView(dragId,beforeId){const list=viewsSorted();const from=list.findIndex(v=>v.id===dragId),to=list.findIndex(v=>v.id===beforeId);if(from<0||to<0||from===to)return;
3578
+ const [moved]=list.splice(from,1);list.splice(to,0,moved);list.forEach((v,i)=>v.order=i);scheduleSave();renderViewsTab();
3579
+ setTimeout(()=>{const r=document.querySelector('#m3dViewsBody .vwrow[data-id="'+dragId+'"]');if(r){r.classList.add('flash');setTimeout(()=>r.classList.remove('flash'),400);}},0);
3580
+ }
3581
+ // ── Search (an always-present 🔍 toggle; the input opens on demand; filters by name) ──────────────────────────────
3582
+ function toggleViewsSearch(){viewsSearchOpen=!viewsSearchOpen;if(!viewsSearchOpen){viewsQuery='';}renderViewsTab();
3583
+ if(viewsSearchOpen){const inp=document.getElementById('vwSearchInput');if(inp)inp.focus();}}
3584
+ // ── Render the Views tab body from C.views[] ────────────────────────────────────────────────────────────────────
3585
+ function renderViewsTab(){const host=document.getElementById('m3dViewsBody');if(!host)return;host.replaceChildren();
3586
+ const all=viewsSorted();const atMax=all.length>=VIEWS_MAX;
3587
+ // Header row: Save-current-view split (main + ▾) · search toggle
3588
+ const head=document.createElement('div');head.id='vwHeadRow';
3589
+ const split=document.createElement('div');split.id='vwSaveSplit';if(atMax)split.classList.add('capped');
3590
+ const saveBtn=document.createElement('button');saveBtn.type='button';saveBtn.id='vwSaveBtn';saveBtn.textContent='+ Save current view';saveBtn.dataset.tip=atMax?(VIEWS_MAX+' of '+VIEWS_MAX+' views — delete one to add'):'Save the current camera, display mode, clips and object visibility as a named view';saveBtn.disabled=atMax;saveBtn.addEventListener('click',saveCurrentView);
3591
+ const moreBtn=document.createElement('button');moreBtn.type='button';moreBtn.id='vwSaveMore';moreBtn.textContent='▾';moreBtn.setAttribute('aria-haspopup','menu');moreBtn.dataset.tip='New view from a direction (Top, Front, Iso, …)';moreBtn.disabled=atMax;moreBtn.addEventListener('click',e=>{e.stopPropagation();openNewFromDirectionMenu(moreBtn);});
3592
+ split.append(saveBtn,moreBtn);
3593
+ const searchTog=document.createElement('button');searchTog.type='button';searchTog.id='vwSearchTog';searchTog.setAttribute('aria-label','Search views');searchTog.dataset.tip='Search views by name';if(viewsSearchOpen)searchTog.classList.add('on');searchTog.appendChild(magnifierSvg());searchTog.addEventListener('click',toggleViewsSearch);
3594
+ head.append(split,searchTog);host.appendChild(head);
3595
+ // Cap counter (N/10 when N≥8) + the disabled reason (at 10)
3596
+ const cnt=document.createElement('div');cnt.id='vwCount';cnt.textContent=all.length+'/'+VIEWS_MAX;if(all.length>=8)cnt.classList.add('show');host.appendChild(cnt);
3597
+ if(atMax){const cap=document.createElement('div');cap.id='vwCap';cap.className='show';cap.textContent=VIEWS_MAX+' of '+VIEWS_MAX+' views — delete one to add.';host.appendChild(cap);}
3598
+ // Search box (revealed on demand)
3599
+ if(viewsSearchOpen){const sb=document.createElement('div');sb.id='vwSearch';sb.className='show'+(viewsQuery?' has':'');
3600
+ const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');ico.appendChild(magnifierSvg());
3601
+ const inp=document.createElement('input');inp.id='vwSearchInput';inp.type='text';inp.placeholder='Search views…';inp.autocomplete='off';inp.value=viewsQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search saved views');
3602
+ const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
3603
+ inp.addEventListener('input',()=>{viewsQuery=inp.value;applyViewsFilter();});
3604
+ inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';viewsQuery='';applyViewsFilter();}else{toggleViewsSearch();}}});
3605
+ clr.addEventListener('click',()=>{if(!inp.value&&!viewsQuery)return;inp.value='';viewsQuery='';applyViewsFilter();inp.focus();});
3606
+ sb.append(ico,inp,clr);host.appendChild(sb);}
3607
+ // Body: empty state, or the list
3608
+ if(!all.length){const e=Object.assign(document.createElement('div'),{className:'vwempty'});e.textContent='No saved views yet — frame the model and Save current view.';host.appendChild(e);return;}
3609
+ const list=document.createElement('div');list.id='vwList';host.appendChild(list);
3610
+ for(const v of all)list.appendChild(buildViewRow(v));
3611
+ applyViewsFilter();
3612
+ }
3613
+ function buildViewRow(v){const row=document.createElement('div');row.className='vwrow'+(activeViewId===v.id?' active':'');row.dataset.id=v.id;
3614
+ const dh=Object.assign(document.createElement('span'),{className:'drag-handle',textContent:'⠿'});dh.dataset.tip='Drag to reorder';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);
3615
+ const glyph=Object.assign(document.createElement('span'),{className:'vwglyph'});glyph.appendChild(viewGlyph(v));row.appendChild(glyph);
3616
+ const name=Object.assign(document.createElement('span'),{className:'vwname',textContent:v.name});name.dataset.tip='Click to activate · double-click to rename';row.appendChild(name);
3617
+ const x=Object.assign(document.createElement('span'),{className:'vx',textContent:'×'});x.dataset.tip='Delete this view';x.addEventListener('click',e=>{e.stopPropagation();deleteView(v);});row.appendChild(x);
3618
+ const dots=Object.assign(document.createElement('span'),{className:'vdots',textContent:'⋯'});dots.dataset.tip='More — Rename, Duplicate, Update to current';dots.setAttribute('role','button');dots.addEventListener('click',e=>{e.stopPropagation();openViewRowMenu(v,dots);});row.appendChild(dots);
3619
+ // single-click activates (deferred so a double-click renames instead); the drag-handle swallows its own clicks
3620
+ let clickT=null;
3621
+ row.addEventListener('click',e=>{if(row._dragging)return;if(e.target===dots||e.target===x)return;clearTimeout(clickT);clickT=setTimeout(()=>{if(!row._dragging)activateView(v);},200);});
3622
+ name.addEventListener('dblclick',e=>{e.preventDefault();e.stopPropagation();clearTimeout(clickT);startViewRename(v,row);});
3623
+ wireViewRowDrag(row,v);
3624
+ return row;}
3625
+ // Filter rows by name; no-results shows a muted line + a Clear affordance (Save stays enabled — it's in the header).
3626
+ function applyViewsFilter(){const host=document.getElementById('m3dViewsBody');if(!host)return;const list=document.getElementById('vwList');
3627
+ const old=host.querySelector('.vwnores');if(old)old.remove();
3628
+ const sb=document.getElementById('vwSearch');if(sb)sb.classList.toggle('has',!!viewsQuery);
3629
+ if(!list)return;const q=(viewsQuery||'').trim().toLowerCase();const rows=[...list.querySelectorAll('.vwrow')];
3630
+ if(!q){rows.forEach(r=>r.style.display='');return;}
3631
+ let any=false;rows.forEach(r=>{const nm=(r.querySelector('.vwname')||{}).textContent||'';const hit=nm.toLowerCase().includes(q);r.style.display=hit?'':'none';if(hit)any=true;});
3632
+ if(!any){const e=document.createElement('div');e.className='vwnores';e.append(document.createTextNode('No views match “'+viewsQuery.trim()+'”.'));
3633
+ const clr=document.createElement('button');clr.type='button';clr.className='pilllink';clr.textContent='Clear';clr.addEventListener('click',()=>{viewsQuery='';const inp=document.getElementById('vwSearchInput');if(inp)inp.value='';applyViewsFilter();if(inp)inp.focus();});
3634
+ e.appendChild(clr);host.appendChild(e);}
3635
+ }
3636
+ // The ▾ "New from direction" menu — reuses the .m3dmenu skin, opened under the ▾ button.
3637
+ function openNewFromDirectionMenu(anchorEl){closeViewRowMenu();
3638
+ const m=document.createElement('div');m.id='vwRowMenu';m.className='m3dmenu open';m.setAttribute('role','menu');
3639
+ const DIRS=[['top','Top'],['front','Front'],['back','Back'],['left','Left'],['right','Right'],['iso','Iso']];
3640
+ const lab=Object.assign(document.createElement('div'),{className:'mlabel',textContent:'New from direction'});m.appendChild(lab);
3641
+ for(const [dir,label] of DIRS){const b=document.createElement('button');b.type='button';b.textContent=label;b.setAttribute('role','menuitem');b.addEventListener('click',()=>{closeViewRowMenu();saveViewFromDirection(dir);});m.appendChild(b);}
3642
+ document.body.appendChild(m);
3643
+ const r=anchorEl.getBoundingClientRect();const mw=m.offsetWidth||160,mh=m.offsetHeight||210;
3644
+ let x=r.right-mw,y=r.bottom+4;if(x<6)x=6;if(y+mh>innerHeight-6)y=r.top-mh-4;if(y<6)y=6;
3645
+ m.style.left=x+'px';m.style.top=y+'px';
3646
+ setTimeout(()=>document.addEventListener('mousedown',viewRowMenuOutside,true),0);
3647
+ }
3648
+ // A themed undo toast (extends toast(): an inline "Undo" action + a ~5s window). Baseline tokens only.
3649
+ function undoToast(msg,onUndo){let t=document.getElementById('undoToast');
3650
+ if(!t){t=document.createElement('div');t.id='undoToast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;align-items:center;gap:12px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s';document.body.appendChild(t);}
3651
+ t.replaceChildren();t.appendChild(document.createTextNode(msg));
3652
+ const btn=document.createElement('button');btn.type='button';btn.textContent='Undo';btn.style.cssText='background:transparent;border:0;color:var(--brand);cursor:pointer;font:600 13px system-ui;padding:0;box-shadow:none';
3653
+ btn.addEventListener('click',()=>{clearTimeout(t._h);t.style.opacity='0';try{onUndo();}catch(e){console.error(e);}});
3654
+ t.appendChild(btn);t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},5000);
3655
+ }
3294
3656
  // Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
3295
3657
  // — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
3296
3658
  // base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
@@ -3302,10 +3664,14 @@ const DIM_LABEL=Object.fromEntries(DIM_CATS);
3302
3664
  // Dimension overlays grouped by the connection they annotate (a middle category under Dimensions). bolt_pitch/
3303
3665
  // edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
3304
3666
  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']}];
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
3667
+ // Renders the OBJECTS tab body (#m3dLegendBody) — NOT the whole panel. The panel shell + tab strip persist across
3668
+ // rebuilds; only this body's children are replaced. Every existing #m3dLegend .lrow/.cat-hdr selector still matches
3669
+ // because the body is a descendant of #m3dLegend. Panel/tab visibility is owned by showLegendPanel/setLegendTab.
3670
+ function build3DLegend(){const host=document.getElementById('m3dLegendBody');if(!host||!window.Steel3DView)return;
3671
+ const panelEl=document.getElementById('m3dLegend');
3672
+ if(panelEl&&!panelEl._ctxWired){panelEl._ctxWired=true;panelEl.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
3307
3673
  const groups=window.Steel3DView.getGroups();host.replaceChildren();
3308
- if(!groups.length){host.style.display='none';return;}
3674
+ if(!groups.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lsempty',textContent:'No objects in the model yet.'}));return;} // empty Objects tab — the panel stays open (Views/Favourites are independent of scene parts)
3309
3675
  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);
3310
3676
  const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
3311
3677
  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
@@ -3452,8 +3818,8 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3452
3818
  x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
3453
3819
  host.appendChild(row);
3454
3820
  }
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();}
3821
+ {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 — body's first child (above the mode toggle); visibility set by updateLegendReset (via refresh3DLegend)
3822
+ refresh3DLegend();applyLegendFilter();refreshLegendSel();} // the Objects body's visibility is owned by the active-tab class, not set here
3457
3823
  // The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
3458
3824
  // "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
3459
3825
  function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
@@ -3531,7 +3897,7 @@ function onLegendSearchInput(q){
3531
3897
  }
3532
3898
  // Show/hide object rows by label; hide object categories left with no visible child; toggle the "no matches" line.
3533
3899
  function applyLegendFilter(){
3534
- const host=document.getElementById('m3dLegend');if(!host)return;
3900
+ const host=document.getElementById('m3dLegendBody');if(!host)return; // the objects rows/.lhint now live in the tab BODY (not the outer #m3dLegend panel) — scope reads AND the no-match insertBefore/appendChild here so the structure mutation targets the real parent
3535
3901
  const q=(legendQuery||'').trim().toLowerCase();
3536
3902
  const old=host.querySelector('.lsempty');if(old)old.remove();
3537
3903
  const rows=[...host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]')];
@@ -3706,6 +4072,8 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
3706
4072
  document.addEventListener('keydown',e=>{if(e.key==='Escape'&&ci$('connImportModal').style.display==='flex'){e.stopPropagation();ciClose();}},true);
3707
4073
  }
3708
4074
  document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
4075
+ // Views toolbar button: open the panel on the Views tab (or, if it's already open on Views, toggle it closed — a natural toolbar toggle).
4076
+ {const vb=document.getElementById('m3dViewsBtn');if(vb)vb.onclick=()=>{const p=document.getElementById('m3dLegend');const openOnViews=p&&p.style.display!=='none'&&legendTab==='views';if(openOnViews){p.style.display='none';vb.classList.remove('on');}else{wireLegendTabs();showLegendPanel('views');}updateViewsBtn();};}
3709
4077
  // Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
3710
4078
  const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
3711
4079
  function workMenuOutside(e){if(!workMenu.contains(e.target)&&e.target!==workBtn)workMenuClose();}
@@ -3749,7 +4117,7 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
3749
4117
  document.getElementById('m3dCube').style.display=on?'block':'none';
3750
4118
  document.getElementById('m3dAxes').style.display=on?'block':'none';
3751
4119
  document.getElementById('snapBar').classList.toggle('s3d',on); // in 3D the snap bar shifts clear of the world-axis triad (bottom-right); see #snapBar.s3d
3752
- if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
4120
+ if(!on)document.getElementById('m3dLegend').style.display='none'; // panel is shown by setView (showLegendPanel) when entering 3D
3753
4121
  }
3754
4122
  async function setView(on){
3755
4123
  if(on){
@@ -3763,7 +4131,7 @@ async function setView(on){
3763
4131
  window.Steel3DView.show();
3764
4132
  await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
3765
4133
  window.Steel3DView.setSelection(selIds);
3766
- wire3DBar();build3DLegend();
4134
+ wire3DBar();wireLegendTabs();build3DLegend();showLegendPanel(); // build the Objects body, then open the panel on the last-used tab
3767
4135
  reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
3768
4136
  }catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
3769
4137
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.80.0",
3
+ "version": "0.82.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": {