@floless/app 0.64.0 → 0.65.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 +38 -10
- package/dist/web/steel-editor.html +53 -8
- 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.65.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.65.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
|
}
|
|
@@ -2000,11 +2000,13 @@ function onDown(e) {
|
|
|
2000
2000
|
const geo = m ? memberGeometry(m, ppf, dtos) : null;
|
|
2001
2001
|
const mesh = meshById.get(id);
|
|
2002
2002
|
const planeZ = geo ? (geo.line[0][2] + geo.line[1][2]) / 2 : 0;
|
|
2003
|
+
const ctrl = e.ctrlKey || e.metaKey;
|
|
2003
2004
|
pending = {
|
|
2004
|
-
id, ctrl
|
|
2005
|
+
id, ctrl, ppf, planeZ,
|
|
2006
|
+
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
2007
|
grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
|
|
2006
2008
|
origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
|
|
2007
|
-
candidates: allCandidates(id),
|
|
2009
|
+
candidates: allCandidates(ctrl ? undefined : id), // member + grid snap targets; a copy (ctrl) snaps to the ORIGINAL too (align the duplicate), a move excludes itself
|
|
2008
2010
|
levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
|
|
2009
2011
|
mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
|
|
2010
2012
|
};
|
|
@@ -2067,7 +2069,9 @@ function onMove(e) {
|
|
|
2067
2069
|
if (pending.epDrag) { onMoveEndpoint(e); return; }
|
|
2068
2070
|
if (!pending.grab || !pending.origMm) return;
|
|
2069
2071
|
if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
|
|
2070
|
-
|
|
2072
|
+
const canDrag = pending.copy || (api && api.dragMoveEnabled && api.dragMoveEnabled());
|
|
2073
|
+
if (!canDrag) { if (!pending._hinted) { pending._hinted = true; if (api && api.dragMoveHint) api.dragMoveHint(); } return; } // drag-move OFF, plain grab → never translate; onUp selects the member (dragging stays false)
|
|
2074
|
+
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
2075
|
dragging = pending;
|
|
2072
2076
|
if (pending.mode === 'vertical') { onMoveVertical(e); return; }
|
|
2073
2077
|
const cur = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!cur) return;
|
|
@@ -2083,7 +2087,8 @@ function onMove(e) {
|
|
|
2083
2087
|
}
|
|
2084
2088
|
if (corr) { dx += corr[0]; dy += corr[1]; }
|
|
2085
2089
|
pending.delta = [dx, dy];
|
|
2086
|
-
if (pending.
|
|
2090
|
+
if (pending.copy) positionCopyGhost(dx, dy); // Ctrl+drag: move a translucent ghost, never the original
|
|
2091
|
+
else if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x + dx, pending.meshPos0.y + dy, pending.meshPos0.z);
|
|
2087
2092
|
if (at) { marker.position.set(at[0], at[1], at[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
|
|
2088
2093
|
// live dimension readout: plan distance moved (feet) + the snap type, near the cursor
|
|
2089
2094
|
readout._dist.textContent = (Math.hypot(dx, dy) / FT_MM).toFixed(2) + ' ft';
|
|
@@ -2091,6 +2096,17 @@ function onMove(e) {
|
|
|
2091
2096
|
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
2092
2097
|
}
|
|
2093
2098
|
|
|
2099
|
+
// Ctrl+drag copy: a translucent ghost of the grabbed set (copyIds) shifted by the plan delta. Clones
|
|
2100
|
+
// share the source geometry + one material (like the Move/Copy tool's ghost) — nothing to dispose.
|
|
2101
|
+
let copyGhost = null;
|
|
2102
|
+
function positionCopyGhost(dx, dy) {
|
|
2103
|
+
if (copyGhost) scene.remove(copyGhost);
|
|
2104
|
+
copyGhost = new THREE.Group();
|
|
2105
|
+
if (!tfGhostMat) tfGhostMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.3, depthWrite: false });
|
|
2106
|
+
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); }
|
|
2107
|
+
scene.add(copyGhost);
|
|
2108
|
+
}
|
|
2109
|
+
function clearCopyGhost() { if (copyGhost) { scene.remove(copyGhost); copyGhost = null; } }
|
|
2094
2110
|
function onBoxMove(e) {
|
|
2095
2111
|
if (Math.hypot(e.clientX - boxSel.x, e.clientY - boxSel.y) < DRAG_TOL_PX) { rubber.style.display = 'none'; return; }
|
|
2096
2112
|
boxSel.moved = true;
|
|
@@ -2114,7 +2130,7 @@ function membersInRect(x0, y0, x1, y1) {
|
|
|
2114
2130
|
}
|
|
2115
2131
|
|
|
2116
2132
|
function onUp(e) {
|
|
2117
|
-
if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; dragEp = null; // always clear overlays
|
|
2133
|
+
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
2134
|
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
2135
|
const bs = boxSel; boxSel = null;
|
|
2120
2136
|
if (bs) { // empty-space gesture: drag = box-select, click = clear selection
|
|
@@ -2149,7 +2165,14 @@ function onUp(e) {
|
|
|
2149
2165
|
}
|
|
2150
2166
|
return;
|
|
2151
2167
|
}
|
|
2152
|
-
if (!wasDragging) {
|
|
2168
|
+
if (!wasDragging) {
|
|
2169
|
+
if (p.id && !p.geo && !p.epDrag && !p.copy && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, false); return; } // a blocked drag (drag-move OFF) selects the GRABBED member, not whatever's under the release point
|
|
2170
|
+
clickSelect(e.clientX, e.clientY, p.ctrl); return; // click → cycle-pick: member, or a derived part stacked on it at a joint (Ctrl+click = additive select)
|
|
2171
|
+
}
|
|
2172
|
+
if (p.copy) { // Ctrl+drag → commit a copy at the plan delta (the editor clones + selects); the ghost was the preview
|
|
2173
|
+
if (p.delta && !(p.delta[0] === 0 && p.delta[1] === 0) && api && api.onCopyDrag3d) api.onCopyDrag3d(p.copyIds, p.delta[0], p.delta[1]);
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2153
2176
|
if (p.mode === 'vertical') { // elevation drag → write T.O.S (inches)
|
|
2154
2177
|
if (p.dz && Math.abs(p.dz) > 1e-6) {
|
|
2155
2178
|
if (api && api.onSelect) api.onSelect(p.id, false);
|
|
@@ -2218,7 +2241,8 @@ function onHoverMove(e) {
|
|
|
2218
2241
|
hoverEp = null;
|
|
2219
2242
|
let top = null; try { top = pickAllAt(lastHoverXY[0], lastHoverXY[1])[0] || null; } catch { top = null; }
|
|
2220
2243
|
const topMesh = top ? meshById.get(top) : null, isPart = !!(topMesh && topMesh.userData.derived);
|
|
2221
|
-
|
|
2244
|
+
const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled();
|
|
2245
|
+
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
2246
|
if (top !== hoverId) { hoverId = top; rebuildEndpoints(); updateStatusChip(); }
|
|
2223
2247
|
});
|
|
2224
2248
|
}
|
|
@@ -2238,9 +2262,12 @@ function updateStatusChip() {
|
|
|
2238
2262
|
else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
|
|
2239
2263
|
else if (selIds.size > 1) txt = `${selIds.size} members selected`;
|
|
2240
2264
|
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
|
-
|
|
2265
|
+
// A read-only host (the vector 3D viewer) supplies its own idle hint — the default speaks editing
|
|
2266
|
+
// verbs (move/stretch/elevate) a viewer's no-op api would silently ignore. Otherwise the default
|
|
2267
|
+
// tells the truth about the drag-move toggle (grab-to-move ON vs click-to-select + Ctrl+drag-copy).
|
|
2268
|
+
else if (api && typeof api.statusHint === 'function' && api.statusHint()) txt = api.statusHint();
|
|
2269
|
+
else { const canDrag = api && api.dragMoveEnabled && api.dragMoveEnabled();
|
|
2270
|
+
txt = (canDrag ? 'Drag to move · ' : 'Click to select · Ctrl+drag to copy · ') + 'ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in'; }
|
|
2244
2271
|
hoverChip.textContent = txt;
|
|
2245
2272
|
hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
|
|
2246
2273
|
hoverChip.style.top = (rect.bottom - 30) + 'px';
|
|
@@ -2254,6 +2281,7 @@ function hide() {
|
|
|
2254
2281
|
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
2282
|
cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
|
|
2256
2283
|
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
2284
|
+
clearCopyGhost(); // drop a stale Ctrl+drag copy ghost on the way out
|
|
2257
2285
|
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
2258
2286
|
}
|
|
2259
2287
|
function isReady() { return built; }
|
|
@@ -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}
|
|
@@ -130,6 +131,9 @@
|
|
|
130
131
|
#comboPop .opt:hover,#comboPop .opt.active{background:#334155}
|
|
131
132
|
/* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
|
|
132
133
|
/* Move/Copy split buttons + transform-suite chrome — all within the locked baseline tokens. */
|
|
134
|
+
.hdrsep{width:1px;height:20px;background:var(--line);flex:none;margin:0 2px} /* header divider: sets the Drag-to-move mode toggle apart from the transform actions */
|
|
135
|
+
#dragMoveB{white-space:nowrap;flex:none}
|
|
136
|
+
#dragMoveB.on{background:var(--brand);border-color:var(--brand);color:#fff}
|
|
133
137
|
.cmwrap{position:relative;display:inline-flex}
|
|
134
138
|
.cmwrap>button:first-child{border-radius:6px 0 0 6px}
|
|
135
139
|
.cmwrap .cmcaret{border-radius:0 6px 6px 0;border-left:0;padding:0 5px;min-width:22px;color:var(--mut)}
|
|
@@ -339,6 +343,8 @@
|
|
|
339
343
|
<button id=cpLevelB>Copy to level…</button>
|
|
340
344
|
</div>
|
|
341
345
|
</div>
|
|
346
|
+
<span class=hdrsep></span>
|
|
347
|
+
<button id=dragMoveB aria-pressed=false title="Drag to move — when ON, dragging a member with the left mouse moves it. When OFF (default), a click only selects, so members can't be nudged by accident (the ↔ Move tool and Ctrl+drag-copy still work). Applies to 2D and 3D.">Drag to move</button>
|
|
342
348
|
<button id=askAiBtn>Ask AI ▸</button>
|
|
343
349
|
<div id=moreWrap>
|
|
344
350
|
<button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
|
|
@@ -1693,6 +1699,13 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
|
|
|
1693
1699
|
// array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
|
|
1694
1700
|
// lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
|
|
1695
1701
|
let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
|
|
1702
|
+
// Drag-to-move: OFF by default so a click only selects (no accidental nudges). Persisted per-browser,
|
|
1703
|
+
// global (a UI pref, not per-app). Ctrl+drag-copy ignores this — it's an explicit gesture.
|
|
1704
|
+
let dragMoveOn=false; try{dragMoveOn=localStorage.getItem('steel:dragmove:v1')==='1';}catch(_){}
|
|
1705
|
+
let dragHintShown=false; try{dragHintShown=localStorage.getItem('steel:dragmovehint:v1')==='1';}catch(_){} // one-time "drag-move is off" teach on the first blocked drag
|
|
1706
|
+
function setDragMove(on){dragMoveOn=!!on;document.body.classList.toggle('dragmove',dragMoveOn);
|
|
1707
|
+
const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-pressed',String(dragMoveOn));}
|
|
1708
|
+
try{localStorage.setItem('steel:dragmove:v1',dragMoveOn?'1':'0');}catch(_){}}
|
|
1696
1709
|
// cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
|
|
1697
1710
|
function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
|
|
1698
1711
|
document.getElementById('mvB').classList.toggle('on',cmTool==='move');
|
|
@@ -1968,12 +1981,16 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
1968
1981
|
return;}}
|
|
1969
1982
|
if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
|
|
1970
1983
|
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
|
-
|
|
1984
|
+
if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
|
|
1985
|
+
// Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
|
|
1986
|
+
// its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
|
|
1987
|
+
if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
|
|
1988
|
+
const canDrag=ctrl||dragMoveOn; // plain grab only translates when the toggle is on; ctrl always drags (to copy)
|
|
1989
|
+
const srcIds=(ctrl&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // ctrl on an unselected member copies just it; else the whole selection
|
|
1990
|
+
const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
|
|
1991
|
+
buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
|
|
1992
|
+
drag={type:'grab',copy:ctrl,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
|
|
1993
|
+
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
1994
|
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
1995
|
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
1979
1996
|
drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
@@ -2022,7 +2039,17 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2022
2039
|
drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
|
|
2023
2040
|
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
2041
|
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==='
|
|
2042
|
+
if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
|
|
2043
|
+
if(!drag.moved){
|
|
2044
|
+
if(Math.hypot(rawdx,rawdy)<4/zoom)return; // below the drag threshold — still a potential click
|
|
2045
|
+
drag.moved=true;
|
|
2046
|
+
if(!drag.armed)return; // drag-move OFF, plain grab → never translate (the up-handler teaches the toggle)
|
|
2047
|
+
if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
|
|
2048
|
+
const ns=new Set(),ci=[];
|
|
2049
|
+
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()});}
|
|
2050
|
+
selIds=ns;drag.items=ci;buildSnap(ns);render();}} // select the clones + snap them to the sources/others; render shows them (drag continues via updateLine)
|
|
2051
|
+
if(!drag.armed)return;
|
|
2052
|
+
let dx=rawdx,dy=rawdy;
|
|
2026
2053
|
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
2054
|
else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint, midpoint △, OR line)
|
|
2028
2055
|
for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
|
|
@@ -2056,6 +2083,16 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
2056
2083
|
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
2084
|
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
2085
|
drag.rect.remove();drag=null;render();return;}
|
|
2086
|
+
if(drag.type==='grab'){
|
|
2087
|
+
if(!drag.moved){ // no drag past threshold → it was a click
|
|
2088
|
+
if(drag.copy){selIds.has(drag.clickId)?selIds.delete(drag.clickId):selIds.add(drag.clickId);} // ctrl+click toggles selection (deferred from down)
|
|
2089
|
+
drag=null;render();return;}
|
|
2090
|
+
if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
|
|
2091
|
+
drag=null;render();
|
|
2092
|
+
if(!dragHintShown){dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use ↔ Move (M)');}
|
|
2093
|
+
return;}
|
|
2094
|
+
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre); // armed move/copy committed (copy always changed geometry via its clones)
|
|
2095
|
+
const wasCopy=drag.copy;drag=null;render();if(wasCopy)toast('Copied '+selIds.size+' member'+(selIds.size===1?'':'s'));return;}
|
|
2059
2096
|
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
|
|
2060
2097
|
svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
|
|
2061
2098
|
svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
|
|
@@ -2092,6 +2129,8 @@ document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members)
|
|
|
2092
2129
|
toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
|
|
2093
2130
|
document.getElementById('undoB').onclick=doUndo;
|
|
2094
2131
|
document.getElementById('redoB').onclick=doRedo;
|
|
2132
|
+
document.getElementById('dragMoveB').onclick=()=>{setDragMove(!dragMoveOn);toast(dragMoveOn?'Drag to move: ON — dragging a member moves it':'Drag to move: OFF — a click only selects');};
|
|
2133
|
+
setDragMove(dragMoveOn); // reflect the persisted state on load (body class + pill)
|
|
2095
2134
|
addEventListener('keydown',e=>{
|
|
2096
2135
|
const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
|
|
2097
2136
|
if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
|
|
@@ -2189,6 +2228,12 @@ const view3dApi={
|
|
|
2189
2228
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
2190
2229
|
geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
|
|
2191
2230
|
onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
|
|
2231
|
+
dragMoveEnabled:()=>dragMoveOn, // the 3D view gates its plain member-drag on this
|
|
2232
|
+
dragMoveHint:()=>{if(dragHintShown)return;dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use ↔ Move (M)');},
|
|
2233
|
+
onCopyDrag3d:(ids,dxMm,dyMm)=>{const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1),r6=n=>Math.round(n*1e6)/1e6; // Ctrl+drag copy: mm delta → plan px (Y FLIPPED), clone + translate + select
|
|
2234
|
+
const dpx=r6(dxMm/k),dpy=r6(-dyMm/k);
|
|
2235
|
+
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;});
|
|
2236
|
+
toast('Copied '+(ids?ids.length:0)+' member'+((ids&&ids.length===1)?'':'s'));},
|
|
2192
2237
|
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
2238
|
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
2239
|
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
|