@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.
- package/dist/floless-server.cjs +36 -13
- package/dist/skills/floless-app-vectorize/SKILL.md +57 -4
- package/dist/web/aware.js +31 -2
- package/dist/web/renderers.js +3 -0
- package/dist/web/steel-3d-view.js +76 -8
- package/dist/web/steel-editor.html +53 -8
- package/dist/web/vector-3d.html +242 -0
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.65.0" : void 0,
|
|
53026
53026
|
pkgVersion: readPkgVersion()
|
|
53027
53027
|
});
|
|
53028
53028
|
}
|
|
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
|
|
|
53032
53032
|
return "dev";
|
|
53033
53033
|
}
|
|
53034
53034
|
function appChannel() {
|
|
53035
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.65.0" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -59133,14 +59133,16 @@ function writeViewer3dApp(dir, appId, scene) {
|
|
|
59133
59133
|
bakeSceneIntoApp(flo, scene);
|
|
59134
59134
|
return flo;
|
|
59135
59135
|
}
|
|
59136
|
-
function writeIfcApp(dir, appId, scene, outputPath) {
|
|
59136
|
+
function writeIfcApp(dir, appId, scene, outputPath, opts = {}) {
|
|
59137
59137
|
(0, import_node_fs20.mkdirSync)(dir, { recursive: true });
|
|
59138
59138
|
const flo = (0, import_node_path17.join)(dir, `${appId}.flo`);
|
|
59139
|
+
const displayName = opts.displayName ?? "Steel IFC";
|
|
59140
|
+
const description = opts.description ?? "IFC file written from an approved steel.takeoff/v1 contract (a companion export; the contract is the source of truth).";
|
|
59139
59141
|
(0, import_node_fs20.writeFileSync)(flo, [
|
|
59140
59142
|
`app: ${appId}`,
|
|
59141
59143
|
"version: 0.1.0",
|
|
59142
|
-
|
|
59143
|
-
|
|
59144
|
+
`display-name: ${displayName}`,
|
|
59145
|
+
`description: ${description}`,
|
|
59144
59146
|
"exposes-as-agent: false",
|
|
59145
59147
|
"requires:",
|
|
59146
59148
|
" - ifc@0.1.x",
|
|
@@ -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 :
|
|
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 =
|
|
64147
|
+
const doc = readContractForApp(req.params.appId);
|
|
64146
64148
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
64147
|
-
const
|
|
64148
|
-
|
|
64149
|
-
|
|
64149
|
+
const isVector = typeof doc === "object" && doc != null && doc.type === "drawing.vector/v1";
|
|
64150
|
+
let scene;
|
|
64151
|
+
let skipped;
|
|
64152
|
+
let extrusionsSkipped = 0;
|
|
64153
|
+
let writeOpts;
|
|
64154
|
+
let emptyError;
|
|
64155
|
+
if (isVector) {
|
|
64156
|
+
const cleaned = postProcess(doc);
|
|
64157
|
+
const vv = validateContract(cleaned);
|
|
64158
|
+
if (!vv.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
64159
|
+
const built = viewsToScene(cleaned);
|
|
64160
|
+
extrusionsSkipped = built.scene.elements.filter((e) => e.kind === "extrusion").length;
|
|
64161
|
+
scene = { ...built.scene, elements: built.scene.elements.filter((e) => e.kind === "box") };
|
|
64162
|
+
skipped = built.skipped;
|
|
64163
|
+
writeOpts = { displayName: "Drawing IFC", description: "IFC file written from a reconstructed drawing.vector/v1 model (member geometry only; a companion export)." };
|
|
64164
|
+
emptyError = extrusionsSkipped > 0 ? `no member geometry to export \u2014 this model is ${extrusionsSkipped} extruded ${extrusionsSkipped === 1 ? "body" : "bodies"}, which don't export to IFC yet` : "no 3D to export \u2014 reconstruct a plan + elevation first (open View 3D to see what's missing)";
|
|
64165
|
+
} else {
|
|
64166
|
+
const v = validateSteelTakeoff(doc);
|
|
64167
|
+
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
64168
|
+
const built = contractToScene(doc);
|
|
64169
|
+
scene = built.scene;
|
|
64170
|
+
skipped = built.skipped;
|
|
64171
|
+
emptyError = "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)";
|
|
64172
|
+
}
|
|
64150
64173
|
if (scene.elements.length === 0) {
|
|
64151
|
-
return reply.status(422).send({ ok: false, error:
|
|
64174
|
+
return reply.status(422).send({ ok: false, error: emptyError, skipped });
|
|
64152
64175
|
}
|
|
64153
64176
|
const companionId = `${req.params.appId}-ifc`;
|
|
64154
64177
|
const filename = `${req.params.appId}.ifc`;
|
|
64155
64178
|
const outPath = (0, import_node_path28.join)(appPath(companionId), filename);
|
|
64156
64179
|
let flo;
|
|
64157
64180
|
try {
|
|
64158
|
-
flo = writeIfcApp(appPath(companionId), companionId, scene, outPath);
|
|
64181
|
+
flo = writeIfcApp(appPath(companionId), companionId, scene, outPath, writeOpts);
|
|
64159
64182
|
} catch (e) {
|
|
64160
64183
|
app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-ifc: companion app write failed");
|
|
64161
64184
|
return reply.status(500).send({ ok: false, error: `could not write the IFC companion app: ${e instanceof Error ? e.message : "write error"}` });
|
|
@@ -64178,7 +64201,7 @@ async function startServer() {
|
|
|
64178
64201
|
if (!(0, import_node_fs30.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
|
|
64179
64202
|
const content = (0, import_node_fs30.readFileSync)(outPath, "utf8");
|
|
64180
64203
|
broadcast({ type: "apps-changed" });
|
|
64181
|
-
return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped };
|
|
64204
|
+
return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
|
|
64182
64205
|
});
|
|
64183
64206
|
app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
|
|
64184
64207
|
const doc = readContract(req.params.appId);
|
|
@@ -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,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
|
-
|
|
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
|
}
|
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;
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
34
|
+
line.member{cursor:pointer;stroke-width:6;stroke-linecap:round} /* default (drag-move OFF): a click selects → the pointer cursor tells the truth */
|
|
35
|
+
body.dragmove line.member{cursor:move} /* drag-move ON: the move cursor signals a drag will translate — a per-hover second signal beside the toolbar pill */
|
|
35
36
|
line.member.sel{stroke-width:7} line.member.rfi{stroke-dasharray:10 6}
|
|
36
37
|
circle.handle{fill:var(--bg);stroke:#f8fafc;stroke-width:3;cursor:grab}
|
|
37
38
|
line.seg{stroke:#475569;stroke-width:2;opacity:0;cursor:crosshair} body.add line.seg{opacity:.5}
|
|
@@ -130,6 +131,9 @@
|
|
|
130
131
|
#comboPop .opt:hover,#comboPop .opt.active{background:#334155}
|
|
131
132
|
/* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
|
|
132
133
|
/* Move/Copy split buttons + transform-suite chrome — all within the locked baseline tokens. */
|
|
134
|
+
.hdrsep{width:1px;height:20px;background:var(--line);flex:none;margin:0 2px} /* header divider: sets the Drag-to-move mode toggle apart from the transform actions */
|
|
135
|
+
#dragMoveB{white-space:nowrap;flex:none}
|
|
136
|
+
#dragMoveB.on{background:var(--brand);border-color:var(--brand);color:#fff}
|
|
133
137
|
.cmwrap{position:relative;display:inline-flex}
|
|
134
138
|
.cmwrap>button:first-child{border-radius:6px 0 0 6px}
|
|
135
139
|
.cmwrap .cmcaret{border-radius:0 6px 6px 0;border-left:0;padding:0 5px;min-width:22px;color:var(--mut)}
|
|
@@ -339,6 +343,8 @@
|
|
|
339
343
|
<button id=cpLevelB>Copy to level…</button>
|
|
340
344
|
</div>
|
|
341
345
|
</div>
|
|
346
|
+
<span class=hdrsep></span>
|
|
347
|
+
<button id=dragMoveB aria-pressed=false title="Drag to move — when ON, dragging a member with the left mouse moves it. When OFF (default), a click only selects, so members can't be nudged by accident (the ↔ Move tool and Ctrl+drag-copy still work). Applies to 2D and 3D.">Drag to move</button>
|
|
342
348
|
<button id=askAiBtn>Ask AI ▸</button>
|
|
343
349
|
<div id=moreWrap>
|
|
344
350
|
<button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
|
|
@@ -1693,6 +1699,13 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
|
|
|
1693
1699
|
// array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
|
|
1694
1700
|
// lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
|
|
1695
1701
|
let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
|
|
1702
|
+
// Drag-to-move: OFF by default so a click only selects (no accidental nudges). Persisted per-browser,
|
|
1703
|
+
// global (a UI pref, not per-app). Ctrl+drag-copy ignores this — it's an explicit gesture.
|
|
1704
|
+
let dragMoveOn=false; try{dragMoveOn=localStorage.getItem('steel:dragmove:v1')==='1';}catch(_){}
|
|
1705
|
+
let dragHintShown=false; try{dragHintShown=localStorage.getItem('steel:dragmovehint:v1')==='1';}catch(_){} // one-time "drag-move is off" teach on the first blocked drag
|
|
1706
|
+
function setDragMove(on){dragMoveOn=!!on;document.body.classList.toggle('dragmove',dragMoveOn);
|
|
1707
|
+
const b=document.getElementById('dragMoveB');if(b){b.classList.toggle('on',dragMoveOn);b.setAttribute('aria-pressed',String(dragMoveOn));}
|
|
1708
|
+
try{localStorage.setItem('steel:dragmove:v1',dragMoveOn?'1':'0');}catch(_){}}
|
|
1696
1709
|
// cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
|
|
1697
1710
|
function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
|
|
1698
1711
|
document.getElementById('mvB').classList.toggle('on',cmTool==='move');
|
|
@@ -1968,12 +1981,16 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
1968
1981
|
return;}}
|
|
1969
1982
|
if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
|
|
1970
1983
|
if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
|
|
1971
|
-
if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id;
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1984
|
+
if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
|
|
1985
|
+
// Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
|
|
1986
|
+
// its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
|
|
1987
|
+
if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
|
|
1988
|
+
const canDrag=ctrl||dragMoveOn; // plain grab only translates when the toggle is on; ctrl always drags (to copy)
|
|
1989
|
+
const srcIds=(ctrl&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // ctrl on an unselected member copies just it; else the whole selection
|
|
1990
|
+
const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
|
|
1991
|
+
buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
|
|
1992
|
+
drag={type:'grab',copy:ctrl,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
|
|
1993
|
+
svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // always capture: even an unarmed grab tracks the gesture so a blocked drag can teach the toggle
|
|
1977
1994
|
if((t===svg||t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e); // t===svg → drag on bare canvas (no raster) still box-selects
|
|
1978
1995
|
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
1979
1996
|
drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
@@ -2022,7 +2039,17 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2022
2039
|
drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
|
|
2023
2040
|
if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
|
|
2024
2041
|
drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
|
|
2025
|
-
if(drag.type==='
|
|
2042
|
+
if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
|
|
2043
|
+
if(!drag.moved){
|
|
2044
|
+
if(Math.hypot(rawdx,rawdy)<4/zoom)return; // below the drag threshold — still a potential click
|
|
2045
|
+
drag.moved=true;
|
|
2046
|
+
if(!drag.armed)return; // drag-move OFF, plain grab → never translate (the up-handler teaches the toggle)
|
|
2047
|
+
if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
|
|
2048
|
+
const ns=new Set(),ci=[];
|
|
2049
|
+
for(const it of drag.items){const src=byId(it.id);if(!src)continue;const c=cloneMember(src);P.members.push(c);ns.add(c.id);ci.push({id:c.id,o0:c.wp[0].slice(),o1:c.wp[1].slice()});}
|
|
2050
|
+
selIds=ns;drag.items=ci;buildSnap(ns);render();}} // select the clones + snap them to the sources/others; render shows them (drag continues via updateLine)
|
|
2051
|
+
if(!drag.armed)return;
|
|
2052
|
+
let dx=rawdx,dy=rawdy;
|
|
2026
2053
|
if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
|
|
2027
2054
|
else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint, midpoint △, OR line)
|
|
2028
2055
|
for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
|
|
@@ -2056,6 +2083,16 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
2056
2083
|
const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
|
|
2057
2084
|
if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
|
|
2058
2085
|
drag.rect.remove();drag=null;render();return;}
|
|
2086
|
+
if(drag.type==='grab'){
|
|
2087
|
+
if(!drag.moved){ // no drag past threshold → it was a click
|
|
2088
|
+
if(drag.copy){selIds.has(drag.clickId)?selIds.delete(drag.clickId):selIds.add(drag.clickId);} // ctrl+click toggles selection (deferred from down)
|
|
2089
|
+
drag=null;render();return;}
|
|
2090
|
+
if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
|
|
2091
|
+
drag=null;render();
|
|
2092
|
+
if(!dragHintShown){dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use ↔ Move (M)');}
|
|
2093
|
+
return;}
|
|
2094
|
+
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre); // armed move/copy committed (copy always changed geometry via its clones)
|
|
2095
|
+
const wasCopy=drag.copy;drag=null;render();if(wasCopy)toast('Copied '+selIds.size+' member'+(selIds.size===1?'':'s'));return;}
|
|
2059
2096
|
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
|
|
2060
2097
|
svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
|
|
2061
2098
|
svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
|
|
@@ -2092,6 +2129,8 @@ document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members)
|
|
|
2092
2129
|
toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
|
|
2093
2130
|
document.getElementById('undoB').onclick=doUndo;
|
|
2094
2131
|
document.getElementById('redoB').onclick=doRedo;
|
|
2132
|
+
document.getElementById('dragMoveB').onclick=()=>{setDragMove(!dragMoveOn);toast(dragMoveOn?'Drag to move: ON — dragging a member moves it':'Drag to move: OFF — a click only selects');};
|
|
2133
|
+
setDragMove(dragMoveOn); // reflect the persisted state on load (body class + pill)
|
|
2095
2134
|
addEventListener('keydown',e=>{
|
|
2096
2135
|
const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
|
|
2097
2136
|
if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
|
|
@@ -2189,6 +2228,12 @@ const view3dApi={
|
|
|
2189
2228
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
2190
2229
|
geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
|
|
2191
2230
|
onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
|
|
2231
|
+
dragMoveEnabled:()=>dragMoveOn, // the 3D view gates its plain member-drag on this
|
|
2232
|
+
dragMoveHint:()=>{if(dragHintShown)return;dragHintShown=true;try{localStorage.setItem('steel:dragmovehint:v1','1');}catch(_){}toast('Drag-to-move is off — turn it on in the toolbar, or use ↔ Move (M)');},
|
|
2233
|
+
onCopyDrag3d:(ids,dxMm,dyMm)=>{const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1),r6=n=>Math.round(n*1e6)/1e6; // Ctrl+drag copy: mm delta → plan px (Y FLIPPED), clone + translate + select
|
|
2234
|
+
const dpx=r6(dxMm/k),dpy=r6(-dyMm/k);
|
|
2235
|
+
edit(()=>{const ns=new Set();for(const id of (ids||[])){const src=byId(id);if(!src)continue;const c=cloneMember(src);translateMembers([c],[dpx,dpy,0]);P.members.push(c);ns.add(c.id);}selIds=ns;});
|
|
2236
|
+
toast('Copied '+(ids?ids.length:0)+' member'+((ids&&ids.length===1)?'':'s'));},
|
|
2192
2237
|
onMoveEndpoint:(id,end,wp)=>{edit(()=>{const m=byId(id);if(m&&Array.isArray(m.wp)&&m.wp.length>=2)m.wp[end]=wp;});}, // 3D end-node drag → write just that endpoint
|
|
2193
2238
|
onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
|
|
2194
2239
|
if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dIn;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dIn;m.col.tosDef=false;} // rigid raise (bos null→0, both shift) so the column isn't stretched
|
|
@@ -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>
|