@floless/app 0.66.1 → 0.68.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.68.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.68.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -53892,6 +53892,8 @@ 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;
53896
+ delete baked.prop_labels;
53895
53897
  if (Array.isArray(contract.plans)) {
53896
53898
  baked.plans = contract.plans.map((p) => {
53897
53899
  if (p && typeof p === "object") {
@@ -64852,7 +64854,7 @@ async function startServer() {
64852
64854
  "/api/contract-request",
64853
64855
  { bodyLimit: 25 * 1024 * 1024 },
64854
64856
  async (req, reply) => {
64855
- const { appId, instruction, snapshots } = req.body ?? {};
64857
+ const { appId, instruction, intent, target, snapshots } = req.body ?? {};
64856
64858
  if (!appId || !instruction) {
64857
64859
  return reply.status(400).send({ ok: false, error: "appId, instruction required" });
64858
64860
  }
@@ -64862,7 +64864,10 @@ async function startServer() {
64862
64864
  } catch (e) {
64863
64865
  return reply.status(400).send({ ok: false, error: e instanceof Error ? e.message : "bad snapshot" });
64864
64866
  }
64865
- const request = addRequest({ type: "tweak-contract", appId, instruction }, decoded);
64867
+ const request = addRequest(
64868
+ { type: "tweak-contract", appId, instruction, ...intent ? { intent } : {}, ...target ? { target } : {} },
64869
+ decoded
64870
+ );
64866
64871
  broadcast({ type: "request-added", request });
64867
64872
  return { ok: true, request };
64868
64873
  }
@@ -40,9 +40,38 @@
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." },
45
+ "prop_labels": {
46
+ "type": "object",
47
+ "additionalProperties": true,
48
+ "description": "Draft-only display state (like dim_overlays): which member properties render as text-chip labels on the 2D/3D canvas, the anchor placement, and scope. Model-global. NOT baked into the lock / 3D scene / IFC / BOM.",
49
+ "properties": {
50
+ "props": { "type": "array", "items": { "type": "string" }, "description": "Checked property registry keys to show as labels (e.g. 'profile', 'tos_start')." },
51
+ "placement": { "enum": ["start", "mid", "end"], "description": "Anchor point of the label stack on each member." },
52
+ "selected_only": { "type": "boolean", "description": "When true, labels pin to `ids` (captured at check time); otherwise every member on the plan with a value is labeled." },
53
+ "ids": { "type": "array", "items": { "type": "string" }, "description": "Member ids the labels pin to, used only when selected_only is true." }
54
+ }
55
+ }
44
56
  },
45
57
  "$defs": {
58
+ "detailPlacement": {
59
+ "type": "object",
60
+ "required": ["id"],
61
+ "properties": {
62
+ "id": { "type": "string", "description": "Stable, sheet-unique placement id." },
63
+ "detailName": { "type": "string", "description": "Key into custom_details for the image bytes." },
64
+ "sheet": { "type": "string", "description": "Sheet the detail was placed against (targeting scope)." },
65
+ "anchorId": { "type": ["string", "null"], "description": "Host member id the detail was dropped onto, or null (free placement)." },
66
+ "pos": { "type": "array", "items": { "type": "number" }, "description": "World-scene mm [x,y,z] of the plane centre." },
67
+ "u": { "type": "array", "items": { "type": "number" }, "description": "In-plane x-axis basis from the picked face." },
68
+ "n": { "type": "array", "items": { "type": "number" }, "description": "Plane-normal basis from the picked face." },
69
+ "rotZ": { "type": "number", "description": "In-plane rotation about the plane normal, degrees." },
70
+ "size": { "type": "number", "description": "Plane width in mm (height follows the image aspect)." },
71
+ "opacity": { "type": "number", "minimum": 0, "maximum": 1 }
72
+ },
73
+ "additionalProperties": true
74
+ },
46
75
  "grid": {
47
76
  "type": ["object", "null"],
48
77
  "required": ["origin"],
@@ -29,6 +29,14 @@ 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
37
+ let propLabelHost = null; // fixed-position container for the right-click property labels (positioned each frame)
38
+ const propLabelPool = []; // reused multi-line <div> label chips, one per labelled member
39
+ let propLabelSpec = null; // { labels:[{id, lines:[...]}], placement } pushed from the editor (owns the text); this view owns projection/placement
32
40
  const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
33
41
  let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
34
42
  let displayMode = 'solid'; // solid | wire | xray
@@ -148,6 +156,12 @@ function init(canvas, theApi) {
148
156
  dimLabelHost = document.createElement('div');
149
157
  dimLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:57';
150
158
  document.body.appendChild(dimLabelHost);
159
+ memberLabelHost = document.createElement('div'); // member mark/id labels (Slice 4) — below the dim labels
160
+ memberLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
161
+ document.body.appendChild(memberLabelHost);
162
+ propLabelHost = document.createElement('div'); // right-click property labels — own host so they never clobber the mark/id pool
163
+ propLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
164
+ document.body.appendChild(propLabelHost);
151
165
  // persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
152
166
  hoverChip = document.createElement('div');
153
167
  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 +205,8 @@ function loop() {
191
205
  sizeClipHandles();
192
206
  positionDimLabels();
193
207
  positionOverlayLabels();
208
+ positionMemberLabels();
209
+ positionPropLabels();
194
210
  renderer.render(scene, camera);
195
211
  if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
196
212
  const saved = renderer.clippingPlanes;
@@ -332,6 +348,34 @@ function placeExtrusion(mesh, el) {
332
348
  mesh.position.set(0, 0, z0); // the shape carries world XY; lift the slab to its bottom face
333
349
  }
334
350
 
351
+ // A placed detail (Slice 4 — insert a vectored detail into the model): a flat image plane the user
352
+ // drops into the 3D scene to reference a 2D detail against real members. `url` is a data: image (from
353
+ // the editor's detail library); the plane sits at the element's world point, oriented to the picked
354
+ // face's basis (u = in-plane x, n = normal) or a default vertical facing -Y, sized by `size` mm with
355
+ // the image's own aspect applied once the texture loads. Injected CLIENT-SIDE by the editor into the
356
+ // scene it feeds this view; the server's contract-to-scene never emits them, so they stay out of the
357
+ // reconstructed scene / IFC / Tekla export (the bytes ride the contract to the localhost host only).
358
+ function placeImage(mesh, el) {
359
+ const size = Math.max(el.size || 1000, 1);
360
+ mesh.geometry = new THREE.PlaneGeometry(size, size);
361
+ const tex = new THREE.TextureLoader().load(el.url || '', (t) => {
362
+ const iw = t.image && t.image.width, ih = t.image && t.image.height;
363
+ if (iw && ih) mesh.scale.set(1, ih / iw, 1); // geometry is square; scale local Y to the image aspect
364
+ });
365
+ tex.colorSpace = THREE.SRGBColorSpace;
366
+ mesh.material = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: el.opacity == null ? 1 : el.opacity, side: THREE.DoubleSide, depthWrite: false });
367
+ const u = el.u ? new THREE.Vector3(el.u[0], el.u[1], el.u[2]).normalize() : new THREE.Vector3(1, 0, 0);
368
+ const n = el.n ? new THREE.Vector3(el.n[0], el.n[1], el.n[2]).normalize() : new THREE.Vector3(0, -1, 0);
369
+ const vAxis = new THREE.Vector3().crossVectors(n, u).normalize();
370
+ mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(u, vAxis, n));
371
+ if (el.rotZ) mesh.rotateZ((el.rotZ * Math.PI) / 180); // in-plane spin about the plane normal, degrees
372
+ // Float the plane a hair off the picked surface (along its normal) so it reads clearly instead of
373
+ // z-fighting the member it was dropped on, and so a click selects the detail (frontmost), not the member.
374
+ const OFF = 25;
375
+ 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);
376
+ mesh.renderOrder = 995;
377
+ }
378
+
335
379
  // A cylinder between two points (anchor rod / bolt shank).
336
380
  function placeBar(mesh, a, b, r, radial) {
337
381
  const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
@@ -384,6 +428,7 @@ function placeElement(mesh, el) {
384
428
  switch (el.kind || 'box') {
385
429
  case 'box': placeMember(mesh, el.from, el.to, el); return true;
386
430
  case 'extrusion': placeExtrusion(mesh, el); return true;
431
+ case 'image': placeImage(mesh, el); return true;
387
432
  case 'plate': placePlate(mesh, el); return true;
388
433
  case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
389
434
  case 'nut': placeNut(mesh, el); return true;
@@ -405,9 +450,12 @@ function materialFor(groupKey) {
405
450
  }
406
451
 
407
452
  function clearRoot() {
408
- for (const m of meshById.values()) {
453
+ for (const [id, m] of meshById) {
409
454
  m.geometry.dispose();
410
455
  if (m.userData._selMat) m.userData._selMat.dispose(); // per-mesh selection clone (else it leaks per rebuild)
456
+ // A placed detail (Slice 4) owns a per-mesh MeshBasicMaterial + its image texture (NOT a shared baseMat);
457
+ // dispose both or every rebuild — which happens on each edit — leaks the old material + GPU texture.
458
+ if (String(id).startsWith('det:') && m.material) { if (m.material.map) m.material.map.dispose(); m.material.dispose(); }
411
459
  root.remove(m);
412
460
  }
413
461
  meshById.clear();
@@ -511,6 +559,8 @@ function buildFromScene(sc) {
511
559
  buildGrid(box);
512
560
  buildStructGrid();
513
561
  buildRefLines();
562
+ syncMemberLabels(); // refresh the member-label pool from the (possibly edited) member set
563
+ syncPropLabels(); // re-anchor the property labels too (member geometry may have moved)
514
564
  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
565
  if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
516
566
  applyGroupVisibility(); applyDisplayMode();
@@ -551,6 +601,122 @@ function buildRefLines() {
551
601
  }
552
602
  function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
553
603
 
604
+ // ---- Insert a vectored detail (Slice 4): arm a placement pick; the editor owns the entry ----
605
+ // Mirrors setClipMode's arm/disarm. `pending` = {name,url} the editor's Insert menu queued; the
606
+ // placement click raycasts a member face (anchor) or the ground plane and hands the point + basis +
607
+ // anchor id back to the editor, which creates + selects the detail and records the create request.
608
+ function setInsertMode(on, pending) {
609
+ insertMode = !!on;
610
+ insertPending = insertMode ? (pending || insertPending) : null;
611
+ if (insertMode) { if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); setClipMode(null); }
612
+ if (canvasEl) canvasEl.style.cursor = insertMode ? 'crosshair' : 'default';
613
+ if (api && api.onInsertModeChange) api.onInsertModeChange(insertMode);
614
+ }
615
+ function insertModeOn() { return insertMode; }
616
+ // The placement raycast: a member face under the cursor gives the point + its basis + the member id to
617
+ // anchor to; empty space drops the detail on the ground plane (z=0) with a default vertical orientation.
618
+ function insertPick(cx, cy) {
619
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
620
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
621
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
622
+ raycaster.setFromCamera(ndc, camera);
623
+ const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible && !String(m.userData.id || '').startsWith('det:')), false)[0];
624
+ if (hit && hit.face) {
625
+ const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
626
+ const b = planeBasis([n.x, n.y, n.z]);
627
+ 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 };
628
+ }
629
+ const g = new THREE.Vector3();
630
+ if (!raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), g)) return null;
631
+ return { point: [g.x, g.y, g.z], u: [1, 0, 0], n: [0, -1, 0], anchorId: null };
632
+ }
633
+
634
+ // ---- Member labels (Slice 4): a toggled overlay of each member's mark/id, glued to the model ----
635
+ function setLabelsOn(on) { labelsOnFlag = on === undefined ? !labelsOnFlag : !!on; syncMemberLabels(); }
636
+ // Build/refresh one themed label div per member (reused across rebuilds); text = mark or id.
637
+ function syncMemberLabels() {
638
+ if (!memberLabelHost || !api) return;
639
+ const ms = labelsOnFlag ? members() : [];
640
+ while (memberLabelPool.length < ms.length) {
641
+ const el = document.createElement('div');
642
+ 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)';
643
+ memberLabelHost.appendChild(el); memberLabelPool.push(el);
644
+ }
645
+ const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
646
+ for (let i = 0; i < memberLabelPool.length; i++) {
647
+ const el = memberLabelPool[i], m = ms[i];
648
+ if (!m || !Array.isArray(m.wp) || m.wp.length < 2) { el.style.display = 'none'; el._mid = null; continue; }
649
+ const g = memberGeometry(m, ppf, dtos);
650
+ 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];
651
+ el.textContent = m.mark || m.id || '';
652
+ el.style.display = 'block';
653
+ }
654
+ }
655
+ // Project each member label's 3D midpoint → screen px (called from the render loop, like the dim labels).
656
+ function positionMemberLabels() {
657
+ if (!memberLabelHost) return;
658
+ if (!labelsOnFlag || (canvasEl && canvasEl.style.display === 'none')) { memberLabelHost.style.display = 'none'; return; }
659
+ memberLabelHost.style.display = 'block';
660
+ const rect = canvasEl.getBoundingClientRect();
661
+ for (const el of memberLabelPool) {
662
+ if (el.style.display === 'none' || !el._mid) continue;
663
+ if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; }
664
+ const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
665
+ if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
666
+ el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
667
+ el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
668
+ el.style.visibility = 'visible';
669
+ }
670
+ }
671
+
672
+ // ---- Right-click property labels: the editor owns the TEXT (it has the property registry + formatters +
673
+ // scope) and hands us { labels:[{id, lines}], placement }; this view owns the ANCHOR (member start/mid/end
674
+ // via memberGeometry) + projection. Mirrors the member-label pool exactly, but stacks multiple lines per chip
675
+ // and lives in its own host so the mark/id "Labels" toggle and these can be on at once without fighting. ----
676
+ function setPropLabels(spec) {
677
+ propLabelSpec = (spec && Array.isArray(spec.labels) && spec.labels.length) ? spec : null;
678
+ syncPropLabels();
679
+ }
680
+ function syncPropLabels() {
681
+ if (!propLabelHost || !api) return;
682
+ const labels = propLabelSpec ? propLabelSpec.labels : [];
683
+ const place = propLabelSpec ? propLabelSpec.placement : 'mid';
684
+ while (propLabelPool.length < labels.length) {
685
+ const el = document.createElement('div');
686
+ el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:#0b1220;color:#e2e8f0;border:1px solid var(--brand,#3b82f6);border-radius:4px;padding:2px 6px;font:11px system-ui;line-height:1.35;text-align:center;white-space:pre;box-shadow:0 0 0 1px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.5)'; // multi-line via white-space:pre; brand-blue accent matches the 2D chips
687
+ propLabelHost.appendChild(el); propLabelPool.push(el);
688
+ }
689
+ const byId = new Map(members().map((m) => [m.id, m]));
690
+ const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
691
+ for (let i = 0; i < propLabelPool.length; i++) {
692
+ const el = propLabelPool[i], L = labels[i], m = L ? byId.get(L.id) : null;
693
+ if (!m || !Array.isArray(m.wp) || m.wp.length < 2 || !L.lines || !L.lines.length) { el.style.display = 'none'; el._mid = null; el._memberId = null; continue; }
694
+ const g = memberGeometry(m, ppf, dtos), a = g.line[0], b = g.line[1];
695
+ el._mid = place === 'start' ? a : place === 'end' ? b : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
696
+ el._memberId = m.id;
697
+ el.textContent = L.lines.join('\n');
698
+ el.style.display = 'block';
699
+ }
700
+ }
701
+ // Project each label's anchor → screen px (called from the render loop, like the dim + member labels).
702
+ function positionPropLabels() {
703
+ if (!propLabelHost) return;
704
+ if (!propLabelSpec || (canvasEl && canvasEl.style.display === 'none')) { propLabelHost.style.display = 'none'; return; }
705
+ propLabelHost.style.display = 'block';
706
+ const rect = canvasEl.getBoundingClientRect();
707
+ for (const el of propLabelPool) {
708
+ if (el.style.display === 'none' || !el._mid) continue;
709
+ const mesh = meshById.get(el._memberId);
710
+ if (mesh && mesh.visible === false) { el.style.visibility = 'hidden'; continue; } // member legend-hidden or isolated away → hide its labels too
711
+ if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; } // sectioned away by a clip
712
+ const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
713
+ if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; } // behind / off-screen
714
+ el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
715
+ el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
716
+ el.style.visibility = 'visible';
717
+ }
718
+ }
719
+
554
720
  // ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
