@floless/app 0.64.0 → 0.66.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.64.0" : void 0,
53025
+ define: true ? "0.66.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.64.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.66.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -59133,14 +59133,16 @@ function writeViewer3dApp(dir, appId, scene) {
59133
59133
  bakeSceneIntoApp(flo, scene);
59134
59134
  return flo;
59135
59135
  }
59136
- function writeIfcApp(dir, appId, scene, outputPath) {
59136
+ function writeIfcApp(dir, appId, scene, outputPath, opts = {}) {
59137
59137
  (0, import_node_fs20.mkdirSync)(dir, { recursive: true });
59138
59138
  const flo = (0, import_node_path17.join)(dir, `${appId}.flo`);
59139
+ const displayName = opts.displayName ?? "Steel IFC";
59140
+ const description = opts.description ?? "IFC file written from an approved steel.takeoff/v1 contract (a companion export; the contract is the source of truth).";
59139
59141
  (0, import_node_fs20.writeFileSync)(flo, [
59140
59142
  `app: ${appId}`,
59141
59143
  "version: 0.1.0",
59142
- "display-name: Steel IFC",
59143
- "description: IFC file written from an approved steel.takeoff/v1 contract (a companion export; the contract is the source of truth).",
59144
+ `display-name: ${displayName}`,
59145
+ `description: ${description}`,
59144
59146
  "exposes-as-agent: false",
59145
59147
  "requires:",
59146
59148
  " - ifc@0.1.x",
@@ -64142,20 +64144,41 @@ async function startServer() {
64142
64144
  return { ok: true, report: { ...extracted, interactive: true }, skipped };
64143
64145
  });
64144
64146
  app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
64145
- const doc = readContract(req.params.appId);
64147
+ const doc = readContractForApp(req.params.appId);
64146
64148
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
64147
- const v = validateSteelTakeoff(doc);
64148
- if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
64149
- const { scene, skipped } = contractToScene(doc);
64149
+ const isVector = typeof doc === "object" && doc != null && doc.type === "drawing.vector/v1";
64150
+ let scene;
64151
+ let skipped;
64152
+ let extrusionsSkipped = 0;
64153
+ let writeOpts;
64154
+ let emptyError;
64155
+ if (isVector) {
64156
+ const cleaned = postProcess(doc);
64157
+ const vv = validateContract(cleaned);
64158
+ if (!vv.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
64159
+ const built = viewsToScene(cleaned);
64160
+ extrusionsSkipped = built.scene.elements.filter((e) => e.kind === "extrusion").length;
64161
+ scene = { ...built.scene, elements: built.scene.elements.filter((e) => e.kind === "box") };
64162
+ skipped = built.skipped;
64163
+ writeOpts = { displayName: "Drawing IFC", description: "IFC file written from a reconstructed drawing.vector/v1 model (member geometry only; a companion export)." };
64164
+ emptyError = extrusionsSkipped > 0 ? `no member geometry to export \u2014 this model is ${extrusionsSkipped} extruded ${extrusionsSkipped === 1 ? "body" : "bodies"}, which don't export to IFC yet` : "no 3D to export \u2014 reconstruct a plan + elevation first (open View 3D to see what's missing)";
64165
+ } else {
64166
+ const v = validateSteelTakeoff(doc);
64167
+ if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
64168
+ const built = contractToScene(doc);
64169
+ scene = built.scene;
64170
+ skipped = built.skipped;
64171
+ emptyError = "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)";
64172
+ }
64150
64173
  if (scene.elements.length === 0) {
64151
- return reply.status(422).send({ ok: false, error: "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)", skipped });
64174
+ return reply.status(422).send({ ok: false, error: emptyError, skipped });
64152
64175
  }
64153
64176
  const companionId = `${req.params.appId}-ifc`;
64154
64177
  const filename = `${req.params.appId}.ifc`;
64155
64178
  const outPath = (0, import_node_path28.join)(appPath(companionId), filename);
64156
64179
  let flo;
64157
64180
  try {
64158
- flo = writeIfcApp(appPath(companionId), companionId, scene, outPath);
64181
+ flo = writeIfcApp(appPath(companionId), companionId, scene, outPath, writeOpts);
64159
64182
  } catch (e) {
64160
64183
  app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-ifc: companion app write failed");
64161
64184
  return reply.status(500).send({ ok: false, error: `could not write the IFC companion app: ${e instanceof Error ? e.message : "write error"}` });
@@ -64178,7 +64201,7 @@ async function startServer() {
64178
64201
  if (!(0, import_node_fs30.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
64179
64202
  const content = (0, import_node_fs30.readFileSync)(outPath, "utf8");
64180
64203
  broadcast({ type: "apps-changed" });
64181
- return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped };
64204
+ return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
64182
64205
  });
64183
64206
  app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
64184
64207
  const doc = readContract(req.params.appId);
package/dist/web/aware.js CHANGED
@@ -1364,6 +1364,9 @@
1364
1364
  // that page — distinct from the steel `scene` chain below (AWARE exporter companions).
1365
1365
  if (window.CONTRACT_RENDERERS[contractType].viewerUrl) {
1366
1366
  addNodeAction(card, 'View 3D ▸', () => openLocalViewer(currentId, contractType));
1367
+ // A reconstructed model's MEMBER geometry exports to IFC through the same companion as
1368
+ // steel (extruded bodies are view-only for now — the server says so if there's nothing).
1369
+ addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId, true)); // isVector → "unplaced" skip wording
1367
1370
  }
1368
1371
  // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1369
1372
  // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
@@ -1862,7 +1865,7 @@
1862
1865
  // Export IFC ▸ — write the approved takeoff OUT to a universal .ifc and download it. The server
1863
1866
  // builds a model-free ifc companion, runs it, and returns the .ifc text (res.content); the browser
1864
1867
  // turns it into a download. api() throws on any failure (no contract, all-RFI, compile/write error).
1865
- async function exportContractIfc(appId) {
1868
+ async function exportContractIfc(appId, isVector) {
1866
1869
  showToast('Generating IFC…', 'info');
1867
1870
  try {
1868
1871
  const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-ifc', { method: 'POST' });
@@ -1873,7 +1876,13 @@
1873
1876
  document.body.appendChild(a); a.click(); a.remove();
1874
1877
  setTimeout(() => URL.revokeObjectURL(url), 4000);
1875
1878
  const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1876
- showToast(`IFC downloaded ${res.filename || appId + '.ifc'}${dropped ? ` · ${dropped} RFI member(s) skipped` : ''}`, 'ok');
1879
+ // Skip wording differs by contract: steel `skipped` = RFI members (no AISC size); a vector
1880
+ // reconstruction's `skipped` = unplaced entities, plus extruded bodies (extrusionsSkipped)
1881
+ // that don't export to IFC yet. Label each honestly.
1882
+ const notes = [];
1883
+ if (dropped) notes.push(isVector ? `${dropped} unplaced` : `${dropped} RFI member${dropped === 1 ? '' : 's'}`);
1884
+ if (res.extrusionsSkipped) notes.push(`${res.extrusionsSkipped} extruded ${res.extrusionsSkipped === 1 ? 'body' : 'bodies'} (view-only)`);
1885
+ showToast(`IFC downloaded — ${res.filename || appId + '.ifc'}${notes.length ? ' · ' + notes.join(', ') + ' skipped' : ''}`, 'ok');
1877
1886
  } catch (e) {
1878
1887
  showToast('IFC export failed: ' + (e && e.message ? e.message : String(e)), 'warn');
1879
1888
  }
@@ -51,6 +51,19 @@ const EP_END = 0xf472b6; // end endpoint (end 2) — magenta
51
51
 
52
52
  // ---- 3D dimension tool ----
53
53
  let dimMode3d = false; // tool armed
54
+ let snapOnly3d = null; // Tekla-style snap override for the dim tool: a snapCandidates type ('vertex'|'intersection'|'midpoint'|'centerline'|'vertical-axis'), 'none' = free, null = all (set from the right-click menu in steel-editor.html)
55
+ // map a 3D candidate type → the 2D running-snap key (⋯ menu → Snapping). Types with no key (level, vertical-axis) are 3D-only aids, always allowed.
56
+ const SNAP3D_PREF = { vertex: 'end', intersection: 'int', midpoint: 'mid', centerline: 'line', 'grid-line': 'line', 'grid-int': 'int' };
57
+ // a grid candidate belongs to the same override FAMILY as its member analog, so "Intersection only" also forces grid intersections and "Centerline only" also forces grid lines.
58
+ const SNAP3D_FAMILY = { 'grid-int': 'intersection', 'grid-line': 'centerline' };
59
+ // is a candidate of this type allowed right now? transient override wins (forces one type/family, or 'none' = free); else honour the persistent running-snaps.
60
+ function candAllowed3d(type) {
61
+ if (snapOnly3d === 'none') return false;
62
+ if (snapOnly3d) return type === snapOnly3d || SNAP3D_FAMILY[type] === snapOnly3d;
63
+ const pref = api && api.snapEnabled && api.snapEnabled(); const k = SNAP3D_PREF[type];
64
+ return !k || !pref || pref[k] !== false;
65
+ }
66
+ let rightDownXY = null, rightMoved = false; // right button: dragged (= orbit/pan, no menu) vs clicked (= snap menu)
54
67
  let dimAxis3d = 'free'; // sticky axis: free | x | y | z
55
68
  let dims3dVisibleFlag = true; // show/hide placed dims
56
69
  let dimDraft3d = null; // { a:[x,y,z] } once the first point is placed (awaiting the 2nd)
@@ -113,9 +126,11 @@ function init(canvas, theApi) {
113
126
  epPreview = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x3b82f6 }));
114
127
  epPreview.material.depthTest = false; epPreview.renderOrder = 997; epPreview.visible = false; scene.add(epPreview);
115
128
  raycaster = new THREE.Raycaster();
116
- // a --brand snap marker (one sphere at the winning snap target; the readout carries the type)
117
- marker = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
118
- marker.visible = false; marker.renderOrder = 999; marker.material.depthTest = false; scene.add(marker);
129
+ // Snap marker: a camera-facing cyan RETICLE (crosshair ring + per-type inner glyph), not a bare dot
130
+ // a solid sphere vanished against the dark bg + coloured steel. Cyan #22d3ee matches the 2D snap marker;
131
+ // every stroke gets a dark halo so it reads on any background. depthTest off always on top.
132
+ marker = new THREE.Sprite(new THREE.SpriteMaterial({ map: reticleFor('dot'), transparent: true, depthTest: false, depthWrite: false }));
133
+ marker.visible = false; marker.renderOrder = 999; scene.add(marker);
119
134
  // live dimension readout (toast-styled, follows the cursor during a drag — feet + snap type).
120
135
  // Two spans + textContent (never innerHTML) so it's XSS-proof; the type span is muted.
121
136
  readout = document.createElement('div');
@@ -1126,7 +1141,7 @@ function onKey(e) {
1126
1141
  const midGesture = pending || dragging || boxSel;
1127
1142
  if (k === 'd' && !e.ctrlKey && !e.metaKey && !e.altKey && !midGesture) { e.preventDefault(); toggleDimTool(); return; } // D arms/disarms the dimension tool
1128
1143
  if (dimMode3d && !midGesture) {
1129
- if (k === 'escape') { e.preventDefault(); if (dimDraft3d) { dimDraft3d = null; dimPreviewLine.visible = false; readout.style.display = 'none'; } else toggleDimTool(); return; } // Esc: cancel the in-progress dim, else exit the tool
1144
+ if (k === 'escape') { e.preventDefault(); if (snapOnly3d) setSnapOnly3d(null); else if (dimDraft3d) { dimDraft3d = null; dimPreviewLine.visible = false; readout.style.display = 'none'; } else toggleDimTool(); return; } // Esc steps back: drop the snap override → cancel the in-progress dim exit the tool (mirrors the 2D two-step)
1130
1145
  if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
1131
1146
  }
1132
1147
  // Named-view shortcuts mirror the ViewCube faces (parity with AWARE viewer-3d): T/F/R/B/L snap to an ortho view.
@@ -1140,11 +1155,13 @@ function onKey(e) {
1140
1155
  // ---- 3D dimension tool controls (driven from the toolbar + the keyboard) ----
1141
1156
  function toggleDimTool() {
1142
1157
  dimMode3d = !dimMode3d;
1143
- if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
1158
+ if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; setSnapOnly3d(null); } // disarming ends the operation → the snap override reverts to all snaps
1144
1159
  else { if (clipMode) setClipMode(null); if (wpMode) armWorkPlanePick(wpMode); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); drClear3d(); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick, a set-plane pick, Move/Copy AND Add-member (all share the left-click + Esc)
1145
1160
  reflectDimBar();
1146
1161
  return dimMode3d;
1147
1162
  }
1163
+ // snap override setter — notifies the host page so its header chip stays in sync with every clear path
1164
+ function setSnapOnly3d(k) { snapOnly3d = k || null; if (api && api.onSnapOnlyChanged) api.onSnapOnlyChanged(); }
1148
1165
  function setDimAxis(a) { if (['free', 'x', 'y', 'z'].includes(a)) dimAxis3d = a; }
1149
1166
  function dimAxis() { return dimAxis3d; }
1150
1167
  function setDims3dVisible(on) { dims3dVisibleFlag = !!on; refreshDims(); }
@@ -1784,8 +1801,8 @@ function wpPointAt(e) {
1784
1801
  const wp = effectiveWP();
1785
1802
  const hit = rayToWP(e.clientX, e.clientY, wp); if (!hit) return null;
1786
1803
  if (!e.altKey) {
1787
- if (!tfCandidates) tfCandidates = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
1788
- const r = snapPoint(hit, tfCandidates, toScreen, SNAP_TOL_PX);
1804
+ if (!tfCandidates) tfCandidates = allCandidates(null); // members + grid, so Move/Copy + draw-on-plane snap to the grid too
1805
+ const r = snapPoint(hit, tfCandidates.filter((c) => candAllowed3d(c.type)), toScreen, SNAP_TOL_PX); // honour the running-snaps / override
1789
1806
  if (r.candidate) return { p: projectToPlane(r.snapped, wp.origin, wp.normal), snap: true, type: r.candidate.type };
1790
1807
  }
1791
1808
  return { p: hit, snap: false, type: null };
@@ -1835,7 +1852,7 @@ function tfPreview(e) {
1835
1852
  const r = wpPointAt(e); if (!r) return;
1836
1853
  const p = tfConstrain(r.p, e);
1837
1854
  tfLast = p; tfLastClient = [e.clientX, e.clientY];
1838
- if (r.snap && tfAxis === 'free' && !e.shiftKey) { marker.position.set(p[0], p[1], p[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
1855
+ if (r.snap && tfAxis === 'free' && !e.shiftKey) showMarker(p, r.type); else marker.visible = false;
1839
1856
  const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
1840
1857
  tfGhostBuild(tfOffsetsFor(v, st));
1841
1858
  if (!tfRubber) { tfRubber = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee })); tfRubber.material.depthTest = false; tfRubber.renderOrder = 997; scene.add(tfRubber); }
@@ -1913,7 +1930,7 @@ function drClick(e) {
1913
1930
  }
1914
1931
  function drPreview(e) {
1915
1932
  const r = wpPointAt(e); if (!r) return;
1916
- if (r.snap) { marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
1933
+ if (r.snap) showMarker(r.p, r.type); else marker.visible = false;
1917
1934
  if (!drDraft) { readout.style.display = 'none'; return; }
1918
1935
  const p = drShiftLock(r.p, e);
1919
1936
  if (!drRubber) { drRubber = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee })); drRubber.material.depthTest = false; drRubber.renderOrder = 997; scene.add(drRubber); }
@@ -1933,7 +1950,45 @@ function pxToWorldAt(px, pos) {
1933
1950
  const dist = pos ? camera.position.distanceTo(pos) : camera.position.distanceTo(controls.target);
1934
1951
  return px * (2 * Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2) * dist) / h;
1935
1952
  }
1936
- const markerSize = () => pxToWorldAt(8, marker && marker.position); // snap marker ~8px at its own depth
1953
+ const RETICLE_PX = 44; // on-screen size of the snap reticle (the full sprite); the ring/glyph fill the middle ~55%
1954
+ // One CanvasTexture per glyph kind (built lazily, cached). Each shape is stroked twice — a wide dark halo
1955
+ // first, then the cyan on top — so the marker stays legible over dark bg, bright members, or the grid.
1956
+ const reticleTex = {};
1957
+ function makeReticleTexture(kind) {
1958
+ const S = 128, c = document.createElement('canvas'); c.width = c.height = S;
1959
+ const g = c.getContext('2d'), cx = S / 2, cy = S / 2, CY = '#22d3ee', HALO = 'rgba(2,8,23,0.9)';
1960
+ g.lineCap = 'round'; g.lineJoin = 'round';
1961
+ const dual = (draw, wCy, wHalo) => { g.strokeStyle = HALO; g.lineWidth = wHalo; draw(); g.strokeStyle = CY; g.lineWidth = wCy; draw(); };
1962
+ dual(() => { g.beginPath(); g.arc(cx, cy, 38, 0, Math.PI * 2); g.stroke(); }, 5, 11); // crosshair ring
1963
+ const tick = (dx, dy) => { g.beginPath(); g.moveTo(cx + dx * 44, cy + dy * 44); g.lineTo(cx + dx * 58, cy + dy * 58); g.stroke(); };
1964
+ dual(() => { tick(0, -1); tick(0, 1); tick(-1, 0); tick(1, 0); }, 5, 11); // N/E/S/W ticks
1965
+ const r = 15; // inner glyph, mirrors the 2D marker language
1966
+ if (kind === 'x') dual(() => { g.beginPath(); g.moveTo(cx - r, cy - r); g.lineTo(cx + r, cy + r); g.moveTo(cx - r, cy + r); g.lineTo(cx + r, cy - r); g.stroke(); }, 7, 13);
1967
+ else if (kind === 'tri') dual(() => { g.beginPath(); g.moveTo(cx, cy - r); g.lineTo(cx + 0.87 * r, cy + 0.5 * r); g.lineTo(cx - 0.87 * r, cy + 0.5 * r); g.closePath(); g.stroke(); }, 6, 12);
1968
+ else if (kind === 'bar') dual(() => { g.beginPath(); g.moveTo(cx - r, cy); g.lineTo(cx + r, cy); g.stroke(); }, 8, 14);
1969
+ else if (kind === 'vbar') dual(() => { g.beginPath(); g.moveTo(cx, cy - r); g.lineTo(cx, cy + r); g.stroke(); }, 8, 14);
1970
+ else if (kind === 'square') dual(() => { g.beginPath(); g.rect(cx - r, cy - r, 2 * r, 2 * r); g.stroke(); }, 7, 13); // vertex/endpoint = square (CAD convention; matches the 2D endpoint marker)
1971
+ else if (kind === 'hourglass') dual(() => { g.beginPath(); g.moveTo(cx - r, cy - r); g.lineTo(cx + r, cy - r); g.lineTo(cx - r, cy + r); g.lineTo(cx + r, cy + r); g.closePath(); g.stroke(); }, 6, 12); // on-line ("nearest") = hourglass/clepsydra, matches the 2D marker
1972
+ else { g.fillStyle = HALO; g.beginPath(); g.arc(cx, cy, 11, 0, Math.PI * 2); g.fill(); g.fillStyle = CY; g.beginPath(); g.arc(cx, cy, 8, 0, Math.PI * 2); g.fill(); } // dot (fallback)
1973
+ const tex = new THREE.CanvasTexture(c); if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace; tex.needsUpdate = true;
1974
+ return tex;
1975
+ }
1976
+ // snap-candidate type → glyph kind (matches the 2D circle/×/△/─ vocabulary)
1977
+ function reticleFor(type) {
1978
+ const k = (type === 'intersection' || type === 'grid-int') ? 'x' : type === 'midpoint' ? 'tri'
1979
+ : (type === 'centerline' || type === 'grid-line') ? 'hourglass' // snap ONTO a line = "nearest"
1980
+ : type === 'level' ? 'bar' // elevation level = horizontal bar
1981
+ : type === 'vertical-axis' ? 'vbar' : type === 'vertex' ? 'square' : 'dot';
1982
+ return reticleTex[k] || (reticleTex[k] = makeReticleTexture(k));
1983
+ }
1984
+ let lastSnapType3d = null; // test-observability: the glyph the reticle last showed (null when hidden)
1985
+ // Place + size + show the snap reticle at a world point, glyph chosen by snap type. Screen-constant size.
1986
+ function showMarker(p, type) {
1987
+ marker.material.map = reticleFor(type); marker.material.needsUpdate = true;
1988
+ marker.position.set(p[0], p[1], p[2]);
1989
+ marker.scale.setScalar(pxToWorldAt(RETICLE_PX, marker.position));
1990
+ marker.visible = true; lastSnapType3d = type || 'dot';
1991
+ }
1937
1992
 
1938
1993
  // The snapped 3D point under the cursor for the dim tool. `anchor` (point 1) is set during the 2nd
1939
1994
  // pick: with Alt held the result is locked to the anchor's plan X/Y (a pure vertical / Z measurement,
@@ -1947,21 +2002,42 @@ function dimPointAt(e, anchor) {
1947
2002
  let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
1948
2003
  if (id) { const m = members().find((x) => x.id === id); if (m) { const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm()); planeZ = (g.line[0][2] + g.line[1][2]) / 2; } }
1949
2004
  const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
1950
- if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
1951
- const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
1952
- return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
2005
+ if (!dimCandidates3d) dimCandidates3d = allCandidates(null); // member + grid candidates (allCandidates folds in the grid)
2006
+ const cands = dimCandidates3d.filter((c) => candAllowed3d(c.type)); // right-click override (one type / free) + the persistent running-snaps from the ⋯ menu
2007
+ // Grid geometry is drawn at its OWN Z (often ground, e.g. 0), which can be far from the steel-level planeZ.
2008
+ // Evaluate grid candidates against a ray hit on the GRID plane so they align with what's drawn; member
2009
+ // candidates keep the member/scene plane. Pick whichever winner is closest to the cursor on screen.
2010
+ const gz = gridPlaneZ();
2011
+ const isGrid = (c) => c.type === 'grid-int' || c.type === 'grid-line';
2012
+ const gridCands = gz != null ? cands.filter(isGrid) : [];
2013
+ const memCands = gridCands.length ? cands.filter((c) => !isGrid(c)) : cands;
2014
+ const best = (cset, dragged) => { if (!cset.length || !dragged) return null; const r = snapPoint(dragged, cset, toScreen, SNAP_TOL_PX);
2015
+ if (!r.candidate) return null; const a = toScreen(dragged), b = toScreen(r.snapped); return { r, d: Math.hypot(a.x - b.x, a.y - b.y) }; };
2016
+ const rMem = best(memCands, [hit[0], hit[1], hit[2]]);
2017
+ const hitGrid = gridCands.length ? rayToPlane(e.clientX, e.clientY, gz) : null;
2018
+ const rGrid = best(gridCands, hitGrid);
2019
+ const win = rGrid && (!rMem || rGrid.d <= rMem.d) ? rGrid : rMem;
2020
+ return { p: win ? win.r.snapped : hit, type: win ? win.r.candidate.type : null };
2021
+ }
2022
+ // The grid's world Z (where its snap targets live), derived from the current grid candidates — or null if no grid.
2023
+ function gridPlaneZ() {
2024
+ if (!dimCandidates3d) return null;
2025
+ const p = dimCandidates3d.find((c) => c.type === 'grid-int'); if (p) return p.p[2];
2026
+ const l = dimCandidates3d.find((c) => c.type === 'grid-line'); if (l) return l.a[2];
2027
+ return null;
1953
2028
  }
1954
2029
  // The axis to measure on: Alt held during the 2nd pick forces a pure-vertical (Z) measurement,
1955
2030
  // overriding the sticky axis (else a sticky X/Y would collapse the Alt-locked point to zero length).
1956
2031
  function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
1957
2032
 
1958
2033
  function onDown(e) {
2034
+ if (e.button === 2) { rightDownXY = [e.clientX, e.clientY]; rightMoved = false; } // arm the click-vs-drag test for the snap menu
1959
2035
  if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
1960
2036
  if (wpMode === 'face') { e.stopPropagation(); const r = wpFacePick(e.clientX, e.clientY); if (r) { workPlane = r; wpMode = null; canvasEl.style.cursor = 'default'; renderWorkPlane(); reflectWpBar(); if (api && api.toast) api.toast('Working plane set on the face'); } return; } // armed: one face click sets the plane
1961
2037
  if (wpMode === '3pt') { e.stopPropagation();
1962
2038
  const r = dimPointAt(e); if (!r) return;
1963
2039
  wpDraft = wpDraft || []; wpDraft.push(r.p);
1964
- marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true;
2040
+ showMarker(r.p, r.type);
1965
2041
  if (wpDraft.length === 3) { const wp = planeFrom3Points(wpDraft[0], wpDraft[1], wpDraft[2]);
1966
2042
  wpDraft = null; wpMode = null; marker.visible = false; canvasEl.style.cursor = 'default';
1967
2043
  if (wp) { wp.kind = 'p3'; workPlane = wp; renderWorkPlane(); if (api && api.toast) api.toast('Working plane set from 3 points'); }
@@ -2000,11 +2076,13 @@ function onDown(e) {
2000
2076
  const geo = m ? memberGeometry(m, ppf, dtos) : null;
2001
2077
  const mesh = meshById.get(id);
2002
2078
  const planeZ = geo ? (geo.line[0][2] + geo.line[1][2]) / 2 : 0;
2079
+ const ctrl = e.ctrlKey || e.metaKey;
2003
2080
  pending = {
2004
- id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
2081
+ id, ctrl, ppf, planeZ,
2082
+ copy: ctrl, copyIds: ctrl ? (selIds.has(id) ? [...selIds] : [id]) : null, // Ctrl+drag copies: the selection if this member is in it, else just it
2005
2083
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
2006
2084
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
2007
- candidates: allCandidates(id),
2085
+ candidates: allCandidates(ctrl ? undefined : id).filter((c) => candAllowed3d(c.type)), // member + grid snap targets (a ctrl-copy snaps to the ORIGINAL too; a move excludes itself), filtered by the running-snaps (⋯ menu → Snapping)
2008
2086
  levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
2009
2087
  mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
2010
2088
  };
@@ -2020,7 +2098,7 @@ function onMoveVertical(e) {
2020
2098
  const newZ = r.candidate ? r.snapped[2] : hit[2];
2021
2099
  pending.dz = newZ - pending.planeZ;
2022
2100
  if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x, pending.meshPos0.y, pending.meshPos0.z + pending.dz);
2023
- if (r.candidate) { marker.position.set(gx, gy, newZ); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
2101
+ if (r.candidate) showMarker([gx, gy, newZ], r.candidate.type); else marker.visible = false;
2024
2102
  readout._dist.textContent = (newZ / FT_MM).toFixed(2) + ' ft'; // resulting T.O.S elevation
2025
2103
  readout._type.textContent = r.candidate ? ' · level' : ' ↕';
2026
2104
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
@@ -2035,7 +2113,7 @@ function startEndpointGrab(ep, e) {
2035
2113
  pending = {
2036
2114
  epDrag: true, id: ep.id, end: ep.end, ppf,
2037
2115
  planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
2038
- candidates: allCandidates(ep.id),
2116
+ candidates: allCandidates(ep.id).filter((c) => candAllowed3d(c.type)), // member + grid snap targets, filtered by the running-snaps (⋯ menu → Snapping)
2039
2117
  newPt: [g.line[ep.end][0], g.line[ep.end][1]],
2040
2118
  };
2041
2119
  dragEp = { id: ep.id, end: ep.end };
@@ -2053,13 +2131,14 @@ function onMoveEndpoint(e) {
2053
2131
  const f = pending.fixed;
2054
2132
  epPreview.geometry.setFromPoints([new THREE.Vector3(f[0], f[1], f[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
2055
2133
  epPreview.visible = true;
2056
- if (r.candidate) { marker.position.set(np[0], np[1], np[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
2134
+ if (r.candidate) showMarker(np, r.candidate.type); else marker.visible = false;
2057
2135
  const len = Math.hypot(np[0] - f[0], np[1] - f[1]) / FT_MM;
2058
2136
  readout._dist.textContent = len.toFixed(2) + ' ft'; readout._type.textContent = r.candidate ? ' · ' + r.candidate.type : '';
2059
2137
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
2060
2138
  }
2061
2139
 
2062
2140
  function onMove(e) {
2141
+ if (rightDownXY && Math.hypot(e.clientX - rightDownXY[0], e.clientY - rightDownXY[1]) > 5) rightMoved = true; // right-DRAG = orbit/pan, so no snap menu on the release
2063
2142
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // 3D inactive — ignore window pointer events
2064
2143
  if (boxSel) { onBoxMove(e); return; }
2065
2144
  if (pending && pending.clipDrag) { onMoveClip(e); return; } // dragging a clip handle (plane offset / box face)
@@ -2067,7 +2146,9 @@ function onMove(e) {
2067
2146
  if (pending.epDrag) { onMoveEndpoint(e); return; }
2068
2147
  if (!pending.grab || !pending.origMm) return;
2069
2148
  if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
2070
- if (!dragging) pending.mode = e.altKey ? 'vertical' : 'plan'; // lock the mode at drag start (Alt = vertical/elevation)
2149
+ const canDrag = pending.copy || (api && api.dragMoveEnabled && api.dragMoveEnabled());
2150
+ if (!canDrag) { if (!pending._hinted) { pending._hinted = true; if (api && api.dragMoveHint) api.dragMoveHint(); } return; } // drag-move OFF, plain grab → never translate; onUp selects the member (dragging stays false)
2151
+ if (!dragging) pending.mode = pending.copy ? 'copy' : (e.altKey ? 'vertical' : 'plan'); // lock the mode at drag start (copy is plan-only; Alt = vertical/elevation)
2071
2152
  dragging = pending;
2072
2153
  if (pending.mode === 'vertical') { onMoveVertical(e); return; }
2073
2154
  const cur = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!cur) return;
@@ -2083,14 +2164,26 @@ function onMove(e) {
2083
2164
  }
2084
2165
  if (corr) { dx += corr[0]; dy += corr[1]; }
2085
2166
  pending.delta = [dx, dy];
2086
- if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x + dx, pending.meshPos0.y + dy, pending.meshPos0.z);
2087
- if (at) { marker.position.set(at[0], at[1], at[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
2167
+ if (pending.copy) positionCopyGhost(dx, dy); // Ctrl+drag: move a translucent ghost, never the original
2168
+ else if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x + dx, pending.meshPos0.y + dy, pending.meshPos0.z);
2169
+ if (at) showMarker(at, atType); else marker.visible = false;
2088
2170
  // live dimension readout: plan distance moved (feet) + the snap type, near the cursor
2089
2171
  readout._dist.textContent = (Math.hypot(dx, dy) / FT_MM).toFixed(2) + ' ft';
2090
2172
  readout._type.textContent = atType ? ' · ' + atType : '';
2091
2173
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
2092
2174
  }
2093
2175
 
2176
+ // Ctrl+drag copy: a translucent ghost of the grabbed set (copyIds) shifted by the plan delta. Clones
2177
+ // share the source geometry + one material (like the Move/Copy tool's ghost) — nothing to dispose.
2178
+ let copyGhost = null;
2179
+ function positionCopyGhost(dx, dy) {
2180
+ if (copyGhost) scene.remove(copyGhost);
2181
+ copyGhost = new THREE.Group();
2182
+ if (!tfGhostMat) tfGhostMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.3, depthWrite: false });
2183
+ for (const id of (pending.copyIds || [])) { const m = meshById.get(id); if (!m || !m.visible) continue; const c = new THREE.Mesh(m.geometry, tfGhostMat); c.applyMatrix4(m.matrixWorld); c.position.x += dx; c.position.y += dy; copyGhost.add(c); }
2184
+ scene.add(copyGhost);
2185
+ }
2186
+ function clearCopyGhost() { if (copyGhost) { scene.remove(copyGhost); copyGhost = null; } }
2094
2187
  function onBoxMove(e) {
2095
2188
  if (Math.hypot(e.clientX - boxSel.x, e.clientY - boxSel.y) < DRAG_TOL_PX) { rubber.style.display = 'none'; return; }
2096
2189
  boxSel.moved = true;
@@ -2114,7 +2207,8 @@ function membersInRect(x0, y0, x1, y1) {
2114
2207
  }
2115
2208
 
2116
2209
  function onUp(e) {
2117
- if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; dragEp = null; // always clear overlays
2210
+ if (e.button === 2) rightDownXY = null; // end the click-vs-drag test (rightMoved keeps the verdict for the contextmenu that follows)
2211
+ if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; clearCopyGhost(); dragEp = null; // always clear overlays
2118
2212
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; boxSel = pending = dragging = null; if (controls) controls.enabled = true; return; } // 3D hidden mid-gesture → drop stale gesture state (no resume on re-show)
2119
2213
  const bs = boxSel; boxSel = null;
2120
2214
  if (bs) { // empty-space gesture: drag = box-select, click = clear selection
@@ -2149,7 +2243,14 @@ function onUp(e) {
2149
2243
  }
2150
2244
  return;
2151
2245
  }
2152
- if (!wasDragging) { clickSelect(e.clientX, e.clientY, p.ctrl); return; } // click → cycle-pick: member, or a derived part stacked on it at a joint
2246
+ if (!wasDragging) {
2247
+ if (p.id && !p.geo && !p.epDrag && !p.copy && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, false); return; } // a blocked drag (drag-move OFF) selects the GRABBED member, not whatever's under the release point
2248
+ clickSelect(e.clientX, e.clientY, p.ctrl); return; // click → cycle-pick: member, or a derived part stacked on it at a joint (Ctrl+click = additive select)
2249
+ }
2250
+ if (p.copy) { // Ctrl+drag → commit a copy at the plan delta (the editor clones + selects); the ghost was the preview
2251
+ if (p.delta && !(p.delta[0] === 0 && p.delta[1] === 0) && api && api.onCopyDrag3d) api.onCopyDrag3d(p.copyIds, p.delta[0], p.delta[1]);
2252
+ return;
2253
+ }
2153
2254
  if (p.mode === 'vertical') { // elevation drag → write T.O.S (inches)
2154
2255
  if (p.dz && Math.abs(p.dz) > 1e-6) {
2155
2256
  if (api && api.onSelect) api.onSelect(p.id, false);
@@ -2181,7 +2282,7 @@ function onHoverMove(e) {
2181
2282
  if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
2182
2283
  canvasEl.style.cursor = 'crosshair';
2183
2284
  if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2184
- if (r && !(wpDraft && wpDraft.length)) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } }
2285
+ if (r && !(wpDraft && wpDraft.length)) showMarker(r.p, r.type); }
2185
2286
  updateStatusChip(); return; }
2186
2287
  if (addActive()) { // Add-member armed: snap marker + rubber preview on the working plane
2187
2288
  canvasEl.style.cursor = 'crosshair';
@@ -2191,14 +2292,15 @@ function onHoverMove(e) {
2191
2292
  canvasEl.style.cursor = 'crosshair';
2192
2293
  if (tfDraft) tfPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
2193
2294
  else { const r = wpPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2194
- if (r && r.snap) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false; }
2295
+ if (r && r.snap) showMarker(r.p, r.type); else marker.visible = false; }
2195
2296
  updateStatusChip(); return; }
2196
2297
  if (clipMode) { canvasEl.style.cursor = 'crosshair'; if (clipMode === 'box') clipBoxPreviewAt(lastHoverXY[0], lastHoverXY[1]); return; } // armed clip pick: crosshair (+ live box preview while drawing)
2197
2298
  if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
2198
- canvasEl.style.cursor = 'crosshair';
2199
2299
  const ev = { clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt };
2200
2300
  const r = dimPointAt(ev, dimDraft3d && dimDraft3d.a);
2201
- if (r) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
2301
+ const snapped = !!(r && r.type);
2302
+ if (snapped) showMarker(r.p, r.type); else marker.visible = false; // reticle only on a real snap (free point rides the preview line) — parity with 2D snapClear
2303
+ canvasEl.style.cursor = snapped ? 'none' : 'crosshair'; // at a snap the reticle IS the cursor (Tekla) — hide the native crosshair so it doesn't double up on the marker
2202
2304
  if (dimDraft3d && r) {
2203
2305
  const g = dim3dGeom(dimDraft3d.a, r.p, dimEffectiveAxis(dimLastAlt));
2204
2306
  dimPreviewLine.geometry.setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]); dimPreviewLine.visible = true;
@@ -2218,7 +2320,8 @@ function onHoverMove(e) {
2218
2320
  hoverEp = null;
2219
2321
  let top = null; try { top = pickAllAt(lastHoverXY[0], lastHoverXY[1])[0] || null; } catch { top = null; }
2220
2322
  const topMesh = top ? meshById.get(top) : null, isPart = !!(topMesh && topMesh.userData.derived);
2221
- canvasEl.style.cursor = geoMode() ? 'crosshair' : (top ? (isPart ? 'pointer' : 'grab') : 'default'); // derived part → pointer (select-only); member → grab (draggable)
2323
+ const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled();
2324
+ canvasEl.style.cursor = geoMode() ? 'crosshair' : (top ? ((isPart || !canDrag) ? 'pointer' : 'grab') : 'default'); // derived part → pointer (select-only); member → grab only when drag-move is ON, else pointer (a click just selects — the cursor tells the truth, like 2D)
2222
2325
  if (top !== hoverId) { hoverId = top; rebuildEndpoints(); updateStatusChip(); }
2223
2326
  });
2224
2327
  }
@@ -2238,9 +2341,12 @@ function updateStatusChip() {
2238
2341
  else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
2239
2342
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
2240
2343
  else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
2241
- // A read-only host (the vector 3D viewer) supplies its own idle hint — the default speaks
2242
- // editing verbs (move/stretch/elevate) that a viewer's no-op api would silently ignore.
2243
- else txt = (api && typeof api.statusHint === 'function' && api.statusHint()) || 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
2344
+ // A read-only host (the vector 3D viewer) supplies its own idle hint — the default speaks editing
2345
+ // verbs (move/stretch/elevate) a viewer's no-op api would silently ignore. Otherwise the default
2346
+ // tells the truth about the drag-move toggle (grab-to-move ON vs click-to-select + Ctrl+drag-copy).
2347
+ else if (api && typeof api.statusHint === 'function' && api.statusHint()) txt = api.statusHint();
2348
+ else { const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled();
2349
+ txt = (canDrag ? 'Drag to move · ' : 'Click to select · Ctrl+drag to copy · ') + 'ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in'; }
2244
2350
  hoverChip.textContent = txt;
2245
2351
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
2246
2352
  hoverChip.style.top = (rect.bottom - 30) + 'px';
@@ -2254,6 +2360,7 @@ function hide() {
2254
2360
  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
2255
2361
  cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
2256
2362
  if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
2363
+ clearCopyGhost(); // drop a stale Ctrl+drag copy ghost on the way out
2257
2364
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
2258
2365
  }
2259
2366
  function isReady() { return built; }
@@ -2309,6 +2416,8 @@ function debug() {
2309
2416
  endpoints: epGroup ? epGroup.children.length : 0,
2310
2417
  endpointColors: epGroup ? epGroup.children.map((c) => '#' + c.material.color.getHexString()) : [],
2311
2418
  hoverId, hoverEp, dragEp, refLine: refLineOn,
2419
+ snapMarker: marker && marker.visible ? { type: lastSnapType3d, at: [marker.position.x, marker.position.y, marker.position.z] } : null, // test-observability for the snap reticle
2420
+
2312
2421
  inFrustum: (() => { let n = 0, t = 0; for (const m of meshById.values()) { t++; const v = m.getWorldPosition(new THREE.Vector3()).project(camera); if (Math.abs(v.x) <= 1 && Math.abs(v.y) <= 1 && v.z >= -1 && v.z <= 1) n++; } return { n, t }; })(),
2313
2422
  camPos: camera ? [camera.position.x, camera.position.y, camera.position.z] : null,
2314
2423
  target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
@@ -2336,6 +2445,13 @@ function centerOf(id) {
2336
2445
  return { x: rect.left + (w.x * 0.5 + 0.5) * rect.width, y: rect.top + (-w.y * 0.5 + 0.5) * rect.height };
2337
2446
  }
2338
2447
  function worldOf(id) { const m = meshById.get(id); if (!m) return null; const w = m.getWorldPosition(new THREE.Vector3()); return [w.x, w.y, w.z]; } // test helper: a rendered part's world position
2448
+ // test helper: client (px) coords of every POINT snap candidate of `type` currently in view (members + grid, via
2449
+ // allCandidates) — drives precise E2E snap hovers, including grid nodes/intersections (grid-int).
2450
+ function snapCandidatesScreen(type) {
2451
+ const rect = canvasEl.getBoundingClientRect();
2452
+ return allCandidates(null).filter((c) => c.type === type && c.p)
2453
+ .map((c) => { const s = toScreen(c.p); return { world: c.p, x: rect.left + s.x, y: rect.top + s.y }; });
2454
+ }
2339
2455
  window.Steel3DView = {
2340
2456
  init, show, hide, rebuild, setSelection, isReady, dispose,
2341
2457
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
@@ -2350,12 +2466,13 @@ window.Steel3DView = {
2350
2466
  cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
2351
2467
  drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
2352
2468
  toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
2469
+ setSnapOnly: setSnapOnly3d, snapOnly: () => snapOnly3d, dimToolOn: () => dimMode3d, rightDragged: () => rightMoved, // Tekla-style snap override (right-click menu lives in steel-editor.html)
2353
2470
  refreshOverlayDims,
2354
2471
  overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests
2355
2472
  overlayLabelTexts: () => overlayLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent),
2356
2473
  dims3dCount: () => (api && api.getDims3d ? (api.getDims3d() || []).length : 0),
2357
2474
  selDims3dCount: () => (api && api.selDim3d ? (api.selDim3d() || []).length : 0), // test helper: how many 3D dims are selected
2358
2475
  dim3dLabelOf: (i) => { const el = dimLabelPool[i]; if (!el || el.style.display === 'none') return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, text: el.textContent }; },
2359
- debug, probe, screenOf, centerOf, worldOf, clipHandlesScreen,
2476
+ debug, probe, screenOf, centerOf, worldOf, snapCandidatesScreen, clipHandlesScreen,
2360
2477
  pointClipped: (p) => isPointClipped(p), // test helper: is a world point on the cut-away side of any active clip?
2361
2478
  };
@@ -31,7 +31,8 @@
31
31
  select,input{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:6px;width:100%;font:13px system-ui}
32
32
  .row{margin:8px 0} .danger{background:#7f1d1d;border-color:#991b1b} .danger:hover{background:#991b1b}
33
33
  .pill{display:inline-block;padding:1px 6px;border-radius:4px;font-size:11px}
34
- line.member{cursor:move;stroke-width:6;stroke-linecap:round}
34
+ line.member{cursor:pointer;stroke-width:6;stroke-linecap:round} /* default (drag-move OFF): a click selects → the pointer cursor tells the truth */
35
+ body.dragmove line.member{cursor:move} /* drag-move ON: the move cursor signals a drag will translate — a per-hover second signal beside the toolbar pill */
35
36
  line.member.sel{stroke-width:7} line.member.rfi{stroke-dasharray:10 6}
36
37
  circle.handle{fill:var(--bg);stroke:#f8fafc;stroke-width:3;cursor:grab}
37
38
  line.seg{stroke:#475569;stroke-width:2;opacity:0;cursor:crosshair} body.add line.seg{opacity:.5}
@@ -84,6 +85,33 @@
84
85
  .aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
85
86
  #saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
86
87
  #csStat.local{border:1px solid #22d3ee;background:rgba(34,211,238,.10);border-radius:4px;padding:1px 6px} /* lift the chip out of the muted row when a local frame is active (reuses the dim/accent cyan — no new colour) */
88
+ #snapStat{cursor:pointer;border:1px solid #22d3ee;background:rgba(34,211,238,.10);border-radius:4px;padding:1px 6px} /* snap-override chip — same cyan treatment as #csStat.local; the cyan IS the snap-marker colour it restricts */
89
+ #snapStat:hover b{color:#fff}
90
+ #snapMenu{position:fixed;left:0;top:0;z-index:45;min-width:186px} /* right-click snap menu — reuses the .m3dmenu skin, opened at the cursor */
91
+ #snapMenu .sg{display:inline-block;width:17px;color:#22d3ee} /* leading glyph = the marker the cursor will show */
92
+ #snapMenu button.dim{opacity:.55} /* a running-snap turned off in the ⋯ menu — still forceable here (AutoCAD-style) */
93
+ #snapMenu .offtag{font-size:9px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin-left:6px}
94
+ /* ⋯ menu → Snapping section: persistent running-snap toggles as delicate slider switches (CSS-only, no native input). Toggle = brand blue (the menu's "on" language); the glyph stays snap-cyan (a legend of the on-canvas marker), dimmed when the snap is off. */
95
+ #moreMenu .msnap-hdr{display:flex;align-items:center;justify-content:space-between} /* collapsible "Snapping" header — saves menu space; the rows live in .msnap-sect below */
96
+ #moreMenu .msnap-hdr .chev{color:var(--mut);font-size:10px;transition:transform .15s}
97
+ #moreMenu .msnap-hdr[aria-expanded=true] .chev{transform:rotate(90deg)}
98
+ #moreMenu .msnap-sect{display:none} #moreMenu .msnap-sect.open{display:block}
99
+ #moreMenu button.msnap{display:flex;align-items:center;gap:0}
100
+ #moreMenu button.msnap.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
101
+ #moreMenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s}
102
+ #moreMenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
103
+ #moreMenu button.msnap.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
104
+ #moreMenu button.msnap.on .mck::after{transform:translateX(12px);background:var(--brand)}
105
+ #moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
106
+ #moreMenu button.msnap.on .sg{opacity:1}
107
+ /* Quick-access snap bar — always-expanded row of glyph toggles, bottom-right of the canvas (both views); reuses the brand-fill "on" toolbar language */
108
+ #snapBar{position:absolute;right:12px;bottom:12px;z-index:8;display:flex;gap:2px;padding:3px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 4px 14px rgba(0,0,0,.45)}
109
+ #snapBar.s3d{right:104px} /* in 3D, sit just LEFT of the bottom-right world-axis triad (#m3dAxes, right 15 + 78 wide) — same bottom edge, side by side (the ViewCube is top-right) */
110
+ #snapBar button{width:24px;height:24px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#22d3ee;opacity:.55;font-size:12px;line-height:1;cursor:pointer;padding:0;transition:background-color .12s,opacity .12s,color .12s}
111
+ #snapBar button:hover{background:rgba(148,163,184,.12);opacity:.9}
112
+ #snapBar button.on{background:var(--brand);color:#0f172a;opacity:1}
113
+ #snapBar button:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
114
+ #moreMenu .mhint{color:var(--mut);font-size:11px;line-height:1.4;padding:4px 12px 6px;max-width:230px;white-space:normal}
87
115
  #rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
88
116
  #confStat{cursor:pointer;text-decoration:underline dotted} #confStat:hover b{filter:brightness(1.25)}
89
117
  .conf-cats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px;border-bottom:1px solid var(--line)}
@@ -131,9 +159,11 @@
131
159
  /* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
132
160
  /* Move/Copy split buttons + transform-suite chrome — all within the locked baseline tokens. */
133
161
  .cmwrap{position:relative;display:inline-flex}
134
- .cmwrap>button:first-child{border-radius:6px 0 0 6px}
135
- .cmwrap .cmcaret{border-radius:0 6px 6px 0;border-left:0;padding:0 5px;min-width:22px;color:var(--mut)}
136
- .cmwrap>button.on,.cmwrap .cmcaret.on{background:var(--brand);border-color:var(--brand);color:#fff}
162
+ #xfB{white-space:nowrap}
163
+ #xfB.on{background:var(--brand);border-color:var(--brand);color:#fff} /* a transform tool is armed */
164
+ .cmmenu .mstate{margin-left:auto;font-size:11px;color:var(--mut);padding-left:12px} /* the Drag-to-move on/off state, right-aligned in its menu row */
165
+ .cmmenu #dragMoveB.on .mstate{color:var(--brand)}
166
+ .cmmenu #dragMoveB.on{color:var(--text)}
137
167
  .cmmenu{display:none;position:absolute;left:0;top:calc(100% + 6px);min-width:200px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30}
138
168
  .cmmenu.open{display:block}
139
169
  .cmmenu button{display:flex;justify-content:space-between;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
@@ -158,7 +188,7 @@
158
188
  #moreWrap{position:relative;display:inline-flex}
159
189
  #moreMenu{position:absolute;right:0;top:calc(100% + 6px);min-width:210px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30;display:none}
160
190
  #moreMenu.open{display:block}
161
- #moreMenu .mlabel,.m3dmenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
191
+ #moreMenu .mlabel,.m3dmenu .mlabel,.cmmenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
162
192
  #moreMenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
163
193
  #moreMenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
164
194
  #moreMenu button:hover{background:#334155}
@@ -184,6 +214,9 @@
184
214
  #lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
185
215
  line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
186
216
  .snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
217
+ line.extguide{stroke:#22d3ee;stroke-width:1.5;stroke-dasharray:6 5;opacity:.55;vector-effect:non-scaling-stroke;pointer-events:none} /* extension guide ray — faintest line on screen (a hint, not committed geometry) */
218
+ /* while a snap marker is up, the marker IS the cursor (Tekla) — hide the native crosshair over the canvas so it doesn't double up on it. !important beats the per-tool crosshair rules below; scoped to #svg so panels keep their cursor. */
219
+ body.snapping #svg,body.snapping #svg *{cursor:none!important}
187
220
  line.dim{stroke:#22d3ee;stroke-width:2.5;vector-effect:non-scaling-stroke;pointer-events:stroke;cursor:pointer}
188
221
  line.dimwit{stroke:#22d3ee;stroke-width:1;opacity:.85;vector-effect:non-scaling-stroke;pointer-events:none}
189
222
  circle.dimend{fill:#22d3ee;pointer-events:none}
@@ -317,32 +350,42 @@
317
350
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
318
351
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
319
352
  <span class=stat id=csStat title="Coordinate system — Global by default. Set a local frame from the ⋯ menu for skewed framing.">Axes <b>Global</b></span>
353
+ <span class=stat id=snapStat style="display:none" title="Snap restricted for this operation — right-click the canvas to change; click here or press Esc to clear">Snap <b>—</b></span>
320
354
  <span class=stat id=confStat title="Confidence report — AI-read score vs. target. Click to review element evidence." style="display:none">Confidence <b id=confPct>—</b><span id=confTgt></span></span>
321
355
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
322
356
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
323
357
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
324
358
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
325
- <button id=mAdd title="Toggle add-member mode">Add member</button>
326
- <button id=dimB title="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free.">⊢ Dimension</button>
359
+ <button id=mAdd title="Toggle add-member mode — drag to draw. Shift=ortho, Alt=no snap, right-click=restrict snap to one type">Add member</button>
360
+ <button id=dimB title="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free. Right-click the canvas to restrict snapping to one type.">⊢ Dimension</button>
327
361
  <div class=cmwrap>
328
- <button id=mvB title="Move (M) pick a base point, then a destination. Type a distance or dx,dy after the first pick for an exact move.">↔ Move</button><button id=mvCaret class=cmcaret aria-haspopup=menu aria-expanded=false aria-label="Move options">▾</button>
329
- <div class=cmmenu id=mvMenu role=menu>
362
+ <button id=xfB aria-haspopup=menu aria-expanded=false title="Move, Copy, array and to-level, plus how left-dragging a member behaves. Move (M) / Copy (C) also arm from the keyboard.">Move / Copy ▾</button>
363
+ <div class=cmmenu id=xfMenu role=menu>
364
+ <div class=mlabel>Move</div>
330
365
  <button id=mvTwoB>Move — two points <span class=mkbd>M</span></button>
331
366
  <button id=mvLevelB>Move to level…</button>
332
- </div>
333
- </div>
334
- <div class=cmwrap>
335
- <button id=cpB title="Copy (C) — pick a base point, then a destination. Set ×N in the panel for a linear row; type a value for an exact offset.">⧉ Copy</button><button id=cpCaret class=cmcaret aria-haspopup=menu aria-expanded=false aria-label="Copy options">▾</button>
336
- <div class=cmmenu id=cpMenu role=menu>
367
+ <div class=mlabel>Copy</div>
337
368
  <button id=cpTwoB>Copy — two points <span class=mkbd>C</span></button>
338
369
  <button id=cpArrB>Copy array…</button>
339
370
  <button id=cpLevelB>Copy to level…</button>
371
+ <div class=mlabel>Dragging</div>
372
+ <button id=dragMoveB role=menuitemcheckbox aria-checked=false title="When ON, left-dragging a member moves it. When OFF (default), a click only selects — members can't be nudged by accident (Move and Ctrl+drag-copy still work). Applies to 2D and 3D.">Drag to move<span class=mstate id=dragMoveState>Off</span></button>
340
373
  </div>
341
374
  </div>
342
375
  <button id=askAiBtn>Ask AI ▸</button>
343
376
  <div id=moreWrap>
344
377
  <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
345
378
  <div id=moreMenu role=menu>
379
+ <button id=snapHdr class=msnap-hdr aria-expanded=false aria-controls=snapSect title="Running snaps — click to expand and turn each on/off (also on the snap bar, bottom-right)">Snapping<span class=chev aria-hidden=true>▸</span></button>
380
+ <div id=snapSect class=msnap-sect>
381
+ <button class="msnap on" data-snap=end role=menuitemcheckbox aria-checked=true title="Snap to member/segment endpoints"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>□</span>Endpoint</button>
382
+ <button class="msnap on" data-snap=int role=menuitemcheckbox aria-checked=true title="Snap to where two members/segments cross"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>✕</span>Intersection</button>
383
+ <button class="msnap on" data-snap=mid role=menuitemcheckbox aria-checked=true title="Snap to the midpoint of a member/segment"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>△</span>Midpoint</button>
384
+ <button class="msnap on" data-snap=line role=menuitemcheckbox aria-checked=true title="Snap to the nearest point on any member/segment line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>⧗</span>Nearest</button>
385
+ <button class="msnap" data-snap=ext role=menuitemcheckbox aria-checked=false title="Snap along the invisible extension of a member/line past its endpoint, with a dashed guide line"><span class=mck aria-hidden=true></span><span class=sg aria-hidden=true>┈</span>Extension</button>
386
+ <div class=mhint>Right-click the canvas any time to force one snap type for a single pick.</div>
387
+ </div>
388
+ <hr>
346
389
  <div class=mlabel>Dimensions</div>
347
390
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
348
391
  <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
@@ -390,7 +433,7 @@
390
433
  <span class=tb-sep></span>
391
434
  <!-- Measure / annotate -->
392
435
  <button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
393
- <button id=m3dDim data-tip="Measure — click two snapped points (D); axis Free/X/Y/Z, Alt = vertical">Dimension</button>
436
+ <button id=m3dDim data-tip="Measure — click two snapped points (D); axis Free/X/Y/Z, Alt = vertical; right-click = restrict snap">Dimension</button>
394
437
  <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>
395
438
  <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
396
439
  <span class=tb-sep></span>
@@ -440,6 +483,14 @@
440
483
  <span id=zPct>100%</span>
441
484
  <button id=zFit title="Zoom to fit (Home)">Fit</button>
442
485
  </div>
486
+ <!-- Quick-access snap toggles (same persistent state as the ⋯ menu → Snapping section; keep the two in sync — same 5 items). -->
487
+ <div id=snapBar role=group aria-label="Snap settings">
488
+ <button data-snap=end title="Endpoint snap">□</button>
489
+ <button data-snap=int title="Intersection snap">✕</button>
490
+ <button data-snap=mid title="Midpoint snap">△</button>
491
+ <button data-snap=line title="Nearest snap">⧗</button>
492
+ <button data-snap=ext title="Extension snap">┈</button>
493
+ </div>
443
494
  </div>
444
495
  <aside id=panel></aside>
445
496
  </main>
@@ -1190,6 +1241,7 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
1190
1241
  selIds=ns;selDimIds.clear();mode='sel';setMode();pushUndo(pv);render();}
1191
1242
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
1192
1243
  function render(){
1244
+ document.body.classList.remove('snapping'); // a wholesale svg rebuild drops any #snapMark → clear the cursor-hide flag too (the next pointermove re-adds it if still snapping)
1193
1245
  if(geoMode&&(selIds.size<1||(geoMode==='split'&&selIds.size!==1))){geoMode=null;document.body.classList.remove('geo');} // Split needs exactly one member; Extend/Trim works on any selection
1194
1246
  let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
1195
1247
  s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
@@ -1580,43 +1632,107 @@ function rectHit(p0,p1,r){
1580
1632
  const c=[[r[0],r[1]],[r[2],r[1]],[r[2],r[3]],[r[0],r[3]]];
1581
1633
  for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
1582
1634
  return false;}
1583
- // --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△); members lie on grids → snaps to grid intersections; Alt = off ---
1584
- const SNAP_PX=9; let snapPts=[], snapSegs=[], snapMids=[], lastSnapKind='end'; // lastSnapKind drives the marker glyph: 'mid' triangle, else circle
1635
+ // --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△) + line INTERSECTIONS (×); members lie on grids → snaps to grid intersections; Alt = off; right-click = snap-override menu ---
1636
+ const SNAP_PX=9; let snapPts=[], snapSegs=[], snapMids=[], snapInts=[], snapGeom=[], lastSnapKind='end', snapExtFrom=null; // lastSnapKind marker glyph; snapGeom = member/segment lines (no dims) for extension; snapExtFrom = the endpoint an extension snap projects from
1637
+ let snapOnly=null; // Tekla-style TRANSIENT override: 'end'|'int'|'mid'|'line'|'ext' restricts snapping to ONE type for the current operation (right-click menu); null = use the persistent running-snaps
1638
+ // persistent running-snaps (AutoCAD Osnap-style), GLOBAL across models, toggled from the ⋯ menu → Snapping
1639
+ const SNAP_PREF_KEY='steel:snapPrefs:v1', SNAP_DEFAULTS={end:true,int:true,mid:true,line:true,ext:false};
1640
+ let snapEnabled=(()=>{try{return Object.assign({},SNAP_DEFAULTS,JSON.parse(localStorage.getItem(SNAP_PREF_KEY)||'{}'));}catch(_){return {...SNAP_DEFAULTS};}})();
1641
+ function saveSnapPrefs(){try{localStorage.setItem(SNAP_PREF_KEY,JSON.stringify(snapEnabled));}catch(_){}}
1642
+ // a type fires when a transient override forces exactly it (override ignores running-snaps, AutoCAD-style), else when its running-snap is on
1643
+ const snapAllowed=k=>snapOnly?snapOnly===k:snapEnabled[k]!==false;
1585
1644
  const _mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
1645
+ // intersection of segments a-b and c-d (inside both), or null. ponytail: O(n²) pairwise per buildSnap — fine for hundreds of lines; spatial index if a big plan ever lags.
1646
+ function _segX(a,b,c,d){const rx=b[0]-a[0],ry=b[1]-a[1],sx=d[0]-c[0],sy=d[1]-c[1],den=rx*sy-ry*sx;
1647
+ if(Math.abs(den)<1e-9)return null;const qx=c[0]-a[0],qy=c[1]-a[1],t=(qx*sy-qy*sx)/den,u=(qx*ry-qy*rx)/den;
1648
+ return (t<0||t>1||u<0||u>1)?null:[a[0]+rx*t,a[1]+ry*t];}
1586
1649
  function buildSnap(except,opts){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
1587
- snapPts=[];snapSegs=[];snapMids=[];
1588
- for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);snapMids.push(_mid(m.wp[0],m.wp[1]));}
1589
- for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);snapMids.push(_mid(s.a,s.b));}
1590
- if(dimsVisible&&Array.isArray(P.dims))for(const d of P.dims){if(ex.has(d.id))continue; // snap to a dimension's VISIBLE (offset) line its ends, midpoint, and onto-line (dimGeo already gives g.mid)
1591
- const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);snapPts.push(g.p1,g.p2);snapSegs.push([g.p1,g.p2]);snapMids.push(g.mid);}
1592
- if(gridOn()&&!(opts&&opts.noGrid)){const geo=gridGeo();if(geo){const gs=GC().gridSnapGeom(geo); // grid lines + intersections snap like member geometry (a hidden grid doesn't snap; a DRAGGED grid line must not stick to its own intersections)
1593
- for(const sg of gs.segs)snapSegs.push(sg);for(const q of gs.pts)snapPts.push(q);}}}
1650
+ snapPts=[];snapSegs=[];snapMids=[];snapInts=[];snapGeom=[]; // snapGeom = real member/segment/grid lines (extension + intersections project off these, never off dim/annotation lines)
1651
+ for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);snapMids.push(_mid(m.wp[0],m.wp[1]));snapGeom.push([m.wp[0],m.wp[1]]);}
1652
+ for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);snapMids.push(_mid(s.a,s.b));snapGeom.push([s.a,s.b]);}
1653
+ if(gridOn()&&!(opts&&opts.noGrid)){const geo=gridGeo();if(geo){const gs=GC().gridSnapGeom(geo); // grid lines snap like member geometry nearest + extension + member/grid intersections; grid nodes → endpoints. (Hidden grid doesn't snap; a DRAGGED grid line passes opts.noGrid so it can't stick to its own lines.)
1654
+ for(const sg of gs.segs){snapSegs.push(sg);snapGeom.push(sg);}for(const q of gs.pts)snapPts.push(q);}}
1655
+ for(let i=0;i<snapGeom.length;i++)for(let j=i+1;j<snapGeom.length;j++){const x=_segX(snapGeom[i][0],snapGeom[i][1],snapGeom[j][0],snapGeom[j][1]);if(x)snapInts.push(x);} // member×member, member×grid and grid×grid crossings intersection (×) snap
1656
+ if(dimsVisible&&!(opts&&opts.noDims)&&Array.isArray(P.dims))for(const d of P.dims){if(ex.has(d.id))continue; // dim (annotation) lines: nearest / endpoint / midpoint only — NOT intersections or extension; opts.noDims skips them (e.g. a grid-line drag shouldn't stick to annotations)
1657
+ const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);snapPts.push(g.p1,g.p2);snapSegs.push([g.p1,g.p2]);snapMids.push(g.mid);}}
1594
1658
  // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
1595
1659
  function nearestOnLine(x,y,tol){let bp=null,bd=tol;
1596
1660
  for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
1597
1661
  return bp?{x:bp[0],y:bp[1],d:bd}:null;}
1598
- function snap(x,y){const tol=SNAP_PX/zoom;lastSnapKind='end';let bp=null,bd=tol;
1599
- for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
1600
- if(bp)return {x:bp[0],y:bp[1],hit:true,kind:'end'}; // 1) endpoint / grid intersection (highest priority)
1601
- let mp=null,md=tol;for(const q of snapMids){const d=Math.hypot(q[0]-x,q[1]-y);if(d<md){md=d;mp=q;}}
1602
- if(mp){lastSnapKind='mid';return {x:mp[0],y:mp[1],hit:true,kind:'mid'};} // 2) midpoint of a member/segment/dimension line (△)
1603
- const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true,kind:'line'}; // 3) else snap ONTO a line (not only its endpoints)
1604
- let sx=x,sy=y,bx=tol,by=tol; // 4) else axis-align to a nearby point
1605
- for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
1606
- return {x:sx,y:sy,hit:false,kind:null};}
1662
+ // nearest point on the EXTENSION of a line — the perpendicular foot on the infinite line, but ONLY where it
1663
+ // lies BEYOND the segment (t<0 or t>1). Returns the foot + the endpoint it extends from (for the dashed guide).
1664
+ function nearestOnExtension(x,y,tol){let best=null,bd=tol;
1665
+ for(const sg of snapGeom){const a=sg[0],b=sg[1],vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy;if(L2<1e-9)continue;
1666
+ const t=((x-a[0])*vx+(y-a[1])*vy)/L2;if(t>=0&&t<=1)continue; // on the segment itself that's the "nearest" snap, not extension
1667
+ const fx=a[0]+t*vx,fy=a[1]+t*vy,d=Math.hypot(fx-x,fy-y);
1668
+ if(d<bd){bd=d;best={x:fx,y:fy,from:(t<0?a:b)};}}
1669
+ return best;}
1670
+ function snap(x,y){const tol=SNAP_PX/zoom;lastSnapKind='end';snapExtFrom=null;
1671
+ if(snapAllowed('end')){let bp=null,bd=tol;
1672
+ for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
1673
+ if(bp)return {x:bp[0],y:bp[1],hit:true,kind:'end'};} // 1) endpoint / grid intersection (highest priority)
1674
+ if(snapAllowed('int')){let ip=null,idd=tol;for(const q of snapInts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<idd){idd=d;ip=q;}}
1675
+ if(ip){lastSnapKind='int';return {x:ip[0],y:ip[1],hit:true,kind:'int'};}} // 2) member/segment line crossing (×) — Tekla's intersection snap
1676
+ if(snapAllowed('mid')){let mp=null,md=tol;for(const q of snapMids){const d=Math.hypot(q[0]-x,q[1]-y);if(d<md){md=d;mp=q;}}
1677
+ if(mp){lastSnapKind='mid';return {x:mp[0],y:mp[1],hit:true,kind:'mid'};}} // 3) midpoint of a member/segment/dimension line (△)
1678
+ if(snapAllowed('line')){const ln=nearestOnLine(x,y,tol);if(ln){lastSnapKind='line';return {x:ln.x,y:ln.y,hit:true,kind:'line'};}} // 4) nearest point ON a line (hourglass)
1679
+ if(snapAllowed('ext')){const ex=nearestOnExtension(x,y,tol);if(ex){lastSnapKind='ext';snapExtFrom=ex.from;return {x:ex.x,y:ex.y,hit:true,kind:'ext'};}} // 5) along a line's EXTENSION past its end (dashed guide) — off by default
1680
+ if(!snapOnly&&snapEnabled.end!==false){let sx=x,sy=y,bx=tol,by=tol; // 6) else axis-align to a nearby endpoint's row/column (an endpoint-based aid → off when Endpoint snap is off, or under any "only X" override)
1681
+ for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
1682
+ return {x:sx,y:sy,hit:false,kind:null};}
1683
+ return {x,y,hit:false,kind:null};}
1607
1684
  // one persistent marker element; kind 'mid' draws an upward triangle, everything else the original circle. Defaults to the last snap()'s kind.
1608
1685
  function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
1609
1686
  const ns='http://www.w3.org/2000/svg';let el;
1610
1687
  if(kind==='mid'){const r=8/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x} ${y-r} L ${x+0.866*r} ${y+0.5*r} L ${x-0.866*r} ${y+0.5*r} Z`);} // apex-up equilateral, centroid ON the snap point
1611
- else{el=document.createElementNS(ns,'circle');el.setAttribute('cx',x);el.setAttribute('cy',y);el.setAttribute('r',6/zoom);}
1612
- el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);}
1613
- function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
1688
+ else if(kind==='int'){const r=7/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x-r} ${y-r} L ${x+r} ${y+r} M ${x-r} ${y+r} L ${x+r} ${y-r}`);} // × centred on the line crossing
1689
+ else if(kind==='end'){const r=6/zoom;el=document.createElementNS(ns,'rect');el.setAttribute('x',x-r);el.setAttribute('y',y-r);el.setAttribute('width',2*r);el.setAttribute('height',2*r);} // endpoint = SQUARE (CAD convention)
1690
+ else if(kind==='ext'){const r=6/zoom;el=document.createElementNS(ns,'rect');el.setAttribute('x',x-r);el.setAttribute('y',y-r);el.setAttribute('width',2*r);el.setAttribute('height',2*r);el.setAttribute('stroke-dasharray',`${3/zoom} ${2/zoom}`); // extension = a DASHED square (on the endpoint's ray) + the dashed guide line below
1691
+ if(snapExtFrom){const g=document.createElementNS(ns,'line');g.setAttribute('x1',snapExtFrom[0]);g.setAttribute('y1',snapExtFrom[1]);g.setAttribute('x2',x);g.setAttribute('y2',y);g.id='snapExtLine';g.setAttribute('class','extguide');svg.appendChild(g);}}
1692
+ 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
+ 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
+ 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');}
1695
+ // --- Tekla-style snap override (right-click): restrict snapping to ONE type for the current operation.
1696
+ // 2D: right-click anywhere on the canvas (set it before grabbing an endpoint — a drag captures the pointer).
1697
+ // 3D: right-CLICK while the Dimension tool is armed (right-DRAG stays orbit/pan). The override clears when
1698
+ // the operation ends: tool disarm, plan/view switch, a select-mode drag ending, Esc, or "All snaps". ---
1699
+ const SNAP_KINDS_2D=[['end','□','Endpoint'],['int','✕','Intersection'],['mid','△','Midpoint'],['line','⧗','Nearest'],['ext','┈','Extension']]; // glyphs mirror the on-canvas markers; 'line' = AutoCAD "Nearest"
1700
+ const SNAP_KINDS_3D=[['vertex','□','Vertex'],['intersection','✕','Intersection'],['midpoint','△','Midpoint'],['centerline','⧗','Centerline'],['vertical-axis','│','Vertical axis']];
1701
+ function snapKindLabel(k,is3d){const r=(is3d?SNAP_KINDS_3D:SNAP_KINDS_2D).find(x=>x[0]===k);return r?r[2]:k;}
1702
+ function updSnapStat(){const el=document.getElementById('snapStat');if(!el)return;
1703
+ const V=window.Steel3DView,k=view3d?(V&&V.snapOnly?V.snapOnly():null):snapOnly;
1704
+ if(!k){el.style.display='none';return;}
1705
+ el.style.display='';el.innerHTML='Snap <b>'+(k==='none'?'off':esc(snapKindLabel(k,view3d))+' only')+'</b>';}
1706
+ function setSnapOnly(k,is3d){if(is3d){const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(k);}else{snapOnly=k;}updSnapStat();}
1707
+ function snapOnlyClear2d(){if(snapOnly){snapOnly=null;updSnapStat();}}
1708
+ function snapMenuEl(){let m=document.getElementById('snapMenu');
1709
+ if(!m){m=document.createElement('div');m.id='snapMenu';m.className='m3dmenu';m.setAttribute('role','menu');m.tabIndex=-1;document.body.appendChild(m);
1710
+ m.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;setSnapOnly(b.dataset.k||null,m._is3d);closeSnapMenu();});}
1711
+ return m;}
1712
+ function snapMenuOpen(){const m=document.getElementById('snapMenu');return !!(m&&m.classList.contains('open'));}
1713
+ function closeSnapMenu(){const m=document.getElementById('snapMenu');if(m)m.classList.remove('open');}
1714
+ function openSnapMenu(x,y,is3d){const m=snapMenuEl();m._is3d=!!is3d;
1715
+ const V=window.Steel3DView,cur=is3d?(V&&V.snapOnly?V.snapOnly():null):snapOnly;
1716
+ const off=k=>!is3d&&k&&snapEnabled[k]===false; // a running-snap turned off in the ⋯ menu → dim it here (the override can still force it — AutoCAD-style)
1717
+ const row=(k,g,l)=>`<button role=menuitem data-k="${k}" class="${(cur===k||(!cur&&!k))?'on':''}${off(k)?' dim':''}"><span class=sg aria-hidden=true>${g}</span>${l}${off(k)?' <span class=offtag>off</span>':''}</button>`;
1718
+ m.innerHTML=row('','✓','All snaps (default)')+'<hr>'+(is3d?SNAP_KINDS_3D:SNAP_KINDS_2D).map(r=>row(r[0],r[1],r[2])).join('')+'<hr>'+row('none','⌀','No snap (free)');
1719
+ m.classList.add('open');const r=m.getBoundingClientRect();
1720
+ 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
+ m.focus();}
1722
+ 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);});
1724
+ 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);});
1728
+ document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
1614
1729
  // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
