@floless/app 0.65.0 → 0.66.1

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.65.0" : void 0,
53025
+ define: true ? "0.66.1" : 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.65.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.66.1" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -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'); }
@@ -2006,7 +2082,7 @@ function onDown(e) {
2006
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
2007
2083
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
2008
2084
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
2009
- candidates: allCandidates(ctrl ? undefined : id), // member + grid snap targets; a copy (ctrl) snaps to the ORIGINAL too (align the duplicate), a move excludes itself
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)
2010
2086
  levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
2011
2087
  mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
2012
2088
  };
@@ -2022,7 +2098,7 @@ function onMoveVertical(e) {
2022
2098
  const newZ = r.candidate ? r.snapped[2] : hit[2];
2023
2099
  pending.dz = newZ - pending.planeZ;
2024
2100
  if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x, pending.meshPos0.y, pending.meshPos0.z + pending.dz);
2025
- 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;
2026
2102
  readout._dist.textContent = (newZ / FT_MM).toFixed(2) + ' ft'; // resulting T.O.S elevation
2027
2103
  readout._type.textContent = r.candidate ? ' · level' : ' ↕';
2028
2104
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
@@ -2037,7 +2113,7 @@ function startEndpointGrab(ep, e) {
2037
2113
  pending = {
2038
2114
  epDrag: true, id: ep.id, end: ep.end, ppf,
2039
2115
  planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
2040
- 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)
2041
2117
  newPt: [g.line[ep.end][0], g.line[ep.end][1]],
2042
2118
  };
2043
2119
  dragEp = { id: ep.id, end: ep.end };