555
721
  // them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
556
722
  // wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
@@ -738,8 +904,9 @@ function setDisplayMode(m) { displayMode = m; applyDisplayMode(); }
738
904
  // solid → opaque; wire → wireframe; xray → translucent, no depth write. Applied to whatever material
739
905
  // each mesh currently carries (shared profile material OR its selection clone).
740
906
  function applyDisplayMode() {
741
- for (const mesh of meshById.values()) {
907
+ for (const [id, mesh] of meshById) {
742
908
  const mat = mesh.material; if (!mat) continue;
909
+ if (String(id).startsWith('det:')) continue; // placed details (Slice 4) own their transparency + opacity — display mode must not override them
743
910
  if (displayMode === 'wire') { mat.wireframe = true; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
744
911
  else if (displayMode === 'xray') { mat.wireframe = false; mat.transparent = true; mat.opacity = 0.25; mat.depthWrite = false; }
745
912
  else { mat.wireframe = false; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
@@ -1132,6 +1299,7 @@ function onKey(e) {
1132
1299
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
1133
1300
  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
1301
  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
1302
+ 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
1303
  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
1304
  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
1305
  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 +1366,10 @@ function setSelection(ids) {
1198
1366
  const set = ids instanceof Set ? ids : new Set(ids || []);
1199
1367
  for (const [id, mesh] of meshById) {
1200
1368
  const on = set.has(id);
1369
+ // A placed detail (Slice 4) uses a textured MeshBasicMaterial with no `.emissive` — the member
1370
+ // material swap below would drop its texture (and MeshBasicMaterial has no emissive to glow). Keep
1371
+ // its plane and tint the map (blue when selected, white = untinted otherwise) as the selection cue.
1372
+ if (String(id).startsWith('det:')) { if (mesh.material && mesh.material.color) mesh.material.color.setHex(on ? 0x93c5fd : 0xffffff); continue; }
1201
1373
  // Selected → a per-mesh clone that glows (so one selected member doesn't tint its whole profile
1202
1374
  // group). Deselected → back to the SHARED profile material (its real colour).
1203
1375
  mesh.material = on ? selectedMaterial(mesh) : materialFor(mesh.userData.group);
@@ -2046,6 +2218,7 @@ function onDown(e) {
2046
2218
  return; }
2047
2219
  if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
2048
2220
  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
2221
+ 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
2222
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
2050
2223
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
2051
2224
  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 +2530,12 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
2357
2530
  function hide() {
2358
2531
  if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
2359
2532
  if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
2533
+ if (insertMode) setInsertMode(false); // disarm a pending detail placement — else the first click on return would place it
2360
2534
  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
2535
  cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
2362
2536
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
2537
+ 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
2538
+ if (propLabelHost) propLabelHost.style.display = 'none'; // property labels ride the same loop — hide synchronously so none strand over the 2D UI
2363
2539
  clearCopyGhost(); // drop a stale Ctrl+drag copy ghost on the way out
2364
2540
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
2365
2541
  }
@@ -2376,6 +2552,10 @@ function dispose() {
2376
2552
  window.removeEventListener('keydown', onKey);
2377
2553
  for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
2378
2554
  dimLabelHost = null; dimLabelPool.length = 0;
2555
+ if (memberLabelHost && memberLabelHost.parentNode) memberLabelHost.parentNode.removeChild(memberLabelHost);
2556
+ memberLabelHost = null; memberLabelPool.length = 0; labelsOnFlag = false; insertMode = false; insertPending = null;
2557
+ if (propLabelHost && propLabelHost.parentNode) propLabelHost.parentNode.removeChild(propLabelHost);
2558
+ propLabelHost = null; propLabelPool.length = 0; propLabelSpec = null;
2379
2559
  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
2560
  if (!w) continue;
2381
2561
  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 +2636,11 @@ window.Steel3DView = {
2456
2636
  init, show, hide, rebuild, setSelection, isReady, dispose,
2457
2637
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
2458
2638
  setRefLine, refLine: () => refLineOn,
2639
+ setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
2640
+ setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2641
+ syncMemberLabels, // editor calls after a mark/id edit to refresh labels
2642
+ setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
2643
+ propLabelTexts: () => propLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent), // visible property-label chips — for tests
2459
2644
  refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
2460
2645
  gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
2461
2646
  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}
@@ -224,6 +229,11 @@
224
229
  text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
225
230
  line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
226
231
  circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
232
+ /* property-label chips (right-click → Properties popup): dark chip + brand-blue accent, distinct from the cyan dimension chips.
233
+ pointer-events:none on the whole group — labels are passive annotations; clicks/marquee/drag pass through to the members underneath. */
234
+ g.pllabels{pointer-events:none}
235
+ rect.plchip{fill:#0b1220;stroke:var(--brand);stroke-width:1;vector-effect:non-scaling-stroke}
236
+ text.pltx{fill:#e2e8f0;font-family:system-ui;text-anchor:middle;dominant-baseline:central}
227
237
  /* structural grid layer (under the members): dash-dot slate lines + label bubbles at both ends */
228
238
  line.gridln{stroke:#64748b;stroke-width:1.3;stroke-dasharray:12 4 2 4;vector-effect:non-scaling-stroke;pointer-events:none;opacity:.85}
229
239
  line.gridhit{stroke:transparent;stroke-width:9;vector-effect:non-scaling-stroke;pointer-events:stroke}
@@ -284,6 +294,34 @@
284
294
  .m3dmenu .wpprow button{width:auto;flex:none;padding:4px 8px;border:1px solid var(--line);border-radius:5px}
285
295
  .m3dmenu .wpprow input{width:74px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 6px;font:12px system-ui}
286
296
  .m3dmenu .wpprow input:focus{outline:none;border-color:var(--brand)}
297
+ /* right-click property-labels popup — floating panel, built from the same tokens as .m3dmenu / .mpanel (no new vocabulary) */
298
+ #propPop{position:fixed;left:0;top:0;z-index:46;width:280px;max-height:60vh;display:none;flex-direction:column;background:var(--panel);border:1px solid #475569;border-radius:10px;box-shadow:0 16px 48px rgba(0,0,0,.6);font-size:12px;color:var(--text)}
299
+ #propPop.open{display:flex}
300
+ #propPop .pph{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line)}
301
+ #propPop .pph b{font-size:12px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
302
+ #propPop .pph .pcount{color:var(--mut);font-size:10px;white-space:nowrap}
303
+ #propPop .pph button{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:4px;font-size:11px;padding:1px 6px;cursor:pointer;line-height:1.5}
304
+ #propPop .pph button:hover{border-color:var(--brand);color:var(--text)}
305
+ #propPop .pph button.pin.on{border-color:var(--brand);color:var(--brand)}
306
+ #propPop .ppsearch{padding:8px 10px 4px}
307
+ #propPop .ppsearch input{width:100%;height:26px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:0 8px;font:12px system-ui;box-sizing:border-box}
308
+ #propPop .ppsearch input:focus{outline:none;border-color:var(--brand)}
309
+ #propPop .ppmeta{color:var(--mut);font-size:10px;padding:2px 10px 0}
310
+ #propPop .ppscope{color:var(--mut);font-size:10px;padding:2px 10px 6px;font-style:italic}
311
+ #propPop .pplist{overflow:auto;flex:1;padding:2px 0;min-height:40px;border-top:1px solid var(--line)}
312
+ #propPop .pprow{display:flex;align-items:center;gap:8px;min-height:32px;padding:2px 10px;cursor:pointer}
313
+ #propPop .pprow:hover{background:#334155}
314
+ #propPop .pprow.dis{opacity:.5;cursor:default}
315
+ #propPop .pprow.dis:hover{background:transparent}
316
+ #propPop .pprow input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto} /* width:auto beats the editor's global input{width:100%} that would otherwise stretch the checkbox across the row */
317
+ #propPop .pprow .pn{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
318
+ #propPop .pprow .pv{color:var(--mut);font-variant-numeric:tabular-nums;max-width:44%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
319
+ #propPop .pprow .pv.varies{font-style:italic}
320
+ #propPop .ppempty{color:var(--mut);font-size:11px;padding:12px 10px;text-align:center}
321
+ #propPop .ppfoot{border-top:1px solid var(--line);padding:8px 10px;display:flex;flex-direction:column;gap:6px}
322
+ #propPop .ppfoot .seg2{margin-top:0}
323
+ #propPop .ppfoot label{display:flex;align-items:center;gap:7px;color:var(--text);cursor:pointer;font-size:11px}
324
+ #propPop .ppfoot label input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto}
287
325
  /* thin cluster divider in the 3D toolbar */
288
326
  #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
289
327
  /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
@@ -433,6 +471,7 @@
433
471
  <span class=tb-sep></span>
434
472
  <!-- Measure / annotate -->
435
473
  <button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
474
+ <button id=m3dLabels data-tip="Show each member's mark/id label in the 3D view">Labels</button>
436
475
  <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
476
  <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
477
  <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
@@ -447,6 +486,11 @@
447
486
  <button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
448
487
  </div>
449
488
  </div>
489
+ <div class=m3dwrap>
490
+ <button id=m3dInsert data-tip="Insert a 2D detail image into the 3D scene, near a beam">Insert ▾</button>
491
+ <div id=m3dInsertMenu class=m3dmenu role=menu></div>
492
+ <input id=insFile type=file accept="image/*" style="display:none">
493
+ </div>
450
494
  <div class=m3dwrap>
451
495
  <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
496
  <div id=m3dWpMenu class=m3dmenu role=menu>
@@ -594,8 +638,9 @@ async function boot() {
594
638
  C.custom_details = C.custom_details || {};
595
639
  C.profile_colors = C.profile_colors || {};
596
640
  if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
641
+ if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
597
642
  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
- main();
643
+ main(); // (C.prop_labels is normalised inside main(), right after the PROP_DEFS/sanitizePropLabels registry is defined — see ~"normalise the contract's incoming value" — since that helper isn't in scope out here in boot())
599
644
  // SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
600
645
  // We open our own EventSource to the same /api/events endpoint as the main app.
601
646
  // Uses the same parse format: es.onmessage → JSON.parse(e.data) → {type, ...}.
@@ -762,7 +807,7 @@ const autofillTOS=syncDefaults;
762
807
  // live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
763
808
  // unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
764
809
  // 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
810
+ 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
811
  // Cross-plan undo (spec §4.5): a commit that also touches ANOTHER plan captures that plan's member
767
812
  // slice too; undo/redo restore it by sheet. The entry lives on the CURRENT plan's stack — undoing
768
813
  // from the plan where the command ran is the supported path (same per-plan-stack rule as everything).
@@ -797,8 +842,9 @@ function apply(json){const d=JSON.parse(json);
797
842
  if(Array.isArray(d.others))for(const o of d.others){const tp=C.plans.find(p=>p.sheet===o.sheet);
798
843
  if(tp&&tp!==P)tp.members=(o.members||[]).map(ensureMeta);
799
844
  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.
845
+ if('details' in d)C.detail_placements=Array.isArray(d.details)?d.details:[]; // restore the model-global placed details (Slice 4)
800
846
  updCS();
801
- selIds=new Set([...selIds].filter(id=>byId(id)));
847
+ selIds=new Set([...selIds].filter(id=>byId(id)||/^det:/.test(id))); // keep a selected placed-detail id (det:…) — it isn't a member
802
848
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
803
849
  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
850
  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 +865,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
819
865
  else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
820
866
  else el.textContent=msg||'Auto-save on';}
821
867
  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,
868
+ custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, prop_labels:C.prop_labels, joints:C.joints, detail_placements:C.detail_placements,
823
869
  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
870
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
825
871
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
@@ -851,7 +897,9 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
851
897
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
852
898
  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
899
  if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
900
+ if(Array.isArray(d.detail_placements))C.detail_placements=d.detail_placements; // restore placed details
854
901
  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)
902
+ if('prop_labels' in d)C.prop_labels=sanitizePropLabels(d.prop_labels); // restore canvas property-label display state (sanitised to known keys/placement)
855
903
  if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
856
904
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
857
905
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
@@ -1277,9 +1325,11 @@ function render(){
1277
1325
  for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
1278
1326
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
1279
1327
  s+=renderDims();
1328
+ s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
1280
1329
  if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
1281
1330
  svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle();
1282
1331
  if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
1332
+ syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
1283
1333
  }
1284
1334
  function updDup(){const n=redundantDups().length;
1285
1335
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
@@ -1297,6 +1347,15 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
1297
1347
  svg.querySelectorAll('text.gridtx').forEach(t=>t.style.fontSize=(12/zoom)+'px');
1298
1348
  svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
1299
1349
  r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
1350
+ // property-label chips: recompute size + stacked screen offset from the baked anchor (ax,ay) + per-row screen
1351
+ // offset (off) so they stay constant on screen while zooming (like dim chips) — AND re-apply the zoom
1352
+ // threshold live (data-mlen × zoom), so zooming reveals/hides labels as members cross ~24px on screen.
1353
+ {const plG=svg.querySelector('g.pllabels');if(plG){let hid=0;
1354
+ plG.querySelectorAll('rect.plchip').forEach(r=>{const w=(+r.dataset.tw*6.4+12)/zoom,h=15/zoom,cy=(+r.dataset.ay)+(+r.dataset.off)/zoom;
1355
+ const show=(+r.dataset.mlen)*zoom>=PLABEL_MIN_PX;r.style.display=show?'':'none';if(!show)hid++;
1356
+ r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.ax)-w/2);r.setAttribute('y',cy-h/2);});
1357
+ plG.querySelectorAll('text.pltx').forEach(t=>{const show=(+t.dataset.mlen)*zoom>=PLABEL_MIN_PX;t.style.display=show?'':'none';t.setAttribute('y',(+t.dataset.ay)+(+t.dataset.off)/zoom);t.style.fontSize=(11/zoom)+'px';});
1358
+ propLabelsHidden=hid;updatePropHint();}} // hid counts hidden chip ROWS (>0 ⇒ note shows) — the note only needs the boolean
1300
1359
  const cg=svg.querySelector('g.csglyph');if(cg&&P.frame)cg.outerHTML=axisGlyphSvg(P.frame.o,P.frame.u,false);} // glyph is sized in 1/zoom → regenerate on zoom (like the dim chips)
1301
1360
  function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
1302
1361
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
@@ -1358,6 +1417,40 @@ function panel(){
1358
1417
  const ap=document.getElementById('addProf');
1359
1418
  ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
1360
1419
  return;}
1420
+ // A placed detail image (Slice 4) selected — its own inspector: Position / Orientation / Appearance +
1421
+ // "Ask AI to build this" (records a modify request) + Remove. Every field mutation rides edit() → undoable.
1422
+ // HTML built exactly like the connection-part inspector below: every dynamic value is esc()-escaped
1423
+ // (the file's HTML-escaper), numbers are cast to Number — no untrusted markup reaches innerHTML.
1424
+ {const dsel=[...selIds];
1425
+ const detId=dsel.length===1&&/^det:/.test(dsel[0])?dsel[0].slice(4):null;
1426
+ const dp=detId?(C.detail_placements||[]).find(x=>x&&x.id===detId):null;
1427
+ if(dp){
1428
+ const inMM=v=>{const pl=parseLen(v);return pl==null?NaN:pl*25.4;}; // ft-in field → mm
1429
+ const ftin=mm=>esc(fmtFtIn(Number(mm||0)/25.4));
1430
+ const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${esc(t)}</span><hr></div>`;
1431
+ 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)
1432
+ const opPct=Math.round((dp.opacity==null?1:dp.opacity)*100);
1433
+ const host=dp.anchorId?('On <b>'+esc(dp.anchorId)+'</b> · '):'';
1434
+ const html=`<h3>Placed detail <span style="color:var(--mut);font-weight:400">· ${esc(dp.detailName||'')}</span></h3>`
1435
+ +`<div class="row hint" style="margin-top:2px">${host}flat image plane — a reference (v1). Ask the AI to build real geometry.</div>`
1436
+ +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>`
1437
+ +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>`
1438
+ +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>`
1439
+ +`<div class=divrow><hr></div>`
1440
+ +`<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>`;
1441
+ p.innerHTML=html;
1442
+ const find=()=>(C.detail_placements||[]).find(x=>x&&x.id===detId);
1443
+ const wr=(id,fn)=>{const i=document.getElementById(id);if(i)i.onchange=e=>fn(e.target.value);};
1444
+ 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;}});};
1445
+ wr('detX',v=>setPos(0,v));wr('detY',v=>setPos(1,v));wr('detZ',v=>setPos(2,v));
1446
+ wr('detRot',v=>{const n=Number((v||'').replace(',','.'));if(!isFinite(n))return;edit(()=>{const d=find();if(d)d.rotZ=n;});});
1447
+ wr('detSize',v=>{const mm=inMM(v);if(!isFinite(mm)||mm<=0)return;edit(()=>{const d=find();if(d)d.size=mm;});});
1448
+ {const r=document.getElementById('detOpacity'),ov=document.getElementById('detOpacityV');
1449
+ 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;});};}}
1450
+ {const ab=document.getElementById('detAsk');if(ab)ab.onclick=()=>detailRequest('modify',dp);}
1451
+ {const rm=document.getElementById('detRemove');if(rm)rm.onclick=()=>edit(()=>{C.detail_placements=(C.detail_placements||[]).filter(x=>x&&x.id!==detId);selIds.clear();});}
1452
+ return;
1453
+ }}
1361
1454
  // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
1362
1455
  // read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
1363
1456
  {const selList=[...selIds];
@@ -1692,6 +1785,160 @@ function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
1692
1785
  else{const r=7/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x-r} ${y-r} L ${x+r} ${y-r} L ${x-r} ${y+r} L ${x+r} ${y+r} Z`);} // on-line ("nearest") = HOURGLASS/clepsydra (AutoCAD Nearest glyph); circle is reserved for a future Center snap
1693
1786
  el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);document.body.classList.add('snapping');} // marker up → hide the native crosshair (see .snapping CSS)
1694
1787
  function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();const g=document.getElementById('snapExtLine');if(g)g.remove();document.body.classList.remove('snapping');}
1788
+ // ===== Right-click property labels (Properties popup → text-chip labels on the 2D/3D canvas) =====
1789
+ // One registry drives BOTH the popup rows and the rendered labels: {key,label,get(m),fmt}. get() returns
1790
+ // the raw value, or `undefined` when the property doesn't apply to that member's role (shown as "—",
1791
+ // checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
1792
+ // go through the same imperial formatters the side pane uses; the contract stays canonical metric.
1793
+ function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
1794
+ function _lenFt(m){return len(m.wp[0],m.wp[1])/FT;}
1795
+ const PROP_DEFS=[
1796
+ {key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
1797
+ {key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
1798
+ {key:'type', label:'Type', get:m=>MTYPE_LABEL[memberTypeOf(m)], fmt:v=>v==null?'':String(v)},
1799
+ {key:'role', label:'Role', get:m=>m.role==='column'?'Column':'Beam', fmt:v=>v||''},
1800
+ {key:'length', label:'Length', get:m=>_lenFt(m), fmt:v=>v==null?'':v.toFixed(1)+' ft'},
1801
+ {key:'weight', label:'Weight', get:m=>{const w=_wt(m.profile);return w==null?undefined:_lenFt(m)*w;}, fmt:v=>v==null?'':Math.round(v).toLocaleString()+' lb'},
1802
+ {key:'position', label:'Position', get:m=>m.role==='column'?undefined:posDefault(m), fmt:v=>v?v[0].toUpperCase()+v.slice(1):''},
1803
+ {key:'verified', label:'Verified', get:m=>m.verified===true?'Yes':'No', fmt:v=>v||''},
1804
+ {key:'tos_start', label:'TOS (start)', get:m=>m.role==='column'?undefined:_effTos(m.ends&&m.ends[0]), fmt:v=>v==null?'':fmtFtIn(v)},
1805
+ {key:'tos_end', label:'TOS (end)', get:m=>m.role==='column'?undefined:_effTos(m.ends&&m.ends[1]), fmt:v=>v==null?'':fmtFtIn(v)},
1806
+ {key:'col_tos', label:'Top (TOS)', get:m=>m.role==='column'?_effTos(m.col):undefined, fmt:v=>v==null?'':fmtFtIn(v)},
1807
+ {key:'col_bos', label:'Bottom (BOS)', get:m=>m.role==='column'?(m.col?m.col.bos:undefined):undefined, fmt:v=>v==null?'':fmtFtIn(v)},
1808
+ {key:'note_start',label:'Note (start)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[0])?(m.ends[0].note||''):undefined), fmt:v=>v==null?'':String(v)},
1809
+ {key:'note_end', label:'Note (end)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[1])?(m.ends[1].note||''):undefined), fmt:v=>v==null?'':String(v)},
1810
+ {key:'col_note', label:'Note', get:m=>m.role==='column'?(m.col?(m.col.note||''):undefined):undefined, fmt:v=>v==null?'':String(v)},
1811
+ {key:'detail_start',label:'Detail (start)',get:m=>m.role==='column'?undefined:((m.ends&&m.ends[0])?(m.ends[0].detail||''):undefined), fmt:v=>v==null?'':String(v)},
1812
+ {key:'detail_end',label:'Detail (end)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[1])?(m.ends[1].detail||''):undefined), fmt:v=>v==null?'':String(v)},
1813
+ {key:'col_detail',label:'Detail', get:m=>m.role==='column'?(m.col?(m.col.detail||''):undefined):undefined, fmt:v=>v==null?'':String(v)},
1814
+ ];
1815
+ const PROP_KEYS=new Set(PROP_DEFS.map(d=>d.key));
1816
+ // normalise persisted/incoming prop_labels to known keys/placement so a corrupt draft can't desync the UI
1817
+ function sanitizePropLabels(x){const o=(x&&typeof x==='object'&&!Array.isArray(x))?x:{};
1818
+ return {props:Array.isArray(o.props)?o.props.filter(k=>PROP_KEYS.has(k)):[],
1819
+ placement:['start','mid','end'].includes(o.placement)?o.placement:'mid',
1820
+ selected_only:o.selected_only===true,
1821
+ ids:Array.isArray(o.ids)?o.ids.filter(v=>typeof v==='string'):[]};}
1822
+ C.prop_labels=sanitizePropLabels(C.prop_labels); // normalise the contract's incoming value now (PROP_KEYS is initialised above; runs before main's bootstrap render + restoreSaved override)
1823
+ // the "Label: value" lines a member contributes for the currently-checked props (registry order; skips N/A + empty)
1824
+ function propLabelLinesFor(m){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];ensureMeta(m);
1825
+ const out=[];for(const def of PROP_DEFS){if(!pl.props.includes(def.key))continue;const raw=def.get(m);if(raw===undefined)continue;const t=def.fmt(raw);if(t==='')continue;out.push(def.label+': '+t);}return out;}
1826
+ // members that actually get a label, honouring the plan-wide-vs-selected-only scope
1827
+ function propLabelMembers(){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];
1828
+ let base=P.members;if(pl.selected_only){const ids=new Set(pl.ids||[]);base=P.members.filter(m=>ids.has(m.id));}
1829
+ return base.filter(m=>propLabelLinesFor(m).length>0);}
1830
+ // one popup row's aggregate across the current selection: a shared value, "Varies", or N/A (disabled)
1831
+ function propAggRow(def,arr){let anyApplicable=false,anyNonEmpty=false;const vals=[];
1832
+ for(const m of arr){ensureMeta(m);const raw=def.get(m);if(raw===undefined)continue;anyApplicable=true;const t=def.fmt(raw);vals.push(t);if(t!=='')anyNonEmpty=true;}
1833
+ if(!anyApplicable||!anyNonEmpty)return {state:'na'};
1834
+ const nonEmpty=[...new Set(vals.filter(t=>t!==''))];
1835
+ if(nonEmpty.length===1&&vals.every(t=>t===nonEmpty[0]))return {state:'val',text:nonEmpty[0]};
1836
+ return {state:'varies'};}
1837
+
1838
+ // ---- 2D SVG chips (rendered inside render(); zoom-constant via data-ax/ay/off, rescaled in updateBadges) ----
1839
+ let propLabelsHidden=0;
1840
+ // A member's labels hide when its on-screen length < this (px) — the density guard (§5.5). Kept as one
1841
+ // constant so renderPropLabels (initial paint) and updateBadges (live on zoom) agree.
1842
+ const PLABEL_MIN_PX=24;
1843
+ function renderPropLabels(){const pl=C.prop_labels;propLabelsHidden=0;if(!pl||!pl.props.length)return '';
1844
+ const ms=propLabelMembers();if(!ms.length)return '';
1845
+ let hidden=0,s='';
1846
+ // Emit chips for EVERY labelled member (carrying its display length in data-mlen); the zoom threshold
1847
+ // only sets each chip's initial visibility. updateBadges re-applies it live as the user zooms — so
1848
+ // zooming in reveals a hidden label and zooming out hides it (they're in the DOM either way).
1849
+ for(const m of ms){const lines=propLabelLinesFor(m);if(!lines.length)continue;
1850
+ const a=m.wp[0],b=m.wp[1],mlen=len(a,b);const show=mlen*zoom>=PLABEL_MIN_PX;if(!show)hidden++;
1851
+ const anc=pl.placement==='start'?a:pl.placement==='end'?b:[(a[0]+b[0])/2,(a[1]+b[1])/2];
1852
+ const n=lines.length,hide=show?'':';display:none';
1853
+ lines.forEach((txt,i)=>{const off=(i-(n-1)/2)*15,tw=txt.length,w=(tw*6.4+12)/zoom,h=15/zoom,cy=anc[1]+off/zoom;
1854
+ s+=`<rect class=plchip data-ax="${anc[0]}" data-ay="${anc[1]}" data-off="${off}" data-tw="${tw}" data-mlen="${mlen}" x="${anc[0]-w/2}" y="${cy-h/2}" width="${w}" height="${h}" rx="${3/zoom}" style="${hide}"/>`
1855
+ +`<text class=pltx data-ax="${anc[0]}" data-ay="${anc[1]}" data-off="${off}" data-mlen="${mlen}" x="${anc[0]}" y="${cy}" style="font-size:${11/zoom}px${hide}">${esc(txt)}</text>`;});}
1856
+ propLabelsHidden=hidden;
1857
+ return `<g class="pllabels">${s}</g>`;}
1858
+ // fixed-screen corner note when the zoom threshold is hiding labels (an HTML pill, outside the zoomed SVG)
1859
+ function updatePropHint(){let el=document.getElementById('plHint');
1860
+ const show=!view3d&&propLabelsHidden>0;
1861
+ if(!show){if(el)el.style.display='none';return;}
1862
+ if(!el){el=document.createElement('div');el.id='plHint';el.style.cssText='position:fixed;z-index:20;pointer-events:none;background:var(--panel);color:var(--mut);border:1px solid var(--line);border-radius:6px;padding:3px 9px;font:11px system-ui;box-shadow:0 2px 8px rgba(0,0,0,.4)';document.body.appendChild(el);}
1863
+ const st=document.getElementById('stage').getBoundingClientRect();
1864
+ el.textContent='Labels hidden — zoom in';el.style.display='block';
1865
+ el.style.left=(st.left+st.width/2-70)+'px';el.style.top=(st.bottom-30)+'px';}
1866
+
1867
+ // ---- 3D bridge: hand the label spec to the 3D view (it owns projection/placement over member geometry) ----
1868
+ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabels)return;
1869
+ const pl=C.prop_labels;
1870
+ if(!pl||!pl.props.length){V.setPropLabels(null);return;}
1871
+ V.setPropLabels({labels:propLabelMembers().map(m=>({id:m.id,lines:propLabelLinesFor(m)})),placement:pl.placement});}
1872
+
1873
+ // ---- The floating Properties popup ----
1874
+ let propPopPinned=false;
1875
+ function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
1876
+ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
1877
+ el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
1878
+ el.innerHTML=`<div class=pph><b id=ppTitle>Properties</b><span class=pcount id=ppLabeled></span>`
1879
+ +`<button class=pin id=ppPin title="Pin — keep open across selections">📌</button>`
1880
+ +`<button id=ppClear title="Uncheck every label">Clear all</button>`
1881
+ +`<button id=ppClose title="Close (Esc)">✕</button></div>`
1882
+ +`<div class=ppsearch><input id=ppSearch placeholder="Search properties…" autocomplete=off aria-label="Search properties"></div>`
1883
+ +`<div class=ppmeta id=ppMeta></div><div class=ppscope id=ppScope></div>`
1884
+ +`<div class=pplist id=ppList></div>`
1885
+ +`<div class=ppfoot><div class=seg2 id=ppPlace><button data-pl=start>Start</button><button data-pl=mid>Middle</button><button data-pl=end>End</button></div>`
1886
+ +`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
1887
+ document.body.appendChild(el);
1888
+ const list=el.querySelector('#ppList');
1889
+ list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis'))return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);});
1890
+ list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row)togglePropKey(row.dataset.k,cb.checked);});
1891
+ el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
1892
+ el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
1893
+ el.querySelector('#ppClose').onclick=()=>closePropPop(true);
1894
+ el.querySelector('#ppPin').onclick=()=>{propPopPinned=!propPopPinned;if(propPopPinned)dockPropPop();renderPropPop();};
1895
+ el.querySelector('#ppPlace').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;C.prop_labels.placement=b.dataset.pl;refreshPropLabels();});
1896
+ el.querySelector('#ppSel').addEventListener('change',e=>{const pl=C.prop_labels;pl.selected_only=e.target.checked;if(pl.selected_only)pl.ids=selArr().map(m=>m.id);refreshPropLabels();});
1897
+ // focus trap + Esc (behaves as a lightweight non-modal dialog)
1898
+ el.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();closePropPop(true);return;}
1899
+ if(e.key!=='Tab')return;const f=[...el.querySelectorAll('button,input,[tabindex]')].filter(n=>!n.disabled&&n.offsetParent!==null);
1900
+ if(!f.length)return;const first=f[0],last=f[f.length-1];
1901
+ if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
1902
+ else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}});
1903
+ return el;}
1904
+ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(on===undefined)on=!has;
1905
+ if(on&&!has)pl.props.push(k);else if(!on&&has)pl.props=pl.props.filter(x=>x!==k);
1906
+ if(on&&pl.selected_only&&selArr().length)pl.ids=selArr().map(m=>m.id); // Selected-only: re-target the pinned set to the current selection when adding a label (so checking a prop after re-selecting labels what's actually selected, not a stale set)
1907
+ refreshPropLabels();}
1908
+ // rebuild the popup contents against the current selection (chrome + rows), preserving row focus
1909
+ function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
1910
+ const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
1911
+ el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
1912
+ el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
1913
+ el.querySelector('#ppScope').textContent=pl.selected_only?'Labels apply to the selected members':'Labels apply to all members on this plan';
1914
+ el.querySelectorAll('#ppPlace button').forEach(b=>b.classList.toggle('on',b.dataset.pl===pl.placement));
1915
+ el.querySelector('#ppSel').checked=pl.selected_only;
1916
+ const ae=document.activeElement,fk=(ae&&ae.closest&&ae.closest('.pprow'))?ae.closest('.pprow').dataset.k:null;
1917
+ let shown=0;const rows=PROP_DEFS.map(def=>{
1918
+ if(q&&!def.label.toLowerCase().includes(q))return '';
1919
+ shown++;const r=propAggRow(def,arr),checked=pl.props.includes(def.key),dis=r.state==='na';
1920
+ const val=r.state==='val'?esc(r.text):r.state==='varies'?'Varies':'—',vc=r.state==='varies'?' varies':'';
1921
+ return `<div class="pprow${dis?' dis':''}" data-k="${def.key}"${dis?' title="Nothing to label — no value in the selection"':''}>`
1922
+ +`<input type=checkbox ${checked?'checked':''}${dis?' disabled':''} aria-label="${esc(def.label)}">`
1923
+ +`<span class=pn>${esc(def.label)}</span><span class="pv${vc}">${val}</span></div>`;}).join('');
1924
+ el.querySelector('#ppList').innerHTML=rows||'<div class=ppempty>No properties match your search.</div>';
1925
+ el.querySelector('#ppMeta').textContent=shown+' of '+PROP_DEFS.length+' shown';
1926
+ if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
1927
+ function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
1928
+ el.style.left=Math.max(4,st.right-el.offsetWidth-12)+'px';el.style.top=(st.top+12)+'px';}
1929
+ function openPropLabels(x,y){if(!selArr().length)return;const el=propPopEl();const sr=el.querySelector('#ppSearch');
1930
+ sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
1931
+ el.classList.add('open');renderPropPop();
1932
+ if(propPopPinned){dockPropPop();}
1933
+ else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
1934
+ setTimeout(()=>sr.focus(),0);}
1935
+ function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
1936
+ const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
1937
+ document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
1938
+ // after every render the checked labels + the popup rows stay in sync with the (possibly changed) selection/geometry
1939
+ function syncPropLabelsAfterRender(){updatePropHint();refreshPropLabels3d();if(propPopOpen())renderPropPop();}
1940
+ function refreshPropLabels(){scheduleSave();render();} // render() → renderPropLabels() (2D) + syncPropLabelsAfterRender() (hint/3D/popup)
1941
+
1695
1942
  // --- Tekla-style snap override (right-click): restrict snapping to ONE type for the current operation.
