@floless/app 0.34.2 → 0.35.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.
@@ -0,0 +1,422 @@
1
+ /*
2
+ * steel-3d-view.js — the 3D render/interaction layer for the steel-takeoff editor's 3D mode.
3
+ *
4
+ * Three.js lives ONLY here (the rest of the editor is import-free). It renders the SAME scene the
5
+ * IFC/Tekla bake uses (fetched from /api/contract/:id/scene → contractToScene), so the editable 3D
6
+ * matches what bakes out. Phase B = read-only: orbit + shaded model + selection highlight. Phase C
7
+ * adds drag + snapping (it will pull the pure math from ./steel-3d-core.js).
8
+ *
9
+ * Exposes `window.Steel3DView` so the editor's classic script can drive it. The scene is Z-up
10
+ * (contractToScene emits meta.up='z'), so the camera/grid are set up Z-up.
11
+ */
12
+ import * as THREE from 'three';
13
+ import { OrbitControls } from 'three/addons/OrbitControls.js';
14
+ import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels } from './steel-3d-core.js';
15
+
16
+ let renderer, scene, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
17
+ let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
18
+ const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
19
+ const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
20
+ const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
21
+ let built = false, fitPending = false;
22
+ const meshById = new Map(); // member id -> THREE.Mesh
23
+ const groupColor = new Map(); // group key -> THREE.Color
24
+ const baseMat = new Map(); // group key -> MeshStandardMaterial (shared per profile)
25
+ const SELECT_EMISSIVE = 0x3b82f6; // --brand: selected members glow blue
26
+
27
+ function init(canvas, theApi) {
28
+ if (renderer) return; // once
29
+ canvasEl = canvas; api = theApi;
30
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
31
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
32
+ scene = new THREE.Scene();
33
+ scene.background = new THREE.Color(0x020817); // match the app --bg (the mode change reads from the geometry, not a tint)
34
+ camera = new THREE.PerspectiveCamera(50, 1, 1, 50_000_000);
35
+ camera.up.set(0, 0, 1); // Z-up to match the scene
36
+ controls = new OrbitControls(camera, canvas);
37
+ controls.enableDamping = true;
38
+ scene.add(new THREE.AmbientLight(0xffffff, 0.65));
39
+ const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(0.4, -1, 1.2); scene.add(key);
40
+ const fill = new THREE.DirectionalLight(0xffffff, 0.25); fill.position.set(-1, 0.6, 0.4); scene.add(fill);
41
+ root = new THREE.Group(); scene.add(root);
42
+ raycaster = new THREE.Raycaster();
43
+ // a --brand snap marker (one sphere at the winning snap target; the readout carries the type)
44
+ marker = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
45
+ marker.visible = false; marker.renderOrder = 999; marker.material.depthTest = false; scene.add(marker);
46
+ // live dimension readout (toast-styled, follows the cursor during a drag — feet + snap type).
47
+ // Two spans + textContent (never innerHTML) so it's XSS-proof; the type span is muted.
48
+ readout = document.createElement('div');
49
+ readout.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:60;background:var(--panel,#0f172a);color:var(--text,#f8fafc);border:1px solid var(--line,#1e293b);border-radius:4px;padding:3px 7px;font:12px system-ui;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5)';
50
+ readout._dist = document.createElement('span');
51
+ readout._type = document.createElement('span'); readout._type.style.color = 'var(--mut,#94a3b8)';
52
+ readout.append(readout._dist, readout._type);
53
+ document.body.appendChild(readout);
54
+ // Pointer gestures: down on a member → grab it (suppress orbit); move → drag with snapping; up →
55
+ // commit the move, or (no move) select. Down on empty space → OrbitControls orbits/pans as usual.
56
+ // Capture phase on pointerdown so we can stop orbit BEFORE OrbitControls sees a member grab.
57
+ canvas.addEventListener('pointerdown', onDown, true);
58
+ window.addEventListener('pointermove', onMove); // window so a drag keeps tracking off-canvas
59
+ window.addEventListener('pointerup', onUp);
60
+ ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
61
+ // Resize once more on the next frame: the stage may still be laying out when init() runs (the
62
+ // designer flagged a first-render size mismatch as the common rough edge here).
63
+ requestAnimationFrame(resize);
64
+ resize();
65
+ // the render loop is started by show() (and stopped by hide()) so it never spins while in 2D
66
+ }
67
+
68
+ function resize() {
69
+ if (!renderer) return;
70
+ const el = canvasEl.parentElement || canvasEl;
71
+ const w = el.clientWidth || 1, h = el.clientHeight || 1;
72
+ renderer.setSize(w, h, false);
73
+ camera.aspect = w / h; camera.updateProjectionMatrix();
74
+ }
75
+
76
+ function loop() {
77
+ rafId = requestAnimationFrame(loop);
78
+ controls.update(); renderer.render(scene, camera);
79
+ }
80
+
81
+ const V = (x, y, z) => new THREE.Vector3(x, y, z);
82
+
83
+ // ---- structural cross-section shapes (ported from AWARE viewer-3d for parity) ----
84
+ // section.w = flange/overall width, section.d = section depth; shape derived from the profile name.
85
+ function shapeOf(el) {
86
+ const explicit = (el.section && el.section.shape || '').toString().toUpperCase().trim();
87
+ if (/^(I|C|L|TUBE|BOX)$/.test(explicit)) return explicit; // explicit section.shape override wins
88
+ const p = ((el.meta && el.meta.profile) || '').toString().toUpperCase().trim();
89
+ if (/^(W|M|S|HP|UC|UB|UKC|UKB|IPE|HE)/.test(p)) return 'I'; // wide-flange / I-section
90
+ if (/^(C|MC|PFC)/.test(p)) return 'C'; // channel
91
+ if (/^L/.test(p)) return 'L'; // angle
92
+ if (/^(HSS|PIPE|TS|SHS|RHS|CHS|TUBE|HSQ)/.test(p)) return 'TUBE'; // hollow tube
93
+ return 'BOX';
94
+ }
95
+ function profileShape(kind, w, d) {
96
+ const s = new THREE.Shape(), hw = w / 2, hd = d / 2;
97
+ if (kind === 'I') {
98
+ const tf = Math.min(d * 0.5, Math.max(d * 0.10, 6)), tw = Math.min(w * 0.5, Math.max(w * 0.10, 5));
99
+ s.moveTo(-hw, -hd); s.lineTo(hw, -hd); s.lineTo(hw, -hd + tf); s.lineTo(tw / 2, -hd + tf);
100
+ s.lineTo(tw / 2, hd - tf); s.lineTo(hw, hd - tf); s.lineTo(hw, hd); s.lineTo(-hw, hd);
101
+ s.lineTo(-hw, hd - tf); s.lineTo(-tw / 2, hd - tf); s.lineTo(-tw / 2, -hd + tf); s.lineTo(-hw, -hd + tf); s.closePath();
102
+ } else if (kind === 'C') {
103
+ const tf = Math.max(d * 0.10, 5), tw = Math.max(w * 0.12, 5);
104
+ s.moveTo(-hw, -hd); s.lineTo(hw, -hd); s.lineTo(hw, -hd + tf); s.lineTo(-hw + tw, -hd + tf);
105
+ s.lineTo(-hw + tw, hd - tf); s.lineTo(hw, hd - tf); s.lineTo(hw, hd); s.lineTo(-hw, hd); s.closePath();
106
+ } else if (kind === 'L') {
107
+ const t = Math.max(Math.min(w, d) * 0.18, 5);
108
+ s.moveTo(-hw, -hd); s.lineTo(hw, -hd); s.lineTo(hw, -hd + t); s.lineTo(-hw + t, -hd + t); s.lineTo(-hw + t, hd); s.lineTo(-hw, hd); s.closePath();
109
+ } else if (kind === 'TUBE') {
110
+ const t = Math.max(Math.min(w, d) * 0.12, 4);
111
+ s.moveTo(-hw, -hd); s.lineTo(hw, -hd); s.lineTo(hw, hd); s.lineTo(-hw, hd); s.closePath();
112
+ const h = new THREE.Path(); h.moveTo(-hw + t, -hd + t); h.lineTo(hw - t, -hd + t); h.lineTo(hw - t, hd - t); h.lineTo(-hw + t, hd - t); h.closePath(); s.holes.push(h);
113
+ } else { s.moveTo(-hw, -hd); s.lineTo(hw, -hd); s.lineTo(hw, hd); s.lineTo(-hw, hd); s.closePath(); }
114
+ return s;
115
+ }
116
+ function memberGeom(el, w, d, len) {
117
+ const kind = shapeOf(el);
118
+ if (kind === 'BOX') return new THREE.BoxGeometry(w, len, d); // length on local Y
119
+ const g = new THREE.ExtrudeGeometry(profileShape(kind, w, d), { depth: len, bevelEnabled: false });
120
+ g.translate(0, 0, -len / 2); return g; // extruded +Z, centred
121
+ }
122
+ const _ZA = new THREE.Vector3(0, 0, 1), _YA = new THREE.Vector3(0, 1, 0);
123
+ // NOTE vs AWARE viewer-3d: AWARE converts the Z-up scene into Three's Y-up world before orienting, so
124
+ // its roll targets world-Y. We render the scene NATIVELY Z-up (camera.up=Z), so the roll must target
125
+ // world-Z (_ZA) — else the web rolls flat (horizontal) instead of standing vertical.
126
+ function orientMember(mesh, dir) {
127
+ const m = dir.clone().normalize();
128
+ if (mesh.geometry.type === 'BoxGeometry') { mesh.quaternion.setFromUnitVectors(_YA, m); return; } // length on local Y
129
+ const q = new THREE.Quaternion().setFromUnitVectors(_ZA, m); // member axis = extrude (local Z)
130
+ const proj = _ZA.clone().sub(m.clone().multiplyScalar(_ZA.dot(m))); // world-UP (Z) perpendicular to member
131
+ if (proj.lengthSq() > 1e-6) {
132
+ proj.normalize();
133
+ const ly = _YA.clone().applyQuaternion(q); // where the section depth (local Y) points
134
+ const ang = Math.atan2(ly.clone().cross(proj).dot(m), ly.dot(proj));
135
+ q.premultiply(new THREE.Quaternion().setFromAxisAngle(m, ang)); // roll so the web stands vertical (depth → world-Z)
136
+ }
137
+ mesh.quaternion.copy(q);
138
+ }
139
+ // Build the member's geometry (real cross-section) from a→b and orient + centre it.
140
+ function placeMember(mesh, a, b, el) {
141
+ const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
142
+ const dir = new THREE.Vector3().subVectors(B, A); const len = dir.length() || 1; dir.normalize();
143
+ const w = Math.max((el.section && el.section.w) || 50, 1), d = Math.max((el.section && el.section.d) || 50, 1);
144
+ mesh.geometry = memberGeom(el, w, d, len);
145
+ orientMember(mesh, dir);
146
+ mesh.position.copy(A).addScaledVector(dir, len / 2);
147
+ }
148
+
149
+ function materialFor(groupKey) {
150
+ if (!baseMat.has(groupKey)) {
151
+ const col = groupColor.get(groupKey) || new THREE.Color(0x94a3b8);
152
+ baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: 0.1, roughness: 0.75 }));
153
+ }
154
+ return baseMat.get(groupKey);
155
+ }
156
+
157
+ function clearRoot() {
158
+ for (const m of meshById.values()) {
159
+ m.geometry.dispose();
160
+ if (m.userData._selMat) m.userData._selMat.dispose(); // per-mesh selection clone (else it leaks per rebuild)
161
+ root.remove(m);
162
+ }
163
+ meshById.clear();
164
+ }
165
+
166
+ function buildFromScene(sc) {
167
+ clearRoot();
168
+ for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
169
+ groupColor.clear(); baseMat.clear();
170
+ for (const g of sc.groups || []) groupColor.set(g.key, new THREE.Color(g.color || '#94a3b8'));
171
+ const box = new THREE.Box3();
172
+ for (const el of sc.elements || []) {
173
+ const mesh = new THREE.Mesh(undefined, materialFor(el.group));
174
+ placeMember(mesh, el.from, el.to, el); // real cross-section (I / channel / angle / tube / box) from the profile
175
+ mesh.userData.id = el.id; mesh.userData.group = el.group; // group needed to restore the profile material on deselect
176
+ root.add(mesh); meshById.set(el.id, mesh);
177
+ box.expandByObject(mesh);
178
+ }
179
+ buildGrid(box);
180
+ built = true;
181
+ if (fitPending || !box.isEmpty()) { fitCamera(box); fitPending = false; }
182
+ }
183
+
184
+ // A muted reference grid on the ground plane (XY, since Z-up), sized to the model. --line at low
185
+ // opacity — no white grid (per the baseline). Rebuilt with the model so it always fits.
186
+ function buildGrid(box) {
187
+ if (grid) { scene.remove(grid); grid.geometry.dispose(); grid.material.dispose(); grid = null; }
188
+ if (box.isEmpty()) return;
189
+ const size = box.getSize(new THREE.Vector3());
190
+ const span = Math.max(size.x, size.y) * 1.6 || 5000;
191
+ grid = new THREE.GridHelper(span, 24, 0x1e293b, 0x1e293b); // --line (slate-800)
192
+ grid.material.transparent = true; grid.material.opacity = 0.35;
193
+ grid.rotation.x = Math.PI / 2; // GridHelper is XZ by default → rotate to XY for Z-up
194
+ const c = box.getCenter(new THREE.Vector3());
195
+ grid.position.set(c.x, c.y, box.min.z); // ground at the lowest member
196
+ scene.add(grid);
197
+ }
198
+
199
+ function fitCamera(box) {
200
+ if (box.isEmpty()) return;
201
+ const c = box.getCenter(new THREE.Vector3());
202
+ const dir = new THREE.Vector3(0.55, -0.8, 0.5).normalize(); // 3/4 iso: front-right-above (Z-up)
203
+ // Build the camera's right/up axes for this view direction, then size the distance so the WIDEST
204
+ // box corner (projected onto right/up) fits the frustum. Projecting corners (not a bounding sphere)
205
+ // fits a flat plan tightly — a sphere over-zooms because it ignores that the slab is thin.
206
+ const fwd = dir.clone().negate();
207
+ const up0 = Math.abs(fwd.z) > 0.95 ? V(0, 1, 0) : V(0, 0, 1);
208
+ const right = new THREE.Vector3().crossVectors(up0, fwd).normalize();
209
+ const up = new THREE.Vector3().crossVectors(fwd, right).normalize();
210
+ const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2), tanH = tanV * camera.aspect;
211
+ let need = 1000;
212
+ for (let i = 0; i < 8; i++) {
213
+ const corner = V(i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z).sub(c);
214
+ need = Math.max(need, Math.abs(corner.dot(right)) / tanH, Math.abs(corner.dot(up)) / tanV);
215
+ }
216
+ const dist = need * 1.15; // small margin
217
+ controls.target.copy(c);
218
+ camera.position.copy(c).addScaledVector(dir, dist);
219
+ const r = box.getBoundingSphere(new THREE.Sphere()).radius || dist;
220
+ camera.near = Math.max(dist / 1000, 1); camera.far = dist + r * 4; camera.updateProjectionMatrix();
221
+ controls.update();
222
+ }
223
+
224
+ async function rebuild() {
225
+ if (!api) return;
226
+ const sc = await api.fetchScene(); // editor POSTs the current contract to /scene
227
+ if (sc) buildFromScene(sc);
228
+ }
229
+
230
+ function setSelection(ids) {
231
+ const set = ids instanceof Set ? ids : new Set(ids || []);
232
+ for (const [id, mesh] of meshById) {
233
+ const on = set.has(id);
234
+ // Selected → a per-mesh clone that glows (so one selected member doesn't tint its whole profile
235
+ // group). Deselected → back to the SHARED profile material (its real colour).
236
+ mesh.material = on ? selectedMaterial(mesh) : materialFor(mesh.userData.group);
237
+ mesh.material.emissive = new THREE.Color(on ? SELECT_EMISSIVE : 0x000000);
238
+ mesh.material.emissiveIntensity = on ? 0.6 : 0;
239
+ }
240
+ }
241
+ function selectedMaterial(mesh) {
242
+ if (!mesh.userData._selMat) mesh.userData._selMat = materialFor(mesh.userData.group).clone();
243
+ return mesh.userData._selMat;
244
+ }
245
+
246
+ // Raycast the member meshes at a client (px) point → the hit member id, or null for empty space.
247
+ function pickAt(cx, cy) {
248
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true); // ensure current after any camera/mesh move
249
+ const rect = canvasEl.getBoundingClientRect();
250
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
251
+ raycaster.setFromCamera(ndc, camera);
252
+ const hits = raycaster.intersectObjects([...meshById.values()], false);
253
+ lastPick = hits.length ? hits[0].object.userData.id : null;
254
+ return lastPick;
255
+ }
256
+
257
+ // project a 3D mm point → canvas-relative screen px (for screen-space snapping + the marker)
258
+ function toScreen(p) {
259
+ const v = new THREE.Vector3(p[0], p[1], p[2]).project(camera);
260
+ const rect = canvasEl.getBoundingClientRect();
261
+ return { x: (v.x * 0.5 + 0.5) * rect.width, y: (-v.y * 0.5 + 0.5) * rect.height };
262
+ }
263
+ // raycast the cursor onto the horizontal work-plane z=planeZ → [x,y,z] mm, or null
264
+ function rayToPlane(cx, cy, planeZ) {
265
+ camera.updateMatrixWorld();
266
+ const rect = canvasEl.getBoundingClientRect();
267
+ if (!rect.width || !rect.height) return null; // collapsed/hidden canvas → no valid ray
268
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
269
+ raycaster.setFromCamera(ndc, camera);
270
+ const hit = new THREE.Vector3();
271
+ return raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), -planeZ), hit) ? [hit.x, hit.y, hit.z] : null;
272
+ }
273
+ // raycast onto a VERTICAL plane through `thru` facing the camera (its normal is the camera's
274
+ // horizontal direction) → [x,y,z]; the z is the new elevation for a vertical drag.
275
+ function rayToVerticalPlane(cx, cy, thru) {
276
+ camera.updateMatrixWorld();
277
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
278
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
279
+ raycaster.setFromCamera(ndc, camera);
280
+ const n = new THREE.Vector3(); camera.getWorldDirection(n); n.z = 0;
281
+ if (n.lengthSq() < 1e-9) n.set(1, 0, 0); n.normalize();
282
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(n, new THREE.Vector3(thru[0], thru[1], thru[2]));
283
+ const hit = new THREE.Vector3();
284
+ return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
285
+ }
286
+ const members = () => (api && api.getMembers && api.getMembers()) || [];
287
+ const markerSize = () => Math.max(camera.position.distanceTo(controls.target) * 0.012, 10);
288
+
289
+ function onDown(e) {
290
+ if (e.button !== 0) return;
291
+ downXY = [e.clientX, e.clientY];
292
+ let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
293
+ if (!id) { pending = null; return; } // empty space → OrbitControls orbits/pans
294
+ e.stopPropagation(); controls.enabled = false; // grabbed a member → suppress orbit (enabled=false is the reliable guard)
295
+ try {
296
+ const m = members().find((x) => x.id === id);
297
+ const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
298
+ const geo = m ? memberGeometry(m, ppf, dtos) : null;
299
+ const mesh = meshById.get(id);
300
+ const planeZ = geo ? (geo.line[0][2] + geo.line[1][2]) / 2 : 0;
301
+ pending = {
302
+ id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
303
+ grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
304
+ origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
305
+ candidates: snapCandidates(members(), ppf, dtos, id),
306
+ levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
307
+ mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
308
+ };
309
+ } catch (err) { controls.enabled = true; pending = null; } // a failure must never strand orbit disabled
310
+ }
311
+
312
+ // Vertical (Alt) drag: raise/lower the member's elevation, snapping to other members' T.O.S levels.
313
+ function onMoveVertical(e) {
314
+ const hit = rayToVerticalPlane(e.clientX, e.clientY, pending.grab); if (!hit) return;
315
+ const gx = pending.grab[0], gy = pending.grab[1];
316
+ const cands = pending.levels.map((z) => ({ type: 'level', p: [gx, gy, z] })); // levels as point candidates at the grab plan X/Y
317
+ const r = snapPoint([gx, gy, hit[2]], cands, toScreen, SNAP_TOL_PX);
318
+ const newZ = r.candidate ? r.snapped[2] : hit[2];
319
+ pending.dz = newZ - pending.planeZ;
320
+ if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x, pending.meshPos0.y, pending.meshPos0.z + pending.dz);
321
+ if (r.candidate) { marker.position.set(gx, gy, newZ); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
322
+ readout._dist.textContent = (newZ / FT_MM).toFixed(2) + ' ft'; // resulting T.O.S elevation
323
+ readout._type.textContent = r.candidate ? ' · level' : ' ↕';
324
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
325
+ }
326
+
327
+ function onMove(e) {
328
+ if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // 3D inactive — ignore window pointer events
329
+ if (!pending || !pending.grab || !pending.origMm) return;
330
+ if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
331
+ if (!dragging) pending.mode = e.altKey ? 'vertical' : 'plan'; // lock the mode at drag start (Alt = vertical/elevation)
332
+ dragging = pending;
333
+ if (pending.mode === 'vertical') { onMoveVertical(e); return; }
334
+ const cur = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!cur) return;
335
+ let dx = cur[0] - pending.grab[0], dy = cur[1] - pending.grab[1];
336
+ // snap the translated endpoints; take the closest winning correction
337
+ let best = Infinity, corr = null, at = null, atType = null;
338
+ for (const ep of pending.origMm) {
339
+ const tp = [ep[0] + dx, ep[1] + dy, ep[2]];
340
+ const r = snapPoint(tp, pending.candidates, toScreen, SNAP_TOL_PX);
341
+ if (!r.candidate) continue;
342
+ const a = toScreen(tp), b = toScreen(r.snapped), d = Math.hypot(b.x - a.x, b.y - a.y);
343
+ if (d < best) { best = d; corr = [r.snapped[0] - tp[0], r.snapped[1] - tp[1]]; at = r.snapped; atType = r.candidate.type; }
344
+ }
345
+ if (corr) { dx += corr[0]; dy += corr[1]; }
346
+ pending.delta = [dx, dy];
347
+ if (pending.mesh && pending.meshPos0) pending.mesh.position.set(pending.meshPos0.x + dx, pending.meshPos0.y + dy, pending.meshPos0.z);
348
+ if (at) { marker.position.set(at[0], at[1], at[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
349
+ // live dimension readout: plan distance moved (feet) + the snap type, near the cursor
350
+ readout._dist.textContent = (Math.hypot(dx, dy) / FT_MM).toFixed(2) + ' ft';
351
+ readout._type.textContent = atType ? ' · ' + atType : '';
352
+ readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
353
+ }
354
+
355
+ function onUp(e) {
356
+ if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; // always clear drag overlays, even on the early-return paths
357
+ if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; return; } // 3D inactive
358
+ const p = pending, wasDragging = dragging; pending = dragging = null;
359
+ controls.enabled = true;
360
+ const moved = downXY ? Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) : Infinity; downXY = null;
361
+ if (!p) { // empty-space gesture: a click (no move) clears the selection
362
+ if (moved <= DRAG_TOL_PX && api && api.onSelect) api.onSelect(null, false);
363
+ return;
364
+ }
365
+ if (!wasDragging) { if (api && api.onSelect) api.onSelect(p.id, p.ctrl); return; } // member click → select
366
+ if (p.mode === 'vertical') { // elevation drag → write T.O.S (inches)
367
+ if (p.dz && Math.abs(p.dz) > 1e-6) {
368
+ if (api && api.onSelect) api.onSelect(p.id, false);
369
+ if (api && api.onElevateMember) api.onElevateMember(p.id, p.dz / 25.4); // mm → inches
370
+ }
371
+ return;
372
+ }
373
+ if (!p.delta || (p.delta[0] === 0 && p.delta[1] === 0)) return; // dragged but no net move
374
+ const newWp = p.origMm.map((mm) => planPointToWp([mm[0] + p.delta[0], mm[1] + p.delta[1]], p.ppf));
375
+ if (api && api.onSelect) api.onSelect(p.id, false); // keep the moved member selected
376
+ if (api && api.onMoveMember) api.onMoveMember(p.id, newWp); // editor writes wp via edit()
377
+ }
378
+
379
+ function show() { if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); } // resume the render loop
380
+ function hide() { if (canvasEl) canvasEl.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } // stop rendering while in 2D
381
+ function isReady() { return built; }
382
+
383
+ function dispose() {
384
+ if (rafId) cancelAnimationFrame(rafId);
385
+ if (ro) ro.disconnect();
386
+ if (canvasEl) canvasEl.removeEventListener('pointerdown', onDown, true);
387
+ window.removeEventListener('pointermove', onMove);
388
+ window.removeEventListener('pointerup', onUp);
389
+ if (readout && readout.parentNode) readout.parentNode.removeChild(readout);
390
+ clearRoot();
391
+ if (renderer) renderer.dispose();
392
+ renderer = scene = camera = controls = root = api = canvasEl = ro = null; built = false;
393
+ }
394
+
395
+ function debug() {
396
+ const box = new THREE.Box3(); for (const m of meshById.values()) box.expandByObject(m);
397
+ const s = box.getSize(new THREE.Vector3()), c = box.getCenter(new THREE.Vector3());
398
+ return {
399
+ meshes: meshById.size,
400
+ triangles: renderer ? renderer.info.render.triangles : -1,
401
+ bboxEmpty: box.isEmpty(), bboxSize: [s.x, s.y, s.z], bboxCenter: [c.x, c.y, c.z],
402
+ firstId: meshById.keys().next().value ?? null,
403
+ ids: [...meshById.keys()].slice(0, 12),
404
+ geomTypes: [...meshById.values()].reduce((h, m) => { const t = m.geometry.type; h[t] = (h[t] || 0) + 1; return h; }, {}),
405
+ lastPick,
406
+ camPos: camera ? [camera.position.x, camera.position.y, camera.position.z] : null,
407
+ near: camera?.near, far: camera?.far,
408
+ };
409
+ }
410
+ // test helper: the current material colour + emissive of a member (verifies deselect restores colour)
411
+ function probe(id) {
412
+ const m = meshById.get(id); if (!m) return null;
413
+ return { color: '#' + m.material.color.getHexString(), emissive: m.material.emissiveIntensity || 0 };
414
+ }
415
+ // test helper: client (px) coords of a member's endpoint (end 0|1) — lets an E2E drive a precise drag
416
+ function screenOf(id, end) {
417
+ const m = members().find((x) => x.id === id); if (!m) return null;
418
+ const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm());
419
+ const s = toScreen(g.line[end]); const rect = canvasEl.getBoundingClientRect();
420
+ return { x: rect.left + s.x, y: rect.top + s.y };
421
+ }
422
+ window.Steel3DView = { init, show, hide, rebuild, setSelection, isReady, debug, probe, screenOf, dispose };