1615
1730
  // buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
1616
1731
  // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
1617
1732
  const SVGNS='http://www.w3.org/2000/svg';
1618
1733
  function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
1619
1734
  if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes/move-copy) + ensures dims are visible — covers the D-key path too, not just the button
1735
+ snapOnlyClear2d(); // any dim-tool transition ends the previous operation → the snap override reverts (arming must not inherit add-mode's override)
1620
1736
  dimDraft=null;dimChainPrev=null;dimPrevClear();}
1621
1737
  function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
1622
1738
  if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;return {x,y,raw:[q.x,q.y],hit:sn.hit};}
@@ -1659,9 +1775,9 @@ function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
1659
1775
  function dimHintText(){if(dimChainPrev)return 'Chaining — click the next point to add another dimension from the last one. Enter or Esc ends the chain.';
1660
1776
  const loc=!!(P&&P.frame);
1661
1777
  const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
1662
- return 'Dimension — '+a+(dimChain?' · Chain ON':'')+(loc?' · local axes':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
1778
+ return 'Dimension — '+a+(dimChain?' · Chain ON':'')+(loc?' · local axes':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · right-click=snap menu · Esc=cancel.';}
1663
1779
  function toggleDimChain(){dimChain=!dimChain;if(!dimChain){dimChainPrev=null;dimPrevClear();}render();} // Chain (C): toggle continuous dimensioning; turning it off ends any running chain (render() repaints the panel)
1664
- function toggleDimSplit(){dimSplitMode=!dimSplitMode;snapClear();render();} // S / "Add split point": arm splitting on the selected dim(s); each click then inserts a point
1780
+ function toggleDimSplit(){dimSplitMode=!dimSplitMode;snapClear();snapOnlyClear2d();render();} // S / "Add split point": arm splitting on the selected dim(s); each click then inserts a point; any transition drops the previous operation's snap override
1665
1781
  // Split the SELECTED dim nearest the click into two at a point ON its baseline (same axis + offset).
1666
1782
  // Picks the closest selected dim within a screen tolerance whose interior the click projects into; the
1667
1783
  // split point is the perpendicular foot, so a free (oblique) dim never kinks and the total is preserved.
@@ -1693,12 +1809,16 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
1693
1809
  // array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
1694
1810
  // lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
1695
1811
  let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
1812
+ // Drag-to-move: OFF by default so a click only selects (no accidental nudges). Persisted per-browser,
1813
+ // global (a UI pref, not per-app). Ctrl+drag-copy ignores this — it's an explicit gesture.
1814
+ let dragMoveOn=false; try{dragMoveOn=localStorage.getItem('steel:dragmove:v1')==='1';}catch(_){}
1815
+ let dragHintShown=false; try{dragHintShown=localStorage.getItem('steel:dragmovehint:v1')==='1';}catch(_){} // one-time "drag-move is off" teach on the first blocked drag
1816
+ function setDragMove(on){dragMoveOn=!!on;document.body.classList.toggle('dragmove',dragMoveOn);
1817
+ const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-checked',String(dragMoveOn));const st=document.getElementById('dragMoveState');if(st)st.textContent=dragMoveOn?'On':'Off';}
1818
+ try{localStorage.setItem('steel:dragmove:v1',dragMoveOn?'1':'0');}catch(_){}}
1696
1819
  // cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
1697
1820
  function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
1698
- document.getElementById('mvB').classList.toggle('on',cmTool==='move');
1699
- document.getElementById('mvCaret').classList.toggle('on',cmTool==='move');
1700
- document.getElementById('cpB').classList.toggle('on',cmTool==='copy');
1701
- document.getElementById('cpCaret').classList.toggle('on',cmTool==='copy');}
1821
+ document.getElementById('xfB').classList.toggle('on',!!cmTool);} // the Transform button fills when Move OR Copy is armed (the side-panel badge names which)
1702
1822
  function disarmCm(){cmTool=null;cmArrayMode=false;cmDraft=null;cmHudClose();cmPrevClear();setCmUi();
1703
1823
  if(window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d();} // drop the 3D-side pick/preview too
1704
1824
  function armCm(tool,array){ // re-arming the same config toggles the tool off — works in 2D and (on the working plane) in 3D
@@ -1870,6 +1990,7 @@ function toLevelCommit(tool,ti){const sel=selArr();const T=C.plans[ti];
1870
1990
  function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
1871
1991
  const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
1872
1992
  if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
1993
+ snapOnlyClear2d(); // any set-axes transition ends the previous operation → the snap override reverts (arming must not inherit another tool's override)
1873
1994
  csDraft=null;csPrevClear();}
1874
1995
  function csSnapAt(e){const q=toSvg(e);if(e.altKey)return [q.x,q.y];buildSnap(null);const sn=snap(q.x,q.y);return [sn.x,sn.y];}
1875
1996
  function csClick(e){const p=csSnapAt(e);
@@ -1926,7 +2047,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1926
2047
  const gel=gb||ghit;
1927
2048
  if(gel&&mode==='sel'&&!geoMode&&!dimSplitMode&&!picking&&P.grid){
1928
2049
  const axis=gel.dataset.gv!=null?'v':'h',gi=+(gel.dataset.gv!=null?gel.dataset.gv:gel.dataset.gh);
1929
- if(isFinite(gi)){buildSnap(null,{noGrid:true});const q=toSvg(e);const geo=gridGeo();const line=geo&&geo[axis][gi];
2050
+ if(isFinite(gi)){buildSnap(null,{noGrid:true,noDims:true});const q=toSvg(e);const geo=gridGeo();const line=geo&&geo[axis][gi];
1930
2051
  if(line){const end=gb?+(gb.dataset.end||0):null; // a bubble grab can become an END drag (along the line); a body grab is always a perpendicular move
1931
2052
  drag={type:'gridline',axis,gi,startPt:[q.x,q.y],orig:axis==='v'?line.x:line.y,origEnd:end===0?line.lo:line.hi,end,
1932
2053
  base:geo[axis].map(l=>axis==='v'?l.x:l.y),bubble:!!gb,moved:false,dir:null,pre:snapshot()}; // base = dragstart positions so one drag quantizes the string ONCE (no 1/16" creep on unmoved lines)
@@ -1968,12 +2089,16 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1968
2089
  return;}}
1969
2090
  if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
1970
2091
  if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
1971
- if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id;
1972
- if(e.ctrlKey||e.metaKey){selIds.has(id)?selIds.delete(id):selIds.add(id);render();return;}
1973
- if(!selIds.has(id)){selIds=new Set([id]);render();}
1974
- const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
1975
- buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
1976
- drag={type:'move',start:[p.x,p.y],items,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
2092
+ if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
2093
+ // Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
2094
+ // its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
2095
+ if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
2096
+ const canDrag=ctrl||dragMoveOn; // plain grab only translates when the toggle is on; ctrl always drags (to copy)
2097
+ const srcIds=(ctrl&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // ctrl on an unselected member copies just it; else the whole selection
2098
+ const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
2099
+ buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
2100
+ drag={type:'grab',copy:ctrl,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
2101
+ svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // always capture: even an unarmed grab tracks the gesture so a blocked drag can teach the toggle
1977
2102
  if((t===svg||t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e); // t===svg → drag on bare canvas (no raster) still box-selects
1978
2103
  const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
1979
2104
  drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
@@ -2022,14 +2147,26 @@ svg.addEventListener('pointermove',e=>{
2022
2147
  drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
2023
2148
  if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
2024
2149
  drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
2025
- if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
2150
+ if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
2151
+ if(!drag.moved){
2152
+ if(Math.hypot(rawdx,rawdy)<4/zoom)return; // below the drag threshold — still a potential click
2153
+ drag.moved=true;
2154
+ if(!drag.armed)return; // drag-move OFF, plain grab → never translate (the up-handler teaches the toggle)
2155
+ if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
2156
+ const ns=new Set(),ci=[];
2157
+ for(const it of drag.items){const src=byId(it.id);if(!src)continue;const c=cloneMember(src);P.members.push(c);ns.add(c.id);ci.push({id:c.id,o0:c.wp[0].slice(),o1:c.wp[1].slice()});}
2158
+ selIds=ns;drag.items=ci;buildSnap(ns);render();}} // select the clones + snap them to the sources/others; render shows them (drag continues via updateLine)
2159
+ if(!drag.armed)return;
2160
+ let dx=rawdx,dy=rawdy;
2026
2161
  if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
2027
- else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint, midpoint △, OR line)
2162
+ else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint, intersection ×, midpoint △, OR line) — each family honours the right-click override
2028
2163
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
2029
- for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'end'};}}
2030
- for(const q of snapMids){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'mid'};}}
2031
- const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny,kind:'line'};}} // also snap onto other lines
2032
- if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1],best.kind);}else snapClear();}
2164
+ if(snapAllowed('end'))for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'end'};}}
2165
+ if(snapAllowed('int'))for(const q of snapInts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'int'};}}
2166
+ if(snapAllowed('mid'))for(const q of snapMids){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny,kind:'mid'};}}
2167
+ if(snapAllowed('line')){const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny,kind:'line'};}} // also snap onto other lines (honours the running-snaps + override)
2168
+ if(snapAllowed('ext')){const ex=nearestOnExtension(nx,ny,bd);if(ex){bd=Math.hypot(ex.x-nx,ex.y-ny);best={q:[ex.x,ex.y],cx:ex.x-nx,cy:ex.y-ny,kind:'ext',from:ex.from};}}} // ...and onto a line's extension (parity with the single-endpoint drag / draw / dim paths)
2169
+ if(best){dx+=best.cx;dy+=best.cy;if(best.kind==='ext')snapExtFrom=best.from;snapMark(best.q[0],best.q[1],best.kind);}else snapClear();}
2033
2170
  else snapClear();
