@floless/app 0.62.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +3 -3
- package/dist/schemas/steel.takeoff.v1.schema.json +30 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +29 -1
- package/dist/skills/floless-app-vectorize/SKILL.md +57 -4
- package/dist/web/aware.js +20 -0
- package/dist/web/grid-core.js +441 -0
- package/dist/web/renderers.js +3 -0
- package/dist/web/steel-3d-core.js +49 -3
- package/dist/web/steel-3d-view.js +417 -8
- package/dist/web/steel-editor.html +414 -32
- package/dist/web/vector-3d.html +242 -0
- package/package.json +1 -1
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
import * as THREE from 'three';
|
|
18
18
|
import { OrbitControls } from 'three/addons/OrbitControls.js';
|
|
19
19
|
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
|
|
20
|
-
import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom } from './steel-3d-core.js';
|
|
20
|
+
import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom, planeBasis, projectToPlane, planeFrom3Points, vecToPlane } from './steel-3d-core.js';
|
|
21
|
+
import { gridGeometry, gridCandidates3d, mmPerPx } from './grid-core.js';
|
|
21
22
|
|
|
22
23
|
let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
|
|
23
24
|
let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
|
|
@@ -279,6 +280,43 @@ function placePlate(mesh, el) {
|
|
|
279
280
|
mesh.position.set(...(el.center || [0, 0, 0]));
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
// Signed area of a closed 2D polygon (shoelace) — used to reject a zero-area/collinear footprint.
|
|
284
|
+
function shoelaceArea(pts) {
|
|
285
|
+
let a = 0;
|
|
286
|
+
for (let i = 0, n = pts.length; i < n; i++) {
|
|
287
|
+
const p = pts[i], q = pts[(i + 1) % n];
|
|
288
|
+
a += p[0] * q[1] - q[0] * p[1];
|
|
289
|
+
}
|
|
290
|
+
return a / 2;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A vertical extrusion of an arbitrary world-XY footprint between two Z values — the multi-view
|
|
294
|
+
// reconstruction's generic body (a plate, wall or slab read from a plan + elevation; see
|
|
295
|
+
// server/views-to-scene.ts). Same ExtrudeGeometry recipe as placePlate, but the outline is the
|
|
296
|
+
// element's own polygon in WORLD coordinates and the sweep is always +Z, so no basis rotation.
|
|
297
|
+
function placeExtrusion(mesh, el) {
|
|
298
|
+
const fp = el.footprint || [];
|
|
299
|
+
// Reject a degenerate outline (fewer than 3 vertices, or a zero-area collinear loop by the
|
|
300
|
+
// shoelace test) — ExtrudeGeometry on it produces a NaN/empty body, not an honest shape.
|
|
301
|
+
if (fp.length < 3 || Math.abs(shoelaceArea(fp)) < 1e-6) { mesh.geometry = new THREE.BufferGeometry(); return; }
|
|
302
|
+
const shape = new THREE.Shape();
|
|
303
|
+
shape.moveTo(fp[0][0], fp[0][1]);
|
|
304
|
+
for (let i = 1; i < fp.length; i++) shape.lineTo(fp[i][0], fp[i][1]);
|
|
305
|
+
shape.closePath();
|
|
306
|
+
for (const h of el.holes || []) { // optional opening loops (same world-XY polygon form)
|
|
307
|
+
if (!h || h.length < 3) continue;
|
|
308
|
+
const hole = new THREE.Path();
|
|
309
|
+
hole.moveTo(h[0][0], h[0][1]);
|
|
310
|
+
for (let i = 1; i < h.length; i++) hole.lineTo(h[i][0], h[i][1]);
|
|
311
|
+
hole.closePath();
|
|
312
|
+
shape.holes.push(hole);
|
|
313
|
+
}
|
|
314
|
+
const z0 = Math.min(el.from || 0, el.to || 0), z1 = Math.max(el.from || 0, el.to || 0);
|
|
315
|
+
mesh.geometry = new THREE.ExtrudeGeometry(shape, { depth: Math.max(z1 - z0, 0.5), bevelEnabled: false });
|
|
316
|
+
mesh.quaternion.identity();
|
|
317
|
+
mesh.position.set(0, 0, z0); // the shape carries world XY; lift the slab to its bottom face
|
|
318
|
+
}
|
|
319
|
+
|
|
282
320
|
// A cylinder between two points (anchor rod / bolt shank).
|
|
283
321
|
function placeBar(mesh, a, b, r, radial) {
|
|
284
322
|
const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
|
|
@@ -330,6 +368,7 @@ function placeWasher(mesh, el) {
|
|
|
330
368
|
function placeElement(mesh, el) {
|
|
331
369
|
switch (el.kind || 'box') {
|
|
332
370
|
case 'box': placeMember(mesh, el.from, el.to, el); return true;
|
|
371
|
+
case 'extrusion': placeExtrusion(mesh, el); return true;
|
|
333
372
|
case 'plate': placePlate(mesh, el); return true;
|
|
334
373
|
case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
|
|
335
374
|
case 'nut': placeNut(mesh, el); return true;
|
|
@@ -455,6 +494,7 @@ function buildFromScene(sc) {
|
|
|
455
494
|
dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
|
|
456
495
|
renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
|
|
457
496
|
buildGrid(box);
|
|
497
|
+
buildStructGrid();
|
|
458
498
|
buildRefLines();
|
|
459
499
|
if (isolatedIds) { isolatedIds = new Set([...isolatedIds].filter((id) => meshById.has(id))); if (!isolatedIds.size) { isolatedIds = null; if (api && api.onIsolateChange) api.onIsolateChange(false); } } // drop ids an edit removed; if none survive, exit isolation + refresh the host's Show-all button
|
|
460
500
|
if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
|
|
@@ -496,6 +536,97 @@ function buildRefLines() {
|
|
|
496
536
|
}
|
|
497
537
|
function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
|
|
498
538
|
|
|
539
|
+
// ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
|
|
540
|
+
// them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
|
|
541
|
+
// wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
|
|
542
|
+
// the contract via api.getGrid() (grid-core owns parsing/geometry); allCandidates() feeds the same
|
|
543
|
+
// grid into snapping, plan-locked.
|
|
544
|
+
let structGridGroup = null, gridLabelGroup = null;
|
|
545
|
+
const gridTexCache = new Map(); // 'b:LABEL' bubble / 't:LABEL' tag -> CanvasTexture (kept until dispose)
|
|
546
|
+
|
|
547
|
+
function allCandidates(excludeId) {
|
|
548
|
+
const base = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm(), excludeId);
|
|
549
|
+
try { return base.concat(gridCandidates3d(api.getGrid ? api.getGrid() : null, api.ptPerFt())); }
|
|
550
|
+
catch { return base; } // a malformed hand-edited grid must never break member dragging
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function gridBubbleTexture(label) {
|
|
554
|
+
const key = 'b:' + label;
|
|
555
|
+
if (gridTexCache.has(key)) return gridTexCache.get(key);
|
|
556
|
+
const S = 128, cv = document.createElement('canvas'); cv.width = cv.height = S;
|
|
557
|
+
const g = cv.getContext('2d');
|
|
558
|
+
g.beginPath(); g.arc(S / 2, S / 2, S / 2 - 5, 0, Math.PI * 2);
|
|
559
|
+
g.fillStyle = '#0f172a'; g.fill();
|
|
560
|
+
g.lineWidth = 5; g.strokeStyle = '#64748b'; g.stroke();
|
|
561
|
+
g.fillStyle = '#e2e8f0'; g.font = 'bold ' + (label.length > 2 ? 44 : 58) + 'px system-ui';
|
|
562
|
+
g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
563
|
+
g.fillText(label, S / 2, S / 2 + 3);
|
|
564
|
+
const tex = new THREE.CanvasTexture(cv);
|
|
565
|
+
gridTexCache.set(key, tex);
|
|
566
|
+
return tex;
|
|
567
|
+
}
|
|
568
|
+
function gridTagTexture(label) {
|
|
569
|
+
const key = 't:' + label;
|
|
570
|
+
if (gridTexCache.has(key)) return gridTexCache.get(key);
|
|
571
|
+
const W = 256, H = 80, cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
|
572
|
+
const g = cv.getContext('2d');
|
|
573
|
+
g.beginPath(); g.roundRect(4, 4, W - 8, H - 8, 14);
|
|
574
|
+
g.fillStyle = '#0f172a'; g.fill();
|
|
575
|
+
g.lineWidth = 4; g.strokeStyle = '#64748b'; g.stroke();
|
|
576
|
+
g.fillStyle = '#94a3b8'; g.font = 'bold 40px system-ui';
|
|
577
|
+
g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
578
|
+
g.fillText(label, W / 2, H / 2 + 2);
|
|
579
|
+
const tex = new THREE.CanvasTexture(cv);
|
|
580
|
+
gridTexCache.set(key, tex);
|
|
581
|
+
return tex;
|
|
582
|
+
}
|
|
583
|
+
function clearStructGrid() {
|
|
584
|
+
for (const [grp, parent] of [[structGridGroup, scene], [gridLabelGroup, overlayScene]]) {
|
|
585
|
+
if (!grp) continue;
|
|
586
|
+
for (const o of [...grp.children]) { grp.remove(o); if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); } // sprite materials are per-sprite; textures stay cached
|
|
587
|
+
if (parent) parent.remove(grp);
|
|
588
|
+
}
|
|
589
|
+
structGridGroup = gridLabelGroup = null;
|
|
590
|
+
}
|
|
591
|
+
function buildStructGrid() {
|
|
592
|
+
clearStructGrid();
|
|
593
|
+
if (!api || !api.getGrid || !scene) return;
|
|
594
|
+
let g = null, geo = null;
|
|
595
|
+
try { g = api.getGrid(); if (!g || g.on === false) return; geo = gridGeometry(g, api.ptPerFt()); }
|
|
596
|
+
catch (e) { console.warn('[steel-3d] grid skipped (bad grid data)', e); return; }
|
|
597
|
+
if (!geo.v.length && !geo.h.length) return;
|
|
598
|
+
const k = mmPerPx(api.ptPerFt());
|
|
599
|
+
const mmX = (px) => px * k, mmY = (px) => -px * k; // display → scene (Y flips)
|
|
600
|
+
const levels = geo.zs.length ? geo.zs : [{ mm: 0, label: '0' }];
|
|
601
|
+
const pts = [];
|
|
602
|
+
for (const lv of levels) {
|
|
603
|
+
const z = lv.mm - 25; // sit just under the steel at the level (no z-fight/burial)
|
|
604
|
+
for (const l of geo.v) pts.push(V(mmX(l.x), mmY(l.lo), z), V(mmX(l.x), mmY(l.hi), z));
|
|
605
|
+
for (const l of geo.h) pts.push(V(mmX(l.lo), mmY(l.y), z), V(mmX(l.hi), mmY(l.y), z));
|
|
606
|
+
}
|
|
607
|
+
structGridGroup = new THREE.Group();
|
|
608
|
+
const seg = new THREE.LineSegments(
|
|
609
|
+
new THREE.BufferGeometry().setFromPoints(pts),
|
|
610
|
+
new THREE.LineDashedMaterial({ color: 0x64748b, dashSize: 450, gapSize: 220, transparent: true, opacity: 0.85 }),
|
|
611
|
+
);
|
|
612
|
+
seg.computeLineDistances();
|
|
613
|
+
structGridGroup.add(seg);
|
|
614
|
+
scene.add(structGridGroup);
|
|
615
|
+
gridLabelGroup = new THREE.Group();
|
|
616
|
+
const span = Math.max((geo.x1 - geo.x0) * k, (geo.y1 - geo.y0) * k, 1);
|
|
617
|
+
const dia = Math.min(Math.max(span * 0.035, 400), 3000); // bubble ⌀ scales with the grid, clamped sane
|
|
618
|
+
const z0 = levels[0].mm;
|
|
619
|
+
const sprite = (tex, x, y, z, w, h) => {
|
|
620
|
+
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
|
|
621
|
+
s.position.set(x, y, z); s.scale.set(w, h, 1); s.renderOrder = 994;
|
|
622
|
+
gridLabelGroup.add(s);
|
|
623
|
+
};
|
|
624
|
+
for (const l of geo.v) { sprite(gridBubbleTexture(l.label), mmX(l.x), mmY(l.lo), z0, dia, dia); sprite(gridBubbleTexture(l.label), mmX(l.x), mmY(l.hi), z0, dia, dia); }
|
|
625
|
+
for (const l of geo.h) { sprite(gridBubbleTexture(l.label), mmX(l.lo), mmY(l.y), z0, dia, dia); sprite(gridBubbleTexture(l.label), mmX(l.hi), mmY(l.y), z0, dia, dia); }
|
|
626
|
+
if (geo.zs.length > 1) for (const lv of geo.zs) sprite(gridTagTexture(lv.label), mmX(geo.x0), mmY(geo.y1), lv.mm, dia * 2.4, dia * 0.75); // level tags up the near corner
|
|
627
|
+
overlayScene.add(gridLabelGroup);
|
|
628
|
+
}
|
|
629
|
+
|
|
499
630
|
// The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
|
|
500
631
|
// to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
|
|
501
632
|
// model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
|
|
@@ -662,6 +793,7 @@ function rebuildClipPlanes(c) { // recompute a clip's .planes from its ed
|
|
|
662
793
|
}
|
|
663
794
|
function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
|
|
664
795
|
if (m && dimMode3d) toggleDimTool();
|
|
796
|
+
if (m) { if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); cmClear3d(); drClear3d(); } // clip shares the left-click — disarm Move/Copy + Add so their picks don't steal it (onDown routes them first)
|
|
665
797
|
clipMode = (m === 'plane' || m === 'box') ? m : null;
|
|
666
798
|
clipBoxDraft = null; setClipPreview(null);
|
|
667
799
|
if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
|
|
@@ -984,6 +1116,7 @@ function onKey(e) {
|
|
|
984
1116
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
|
|
985
1117
|
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
986
1118
|
if (pending && pending.clipDrag && e.key === 'Escape') { e.preventDefault(); const p = pending; if (p.plane) p.clip.point = p.prePoint; else p.clip.box.copy(p.preBox); rebuildClipPlanes(p.clip); applyClips(); renderClipGizmo(); pending = dragging = null; if (controls) controls.enabled = true; if (readout) readout.style.display = 'none'; return; } // Esc mid-drag → undo the handle move
|
|
1119
|
+
if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
|
|
987
1120
|
if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
|
|
988
1121
|
if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
|
|
989
1122
|
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)
|
|
@@ -997,7 +1130,7 @@ function onKey(e) {
|
|
|
997
1130
|
if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
|
|
998
1131
|
}
|
|
999
1132
|
// Named-view shortcuts mirror the ViewCube faces (parity with AWARE viewer-3d): T/F/R/B/L snap to an ortho view.
|
|
1000
|
-
if (VIEW_KEYS[k] && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); applyView(VIEW_KEYS[k]); return; }
|
|
1133
|
+
if (VIEW_KEYS[k] && !cmActive() && !wpMode && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); applyView(VIEW_KEYS[k]); return; } // while Move/Copy or a set-plane pick is armed, F belongs to the axis force — not the Front view (review finding)
|
|
1001
1134
|
const a = ARROWS[e.key]; if (!a) return;
|
|
1002
1135
|
const [hx, hy] = a;
|
|
1003
1136
|
if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
|
|
@@ -1008,7 +1141,7 @@ function onKey(e) {
|
|
|
1008
1141
|
function toggleDimTool() {
|
|
1009
1142
|
dimMode3d = !dimMode3d;
|
|
1010
1143
|
if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
|
|
1011
|
-
else { if (clipMode) setClipMode(null); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick (
|
|
1144
|
+
else { if (clipMode) setClipMode(null); if (wpMode) armWorkPlanePick(wpMode); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); drClear3d(); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick, a set-plane pick, Move/Copy AND Add-member (all share the left-click + Esc)
|
|
1012
1145
|
reflectDimBar();
|
|
1013
1146
|
return dimMode3d;
|
|
1014
1147
|
}
|
|
@@ -1561,6 +1694,234 @@ function rayToVerticalPlane(cx, cy, thru) {
|
|
|
1561
1694
|
}
|
|
1562
1695
|
const members = () => (api && api.getMembers && api.getMembers()) || [];
|
|
1563
1696
|
const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
|
|
1697
|
+
|
|
1698
|
+
// ---- working plane (spec §6): session view-state grounding ALL 3D picks. null = the active plan's
|
|
1699
|
+
// level (horizontal at default TOS) — logically always present, only DRAWN when explicitly set. ----
|
|
1700
|
+
let workPlane = null; // {origin:[x,y,z]mm, normal, xAxis} | null
|
|
1701
|
+
let wpMode = null; // 'face' | '3pt' — armed set-plane pick mode
|
|
1702
|
+
let wpDraft = null; // 3pt picks collected so far
|
|
1703
|
+
let wpVisible = true; // "Show plane" toggle (applies to an explicit plane)
|
|
1704
|
+
let wpGroup = null;
|
|
1705
|
+
function effectiveWP() { return workPlane || { origin: [0, 0, api ? api.defaultTosMm() : 0], normal: [0, 0, 1], xAxis: [1, 0, 0] }; }
|
|
1706
|
+
function renderWorkPlane() {
|
|
1707
|
+
if (wpGroup) { scene.remove(wpGroup); wpGroup.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); wpGroup = null; }
|
|
1708
|
+
if (!workPlane || !wpVisible) return; // the implicit level plane is not drawn — the 2D plan already communicates it
|
|
1709
|
+
const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis);
|
|
1710
|
+
const CELL = 1524, N = 12, half = (CELL * N) / 2; // 12 × 12 cells at 5 ft — a work patch, not an infinite plane
|
|
1711
|
+
const g = new THREE.Group();
|
|
1712
|
+
const verts = [];
|
|
1713
|
+
for (let i = 0; i <= N; i++) { const o = -half + i * CELL; verts.push(o, -half, 0, o, half, 0, -half, o, 0, half, o, 0); }
|
|
1714
|
+
const grid = new THREE.BufferGeometry(); grid.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
|
1715
|
+
g.add(new THREE.LineSegments(grid, new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.28 })));
|
|
1716
|
+
const fill = new THREE.Mesh(new THREE.PlaneGeometry(CELL * N, CELL * N), new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.09, side: THREE.DoubleSide, depthWrite: false }));
|
|
1717
|
+
g.add(fill);
|
|
1718
|
+
const ax = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(half, 0, 0)]), new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.9 }));
|
|
1719
|
+
ax.renderOrder = 996; g.add(ax); // the brighter ray = the plane's local X
|
|
1720
|
+
const ay = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, half / 2, 0)]), new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.55 }));
|
|
1721
|
+
ay.renderOrder = 996; g.add(ay); // the shorter, dimmer ray = local Y (tells the axes apart on a skewed plane)
|
|
1722
|
+
const od = new THREE.Mesh(new THREE.SphereGeometry(60, 12, 8), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
|
|
1723
|
+
g.add(od); // origin dot
|
|
1724
|
+
g.applyMatrix4(new THREE.Matrix4().makeBasis(new THREE.Vector3(...u), new THREE.Vector3(...v), new THREE.Vector3(...wp.normal)));
|
|
1725
|
+
g.position.set(wp.origin[0], wp.origin[1], wp.origin[2]);
|
|
1726
|
+
wpGroup = g; scene.add(g); // on `scene` (like epGroup) so rebuild() never wipes it; not in meshById → picking ignores it
|
|
1727
|
+
}
|
|
1728
|
+
function reflectWpBar() { const b = document.getElementById('m3dWp'); if (b) { b.classList.toggle('on', !!wpMode || !!workPlane);
|
|
1729
|
+
const kind = workPlane && workPlane.kind ? { face: 'Face', p3: '3pt', xy: 'XY', xz: 'XZ', yz: 'YZ' }[workPlane.kind] || '' : '';
|
|
1730
|
+
b.textContent = wpMode ? '◇ Plane ✕' : (kind ? '◇ Plane: ' + kind + ' ▾' : '◇ Plane ▾'); } } // the label reads the active plane without opening the menu (ux review)
|
|
1731
|
+
function armWorkPlanePick(mode) { if (wpMode === mode) { wpMode = null; wpDraft = null; } else { wpMode = mode; wpDraft = null; if (dimMode3d) toggleDimTool(); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); cmClear3d(); drClear3d(); } marker.visible = false; canvasEl.style.cursor = wpMode ? 'crosshair' : 'default'; reflectWpBar(); } // a set-plane pick shares the left-click — disarm Move/Copy + Add (their onDown routes run first)
|
|
1732
|
+
function setWorkPlanePrincipal(kind, offsetMm) {
|
|
1733
|
+
const off = offsetMm || 0;
|
|
1734
|
+
const def = { xy: { normal: [0, 0, 1], xAxis: [1, 0, 0] }, xz: { normal: [0, 1, 0], xAxis: [1, 0, 0] }, yz: { normal: [1, 0, 0], xAxis: [0, 1, 0] } }[kind];
|
|
1735
|
+
if (!def) return;
|
|
1736
|
+
workPlane = { origin: def.normal.map((c) => c * off), normal: def.normal, xAxis: def.xAxis, kind };
|
|
1737
|
+
wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar();
|
|
1738
|
+
}
|
|
1739
|
+
function clearWorkPlane() { workPlane = null; wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar(); }
|
|
1740
|
+
function toggleWorkPlaneVisible(on) { wpVisible = on === undefined ? !wpVisible : !!on; renderWorkPlane(); }
|
|
1741
|
+
function workPlaneInfo() { return { set: !!workPlane, mode: wpMode, visible: wpVisible, plane: workPlane ? { origin: [...workPlane.origin], normal: [...workPlane.normal], xAxis: [...workPlane.xAxis] } : null }; }
|
|
1742
|
+
// face pick: the clip tool's raycast, minus the camera-side flip — the plane lands ON the face.
|
|
1743
|
+
function wpFacePick(cx, cy) {
|
|
1744
|
+
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
1745
|
+
const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
|
|
1746
|
+
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
1747
|
+
raycaster.setFromCamera(ndc, camera);
|
|
1748
|
+
const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false)[0];
|
|
1749
|
+
if (!hit || !hit.face) return null;
|
|
1750
|
+
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
|
|
1751
|
+
const b = planeBasis([n.x, n.y, n.z]);
|
|
1752
|
+
return { origin: [hit.point.x, hit.point.y, hit.point.z], normal: [n.x, n.y, n.z], xAxis: b.u, kind: 'face' };
|
|
1753
|
+
}
|
|
1754
|
+
// ray → the working plane (general orientation) → [x,y,z] mm, or null
|
|
1755
|
+
function rayToWP(cx, cy, wp) {
|
|
1756
|
+
camera.updateMatrixWorld();
|
|
1757
|
+
const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
|
|
1758
|
+
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
1759
|
+
raycaster.setFromCamera(ndc, camera);
|
|
1760
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(new THREE.Vector3(...wp.normal), new THREE.Vector3(...wp.origin));
|
|
1761
|
+
const hit = new THREE.Vector3();
|
|
1762
|
+
return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// ---- 3D Move/Copy (spec §3+§6): the EDITOR owns the tool state (cmState via api); this side owns
|
|
1766
|
+
// picking/preview in scene mm against the working plane, and hands commit offsets back. ----
|
|
1767
|
+
let tfDraft = null; // {base:[mm], vA:[mm]|null}
|
|
1768
|
+
let tfLastShift = false; // shiftKey mirror for the rAF-synthetic hover event
|
|
1769
|
+
let tfLast = null; // last constrained preview point (HUD distance direction)
|
|
1770
|
+
let tfLastClient = null; // last pointer client [x,y] (HUD placement)
|
|
1771
|
+
let tfAxis = 'free'; // 'free' | 'u' | 'v' — X/Y force along the plane basis
|
|
1772
|
+
let tfCandidates = null;
|
|
1773
|
+
let tfGhost = null, tfRubber = null, tfRubberA = null, tfGhostMat = null;
|
|
1774
|
+
const cmActive = () => (api && api.cmState ? api.cmState() : null);
|
|
1775
|
+
const cmHasBase = () => !!tfDraft;
|
|
1776
|
+
const cmLastClient = () => tfLastClient;
|
|
1777
|
+
function setCmAxis(a) { tfAxis = a === 'x' ? 'u' : a === 'y' ? 'v' : 'free'; }
|
|
1778
|
+
function cmClear3d() { tfDraft = null; tfCandidates = null; tfAxis = 'free'; tfPreviewClear(); if (canvasEl) canvasEl.style.cursor = 'default'; } // callable BEFORE init() (editor boot) — every touched handle is guarded
|
|
1779
|
+
function cmEscape() { if (!tfDraft) return false; if (tfDraft.vA) { tfDraft.vA = null; tfPreviewClear(); return true; } tfDraft = null; tfPreviewClear(); return true; }
|
|
1780
|
+
function tfDisposeGhost() { if (!tfGhost) return; if (scene) scene.remove(tfGhost); for (const c of tfGhost.children) { if (c.userData._ownGeom && c.geometry) c.geometry.dispose(); } tfGhost = null; } // clones share the source geometry/material; only the >600 fallback BoxGeometry is per-ghost → dispose those (flagged _ownGeom) so the preview can't leak
|
|
1781
|
+
function tfPreviewClear() { tfDisposeGhost(); if (tfRubber) tfRubber.visible = false; if (tfRubberA) tfRubberA.visible = false; if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
|
|
1782
|
+
// snapped pick projected onto the working plane (Tekla: snaps win, then land on the plane)
|
|
1783
|
+
function wpPointAt(e) {
|
|
1784
|
+
const wp = effectiveWP();
|
|
1785
|
+
const hit = rayToWP(e.clientX, e.clientY, wp); if (!hit) return null;
|
|
1786
|
+
if (!e.altKey) {
|
|
1787
|
+
if (!tfCandidates) tfCandidates = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
|
|
1788
|
+
const r = snapPoint(hit, tfCandidates, toScreen, SNAP_TOL_PX);
|
|
1789
|
+
if (r.candidate) return { p: projectToPlane(r.snapped, wp.origin, wp.normal), snap: true, type: r.candidate.type };
|
|
1790
|
+
}
|
|
1791
|
+
return { p: hit, snap: false, type: null };
|
|
1792
|
+
}
|
|
1793
|
+
// Shift = dominant in-plane axis; X/Y force follows the plane basis (like the 2D local frame)
|
|
1794
|
+
function tfConstrain(p, e) {
|
|
1795
|
+
if (!tfDraft) return p;
|
|
1796
|
+
const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis), b = tfDraft.base;
|
|
1797
|
+
const d = [p[0] - b[0], p[1] - b[1], p[2] - b[2]], c = vecToPlane(d, u, v, wp.normal);
|
|
1798
|
+
let ax = tfAxis;
|
|
1799
|
+
if (e && e.shiftKey) ax = Math.abs(c.du) >= Math.abs(c.dv) ? 'u' : 'v';
|
|
1800
|
+
if (ax === 'u') return [b[0] + c.du * u[0], b[1] + c.du * u[1], b[2] + c.du * u[2]];
|
|
1801
|
+
if (ax === 'v') return [b[0] + c.dv * v[0], b[1] + c.dv * v[1], b[2] + c.dv * v[2]];
|
|
1802
|
+
return p;
|
|
1803
|
+
}
|
|
1804
|
+
// mirror of the editor's offset builders (mm side): counts INCLUDE the original, grid skips (0,0)
|
|
1805
|
+
function tfOffsetsFor(v, st) {
|
|
1806
|
+
const lin = (vec, n) => { const o = []; for (let k = 1; k <= n; k++) o.push([vec[0] * k, vec[1] * k, vec[2] * k]); return o; };
|
|
1807
|
+
if (st.array) {
|
|
1808
|
+
if (!tfDraft.vA) return lin(v, Math.max(0, st.countA - 1));
|
|
1809
|
+
const o = []; for (let i = 0; i < st.countA; i++) for (let j = 0; j < st.countB; j++) { if (!i && !j) continue; o.push([tfDraft.vA[0] * i + v[0] * j, tfDraft.vA[1] * i + v[1] * j, tfDraft.vA[2] * i + v[2] * j]); } return o;
|
|
1810
|
+
}
|
|
1811
|
+
return st.tool === 'copy' ? lin(v, st.count) : [v];
|
|
1812
|
+
}
|
|
1813
|
+
function tfGhostBuild(offs) {
|
|
1814
|
+
tfDisposeGhost(); // dispose the previous rebuild's own geometries (the >600 boxes) before dropping it
|
|
1815
|
+
tfGhost = new THREE.Group();
|
|
1816
|
+
if (!tfGhostMat) { tfGhostMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.3, depthWrite: false }); }
|
|
1817
|
+
const sel = [...selIds].map((id) => meshById.get(id)).filter((m) => m && m.visible);
|
|
1818
|
+
if (sel.length * offs.length <= 600) {
|
|
1819
|
+
for (const off of offs) for (const m of sel) {
|
|
1820
|
+
const c = new THREE.Mesh(m.geometry, tfGhostMat); // shares the source geometry — nothing to dispose
|
|
1821
|
+
c.applyMatrix4(m.matrixWorld); c.position.x += off[0]; c.position.y += off[1]; c.position.z += off[2];
|
|
1822
|
+
tfGhost.add(c);
|
|
1823
|
+
}
|
|
1824
|
+
} else {
|
|
1825
|
+
const bb = new THREE.Box3(); for (const m of sel) bb.expandByObject(m);
|
|
1826
|
+
const size = bb.getSize(new THREE.Vector3()), ctr = bb.getCenter(new THREE.Vector3());
|
|
1827
|
+
const boxGeom = new THREE.BoxGeometry(size.x, size.y, size.z); // ONE shared box for all fallback ghosts this rebuild
|
|
1828
|
+
for (const off of offs) { const h = new THREE.Mesh(boxGeom, tfGhostMat); h.position.set(ctr.x + off[0], ctr.y + off[1], ctr.z + off[2]); tfGhost.add(h); }
|
|
1829
|
+
if (tfGhost.children[0]) tfGhost.children[0].userData._ownGeom = true; // dispose the shared box once via the first child on the next clear
|
|
1830
|
+
}
|
|
1831
|
+
scene.add(tfGhost);
|
|
1832
|
+
}
|
|
1833
|
+
function tfPreview(e) {
|
|
1834
|
+
const st = cmActive(); if (!st || !tfDraft) return;
|
|
1835
|
+
const r = wpPointAt(e); if (!r) return;
|
|
1836
|
+
const p = tfConstrain(r.p, e);
|
|
1837
|
+
tfLast = p; tfLastClient = [e.clientX, e.clientY];
|
|
1838
|
+
if (r.snap && tfAxis === 'free' && !e.shiftKey) { marker.position.set(p[0], p[1], p[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
|
|
1839
|
+
const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
|
|
1840
|
+
tfGhostBuild(tfOffsetsFor(v, st));
|
|
1841
|
+
if (!tfRubber) { tfRubber = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee })); tfRubber.material.depthTest = false; tfRubber.renderOrder = 997; scene.add(tfRubber); }
|
|
1842
|
+
tfRubber.geometry.setFromPoints([new THREE.Vector3(...tfDraft.base), new THREE.Vector3(...p)]);
|
|
1843
|
+
tfRubber.visible = true;
|
|
1844
|
+
if (st.array && tfDraft.vA) {
|
|
1845
|
+
if (!tfRubberA) { tfRubberA = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee, transparent: true, opacity: 0.35 })); tfRubberA.material.depthTest = false; tfRubberA.renderOrder = 997; scene.add(tfRubberA); }
|
|
1846
|
+
tfRubberA.geometry.setFromPoints([new THREE.Vector3(...tfDraft.base), new THREE.Vector3(tfDraft.base[0] + tfDraft.vA[0], tfDraft.base[1] + tfDraft.vA[1], tfDraft.base[2] + tfDraft.vA[2])]);
|
|
1847
|
+
tfRubberA.visible = true;
|
|
1848
|
+
}
|
|
1849
|
+
const L = Math.hypot(v[0], v[1], v[2]);
|
|
1850
|
+
readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft';
|
|
1851
|
+
readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : (st.tool === 'copy' && !st.array && st.count > 1 ? ' ×' + st.count : '');
|
|
1852
|
+
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
1853
|
+
}
|
|
1854
|
+
function tfClick(e) {
|
|
1855
|
+
const st = cmActive(); if (!st) return false;
|
|
1856
|
+
if (!selIds.size) { // selection drained while armed (undo/delete) — end the tool and let the click SELECT instead
|
|
1857
|
+
if (api && api.toast) api.toast('Selection is empty — ' + (st.tool === 'move' ? 'Move' : 'Copy') + ' ended');
|
|
1858
|
+
if (api && api.disarmTransform) api.disarmTransform();
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
const r = wpPointAt(e); if (!r) return true;
|
|
1862
|
+
const p = tfConstrain(r.p, e);
|
|
1863
|
+
tfLastClient = [e.clientX, e.clientY];
|
|
1864
|
+
if (!tfDraft) { tfDraft = { base: p, vA: null }; tfCandidates = null; tfLast = p; return true; }
|
|
1865
|
+
const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
|
|
1866
|
+
if (Math.hypot(v[0], v[1], v[2]) < 5) return true; // ~zero pick (5 mm) — ignore, like the 2D 0.5 px guard
|
|
1867
|
+
if (st.array && !tfDraft.vA) { tfDraft.vA = v; return true; }
|
|
1868
|
+
const offs = tfOffsetsFor(v, st);
|
|
1869
|
+
if (!offs.length) { if (api && api.toast) api.toast(st.array ? 'A 1 × 1 array places no copies — raise a count' : 'Nothing to place'); tfDraft = null; tfPreviewClear(); return true; }
|
|
1870
|
+
api.onTransform3d(offs);
|
|
1871
|
+
tfDraft = null; tfPreviewClear();
|
|
1872
|
+
return true;
|
|
1873
|
+
}
|
|
1874
|
+
// HUD hand-off: the editor parses (inches), we turn it into a plane-space vector and run the same flow.
|
|
1875
|
+
function cmHudApply(pv) {
|
|
1876
|
+
const st = cmActive(); if (!st || !tfDraft) return { err: 'Pick a base point first' };
|
|
1877
|
+
const wp = effectiveWP(), { u, v: vv } = planeBasis(wp.normal, wp.xAxis);
|
|
1878
|
+
let v;
|
|
1879
|
+
if (pv.comp) { const IN = 25.4, du = pv.comp[0] * IN, dv = pv.comp[1] * IN, dn = (pv.comp[2] || 0) * IN;
|
|
1880
|
+
v = [du * u[0] + dv * vv[0] + dn * wp.normal[0], du * u[1] + dv * vv[1] + dn * wp.normal[1], du * u[2] + dv * vv[2] + dn * wp.normal[2]]; }
|
|
1881
|
+
else { if (!tfLast) return { err: 'Move the mouse to aim, or type dx,dy' };
|
|
1882
|
+
const dir = [tfLast[0] - tfDraft.base[0], tfLast[1] - tfDraft.base[1], tfLast[2] - tfDraft.base[2]];
|
|
1883
|
+
const L = Math.hypot(dir[0], dir[1], dir[2]); if (L < 1e-6) return { err: 'Move the mouse to aim, or type dx,dy' };
|
|
1884
|
+
const d = pv.dist * 25.4; v = [dir[0] / L * d, dir[1] / L * d, dir[2] / L * d]; }
|
|
1885
|
+
if (Math.hypot(v[0], v[1], v[2]) < 5) return { err: 'Zero distance — type a non-zero value' };
|
|
1886
|
+
if (st.array && !tfDraft.vA) { tfDraft.vA = v; tfPreviewClear(); return { ok: true, staged: true }; }
|
|
1887
|
+
const offs = tfOffsetsFor(v, st);
|
|
1888
|
+
if (!offs.length) { tfDraft = null; tfPreviewClear(); return { err: st.array ? 'A 1 × 1 array places no copies — raise a count' : 'Nothing to place' }; }
|
|
1889
|
+
api.onTransform3d(offs);
|
|
1890
|
+
tfDraft = null; tfPreviewClear();
|
|
1891
|
+
return { ok: true };
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// ---- draw a new member on the working plane (spec §7): two plane picks → api.onAddMember3d (scene
|
|
1895
|
+
// mm); the editor maps them to wp + per-end TOS and materializes a beam or (near-vertical) column. ----
|
|
1896
|
+
let drDraft = null; // first pick, scene mm
|
|
1897
|
+
let drRubber = null;
|
|
1898
|
+
const addActive = () => !!(api && api.addModeActive && api.addModeActive());
|
|
1899
|
+
function drClear3d() { drDraft = null; if (drRubber) drRubber.visible = false; if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; } // callable pre-init (all handles guarded)
|
|
1900
|
+
function drShiftLock(p, e) { // Shift = ortho along the plane's local axes (same basis as Move/Copy)
|
|
1901
|
+
if (!e || !e.shiftKey || !drDraft) return p;
|
|
1902
|
+
const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis), d = [p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]], c = vecToPlane(d, u, v, wp.normal);
|
|
1903
|
+
return Math.abs(c.du) >= Math.abs(c.dv) ? [drDraft[0] + c.du * u[0], drDraft[1] + c.du * u[1], drDraft[2] + c.du * u[2]] : [drDraft[0] + c.dv * v[0], drDraft[1] + c.dv * v[1], drDraft[2] + c.dv * v[2]];
|
|
1904
|
+
}
|
|
1905
|
+
function drClick(e) {
|
|
1906
|
+
const r = wpPointAt(e); if (!r) return;
|
|
1907
|
+
if (!drDraft) { drDraft = r.p; return; }
|
|
1908
|
+
const p = drShiftLock(r.p, e);
|
|
1909
|
+
if (Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]) < 50) return; // ~<2in — ignore a doubled click
|
|
1910
|
+
if (api && api.onAddMember3d) api.onAddMember3d(drDraft, p);
|
|
1911
|
+
drDraft = null; tfCandidates = null; // the new member is a snap target for the NEXT one — invalidate the shared snap cache
|
|
1912
|
+
if (drRubber) drRubber.visible = false; readout.style.display = 'none'; // stays armed for the next member
|
|
1913
|
+
}
|
|
1914
|
+
function drPreview(e) {
|
|
1915
|
+
const r = wpPointAt(e); if (!r) return;
|
|
1916
|
+
if (r.snap) { marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
|
|
1917
|
+
if (!drDraft) { readout.style.display = 'none'; return; }
|
|
1918
|
+
const p = drShiftLock(r.p, e);
|
|
1919
|
+
if (!drRubber) { drRubber = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee })); drRubber.material.depthTest = false; drRubber.renderOrder = 997; scene.add(drRubber); }
|
|
1920
|
+
drRubber.geometry.setFromPoints([new THREE.Vector3(...drDraft), new THREE.Vector3(...p)]); drRubber.visible = true;
|
|
1921
|
+
const L = Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]);
|
|
1922
|
+
readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft'; readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : '';
|
|
1923
|
+
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
1924
|
+
}
|
|
1564
1925
|
// world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
|
|
1565
1926
|
// BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
|
|
1566
1927
|
// world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
|
|
@@ -1586,7 +1947,7 @@ function dimPointAt(e, anchor) {
|
|
|
1586
1947
|
let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
|
|
1587
1948
|
if (id) { const m = members().find((x) => x.id === id); if (m) { const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm()); planeZ = (g.line[0][2] + g.line[1][2]) / 2; } }
|
|
1588
1949
|
const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
|
|
1589
|
-
if (!dimCandidates3d) dimCandidates3d =
|
|
1950
|
+
if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
|
|
1590
1951
|
const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
|
|
1591
1952
|
return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
|
|
1592
1953
|
}
|
|
@@ -1596,6 +1957,19 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
|
|
|
1596
1957
|
|
|
1597
1958
|
function onDown(e) {
|
|
1598
1959
|
if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
|
|
1960
|
+
if (wpMode === 'face') { e.stopPropagation(); const r = wpFacePick(e.clientX, e.clientY); if (r) { workPlane = r; wpMode = null; canvasEl.style.cursor = 'default'; renderWorkPlane(); reflectWpBar(); if (api && api.toast) api.toast('Working plane set on the face'); } return; } // armed: one face click sets the plane
|
|
1961
|
+
if (wpMode === '3pt') { e.stopPropagation();
|
|
1962
|
+
const r = dimPointAt(e); if (!r) return;
|
|
1963
|
+
wpDraft = wpDraft || []; wpDraft.push(r.p);
|
|
1964
|
+
marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true;
|
|
1965
|
+
if (wpDraft.length === 3) { const wp = planeFrom3Points(wpDraft[0], wpDraft[1], wpDraft[2]);
|
|
1966
|
+
wpDraft = null; wpMode = null; marker.visible = false; canvasEl.style.cursor = 'default';
|
|
1967
|
+
if (wp) { wp.kind = 'p3'; workPlane = wp; renderWorkPlane(); if (api && api.toast) api.toast('Working plane set from 3 points'); }
|
|
1968
|
+
else if (api && api.toast) api.toast('Points are collinear — plane not set');
|
|
1969
|
+
reflectWpBar(); }
|
|
1970
|
+
return; }
|
|
1971
|
+
if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
|
|
1972
|
+
if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
|
|
1599
1973
|
if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
|
|
1600
1974
|
if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
|
|
1601
1975
|
if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(e.clientX, e.clientY); } catch { ch = null; } if (ch) { e.stopPropagation(); controls.enabled = false; downXY = [e.clientX, e.clientY]; startClipDrag(ch, e); return; } } // grab a clip handle (plane dot / box face) → drag it
|
|
@@ -1630,7 +2004,7 @@ function onDown(e) {
|
|
|
1630
2004
|
id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
|
|
1631
2005
|
grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
|
|
1632
2006
|
origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
|
|
1633
|
-
candidates:
|
|
2007
|
+
candidates: allCandidates(id),
|
|
1634
2008
|
levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
|
|
1635
2009
|
mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
|
|
1636
2010
|
};
|
|
@@ -1661,7 +2035,7 @@ function startEndpointGrab(ep, e) {
|
|
|
1661
2035
|
pending = {
|
|
1662
2036
|
epDrag: true, id: ep.id, end: ep.end, ppf,
|
|
1663
2037
|
planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
|
|
1664
|
-
candidates:
|
|
2038
|
+
candidates: allCandidates(ep.id),
|
|
1665
2039
|
newPt: [g.line[ep.end][0], g.line[ep.end][1]],
|
|
1666
2040
|
};
|
|
1667
2041
|
dragEp = { id: ep.id, end: ep.end };
|
|
@@ -1799,10 +2173,26 @@ function closestOnSeg3(p, a, b) {
|
|
|
1799
2173
|
function onHoverMove(e) {
|
|
1800
2174
|
lastHoverXY = [e.clientX, e.clientY];
|
|
1801
2175
|
dimLastAlt = e.altKey; // remembered so the rAF body can rebuild a synthetic event
|
|
2176
|
+
tfLastShift = e.shiftKey; // same for the Move/Copy ortho lock
|
|
1802
2177
|
if (hoverRAF || pending || dragging || boxSel) return;
|
|
1803
2178
|
hoverRAF = requestAnimationFrame(() => {
|
|
1804
2179
|
hoverRAF = 0;
|
|
1805
2180
|
if (!lastHoverXY || pending || dragging || boxSel) return;
|
|
2181
|
+
if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
|
|
2182
|
+
canvasEl.style.cursor = 'crosshair';
|
|
2183
|
+
if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
|
|
2184
|
+
if (r && !(wpDraft && wpDraft.length)) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } }
|
|
2185
|
+
updateStatusChip(); return; }
|
|
2186
|
+
if (addActive()) { // Add-member armed: snap marker + rubber preview on the working plane
|
|
2187
|
+
canvasEl.style.cursor = 'crosshair';
|
|
2188
|
+
drPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
|
|
2189
|
+
updateStatusChip(); return; }
|
|
2190
|
+
if (cmActive()) { // Move/Copy armed: previews ride the working plane
|
|
2191
|
+
canvasEl.style.cursor = 'crosshair';
|
|
2192
|
+
if (tfDraft) tfPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
|
|
2193
|
+
else { const r = wpPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
|
|
2194
|
+
if (r && r.snap) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false; }
|
|
2195
|
+
updateStatusChip(); return; }
|
|
1806
2196
|
if (clipMode) { canvasEl.style.cursor = 'crosshair'; if (clipMode === 'box') clipBoxPreviewAt(lastHoverXY[0], lastHoverXY[1]); return; } // armed clip pick: crosshair (+ live box preview while drawing)
|
|
1807
2197
|
if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
|
|
1808
2198
|
canvasEl.style.cursor = 'crosshair';
|
|
@@ -1837,12 +2227,20 @@ function updateStatusChip() {
|
|
|
1837
2227
|
const rect = canvasEl.getBoundingClientRect();
|
|
1838
2228
|
let txt = '';
|
|
1839
2229
|
const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
|
|
1840
|
-
if (
|
|
2230
|
+
if (wpMode === 'face') txt = 'Click a member face — the working plane lands on it · Esc to cancel';
|
|
2231
|
+
else if (wpMode === '3pt') txt = ['Click the plane origin (snapped) · Esc to cancel', 'Click the local-X direction point · Esc to cancel', 'Click a point on the plane’s Y side · Esc to cancel'][(wpDraft || []).length] || '';
|
|
2232
|
+
else if (addActive()) txt = drDraft ? 'Click the member end · Shift straight · Esc drops the start' : 'Draw a member on the working plane — click the start point · Esc exits';
|
|
2233
|
+
else if (cmActive()) { const st = cmActive(), lock = tfAxis === 'u' ? ' · X locked' : tfAxis === 'v' ? ' · Y locked' : '';
|
|
2234
|
+
txt = (!tfDraft ? ((st.tool === 'move' ? 'Move' : (st.array ? 'Copy array' : 'Copy')) + ' — click the base point on the working plane · type after the pick for an exact value')
|
|
2235
|
+
: (st.array && !tfDraft.vA ? 'Click the direction-A point — or type its spacing' : st.array ? 'Click the direction-B point — the grid previews live' : 'Click the destination — X/Y lock to the plane axes · Esc cancels')) + lock; }
|
|
2236
|
+
else if (clipMode === 'plane') txt = 'Click a face to set a clip plane · Esc to cancel';
|
|
1841
2237
|
else if (clipMode === 'box') txt = !clipBoxDraft ? 'Click the first floor corner of the clip box · Esc to cancel' : !clipBoxDraft.b ? 'Click the opposite floor corner · Esc to cancel' : 'Move up/down to set the height, then click · Esc to step back';
|
|
1842
2238
|
else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
|
|
1843
2239
|
else if (selIds.size > 1) txt = `${selIds.size} members selected`;
|
|
1844
2240
|
else if (isolatedIds) txt = `Isolated: ${isolatedIds.size} · Show all to restore`;
|
|
1845
|
-
|
|
2241
|
+
// A read-only host (the vector 3D viewer) supplies its own idle hint — the default speaks
|
|
2242
|
+
// editing verbs (move/stretch/elevate) that a viewer's no-op api would silently ignore.
|
|
2243
|
+
else txt = (api && typeof api.statusHint === 'function' && api.statusHint()) || 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
|
|
1846
2244
|
hoverChip.textContent = txt;
|
|
1847
2245
|
hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
|
|
1848
2246
|
hoverChip.style.top = (rect.bottom - 30) + 'px';
|
|
@@ -1853,6 +2251,8 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
|
|
|
1853
2251
|
function hide() {
|
|
1854
2252
|
if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
|
|
1855
2253
|
if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
|
|
2254
|
+
if (wpMode) { wpMode = null; wpDraft = null; if (marker) marker.visible = false; reflectWpBar(); } // drop an in-progress set-plane pick — else a stale face/3pt pick steals the first click on return
|
|
2255
|
+
cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
|
|
1856
2256
|
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
1857
2257
|
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
1858
2258
|
}
|
|
@@ -1883,6 +2283,9 @@ function dispose() {
|
|
|
1883
2283
|
for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
1884
2284
|
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
1885
2285
|
dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
|
|
2286
|
+
clearStructGrid();
|
|
2287
|
+
for (const tex of gridTexCache.values()) tex.dispose();
|
|
2288
|
+
gridTexCache.clear();
|
|
1886
2289
|
clearRoot();
|
|
1887
2290
|
if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
|
|
1888
2291
|
clearClipGizmo(); setClipPreview(null); overlayScene = null;
|
|
@@ -1911,6 +2314,7 @@ function debug() {
|
|
|
1911
2314
|
target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
|
|
1912
2315
|
near: camera?.near, far: camera?.far,
|
|
1913
2316
|
clips: getClips(), clipPlaneCount: renderer && renderer.clippingPlanes ? renderer.clippingPlanes.length : 0, clipMode, workArea: workAreaState(), selectedClips: [...selectedClipIds],
|
|
2317
|
+
workPlane: workPlaneInfo(), tfHasBase: !!tfDraft, tfGhosts: tfGhost ? tfGhost.children.length : 0, drHasStart: !!drDraft,
|
|
1914
2318
|
};
|
|
1915
2319
|
}
|
|
1916
2320
|
// test helper: the current material colour + emissive of a member (verifies deselect restores colour)
|
|
@@ -1936,10 +2340,15 @@ window.Steel3DView = {
|
|
|
1936
2340
|
init, show, hide, rebuild, setSelection, isReady, dispose,
|
|
1937
2341
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
1938
2342
|
setRefLine, refLine: () => refLineOn,
|
|
2343
|
+
refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
|
|
2344
|
+
gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
|
|
1939
2345
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
1940
2346
|
setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
|
|
1941
2347
|
isolateSelected, clearIsolation, isIsolated,
|
|
1942
2348
|
workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
|
|
2349
|
+
armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
|
|
2350
|
+
cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
|
|
2351
|
+
drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
|
|
1943
2352
|
toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
|
|
1944
2353
|
refreshOverlayDims,
|
|
1945
2354
|
overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests
|