1696
1943
  // 2D: right-click anywhere on the canvas (set it before grabbing an endpoint — a drag captures the pointer).
1697
1944
  // 3D: right-CLICK while the Dimension tool is armed (right-DRAG stays orbit/pan). The override clears when
@@ -1720,11 +1967,17 @@ function openSnapMenu(x,y,is3d){const m=snapMenuEl();m._is3d=!!is3d;
1720
1967
  m.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';m.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';
1721
1968
  m.focus();}
1722
1969
  document.addEventListener('pointerdown',e=>{if(snapMenuOpen()&&!e.target.closest('#snapMenu'))closeSnapMenu();},true);
1723
- document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDefault();openSnapMenu(e.clientX,e.clientY,false);});
1970
+ // Right-click routes by intent (spec §2a — the two conditions are mutually exclusive, so exactly one opens):
1971
+ // a selection in plain select mode → the Properties popup; otherwise (no selection, or a placing/measuring
1972
+ // mode armed) → the snap-override menu. This gates the previously-unconditional 2D snap menu.
1973
+ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDefault();
1974
+ if(mode==='sel'&&!dimMode&&!csaxisMode&&!dimSplitMode&&!geoMode&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;}
1975
+ openSnapMenu(e.clientX,e.clientY,false);});
1724
1976
  document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