@@ -2055,13 +2131,14 @@ function onMoveEndpoint(e) {
2055
2131
  const f = pending.fixed;
2056
2132
  epPreview.geometry.setFromPoints([new THREE.Vector3(f[0], f[1], f[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
2057
2133
  epPreview.visible = true;
2058
- 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;
2059
2135
  const len = Math.hypot(np[0] - f[0], np[1] - f[1]) / FT_MM;
2060
2136
  readout._dist.textContent = len.toFixed(2) + ' ft'; readout._type.textContent = r.candidate ? ' · ' + r.candidate.type : '';
2061
2137
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
2062
2138
  }
2063
2139
 
2064
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
2065
2142
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // 3D inactive — ignore window pointer events
2066
2143
  if (boxSel) { onBoxMove(e); return; }
2067
2144
  if (pending && pending.clipDrag) { onMoveClip(e); return; } // dragging a clip handle (plane offset / box face)
@@ -2069,8 +2146,8 @@ function onMove(e) {
2069
2146
  if (pending.epDrag) { onMoveEndpoint(e); return; }
2070
2147
  if (!pending.grab || !pending.origMm) return;
2071
2148
  if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
2072
- const canDrag = pending.copy || (api && api.dragMoveEnabled && api.dragMoveEnabled());
2073
- 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)
2149
+ const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled(); // the toggle gates BOTH gestures — plain drag (move) AND Ctrl+drag (copy)
2150
+ if (!canDrag) { if (!pending._hinted) { pending._hinted = true; if (api && api.dragMoveHint) api.dragMoveHint(); } return; } // drag-move/copy OFF → never translate; onUp selects the grabbed member (dragging stays false)
2074
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)
2075
2152
  dragging = pending;
2076
2153
  if (pending.mode === 'vertical') { onMoveVertical(e); return; }
@@ -2089,7 +2166,7 @@ function onMove(e) {
2089
2166
  pending.delta = [dx, dy];
2090
2167
  if (pending.copy) positionCopyGhost(dx, dy); // Ctrl+drag: move a translucent ghost, never the original
2091
2168
  else if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x + dx, pending.meshPos0.y + dy, pending.meshPos0.z);
2092
- if (at) { marker.position.set(at[0], at[1], at[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
2169
+ if (at) showMarker(at, atType); else marker.visible = false;
2093
2170
  // live dimension readout: plan distance moved (feet) + the snap type, near the cursor
2094
2171
  readout._dist.textContent = (Math.hypot(dx, dy) / FT_MM).toFixed(2) + ' ft';
2095
2172
  readout._type.textContent = atType ? ' · ' + atType : '';
@@ -2130,6 +2207,7 @@ function membersInRect(x0, y0, x1, y1) {
2130
2207
  }
2131
2208
 
2132
2209
  function onUp(e) {
2210
+ if (e.button === 2) rightDownXY = null; // end the click-vs-drag test (rightMoved keeps the verdict for the contextmenu that follows)
2133
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
2134
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)
2135
2213
  const bs = boxSel; boxSel = null;
@@ -2166,7 +2244,7 @@ function onUp(e) {
2166
2244
  return;
2167
2245
  }
2168
2246
  if (!wasDragging) {
2169
- 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
2247
+ if (p.id && !p.geo && !p.epDrag && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, p.ctrl); return; } // a blocked drag (drag-move/copy OFF) acts on the GRABBED member, not whatever's under the release point (Ctrl toggles it)
2170
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)
2171
2249
  }
2172
2250
  if (p.copy) { // Ctrl+drag → commit a copy at the plan delta (the editor clones + selects); the ghost was the preview
@@ -2204,7 +2282,7 @@ function onHoverMove(e) {
2204
2282
  if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
2205
2283
  canvasEl.style.cursor = 'crosshair';
2206
2284
  if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2207
- 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); }
2208
2286
  updateStatusChip(); return; }
2209
2287
  if (addActive()) { // Add-member armed: snap marker + rubber preview on the working plane
2210
2288
  canvasEl.style.cursor = 'crosshair';
@@ -2214,14 +2292,15 @@ function onHoverMove(e) {
2214
2292
  canvasEl.style.cursor = 'crosshair';
2215
2293
  if (tfDraft) tfPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
2216
2294
  else { const r = wpPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
2217
- 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; }
2218
2296
  updateStatusChip(); return; }
2219
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)
2220
2298
  if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
2221
- canvasEl.style.cursor = 'crosshair';
2222
2299
  const ev = { clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt };
2223
2300
  const r = dimPointAt(ev, dimDraft3d && dimDraft3d.a);
2224
- 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
2225
2304
  if (dimDraft3d && r) {
2226
2305
  const g = dim3dGeom(dimDraft3d.a, r.p, dimEffectiveAxis(dimLastAlt));
2227
2306
  dimPreviewLine.geometry.setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]); dimPreviewLine.visible = true;
@@ -2264,10 +2343,10 @@ function updateStatusChip() {
2264
2343
  else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
2265
2344
  // A read-only host (the vector 3D viewer) supplies its own idle hint — the default speaks editing
2266
2345
  // verbs (move/stretch/elevate) a viewer's no-op api would silently ignore. Otherwise the default
2267
- // tells the truth about the drag-move toggle (grab-to-move ON vs click-to-select + Ctrl+drag-copy).
2346
+ // tells the truth about the drag-move/copy toggle (ON = drag-move + Ctrl-drag-copy; OFF = click-select).
2268
2347
  else if (api && typeof api.statusHint === 'function' && api.statusHint()) txt = api.statusHint();
2269
2348
  else { const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled();
2270
- 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'; }
2349
+ txt = (canDrag ? 'Drag to move · Ctrl+drag to copy · ' : 'Click to select · ') + 'ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in'; }
2271
2350
  hoverChip.textContent = txt;
2272
2351
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
2273
2352
  hoverChip.style.top = (rect.bottom - 30) + 'px';
@@ -2337,6 +2416,8 @@ function debug() {
2337
2416
  endpoints: epGroup ? epGroup.children.length : 0,
2338
2417
  endpointColors: epGroup ? epGroup.children.map((c) => '#' + c.material.color.getHexString()) : [],
2339
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
+
2340
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 }; })(),
2341
2422
  camPos: camera ? [camera.position.x, camera.position.y, camera.position.z] : null,
2342
2423
  target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
@@ -2364,6 +2445,13 @@ function centerOf(id) {
2364
2445
  return { x: rect.left + (w.x * 0.5 + 0.5) * rect.width, y: rect.top + (-w.y * 0.5 + 0.5) * rect.height };
2365
2446
  }
2366
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
+ }
2367
2455
  window.Steel3DView = {
2368
2456
  init, show, hide, rebuild, setSelection, isReady, dispose,
2369
2457
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
@@ -2378,12 +2466,13 @@ window.Steel3DView = {
2378
2466
  cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
2379
2467
  drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
2380
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)
2381
2470
  refreshOverlayDims,
2382
2471
  overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests
2383
2472
  overlayLabelTexts: () => overlayLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent),