2034
2171
  for(const it of drag.items){const m=byId(it.id);if(!m)continue;
2035
2172
  m.wp[0]=[it.o0[0]+dx,it.o0[1]+dy];m.wp[1]=[it.o1[0]+dx,it.o1[1]+dy];updateLine(m);}
@@ -2041,6 +2178,7 @@ svg.addEventListener('pointermove',e=>{
2041
2178
  m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
2042
2179
  svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2043
2180
  if(drag.type==='gridline'){const wasBubble=drag.bubble,moved=drag.moved,pre=drag.pre;drag=null;gridReadoutHide();
2181
+ snapOnlyClear2d(); // a grid-line drag is one discrete operation → its single-shot snap override always reverts (grid-line drags can run while the grid panel is open, so don't gate on anyToolActive())
2044
2182
  if(!moved){if(wasBubble){setGridMode(true); // opens the panel (clears the logical selection). NO render() — replacing the bubble between the two clicks of a dbl-click would swallow the rename gesture…
2045
2183
  svg.querySelectorAll('line.member.sel').forEach(n=>{n.classList.remove('sel');n.style.filter='';}); // …so drop the stale selection VISUALS surgically instead
2046
2184
  svg.querySelectorAll('circle.handle,circle.dimhandle,circle.numbg,text.numtx').forEach(n=>n.remove());
@@ -2056,7 +2194,19 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2056
2194
  const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
2057
2195
  if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
2058
2196
  drag.rect.remove();drag=null;render();return;}
2059
- if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
2197
+ if(drag.type==='grab'){
2198
+ if(!drag.moved){ // no drag past threshold → it was a click
2199
+ if(drag.copy){selIds.has(drag.clickId)?selIds.delete(drag.clickId):selIds.add(drag.clickId);} // ctrl+click toggles selection (deferred from down)
2200
+ drag=null;render();return;}
2201
+ if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
2202
+ drag=null;render();
2203
+ if(!dragHintShown){dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use ↔ Move (M)');}
2204
+ return;}
2205
+ if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre); // armed move/copy committed (copy always changed geometry via its clones)
2206
+ const wasCopy=drag.copy;if(!anyToolActive())snapOnlyClear2d();drag=null;render();if(wasCopy)toast('Copied '+selIds.size+' member'+(selIds.size===1?'':'s'));return;} // a grab move/copy was the operation → single-shot override clears
2207
+ if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);
2208
+ if(!anyToolActive())snapOnlyClear2d(); // a select-mode drag WAS the operation → the override was single-shot
2209
+ drag=null;render();});
2060
2210
  svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