1725
- if(!V||!V.dimToolOn||!V.dimToolOn())return; // 3D menu only for the armed dim tool
1726
- if(V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click
1727
- openSnapMenu(e.clientX,e.clientY,true);});
1977
+ if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
1978
+ if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed snap-override menu (unchanged)
1979
+ if(mode==='sel'&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;} // a selection → Properties popup
1980
+ });
1728
1981
  document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
1729
1982
  // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
1730
1983
  // buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
@@ -2275,6 +2528,7 @@ addEventListener('keydown',e=>{
2275
2528
  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
2529
  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
2530
  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
2531
+ 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
2532
  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
2533
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
2280
2534
  if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
@@ -2292,7 +2546,7 @@ addEventListener('keydown',e=>{
2292
2546
  if(ck==='m'){e.preventDefault();armCm('move',false);return;}
2293
2547
  if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
2294
2548
  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();
2549
+ 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
2550
  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
2551
  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
2552
  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 +2602,15 @@ document.getElementById('cpLevelB').onclick=()=>openLevelModal('copy');
2348
2602
  let copesByMember={}, partsById={};
2349
2603
  const view3dApi={
2350
2604
  // 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)
2605
+ 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');}}
2606
+ // Slice 4: append placed detail images as CLIENT-SIDE scene elements (kind:'image'), resolving each
2607
+ // placement's detailName → custom_details base64 in the browser. The server's contract-to-scene never
2608
+ // emits these, so they stay out of the reconstructed scene / IFC / Tekla export and the baked lock.
2609
+ // (The image bytes live in custom_details, which — as it always has — rides in the contract to the
2610
+ // localhost host; the point is that they are never turned into exported geometry.)
2611
+ 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;}
2612
+ 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
2613
+
2352
2614
  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
2615
  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
2616
  getMembers:()=>P.members, // raw members (wp) for snap geometry
@@ -2415,12 +2677,44 @@ const view3dApi={
2415
2677
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
2416
2678
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
2417
2679
  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)
2680
+ 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
2681
+ 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
2682
+ const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
2683
+ 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};
2684
+ edit(()=>{if(!Array.isArray(C.detail_placements))C.detail_placements=[];C.detail_placements.push(place);selIds=new Set(['det:'+id]);});
2685
+ toast('Detail placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — adjust it in the panel or ask the AI to build it');
2686
+ detailRequest('create',place);},
2418
2687
  onIsolateChange:()=>{updateIsolateBtn();refresh3DLegend();}, // isolate / show-all (incl. Esc, legend "show all", showAllGroups clearing connHidden) → refresh the toolbar button + the legend rows' hidden state
2419
2688
  onWorkAreaChange:()=>updateWorkBtn(), // work area set / toggled → refresh the button + its checkbox
2420
2689
  };
2421
2690
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
2422
2691
  // changes go through render()'s setSelection — only geometry mutations need a rebuild.
2423
2692
  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)