2384
2473
  dims3dCount: () => (api && api.getDims3d ? (api.getDims3d() || []).length : 0),
2385
2474
  selDims3dCount: () => (api && api.selDim3d ? (api.selDim3d() || []).length : 0), // test helper: how many 3D dims are selected
2386
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 }; },
2387
- debug, probe, screenOf, centerOf, worldOf, clipHandlesScreen,
2476
+ debug, probe, screenOf, centerOf, worldOf, snapCandidatesScreen, clipHandlesScreen,
2388
2477
  pointClipped: (p) => isPointClipped(p), // test helper: is a world point on the cut-away side of any active clip?
2389
2478
  };
@@ -85,6 +85,33 @@
85
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}
86
86
  #saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
87
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,.cmmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows and the Move/Copy → Drag-to-move/copy toggle */
102
+ #moreMenu .mck::after,.cmmenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
103
+ #moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
104
+ #moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.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}
88
115
  #rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
89
116
  #confStat{cursor:pointer;text-decoration:underline dotted} #confStat:hover b{filter:brightness(1.25)}
90
117
  .conf-cats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px;border-bottom:1px solid var(--line)}
@@ -131,13 +158,12 @@
131
158
  #comboPop .opt:hover,#comboPop .opt.active{background:#334155}
132
159
  /* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
133
160
  /* Move/Copy split buttons + transform-suite chrome — all within the locked baseline tokens. */
134
- .hdrsep{width:1px;height:20px;background:var(--line);flex:none;margin:0 2px} /* header divider: sets the Drag-to-move mode toggle apart from the transform actions */
135
- #dragMoveB{white-space:nowrap;flex:none}
136
- #dragMoveB.on{background:var(--brand);border-color:var(--brand);color:#fff}
137
161
  .cmwrap{position:relative;display:inline-flex}
138
- .cmwrap>button:first-child{border-radius:6px 0 0 6px}
139
- .cmwrap .cmcaret{border-radius:0 6px 6px 0;border-left:0;padding:0 5px;min-width:22px;color:var(--mut)}
140
- .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
+ /* Drag-to-move/copy menu item wears the shared .mck slider switch (rules above, shared with ⋯ Snapping). */
165
+ .cmmenu #dragMoveB{display:flex;align-items:center;justify-content:flex-start;gap:0}
166
+ .cmmenu #dragMoveB.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool) */
141
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}
142
168
  .cmmenu.open{display:block}
143
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}
@@ -162,7 +188,7 @@
162
188
  #moreWrap{position:relative;display:inline-flex}
163
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}
164
190
  #moreMenu.open{display:block}
165
- #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}
166
192
  #moreMenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
167
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}
168
194
  #moreMenu button:hover{background:#334155}
@@ -188,6 +214,9 @@
188
214
  #lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
189
215
  line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
