@floless/app 0.66.0 → 0.67.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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.66.0" : void 0,
53025
+ define: true ? "0.67.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.66.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.67.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -53892,6 +53892,7 @@ function bakeContractIntoApp(sourcePath, contract) {
53892
53892
  doc.setIn(["nodes", idx, "config", "contract"], contract.type);
53893
53893
  const baked = { ...contract };
53894
53894
  delete baked.dims3d;
53895
+ delete baked.detail_placements;
53895
53896
  if (Array.isArray(contract.plans)) {
53896
53897
  baked.plans = contract.plans.map((p) => {
53897
53898
  if (p && typeof p === "object") {
@@ -64852,7 +64853,7 @@ async function startServer() {
64852
64853
  "/api/contract-request",
64853
64854
  { bodyLimit: 25 * 1024 * 1024 },
64854
64855
  async (req, reply) => {
64855
- const { appId, instruction, snapshots } = req.body ?? {};
64856
+ const { appId, instruction, intent, target, snapshots } = req.body ?? {};
64856
64857
  if (!appId || !instruction) {
64857
64858
  return reply.status(400).send({ ok: false, error: "appId, instruction required" });
64858
64859
  }
@@ -64862,7 +64863,10 @@ async function startServer() {
64862
64863
  } catch (e) {
64863
64864
  return reply.status(400).send({ ok: false, error: e instanceof Error ? e.message : "bad snapshot" });
64864
64865
  }
64865
- const request = addRequest({ type: "tweak-contract", appId, instruction }, decoded);
64866
+ const request = addRequest(
64867
+ { type: "tweak-contract", appId, instruction, ...intent ? { intent } : {}, ...target ? { target } : {} },
64868
+ decoded
64869
+ );
64866
64870
  broadcast({ type: "request-added", request });
64867
64871
  return { ok: true, request };
64868
64872
  }
@@ -40,9 +40,27 @@
40
40
  "filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage. `sheets[]` holds per-framing-plan geometry; the facets + `mode` + on-flag selection are GLOBAL across sheets. The saved selection scopes which lines the reader binds profile labels to per sheet; replaces the length heuristic, fallback when absent. CONFIDENTIAL: each sheet's `page.bg_b64` is machine-local, never committed." },
41
41
  "connections": { "type": "array", "items": { "$ref": "#/$defs/connection" }, "description": "Vendor-neutral connection library: each row maps a connection type → the firm's design detail# → per-platform component ids. Member ends/columns reference a row by id via `ends[].conn` / `col.conn` (legacy `note`/`detail` = fallback when `conn` is empty)." },
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
- "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." }
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
+ "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." }
44
45
  },
45
46
  "$defs": {
47
+ "detailPlacement": {
48
+ "type": "object",
49
+ "required": ["id"],
50
+ "properties": {
51
+ "id": { "type": "string", "description": "Stable, sheet-unique placement id." },
52
+ "detailName": { "type": "string", "description": "Key into custom_details for the image bytes." },
53
+ "sheet": { "type": "string", "description": "Sheet the detail was placed against (targeting scope)." },
54
+ "anchorId": { "type": ["string", "null"], "description": "Host member id the detail was dropped onto, or null (free placement)." },
55
+ "pos": { "type": "array", "items": { "type": "number" }, "description": "World-scene mm [x,y,z] of the plane centre." },
56
+ "u": { "type": "array", "items": { "type": "number" }, "description": "In-plane x-axis basis from the picked face." },
57
+ "n": { "type": "array", "items": { "type": "number" }, "description": "Plane-normal basis from the picked face." },
58
+ "rotZ": { "type": "number", "description": "In-plane rotation about the plane normal, degrees." },
59
+ "size": { "type": "number", "description": "Plane width in mm (height follows the image aspect)." },
60
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1 }
61
+ },
62
+ "additionalProperties": true
63
+ },
46
64
  "grid": {
47
65
  "type": ["object", "null"],
48
66
  "required": ["origin"],
@@ -29,6 +29,11 @@ let epGeom = null, epMatStart = null, epMatEnd = null; // shared across all endp
29
29
  let epRing = null, epPreview = null; // white ring on the active end node + a rubber line while dragging it
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
+ let insertMode = false; // armed "place a vectored detail" pick (mirrors clipMode)
33
+ let insertPending = null; // {name,url} — the detail the editor queued for the next placement click
34
+ let labelsOnFlag = false; // member mark/id overlay labels toggle (a readability aid)
35
+ let memberLabelHost = null; // fixed-position container for the member labels (positioned each frame)
36
+ const memberLabelPool = []; // reused <div> labels, one per member, positioned in the loop
32
37
  const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
33
38
  let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
34
39
  let displayMode = 'solid'; // solid | wire | xray
@@ -148,6 +153,9 @@ function init(canvas, theApi) {
148
153
  dimLabelHost = document.createElement('div');
149
154
  dimLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:57';
150
155
  document.body.appendChild(dimLabelHost);
156
+ memberLabelHost = document.createElement('div'); // member mark/id labels (Slice 4) — below the dim labels
157
+ memberLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
158
+ document.body.appendChild(memberLabelHost);
151
159
  // persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
152
160
  hoverChip = document.createElement('div');
153
161
  hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
@@ -191,6 +199,7 @@ function loop() {
191
199
  sizeClipHandles();
192
200
  positionDimLabels();
193
201
  positionOverlayLabels();
202
+ positionMemberLabels();
194
203
  renderer.render(scene, camera);
195
204
  if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
196
205
  const saved = renderer.clippingPlanes;
@@ -332,6 +341,34 @@ function placeExtrusion(mesh, el) {
332
341
  mesh.position.set(0, 0, z0); // the shape carries world XY; lift the slab to its bottom face
333
342
  }
334
343
 
344
+ // A placed detail (Slice 4 — insert a vectored detail into the model): a flat image plane the user
345
+ // drops into the 3D scene to reference a 2D detail against real members. `url` is a data: image (from
346
+ // the editor's detail library); the plane sits at the element's world point, oriented to the picked
347
+ // face's basis (u = in-plane x, n = normal) or a default vertical facing -Y, sized by `size` mm with
348
+ // the image's own aspect applied once the texture loads. Injected CLIENT-SIDE by the editor into the
349
+ // scene it feeds this view; the server's contract-to-scene never emits them, so they stay out of the
350
+ // reconstructed scene / IFC / Tekla export (the bytes ride the contract to the localhost host only).
351
+ function placeImage(mesh, el) {
352
+ const size = Math.max(el.size || 1000, 1);
353
+ mesh.geometry = new THREE.PlaneGeometry(size, size);
354
+ const tex = new THREE.TextureLoader().load(el.url || '', (t) => {
355
+ const iw = t.image && t.image.width, ih = t.image && t.image.height;
356
+ if (iw && ih) mesh.scale.set(1, ih / iw, 1); // geometry is square; scale local Y to the image aspect
357
+ });
358
+ tex.colorSpace = THREE.SRGBColorSpace;
359
+ mesh.material = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: el.opacity == null ? 1 : el.opacity, side: THREE.DoubleSide, depthWrite: false });
360
+ const u = el.u ? new THREE.Vector3(el.u[0], el.u[1], el.u[2]).normalize() : new THREE.Vector3(1, 0, 0);
361
+ const n = el.n ? new THREE.Vector3(el.n[0], el.n[1], el.n[2]).normalize() : new THREE.Vector3(0, -1, 0);
362
+ const vAxis = new THREE.Vector3().crossVectors(n, u).normalize();
363
+ mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(u, vAxis, n));
364
+ if (el.rotZ) mesh.rotateZ((el.rotZ * Math.PI) / 180); // in-plane spin about the plane normal, degrees
365
+ // Float the plane a hair off the picked surface (along its normal) so it reads clearly instead of
366
+ // z-fighting the member it was dropped on, and so a click selects the detail (frontmost), not the member.
367
+ const OFF = 25;
368
+ mesh.position.set(((el.pos && el.pos[0]) || 0) + n.x * OFF, ((el.pos && el.pos[1]) || 0) + n.y * OFF, ((el.pos && el.pos[2]) || 0) + n.z * OFF);
369
+ mesh.renderOrder = 995;
370
+ }
371
+
335
372
  // A cylinder between two points (anchor rod / bolt shank).
336
373
  function placeBar(mesh, a, b, r, radial) {
337
374
  const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
@@ -384,6 +421,7 @@ function placeElement(mesh, el) {
384
421
  switch (el.kind || 'box') {
385
422
  case 'box': placeMember(mesh, el.from, el.to, el); return true;
386
423
  case 'extrusion': placeExtrusion(mesh, el); return true;
424
+ case 'image': placeImage(mesh, el); return true;
387
425
  case 'plate': placePlate(mesh, el); return true;
388
426
  case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
389
427
  case 'nut': placeNut(mesh, el); return true;
@@ -405,9 +443,12 @@ function materialFor(groupKey) {
405
443
  }
406
444
 
407
445
  function clearRoot() {
408
- for (const m of meshById.values()) {
446
+ for (const [id, m] of meshById) {
409
447
  m.geometry.dispose();
410
448
  if (m.userData._selMat) m.userData._selMat.dispose(); // per-mesh selection clone (else it leaks per rebuild)
449
+ // A placed detail (Slice 4) owns a per-mesh MeshBasicMaterial + its image texture (NOT a shared baseMat);
450
+ // dispose both or every rebuild — which happens on each edit — leaks the old material + GPU texture.
451
+ if (String(id).startsWith('det:') && m.material) { if (m.material.map) m.material.map.dispose(); m.material.dispose(); }
411
452
  root.remove(m);
412
453
  }
413
454
  meshById.clear();
@@ -511,6 +552,7 @@ function buildFromScene(sc) {
511
552
  buildGrid(box);
512
553
  buildStructGrid();
513
554
  buildRefLines();
555
+ syncMemberLabels(); // refresh the member-label pool from the (possibly edited) member set
514
556
  if (isolatedIds) { isolatedIds = new Set([...isolatedIds].filter((id) => meshById.has(id))); if (!isolatedIds.size) { isolatedIds = null; if (api && api.onIsolateChange) api.onIsolateChange(false); } } // drop ids an edit removed; if none survive, exit isolation + refresh the host's Show-all button
515
557
  if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
516
558
  applyGroupVisibility(); applyDisplayMode();
@@ -551,6 +593,74 @@ function buildRefLines() {
551
593
  }
552
594
  function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
553
595
 
596
+ // ---- Insert a vectored detail (Slice 4): arm a placement pick; the editor owns the entry ----
597
+ // Mirrors setClipMode's arm/disarm. `pending` = {name,url} the editor's Insert menu queued; the
598
+ // placement click raycasts a member face (anchor) or the ground plane and hands the point + basis +
599
+ // anchor id back to the editor, which creates + selects the detail and records the create request.
600
+ function setInsertMode(on, pending) {
601
+ insertMode = !!on;
602
+ insertPending = insertMode ? (pending || insertPending) : null;
603
+ if (insertMode) { if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); setClipMode(null); }
604
+ if (canvasEl) canvasEl.style.cursor = insertMode ? 'crosshair' : 'default';
605
+ if (api && api.onInsertModeChange) api.onInsertModeChange(insertMode);
606
+ }
607
+ function insertModeOn() { return insertMode; }
608
+ // The placement raycast: a member face under the cursor gives the point + its basis + the member id to
609
+ // anchor to; empty space drops the detail on the ground plane (z=0) with a default vertical orientation.
610
+ function insertPick(cx, cy) {
611
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
612
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
613
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
614
+ raycaster.setFromCamera(ndc, camera);
615
+ const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible && !String(m.userData.id || '').startsWith('det:')), false)[0];
616
+ if (hit && hit.face) {
617
+ const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
618
+ const b = planeBasis([n.x, n.y, n.z]);
619
+ return { point: [hit.point.x, hit.point.y, hit.point.z], u: b.u, n: [n.x, n.y, n.z], anchorId: hit.object.userData.id || null };
620
+ }
621
+ const g = new THREE.Vector3();
622
+ if (!raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), g)) return null;
623
+ return { point: [g.x, g.y, g.z], u: [1, 0, 0], n: [0, -1, 0], anchorId: null };
624
+ }
625
+
626
+ // ---- Member labels (Slice 4): a toggled overlay of each member's mark/id, glued to the model ----
627
+ function setLabelsOn(on) { labelsOnFlag = on === undefined ? !labelsOnFlag : !!on; syncMemberLabels(); }
628
+ // Build/refresh one themed label div per member (reused across rebuilds); text = mark or id.
629
+ function syncMemberLabels() {
630
+ if (!memberLabelHost || !api) return;
631
+ const ms = labelsOnFlag ? members() : [];
632
+ while (memberLabelPool.length < ms.length) {
633
+ const el = document.createElement('div');
634
+ el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#1e293b);border-radius:4px;padding:1px 5px;font:11px system-ui;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,.4)';
635
+ memberLabelHost.appendChild(el); memberLabelPool.push(el);
636
+ }
637
+ const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
638
+ for (let i = 0; i < memberLabelPool.length; i++) {
639
+ const el = memberLabelPool[i], m = ms[i];
640
+ if (!m || !Array.isArray(m.wp) || m.wp.length < 2) { el.style.display = 'none'; el._mid = null; continue; }
641
+ const g = memberGeometry(m, ppf, dtos);
642
+ el._mid = [(g.line[0][0] + g.line[1][0]) / 2, (g.line[0][1] + g.line[1][1]) / 2, (g.line[0][2] + g.line[1][2]) / 2];
643
+ el.textContent = m.mark || m.id || '';
644
+ el.style.display = 'block';
645
+ }
646
+ }
647
+ // Project each member label's 3D midpoint → screen px (called from the render loop, like the dim labels).
648
+ function positionMemberLabels() {
649
+ if (!memberLabelHost) return;
650
+ if (!labelsOnFlag || (canvasEl && canvasEl.style.display === 'none')) { memberLabelHost.style.display = 'none'; return; }
651
+ memberLabelHost.style.display = 'block';
652
+ const rect = canvasEl.getBoundingClientRect();
653
+ for (const el of memberLabelPool) {
654
+ if (el.style.display === 'none' || !el._mid) continue;
655
+ if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; }
656
+ const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
657
+ if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
658
+ el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
659
+ el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
660
+ el.style.visibility = 'visible';
661
+ }
662
+ }
663
+
554
664
  // ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
555
665
  // them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
556
666
  // wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
@@ -738,8 +848,9 @@ function setDisplayMode(m) { displayMode = m; applyDisplayMode(); }
738
848
  // solid → opaque; wire → wireframe; xray → translucent, no depth write. Applied to whatever material
739
849
  // each mesh currently carries (shared profile material OR its selection clone).
740
850
  function applyDisplayMode() {
741
- for (const mesh of meshById.values()) {
851
+ for (const [id, mesh] of meshById) {
742
852
  const mat = mesh.material; if (!mat) continue;
853
+ if (String(id).startsWith('det:')) continue; // placed details (Slice 4) own their transparency + opacity — display mode must not override them
743
854
  if (displayMode === 'wire') { mat.wireframe = true; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
744
855
  else if (displayMode === 'xray') { mat.wireframe = false; mat.transparent = true; mat.opacity = 0.25; mat.depthWrite = false; }
745
856
  else { mat.wireframe = false; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
@@ -1132,6 +1243,7 @@ function onKey(e) {
1132
1243
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
1133
1244
  if (pending && pending.clipDrag && e.key === 'Escape') { e.preventDefault(); const p = pending; if (p.plane) p.clip.point = p.prePoint; else p.clip.box.copy(p.preBox); rebuildClipPlanes(p.clip); applyClips(); renderClipGizmo(); pending = dragging = null; if (controls) controls.enabled = true; if (readout) readout.style.display = 'none'; return; } // Esc mid-drag → undo the handle move
1134
1245
  if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
1246
+ if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
1135
1247
  if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
1136
1248
  if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
1137
1249
  if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
@@ -1198,6 +1310,10 @@ function setSelection(ids) {
1198
1310
  const set = ids instanceof Set ? ids : new Set(ids || []);
1199
1311
  for (const [id, mesh] of meshById) {
1200
1312
  const on = set.has(id);
1313
+ // A placed detail (Slice 4) uses a textured MeshBasicMaterial with no `.emissive` — the member
1314
+ // material swap below would drop its texture (and MeshBasicMaterial has no emissive to glow). Keep
1315
+ // its plane and tint the map (blue when selected, white = untinted otherwise) as the selection cue.
1316
+ if (String(id).startsWith('det:')) { if (mesh.material && mesh.material.color) mesh.material.color.setHex(on ? 0x93c5fd : 0xffffff); continue; }
1201
1317
  // Selected → a per-mesh clone that glows (so one selected member doesn't tint its whole profile
1202
1318
  // group). Deselected → back to the SHARED profile material (its real colour).
1203
1319
  mesh.material = on ? selectedMaterial(mesh) : materialFor(mesh.userData.group);
@@ -2046,6 +2162,7 @@ function onDown(e) {
2046
2162
  return; }
2047
2163
  if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
2048
2164
  if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
2165
+ if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); setInsertMode(false); return; } // armed: place the queued detail at the pick, then disarm (one shot)
2049
2166
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
2050
2167
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
2051
2168
  if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(e.clientX, e.clientY); } catch { ch = null; } if (ch) { e.stopPropagation(); controls.enabled = false; downXY = [e.clientX, e.clientY]; startClipDrag(ch, e); return; } } // grab a clip handle (plane dot / box face) → drag it
@@ -2146,8 +2263,8 @@ function onMove(e) {
2146
2263
  if (pending.epDrag) { onMoveEndpoint(e); return; }
2147
2264
  if (!pending.grab || !pending.origMm) return;
2148
2265
  if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
2149
- const canDrag = pending.copy || (api && api.dragMoveEnabled && api.dragMoveEnabled());
2150
- if (!canDrag) { if (!pending._hinted) { pending._hinted = true; if (api && api.dragMoveHint) api.dragMoveHint(); } return; } // drag-move OFF, plain grab → never translate; onUp selects the member (dragging stays false)
2266
+ const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled(); // the toggle gates BOTH gestures — plain drag (move) AND Ctrl+drag (copy)
2267
+ if (!canDrag) { if (!pending._hinted) { pending._hinted = true; if (api && api.dragMoveHint) api.dragMoveHint(); } return; } // drag-move/copy OFF → never translate; onUp selects the grabbed member (dragging stays false)
2151
2268
  if (!dragging) pending.mode = pending.copy ? 'copy' : (e.altKey ? 'vertical' : 'plan'); // lock the mode at drag start (copy is plan-only; Alt = vertical/elevation)
2152
2269
  dragging = pending;
2153
2270
  if (pending.mode === 'vertical') { onMoveVertical(e); return; }
@@ -2244,7 +2361,7 @@ function onUp(e) {
2244
2361
  return;
2245
2362
  }
2246
2363
  if (!wasDragging) {
2247
- if (p.id && !p.geo && !p.epDrag && !p.copy && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, false); return; } // a blocked drag (drag-move OFF) selects the GRABBED member, not whatever's under the release point
2364
+ if (p.id && !p.geo && !p.epDrag && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, p.ctrl); return; } // a blocked drag (drag-move/copy OFF) acts on the GRABBED member, not whatever's under the release point (Ctrl toggles it)
2248
2365
  clickSelect(e.clientX, e.clientY, p.ctrl); return; // click → cycle-pick: member, or a derived part stacked on it at a joint (Ctrl+click = additive select)
2249
2366
  }
2250
2367
  if (p.copy) { // Ctrl+drag → commit a copy at the plan delta (the editor clones + selects); the ghost was the preview
@@ -2343,10 +2460,10 @@ function updateStatusChip() {
2343
2460
  else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
2344
2461
  // A read-only host (the vector 3D viewer) supplies its own idle hint — the default speaks editing
2345
2462
  // verbs (move/stretch/elevate) a viewer's no-op api would silently ignore. Otherwise the default
2346
- // tells the truth about the drag-move toggle (grab-to-move ON vs click-to-select + Ctrl+drag-copy).
2463
+ // tells the truth about the drag-move/copy toggle (ON = drag-move + Ctrl-drag-copy; OFF = click-select).
2347
2464
  else if (api && typeof api.statusHint === 'function' && api.statusHint()) txt = api.statusHint();
2348
2465
  else { const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled();
2349
- txt = (canDrag ? 'Drag to move · ' : 'Click to select · Ctrl+drag to copy · ') + 'ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in'; }
2466
+ txt = (canDrag ? 'Drag to move · Ctrl+drag to copy · ' : 'Click to select · ') + 'ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in'; }
2350
2467
  hoverChip.textContent = txt;
2351
2468
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
2352
2469
  hoverChip.style.top = (rect.bottom - 30) + 'px';
@@ -2357,9 +2474,11 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
2357
2474
  function hide() {
2358
2475
  if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
2359
2476
  if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
2477
+ if (insertMode) setInsertMode(false); // disarm a pending detail placement — else the first click on return would place it
2360
2478
  if (wpMode) { wpMode = null; wpDraft = null; if (marker) marker.visible = false; reflectWpBar(); } // drop an in-progress set-plane pick — else a stale face/3pt pick steals the first click on return
2361
2479
  cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
2362
2480
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
2481
+ if (memberLabelHost) memberLabelHost.style.display = 'none'; // member labels ride the same about-to-stop loop — hide them too, or they strand over the 2D UI
2363
2482
  clearCopyGhost(); // drop a stale Ctrl+drag copy ghost on the way out
2364
2483
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
2365
2484
  }
@@ -2376,6 +2495,8 @@ function dispose() {
2376
2495
  window.removeEventListener('keydown', onKey);
2377
2496
  for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
2378
2497
  dimLabelHost = null; dimLabelPool.length = 0;
2498
+ if (memberLabelHost && memberLabelHost.parentNode) memberLabelHost.parentNode.removeChild(memberLabelHost);
2499
+ memberLabelHost = null; memberLabelPool.length = 0; labelsOnFlag = false; insertMode = false; insertPending = null;
2379
2500
  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
2380
2501
  if (!w) continue;
2381
2502
  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(); } });
@@ -2456,6 +2577,9 @@ window.Steel3DView = {
2456
2577
  init, show, hide, rebuild, setSelection, isReady, dispose,
2457
2578
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
2458
2579
  setRefLine, refLine: () => refLineOn,
2580
+ setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
2581
+ setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2582
+ syncMemberLabels, // editor calls after a mark/id edit to refresh labels
2459
2583
  refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
2460
2584
  gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
2461
2585
  toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
@@ -25,6 +25,11 @@
25
25
  #zoombar{position:absolute;left:12px;bottom:12px;display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:5}
26
26
  #zoombar button{padding:1px 9px;font-size:15px}
27
27
  #zoombar input[type=range]{width:120px;padding:0;accent-color:var(--brand)}
28
+ /* placed-detail inspector (Slice 4): ft-in fields in a row + a themed opacity slider (no native widgets) */
29
+ .detrow{display:flex;gap:6px;margin:2px 0}
30
+ .detf{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;font-size:11px;color:var(--mut)}
31
+ .detf input{width:100%}
32
+ #detOpacity{accent-color:var(--brand);flex:1;min-width:0}
28
33
  #zoombar #zPct{min-width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
29
34
  aside{width:240px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto}
30
35
  aside h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
@@ -98,10 +103,10 @@
98
103
  #moreMenu .msnap-sect{display:none} #moreMenu .msnap-sect.open{display:block}
99
104
  #moreMenu button.msnap{display:flex;align-items:center;gap:0}
100
105
  #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) */
101
- #moreMenu .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}
102
- #moreMenu .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}
103
- #moreMenu button.msnap.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
104
- #moreMenu button.msnap.on .mck::after{transform:translateX(12px);background:var(--brand)}
106
+ #moreMenu .mck,.cmmenu .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 and the Move/Copy → Drag-to-move/copy toggle */
107
+ #moreMenu .mck::after,.cmmenu .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}
108
+ #moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
109
+ #moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after{transform:translateX(12px);background:var(--brand)}
105
110
  #moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
106
111
  #moreMenu button.msnap.on .sg{opacity:1}
107
112
  /* Quick-access snap bar — always-expanded row of glyph toggles, bottom-right of the canvas (both views); reuses the brand-fill "on" toolbar language */
@@ -161,9 +166,9 @@
161
166
  .cmwrap{position:relative;display:inline-flex}
162
167
  #xfB{white-space:nowrap}
163
168
  #xfB.on{background:var(--brand);border-color:var(--brand);color:#fff} /* a transform tool is armed */
164
- .cmmenu .mstate{margin-left:auto;font-size:11px;color:var(--mut);padding-left:12px} /* the Drag-to-move on/off state, right-aligned in its menu row */
165
- .cmmenu #dragMoveB.on .mstate{color:var(--brand)}
166
- .cmmenu #dragMoveB.on{color:var(--text)}
169
+ /* Drag-to-move/copy menu item wears the shared .mck slider switch (rules above, shared with Snapping). */
170
+ .cmmenu #dragMoveB{display:flex;align-items:center;justify-content:flex-start;gap:0}
171
+ .cmmenu #dragMoveB.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool) */
167
172
  .cmmenu{display:none;position:absolute;left:0;top:calc(100% + 6px);min-width:200px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30}
168
173
  .cmmenu.open{display:block}
169
174
  .cmmenu button{display:flex;justify-content:space-between;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
@@ -369,7 +374,7 @@
369
374
  <button id=cpArrB>Copy array…</button>
370
375
  <button id=cpLevelB>Copy to level…</button>
371
376
  <div class=mlabel>Dragging</div>
372
- <button id=dragMoveB role=menuitemcheckbox aria-checked=false title="When ON, left-dragging a member moves it. When OFF (default), a click only selects members can't be nudged by accident (Move and Ctrl+drag-copy still work). Applies to 2D and 3D.">Drag to move<span class=mstate id=dragMoveState>Off</span></button>
377
+ <button id=dragMoveB role=menuitemcheckbox aria-checked=false title="When ON, left-dragging a member moves it and Ctrl+drag copies it. When OFF (default), a drag does neither — a click only selects, so members can't be nudged by accident (the Move and Copy tools still work). Applies to 2D and 3D."><span class=mck aria-hidden=true></span>Drag to move/copy</button>
373
378
  </div>
374
379
  </div>
375
380
  <button id=askAiBtn>Ask AI ▸</button>
@@ -433,6 +438,7 @@
433
438
  <span class=tb-sep></span>
434
439
  <!-- Measure / annotate -->
435
440
  <button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
441
+ <button id=m3dLabels data-tip="Show each member's mark/id label in the 3D view">Labels</button>
436
442
  <button id=m3dDim data-tip="Measure — click two snapped points (D); axis Free/X/Y/Z, Alt = vertical; right-click = restrict snap">Dimension</button>
437
443
  <div class=seg-group id=m3dDimAxis style="display:none"><button data-d3axis=free class=on data-tip="Free 3D measurement (F)">Free</button><button data-d3axis=x data-tip="Lock measurement to X (X)">X</button><button data-d3axis=y data-tip="Lock measurement to Y (Y)">Y</button><button data-d3axis=z data-tip="Lock to Z / vertical (Z); Alt = quick vertical">Z</button></div>
438
444
  <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
@@ -447,6 +453,11 @@
447
453
  <button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
448
454
  </div>
449
455
  </div>
456
+ <div class=m3dwrap>
457
+ <button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam">Insert ▾</button>
458
+ <div id=m3dInsertMenu class=m3dmenu role=menu></div>
459
+ <input id=insFile type=file accept="image/*" style="display:none">
460
+ </div>
450
461
  <div class=m3dwrap>
451
462
  <button id=m3dWp data-tip="Working plane — every 3D pick (Move/Copy, next: drawing) lands on it. Set it from a face, 3 points, or a principal plane.">◇ Plane ▾</button>
452
463
  <div id=m3dWpMenu class=m3dmenu role=menu>
@@ -594,6 +605,7 @@ async function boot() {
594
605
  C.custom_details = C.custom_details || {};
595
606
  C.profile_colors = C.profile_colors || {};
596
607
  if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
608
+ if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
597
609
  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
598
610
  main();
599
611
  // SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
@@ -762,7 +774,7 @@ const autofillTOS=syncDefaults;
762
774
  // live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
763
775
  // unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
764
776
  // slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
765
- function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,grid:P.grid||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null});} // clips/work area are view-state but ride the contract undo stack; grid is per-plan (like frame/dims) so the per-plan stack restores it safely
777
+ function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,grid:P.grid||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null,details:(C.detail_placements||[])});} // clips/work area are view-state but ride the contract undo stack; grid is per-plan (like frame/dims) so the per-plan stack restores it safely. details = model-global placed images, captured whole each snapshot so undo/redo is cross-plan-safe.
766
778
  // Cross-plan undo (spec §4.5): a commit that also touches ANOTHER plan captures that plan's member