2061
2211
  svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
2062
2212
  const at=document.elementFromPoint(e.clientX,e.clientY); // the pointer capture the drag takes retargets dblclick to the svg root — resolve the bubble by position, not e.target
@@ -2067,7 +2217,7 @@ svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename tha
2067
2217
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
2068
2218
  document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();
2069
2219
  if(view3d&&window.Steel3DView){const dm=document.getElementById('m3dDim');if(dm&&dm.classList.contains('on'))window.Steel3DView.toggleDimTool();} // the 3D dim tool shares the left-click — disarm it when entering add mode
2070
- selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();else if(window.Steel3DView&&window.Steel3DView.drClear3d)window.Steel3DView.drClear3d();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection; leaving add clears the 3D draw draft
2220
+ selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();else if(window.Steel3DView&&window.Steel3DView.drClear3d)window.Steel3DView.drClear3d();snapOnlyClear2d();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools + drops any dim selection + reverts a snap override; leaving add clears the 3D draw draft
2071
2221
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
2072
2222
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
2073
2223
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
@@ -2092,10 +2242,12 @@ document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members)
2092
2242
  toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
2093
2243
  document.getElementById('undoB').onclick=doUndo;
2094
2244
  document.getElementById('redoB').onclick=doRedo;