190
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}
191
220
  line.dim{stroke:#22d3ee;stroke-width:2.5;vector-effect:non-scaling-stroke;pointer-events:stroke;cursor:pointer}
192
221
  line.dimwit{stroke:#22d3ee;stroke-width:1;opacity:.85;vector-effect:non-scaling-stroke;pointer-events:none}
193
222
  circle.dimend{fill:#22d3ee;pointer-events:none}
@@ -321,34 +350,42 @@
321
350
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
322
351
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
323
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>
324
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>
325
355
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
326
356
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
327
357
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
328
358
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
329
- <button id=mAdd title="Toggle add-member mode">Add member</button>
330
- <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>
331
361
  <div class=cmwrap>
332
- <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>
333
- <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>
334
365
  <button id=mvTwoB>Move — two points <span class=mkbd>M</span></button>
335
366
  <button id=mvLevelB>Move to level…</button>
336
- </div>
337
- </div>
338
- <div class=cmwrap>
339
- <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>
340
- <div class=cmmenu id=cpMenu role=menu>
367
+ <div class=mlabel>Copy</div>
341
368
  <button id=cpTwoB>Copy — two points <span class=mkbd>C</span></button>
342
369
  <button id=cpArrB>Copy array…</button>
343
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 and Ctrl+drag copies it. When OFF (default), a drag does neither — a click only selects, so members can't be nudged by accident (the Move and Copy tools still work). Applies to 2D and 3D."><span class=mck aria-hidden=true></span>Drag to move/copy</button>
344
373
  </div>
345
374
  </div>
346
- <span class=hdrsep></span>
347
- <button id=dragMoveB aria-pressed=false title="Drag to move — when ON, dragging a member with the left mouse moves it. When OFF (default), a click only selects, so members can't be nudged by accident (the ↔ Move tool and Ctrl+drag-copy still work). Applies to 2D and 3D.">Drag to move</button>
348
375
  <button id=askAiBtn>Ask AI ▸</button>
349
376
  <div id=moreWrap>
350
377
  <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
351
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>
352
389
  <div class=mlabel>Dimensions</div>
353
390
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
354
391
  <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
@@ -396,7 +433,7 @@
396
433
  <span class=tb-sep></span>
397
434
  <!-- Measure / annotate -->
398
435
  <button id=m3dRef data-tip="Show each member's reference line (centreline)">Ref line</button>
399
- <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>
400
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>
401
438
  <button id=m3dDimShow data-tip="Show or hide placed 3D dimensions" style="display:none">Hide dims</button>
402
439
  <span class=tb-sep></span>
@@ -446,6 +483,14 @@
446
483
  <span id=zPct>100%</span>
447
484
  <button id=zFit title="Zoom to fit (Home)">Fit</button>
448
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>
449
494
  </div>
450
495
  <aside id=panel></aside>
451
496
  </main>
@@ -1196,6 +1241,7 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
1196
1241
  selIds=ns;selDimIds.clear();mode='sel';setMode();pushUndo(pv);render();}
1197
1242
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
1198
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)
1199
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
1200
1246
  let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
1201
1247
  s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
@@ -1586,43 +1632,107 @@ function rectHit(p0,p1,r){
1586
1632
  const c=[[r[0],r[1]],[r[2],r[1]],[r[2],r[3]],[r[0],r[3]]];
1587
1633
  for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
1588
1634
  return false;}
1589
- // --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△); members lie on grids → snaps to grid intersections; Alt = off ---
1590
- 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;
1591
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];}
1592
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
1593
- snapPts=[];snapSegs=[];snapMids=[];
1594
- 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]));}
1595
- 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));}
1596
- 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)
1597
- 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);}
1598
- 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)
1599
- 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);}}
1600
1658
  // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
1601
1659
  function nearestOnLine(x,y,tol){let bp=null,bd=tol;
1602
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;}}
1603
1661
  return bp?{x:bp[0],y:bp[1],d:bd}:null;}
1604
- function snap(x,y){const tol=SNAP_PX/zoom;lastSnapKind='end';let bp=null,bd=tol;
1605
- for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
1606
- if(bp)return {x:bp[0],y:bp[1],hit:true,kind:'end'}; // 1) endpoint / grid intersection (highest priority)
1607
- 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;}}
1608
- if(mp){lastSnapKind='mid';return {x:mp[0],y:mp[1],hit:true,kind:'mid'};} // 2) midpoint of a member/segment/dimension line (△)
1609
- 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)
1610
- let sx=x,sy=y,bx=tol,by=tol; // 4) else axis-align to a nearby point
1611
- 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];}}
1612
- 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};}
1613
1684
  // one persistent marker element; kind 'mid' draws an upward triangle, everything else the original circle. Defaults to the last snap()'s kind.
1614
1685
  function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
1615
1686
  const ns='http://www.w3.org/2000/svg';let el;
1616
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
1617
- else{el=document.createElementNS(ns,'circle');el.setAttribute('cx',x);el.setAttribute('cy',y);el.setAttribute('r',6/zoom);}
1618
- el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);}
1619
- 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();};
1620
1729
  // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
1621
1730
  // buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
1622
1731
  // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
1623
1732
  const SVGNS='http://www.w3.org/2000/svg';
1624
1733
  function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
1625
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)
1626
1736
  dimDraft=null;dimChainPrev=null;dimPrevClear();}
1627
1737
  function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
1628
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};}
@@ -1665,9 +1775,9 @@ function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
1665
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.';
1666
1776
  const loc=!!(P&&P.frame);
1667
1777
  const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
1668
- 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.';}
1669
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)
1670
- 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
1671
1781
  // Split the SELECTED dim nearest the click into two at a point ON its baseline (same axis + offset).
1672
1782
  // Picks the closest selected dim within a screen tolerance whose interior the click projects into; the
1673
1783
  // split point is the perpendicular foot, so a free (oblique) dim never kinks and the total is preserved.
@@ -1699,19 +1809,17 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
1699
1809
  // array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
