@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.
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.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.
|
|
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 :
|
|
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.
|
|
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
|
-
**
|
|
30
|
-
|
|
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.
|
package/dist/web/renderers.js
CHANGED
|
@@ -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
|
-
|
|
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>
|