@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.
- package/dist/floless-server.cjs +2 -2
- package/dist/web/steel-3d-view.js +118 -29
- package/dist/web/steel-editor.html +223 -69
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "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.
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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 =
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
1952
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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 =
|
|
2073
|
-
if (!canDrag) { if (!pending._hinted) { pending._hinted = true; if (api && api.dragMoveHint) api.dragMoveHint(); } return; } // drag-move OFF
|
|
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)
|
|
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 &&
|
|
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))
|
|
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)
|
|
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
|
-
|
|
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 (
|
|
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 ·
|
|
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
|
-
|
|
139
|
-
.
|
|
140
|
-
.
|
|
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=
|
|
333
|
-
<div class=cmmenu id=
|
|
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
|
-
|
|
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=>({'<':'<','>':'>','&':'&','"':'"'}[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
|
|
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(
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
|
|
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
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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,'
|
|
1618
|
-
el.
|
|
1619
|
-
|
|
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
|
|
1703
|
-
// global (a UI pref, not per-app).
|
|
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 gestures — left-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-
|
|
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('
|
|
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=
|
|
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
|
|
2058
|
-
const
|
|
2059
|
-
|
|
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
|
|
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);
|
|
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
|
|
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 —
|
|
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'&&
|
|
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=>{
|
|
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
|
|
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
|
|
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
|
-
|
|
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>) ---
|