1700
1810
  // lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
1701
1811
  let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
1702
- // Drag-to-move: OFF by default so a click only selects (no accidental nudges). Persisted per-browser,
1703
- // global (a UI pref, not per-app). Ctrl+drag-copy ignores this it's an explicit gesture.
1812
+ // Drag-to-move/copy: OFF by default so a click only selects (no accidental nudges). Persisted
1813
+ // per-browser, global (a UI pref, not per-app). Gates BOTH drag gesturesleft-drag=move AND
1814
+ // Ctrl+drag=copy; only Ctrl+CLICK (a click, not a drag) still toggles selection when off.
1704
1815
  let dragMoveOn=false; try{dragMoveOn=localStorage.getItem('steel:dragmove:v1')==='1';}catch(_){}
1705
1816
  let dragHintShown=false; try{dragHintShown=localStorage.getItem('steel:dragmovehint:v1')==='1';}catch(_){} // one-time "drag-move is off" teach on the first blocked drag
1706
1817
  function setDragMove(on){dragMoveOn=!!on;document.body.classList.toggle('dragmove',dragMoveOn);
1707
- const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-pressed',String(dragMoveOn));}
1818
+ const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-checked',String(dragMoveOn));} // the .mck slider carries the on/off state
1708
1819
  try{localStorage.setItem('steel:dragmove:v1',dragMoveOn?'1':'0');}catch(_){}}
1709
1820
  // cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
1710
1821
  function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
1711
- document.getElementById('mvB').classList.toggle('on',cmTool==='move');
1712
- document.getElementById('mvCaret').classList.toggle('on',cmTool==='move');
1713
- document.getElementById('cpB').classList.toggle('on',cmTool==='copy');
1714
- document.getElementById('cpCaret').classList.toggle('on',cmTool==='copy');}
1822
+ document.getElementById('xfB').classList.toggle('on',!!cmTool);} // the Transform button fills when Move OR Copy is armed (the side-panel badge names which)
1715
1823
  function disarmCm(){cmTool=null;cmArrayMode=false;cmDraft=null;cmHudClose();cmPrevClear();setCmUi();
1716
1824
  if(window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d();} // drop the 3D-side pick/preview too
1717
1825
  function armCm(tool,array){ // re-arming the same config toggles the tool off — works in 2D and (on the working plane) in 3D
@@ -1883,6 +1991,7 @@ function toLevelCommit(tool,ti){const sel=selArr();const T=C.plans[ti];
1883
1991
  function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
1884
1992
  const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
1885
1993
  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
1994
+ snapOnlyClear2d(); // any set-axes transition ends the previous operation → the snap override reverts (arming must not inherit another tool's override)
1886
1995
  csDraft=null;csPrevClear();}
1887
1996
  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];}
1888
1997
  function csClick(e){const p=csSnapAt(e);
@@ -1939,7 +2048,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1939
2048
  const gel=gb||ghit;
1940
2049
  if(gel&&mode==='sel'&&!geoMode&&!dimSplitMode&&!picking&&P.grid){
1941
2050
  const axis=gel.dataset.gv!=null?'v':'h',gi=+(gel.dataset.gv!=null?gel.dataset.gv:gel.dataset.gh);
1942
- if(isFinite(gi)){buildSnap(null,{noGrid:true});const q=toSvg(e);const geo=gridGeo();const line=geo&&geo[axis][gi];
2051
+ if(isFinite(gi)){buildSnap(null,{noGrid:true,noDims:true});const q=toSvg(e);const geo=gridGeo();const line=geo&&geo[axis][gi];
1943
2052
  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
1944
2053
  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,
1945
2054
  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)
@@ -1985,7 +2094,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1985
2094
  // Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
1986
2095
  // its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
1987
2096
  if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
1988
- const canDrag=ctrl||dragMoveOn; // plain grab only translates when the toggle is on; ctrl always drags (to copy)
2097
+ const canDrag=dragMoveOn; // the toggle gates BOTH gestures: left-drag=move and Ctrl+drag=copy (Ctrl+CLICK still toggles selection below — a click, not a drag)
1989
2098
  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
1990
2099
  const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
1991
2100
  buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
@@ -2051,12 +2160,14 @@ svg.addEventListener('pointermove',e=>{
2051
2160
  if(!drag.armed)return;
2052
2161
  let dx=rawdx,dy=rawdy;
2053
2162
  if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
2054
- 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)
2163
+ 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
2055
2164
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
2056
- 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'};}}
2057
- 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'};}}
2058
- 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
2059
- if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1],best.kind);}else snapClear();}
2165
+ 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'};}}
2166
+ 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'};}}
2167
+ 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'};}}
2168
+ 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)
2169
+ 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)
2170
+ 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();}
2060
2171
  else snapClear();