2693
+ // Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
2694
+ // detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
2695
+ // so the terminal AI knows whether to build a new detail or update a placed one, and where.
2696
+ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];if(raw==null){toast('That detail image is missing');return;}
2697
+ if(!view3d){toast('Switch to the 3D view to place a detail');return;}
2698
+ window.Steel3DView.setInsertMode(true,{name});
2699
+ toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
2700
+ async function detailRequest(intent,place,note){
2701
+ // flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
2702
+ // debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
2703
+ // this browser's draft (server would keep it, localStorage wouldn't → restoreSaved clobbers it on reload).
2704
+ // Persist the local draft too so server + localStorage stay in lockstep.
2705
+ try{await window.flushContract();}catch(_){/* a failed save still lets the request queue; the AI reads the current draft */}
2706
+ try{persist();}catch(_){}
2707
+ const raw=(C.custom_details||{})[place.detailName];
2708
+ const snaps=raw?[{name:String(place.detailName||'detail')+'.jpg',dataUrl:String(raw).startsWith('data:')?raw:'data:image/jpeg;base64,'+raw}]:[];
2709
+ const pos=(place.pos||[0,0,0]).map(n=>Math.round(Number(n)||0)).join(', ');
2710
+ const instruction=note||(intent==='create'
2711
+ ? '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.'
2712
+ : 'Update the placed "'+place.detailName+'" detail (id '+place.id+') on sheet '+(place.sheet||'?')+' to match the attached image / my adjustments.');
2713
+ const ids=[intent==='create'?(place.anchorId||('det:'+place.id)):('det:'+place.id)];
2714
+ try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
2715
+ body:JSON.stringify({appId:APP_ID,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
2716
+ toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
2717
+ }catch(_){toast('Could not queue the request');}}
2424
2718
  // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
2425
2719
  // double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
2426
2720
  let leg3dClickT=null,legendAnchor=null;
@@ -2686,6 +2980,30 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2686
2980
  else{clipMenu.classList.add('open');document.addEventListener('mousedown',clipMenuOutside,true);}};
