@floless/app 0.34.2 → 0.35.1
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 +89 -32
- package/dist/schemas/steel.takeoff.v1.schema.json +1 -0
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +6 -3
- package/dist/skills/floless-app-workflows/references/dev-server-and-run-trace.md +4 -2
- package/dist/web/app.css +24 -23
- package/dist/web/app.js +7 -3
- package/dist/web/aware.js +47 -5
- package/dist/web/index.html +22 -9
- package/dist/web/package.json +5 -0
- package/dist/web/panels.js +25 -11
- package/dist/web/steel-3d-core.js +137 -0
- package/dist/web/steel-3d-view.js +422 -0
- package/dist/web/steel-editor.html +140 -6
- package/dist/web/vendor/OrbitControls.js +1963 -0
- package/dist/web/vendor/README.md +15 -0
- package/dist/web/vendor/three.core.js +59732 -0
- package/dist/web/vendor/three.module.js +19552 -0
- package/package.json +1 -1
|
@@ -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 };
|