2061
2172
  for(const it of drag.items){const m=byId(it.id);if(!m)continue;
2062
2173
  m.wp[0]=[it.o0[0]+dx,it.o0[1]+dy];m.wp[1]=[it.o1[0]+dx,it.o1[1]+dy];updateLine(m);}
@@ -2068,6 +2179,7 @@ svg.addEventListener('pointermove',e=>{
2068
2179
  m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
2069
2180
  svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2070
2181
  if(drag.type==='gridline'){const wasBubble=drag.bubble,moved=drag.moved,pre=drag.pre;drag=null;gridReadoutHide();
2182
+ 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())
2071
2183
  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…
2072
2184
  svg.querySelectorAll('line.member.sel').forEach(n=>{n.classList.remove('sel');n.style.filter='';}); // …so drop the stale selection VISUALS surgically instead
2073
2185
  svg.querySelectorAll('circle.handle,circle.dimhandle,circle.numbg,text.numtx').forEach(n=>n.remove());
@@ -2089,11 +2201,13 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2089
2201
  drag=null;render();return;}
2090
2202
  if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
2091
2203
  drag=null;render();
2092
- 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
+ if(!dragHintShown){dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag to move/copy is off — turn it on in the Move / Copy menu, or use the Move (M) / Copy (C) tools');}
2093
2205
  return;}
2094
2206
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre); // armed move/copy committed (copy always changed geometry via its clones)
2095
- const wasCopy=drag.copy;drag=null;render();if(wasCopy)toast('Copied '+selIds.size+' member'+(selIds.size===1?'':'s'));return;}
2096
- if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
2207
+ 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
2208
+ if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);
2209
+ if(!anyToolActive())snapOnlyClear2d(); // a select-mode drag WAS the operation → the override was single-shot
2210
+ drag=null;render();});
2097
2211
  svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
2098
2212
  svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
2099
2213
  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
@@ -2104,7 +2218,7 @@ svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename tha
2104
2218
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
2105
2219
  document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();
2106
2220
  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
2107
- 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
2221
+ 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
2108
2222
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
2109
2223
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
2110
2224
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
@@ -2129,12 +2243,12 @@ document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members)
2129
2243
  toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
2130
2244
  document.getElementById('undoB').onclick=doUndo;
2131
2245
  document.getElementById('redoB').onclick=doRedo;
2132
- 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
+ document.getElementById('dragMoveB').onclick=()=>{setDragMove(!dragMoveOn);toast(dragMoveOn?'Drag to move/copy: ON — drag moves, Ctrl+drag copies':'Drag to move/copy: OFF — a click only selects');};
2133
2247
  setDragMove(dragMoveOn); // reflect the persisted state on load (body class + pill)
2134
2248
  addEventListener('keydown',e=>{
2135
2249
  const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
2136
2250
  if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
2137
- if(e.key==='Escape'&&(mvMenuC.isOpen()||cpMenuC.isOpen())){mvMenuC.close();cpMenuC.close();return;}
2251
+ if(e.key==='Escape'&&xfMenuC.isOpen()){xfMenuC.close();return;}
2138
2252
  if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
2139
2253
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
2140
2254
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
@@ -2143,6 +2257,8 @@ addEventListener('keydown',e=>{
2143
2257
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
2144
2258
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
2145
2259
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
2260
+ if(e.key==='Escape'&&snapMenuOpen()){closeSnapMenu();return;}
2261
+ 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)
2146
2262
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
2147
2263
  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)
2148
2264
  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)
@@ -2197,17 +2313,29 @@ function moreOpen(){return moreMenu.classList.contains('open');}
2197
2313
  function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
2198
2314
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
2199
2315
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
2200
- moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
2316
+ 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)
2317
+ // "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
2318
+ {const snapHdr=document.getElementById('snapHdr'),snapSect=document.getElementById('snapSect');
2319
+ snapHdr.onclick=e=>{e.stopPropagation();const open=snapSect.classList.toggle('open');snapHdr.setAttribute('aria-expanded',String(open));};}
2320
+ // --- Running-snaps: one source of truth (snapEnabled) rendered onto TWO surfaces — the ⋯ menu switches
2321
+ // (aria-checked) + the quick-access snap bar (aria-pressed). toggleSnap() is the only writer; both surfaces
2322
+ // call it, so they can't drift. The transient right-click override is separate and never writes here. ---
2323
+ const snapBar=document.getElementById('snapBar');
2324
+ function reflectSnapPrefs(){
2325
+ 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));}
2326
+ 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));}}
2327
+ function toggleSnap(k){snapEnabled[k]=!(snapEnabled[k]!==false);saveSnapPrefs();reflectSnapPrefs();snapClear();} // clear any stale marker so the change shows on the next move
2328
+ 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)
2329
+ for(const b of snapBar.querySelectorAll('button'))b.onclick=()=>toggleSnap(b.dataset.snap); // quick-bar glyph
2330
+ reflectSnapPrefs();
2201
2331
  // --- Move/Copy split-button dropdowns: same open/close discipline as the ⋯ menu ---
