@floless/app 0.66.1 → 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.1" : 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.1" : 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
@@ -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}
@@ -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];
@@ -2275,6 +2323,7 @@ addEventListener('keydown',e=>{
2275
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;}
2276
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)
2277
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)
2278
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)
2279
2328
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
2280
2329
  if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
@@ -2292,7 +2341,7 @@ addEventListener('keydown',e=>{
2292
2341
  if(ck==='m'){e.preventDefault();armCm('move',false);return;}
2293
2342
  if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
2294
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)
2295
- 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
2296
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
2297
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)
2298
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)
@@ -2348,7 +2397,15 @@ document.getElementById('cpLevelB').onclick=()=>openLevelModal('copy');
2348
2397
  let copesByMember={}, partsById={};
2349
2398
  const view3dApi={
2350
2399
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
2351
- 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
+
2352
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)
2353
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
2354
2411
  getMembers:()=>P.members, // raw members (wp) for snap geometry
@@ -2415,12 +2472,44 @@ const view3dApi={
2415
2472
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
2416
2473
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
2417
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);},
2418
2482
  onIsolateChange:()=>{updateIsolateBtn();refresh3DLegend();}, // isolate / show-all (incl. Esc, legend "show all", showAllGroups clearing connHidden) → refresh the toolbar button + the legend rows' hidden state
2419
2483
  onWorkAreaChange:()=>updateWorkBtn(), // work area set / toggled → refresh the button + its checkbox
2420
2484
  };
2421
2485
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
2422
2486
  // changes go through render()'s setSelection — only geometry mutations need a rebuild.
2423
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');}}
2424
2513
  // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
2425
2514
  // double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
2426
2515
  let leg3dClickT=null,legendAnchor=null;
@@ -2686,6 +2775,30 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2686
2775
  else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
2687
2776
  clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
2688
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);});};
2689
2802
  document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
2690
2803
  // Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
2691
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.1",
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": {