2245
+ document.getElementById('dragMoveB').onclick=()=>{setDragMove(!dragMoveOn);toast(dragMoveOn?'Drag to move: ON — dragging a member moves it':'Drag to move: OFF — a click only selects');};
2246
+ setDragMove(dragMoveOn); // reflect the persisted state on load (body class + pill)
2095
2247
  addEventListener('keydown',e=>{
2096
2248
  const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
2097
2249
  if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
2098
- if(e.key==='Escape'&&(mvMenuC.isOpen()||cpMenuC.isOpen())){mvMenuC.close();cpMenuC.close();return;}
2250
+ if(e.key==='Escape'&&xfMenuC.isOpen()){xfMenuC.close();return;}
2099
2251
  if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
2100
2252
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
2101
2253
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
@@ -2104,6 +2256,8 @@ addEventListener('keydown',e=>{
2104
2256
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
2105
2257
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
2106
2258
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
2259
+ if(e.key==='Escape'&&snapMenuOpen()){closeSnapMenu();return;}
2260
+ if(e.key==='Escape'&&!view3d&&snapOnly){snapOnlyClear2d();return;} // own Esc rung: 1st Esc drops the snap override, the next cancels the tool/draft (mirrors the set-axes two-step)
2107
2261
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
2108
2262
  if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
2109
2263
  if((e.key===' '||e.key==='Enter')&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!e.shiftKey){if(!anyToolActive()&&lastCmd){e.preventDefault();repeatLast();}return;} // idle Space/Enter → repeat the last command (AutoCAD/Tekla)
@@ -2158,17 +2312,29 @@ function moreOpen(){return moreMenu.classList.contains('open');}
2158
2312
  function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
2159
2313
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
2160
2314
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
2161
- moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
2315
+ moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.msnap-hdr'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles + the "Snapping" expander keep the menu open (settings, not one-shot actions)
2316
+ // "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
2317
+ {const snapHdr=document.getElementById('snapHdr'),snapSect=document.getElementById('snapSect');
2318
+ snapHdr.onclick=e=>{e.stopPropagation();const open=snapSect.classList.toggle('open');snapHdr.setAttribute('aria-expanded',String(open));};}
2319
+ // --- Running-snaps: one source of truth (snapEnabled) rendered onto TWO surfaces — the ⋯ menu switches
2320
+ // (aria-checked) + the quick-access snap bar (aria-pressed). toggleSnap() is the only writer; both surfaces
2321
+ // call it, so they can't drift. The transient right-click override is separate and never writes here. ---
2322
+ const snapBar=document.getElementById('snapBar');
2323
+ function reflectSnapPrefs(){
2324
+ for(const b of moreMenu.querySelectorAll('button.msnap')){const on=snapEnabled[b.dataset.snap]!==false;b.classList.toggle('on',on);b.setAttribute('aria-checked',String(on));}
2325
+ for(const b of snapBar.querySelectorAll('button')){const on=snapEnabled[b.dataset.snap]!==false;b.classList.toggle('on',on);b.setAttribute('aria-pressed',String(on));}}
2326
+ function toggleSnap(k){snapEnabled[k]=!(snapEnabled[k]!==false);saveSnapPrefs();reflectSnapPrefs();snapClear();} // clear any stale marker so the change shows on the next move
2327
+ for(const b of moreMenu.querySelectorAll('button.msnap'))b.onclick=e=>{e.stopPropagation();toggleSnap(b.dataset.snap);}; // menu switch (keeps the menu open — settings, not a one-shot action)
2328
+ for(const b of snapBar.querySelectorAll('button'))b.onclick=()=>toggleSnap(b.dataset.snap); // quick-bar glyph
2329
+ reflectSnapPrefs();
2162
2330
  // --- Move/Copy split-button dropdowns: same open/close discipline as the ⋯ menu ---
2163
2331
  function wireCmMenu(caretId,menuId){const b=document.getElementById(caretId),m=document.getElementById(menuId);
2164
2332
  const close=()=>{m.classList.remove('open');b.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',out,true);};
2165
2333
  const out=e=>{if(!m.contains(e.target)&&e.target!==b)close();};
2166
2334
  b.onclick=e=>{e.stopPropagation();if(m.classList.contains('open'))close();else{m.classList.add('open');b.setAttribute('aria-expanded','true');document.addEventListener('mousedown',out,true);}};
2167
- m.addEventListener('click',e=>{if(e.target.closest('button'))close();});
2335
+ m.addEventListener('click',e=>{const btn=e.target.closest('button');if(btn&&btn.id!=='dragMoveB')close();}); // items close the menu — except the Drag-to-move toggle, which stays open so its state flip is visible
2168
2336
  return {close,isOpen:()=>m.classList.contains('open')};}
2169
- const mvMenuC=wireCmMenu('mvCaret','mvMenu'),cpMenuC=wireCmMenu('cpCaret','cpMenu');
2170
- document.getElementById('mvB').onclick=()=>armCm('move',false);
2171
- document.getElementById('cpB').onclick=()=>armCm('copy',false);
2337
+ const xfMenuC=wireCmMenu('xfB','xfMenu'); // one Transform dropdown holds Move, Copy, their options + the Drag-to-move toggle
2172
2338
  document.getElementById('mvTwoB').onclick=()=>armCm('move',false);
2173
2339
  document.getElementById('cpTwoB').onclick=()=>armCm('copy',false);
2174
2340
  document.getElementById('cpArrB').onclick=()=>armCm('copy',true);
@@ -2185,10 +2351,17 @@ const view3dApi={
2185
2351
  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)
2186
2352
  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
2187
2353
  getMembers:()=>P.members, // raw members (wp) for snap geometry
2354
+ snapEnabled:()=>snapEnabled, // the persistent running-snaps (⋯ menu → Snapping) — 3D honours the same on/off set
2188
2355
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
2189
2356
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
2190
2357
  geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
2191
2358
  onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
2359
+ dragMoveEnabled:()=>dragMoveOn, // the 3D view gates its plain member-drag on this
2360
+ dragMoveHint:()=>{if(dragHintShown)return;dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use ↔ Move (M)');},
2361
+ onCopyDrag3d:(ids,dxMm,dyMm)=>{const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1),r6=n=>Math.round(n*1e6)/1e6; // Ctrl+drag copy: mm delta → plan px (Y FLIPPED), clone + translate + select
2362
+ const dpx=r6(dxMm/k),dpy=r6(-dyMm/k);
2363
+ edit(()=>{const ns=new Set();for(const id of (ids||[])){const src=byId(id);if(!src)continue;const c=cloneMember(src);translateMembers([c],[dpx,dpy,0]);P.members.push(c);ns.add(c.id);}selIds=ns;});
2364
+ toast('Copied '+(ids?ids.length:0)+' member'+((ids&&ids.length===1)?'':'s'));},
2192
2365
  onMoveEndpoint:(id,end,wp)=>{edit(()=>{const m=byId(id);if(m&&Array.isArray(m.wp)&&m.wp.length>=2)m.wp[end]=wp;});}, // 3D end-node drag → write just that endpoint
2193
2366
  onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
2194
2367
  if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dIn;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dIn;m.col.tosDef=false;} // rigid raise (bos null→0, both shift) so the column isn't stretched
@@ -2200,6 +2373,7 @@ const view3dApi={
2200
2373
  getDimOverlays:()=>C.dim_overlays||{}, // the legend DIMENSIONS toggles {bolt_pitch,edge_clearance,cope_size} — the 3D view derives + filters overlays from this
2201
2374
  // dims3d is model-global: mutate + persist directly. NOT via edit() — a plan-scoped undo snapshot would corrupt dims3d across plans (undo on plan A could wipe dims added on plan B), and edit()'s sync3D would needlessly re-extrude every member mesh for a pure annotation. boot() guarantees C.dims3d is an array.
2202
2375
  onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
2376
+ onSnapOnlyChanged:()=>updSnapStat(), // keep the header snap-override chip honest for every 3D-side clear (tool disarm etc.)
2203
2377
  onDeleteDim3d:(id)=>{C.dims3d=(C.dims3d||[]).filter(x=>x.id!==id);sel3dDimIds.delete(id);scheduleSave();refreshDims3d();},
2204
2378
  onSelectDim3d:(id,mods)=>{ // Ctrl/Shift multi-select (same as parts); selecting a dim is exclusive with member/2D-dim/clip selection
2205
2379
  if(!id){sel3dDimIds.clear();render();return;}
@@ -2539,6 +2713,7 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2539
2713
  document.getElementById('m3dWpOff').addEventListener('keydown',ev=>{ev.stopPropagation();if(ev.key==='Enter'){ev.preventDefault();const xy=wpMenu.querySelector('button[data-wpp=xy]');if(xy)xy.click();}});} // Enter in the offset = apply XY (the common case); stopPropagation keeps editor shortcuts out
2540
2714
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
2541
2715
  view3d=on;
2716
+ snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);closeSnapMenu();updSnapStat(); // switching views ends the operation → any snap override reverts
2542
2717
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
2543
2718
  t2.classList.toggle('on',!on);t2.setAttribute('aria-pressed',String(!on));
2544
2719
  t3.classList.toggle('on',on);t3.setAttribute('aria-pressed',String(on));
@@ -2549,6 +2724,7 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
2549
2724
  document.getElementById('m3dBar').style.display=on?'flex':'none';
2550
2725
  document.getElementById('m3dCube').style.display=on?'block':'none';
2551
2726
  document.getElementById('m3dAxes').style.display=on?'block':'none';
2727
+ document.getElementById('snapBar').classList.toggle('s3d',on); // in 3D the snap bar shifts clear of the world-axis triad (bottom-right); see #snapBar.s3d
2552
2728
  if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
2553
2729
  }
2554
2730
  async function setView(on){
@@ -3054,6 +3230,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
3054
3230
  dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
3055
3231
  if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // grid panel/pick-origin disarm per plan like the other takeover tools (a leaked pick would set the origin in the WRONG sheet's display space)
3056
3232
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
3233
+ snapOnlyClear2d();closeSnapMenu(); // a snap override never outlives its plan (different geometry)
3057
3234
  disarmCm(); // Move/Copy resets per plan too (its counts persist — they're session prefs, not plan data)
3058
3235
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
3059
3236
  P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
@@ -3142,7 +3319,28 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
3142
3319
  r=r&&C.joints.length===1&&C.joints[0].main==='a1'; // …and the moved member kept its connection
3143
3320
  }finally{C.plans=sp;C.active=sA;P=sP;undo=su;redo=sr;selIds=ss;C.joints=sj;render();}
3144
3321
  return r;})(),'cross-plan undo+redo restores both plans + moved joints');
