@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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.64.0" : void 0,
53025
+ define: true ? "0.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.64.0" : void 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
- "display-name: Steel IFC",
59143
- "description: IFC file written from an approved steel.takeoff/v1 contract (a companion export; the contract is the source of truth).",
59144
+ `display-name: ${displayName}`,
59145
+ `description: ${description}`,
59144
59146
  "exposes-as-agent: false",
59145
59147
  "requires:",
59146
59148
  " - ifc@0.1.x",
@@ -64142,20 +64144,41 @@ async function startServer() {
64142
64144
  return { ok: true, report: { ...extracted, interactive: true }, skipped };
64143
64145
  });
64144
64146
  app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
64145
- const doc = readContract(req.params.appId);
64147
+ const doc = readContractForApp(req.params.appId);
64146
64148
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
64147
- const v = validateSteelTakeoff(doc);
64148
- if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
64149
- const { scene, skipped } = contractToScene(doc);
64149
+ const isVector = typeof doc === "object" && doc != null && doc.type === "drawing.vector/v1";
64150
+ let scene;
64151
+ let skipped;
64152
+ let extrusionsSkipped = 0;
64153
+ let writeOpts;
64154
+ let emptyError;
64155
+ if (isVector) {
64156
+ const cleaned = postProcess(doc);
64157
+ const vv = validateContract(cleaned);
64158
+ if (!vv.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
64159
+ const built = viewsToScene(cleaned);
64160
+ extrusionsSkipped = built.scene.elements.filter((e) => e.kind === "extrusion").length;
64161
+ scene = { ...built.scene, elements: built.scene.elements.filter((e) => e.kind === "box") };
64162
+ skipped = built.skipped;
64163
+ writeOpts = { displayName: "Drawing IFC", description: "IFC file written from a reconstructed drawing.vector/v1 model (member geometry only; a companion export)." };
64164
+ emptyError = extrusionsSkipped > 0 ? `no member geometry to export \u2014 this model is ${extrusionsSkipped} extruded ${extrusionsSkipped === 1 ? "body" : "bodies"}, which don't export to IFC yet` : "no 3D to export \u2014 reconstruct a plan + elevation first (open View 3D to see what's missing)";
64165
+ } else {
64166
+ const v = validateSteelTakeoff(doc);
64167
+ if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
64168
+ const built = contractToScene(doc);
64169
+ scene = built.scene;
64170
+ skipped = built.skipped;
64171
+ emptyError = "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)";
64172
+ }
64150
64173
  if (scene.elements.length === 0) {
64151
- return reply.status(422).send({ ok: false, error: "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)", skipped });
64174
+ return reply.status(422).send({ ok: false, error: emptyError, skipped });
64152
64175
  }
64153
64176
  const companionId = `${req.params.appId}-ifc`;
64154
64177
  const filename = `${req.params.appId}.ifc`;
64155
64178
  const outPath = (0, import_node_path28.join)(appPath(companionId), filename);
64156
64179
  let flo;
64157
64180
  try {
64158
- flo = writeIfcApp(appPath(companionId), companionId, scene, outPath);
64181
+ flo = writeIfcApp(appPath(companionId), companionId, scene, outPath, writeOpts);
64159
64182
  } catch (e) {
64160
64183
  app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-ifc: companion app write failed");
64161
64184
  return reply.status(500).send({ ok: false, error: `could not write the IFC companion app: ${e instanceof Error ? e.message : "write error"}` });
@@ -64178,7 +64201,7 @@ async function startServer() {
64178
64201
  if (!(0, import_node_fs30.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
64179
64202
  const content = (0, import_node_fs30.readFileSync)(outPath, "utf8");
64180
64203
  broadcast({ type: "apps-changed" });
64181
- return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped };
64204
+ return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
64182
64205
  });
64183
64206
  app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
64184
64207
  const doc = readContract(req.params.appId);
package/dist/web/aware.js CHANGED
@@ -1364,6 +1364,9 @@
1364
1364
  // that page — distinct from the steel `scene` chain below (AWARE exporter companions).
1365
1365
  if (window.CONTRACT_RENDERERS[contractType].viewerUrl) {
1366
1366
  addNodeAction(card, 'View 3D ▸', () => openLocalViewer(currentId, contractType));
1367
+ // A reconstructed model's MEMBER geometry exports to IFC through the same companion as
1368
+ // steel (extruded bodies are view-only for now — the server says so if there's nothing).
1369
+ addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId, true)); // isVector → "unplaced" skip wording
1367
1370
  }
