@floless/app 0.63.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.63.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.63.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",
@@ -64076,7 +64078,7 @@ async function startServer() {
64076
64078
  { bodyLimit: 25 * 1024 * 1024 },
64077
64079
  // contracts embed rasters (matches PUT /api/contract) — the editor POSTs the live contract on every 3D rebuild
64078
64080
  async (req, reply) => {
64079
- const doc = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
64081
+ const doc = req.body && "contract" in req.body ? req.body.contract : readContractForApp(req.params.appId);
64080
64082
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
64081
64083
  if (doc && typeof doc === "object" && doc.type === "drawing.vector/v1") {
64082
64084
  const cleaned = postProcess(doc);
@@ -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);
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: floless-app-vectorize
3
- description: Vectorize a drawing into an editable drawing.vector/v1 contract in floless.app — the "Vectorize" reader. Triggers on a pending floless `rebake` request for the `vectorize` app (queued from its "Re-read & re-bake ▸" button), or asks like "vectorize this PDF", "turn my sketch into clean CAD linework", "read this detail into vectors", "trace this photo". Teaches the host AI to read a drawing at COMPOSE time — a vector PDF via ONE deterministic PyMuPDF pass (no LLM), a scan/photo/hand sketch via AWARE's fenced vision.extract (schema-bound, cached) — emit the drawing.vector/v1 contract, PUT it to the contract store, and hand the 2D vector editor to the user.
3
+ description: Vectorize a drawing into an editable drawing.vector/v1 contract in floless.app — the "Vectorize" reader. Triggers on a pending floless `rebake` request for the `vectorize` app (queued from its "Re-read & re-bake ▸" button), or asks like "vectorize this PDF", "turn my sketch into clean CAD linework", "read this detail into vectors", "trace this photo", "make this drawing 3D". Teaches the host AI to read a drawing at COMPOSE time — a vector PDF via ONE deterministic PyMuPDF pass (no LLM), a scan/photo/hand sketch via AWARE's fenced vision.extract (schema-bound, cached) — emit the drawing.vector/v1 contract, PUT it to the contract store, and hand the 2D vector editor to the user; and, for a multi-view set (plan + elevation), to classify views, read datums, and declare entities so the server reconstructs a viewable 3D model.
4
4
  metadata:
5
- version: 0.3.0
5
+ version: 0.4.0
6
6
  ---
7
7
 
8
8
  # Vectorize — read a drawing into a drawing.vector/v1 contract
@@ -26,8 +26,14 @@ The drawing is read **at compose time**, by one of two paths:
26
26
  There is **no model in the run path** either way. The app ships with a tiny hard-coded
27
27
  example; this skill is how the user's OWN drawing replaces it.
28
28
 
29
- **Later slices (mention as "next", do not attempt here):** multi-view 3D reconstruction,
30
- DXF export.
29
+ A **multi-view set** (a plan plus an elevation/section of the same object) can additionally be
30
+ **reconstructed in 3D**: the skill's multi-view pass (step 3c) adds view classification, datums
31
+ and entity declarations to the contract, and the server's deterministic reconstruction renders
32
+ it in the **View 3D** viewer — members and extruded bodies, with everything unplaceable listed
33
+ honestly with a reason.
34
+
35
+ **Later slices (mention as "next", do not attempt here):** DXF export, placing details into a
36
+ live model.
31
37
 
32
38
  > **Pasted a request?** If the user pastes a message beginning with a `[floless-request
33
39
  > type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
@@ -119,6 +125,50 @@ For each raster image (or rasterized PDF page exported to an image):
119
125
  the vision output matches what the drawing actually says. A wildly wrong trace usually means
120
126
  the image is too low-contrast — say so rather than shipping garbage.
121
127
 
128
+ ### 3c. Multi-view pass — make a plan + elevation set 3D (optional)
129
+
130
+ Run this ONLY when the set contains **multiple views of one object** and the user wants 3D.
131
+ Prerequisite discipline: `reading-structural-drawings` — **massing + coordinate frame FIRST**,
132
+ entities last, cross-validate every view. All fields below are additive on the contract the
133
+ 2D pass (3a/3b) already produced; the server's reconstruction (`views-to-scene`) is
134
+ deterministic and **refuses to guess** — anything you leave unresolved lands in the 3D view's
135
+ "Not shown in 3D" list with a reason, never at a made-up position.
136
+
137
+ 1. **Classify every sheet** — set `sheets[].view`:
138
+ `{ kind: 'plan'|'elevation'|'section'|'detail', dir, datum }`. `dir` is the observer look
139
+ direction in world axes (a plan looks `-z`; a south elevation looks `+y`; a west-looking
140
+ section `+x`). **Cross-validate the scale of each axis pair with one known dimension**
141
+ (a labeled length that appears in both views) before trusting it — a wrong `dir` or scale
142
+ silently mirrors or misplaces the model. **Show the user the classification and get an OK**
143
+ ("s1 = plan at 1:50, s2 = south elevation — reconstruct from these?") before writing 3D
144
+ fields — this is the same confirm gate the steel reader uses before its heavy pass.
145
+ 2. **One world frame.** Give every sheet a `transform` (`scale` = world units per display unit,
146
+ `origin` = the display point that is world [0,0], `rotation` if the frame is turned) chosen
147
+ so the views AGREE: pick one shared reference (a grid intersection, a corner that appears in
148
+ both views) as the common origin. World units are the contract's `units` (mm canonical).
149
+ 3. **Datums** — read the height ladder off the elevation (level lines + labels) into the
150
+ top-level `datums: [{ id:'lv1', kind:'level', axis:'z', value:<world units>, label, sheet }]`.
151
+ Grid lines go in the same array with `axis:'x'|'y'`. Only `z` datums can drive heights.
152
+ 4. **Entities** — group the linework into `sheets[].groups`:
153
+ - a **member** (beam/bar/stud seen along its axis): `axis: [[x0,y0],[x1,y1]]` (this sheet's
154
+ display coords) + `section: { w, d }` in WORLD units — from a callout or a dimension, never
155
+ eyeballed;
156
+ - an **extruded body** (plate/wall/slab footprint): `footprint: [[x,y]…]` +
157
+ `extrude: { from, to }` where each end is a **z-datum id** or a world number. A present
158
+ extrude that doesn't resolve is an ERROR (surfaced, not guessed) — omit `extrude` entirely
159
+ if the height must come from the elevation instead;
160
+ - annotation groups (labels, dims) get neither — they simply don't build.
161
+ 5. **Links** — where a section mark or the drawing itself says two groups are the same object in
162
+ two views, add `links: [{ kind:'same-entity', a:{sheet,id}, b:{sheet,id} }]` (ids are only
163
+ sheet-unique — always the composite). A linked elevation entity gives its plan twin the
164
+ Z extent directly; unlinked entities are matched by their shared horizontal span, and
165
+ **anything ambiguous (two elevation entities over the same span) is skipped until you link it**.
166
+ 6. **PUT** the contract (step 4) and **open View 3D ▸** on the read node. Work the
167
+ "Not shown in 3D" panel like a worklist: each row says exactly what is missing (a datum, a
168
+ link, an extrude); fix the contract, re-PUT, reopen. Reconstructed members export through the
169
+ existing 3D/IFC path later; extruded bodies render in FloLess's own viewer only for now —
170
+ say so if the user asks about IFC.
171
+
122
172
  ### 4. Write to the contract store
123
173
 
124
174
  ```
@@ -147,6 +197,9 @@ Tell the user:
147
197
  - **Approve & bake lock**: when satisfied, the user clicks Approve in the editor header — it
148
198
  bakes the (edited) contract into the `read` node's `config.takeoff` and recompiles, arming
149
199
  the Run gate. Never auto-approve on their behalf.
200
+ - **View 3D ▸** (after a multi-view pass, step 3c): the node action opens the read-only
201
+ reconstruction viewer — orbit/pan/zoom, solid/wire/x-ray, per-group toggles, and the
202
+ "Not shown in 3D" panel listing every entity that couldn't be placed, with the reason.
150
203
 
151
204
  ### 6. Clear the request
152
205
 
package/dist/web/aware.js CHANGED
@@ -1360,6 +1360,14 @@
1360
1360
  // openContractEditor() returns false and the click would silently do nothing.
1361
1361
  if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
1362
1362
  addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
1363
+ // A renderer with a LOCAL 3D viewer (the drawing.vector reconstruction) gets View 3D via
1364
+ // that page — distinct from the steel `scene` chain below (AWARE exporter companions).
1365
+ if (window.CONTRACT_RENDERERS[contractType].viewerUrl) {
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
1370
+ }
1363
1371
  // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1364
1372
  // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
1365
1373
  if (window.CONTRACT_RENDERERS[contractType].scene) {
@@ -1770,6 +1778,21 @@
1770
1778
  $contractEditor.hidden = false;
1771
1779
  return true;
1772
1780
  }
1781
+ // A renderer-declared LOCAL viewer page (e.g. the drawing.vector 3D reconstruction) — reuses the
1782
+ // contract-editor overlay/iframe. Read-only: no Approve (the editor owns that gate).
1783
+ function openLocalViewer(appId, type) {
1784
+ const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1785
+ if (!r || !r.viewerUrl) return false;
1786
+ const ap = document.getElementById('contract-editor-approve'); if (ap) ap.hidden = true;
1787
+ $contractEditorTitle.replaceChildren(
1788
+ Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1789
+ Object.assign(document.createElement('span'), { textContent: ' · 3D view', style: 'color:var(--text-muted)' }),
1790
+ );
1791
+ $contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
1792
+ $contractEditorFrame.src = r.viewerUrl(appId);
1793
+ $contractEditor.hidden = false;
1794
+ return true;
1795
+ }
1773
1796
  // The steel filter pre-stage view — reuses the contract-editor overlay/iframe to load the served
1774
1797
  // steel-filter.html (layer/thickness/colour facets + eyedropper). Same seam as the editor: the
1775
1798
  // iframe talks to /api/contract directly and Save writes contract.filter.
@@ -1842,7 +1865,7 @@
1842
1865
  // Export IFC ▸ — write the approved takeoff OUT to a universal .ifc and download it. The server
1843
1866
  // builds a model-free ifc companion, runs it, and returns the .ifc text (res.content); the browser
1844
1867
  // turns it into a download. api() throws on any failure (no contract, all-RFI, compile/write error).
1845
- async function exportContractIfc(appId) {
1868
+ async function exportContractIfc(appId, isVector) {
1846
1869
  showToast('Generating IFC…', 'info');
1847
1870
  try {
1848
1871
  const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-ifc', { method: 'POST' });
@@ -1853,7 +1876,13 @@
1853
1876
  document.body.appendChild(a); a.click(); a.remove();
1854
1877
  setTimeout(() => URL.revokeObjectURL(url), 4000);
1855
1878
  const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1856
- 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');
1857
1886
  } catch (e) {
1858
1887
  showToast('IFC export failed: ' + (e && e.message ? e.message : String(e)), 'warn');
1859
1888
  }
@@ -19,6 +19,9 @@ window.CONTRACT_RENDERERS = {
19
19
  },
20
20
  'drawing.vector/v1': {
21
21
  editorUrl: (appId) => '/vector-editor.html?app=' + encodeURIComponent(appId),
22
+ // Local read-only 3D reconstruction viewer (views-to-scene) — NOT the AWARE exporter chain
23
+ // (`scene`), which only steel supports; extrusion bodies render floless-side only for now.
24
+ viewerUrl: (appId) => '/vector-3d.html?app=' + encodeURIComponent(appId),
22
25
  },
23
26
  };
24
27
 
@@ -280,6 +280,43 @@ function placePlate(mesh, el) {
280
280
  mesh.position.set(...(el.center || [0, 0, 0]));
281
281
  }
282
282
 
283
+ // Signed area of a closed 2D polygon (shoelace) — used to reject a zero-area/collinear footprint.
284
+ function shoelaceArea(pts) {
285
+ let a = 0;
286
+ for (let i = 0, n = pts.length; i < n; i++) {
287
+ const p = pts[i], q = pts[(i + 1) % n];
288
+ a += p[0] * q[1] - q[0] * p[1];
289
+ }
290
+ return a / 2;
291
+ }
292
+
293
+ // A vertical extrusion of an arbitrary world-XY footprint between two Z values — the multi-view
294
+ // reconstruction's generic body (a plate, wall or slab read from a plan + elevation; see
295
+ // server/views-to-scene.ts). Same ExtrudeGeometry recipe as placePlate, but the outline is the
296
+ // element's own polygon in WORLD coordinates and the sweep is always +Z, so no basis rotation.
297
+ function placeExtrusion(mesh, el) {
298
+ const fp = el.footprint || [];
299
+ // Reject a degenerate outline (fewer than 3 vertices, or a zero-area collinear loop by the
300
+ // shoelace test) — ExtrudeGeometry on it produces a NaN/empty body, not an honest shape.
301
+ if (fp.length < 3 || Math.abs(shoelaceArea(fp)) < 1e-6) { mesh.geometry = new THREE.BufferGeometry(); return; }
302
+ const shape = new THREE.Shape();
303
+ shape.moveTo(fp[0][0], fp[0][1]);
304
+ for (let i = 1; i < fp.length; i++) shape.lineTo(fp[i][0], fp[i][1]);
305
+ shape.closePath();
306
+ for (const h of el.holes || []) { // optional opening loops (same world-XY polygon form)
307
+ if (!h || h.length < 3) continue;
308
+ const hole = new THREE.Path();
309
+ hole.moveTo(h[0][0], h[0][1]);
310
+ for (let i = 1; i < h.length; i++) hole.lineTo(h[i][0], h[i][1]);
311
+ hole.closePath();
312
+ shape.holes.push(hole);
313
+ }
314
+ const z0 = Math.min(el.from || 0, el.to || 0), z1 = Math.max(el.from || 0, el.to || 0);
315
+ mesh.geometry = new THREE.ExtrudeGeometry(shape, { depth: Math.max(z1 - z0, 0.5), bevelEnabled: false });
316
+ mesh.quaternion.identity();
317
+ mesh.position.set(0, 0, z0); // the shape carries world XY; lift the slab to its bottom face
318
+ }
319
+
283
320
  // A cylinder between two points (anchor rod / bolt shank).
284
321
  function placeBar(mesh, a, b, r, radial) {
285
322
  const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
@@ -331,6 +368,7 @@ function placeWasher(mesh, el) {
331
368
  function placeElement(mesh, el) {
332
369
  switch (el.kind || 'box') {
333
370
  case 'box': placeMember(mesh, el.from, el.to, el); return true;
371
+ case 'extrusion': placeExtrusion(mesh, el); return true;
334
372
  case 'plate': placePlate(mesh, el); return true;
335
373
  case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
336
374
  case 'nut': placeNut(mesh, el); return true;
@@ -1962,11 +2000,13 @@ function onDown(e) {
1962
2000
  const geo = m ? memberGeometry(m, ppf, dtos) : null;
1963
2001
  const mesh = meshById.get(id);
1964
2002
  const planeZ = geo ? (geo.line[0][2] + geo.line[1][2]) / 2 : 0;
2003
+ const ctrl = e.ctrlKey || e.metaKey;
1965
2004
  pending = {
1966
- 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
1967
2007
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
1968
2008
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
1969
- 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
1970
2010
  levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
1971
2011
  mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
1972
2012
  };
@@ -2029,7 +2069,9 @@ function onMove(e) {
2029
2069
  if (pending.epDrag) { onMoveEndpoint(e); return; }
2030
2070
  if (!pending.grab || !pending.origMm) return;
2031
2071
  if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
2032
- 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)
2033
2075
  dragging = pending;
2034
2076
  if (pending.mode === 'vertical') { onMoveVertical(e); return; }
2035
2077
  const cur = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!cur) return;
@@ -2045,7 +2087,8 @@ function onMove(e) {
2045
2087
  }
2046
2088
  if (corr) { dx += corr[0]; dy += corr[1]; }
2047
2089
  pending.delta = [dx, dy];
2048
- 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);
2049
2092
  if (at) { marker.position.set(at[0], at[1], at[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
2050
2093
  // live dimension readout: plan distance moved (feet) + the snap type, near the cursor
2051
2094
  readout._dist.textContent = (Math.hypot(dx, dy) / FT_MM).toFixed(2) + ' ft';
@@ -2053,6 +2096,17 @@ function onMove(e) {
2053
2096
  readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
2054
2097
  }
2055
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; } }
2056
2110
  function onBoxMove(e) {
2057
2111
  if (Math.hypot(e.clientX - boxSel.x, e.clientY - boxSel.y) < DRAG_TOL_PX) { rubber.style.display = 'none'; return; }
2058
2112
  boxSel.moved = true;
@@ -2076,7 +2130,7 @@ function membersInRect(x0, y0, x1, y1) {
2076
2130
  }
2077
2131
 
2078
2132
  function onUp(e) {
2079
- 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
2080
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)
2081
2135
  const bs = boxSel; boxSel = null;
2082
2136
  if (bs) { // empty-space gesture: drag = box-select, click = clear selection
@@ -2111,7 +2165,14 @@ function onUp(e) {
2111
2165
  }
2112
2166
  return;
2113
2167
  }
2114
- 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
+ }
2115
2176
  if (p.mode === 'vertical') { // elevation drag → write T.O.S (inches)
2116
2177
  if (p.dz && Math.abs(p.dz) > 1e-6) {
2117
2178
  if (api && api.onSelect) api.onSelect(p.id, false);
@@ -2180,7 +2241,8 @@ function onHoverMove(e) {
2180
2241
  hoverEp = null;
2181
2242
  let top = null; try { top = pickAllAt(lastHoverXY[0], lastHoverXY[1])[0] || null; } catch { top = null; }
2182
2243
  const topMesh = top ? meshById.get(top) : null, isPart = !!(topMesh && topMesh.userData.derived);
2183
- 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)
2184
2246
  if (top !== hoverId) { hoverId = top; rebuildEndpoints(); updateStatusChip(); }
2185
2247
  });
2186
2248
  }
@@ -2200,7 +2262,12 @@ function updateStatusChip() {
2200
2262
  else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
2201
2263
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
2202
2264
  else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
2203
- else txt = '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'; }
2204
2271
  hoverChip.textContent = txt;
2205
2272
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
2206
2273
  hoverChip.style.top = (rect.bottom - 30) + 'px';
@@ -2214,6 +2281,7 @@ function hide() {
2214
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
2215
2282
  cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
2216
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
2217
2285
  if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
2218
2286
  }
2219
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
@@ -0,0 +1,242 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Vectorize — 3D view</title>
7
+ <style>
8
+ /* Locked baseline: shadcn dark slate-blue (same tokens + scrollbar theming as the sibling editors). */
9
+ :root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
10
+ *{scrollbar-width:thin;scrollbar-color:#475569 transparent}
11
+ *::-webkit-scrollbar{width:10px;height:10px}
12
+ *::-webkit-scrollbar-track{background:transparent}
13
+ *::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
14
+ *::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
15
+ *::-webkit-scrollbar-corner{background:transparent}
16
+ *{box-sizing:border-box}
17
+ body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column;overflow:hidden}
18
+ header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex:none}
19
+ header b{font-size:14px}
20
+ header .spacer{flex:1}
21
+ .stat{color:var(--mut)} .stat b{color:var(--text);font-variant-numeric:tabular-nums}
22
+ /* The unplaced-entities chip — amber "needs review" language (never red: honesty, not error). */
23
+ #skipChip{display:inline-flex;align-items:center;gap:6px;background:transparent;border:1px solid var(--line);color:#f59e0b;border-radius:6px;padding:4px 10px;font:inherit;cursor:pointer}
24
+ #skipChip:hover{border-color:#f59e0b}
25
+ #skipChip[hidden]{display:none} /* the id's display beats the [hidden] UA rule — restore it */
26
+ /* Full-bleed canvas. Wrapper bg matches the Three.js clear color EXACTLY (#020817) so the brief
27
+ WebGL-init frame never flashes a seam against the app --bg. */
28
+ #wrap{flex:1;position:relative;min-height:0;background:#020817}
29
+ #stage3d{position:absolute;inset:0;width:100%;height:100%;outline:none}
30
+ #state{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--mut);text-align:center;padding:24px;z-index:4}
31
+ #state.show{display:flex}
32
+ #state .spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}
33
+ #state .msg{max-width:520px;line-height:1.55}
34
+ #state .msg b{color:var(--text)}
35
+ @keyframes spin{to{transform:rotate(360deg)}}
36
+ /* Floating toolbar pill, top-left (the established 3D slot). */
37
+ #bar{position:absolute;top:12px;left:12px;display:flex;gap:6px;align-items:center;z-index:5}
38
+ #bar .seg-group{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--panel);box-shadow:0 4px 14px rgba(0,0,0,.45)}
39
+ #bar .seg-group button{padding:5px 11px;margin:0;border:0;border-radius:0;background:transparent;color:var(--mut);font:600 12px system-ui;cursor:pointer}
40
+ #bar .seg-group button+button{border-left:1px solid var(--line)}
41
+ #bar .seg-group button:hover{color:var(--text)}
42
+ #bar .seg-group button.on{background:#0f172a;color:var(--text);box-shadow:inset 0 -2px 0 var(--brand)}
43
+ #bar>button{background:var(--panel);border:1px solid var(--line);border-radius:6px;color:var(--text);font:600 12px system-ui;padding:5px 11px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.45)}
44
+ #bar>button:hover{border-color:var(--brand)}
45
+ #bar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto}
46
+ /* Group visibility toggles folded INLINE into the pill (leaves bottom-left to the skip panel). */
47
+ #bar .gck{display:inline-flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:4px 9px;box-shadow:0 4px 14px rgba(0,0,0,.45);cursor:pointer;color:var(--text);font-size:12px}
48
+ #bar .gck input{accent-color:var(--brand);cursor:pointer;margin:0}
49
+ #bar .gck .ct{color:var(--mut);font-variant-numeric:tabular-nums;font-size:11px}
50
+ #bar .gck .sw{width:9px;height:9px;border-radius:2px;flex:none}
51
+ /* The unplaced-entities panel, docked bottom-left. Glanceable (never a modal — it coexists with
52
+ the scene it annotates). Inner list scrolls with the themed scrollbar. */
53
+ #skipPanel{position:absolute;left:12px;bottom:12px;width:380px;max-width:calc(100% - 24px);background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:5;display:none}
54
+ #skipPanel.open{display:block}
55
+ #skipPanel h3{margin:0;padding:9px 12px 4px;font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
56
+ #skipPanel .hint{padding:0 12px 7px;color:var(--mut);font-size:12px;line-height:1.45}
57
+ #skipList{max-height:200px;overflow:auto;padding:0 6px 8px;display:flex;flex-direction:column;gap:2px}
58
+ .srow{display:flex;align-items:baseline;gap:6px;padding:4px 6px;border-radius:6px;font-size:12px}
59
+ .srow:hover{background:var(--line)}
60
+ .srow .sid{color:#f59e0b;flex:none;font-variant-numeric:tabular-nums}
61
+ .srow .rsn{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <header>
66
+ <b>Vectorize — 3D</b>
67
+ <span class="stat" id="title">—</span>
68
+ <span class="spacer"></span>
69
+ <span class="stat"><b id="nBoxes">0</b> members · <b id="nExtr">0</b> bodies</span>
70
+ <button id="skipChip" hidden>⚠ <b id="nSkipped">0</b> unplaced</button>
71
+ </header>
72
+ <div id="wrap">
73
+ <canvas id="stage3d"></canvas>
74
+ <div id="bar" hidden>
75
+ <div class="seg-group" id="projSeg">
76
+ <button data-proj="persp" class="on" title="Perspective view — natural depth">Persp</button>
77
+ <button data-proj="ortho" title="Orthographic — true scale, no perspective">Ortho</button>
78
+ </div>
79
+ <button id="fitBtn" title="Fit all to view">Fit</button>
80
+ <span class="tb-sep"></span>
81
+ <div class="seg-group" id="modeSeg">
82
+ <button data-mode="solid" class="on" title="Solid shaded model">Solid</button>
83
+ <button data-mode="wire" title="Wireframe — edges only">Wire</button>
84
+ <button data-mode="xray" title="See-through — reveal hidden parts">X-ray</button>
85
+ </div>
86
+ <span class="tb-sep"></span>
87
+ <span id="groupToggles"></span>
88
+ </div>
89
+ <div id="skipPanel">
90
+ <h3 id="skipTitle">Not shown in 3D</h3>
91
+ <div class="hint">These couldn't be matched to a 3D position. Fix the reason below, then reopen this view.</div>
92
+ <div id="skipList"></div>
93
+ </div>
94
+ <div id="state" class="show" role="status" aria-live="polite"><div class="spin"></div><div class="msg" id="stateMsg">Reconstructing…</div></div>
95
+ </div>
96
+ <script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/","three-bvh-csg":"./vendor/three-bvh-csg.module.js"}}</script>
97
+ <script type="module" src="./steel-3d-view.js"></script>
98
+ <script type="module">
99
+ (function poll(waited) { // steel-3d-view registers window.Steel3DView on module load — wait for it
100
+ if (window.Steel3DView) { boot(window.Steel3DView); return; }
101
+ if (waited > 8000) { // the module never loaded (bad vendor path / script error) — don't spin forever
102
+ document.getElementById('stateMsg').textContent = 'Could not load the 3D viewer. Reload the page, or check the console.';
103
+ document.getElementById('state').querySelector('.spin').style.display = 'none';
104
+ return;
105
+ }
106
+ setTimeout(() => poll((waited || 0) + 20), 20);
107
+ })(0);
108
+
109
+ function boot(v3d) {
110
+ const $ = (id) => document.getElementById(id);
111
+ const params = new URLSearchParams(location.search);
112
+ const app = params.get('app');
113
+ let scene = null;
114
+ let skipped = [];
115
+
116
+ function showState(msgNode, spinning) {
117
+ $('stateMsg').replaceChildren(msgNode);
118
+ $('state').classList.add('show');
119
+ $('state').querySelector('.spin').style.display = spinning ? '' : 'none';
120
+ }
121
+ function hideState() { $('state').classList.remove('show'); }
122
+
123
+ // The read-only host api: the module's editing/dim/snap hooks are inert here — a viewer never
124
+ // mutates the contract. fetchScene serves the one scene this page already loaded.
125
+ const api = {
126
+ fetchScene: async () => scene,
127
+ getMembers: () => [],
128
+ ptPerFt: () => 1,
129
+ defaultTosMm: () => 0,
130
+ geoMode: () => null,
131
+ fmtLen: (mm) => `${Math.round(mm)} mm`,
132
+ getDimOverlays: () => [],
133
+ getDims3d: () => [],
134
+ selDim3d: () => [],
135
+ newDim3dId: () => 'd0',
136
+ onSelect: () => {}, onSelectMany: () => {}, onSelectDim3d: () => {}, onAddDim3d: () => {},
137
+ onMoveMember: () => {}, onMoveEndpoint: () => {}, onElevateMember: () => {}, onSplit: () => {},
138
+ onTrimExtend: () => {}, onIsolateChange: () => {}, onClipModeChange: () => {}, onClipsChange: () => {},
139
+ onWorkAreaChange: () => {}, beginClipEdit: () => {},
140
+ statusHint: () => 'Right-drag orbit · middle-drag pan · wheel zoom · dbl-click to zoom in',
141
+ };
142
+
143
+ function emptyStateNode() {
144
+ // Plain AEC language — a cold empty state must not lean on schema vocabulary (per design consult).
145
+ // Two distinct zero-element cases: NOTHING was declared as a 3D entity (cold set), vs entities
146
+ // were read but NONE could be placed (all skipped) — the honest message differs.
147
+ const d = document.createElement('div');
148
+ const b = document.createElement('b');
149
+ const p = document.createElement('div');
150
+ p.style.marginTop = '8px';
151
+ if (skipped.length > 0) {
152
+ b.textContent = skipped.length === 1
153
+ ? 'Nothing placed in 3D yet — the one entity read couldn’t be placed.'
154
+ : `Nothing placed in 3D yet — all ${skipped.length} entities are unplaced.`;
155
+ p.textContent = 'Each one is listed with the reason in the "Not shown in 3D" panel — usually a missing height (a level datum, an extrude value, or a matching elevation). Fix the reasons and reopen this view.';
156
+ } else {
157
+ b.textContent = 'No 3D yet — this drawing set doesn’t have matched plan and elevation sheets.';
158
+ p.textContent = '3D reconstruction needs at least one plan sheet and one elevation (or section) sheet that share entities, so heights can be worked out from the elevation. Classify your sheets as plan/elevation and make sure floor levels or extrude heights are set, then reopen this view — or ask your terminal AI to do it.';
159
+ }
160
+ d.append(b, p);
161
+ return d;
162
+ }
163
+
164
+ function renderSkipPanel() {
165
+ const chip = $('skipChip');
166
+ chip.hidden = skipped.length === 0;
167
+ $('nSkipped').textContent = skipped.length;
168
+ $('skipTitle').textContent = `Not shown in 3D (${skipped.length})`;
169
+ const list = $('skipList');
170
+ while (list.firstChild) list.removeChild(list.firstChild);
171
+ for (const s of skipped) {
172
+ const row = document.createElement('div'); row.className = 'srow';
173
+ const sid = document.createElement('span'); sid.className = 'sid'; sid.textContent = s.id;
174
+ const rsn = document.createElement('span'); rsn.className = 'rsn'; rsn.textContent = s.reason; rsn.title = s.reason;
175
+ row.append(sid, rsn); list.appendChild(row);
176
+ }
177
+ }
178
+
179
+ function renderGroupToggles() {
180
+ const box = $('groupToggles');
181
+ while (box.firstChild) box.removeChild(box.firstChild);
182
+ const counts = new Map();
183
+ for (const el of scene.elements) counts.set(el.group, (counts.get(el.group) || 0) + 1);
184
+ for (const g of v3d.getGroups()) {
185
+ const lab = document.createElement('label'); lab.className = 'gck';
186
+ const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = true;
187
+ inp.addEventListener('change', () => v3d.toggleGroup(g.key));
188
+ const sw = document.createElement('span'); sw.className = 'sw'; sw.style.background = g.color;
189
+ const nm = document.createElement('span'); nm.textContent = g.label;
190
+ const ct = document.createElement('span'); ct.className = 'ct'; ct.textContent = String(counts.get(g.key) || 0);
191
+ lab.append(inp, sw, nm, ct); box.appendChild(lab);
192
+ }
193
+ }
194
+
195
+ $('fitBtn').addEventListener('click', () => v3d.frameAll());
196
+ $('projSeg').addEventListener('click', (e) => {
197
+ const b = e.target.closest('button'); if (!b) return;
198
+ v3d.setProjection(b.dataset.proj);
199
+ for (const x of $('projSeg').children) x.classList.toggle('on', x === b);
200
+ });
201
+ $('modeSeg').addEventListener('click', (e) => {
202
+ const b = e.target.closest('button'); if (!b) return;
203
+ v3d.setDisplayMode(b.dataset.mode);
204
+ for (const x of $('modeSeg').children) x.classList.toggle('on', x === b);
205
+ });
206
+ $('skipChip').addEventListener('click', () => $('skipPanel').classList.toggle('open'));
207
+
208
+ (async () => {
209
+ if (!app) { showState(document.createTextNode('No app given — open this view from a workflow node.'), false); return; }
210
+ let out;
211
+ try {
212
+ const r = await fetch('/api/contract/' + encodeURIComponent(app) + '/scene', {
213
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
214
+ });
215
+ out = await r.json();
216
+ if (!r.ok || !out.ok) throw new Error(out.error || ('HTTP ' + r.status));
217
+ } catch (e) {
218
+ showState(document.createTextNode('Could not reconstruct the drawing (' + e.message + ').'), false);
219
+ return;
220
+ }
221
+ scene = out.scene;
222
+ skipped = Array.isArray(out.skipped) ? out.skipped : [];
223
+ const name = (scene.meta && scene.meta.name) || app;
224
+ $('title').textContent = name;
225
+ document.title = 'Vectorize — 3D · ' + name;
226
+ const boxes = scene.elements.filter((e) => e.kind === 'box').length;
227
+ const extr = scene.elements.filter((e) => e.kind === 'extrusion').length;
228
+ $('nBoxes').textContent = boxes; $('nExtr').textContent = extr;
229
+ renderSkipPanel();
230
+ if (skipped.length) $('skipPanel').classList.add('open'); // partial/failed reconstruction: lead with honesty
231
+ if (scene.elements.length === 0) { showState(emptyStateNode(), false); return; } // (chip + panel still show above #state)
232
+ v3d.init($('stage3d'), api);
233
+ v3d.show();
234
+ await v3d.rebuild(true); // fit the camera on first build
235
+ renderGroupToggles();
236
+ $('bar').hidden = false;
237
+ hideState();
238
+ })();
239
+ }
240
+ </script>
241
+ </body>
242
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.63.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": {