767
779
  // slice too; undo/redo restore it by sheet. The entry lives on the CURRENT plan's stack — undoing
768
780
  // from the plan where the command ran is the supported path (same per-plan-stack rule as everything).
@@ -797,8 +809,9 @@ function apply(json){const d=JSON.parse(json);
797
809
  if(Array.isArray(d.others))for(const o of d.others){const tp=C.plans.find(p=>p.sheet===o.sheet);
798
810
  if(tp&&tp!==P)tp.members=(o.members||[]).map(ensureMeta);
799
811
  else if(!tp){console.error('undo: sheet '+o.sheet+' not found — cross-plan slice skipped');toast('Undo couldn’t restore sheet '+o.sheet);}}} // restore THIS plan's joint slice: drop joints owned by this plan (old ∪ restored member ids), re-add the snapshot's — OTHER sheets' joints stay untouched. grid is per-plan (restored above); 'others' = the cross-plan slice a to-level commit captured. dims3d rides its own path.
812
+ if('details' in d)C.detail_placements=Array.isArray(d.details)?d.details:[]; // restore the model-global placed details (Slice 4)
800
813
  updCS();
801
- selIds=new Set([...selIds].filter(id=>byId(id)));
814
+ selIds=new Set([...selIds].filter(id=>byId(id)||/^det:/.test(id))); // keep a selected placed-detail id (det:…) — it isn't a member
802
815
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
803
816
  if(sel3dDimIds.size){const ok=new Set((C.dims3d||[]).map(x=>x.id));sel3dDimIds=new Set([...sel3dDimIds].filter(id=>ok.has(id)));} // drop stale 3D-dim selections