3145
- const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
3322
+ // 9) intersection snap (×) + the right-click snap override
3323
+ {const sm=P.members,ss=P.segments,sd=P.dims,sdv=dimsVisible,sz=zoom,so=snapOnly;
3324
+ zoom=1;P.members=[{id:'a',wp:[[0,0],[100,0]]},{id:'b',wp:[[50,-50],[50,50]]}];P.segments=[];P.dims=[];dimsVisible=false;
3325
+ buildSnap(null,{noGrid:true});
3326
+ const i1=snap(52,2);ok(i1.hit&&i1.kind==='int'&&pnear([i1.x,i1.y],[50,0]),'snap → intersection (×) at the crossing, outranks midpoint');
3327
+ const e2=snap(1,1);ok(e2.hit&&e2.kind==='end','snap → endpoint still outranks intersection');
3328
+ snapOnly='mid';const o1=snap(1,1);ok(!o1.hit&&pnear([o1.x,o1.y],[1,1]),'override mid → endpoint ignored, raw point back');
3329
+ const o2=snap(50,2);ok(o2.hit&&o2.kind==='mid'&&pnear([o2.x,o2.y],[50,0]),'override mid → midpoint found');
3330
+ snapOnly='none';const o3=snap(1,1);ok(!o3.hit&&pnear([o3.x,o3.y],[1,1]),'override none (free) → no snap at all');
3331
+ snapOnly=so;P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
3332
+ // 10) extension snap (dashed) + persistent running-snaps (⋯ menu → Snapping)
3333
+ {const sm=P.members,ss=P.segments,sd=P.dims,sdv=dimsVisible,sz=zoom,se=Object.assign({},snapEnabled);
3334
+ zoom=1;P.members=[{id:'a',wp:[[0,0],[100,0]]}];P.segments=[];P.dims=[];dimsVisible=false;buildSnap(null,{noGrid:true});
3335
+ snapEnabled.ext=false;const x0=snap(150,1);ok(!x0.hit,'extension OFF by default → no snap past the end');
3336
+ snapEnabled.ext=true;const x1=snap(150,1);ok(x1.hit&&x1.kind==='ext'&&pnear([x1.x,x1.y],[150,0])&&pnear(snapExtFrom,[100,0]),'extension ON → snaps onto the ray past the end, dashed from the near endpoint');
3337
+ const x2=snap(50,1);ok(x2.kind!=='ext','extension does NOT fire ON the segment (that is the nearest/on-line snap)');
3338
+ snapEnabled.end=false;const x3=snap(1,1);ok(x3.kind!=='end','disabling Endpoint in the running-snaps stops endpoint snapping');
3339
+ const ax=snap(2,40);ok(pnear([ax.x,ax.y],[2,40]),'disabling Endpoint also stops the axis-align pull to an endpoint column (Codex fix)');
3340
+ snapEnabled.end=true;const x4=snap(1,1);ok(x4.kind==='end','re-enabling Endpoint restores it');
3341
+ const ax2=snap(2,40);ok(ax2.x===0,'with Endpoint on, axis-align still pulls to the endpoint column');
3342
+ Object.assign(snapEnabled,se);P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
3343
+ const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (frame + snap + transform math)';
3146
3344
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
3147
3345
  })();}
3148
3346
  // --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.64.0",
3
+ "version": "0.66.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": {