2202
2332
  function wireCmMenu(caretId,menuId){const b=document.getElementById(caretId),m=document.getElementById(menuId);
2203
2333
  const close=()=>{m.classList.remove('open');b.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',out,true);};
2204
2334
  const out=e=>{if(!m.contains(e.target)&&e.target!==b)close();};
2205
2335
  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);}};
2206
- m.addEventListener('click',e=>{if(e.target.closest('button'))close();});
2336
+ 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
2207
2337
  return {close,isOpen:()=>m.classList.contains('open')};}
2208
- const mvMenuC=wireCmMenu('mvCaret','mvMenu'),cpMenuC=wireCmMenu('cpCaret','cpMenu');
2209
- document.getElementById('mvB').onclick=()=>armCm('move',false);
2210
- document.getElementById('cpB').onclick=()=>armCm('copy',false);
2338
+ const xfMenuC=wireCmMenu('xfB','xfMenu'); // one Transform dropdown holds Move, Copy, their options + the Drag-to-move toggle
2211
2339
  document.getElementById('mvTwoB').onclick=()=>armCm('move',false);
2212
2340
  document.getElementById('cpTwoB').onclick=()=>armCm('copy',false);
2213
2341
  document.getElementById('cpArrB').onclick=()=>armCm('copy',true);
@@ -2224,12 +2352,13 @@ const view3dApi={
2224
2352
  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)
2225
2353
  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
2226
2354
  getMembers:()=>P.members, // raw members (wp) for snap geometry
2355
+ snapEnabled:()=>snapEnabled, // the persistent running-snaps (⋯ menu → Snapping) — 3D honours the same on/off set
2227
2356
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
2228
2357
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
2229
2358
  geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
2230
2359
  onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
2231
2360
  dragMoveEnabled:()=>dragMoveOn, // the 3D view gates its plain member-drag on this
2232
- 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
+ dragMoveHint:()=>{if(dragHintShown)return;dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag to move/copy is off — turn it on in the Move / Copy menu, or use the Move (M) / Copy (C) tools');},
2233
2362
  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
2234
2363
  const dpx=r6(dxMm/k),dpy=r6(-dyMm/k);
2235
2364
  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;});
@@ -2245,6 +2374,7 @@ const view3dApi={
2245
2374
  getDimOverlays:()=>C.dim_overlays||{}, // the legend DIMENSIONS toggles {bolt_pitch,edge_clearance,cope_size} — the 3D view derives + filters overlays from this
2246
2375
  // 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.
2247
2376
  onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
2377
+ onSnapOnlyChanged:()=>updSnapStat(), // keep the header snap-override chip honest for every 3D-side clear (tool disarm etc.)
2248
2378
  onDeleteDim3d:(id)=>{C.dims3d=(C.dims3d||[]).filter(x=>x.id!==id);sel3dDimIds.delete(id);scheduleSave();refreshDims3d();},
2249
2379
  onSelectDim3d:(id,mods)=>{ // Ctrl/Shift multi-select (same as parts); selecting a dim is exclusive with member/2D-dim/clip selection
2250
2380
  if(!id){sel3dDimIds.clear();render();return;}
@@ -2584,6 +2714,7 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2584
2714
  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
2585
2715
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
2586
2716
  view3d=on;
2717
+ snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);closeSnapMenu();updSnapStat(); // switching views ends the operation → any snap override reverts
2587
2718
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
2588
2719
  t2.classList.toggle('on',!on);t2.setAttribute('aria-pressed',String(!on));
2589
2720
  t3.classList.toggle('on',on);t3.setAttribute('aria-pressed',String(on));
@@ -2594,6 +2725,7 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
2594
2725
  document.getElementById('m3dBar').style.display=on?'flex':'none';
2595
2726
  document.getElementById('m3dCube').style.display=on?'block':'none';
2596
2727
  document.getElementById('m3dAxes').style.display=on?'block':'none';
2728
+ document.getElementById('snapBar').classList.toggle('s3d',on); // in 3D the snap bar shifts clear of the world-axis triad (bottom-right); see #snapBar.s3d
2597
2729
  if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
2598
2730
  }