2687
2981
  clipMenu.querySelectorAll('button').forEach(b=>b.onclick=()=>{clipMenuClose();const a=b.dataset.clip;
2688
2982
  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();});
2983
+ // Labels: a plain toggle (mirrors Ref line) — show each member's mark/id in the 3D view.
2984
+ document.getElementById('m3dLabels').onclick=()=>{const on=!d3.labelsOn();d3.setLabelsOn(on);document.getElementById('m3dLabels').classList.toggle('on',on);};
2985
+ // Insert a detail: the ▾ menu lists the detail library + an "add image" option; picking one arms a
2986
+ // placement pick (onInsertPlace drops it). While armed the button is a cancel target (onInsertModeChange).
2987
+ // The menu is built with DOM nodes (textContent) — the detail names are user text, never innerHTML.
2988
+ const insBtn=document.getElementById('m3dInsert'),insMenu=document.getElementById('m3dInsertMenu');
2989
+ function insMenuOutside(e){if(!insMenu.contains(e.target)&&e.target!==insBtn)insMenuClose();}
2990
+ function insMenuClose(){insMenu.classList.remove('open');document.removeEventListener('mousedown',insMenuOutside,true);}
2991
+ function insMenuBuild(){const names=Object.keys(C.custom_details||{}).sort((a,b)=>String(a).localeCompare(String(b),undefined,{numeric:true}));
2992
+ const frag=document.createDocumentFragment();
2993
+ const lab=document.createElement('div');lab.className='mlabel';lab.textContent='Place a detail';frag.appendChild(lab);
2994
+ 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);}}
2995
+ 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);}
2996
+ frag.appendChild(document.createElement('hr'));
2997
+ const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();document.getElementById('insFile').click();};frag.appendChild(add);
2998
+ insMenu.replaceChildren(frag);}
2999
+ insBtn.onclick=e=>{e.stopPropagation();
3000
+ if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
3001
+ if(insMenu.classList.contains('open'))insMenuClose();
3002
+ else{insMenuBuild();insMenu.classList.add('open');document.addEventListener('mousedown',insMenuOutside,true);}};
3003
+ document.getElementById('insFile').onchange=e=>{const f=e.target.files&&e.target.files[0];e.target.value='';if(!f)return;
3004
+ readImageCompressed(f,b64=>{if(!b64){toast('Could not read that image — try another.');return;}
3005
+ let base=(f.name||'Detail').replace(/\.[^.]+$/,'').trim()||'Detail',name=base,i=2;while(C.custom_details[name]!=null)name=base+' '+(i++);
3006
+ edit(()=>{C.custom_details[name]=b64;});armInsert(name);});};
2689
3007
  document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
2690
3008
  // Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
2691
3009
  const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
@@ -2748,6 +3066,8 @@ async function setView(on){
2748
3066
  }
2749
3067
  }else{if(cmTool&&window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d(); // 3D→2D: drop the mm-space pick the same way (tool stays armed)
2750
3068
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();}
3069
+ if(propPopOpen()){if(propPopPinned)dockPropPop();else closePropPop(true);} // the popup is anchored to one canvas — re-dock if pinned, else dismiss on the view switch
3070
+ refreshPropLabels3d();updatePropHint(); // push the label set to whichever view is now live (3D shows them; 2D hides the 3D host) + refresh the corner note
2751
3071
  }
2752
3072
  document.getElementById('vt2d').onclick=()=>{if(view3d)setView(false);};
2753
3073
  document.getElementById('vt3d').onclick=()=>{if(!view3d)setView(true);};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.66.1",
3
+ "version": "0.68.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": {