1368
1371
  // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1369
1372
  // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
@@ -1862,7 +1865,7 @@
1862
1865
  // Export IFC ▸ — write the approved takeoff OUT to a universal .ifc and download it. The server
1863
1866
  // builds a model-free ifc companion, runs it, and returns the .ifc text (res.content); the browser
1864
1867
  // turns it into a download. api() throws on any failure (no contract, all-RFI, compile/write error).
1865
- async function exportContractIfc(appId) {
1868
+ async function exportContractIfc(appId, isVector) {
1866
1869
  showToast('Generating IFC…', 'info');
1867
1870
  try {
1868
1871
  const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-ifc', { method: 'POST' });
@@ -1873,7 +1876,13 @@
1873
1876
  document.body.appendChild(a); a.click(); a.remove();
1874
1877
  setTimeout(() => URL.revokeObjectURL(url), 4000);
1875
1878
  const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1876
- showToast(`IFC downloaded ${res.filename || appId + '.ifc'}${dropped ? ` · ${dropped} RFI member(s) skipped` : ''}`, 'ok');
1879
+ // Skip wording differs by contract: steel `skipped` = RFI members (no AISC size); a vector
1880
+ // reconstruction's `skipped` = unplaced entities, plus extruded bodies (extrusionsSkipped)
1881
+ // that don't export to IFC yet. Label each honestly.
1882
+ const notes = [];
1883
+ if (dropped) notes.push(isVector ? `${dropped} unplaced` : `${dropped} RFI member${dropped === 1 ? '' : 's'}`);
1884
+ if (res.extrusionsSkipped) notes.push(`${res.extrusionsSkipped} extruded ${res.extrusionsSkipped === 1 ? 'body' : 'bodies'} (view-only)`);
1885
+ showToast(`IFC downloaded — ${res.filename || appId + '.ifc'}${notes.length ? ' · ' + notes.join(', ') + ' skipped' : ''}`, 'ok');
1877
1886
  } catch (e) {
1878
1887
  showToast('IFC export failed: ' + (e && e.message ? e.message : String(e)), 'warn');
1879
1888
  }
@@ -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: e.ctrlKey || e.metaKey, ppf, planeZ,
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
- if (!dragging) pending.mode = e.altKey ? 'vertical' : 'plan'; // lock the mode at drag start (Alt = vertical/elevation)
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.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x + dx, pending.meshPos0.y + dy, pending.meshPos0.z);
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) { clickSelect(e.clientX, e.clientY, p.ctrl); return; } // click → cycle-pick: member, or a derived part stacked on it at a joint
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
- canvasEl.style.cursor = geoMode() ? 'crosshair' : (top ? (isPart ? 'pointer' : 'grab') : 'default'); // derived part → pointer (select-only); member → grab (draggable)
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
- // editing verbs (move/stretch/elevate) that a viewer's no-op api would silently ignore.
2243
- else txt = (api && typeof api.statusHint === 'function' && api.statusHint()) || 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
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:move;stroke-width:6;stroke-linecap:round}
34
+ line.member{cursor:pointer;stroke-width:6;stroke-linecap:round} /* default (drag-move OFF): a click selects → the pointer cursor tells the truth */
35
+ body.dragmove line.member{cursor:move} /* drag-move ON: the move cursor signals a drag will translate — a per-hover second signal beside the toolbar pill */
35
36
  line.member.sel{stroke-width:7} line.member.rfi{stroke-dasharray:10 6}
36
37
  circle.handle{fill:var(--bg);stroke:#f8fafc;stroke-width:3;cursor:grab}
37
38
  line.seg{stroke:#475569;stroke-width:2;opacity:0;cursor:crosshair} body.add line.seg{opacity:.5}
@@ -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
- if(e.ctrlKey||e.metaKey){selIds.has(id)?selIds.delete(id):selIds.add(id);render();return;}
1973
- if(!selIds.has(id)){selIds=new Set([id]);render();}
1974
- const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
1975
- buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
1976
- drag={type:'move',start:[p.x,p.y],items,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
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==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {