@floless/app 0.63.0 → 0.64.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.64.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.64.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -64076,7 +64076,7 @@ async function startServer() {
64076
64076
  { bodyLimit: 25 * 1024 * 1024 },
64077
64077
  // contracts embed rasters (matches PUT /api/contract) — the editor POSTs the live contract on every 3D rebuild
64078
64078
  async (req, reply) => {
64079
- const doc = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
64079
+ const doc = req.body && "contract" in req.body ? req.body.contract : readContractForApp(req.params.appId);
64080
64080
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
64081
64081
  if (doc && typeof doc === "object" && doc.type === "drawing.vector/v1") {
64082
64082
  const cleaned = postProcess(doc);
@@ -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,11 @@
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
+ }
1363
1368
  // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1364
1369
  // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
1365
1370
  if (window.CONTRACT_RENDERERS[contractType].scene) {
@@ -1770,6 +1775,21 @@
1770
1775
  $contractEditor.hidden = false;
1771
1776
  return true;
1772
1777
  }
1778
+ // A renderer-declared LOCAL viewer page (e.g. the drawing.vector 3D reconstruction) — reuses the
1779
+ // contract-editor overlay/iframe. Read-only: no Approve (the editor owns that gate).
1780
+ function openLocalViewer(appId, type) {
1781
+ const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1782
+ if (!r || !r.viewerUrl) return false;
1783
+ const ap = document.getElementById('contract-editor-approve'); if (ap) ap.hidden = true;
1784
+ $contractEditorTitle.replaceChildren(
1785
+ Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1786
+ Object.assign(document.createElement('span'), { textContent: ' · 3D view', style: 'color:var(--text-muted)' }),
1787
+ );
1788
+ $contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
1789
+ $contractEditorFrame.src = r.viewerUrl(appId);
1790
+ $contractEditor.hidden = false;
1791
+ return true;
1792
+ }
1773
1793
  // The steel filter pre-stage view — reuses the contract-editor overlay/iframe to load the served
1774
1794
  // steel-filter.html (layer/thickness/colour facets + eyedropper). Same seam as the editor: the
1775
1795
  // iframe talks to /api/contract directly and Save writes contract.filter.
@@ -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;
@@ -2200,7 +2238,9 @@ function updateStatusChip() {
2200
2238
  else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
2201
2239
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
2202
2240
  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';
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';
2204
2244
  hoverChip.textContent = txt;
2205
2245
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
2206
2246
  hoverChip.style.top = (rect.bottom - 30) + 'px';
@@ -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.64.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": {