@floless/app 0.35.0 → 0.36.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 +14 -4
- package/dist/schemas/steel.takeoff.v1.schema.json +1 -0
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +8 -0
- 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/panels.js +25 -11
- package/dist/web/steel-3d-view.js +496 -38
- package/dist/web/steel-editor.html +90 -11
- package/package.json +1 -1
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Three.js lives ONLY here (the rest of the editor is import-free). It renders the SAME scene the
|
|
5
5
|
* IFC/Tekla bake uses (fetched from /api/contract/:id/scene → contractToScene), so the editable 3D
|
|
6
|
-
* matches what bakes out.
|
|
7
|
-
*
|
|
6
|
+
* matches what bakes out. This mode is a SUPERSET of AWARE's read-only viewer-3d: same navigation
|
|
7
|
+
* (RIGHT-drag orbit, MIDDLE-drag pan, wheel zoom — LEFT is reserved for editing/select), same chrome
|
|
8
|
+
* (Persp/Ortho, Solid/Wire/X-ray, Fit, a per-profile legend with hide/isolate, a ViewCube, a hover
|
|
9
|
+
* readout) — PLUS editing: drag a member to move it (Alt = elevation), and Trim/Extend + Split. The
|
|
10
|
+
* member's START end shows a YELLOW dot, the END a MAGENTA dot — in 3D these mark direction and show
|
|
11
|
+
* the member is grabbable (the BODY is the grab handle; single-end changes go through Trim/Extend or
|
|
12
|
+
* Split, since free endpoint-drag is depth-ambiguous). In 2D the same dots ARE the draggable handles.
|
|
8
13
|
*
|
|
9
14
|
* Exposes `window.Steel3DView` so the editor's classic script can drive it. The scene is Z-up
|
|
10
15
|
* (contractToScene emits meta.up='z'), so the camera/grid are set up Z-up.
|
|
@@ -13,8 +18,21 @@ import * as THREE from 'three';
|
|
|
13
18
|
import { OrbitControls } from 'three/addons/OrbitControls.js';
|
|
14
19
|
import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels } from './steel-3d-core.js';
|
|
15
20
|
|
|
16
|
-
let renderer, scene, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
|
|
21
|
+
let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
|
|
17
22
|
let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
|
|
23
|
+
let boxSel = null, rubber = null; // LEFT-drag-on-empty rubber-band multi-select
|
|
24
|
+
let hoverId = null, hoverChip = null, hoverRAF = 0, lastHoverXY = null; // hover readout + cursor
|
|
25
|
+
let epGroup = null; // yellow/magenta endpoint markers (start/end direction dots)
|
|
26
|
+
let epGeom = null, epMatStart = null, epMatEnd = null; // shared across all endpoint dots (no per-rebuild churn)
|
|
27
|
+
let epRing = null, epPreview = null; // white ring on the active end node + a rubber line while dragging it
|
|
28
|
+
let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged end node
|
|
29
|
+
let refGroup = null; let refLineOn = false; // optional reference (work) lines between each member's end points
|
|
30
|
+
const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
|
|
31
|
+
let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
|
|
32
|
+
let displayMode = 'solid'; // solid | wire | xray
|
|
33
|
+
const groupHidden = new Set(); // profile keys hidden via the legend
|
|
34
|
+
let soloGroup = null; // profile key isolated via the legend (dbl-click)
|
|
35
|
+
let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
|
|
18
36
|
const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
|
|
19
37
|
const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
|
|
20
38
|
const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
|
|
@@ -23,6 +41,8 @@ const meshById = new Map(); // member id -> THREE.Mesh
|
|
|
23
41
|
const groupColor = new Map(); // group key -> THREE.Color
|
|
24
42
|
const baseMat = new Map(); // group key -> MeshStandardMaterial (shared per profile)
|
|
25
43
|
const SELECT_EMISSIVE = 0x3b82f6; // --brand: selected members glow blue
|
|
44
|
+
const EP_START = 0xfacc15; // start endpoint (end 1) — yellow
|
|
45
|
+
const EP_END = 0xf472b6; // end endpoint (end 2) — magenta
|
|
26
46
|
|
|
27
47
|
function init(canvas, theApi) {
|
|
28
48
|
if (renderer) return; // once
|
|
@@ -31,14 +51,34 @@ function init(canvas, theApi) {
|
|
|
31
51
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
32
52
|
scene = new THREE.Scene();
|
|
33
53
|
scene.background = new THREE.Color(0x020817); // match the app --bg (the mode change reads from the geometry, not a tint)
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
perspCam = new THREE.PerspectiveCamera(50, 1, 1, 50_000_000);
|
|
55
|
+
perspCam.up.set(0, 0, 1); // Z-up to match the scene
|
|
56
|
+
orthoCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 50_000_000);
|
|
57
|
+
orthoCam.up.set(0, 0, 1);
|
|
58
|
+
camera = perspCam;
|
|
36
59
|
controls = new OrbitControls(camera, canvas);
|
|
37
60
|
controls.enableDamping = true;
|
|
61
|
+
// CAD-style mouse map (matches AWARE viewer-3d): LEFT is OURS (pick / drag / box-select — NOT
|
|
62
|
+
// orbit), RIGHT-drag orbits, MIDDLE-drag pans, wheel zooms. This removes the old ambiguity where
|
|
63
|
+
// a left-drag could mean either "orbit" or "move a member" — the source of "how do I drag in 3D?".
|
|
64
|
+
controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE };
|
|
65
|
+
controls.zoomToCursor = true; // Tekla-style: the mouse position is the centre of zooming
|
|
38
66
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
|
39
67
|
const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(0.4, -1, 1.2); scene.add(key);
|
|
40
68
|
const fill = new THREE.DirectionalLight(0xffffff, 0.25); fill.position.set(-1, 0.6, 0.4); scene.add(fill);
|
|
41
69
|
root = new THREE.Group(); scene.add(root);
|
|
70
|
+
epGroup = new THREE.Group(); scene.add(epGroup);
|
|
71
|
+
// ONE geometry + two materials shared by every endpoint dot (rebuilt often on hover/selection —
|
|
72
|
+
// sharing avoids allocating/leaking a geometry+material per dot each rebuild).
|
|
73
|
+
epGeom = new THREE.SphereGeometry(1, 16, 12);
|
|
74
|
+
epMatStart = new THREE.MeshBasicMaterial({ color: EP_START }); epMatStart.depthTest = false;
|
|
75
|
+
epMatEnd = new THREE.MeshBasicMaterial({ color: EP_END }); epMatEnd.depthTest = false;
|
|
76
|
+
// white ring drawn on the hovered/dragged end node so it's visibly "selected" (billboarded each frame)
|
|
77
|
+
epRing = new THREE.Mesh(new THREE.RingGeometry(0.78, 1, 28), new THREE.MeshBasicMaterial({ color: 0xf8fafc, side: THREE.DoubleSide }));
|
|
78
|
+
epRing.material.depthTest = false; epRing.renderOrder = 1000; epRing.visible = false; scene.add(epRing);
|
|
79
|
+
// rubber preview line from the fixed end to the dragged end node
|
|
80
|
+
epPreview = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x3b82f6 }));
|
|
81
|
+
epPreview.material.depthTest = false; epPreview.renderOrder = 997; epPreview.visible = false; scene.add(epPreview);
|
|
42
82
|
raycaster = new THREE.Raycaster();
|
|
43
83
|
// a --brand snap marker (one sphere at the winning snap target; the readout carries the type)
|
|
44
84
|
marker = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
|
|
@@ -51,13 +91,25 @@ function init(canvas, theApi) {
|
|
|
51
91
|
readout._type = document.createElement('span'); readout._type.style.color = 'var(--mut,#94a3b8)';
|
|
52
92
|
readout.append(readout._dist, readout._type);
|
|
53
93
|
document.body.appendChild(readout);
|
|
94
|
+
// persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
|
|
95
|
+
hoverChip = document.createElement('div');
|
|
96
|
+
hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
|
|
97
|
+
document.body.appendChild(hoverChip);
|
|
98
|
+
// rubber-band overlay for LEFT-drag box-select on empty space
|
|
99
|
+
rubber = document.createElement('div');
|
|
100
|
+
rubber.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:58;border:1px solid var(--brand,#3b82f6);background:rgba(59,130,246,.12)';
|
|
101
|
+
document.body.appendChild(rubber);
|
|
54
102
|
// 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 →
|
|
56
|
-
//
|
|
103
|
+
// commit the move, or (no move) select. Down on empty space → rubber-band box-select. RIGHT/MIDDLE
|
|
104
|
+
// fall through to OrbitControls (orbit/pan). Capture phase so we own the LEFT button first.
|
|
57
105
|
canvas.addEventListener('pointerdown', onDown, true);
|
|
58
106
|
window.addEventListener('pointermove', onMove); // window so a drag keeps tracking off-canvas
|
|
59
107
|
window.addEventListener('pointerup', onUp);
|
|
108
|
+
canvas.addEventListener('pointermove', onHoverMove); // canvas-only: cursor + hover readout when idle
|
|
109
|
+
canvas.addEventListener('dblclick', onDblClick); // Tekla "set view point": dbl-click sets the orbit/zoom pivot
|
|
110
|
+
window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
|
|
60
111
|
ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
|
|
112
|
+
initCube();
|
|
61
113
|
// Resize once more on the next frame: the stage may still be laying out when init() runs (the
|
|
62
114
|
// designer flagged a first-render size mismatch as the common rough edge here).
|
|
63
115
|
requestAnimationFrame(resize);
|
|
@@ -70,12 +122,16 @@ function resize() {
|
|
|
70
122
|
const el = canvasEl.parentElement || canvasEl;
|
|
71
123
|
const w = el.clientWidth || 1, h = el.clientHeight || 1;
|
|
72
124
|
renderer.setSize(w, h, false);
|
|
73
|
-
|
|
125
|
+
perspCam.aspect = w / h; perspCam.updateProjectionMatrix();
|
|
126
|
+
if (camera === orthoCam) reframeOrtho();
|
|
74
127
|
}
|
|
75
128
|
|
|
76
129
|
function loop() {
|
|
77
130
|
rafId = requestAnimationFrame(loop);
|
|
78
|
-
controls.update();
|
|
131
|
+
controls.update();
|
|
132
|
+
sizeEndpoints();
|
|
133
|
+
renderer.render(scene, camera);
|
|
134
|
+
if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
const V = (x, y, z) => new THREE.Vector3(x, y, z);
|
|
@@ -167,19 +223,24 @@ function buildFromScene(sc) {
|
|
|
167
223
|
clearRoot();
|
|
168
224
|
for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
|
|
169
225
|
groupColor.clear(); baseMat.clear();
|
|
170
|
-
|
|
226
|
+
sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
|
|
227
|
+
for (const g of sceneGroups) groupColor.set(g.key, new THREE.Color(g.color));
|
|
171
228
|
const box = new THREE.Box3();
|
|
172
229
|
for (const el of sc.elements || []) {
|
|
173
230
|
const mesh = new THREE.Mesh(undefined, materialFor(el.group));
|
|
174
231
|
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;
|
|
232
|
+
mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
|
|
176
233
|
root.add(mesh); meshById.set(el.id, mesh);
|
|
177
234
|
box.expandByObject(mesh);
|
|
178
235
|
}
|
|
236
|
+
sceneBox = box.clone();
|
|
179
237
|
buildGrid(box);
|
|
238
|
+
buildRefLines();
|
|
239
|
+
applyGroupVisibility(); applyDisplayMode();
|
|
180
240
|
built = true;
|
|
181
|
-
if (fitPending
|
|
241
|
+
if (fitPending) { fitCamera(box); fitPending = false; } // fit only on entering 3D (rebuild(true)) — NOT on every edit
|
|
182
242
|
}
|
|
243
|
+
let sceneGroups = [];
|
|
183
244
|
|
|
184
245
|
// A muted reference grid on the ground plane (XY, since Z-up), sized to the model. --line at low
|
|
185
246
|
// opacity — no white grid (per the baseline). Rebuilt with the model so it always fits.
|
|
@@ -196,37 +257,171 @@ function buildGrid(box) {
|
|
|
196
257
|
scene.add(grid);
|
|
197
258
|
}
|
|
198
259
|
|
|
199
|
-
|
|
260
|
+
// Optional reference lines: the work-line (between the two end dots) of every member — the centreline
|
|
261
|
+
// downstream tools (Tekla/Revit/IFC) place steel against. Toggled from the toolbar; rebuilt with the model.
|
|
262
|
+
function buildRefLines() {
|
|
263
|
+
if (refGroup) { scene.remove(refGroup); refGroup.geometry.dispose(); refGroup.material.dispose(); refGroup = null; }
|
|
264
|
+
if (!refLineOn || !api) return;
|
|
265
|
+
const pts = [];
|
|
266
|
+
for (const m of members()) {
|
|
267
|
+
if (!m || !Array.isArray(m.wp) || m.wp.length < 2) continue;
|
|
268
|
+
const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm());
|
|
269
|
+
pts.push(new THREE.Vector3(g.line[0][0], g.line[0][1], g.line[0][2]), new THREE.Vector3(g.line[1][0], g.line[1][1], g.line[1][2]));
|
|
270
|
+
}
|
|
271
|
+
if (!pts.length) return;
|
|
272
|
+
refGroup = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x93c5fd }));
|
|
273
|
+
refGroup.renderOrder = 996; scene.add(refGroup);
|
|
274
|
+
}
|
|
275
|
+
function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
|
|
276
|
+
|
|
277
|
+
// The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
|
|
278
|
+
// to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
|
|
279
|
+
// model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
|
|
280
|
+
let orthoBaseH = 0;
|
|
281
|
+
function reframeOrtho() {
|
|
282
|
+
const aspect = perspCam.aspect || 1;
|
|
283
|
+
const h = orthoBaseH > 0 ? orthoBaseH : Math.tan(THREE.MathUtils.degToRad(perspCam.fov) * 0.5) * (camera.position.distanceTo(controls.target) || 1);
|
|
284
|
+
orthoCam.left = -h * aspect; orthoCam.right = h * aspect; orthoCam.top = h; orthoCam.bottom = -h;
|
|
285
|
+
orthoCam.updateProjectionMatrix();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function fitCamera(box, dir) {
|
|
200
289
|
if (box.isEmpty()) return;
|
|
201
290
|
const c = box.getCenter(new THREE.Vector3());
|
|
202
|
-
const
|
|
291
|
+
const d = dir ? dir.clone().normalize() : new THREE.Vector3(0.55, -0.8, 0.5).normalize(); // default 3/4 iso
|
|
203
292
|
// Build the camera's right/up axes for this view direction, then size the distance so the WIDEST
|
|
204
293
|
// box corner (projected onto right/up) fits the frustum. Projecting corners (not a bounding sphere)
|
|
205
294
|
// fits a flat plan tightly — a sphere over-zooms because it ignores that the slab is thin.
|
|
206
|
-
const fwd =
|
|
295
|
+
const fwd = d.clone().negate();
|
|
207
296
|
const up0 = Math.abs(fwd.z) > 0.95 ? V(0, 1, 0) : V(0, 0, 1);
|
|
208
297
|
const right = new THREE.Vector3().crossVectors(up0, fwd).normalize();
|
|
209
298
|
const up = new THREE.Vector3().crossVectors(fwd, right).normalize();
|
|
210
|
-
const
|
|
211
|
-
|
|
299
|
+
const aspect = perspCam.aspect || 1;
|
|
300
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2), tanH = tanV * aspect;
|
|
301
|
+
let need = 1000, extR = 1, extU = 1;
|
|
212
302
|
for (let i = 0; i < 8; i++) {
|
|
213
303
|
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
|
-
|
|
304
|
+
const cr = Math.abs(corner.dot(right)), cu = Math.abs(corner.dot(up)), cd = corner.dot(d); // cd = depth toward the camera
|
|
305
|
+
extR = Math.max(extR, cr); extU = Math.max(extU, cu);
|
|
306
|
+
// include the corner's depth: a corner NEARER the camera projects larger, so it needs MORE distance
|
|
307
|
+
// (omitting cd under-fits the near corners → they clip just outside the frustum).
|
|
308
|
+
need = Math.max(need, cd + Math.max(cr / tanH, cu / tanV));
|
|
215
309
|
}
|
|
216
310
|
const dist = need * 1.15; // small margin
|
|
217
311
|
controls.target.copy(c);
|
|
218
|
-
|
|
312
|
+
perspCam.position.copy(c).addScaledVector(d, dist);
|
|
313
|
+
orthoCam.position.copy(c).addScaledVector(d, dist);
|
|
219
314
|
const r = box.getBoundingSphere(new THREE.Sphere()).radius || dist;
|
|
220
|
-
|
|
315
|
+
const near = Math.max(dist / 1000, 1), far = dist + r * 4;
|
|
316
|
+
perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
|
|
317
|
+
orthoCam.near = near; orthoCam.far = far; orthoCam.zoom = 1;
|
|
318
|
+
orthoBaseH = Math.max(extU, extR / aspect) * 1.15; // tight, aspect-preserving box fit (no ortho under-frame)
|
|
319
|
+
if (camera === orthoCam) reframeOrtho();
|
|
221
320
|
controls.update();
|
|
222
321
|
}
|
|
223
322
|
|
|
224
|
-
|
|
323
|
+
let rebuildSeq = 0;
|
|
324
|
+
async function rebuild(fit = false) {
|
|
225
325
|
if (!api) return;
|
|
326
|
+
if (fit) fitPending = true; // entering 3D re-fits; an edit-triggered rebuild keeps the camera put
|
|
327
|
+
const seq = ++rebuildSeq;
|
|
226
328
|
const sc = await api.fetchScene(); // editor POSTs the current contract to /scene
|
|
329
|
+
if (seq !== rebuildSeq) return; // a newer rebuild superseded this one → drop the stale scene (no out-of-order apply)
|
|
227
330
|
if (sc) buildFromScene(sc);
|
|
228
331
|
}
|
|
229
332
|
|
|
333
|
+
// ---- Persp/Ortho + Solid/Wire/X-ray + group visibility (parity with the AWARE viewer) ----
|
|
334
|
+
function setProjection(mode) {
|
|
335
|
+
const want = mode === 'ortho' ? orthoCam : perspCam;
|
|
336
|
+
if (want === camera) return;
|
|
337
|
+
const tgt = controls.target.clone(), pos = camera.position.clone();
|
|
338
|
+
camera = want; controls.object = camera;
|
|
339
|
+
camera.position.copy(pos); camera.lookAt(tgt);
|
|
340
|
+
if (camera === orthoCam) {
|
|
341
|
+
// match the perspective view on toggle (preserve the user's current zoom) — Fit re-tightens to the box
|
|
342
|
+
orthoBaseH = Math.tan(THREE.MathUtils.degToRad(perspCam.fov) * 0.5) * (pos.distanceTo(tgt) || 1);
|
|
343
|
+
orthoCam.zoom = 1; reframeOrtho();
|
|
344
|
+
} else camera.updateProjectionMatrix();
|
|
345
|
+
controls.update();
|
|
346
|
+
}
|
|
347
|
+
function projection() { return camera === orthoCam ? 'ortho' : 'persp'; }
|
|
348
|
+
function setDisplayMode(m) { displayMode = m; applyDisplayMode(); }
|
|
349
|
+
// solid → opaque; wire → wireframe; xray → translucent, no depth write. Applied to whatever material
|
|
350
|
+
// each mesh currently carries (shared profile material OR its selection clone).
|
|
351
|
+
function applyDisplayMode() {
|
|
352
|
+
for (const mesh of meshById.values()) {
|
|
353
|
+
const mat = mesh.material; if (!mat) continue;
|
|
354
|
+
if (displayMode === 'wire') { mat.wireframe = true; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
|
|
355
|
+
else if (displayMode === 'xray') { mat.wireframe = false; mat.transparent = true; mat.opacity = 0.25; mat.depthWrite = false; }
|
|
356
|
+
else { mat.wireframe = false; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
|
|
357
|
+
mat.needsUpdate = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function applyGroupVisibility() {
|
|
361
|
+
for (const m of meshById.values()) {
|
|
362
|
+
const k = m.userData && m.userData.group;
|
|
363
|
+
m.visible = !groupHidden.has(k) && (soloGroup === null || soloGroup === k);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function toggleGroup(k) { if (groupHidden.has(k)) groupHidden.delete(k); else groupHidden.add(k); soloGroup = null; applyGroupVisibility(); rebuildEndpoints(); }
|
|
367
|
+
function soloToggle(k) { soloGroup = soloGroup === k ? null : k; if (soloGroup) groupHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); }
|
|
368
|
+
function showAllGroups() { groupHidden.clear(); soloGroup = null; applyGroupVisibility(); rebuildEndpoints(); }
|
|
369
|
+
function groupState() { return { hidden: [...groupHidden], solo: soloGroup }; }
|
|
370
|
+
function getGroups() { return sceneGroups.map((g) => ({ ...g })); }
|
|
371
|
+
function frameAll() { fitCamera(sceneBox); }
|
|
372
|
+
const VIEWS = { top: [0, 0, 1], bottom: [0, 0, -1], front: [0, -1, 0], back: [0, 1, 0], right: [1, 0, 0], left: [-1, 0, 0], iso: [0.55, -0.8, 0.5] };
|
|
373
|
+
function applyView(name) { const d = VIEWS[name]; if (d) fitCamera(sceneBox, new THREE.Vector3(d[0], d[1], d[2])); }
|
|
374
|
+
// Fit just the selection (Tekla "Zoom selected" / Shift+Space); falls back to fit-all when nothing's picked.
|
|
375
|
+
function frameSelection() {
|
|
376
|
+
if (!selIds.size) { fitCamera(sceneBox); return; }
|
|
377
|
+
const box = new THREE.Box3();
|
|
378
|
+
for (const id of selIds) { const m = meshById.get(id); if (m && m.visible) box.expandByObject(m); }
|
|
379
|
+
if (!box.isEmpty()) fitCamera(box);
|
|
380
|
+
}
|
|
381
|
+
// ---- Tekla-style keyboard nav: arrows pan, Ctrl+arrows rotate 15°, Shift+arrows rotate 5° ----
|
|
382
|
+
const _UP = new THREE.Vector3(0, 0, 1);
|
|
383
|
+
function rotateView(dAz, dPolar) { // dAz = around world up (Z); dPolar = tilt around the camera-right axis
|
|
384
|
+
camera.updateMatrix();
|
|
385
|
+
const off = camera.position.clone().sub(controls.target);
|
|
386
|
+
off.applyAxisAngle(_UP, dAz);
|
|
387
|
+
const right = new THREE.Vector3().crossVectors(off, _UP);
|
|
388
|
+
if (right.lengthSq() < 1e-9) right.setFromMatrixColumn(camera.matrix, 0); // looking straight down/up → use the camera's screen-right so tilt still works
|
|
389
|
+
right.normalize();
|
|
390
|
+
const tilted = off.clone().applyAxisAngle(right, dPolar);
|
|
391
|
+
if (Math.abs(tilted.clone().normalize().dot(_UP)) < 0.999) off.copy(tilted); // don't flip over the pole
|
|
392
|
+
camera.position.copy(controls.target).add(off); camera.lookAt(controls.target); controls.update();
|
|
393
|
+
}
|
|
394
|
+
function panView(dx, dy) { // dx/dy in "steps" along the camera's screen right/up
|
|
395
|
+
// step ∝ the on-screen world height: distance for perspective, frustum-height/zoom for ortho.
|
|
396
|
+
const step = camera.isOrthographicCamera
|
|
397
|
+
? ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) * 0.06
|
|
398
|
+
: camera.position.distanceTo(controls.target) * 0.06;
|
|
399
|
+
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
|
|
400
|
+
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
|
|
401
|
+
const move = right.multiplyScalar(dx * step).add(up.multiplyScalar(dy * step));
|
|
402
|
+
camera.position.add(move); controls.target.add(move); controls.update();
|
|
403
|
+
}
|
|
404
|
+
const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
|
|
405
|
+
function onKey(e) {
|
|
406
|
+
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
|
|
407
|
+
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
408
|
+
if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
|
|
409
|
+
const a = ARROWS[e.key]; if (!a) return;
|
|
410
|
+
const [hx, hy] = a;
|
|
411
|
+
if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
|
|
412
|
+
else if (e.shiftKey) { e.preventDefault(); rotateView(-hx * Math.PI / 36, hy * Math.PI / 36); } // 5°
|
|
413
|
+
else { e.preventDefault(); panView(hx, hy); } // pan
|
|
414
|
+
}
|
|
415
|
+
// Tekla "set view point": double-click a member → make that point the orbit/zoom pivot.
|
|
416
|
+
function onDblClick(e) {
|
|
417
|
+
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
418
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
419
|
+
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
420
|
+
raycaster.setFromCamera(ndc, camera);
|
|
421
|
+
const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
|
|
422
|
+
if (hits.length) { controls.target.copy(hits[0].point); controls.update(); }
|
|
423
|
+
}
|
|
424
|
+
|
|
230
425
|
function setSelection(ids) {
|
|
231
426
|
const set = ids instanceof Set ? ids : new Set(ids || []);
|
|
232
427
|
for (const [id, mesh] of meshById) {
|
|
@@ -237,22 +432,122 @@ function setSelection(ids) {
|
|
|
237
432
|
mesh.material.emissive = new THREE.Color(on ? SELECT_EMISSIVE : 0x000000);
|
|
238
433
|
mesh.material.emissiveIntensity = on ? 0.6 : 0;
|
|
239
434
|
}
|
|
435
|
+
applyDisplayMode(); // selection swapped the materials → re-apply wire/xray
|
|
436
|
+
selIds = new Set(set);
|
|
437
|
+
rebuildEndpoints(); // endpoint dots follow the selection (+ any hover)
|
|
438
|
+
updateStatusChip();
|
|
240
439
|
}
|
|
440
|
+
let selIds = new Set();
|
|
241
441
|
function selectedMaterial(mesh) {
|
|
242
442
|
if (!mesh.userData._selMat) mesh.userData._selMat = materialFor(mesh.userData.group).clone();
|
|
243
443
|
return mesh.userData._selMat;
|
|
244
444
|
}
|
|
245
445
|
|
|
446
|
+
// ---- yellow (start) / magenta (end) endpoint markers — also the drag handles ----
|
|
447
|
+
// Shown for every selected member, plus the hovered member. The dot at the member's `from` end
|
|
448
|
+
// (start / end 1) is yellow; the `to` end (end 2 / column top) is magenta. Sizes held ~constant on
|
|
449
|
+
// screen in the render loop (sizeEndpoints).
|
|
450
|
+
function rebuildEndpoints() {
|
|
451
|
+
if (!epGroup) return;
|
|
452
|
+
for (const c of [...epGroup.children]) epGroup.remove(c); // shared geom/material → nothing to dispose
|
|
453
|
+
const show = new Set(selIds); if (hoverId) show.add(hoverId);
|
|
454
|
+
for (const id of show) {
|
|
455
|
+
const mesh = meshById.get(id); if (mesh && !mesh.visible) continue; // a legend-hidden member shows no dots
|
|
456
|
+
const m = members().find((x) => x.id === id); if (!m) continue;
|
|
457
|
+
const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm());
|
|
458
|
+
addEndpoint(g.line[0], epMatStart, id, 0); addEndpoint(g.line[1], epMatEnd, id, 1);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function addEndpoint(p, mat, id, end) {
|
|
462
|
+
const s = new THREE.Mesh(epGeom, mat);
|
|
463
|
+
s.position.set(p[0], p[1], p[2]); s.renderOrder = 998; s.userData = { epId: id, epEnd: end }; epGroup.add(s);
|
|
464
|
+
}
|
|
465
|
+
function sizeEndpoints() {
|
|
466
|
+
if (!epGroup) return;
|
|
467
|
+
const r = pxToWorld(EP_PX); // small + screen-constant (shrinks as you zoom in, persp AND ortho)
|
|
468
|
+
const active = dragEp || hoverEp;
|
|
469
|
+
for (const c of epGroup.children) {
|
|
470
|
+
const isActive = active && c.userData.epId === active.id && c.userData.epEnd === active.end;
|
|
471
|
+
c.scale.setScalar(isActive ? r * 1.6 : r); // the targeted end node pops
|
|
472
|
+
}
|
|
473
|
+
// white ring on the active end node so the user sees which start/end is selected
|
|
474
|
+
if (epRing) {
|
|
475
|
+
let dot = null;
|
|
476
|
+
if (active) dot = epGroup.children.find((c) => c.userData.epId === active.id && c.userData.epEnd === active.end);
|
|
477
|
+
if (dot) { epRing.position.copy(dot.position); epRing.quaternion.copy(camera.quaternion); epRing.scale.setScalar(r * 2.4); epRing.visible = true; }
|
|
478
|
+
else epRing.visible = false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- ViewCube (parity with AWARE viewer-3d) — a small labelled cube; a face click snaps the view ----
|
|
483
|
+
const CUBE_FACES = [
|
|
484
|
+
{ label: 'RIGHT', view: 'right' }, { label: 'LEFT', view: 'left' }, // +X, -X
|
|
485
|
+
{ label: 'BACK', view: 'back' }, { label: 'FRONT', view: 'front' }, // +Y, -Y
|
|
486
|
+
{ label: 'TOP', view: 'top' }, { label: 'BOTTOM', view: 'bottom' }, // +Z, -Z
|
|
487
|
+
];
|
|
488
|
+
function faceTexture(label) {
|
|
489
|
+
const c = document.createElement('canvas'); c.width = c.height = 128; const g = c.getContext('2d');
|
|
490
|
+
g.fillStyle = '#1e293b'; g.fillRect(0, 0, 128, 128); g.strokeStyle = '#475569'; g.lineWidth = 6; g.strokeRect(4, 4, 120, 120);
|
|
491
|
+
g.fillStyle = '#e2e8f0'; g.font = 'bold 20px ui-sans-serif,system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
492
|
+
g.fillText(label, 64, 64); return new THREE.CanvasTexture(c);
|
|
493
|
+
}
|
|
494
|
+
function initCube() {
|
|
495
|
+
const host = document.getElementById('m3dCube'); if (!host) return;
|
|
496
|
+
const PX = 84;
|
|
497
|
+
const cr = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
498
|
+
cr.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); cr.setSize(PX, PX);
|
|
499
|
+
host.appendChild(cr.domElement);
|
|
500
|
+
const cs = new THREE.Scene();
|
|
501
|
+
const cc = new THREE.PerspectiveCamera(40, 1, 0.1, 20); cc.position.set(0, 0, 5); cc.up.set(0, 0, 1);
|
|
502
|
+
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1.9, 1.9, 1.9), CUBE_FACES.map((f) => new THREE.MeshBasicMaterial({ map: faceTexture(f.label) })));
|
|
503
|
+
mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry), new THREE.LineBasicMaterial({ color: 0x64748b })));
|
|
504
|
+
cs.add(mesh);
|
|
505
|
+
const ray = new THREE.Raycaster(), mouse = new THREE.Vector2();
|
|
506
|
+
cr.domElement.addEventListener('pointerdown', (e) => {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
const r = cr.domElement.getBoundingClientRect();
|
|
509
|
+
mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1; mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1;
|
|
510
|
+
ray.setFromCamera(mouse, cc);
|
|
511
|
+
const hit = ray.intersectObject(mesh, false)[0]; if (!hit || !hit.face) return;
|
|
512
|
+
const p = mesh.worldToLocal(hit.point.clone()), snap = (x) => Math.abs(x) > 0.6 ? Math.sign(x) : 0;
|
|
513
|
+
const dv = new THREE.Vector3(snap(p.x), snap(p.y), snap(p.z));
|
|
514
|
+
if (Math.abs(dv.x) + Math.abs(dv.y) + Math.abs(dv.z) <= 1) { const f = CUBE_FACES[hit.face.materialIndex]; if (f) applyView(f.view); } // a face → ortho view
|
|
515
|
+
else fitCamera(sceneBox, dv); // an edge/corner → iso-style view from that world direction
|
|
516
|
+
});
|
|
517
|
+
cube = { renderer: cr, scene: cs, cam: cc, mesh };
|
|
518
|
+
}
|
|
519
|
+
// Mirror the scene from the main camera's direction (the FRONT face turns toward the viewer in a
|
|
520
|
+
// front view, etc.) by orienting the cube by the inverse of the camera's world rotation.
|
|
521
|
+
function syncCube() { cube.mesh.quaternion.copy(camera.quaternion).invert(); }
|
|
522
|
+
|
|
246
523
|
// Raycast the member meshes at a client (px) point → the hit member id, or null for empty space.
|
|
524
|
+
// Only VISIBLE meshes (a legend-hidden group must not swallow the pick for what's behind it).
|
|
247
525
|
function pickAt(cx, cy) {
|
|
248
526
|
camera.updateMatrixWorld(); root.updateMatrixWorld(true); // ensure current after any camera/mesh move
|
|
249
527
|
const rect = canvasEl.getBoundingClientRect();
|
|
250
528
|
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
251
529
|
raycaster.setFromCamera(ndc, camera);
|
|
252
|
-
const hits = raycaster.intersectObjects([...meshById.values()], false);
|
|
530
|
+
const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
|
|
253
531
|
lastPick = hits.length ? hits[0].object.userData.id : null;
|
|
254
532
|
return lastPick;
|
|
255
533
|
}
|
|
534
|
+
// The (currently shown) end-node dot nearest the cursor within a screen tolerance → { id, end } or
|
|
535
|
+
// null. Screen-space (not a raycast) so the small dots are easy to grab at any zoom. Dots win over
|
|
536
|
+
// the member body, letting you grab one end to stretch it.
|
|
537
|
+
function pickEndpoint(cx, cy) {
|
|
538
|
+
if (!epGroup || !epGroup.children.length) return null;
|
|
539
|
+
camera.updateMatrixWorld();
|
|
540
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
541
|
+
const px = cx - rect.left, py = cy - rect.top;
|
|
542
|
+
let best = null, bestD = 14; // px grab tolerance
|
|
543
|
+
for (const c of epGroup.children) {
|
|
544
|
+
const v = c.position.clone().project(camera); if (v.z > 1) continue;
|
|
545
|
+
const sx = (v.x * 0.5 + 0.5) * rect.width, sy = (-v.y * 0.5 + 0.5) * rect.height;
|
|
546
|
+
const d = Math.hypot(sx - px, sy - py);
|
|
547
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
548
|
+
}
|
|
549
|
+
return best ? { id: best.userData.epId, end: best.userData.epEnd } : null;
|
|
550
|
+
}
|
|
256
551
|
|
|
257
552
|
// project a 3D mm point → canvas-relative screen px (for screen-space snapping + the marker)
|
|
258
553
|
function toScreen(p) {
|
|
@@ -284,13 +579,27 @@ function rayToVerticalPlane(cx, cy, thru) {
|
|
|
284
579
|
return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
|
|
285
580
|
}
|
|
286
581
|
const members = () => (api && api.getMembers && api.getMembers()) || [];
|
|
287
|
-
const
|
|
582
|
+
const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
|
|
583
|
+
// world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
|
|
584
|
+
// BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
|
|
585
|
+
function worldPerPixel() {
|
|
586
|
+
const h = (canvasEl && canvasEl.clientHeight) || 1;
|
|
587
|
+
if (camera === orthoCam) return ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) / h;
|
|
588
|
+
return (2 * Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2) * camera.position.distanceTo(controls.target)) / h;
|
|
589
|
+
}
|
|
590
|
+
const pxToWorld = (px) => px * worldPerPixel();
|
|
591
|
+
const markerSize = () => pxToWorld(8); // snap marker ~8px radius
|
|
288
592
|
|
|
289
593
|
function onDown(e) {
|
|
290
|
-
if (e.button !== 0) return;
|
|
594
|
+
if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
|
|
291
595
|
downXY = [e.clientX, e.clientY];
|
|
292
596
|
let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
|
|
293
|
-
|
|
597
|
+
// Geometry-edit mode (Trim/Extend or Split armed in the inspector): a click does the edit, no drag.
|
|
598
|
+
if (geoMode() && selIds.size === 1) { e.stopPropagation(); pending = { geo: geoMode(), selId: [...selIds][0], targetId: id }; return; }
|
|
599
|
+
// An end node (the yellow/magenta dot) under the cursor → grab just that endpoint to stretch the member.
|
|
600
|
+
let ep = null; try { ep = pickEndpoint(e.clientX, e.clientY); } catch { ep = null; }
|
|
601
|
+
if (ep) { e.stopPropagation(); controls.enabled = false; startEndpointGrab(ep, e); return; }
|
|
602
|
+
if (!id) { pending = null; boxSel = { x: e.clientX, y: e.clientY }; return; } // empty space → box-select on drag
|
|
294
603
|
e.stopPropagation(); controls.enabled = false; // grabbed a member → suppress orbit (enabled=false is the reliable guard)
|
|
295
604
|
try {
|
|
296
605
|
const m = members().find((x) => x.id === id);
|
|
@@ -324,9 +633,45 @@ function onMoveVertical(e) {
|
|
|
324
633
|
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
325
634
|
}
|
|
326
635
|
|
|
636
|
+
// Grab a single end node: drag it on the member's work-plane (its TOS), snapping to other members.
|
|
637
|
+
function startEndpointGrab(ep, e) {
|
|
638
|
+
try {
|
|
639
|
+
const m = members().find((x) => x.id === ep.id); if (!m) { controls.enabled = true; return; }
|
|
640
|
+
const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
|
|
641
|
+
const g = memberGeometry(m, ppf, dtos);
|
|
642
|
+
pending = {
|
|
643
|
+
epDrag: true, id: ep.id, end: ep.end, ppf,
|
|
644
|
+
planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
|
|
645
|
+
candidates: snapCandidates(members(), ppf, dtos, ep.id),
|
|
646
|
+
newPt: [g.line[ep.end][0], g.line[ep.end][1]],
|
|
647
|
+
};
|
|
648
|
+
dragEp = { id: ep.id, end: ep.end };
|
|
649
|
+
} catch (err) { controls.enabled = true; pending = null; dragEp = null; }
|
|
650
|
+
}
|
|
651
|
+
function onMoveEndpoint(e) {
|
|
652
|
+
if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
|
|
653
|
+
dragging = pending;
|
|
654
|
+
const hit = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!hit) return;
|
|
655
|
+
const r = snapPoint([hit[0], hit[1], pending.planeZ], pending.candidates, toScreen, SNAP_TOL_PX);
|
|
656
|
+
const np = r.candidate ? r.snapped : [hit[0], hit[1], pending.planeZ];
|
|
657
|
+
pending.newPt = [np[0], np[1]];
|
|
658
|
+
const dot = epGroup.children.find((c) => c.userData.epId === pending.id && c.userData.epEnd === pending.end);
|
|
659
|
+
if (dot) dot.position.set(np[0], np[1], pending.planeZ);
|
|
660
|
+
const f = pending.fixed;
|
|
661
|
+
epPreview.geometry.setFromPoints([new THREE.Vector3(f[0], f[1], f[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
|
|
662
|
+
epPreview.visible = true;
|
|
663
|
+
if (r.candidate) { marker.position.set(np[0], np[1], np[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
|
|
664
|
+
const len = Math.hypot(np[0] - f[0], np[1] - f[1]) / FT_MM;
|
|
665
|
+
readout._dist.textContent = len.toFixed(2) + ' ft'; readout._type.textContent = r.candidate ? ' · ' + r.candidate.type : '';
|
|
666
|
+
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
667
|
+
}
|
|
668
|
+
|
|
327
669
|
function onMove(e) {
|
|
328
670
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // 3D inactive — ignore window pointer events
|
|
329
|
-
if (
|
|
671
|
+
if (boxSel) { onBoxMove(e); return; }
|
|
672
|
+
if (!pending || pending.geo) return;
|
|
673
|
+
if (pending.epDrag) { onMoveEndpoint(e); return; }
|
|
674
|
+
if (!pending.grab || !pending.origMm) return;
|
|
330
675
|
if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
|
|
331
676
|
if (!dragging) pending.mode = e.altKey ? 'vertical' : 'plan'; // lock the mode at drag start (Alt = vertical/elevation)
|
|
332
677
|
dragging = pending;
|
|
@@ -352,14 +697,61 @@ function onMove(e) {
|
|
|
352
697
|
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
353
698
|
}
|
|
354
699
|
|
|
700
|
+
function onBoxMove(e) {
|
|
701
|
+
if (Math.hypot(e.clientX - boxSel.x, e.clientY - boxSel.y) < DRAG_TOL_PX) { rubber.style.display = 'none'; return; }
|
|
702
|
+
boxSel.moved = true;
|
|
703
|
+
const x = Math.min(e.clientX, boxSel.x), y = Math.min(e.clientY, boxSel.y);
|
|
704
|
+
rubber.style.left = x + 'px'; rubber.style.top = y + 'px';
|
|
705
|
+
rubber.style.width = Math.abs(e.clientX - boxSel.x) + 'px'; rubber.style.height = Math.abs(e.clientY - boxSel.y) + 'px';
|
|
706
|
+
rubber.style.display = 'block';
|
|
707
|
+
}
|
|
708
|
+
// every VISIBLE member whose centre projects inside the drag rectangle (centre-point hit-test)
|
|
709
|
+
function membersInRect(x0, y0, x1, y1) {
|
|
710
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
711
|
+
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
|
712
|
+
const out = [];
|
|
713
|
+
for (const [id, m] of meshById) {
|
|
714
|
+
if (!m.visible) continue;
|
|
715
|
+
const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
|
|
716
|
+
const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
|
|
717
|
+
if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(id);
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
355
722
|
function onUp(e) {
|
|
356
|
-
if (marker) marker.visible = false; if (readout) readout.style.display = 'none';
|
|
357
|
-
if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; return; } // 3D
|
|
723
|
+
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
|
|
724
|
+
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)
|
|
725
|
+
const bs = boxSel; boxSel = null;
|
|
726
|
+
if (bs) { // empty-space gesture: drag = box-select, click = clear selection
|
|
727
|
+
if (bs.moved) { const ids = membersInRect(bs.x, bs.y, e.clientX, e.clientY); if (api && api.onSelectMany) api.onSelectMany(ids); }
|
|
728
|
+
else if (api && api.onSelect) api.onSelect(null, false);
|
|
729
|
+
downXY = null; return;
|
|
730
|
+
}
|
|
358
731
|
const p = pending, wasDragging = dragging; pending = dragging = null;
|
|
359
732
|
controls.enabled = true;
|
|
360
733
|
const moved = downXY ? Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) : Infinity; downXY = null;
|
|
361
|
-
if (!p)
|
|
362
|
-
|
|
734
|
+
if (!p) return;
|
|
735
|
+
if (p.epDrag) { // end-node drag → write just that endpoint's wp; a click (no drag) just selects the member
|
|
736
|
+
if (wasDragging && p.newPt && api && api.onMoveEndpoint) { if (api.onSelect) api.onSelect(p.id, false); api.onMoveEndpoint(p.id, p.end, planPointToWp(p.newPt, p.ppf)); }
|
|
737
|
+
else if (api && api.onSelect) api.onSelect(p.id, false);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (p.geo) { // Trim/Extend or Split — a click (no drag) performs the edit
|
|
741
|
+
if (moved > DRAG_TOL_PX) return;
|
|
742
|
+
if (p.geo === 'el') { if (p.targetId && p.targetId !== p.selId && api && api.onTrimExtend) api.onTrimExtend(p.selId, p.targetId); return; }
|
|
743
|
+
if (p.geo === 'split') { // cut the selected member at the clicked point on its centreline
|
|
744
|
+
const m = members().find((x) => x.id === p.selId); if (!m) return;
|
|
745
|
+
const ppf = api.ptPerFt();
|
|
746
|
+
const g = memberGeometry(m, ppf, api.defaultTosMm());
|
|
747
|
+
const planeZ = (g.line[0][2] + g.line[1][2]) / 2;
|
|
748
|
+
const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return;
|
|
749
|
+
if (p.targetId !== p.selId) return; // the cut click must land on the selected member's body
|
|
750
|
+
const { pt: cut, t } = closestOnSeg3([hit[0], hit[1]], g.line[0], g.line[1]); // nearest point ON the member (plan)
|
|
751
|
+
if (t <= 0.03 || t >= 0.97) return; // too near an end → no degenerate split
|
|
752
|
+
if (api && api.onSplit) api.onSplit(p.selId, planPointToWp(cut, ppf));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
363
755
|
return;
|
|
364
756
|
}
|
|
365
757
|
if (!wasDragging) { if (api && api.onSelect) api.onSelect(p.id, p.ctrl); return; } // member click → select
|
|
@@ -375,21 +767,69 @@ function onUp(e) {
|
|
|
375
767
|
if (api && api.onSelect) api.onSelect(p.id, false); // keep the moved member selected
|
|
376
768
|
if (api && api.onMoveMember) api.onMoveMember(p.id, newWp); // editor writes wp via edit()
|
|
377
769
|
}
|
|
770
|
+
// nearest point on segment a-b to plan point p (XY only) → { pt:[x,y], t }
|
|
771
|
+
function closestOnSeg3(p, a, b) {
|
|
772
|
+
const abx = b[0] - a[0], aby = b[1] - a[1], l2 = abx * abx + aby * aby || 1;
|
|
773
|
+
let t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / l2; t = Math.max(0, Math.min(1, t));
|
|
774
|
+
return { pt: [a[0] + abx * t, a[1] + aby * t], t };
|
|
775
|
+
}
|
|
378
776
|
|
|
379
|
-
|
|
380
|
-
function
|
|
777
|
+
// ---- hover: cursor + readout chip (no drag in progress) ----
|
|
778
|
+
function onHoverMove(e) {
|
|
779
|
+
lastHoverXY = [e.clientX, e.clientY];
|
|
780
|
+
if (hoverRAF || pending || dragging || boxSel) return;
|
|
781
|
+
hoverRAF = requestAnimationFrame(() => {
|
|
782
|
+
hoverRAF = 0;
|
|
783
|
+
if (!lastHoverXY || pending || dragging || boxSel) return;
|
|
784
|
+
let ep = null; try { ep = pickEndpoint(lastHoverXY[0], lastHoverXY[1]); } catch { ep = null; }
|
|
785
|
+
if (ep) { // over an end node → ring + move cursor; keep its member's dots shown
|
|
786
|
+
hoverEp = ep; canvasEl.style.cursor = 'move';
|
|
787
|
+
if (hoverId !== ep.id) { hoverId = ep.id; rebuildEndpoints(); }
|
|
788
|
+
updateStatusChip(); return;
|
|
789
|
+
}
|
|
790
|
+
hoverEp = null;
|
|
791
|
+
let id = null; try { id = pickAt(lastHoverXY[0], lastHoverXY[1]); } catch { id = null; }
|
|
792
|
+
canvasEl.style.cursor = geoMode() ? 'crosshair' : (id ? 'grab' : 'default');
|
|
793
|
+
if (id !== hoverId) { hoverId = id; rebuildEndpoints(); updateStatusChip(); }
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
function updateStatusChip() {
|
|
797
|
+
if (!hoverChip || !canvasEl) return;
|
|
798
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
799
|
+
let txt = '';
|
|
800
|
+
const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
|
|
801
|
+
if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
|
|
802
|
+
else if (selIds.size > 1) txt = `${selIds.size} members selected`;
|
|
803
|
+
else txt = 'Drag a member to move · ends to stretch · Alt+drag elevation · right-drag orbits';
|
|
804
|
+
hoverChip.textContent = txt;
|
|
805
|
+
hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
|
|
806
|
+
hoverChip.style.top = (rect.bottom - 30) + 'px';
|
|
807
|
+
hoverChip.style.display = canvasEl.style.display === 'none' ? 'none' : 'block';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
|
|
811
|
+
function hide() { if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } }
|
|
381
812
|
function isReady() { return built; }
|
|
382
813
|
|
|
383
814
|
function dispose() {
|
|
384
815
|
if (rafId) cancelAnimationFrame(rafId);
|
|
816
|
+
if (hoverRAF) cancelAnimationFrame(hoverRAF);
|
|
385
817
|
if (ro) ro.disconnect();
|
|
386
|
-
if (canvasEl) canvasEl.removeEventListener('pointerdown', onDown, true);
|
|
818
|
+
if (canvasEl) { canvasEl.removeEventListener('pointerdown', onDown, true); canvasEl.removeEventListener('pointermove', onHoverMove); canvasEl.removeEventListener('dblclick', onDblClick); }
|
|
387
819
|
window.removeEventListener('pointermove', onMove);
|
|
388
820
|
window.removeEventListener('pointerup', onUp);
|
|
389
|
-
|
|
821
|
+
window.removeEventListener('keydown', onKey);
|
|
822
|
+
for (const ovl of [readout, hoverChip, rubber]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
|
|
823
|
+
if (cube) {
|
|
824
|
+
cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
|
|
825
|
+
cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
|
|
826
|
+
}
|
|
827
|
+
if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
|
|
828
|
+
for (const o of [epRing, epPreview, refGroup]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
829
|
+
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
390
830
|
clearRoot();
|
|
391
831
|
if (renderer) renderer.dispose();
|
|
392
|
-
renderer = scene = camera = controls = root = api = canvasEl = ro = null; built = false;
|
|
832
|
+
renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
|
|
393
833
|
}
|
|
394
834
|
|
|
395
835
|
function debug() {
|
|
@@ -402,15 +842,21 @@ function debug() {
|
|
|
402
842
|
firstId: meshById.keys().next().value ?? null,
|
|
403
843
|
ids: [...meshById.keys()].slice(0, 12),
|
|
404
844
|
geomTypes: [...meshById.values()].reduce((h, m) => { const t = m.geometry.type; h[t] = (h[t] || 0) + 1; return h; }, {}),
|
|
405
|
-
lastPick,
|
|
845
|
+
lastPick, projection: projection(), mode: displayMode,
|
|
846
|
+
hidden: [...groupHidden], solo: soloGroup, visible: [...meshById.values()].filter((m) => m.visible).length,
|
|
847
|
+
endpoints: epGroup ? epGroup.children.length : 0,
|
|
848
|
+
endpointColors: epGroup ? epGroup.children.map((c) => '#' + c.material.color.getHexString()) : [],
|
|
849
|
+
hoverId, hoverEp, dragEp, refLine: refLineOn,
|
|
850
|
+
inFrustum: (() => { let n = 0, t = 0; for (const m of meshById.values()) { t++; const v = m.getWorldPosition(new THREE.Vector3()).project(camera); if (Math.abs(v.x) <= 1 && Math.abs(v.y) <= 1 && v.z >= -1 && v.z <= 1) n++; } return { n, t }; })(),
|
|
406
851
|
camPos: camera ? [camera.position.x, camera.position.y, camera.position.z] : null,
|
|
852
|
+
target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
|
|
407
853
|
near: camera?.near, far: camera?.far,
|
|
408
854
|
};
|
|
409
855
|
}
|
|
410
856
|
// test helper: the current material colour + emissive of a member (verifies deselect restores colour)
|
|
411
857
|
function probe(id) {
|
|
412
858
|
const m = meshById.get(id); if (!m) return null;
|
|
413
|
-
return { color: '#' + m.material.color.getHexString(), emissive: m.material.emissiveIntensity || 0 };
|
|
859
|
+
return { color: '#' + m.material.color.getHexString(), emissive: m.material.emissiveIntensity || 0, visible: m.visible, wireframe: !!m.material.wireframe };
|
|
414
860
|
}
|
|
415
861
|
// test helper: client (px) coords of a member's endpoint (end 0|1) — lets an E2E drive a precise drag
|
|
416
862
|
function screenOf(id, end) {
|
|
@@ -419,4 +865,16 @@ function screenOf(id, end) {
|
|
|
419
865
|
const s = toScreen(g.line[end]); const rect = canvasEl.getBoundingClientRect();
|
|
420
866
|
return { x: rect.left + s.x, y: rect.top + s.y };
|
|
421
867
|
}
|
|
422
|
-
|
|
868
|
+
// test helper: client (px) coords of a member's centre — lets an E2E click/drag the body
|
|
869
|
+
function centerOf(id) {
|
|
870
|
+
const m = meshById.get(id); if (!m) return null;
|
|
871
|
+
const w = m.getWorldPosition(new THREE.Vector3()).project(camera); const rect = canvasEl.getBoundingClientRect();
|
|
872
|
+
return { x: rect.left + (w.x * 0.5 + 0.5) * rect.width, y: rect.top + (-w.y * 0.5 + 0.5) * rect.height };
|
|
873
|
+
}
|
|
874
|
+
window.Steel3DView = {
|
|
875
|
+
init, show, hide, rebuild, setSelection, isReady, dispose,
|
|
876
|
+
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
877
|
+
setRefLine, refLine: () => refLineOn,
|
|
878
|
+
toggleGroup, soloToggle, showAllGroups, groupState, getGroups,
|
|
879
|
+
debug, probe, screenOf, centerOf,
|
|
880
|
+
};
|