804
817
  dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
@@ -819,7 +832,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
819
832
  else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
820
833
  else el.textContent=msg||'Auto-save on';}
821
834
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
822
- custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, joints:C.joints,
835
+ custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, joints:C.joints, detail_placements:C.detail_placements,
823
836
  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);}}
824
837
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
825
838
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
@@ -851,6 +864,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
851
864
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
852
865
  C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;if('grid' in s)p.grid=s.grid||null;p.autofilled=true;}});
853
866
  if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
867
+ if(Array.isArray(d.detail_placements))C.detail_placements=d.detail_placements; // restore placed details
854
868
  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)
855
869
  if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
856
870
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
@@ -1358,6 +1372,40 @@ function panel(){
1358
1372
  const ap=document.getElementById('addProf');
1359
1373
  ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
1360
1374
  return;}
1375
+ // A placed detail image (Slice 4) selected — its own inspector: Position / Orientation / Appearance +
1376
+ // "Ask AI to build this" (records a modify request) + Remove. Every field mutation rides edit() → undoable.
1377
+ // HTML built exactly like the connection-part inspector below: every dynamic value is esc()-escaped
1378
+ // (the file's HTML-escaper), numbers are cast to Number — no untrusted markup reaches innerHTML.
1379
+ {const dsel=[...selIds];
1380
+ const detId=dsel.length===1&&/^det:/.test(dsel[0])?dsel[0].slice(4):null;
1381
+ const dp=detId?(C.detail_placements||[]).find(x=>x&&x.id===detId):null;
1382
+ if(dp){
1383
+ const inMM=v=>{const pl=parseLen(v);return pl==null?NaN:pl*25.4;}; // ft-in field → mm
1384
+ const ftin=mm=>esc(fmtFtIn(Number(mm||0)/25.4));
1385
+ const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${esc(t)}</span><hr></div>`;
1386
+ const num=(id,lbl,val)=>`<label class=detf><span>${esc(lbl)}</span><input id=${esc(id)} inputmode=decimal value="${val}" autocomplete=off></label>`; // id/lbl are literals; val is ftin() (already esc'd)
1387
+ const opPct=Math.round((dp.opacity==null?1:dp.opacity)*100);
1388
+ const host=dp.anchorId?('On <b>'+esc(dp.anchorId)+'</b> · '):'';
1389
+ const html=`<h3>Placed detail <span style="color:var(--mut);font-weight:400">· ${esc(dp.detailName||'')}</span></h3>`
1390
+ +`<div class="row hint" style="margin-top:2px">${host}flat image plane — a reference (v1). Ask the AI to build real geometry.</div>`
1391
+ +sec('Position')+`<div class=detrow>${num('detX','X',ftin(dp.pos&&dp.pos[0]))}${num('detY','Y',ftin(dp.pos&&dp.pos[1]))}${num('detZ','Z',ftin(dp.pos&&dp.pos[2]))}</div>`
1392
+ +sec('Orientation')+`<div class=detrow><label class=detf><span>Rotation °</span><input id=detRot inputmode=decimal value="${esc(String(dp.rotZ||0))}" autocomplete=off></label><label class=detf><span>Size</span><input id=detSize inputmode=decimal value="${ftin(dp.size||1000)}" autocomplete=off></label></div>`
1393
+ +sec('Appearance')+`<div class=detrow style="align-items:center"><span class=detf style="flex:none">Opacity</span><input id=detOpacity type=range min=0 max=100 value="${opPct}"><span id=detOpacityV class=edec style="min-width:36px;text-align:right;font-variant-numeric:tabular-nums">${opPct}%</span></div>`
1394
+ +`<div class=divrow><hr></div>`
1395
+ +`<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=detAsk title="Record a request for your terminal AI to build/adjust this detail">Ask AI to build this…</button><button class=danger id=detRemove>Remove detail</button></div>`;
1396
+ p.innerHTML=html;
1397
+ const find=()=>(C.detail_placements||[]).find(x=>x&&x.id===detId);
1398
+ const wr=(id,fn)=>{const i=document.getElementById(id);if(i)i.onchange=e=>fn(e.target.value);};
1399
+ const setPos=(k,val)=>{const mm=inMM(val);if(!isFinite(mm))return;edit(()=>{const d=find();if(d){d.pos=(d.pos||[0,0,0]).slice();d.pos[k]=mm;}});};
1400
+ wr('detX',v=>setPos(0,v));wr('detY',v=>setPos(1,v));wr('detZ',v=>setPos(2,v));
1401
+ wr('detRot',v=>{const n=Number((v||'').replace(',','.'));if(!isFinite(n))return;edit(()=>{const d=find();if(d)d.rotZ=n;});});
1402
+ wr('detSize',v=>{const mm=inMM(v);if(!isFinite(mm)||mm<=0)return;edit(()=>{const d=find();if(d)d.size=mm;});});
1403
+ {const r=document.getElementById('detOpacity'),ov=document.getElementById('detOpacityV');
1404
+ if(r){r.oninput=()=>{ov.textContent=r.value+'%';};r.onchange=()=>{const op=Math.max(0,Math.min(1,Number(r.value)/100));edit(()=>{const d=find();if(d)d.opacity=op;});};}}
1405
+ {const ab=document.getElementById('detAsk');if(ab)ab.onclick=()=>detailRequest('modify',dp);}
1406
+ {const rm=document.getElementById('detRemove');if(rm)rm.onclick=()=>edit(()=>{C.detail_placements=(C.detail_placements||[]).filter(x=>x&&x.id!==detId);selIds.clear();});}
1407
+ return;
1408
+ }}
1361
1409
  // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
1362
1410
  // read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
1363
1411
  {const selList=[...selIds];
@@ -1809,12 +1857,13 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
1809
1857
  // array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
1810
1858
  // lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
1811
1859
  let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
1812
- // Drag-to-move: OFF by default so a click only selects (no accidental nudges). Persisted per-browser,
1813
- // global (a UI pref, not per-app). Ctrl+drag-copy ignores this it's an explicit gesture.
1860
+ // Drag-to-move/copy: OFF by default so a click only selects (no accidental nudges). Persisted
1861
+ // per-browser, global (a UI pref, not per-app). Gates BOTH drag gesturesleft-drag=move AND
1862
+ // Ctrl+drag=copy; only Ctrl+CLICK (a click, not a drag) still toggles selection when off.
1814
1863
  let dragMoveOn=false; try{dragMoveOn=localStorage.getItem('steel:dragmove:v1')==='1';}catch(_){}
1815
1864
  let dragHintShown=false; try{dragHintShown=localStorage.getItem('steel:dragmovehint:v1')==='1';}catch(_){} // one-time "drag-move is off" teach on the first blocked drag
1816
1865
  function setDragMove(on){dragMoveOn=!!on;document.body.classList.toggle('dragmove',dragMoveOn);
1817
- const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-checked',String(dragMoveOn));const st=document.getElementById('dragMoveState');if(st)st.textContent=dragMoveOn?'On':'Off';}
1866
+ const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-checked',String(dragMoveOn));} // the .mck slider carries the on/off state
1818
1867
  try{localStorage.setItem('steel:dragmove:v1',dragMoveOn?'1':'0');}catch(_){}}
1819
1868
  // cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
1820
1869
  function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
@@ -2093,7 +2142,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
2093
2142
  // Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
2094
2143
  // its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
2095
2144
  if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
2096
- const canDrag=ctrl||dragMoveOn; // plain grab only translates when the toggle is on; ctrl always drags (to copy)
2145
+ const canDrag=dragMoveOn; // the toggle gates BOTH gestures: left-drag=move and Ctrl+drag=copy (Ctrl+CLICK still toggles selection below — a click, not a drag)
2097
2146
  const srcIds=(ctrl&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // ctrl on an unselected member copies just it; else the whole selection
2098
2147
  const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
2099
2148
  buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
@@ -2200,7 +2249,7 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2200
2249
  drag=null;render();return;}
2201
2250
  if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
2202
2251
  drag=null;render();
2203
- if(!dragHintShown){dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use Move (M)');}
2252
+ if(!dragHintShown){dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag to move/copy is off — turn it on in the Move / Copy menu, or use the Move (M) / Copy (C) tools');}
2204
2253
  return;}
2205
2254
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre); // armed move/copy committed (copy always changed geometry via its clones)
2206
2255
  const wasCopy=drag.copy;if(!anyToolActive())snapOnlyClear2d();drag=null;render();if(wasCopy)toast('Copied '+selIds.size+' member'+(selIds.size===1?'':'s'));return;} // a grab move/copy was the operation → single-shot override clears
@@ -2242,7 +2291,7 @@ document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members)
2242
2291
  toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
2243
2292
  document.getElementById('undoB').onclick=doUndo;
2244
2293
  document.getElementById('redoB').onclick=doRedo;
2245
- document.getElementById('dragMoveB').onclick=()=>{setDragMove(!dragMoveOn);toast(dragMoveOn?'Drag to move: ON — dragging a member moves it':'Drag to move: OFF — a click only selects');};
2294
+ document.getElementById('dragMoveB').onclick=()=>{setDragMove(!dragMoveOn);toast(dragMoveOn?'Drag to move/copy: ON — drag moves, Ctrl+drag copies':'Drag to move/copy: OFF — a click only selects');};
2246
2295
  setDragMove(dragMoveOn); // reflect the persisted state on load (body class + pill)
2247
2296
  addEventListener('keydown',e=>{
2248
2297
  const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
@@ -2274,6 +2323,7 @@ addEventListener('keydown',e=>{
2274
2323
  if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
2275
2324
  if((e.key==='Delete'||e.key==='Backspace')&&view3d&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length&&!selIds.size&&!sel3dDimIds.size&&!selDimIds.size&&!inForm){e.preventDefault();window.Steel3DView.deleteSelectedClips();return;} // 3D: Del removes the selected clip plane(s)/box(es) — only when nothing else is selected (clip selection is exclusive)
2276
2325
  if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimIds.size&&!selIds.size&&!inForm){e.preventDefault();[...sel3dDimIds].forEach(id=>view3dApi.onDeleteDim3d(id));return;} // 3D: Delete removes ALL selected 3D dims — only when no member is selected
2326
+ if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!selDimIds.size&&[...selIds].every(id=>/^det:/.test(id))&&!inForm){e.preventDefault();edit(()=>{const rm=new Set([...selIds].map(id=>id.slice(4)));C.detail_placements=(C.detail_placements||[]).filter(x=>x&&!rm.has(x.id));selIds.clear();});return;} // a placed-detail-only selection deletes from detail_placements — Delete now matches every other object's remove gesture (not just the inspector button)
2277
2327
  if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
2278
2328
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
2279
2329
  if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
@@ -2291,7 +2341,7 @@ addEventListener('keydown',e=>{
2291
2341
  if(ck==='m'){e.preventDefault();armCm('move',false);return;}
2292
2342
  if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
2293
2343
  if(!inForm&&selDimIds.size>=1&&!selIds.size&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault();toggleDimSplit();return;} // S — toggle "add split point" on the selected dimension(s)
2294
- if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
2344
+ if(!inForm&&[...selIds].some(id=>!/^det:/.test(id))&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase(); // member-only shortcuts — require a real member selected, not a placed-detail-only selection
2295
2345
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();setLastCmd('Extend/Trim',()=>{geoMode='el';setGeo();render();});return;} // Extend/Trim — any selection
2296
2346
  if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
2297
2347
  if(kk==='s'&&selIds.size===1&&!selDimIds.size){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();setLastCmd('Split',()=>{geoMode='split';setGeo();render();});return;}} // member Split — single member only, and not while dims are also selected (mixed → ambiguous, so neither split fires)
@@ -2347,7 +2397,15 @@ document.getElementById('cpLevelB').onclick=()=>openLevelModal('copy');
2347
2397
  let copesByMember={}, partsById={};
2348
2398
  const view3dApi={
2349
2399
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
2350
- fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={},pb={};for(const el of ((j.scene&&j.scene.elements)||[])){if(!el)continue;if(el.id)pb[el.id]=el;if(el.kind==='cut'&&el.member){(cm[el.member]||(cm[el.member]=[])).push((el.meta&&el.meta.label)||'Cope');}}copesByMember=cm;partsById=pb;return j.scene;}, // cache resolved parts by id (real dims for the part Inspector, not just "auto") AND cope labels per member (the coped-beam note)
2400
+ fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');const cm={},pb={};const sc=j.scene||{};sc.elements=Array.isArray(sc.elements)?sc.elements:[];for(const el of sc.elements){if(!el)continue;if(el.id)pb[el.id]=el;if(el.kind==='cut'&&el.member){(cm[el.member]||(cm[el.member]=[])).push((el.meta&&el.meta.label)||'Cope');}}
2401
+ // Slice 4: append placed detail images as CLIENT-SIDE scene elements (kind:'image'), resolving each
2402
+ // placement's detailName → custom_details base64 in the browser. The server's contract-to-scene never
2403
+ // emits these, so they stay out of the reconstructed scene / IFC / Tekla export and the baked lock.
2404
+ // (The image bytes live in custom_details, which — as it always has — rides in the contract to the
2405
+ // localhost host; the point is that they are never turned into exported geometry.)
2406
+ for(const p of (C.detail_placements||[])){if(!p||!p.id)continue;const raw=C.custom_details[p.detailName];if(raw==null)continue;const url=String(raw).startsWith('data:')?raw:'data:image/jpeg;base64,'+raw;const el={kind:'image',id:'det:'+p.id,group:'detail',url,pos:p.pos||[0,0,0],u:p.u,n:p.n,rotZ:p.rotZ||0,size:p.size||1000,opacity:p.opacity==null?1:p.opacity};sc.elements.push(el);pb[el.id]=el;}
2407
+ copesByMember=cm;partsById=pb;return sc;}, // cache resolved parts by id (real dims for the part Inspector) AND cope labels per member; placed details ride in as image elements
2408
+
2351
2409
  onSelect:(id,additive)=>{selDimIds.clear();sel3dDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
2352
2410
  onSelectMany:(ids)=>{selDimIds.clear();sel3dDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
2353
2411
  getMembers:()=>P.members, // raw members (wp) for snap geometry
@@ -2357,7 +2415,7 @@ const view3dApi={
2357
2415
  geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
2358
2416
  onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
2359
2417
  dragMoveEnabled:()=>dragMoveOn, // the 3D view gates its plain member-drag on this
2360
- dragMoveHint:()=>{if(dragHintShown)return;dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use Move (M)');},
2418
+ dragMoveHint:()=>{if(dragHintShown)return;dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag to move/copy is off — turn it on in the Move / Copy menu, or use the Move (M) / Copy (C) tools');},
2361
2419
  onCopyDrag3d:(ids,dxMm,dyMm)=>{const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1),r6=n=>Math.round(n*1e6)/1e6; // Ctrl+drag copy: mm delta → plan px (Y FLIPPED), clone + translate + select
2362
2420
  const dpx=r6(dxMm/k),dpy=r6(-dyMm/k);
2363
2421
  edit(()=>{const ns=new Set();for(const id of (ids||[])){const src=byId(id);if(!src)continue;const c=cloneMember(src);translateMembers([c],[dpx,dpy,0]);P.members.push(c);ns.add(c.id);}selIds=ns;});
@@ -2414,12 +2472,44 @@ const view3dApi={
2414
2472
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
2415
2473
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
2416
2474
  onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
2475
+ onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'Insert ✕':'Insert ▾';}}, // mirror the clip arm/cancel affordance
2476
+ onInsertPlace:(pick,pending)=>{if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
2477
+ const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
2478
+ const place={id,detailName:pending.name,sheet,anchorId:pick.anchorId||null,pos:pick.point,u:pick.u,n:pick.n,rotZ:0,size:1000,opacity:1};
2479
+ edit(()=>{if(!Array.isArray(C.detail_placements))C.detail_placements=[];C.detail_placements.push(place);selIds=new Set(['det:'+id]);});
2480
+ toast('Detail placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — adjust it in the panel or ask the AI to build it');
2481
+ detailRequest('create',place);},
2417
2482
  onIsolateChange:()=>{updateIsolateBtn();refresh3DLegend();}, // isolate / show-all (incl. Esc, legend "show all", showAllGroups clearing connHidden) → refresh the toolbar button + the legend rows' hidden state
2418
2483
  onWorkAreaChange:()=>updateWorkBtn(), // work area set / toggled → refresh the button + its checkbox
2419
2484
  };
2420
2485
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
2421
2486
  // changes go through render()'s setSelection — only geometry mutations need a rebuild.
2422
2487
  function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile)
2488
+ // Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
2489
+ // detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
2490
+ // so the terminal AI knows whether to build a new detail or update a placed one, and where.
2491
+ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];if(raw==null){toast('That detail image is missing');return;}
2492
+ if(!view3d){toast('Switch to the 3D view to place a detail');return;}
2493
+ window.Steel3DView.setInsertMode(true,{name});
2494
+ toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
2495
+ async function detailRequest(intent,place,note){
2496
+ // flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
2497
+ // debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
2498
+ // this browser's draft (server would keep it, localStorage wouldn't → restoreSaved clobbers it on reload).
2499
+ // Persist the local draft too so server + localStorage stay in lockstep.
2500
+ try{await window.flushContract();}catch(_){/* a failed save still lets the request queue; the AI reads the current draft */}
2501
+ try{persist();}catch(_){}
2502
+ const raw=(C.custom_details||{})[place.detailName];
2503
+ const snaps=raw?[{name:String(place.detailName||'detail')+'.jpg',dataUrl:String(raw).startsWith('data:')?raw:'data:image/jpeg;base64,'+raw}]:[];
2504
+ const pos=(place.pos||[0,0,0]).map(n=>Math.round(Number(n)||0)).join(', ');
2505
+ const instruction=note||(intent==='create'
2506
+ ? 'Insert the "'+place.detailName+'" detail as real geometry on '+(place.anchorId?('member '+place.anchorId):'the model')+' (sheet '+(place.sheet||'?')+'). A flat reference image is placed at '+pos+' mm — build the actual detail there.'
2507
+ : 'Update the placed "'+place.detailName+'" detail (id '+place.id+') on sheet '+(place.sheet||'?')+' to match the attached image / my adjustments.');
2508
+ const ids=[intent==='create'?(place.anchorId||('det:'+place.id)):('det:'+place.id)];
2509
+ try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
2510
+ body:JSON.stringify({appId:APP_ID,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
2511
+ toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
2512
+ }catch(_){toast('Could not queue the request');}}
2423
2513
  // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
2424
2514
  // double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
2425
2515
  let leg3dClickT=null,legendAnchor=null;
@@ -2685,6 +2775,30 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2685
2775
  else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
2686
2776
  clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
2687
2777
  if(a==='plane'){d3.setClipMode('plane');setLastCmd('Clip plane',()=>d3.setClipMode('plane'));}else if(a==='box'){d3.setClipMode('box');setLastCmd('Clip box',()=>d3.setClipMode('box'));}else if(a==='clear')d3.clearClips();});
2778
+ // Labels: a plain toggle (mirrors Ref line) — show each member's mark/id in the 3D view.
2779
+ document.getElementById('m3dLabels').onclick=()=>{const on=!d3.labelsOn();d3.setLabelsOn(on);document.getElementById('m3dLabels').classList.toggle('on',on);};
2780
+ // Insert a detail: the ▾ menu lists the detail library + an "add image" option; picking one arms a
2781
+ // placement pick (onInsertPlace drops it). While armed the button is a cancel target (onInsertModeChange).
2782
+ // The menu is built with DOM nodes (textContent) — the detail names are user text, never innerHTML.
2783
+ const insBtn=document.getElementById('m3dInsert'),insMenu=document.getElementById('m3dInsertMenu');
2784
+ function insMenuOutside(e){if(!insMenu.contains(e.target)&&e.target!==insBtn)insMenuClose();}
2785
+ function insMenuClose(){insMenu.classList.remove('open');document.removeEventListener('mousedown',insMenuOutside,true);}
2786
+ function insMenuBuild(){const names=Object.keys(C.custom_details||{}).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
2787
+ const frag=document.createDocumentFragment();
2788
+ const lab=document.createElement('div');lab.className='mlabel';lab.textContent='Place a detail';frag.appendChild(lab);
2789
+ if(names.length){for(const n of names){const b=document.createElement('button');b.textContent=n;b.title='Place '+n;b.onclick=()=>{insMenuClose();armInsert(n);};frag.appendChild(b);}}
2790
+ else{const note=document.createElement('div');note.style.cssText='padding:4px 10px;color:var(--mut);font-size:12px';note.textContent='No saved details yet — add one below.';frag.appendChild(note);}
2791
+ frag.appendChild(document.createElement('hr'));
2792
+ const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();document.getElementById('insFile').click();};frag.appendChild(add);
2793
+ insMenu.replaceChildren(frag);}
2794
+ insBtn.onclick=e=>{e.stopPropagation();
2795
+ if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
2796
+ if(insMenu.classList.contains('open'))insMenuClose();
2797
+ else{insMenuBuild();insMenu.classList.add('open');document.addEventListener('mousedown',insMenuOutside,true);}};
2798
+ document.getElementById('insFile').onchange=e=>{const f=e.target.files&&e.target.files[0];e.target.value='';if(!f)return;
2799
+ readImageCompressed(f,b64=>{if(!b64){toast('Could not read that image — try another.');return;}
2800
+ let base=(f.name||'Detail').replace(/\.[^.]+$/,'').trim()||'Detail',name=base,i=2;while(C.custom_details[name]!=null)name=base+' '+(i++);
2801
+ edit(()=>{C.custom_details[name]=b64;});armInsert(name);});};
2688
2802
  document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
2689
2803
  // Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
2690
2804
  const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.66.0",
3
+ "version": "0.67.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": {