2599
2731
  async function setView(on){
@@ -3099,6 +3231,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
3099
3231
  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)
3100
3232
  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)
3101
3233
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
3234
+ snapOnlyClear2d();closeSnapMenu(); // a snap override never outlives its plan (different geometry)
3102
3235
  disarmCm(); // Move/Copy resets per plan too (its counts persist — they're session prefs, not plan data)
3103
3236
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
3104
3237
  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)
@@ -3187,7 +3320,28 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
3187
3320
  r=r&&C.joints.length===1&&C.joints[0].main==='a1'; // …and the moved member kept its connection
3188
3321
  }finally{C.plans=sp;C.active=sA;P=sP;undo=su;redo=sr;selIds=ss;C.joints=sj;render();}
3189
3322
  return r;})(),'cross-plan undo+redo restores both plans + moved joints');
3190
- const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
3323
+ // 9) intersection snap (×) + the right-click snap override
3324
+ {const sm=P.members,ss=P.segments,sd=P.dims,sdv=dimsVisible,sz=zoom,so=snapOnly;
3325
+ zoom=1;P.members=[{id:'a',wp:[[0,0],[100,0]]},{id:'b',wp:[[50,-50],[50,50]]}];P.segments=[];P.dims=[];dimsVisible=false;
3326
+ buildSnap(null,{noGrid:true});
3327
+ 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');
3328
+ const e2=snap(1,1);ok(e2.hit&&e2.kind==='end','snap → endpoint still outranks intersection');
3329
+ snapOnly='mid';const o1=snap(1,1);ok(!o1.hit&&pnear([o1.x,o1.y],[1,1]),'override mid → endpoint ignored, raw point back');
3330
+ const o2=snap(50,2);ok(o2.hit&&o2.kind==='mid'&&pnear([o2.x,o2.y],[50,0]),'override mid → midpoint found');
3331
+ snapOnly='none';const o3=snap(1,1);ok(!o3.hit&&pnear([o3.x,o3.y],[1,1]),'override none (free) → no snap at all');
3332
+ snapOnly=so;P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
3333
+ // 10) extension snap (dashed) + persistent running-snaps (⋯ menu → Snapping)
3334
+ {const sm=P.members,ss=P.segments,sd=P.dims,sdv=dimsVisible,sz=zoom,se=Object.assign({},snapEnabled);
3335
+ zoom=1;P.members=[{id:'a',wp:[[0,0],[100,0]]}];P.segments=[];P.dims=[];dimsVisible=false;buildSnap(null,{noGrid:true});
3336
+ snapEnabled.ext=false;const x0=snap(150,1);ok(!x0.hit,'extension OFF by default → no snap past the end');
3337
+ 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');
3338
+ const x2=snap(50,1);ok(x2.kind!=='ext','extension does NOT fire ON the segment (that is the nearest/on-line snap)');
3339
+ snapEnabled.end=false;const x3=snap(1,1);ok(x3.kind!=='end','disabling Endpoint in the running-snaps stops endpoint snapping');
3340
+ 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)');
3341
+ snapEnabled.end=true;const x4=snap(1,1);ok(x4.kind==='end','re-enabling Endpoint restores it');
3342
+ const ax2=snap(2,40);ok(ax2.x===0,'with Endpoint on, axis-align still pulls to the endpoint column');
3343
+ Object.assign(snapEnabled,se);P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
3344
+ const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (frame + snap + transform math)';
3191
3345
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
3192
3346
  })();}
3193
3347
  // --- 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.65.0",
3
+ "version": "0.66.1",
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": {