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