@floless/app 0.61.0 → 0.63.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 +227 -5
- package/dist/schemas/drawing.vector.v1.schema.json +92 -4
- package/dist/schemas/steel.takeoff.v1.schema.json +30 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +29 -1
- package/dist/web/grid-core.js +441 -0
- package/dist/web/steel-3d-core.js +49 -3
- package/dist/web/steel-3d-view.js +424 -10
- package/dist/web/steel-editor.html +734 -20
- 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
|
|
@@ -36,6 +37,7 @@ let soloGroups = new Set(); // profile keys isolated via the leg
|
|
|
36
37
|
let isolatedIds = null; // Tekla "isolate selected": Set of ids shown exclusively, or null for off
|
|
37
38
|
let connHidden = new Set(); // explicit per-PART hide (legend connection rows) — lets a shared part-kind (weld/nut) hide per-connection by id, not per group
|
|
38
39
|
let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
|
|
40
|
+
let triad = null; // world-axis triad { renderer, scene, cam, group } — passive X/Y/Z readout
|
|
39
41
|
const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
|
|
40
42
|
const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
|
|
41
43
|
const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
|
|
@@ -150,6 +152,7 @@ function init(canvas, theApi) {
|
|
|
150
152
|
window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
|
|
151
153
|
ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
|
|
152
154
|
initCube();
|
|
155
|
+
initTriad();
|
|
153
156
|
// Resize once more on the next frame: the stage may still be laying out when init() runs (the
|
|
154
157
|
// designer flagged a first-render size mismatch as the common rough edge here).
|
|
155
158
|
requestAnimationFrame(resize);
|
|
@@ -181,6 +184,7 @@ function loop() {
|
|
|
181
184
|
renderer.autoClear = true; renderer.clippingPlanes = saved;
|
|
182
185
|
}
|
|
183
186
|
if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
|
|
187
|
+
if (triad) { syncTriad(); triad.renderer.render(triad.scene, triad.cam); }
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
const V = (x, y, z) => new THREE.Vector3(x, y, z);
|
|
@@ -452,6 +456,7 @@ function buildFromScene(sc) {
|
|
|
452
456
|
dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
|
|
453
457
|
renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
|
|
454
458
|
buildGrid(box);
|
|
459
|
+
buildStructGrid();
|
|
455
460
|
buildRefLines();
|
|
456
461
|
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
|
|
457
462
|
if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
|
|
@@ -493,6 +498,97 @@ function buildRefLines() {
|
|
|
493
498
|
}
|
|
494
499
|
function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
|
|
495
500
|
|
|
501
|
+
// ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
|
|
502
|
+
// them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
|
|
503
|
+
// wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
|
|
504
|
+
// the contract via api.getGrid() (grid-core owns parsing/geometry); allCandidates() feeds the same
|
|
505
|
+
// grid into snapping, plan-locked.
|
|
506
|
+
let structGridGroup = null, gridLabelGroup = null;
|
|
507
|
+
const gridTexCache = new Map(); // 'b:LABEL' bubble / 't:LABEL' tag -> CanvasTexture (kept until dispose)
|
|
508
|
+
|
|
509
|
+
function allCandidates(excludeId) {
|
|
510
|
+
const base = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm(), excludeId);
|
|
511
|
+
try { return base.concat(gridCandidates3d(api.getGrid ? api.getGrid() : null, api.ptPerFt())); }
|
|
512
|
+
catch { return base; } // a malformed hand-edited grid must never break member dragging
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function gridBubbleTexture(label) {
|
|
516
|
+
const key = 'b:' + label;
|
|
517
|
+
if (gridTexCache.has(key)) return gridTexCache.get(key);
|
|
518
|
+
const S = 128, cv = document.createElement('canvas'); cv.width = cv.height = S;
|
|
519
|
+
const g = cv.getContext('2d');
|
|
520
|
+
g.beginPath(); g.arc(S / 2, S / 2, S / 2 - 5, 0, Math.PI * 2);
|
|
521
|
+
g.fillStyle = '#0f172a'; g.fill();
|
|
522
|
+
g.lineWidth = 5; g.strokeStyle = '#64748b'; g.stroke();
|
|
523
|
+
g.fillStyle = '#e2e8f0'; g.font = 'bold ' + (label.length > 2 ? 44 : 58) + 'px system-ui';
|
|
524
|
+
g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
525
|
+
g.fillText(label, S / 2, S / 2 + 3);
|
|
526
|
+
const tex = new THREE.CanvasTexture(cv);
|
|
527
|
+
gridTexCache.set(key, tex);
|
|
528
|
+
return tex;
|
|
529
|
+
}
|
|
530
|
+
function gridTagTexture(label) {
|
|
531
|
+
const key = 't:' + label;
|
|
532
|
+
if (gridTexCache.has(key)) return gridTexCache.get(key);
|
|
533
|
+
const W = 256, H = 80, cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
|
534
|
+
const g = cv.getContext('2d');
|
|
535
|
+
g.beginPath(); g.roundRect(4, 4, W - 8, H - 8, 14);
|
|
536
|
+
g.fillStyle = '#0f172a'; g.fill();
|
|
537
|
+
g.lineWidth = 4; g.strokeStyle = '#64748b'; g.stroke();
|
|
538
|
+
g.fillStyle = '#94a3b8'; g.font = 'bold 40px system-ui';
|
|
539
|
+
g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
540
|
+
g.fillText(label, W / 2, H / 2 + 2);
|
|
541
|
+
const tex = new THREE.CanvasTexture(cv);
|
|
542
|
+
gridTexCache.set(key, tex);
|
|
543
|
+
return tex;
|
|
544
|
+
}
|
|
545
|
+
function clearStructGrid() {
|
|
546
|
+
for (const [grp, parent] of [[structGridGroup, scene], [gridLabelGroup, overlayScene]]) {
|
|
547
|
+
if (!grp) continue;
|
|
548
|
+
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
|
|
549
|
+
if (parent) parent.remove(grp);
|
|
550
|
+
}
|
|
551
|
+
structGridGroup = gridLabelGroup = null;
|
|
552
|
+
}
|
|
553
|
+
function buildStructGrid() {
|
|
554
|
+
clearStructGrid();
|
|
555
|
+
if (!api || !api.getGrid || !scene) return;
|
|
556
|
+
let g = null, geo = null;
|
|
557
|
+
try { g = api.getGrid(); if (!g || g.on === false) return; geo = gridGeometry(g, api.ptPerFt()); }
|
|
558
|
+
catch (e) { console.warn('[steel-3d] grid skipped (bad grid data)', e); return; }
|
|
559
|
+
if (!geo.v.length && !geo.h.length) return;
|
|
560
|
+
const k = mmPerPx(api.ptPerFt());
|
|
561
|
+
const mmX = (px) => px * k, mmY = (px) => -px * k; // display → scene (Y flips)
|
|
562
|
+
const levels = geo.zs.length ? geo.zs : [{ mm: 0, label: '0' }];
|
|
563
|
+
const pts = [];
|
|
564
|
+
for (const lv of levels) {
|
|
565
|
+
const z = lv.mm - 25; // sit just under the steel at the level (no z-fight/burial)
|
|
566
|
+
for (const l of geo.v) pts.push(V(mmX(l.x), mmY(l.lo), z), V(mmX(l.x), mmY(l.hi), z));
|
|
567
|
+
for (const l of geo.h) pts.push(V(mmX(l.lo), mmY(l.y), z), V(mmX(l.hi), mmY(l.y), z));
|
|
568
|
+
}
|
|
569
|
+
structGridGroup = new THREE.Group();
|
|
570
|
+
const seg = new THREE.LineSegments(
|
|
571
|
+
new THREE.BufferGeometry().setFromPoints(pts),
|
|
572
|
+
new THREE.LineDashedMaterial({ color: 0x64748b, dashSize: 450, gapSize: 220, transparent: true, opacity: 0.85 }),
|
|
573
|
+
);
|
|
574
|
+
seg.computeLineDistances();
|
|
575
|
+
structGridGroup.add(seg);
|
|
576
|
+
scene.add(structGridGroup);
|
|
577
|
+
gridLabelGroup = new THREE.Group();
|
|
578
|
+
const span = Math.max((geo.x1 - geo.x0) * k, (geo.y1 - geo.y0) * k, 1);
|
|
579
|
+
const dia = Math.min(Math.max(span * 0.035, 400), 3000); // bubble ⌀ scales with the grid, clamped sane
|
|
580
|
+
const z0 = levels[0].mm;
|
|
581
|
+
const sprite = (tex, x, y, z, w, h) => {
|
|
582
|
+
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
|
|
583
|
+
s.position.set(x, y, z); s.scale.set(w, h, 1); s.renderOrder = 994;
|
|
584
|
+
gridLabelGroup.add(s);
|
|
585
|
+
};
|
|
586
|
+
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); }
|
|
587
|
+
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); }
|
|
588
|
+
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
|
|
589
|
+
overlayScene.add(gridLabelGroup);
|
|
590
|
+
}
|
|
591
|
+
|
|
496
592
|
// The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
|
|
497
593
|
// to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
|
|
498
594
|
// model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
|
|
@@ -659,6 +755,7 @@ function rebuildClipPlanes(c) { // recompute a clip's .planes from its ed
|
|
|
659
755
|
}
|
|
660
756
|
function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
|
|
661
757
|
if (m && dimMode3d) toggleDimTool();
|
|
758
|
+
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)
|
|
662
759
|
clipMode = (m === 'plane' || m === 'box') ? m : null;
|
|
663
760
|
clipBoxDraft = null; setClipPreview(null);
|
|
664
761
|
if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
|
|
@@ -981,6 +1078,7 @@ function onKey(e) {
|
|
|
981
1078
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
|
|
982
1079
|
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
983
1080
|
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
|
|
1081
|
+
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
|
|
984
1082
|
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
|
|
985
1083
|
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)
|
|
986
1084
|
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)
|
|
@@ -994,7 +1092,7 @@ function onKey(e) {
|
|
|
994
1092
|
if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
|
|
995
1093
|
}
|
|
996
1094
|
// Named-view shortcuts mirror the ViewCube faces (parity with AWARE viewer-3d): T/F/R/B/L snap to an ortho view.
|
|
997
|
-
if (VIEW_KEYS[k] && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); applyView(VIEW_KEYS[k]); return; }
|
|
1095
|
+
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)
|
|
998
1096
|
const a = ARROWS[e.key]; if (!a) return;
|
|
999
1097
|
const [hx, hy] = a;
|
|
1000
1098
|
if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
|
|
@@ -1005,7 +1103,7 @@ function onKey(e) {
|
|
|
1005
1103
|
function toggleDimTool() {
|
|
1006
1104
|
dimMode3d = !dimMode3d;
|
|
1007
1105
|
if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
|
|
1008
|
-
else { if (clipMode) setClipMode(null); controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // arming the dim tool disarms the clip pick (
|
|
1106
|
+
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)
|
|
1009
1107
|
reflectDimBar();
|
|
1010
1108
|
return dimMode3d;
|
|
1011
1109
|
}
|
|
@@ -1414,6 +1512,46 @@ function initCube() {
|
|
|
1414
1512
|
});
|
|
1415
1513
|
cube = { renderer: cr, scene: cs, cam: cc, mesh };
|
|
1416
1514
|
}
|
|
1515
|
+
|
|
1516
|
+
// ---- World-axis triad (Tekla-style) — a passive bottom-right gizmo showing where world X/Y/Z point.
|
|
1517
|
+
// Same sync-to-camera pattern as the ViewCube, but pointer-events:none: orientation CHANGES stay the
|
|
1518
|
+
// cube's job; this only reads out. Colors are the CAD convention (X red, Y green, Z blue = --brand).
|
|
1519
|
+
const TRIAD_AXES = [['X', '#ef4444', [1, 0, 0]], ['Y', '#22c55e', [0, 1, 0]], ['Z', '#3b82f6', [0, 0, 1]]];
|
|
1520
|
+
function triadTip(label, color, pos) { // free-standing colored letter with a dark halo — legible over any geometry, no disc
|
|
1521
|
+
const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
|
|
1522
|
+
g.font = 'bold 46px ui-sans-serif,system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
1523
|
+
g.lineWidth = 8; g.lineJoin = 'round'; g.strokeStyle = 'rgba(2,8,23,.9)'; g.strokeText(label, 32, 34);
|
|
1524
|
+
g.fillStyle = color; g.fillText(label, 32, 34);
|
|
1525
|
+
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c) }));
|
|
1526
|
+
s.position.copy(pos); s.scale.setScalar(0.85);
|
|
1527
|
+
return s;
|
|
1528
|
+
}
|
|
1529
|
+
function initTriad() {
|
|
1530
|
+
const host = document.getElementById('m3dAxes'); if (!host) return;
|
|
1531
|
+
const PX = 78;
|
|
1532
|
+
const tr = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
1533
|
+
tr.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); tr.setSize(PX, PX);
|
|
1534
|
+
host.appendChild(tr.domElement);
|
|
1535
|
+
const ts = new THREE.Scene();
|
|
1536
|
+
const tc = new THREE.OrthographicCamera(-2.1, 2.1, 2.1, -2.1, 0.1, 20); tc.position.set(0, 0, 5);
|
|
1537
|
+
const g = new THREE.Group();
|
|
1538
|
+
const Y = new THREE.Vector3(0, 1, 0); // Cylinder/ConeGeometry's own axis
|
|
1539
|
+
for (const [label, color, dir] of TRIAD_AXES) {
|
|
1540
|
+
const d = new THREE.Vector3(dir[0], dir[1], dir[2]);
|
|
1541
|
+
const mat = new THREE.MeshBasicMaterial({ color });
|
|
1542
|
+
const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.05, 8), mat);
|
|
1543
|
+
shaft.quaternion.setFromUnitVectors(Y, d);
|
|
1544
|
+
shaft.position.copy(d).multiplyScalar(0.525);
|
|
1545
|
+
const head = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.34, 12), mat); // arrowhead at the shaft end
|
|
1546
|
+
head.quaternion.copy(shaft.quaternion);
|
|
1547
|
+
head.position.copy(d).multiplyScalar(1.22);
|
|
1548
|
+
g.add(shaft, head, triadTip(label, color, d.clone().multiplyScalar(1.62)));
|
|
1549
|
+
}
|
|
1550
|
+
g.add(new THREE.Mesh(new THREE.SphereGeometry(0.1, 12, 8), new THREE.MeshBasicMaterial({ color: 0xe2e8f0 }))); // origin dot
|
|
1551
|
+
ts.add(g);
|
|
1552
|
+
triad = { renderer: tr, scene: ts, cam: tc, group: g };
|
|
1553
|
+
}
|
|
1554
|
+
function syncTriad() { triad.group.quaternion.copy(camera.quaternion).invert(); }
|
|
1417
1555
|
// Mirror the scene from the main camera's direction (the FRONT face turns toward the viewer in a
|
|
1418
1556
|
// front view, etc.) by orienting the cube by the inverse of the camera's world rotation.
|
|
1419
1557
|
function syncCube() { cube.mesh.quaternion.copy(camera.quaternion).invert(); }
|
|
@@ -1518,6 +1656,234 @@ function rayToVerticalPlane(cx, cy, thru) {
|
|
|
1518
1656
|
}
|
|
1519
1657
|
const members = () => (api && api.getMembers && api.getMembers()) || [];
|
|
1520
1658
|
const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
|
|
1659
|
+
|
|
1660
|
+
// ---- working plane (spec §6): session view-state grounding ALL 3D picks. null = the active plan's
|
|
1661
|
+
// level (horizontal at default TOS) — logically always present, only DRAWN when explicitly set. ----
|
|
1662
|
+
let workPlane = null; // {origin:[x,y,z]mm, normal, xAxis} | null
|
|
1663
|
+
let wpMode = null; // 'face' | '3pt' — armed set-plane pick mode
|
|
1664
|
+
let wpDraft = null; // 3pt picks collected so far
|
|
1665
|
+
let wpVisible = true; // "Show plane" toggle (applies to an explicit plane)
|
|
1666
|
+
let wpGroup = null;
|
|
1667
|
+
function effectiveWP() { return workPlane || { origin: [0, 0, api ? api.defaultTosMm() : 0], normal: [0, 0, 1], xAxis: [1, 0, 0] }; }
|
|
1668
|
+
function renderWorkPlane() {
|
|
1669
|
+
if (wpGroup) { scene.remove(wpGroup); wpGroup.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); wpGroup = null; }
|
|
1670
|
+
if (!workPlane || !wpVisible) return; // the implicit level plane is not drawn — the 2D plan already communicates it
|
|
1671
|
+
const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis);
|
|
1672
|
+
const CELL = 1524, N = 12, half = (CELL * N) / 2; // 12 × 12 cells at 5 ft — a work patch, not an infinite plane
|
|
1673
|
+
const g = new THREE.Group();
|
|
1674
|
+
const verts = [];
|
|
1675
|
+
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); }
|
|
1676
|
+
const grid = new THREE.BufferGeometry(); grid.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
|
|
1677
|
+
g.add(new THREE.LineSegments(grid, new THREE.LineBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.28 })));
|
|
1678
|
+
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 }));
|
|
1679
|
+
g.add(fill);
|
|
1680
|
+
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 }));
|
|
1681
|
+
ax.renderOrder = 996; g.add(ax); // the brighter ray = the plane's local X
|
|
1682
|
+
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 }));
|
|
1683
|
+
ay.renderOrder = 996; g.add(ay); // the shorter, dimmer ray = local Y (tells the axes apart on a skewed plane)
|
|
1684
|
+
const od = new THREE.Mesh(new THREE.SphereGeometry(60, 12, 8), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
|
|
1685
|
+
g.add(od); // origin dot
|
|
1686
|
+
g.applyMatrix4(new THREE.Matrix4().makeBasis(new THREE.Vector3(...u), new THREE.Vector3(...v), new THREE.Vector3(...wp.normal)));
|
|
1687
|
+
g.position.set(wp.origin[0], wp.origin[1], wp.origin[2]);
|
|
1688
|
+
wpGroup = g; scene.add(g); // on `scene` (like epGroup) so rebuild() never wipes it; not in meshById → picking ignores it
|
|
1689
|
+
}
|
|
1690
|
+
function reflectWpBar() { const b = document.getElementById('m3dWp'); if (b) { b.classList.toggle('on', !!wpMode || !!workPlane);
|
|
1691
|
+
const kind = workPlane && workPlane.kind ? { face: 'Face', p3: '3pt', xy: 'XY', xz: 'XZ', yz: 'YZ' }[workPlane.kind] || '' : '';
|
|
1692
|
+
b.textContent = wpMode ? '◇ Plane ✕' : (kind ? '◇ Plane: ' + kind + ' ▾' : '◇ Plane ▾'); } } // the label reads the active plane without opening the menu (ux review)
|
|
1693
|
+
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)
|
|
1694
|
+
function setWorkPlanePrincipal(kind, offsetMm) {
|
|
1695
|
+
const off = offsetMm || 0;
|
|
1696
|
+
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];
|
|
1697
|
+
if (!def) return;
|
|
1698
|
+
workPlane = { origin: def.normal.map((c) => c * off), normal: def.normal, xAxis: def.xAxis, kind };
|
|
1699
|
+
wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar();
|
|
1700
|
+
}
|
|
1701
|
+
function clearWorkPlane() { workPlane = null; wpMode = null; wpDraft = null; renderWorkPlane(); reflectWpBar(); }
|
|
1702
|
+
function toggleWorkPlaneVisible(on) { wpVisible = on === undefined ? !wpVisible : !!on; renderWorkPlane(); }
|
|
1703
|
+
function workPlaneInfo() { return { set: !!workPlane, mode: wpMode, visible: wpVisible, plane: workPlane ? { origin: [...workPlane.origin], normal: [...workPlane.normal], xAxis: [...workPlane.xAxis] } : null }; }
|
|
1704
|
+
// face pick: the clip tool's raycast, minus the camera-side flip — the plane lands ON the face.
|
|
1705
|
+
function wpFacePick(cx, cy) {
|
|
1706
|
+
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
1707
|
+
const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
|
|
1708
|
+
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
1709
|
+
raycaster.setFromCamera(ndc, camera);
|
|
1710
|
+
const hit = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false)[0];
|
|
1711
|
+
if (!hit || !hit.face) return null;
|
|
1712
|
+
const n = hit.face.normal.clone().transformDirection(hit.object.matrixWorld).normalize();
|
|
1713
|
+
const b = planeBasis([n.x, n.y, n.z]);
|
|
1714
|
+
return { origin: [hit.point.x, hit.point.y, hit.point.z], normal: [n.x, n.y, n.z], xAxis: b.u, kind: 'face' };
|
|
1715
|
+
}
|
|
1716
|
+
// ray → the working plane (general orientation) → [x,y,z] mm, or null
|
|
1717
|
+
function rayToWP(cx, cy, wp) {
|
|
1718
|
+
camera.updateMatrixWorld();
|
|
1719
|
+
const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
|
|
1720
|
+
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
1721
|
+
raycaster.setFromCamera(ndc, camera);
|
|
1722
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(new THREE.Vector3(...wp.normal), new THREE.Vector3(...wp.origin));
|
|
1723
|
+
const hit = new THREE.Vector3();
|
|
1724
|
+
return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ---- 3D Move/Copy (spec §3+§6): the EDITOR owns the tool state (cmState via api); this side owns
|
|
1728
|
+
// picking/preview in scene mm against the working plane, and hands commit offsets back. ----
|
|
1729
|
+
let tfDraft = null; // {base:[mm], vA:[mm]|null}
|
|
1730
|
+
let tfLastShift = false; // shiftKey mirror for the rAF-synthetic hover event
|
|
1731
|
+
let tfLast = null; // last constrained preview point (HUD distance direction)
|
|
1732
|
+
let tfLastClient = null; // last pointer client [x,y] (HUD placement)
|
|
1733
|
+
let tfAxis = 'free'; // 'free' | 'u' | 'v' — X/Y force along the plane basis
|
|
1734
|
+
let tfCandidates = null;
|
|
1735
|
+
let tfGhost = null, tfRubber = null, tfRubberA = null, tfGhostMat = null;
|
|
1736
|
+
const cmActive = () => (api && api.cmState ? api.cmState() : null);
|
|
1737
|
+
const cmHasBase = () => !!tfDraft;
|
|
1738
|
+
const cmLastClient = () => tfLastClient;
|
|
1739
|
+
function setCmAxis(a) { tfAxis = a === 'x' ? 'u' : a === 'y' ? 'v' : 'free'; }
|
|
1740
|
+
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
|
|
1741
|
+
function cmEscape() { if (!tfDraft) return false; if (tfDraft.vA) { tfDraft.vA = null; tfPreviewClear(); return true; } tfDraft = null; tfPreviewClear(); return true; }
|
|
1742
|
+
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
|
|
1743
|
+
function tfPreviewClear() { tfDisposeGhost(); if (tfRubber) tfRubber.visible = false; if (tfRubberA) tfRubberA.visible = false; if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
|
|
1744
|
+
// snapped pick projected onto the working plane (Tekla: snaps win, then land on the plane)
|
|
1745
|
+
function wpPointAt(e) {
|
|
1746
|
+
const wp = effectiveWP();
|
|
1747
|
+
const hit = rayToWP(e.clientX, e.clientY, wp); if (!hit) return null;
|
|
1748
|
+
if (!e.altKey) {
|
|
1749
|
+
if (!tfCandidates) tfCandidates = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
|
|
1750
|
+
const r = snapPoint(hit, tfCandidates, toScreen, SNAP_TOL_PX);
|
|
1751
|
+
if (r.candidate) return { p: projectToPlane(r.snapped, wp.origin, wp.normal), snap: true, type: r.candidate.type };
|
|
1752
|
+
}
|
|
1753
|
+
return { p: hit, snap: false, type: null };
|
|
1754
|
+
}
|
|
1755
|
+
// Shift = dominant in-plane axis; X/Y force follows the plane basis (like the 2D local frame)
|
|
1756
|
+
function tfConstrain(p, e) {
|
|
1757
|
+
if (!tfDraft) return p;
|
|
1758
|
+
const wp = effectiveWP(), { u, v } = planeBasis(wp.normal, wp.xAxis), b = tfDraft.base;
|
|
1759
|
+
const d = [p[0] - b[0], p[1] - b[1], p[2] - b[2]], c = vecToPlane(d, u, v, wp.normal);
|
|
1760
|
+
let ax = tfAxis;
|
|
1761
|
+
if (e && e.shiftKey) ax = Math.abs(c.du) >= Math.abs(c.dv) ? 'u' : 'v';
|
|
1762
|
+
if (ax === 'u') return [b[0] + c.du * u[0], b[1] + c.du * u[1], b[2] + c.du * u[2]];
|
|
1763
|
+
if (ax === 'v') return [b[0] + c.dv * v[0], b[1] + c.dv * v[1], b[2] + c.dv * v[2]];
|
|
1764
|
+
return p;
|
|
1765
|
+
}
|
|
1766
|
+
// mirror of the editor's offset builders (mm side): counts INCLUDE the original, grid skips (0,0)
|
|
1767
|
+
function tfOffsetsFor(v, st) {
|
|
1768
|
+
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; };
|
|
1769
|
+
if (st.array) {
|
|
1770
|
+
if (!tfDraft.vA) return lin(v, Math.max(0, st.countA - 1));
|
|
1771
|
+
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;
|
|
1772
|
+
}
|
|
1773
|
+
return st.tool === 'copy' ? lin(v, st.count) : [v];
|
|
1774
|
+
}
|
|
1775
|
+
function tfGhostBuild(offs) {
|
|
1776
|
+
tfDisposeGhost(); // dispose the previous rebuild's own geometries (the >600 boxes) before dropping it
|
|
1777
|
+
tfGhost = new THREE.Group();
|
|
1778
|
+
if (!tfGhostMat) { tfGhostMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6, transparent: true, opacity: 0.3, depthWrite: false }); }
|
|
1779
|
+
const sel = [...selIds].map((id) => meshById.get(id)).filter((m) => m && m.visible);
|
|
1780
|
+
if (sel.length * offs.length <= 600) {
|
|
1781
|
+
for (const off of offs) for (const m of sel) {
|
|
1782
|
+
const c = new THREE.Mesh(m.geometry, tfGhostMat); // shares the source geometry — nothing to dispose
|
|
1783
|
+
c.applyMatrix4(m.matrixWorld); c.position.x += off[0]; c.position.y += off[1]; c.position.z += off[2];
|
|
1784
|
+
tfGhost.add(c);
|
|
1785
|
+
}
|
|
1786
|
+
} else {
|
|
1787
|
+
const bb = new THREE.Box3(); for (const m of sel) bb.expandByObject(m);
|
|
1788
|
+
const size = bb.getSize(new THREE.Vector3()), ctr = bb.getCenter(new THREE.Vector3());
|
|
1789
|
+
const boxGeom = new THREE.BoxGeometry(size.x, size.y, size.z); // ONE shared box for all fallback ghosts this rebuild
|
|
1790
|
+
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); }
|
|
1791
|
+
if (tfGhost.children[0]) tfGhost.children[0].userData._ownGeom = true; // dispose the shared box once via the first child on the next clear
|
|
1792
|
+
}
|
|
1793
|
+
scene.add(tfGhost);
|
|
1794
|
+
}
|
|
1795
|
+
function tfPreview(e) {
|
|
1796
|
+
const st = cmActive(); if (!st || !tfDraft) return;
|
|
1797
|
+
const r = wpPointAt(e); if (!r) return;
|
|
1798
|
+
const p = tfConstrain(r.p, e);
|
|
1799
|
+
tfLast = p; tfLastClient = [e.clientX, e.clientY];
|
|
1800
|
+
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;
|
|
1801
|
+
const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
|
|
1802
|
+
tfGhostBuild(tfOffsetsFor(v, st));
|
|
1803
|
+
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); }
|
|
1804
|
+
tfRubber.geometry.setFromPoints([new THREE.Vector3(...tfDraft.base), new THREE.Vector3(...p)]);
|
|
1805
|
+
tfRubber.visible = true;
|
|
1806
|
+
if (st.array && tfDraft.vA) {
|
|
1807
|
+
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); }
|
|
1808
|
+
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])]);
|
|
1809
|
+
tfRubberA.visible = true;
|
|
1810
|
+
}
|
|
1811
|
+
const L = Math.hypot(v[0], v[1], v[2]);
|
|
1812
|
+
readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft';
|
|
1813
|
+
readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : (st.tool === 'copy' && !st.array && st.count > 1 ? ' ×' + st.count : '');
|
|
1814
|
+
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
1815
|
+
}
|
|
1816
|
+
function tfClick(e) {
|
|
1817
|
+
const st = cmActive(); if (!st) return false;
|
|
1818
|
+
if (!selIds.size) { // selection drained while armed (undo/delete) — end the tool and let the click SELECT instead
|
|
1819
|
+
if (api && api.toast) api.toast('Selection is empty — ' + (st.tool === 'move' ? 'Move' : 'Copy') + ' ended');
|
|
1820
|
+
if (api && api.disarmTransform) api.disarmTransform();
|
|
1821
|
+
return false;
|
|
1822
|
+
}
|
|
1823
|
+
const r = wpPointAt(e); if (!r) return true;
|
|
1824
|
+
const p = tfConstrain(r.p, e);
|
|
1825
|
+
tfLastClient = [e.clientX, e.clientY];
|
|
1826
|
+
if (!tfDraft) { tfDraft = { base: p, vA: null }; tfCandidates = null; tfLast = p; return true; }
|
|
1827
|
+
const v = [p[0] - tfDraft.base[0], p[1] - tfDraft.base[1], p[2] - tfDraft.base[2]];
|
|
1828
|
+
if (Math.hypot(v[0], v[1], v[2]) < 5) return true; // ~zero pick (5 mm) — ignore, like the 2D 0.5 px guard
|
|
1829
|
+
if (st.array && !tfDraft.vA) { tfDraft.vA = v; return true; }
|
|
1830
|
+
const offs = tfOffsetsFor(v, st);
|
|
1831
|
+
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; }
|
|
1832
|
+
api.onTransform3d(offs);
|
|
1833
|
+
tfDraft = null; tfPreviewClear();
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
// HUD hand-off: the editor parses (inches), we turn it into a plane-space vector and run the same flow.
|
|
1837
|
+
function cmHudApply(pv) {
|
|
1838
|
+
const st = cmActive(); if (!st || !tfDraft) return { err: 'Pick a base point first' };
|
|
1839
|
+
const wp = effectiveWP(), { u, v: vv } = planeBasis(wp.normal, wp.xAxis);
|
|
1840
|
+
let v;
|
|
1841
|
+
if (pv.comp) { const IN = 25.4, du = pv.comp[0] * IN, dv = pv.comp[1] * IN, dn = (pv.comp[2] || 0) * IN;
|
|
1842
|
+
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]]; }
|
|
1843
|
+
else { if (!tfLast) return { err: 'Move the mouse to aim, or type dx,dy' };
|
|
1844
|
+
const dir = [tfLast[0] - tfDraft.base[0], tfLast[1] - tfDraft.base[1], tfLast[2] - tfDraft.base[2]];
|
|
1845
|
+
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' };
|
|
1846
|
+
const d = pv.dist * 25.4; v = [dir[0] / L * d, dir[1] / L * d, dir[2] / L * d]; }
|
|
1847
|
+
if (Math.hypot(v[0], v[1], v[2]) < 5) return { err: 'Zero distance — type a non-zero value' };
|
|
1848
|
+
if (st.array && !tfDraft.vA) { tfDraft.vA = v; tfPreviewClear(); return { ok: true, staged: true }; }
|
|
1849
|
+
const offs = tfOffsetsFor(v, st);
|
|
1850
|
+
if (!offs.length) { tfDraft = null; tfPreviewClear(); return { err: st.array ? 'A 1 × 1 array places no copies — raise a count' : 'Nothing to place' }; }
|
|
1851
|
+
api.onTransform3d(offs);
|
|
1852
|
+
tfDraft = null; tfPreviewClear();
|
|
1853
|
+
return { ok: true };
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// ---- draw a new member on the working plane (spec §7): two plane picks → api.onAddMember3d (scene
|
|
1857
|
+
// mm); the editor maps them to wp + per-end TOS and materializes a beam or (near-vertical) column. ----
|
|
1858
|
+
let drDraft = null; // first pick, scene mm
|
|
1859
|
+
let drRubber = null;
|
|
1860
|
+
const addActive = () => !!(api && api.addModeActive && api.addModeActive());
|
|
1861
|
+
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)
|
|
1862
|
+
function drShiftLock(p, e) { // Shift = ortho along the plane's local axes (same basis as Move/Copy)
|
|
1863
|
+
if (!e || !e.shiftKey || !drDraft) return p;
|
|
1864
|
+
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);
|
|
1865
|
+
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]];
|
|
1866
|
+
}
|
|
1867
|
+
function drClick(e) {
|
|
1868
|
+
const r = wpPointAt(e); if (!r) return;
|
|
1869
|
+
if (!drDraft) { drDraft = r.p; return; }
|
|
1870
|
+
const p = drShiftLock(r.p, e);
|
|
1871
|
+
if (Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]) < 50) return; // ~<2in — ignore a doubled click
|
|
1872
|
+
if (api && api.onAddMember3d) api.onAddMember3d(drDraft, p);
|
|
1873
|
+
drDraft = null; tfCandidates = null; // the new member is a snap target for the NEXT one — invalidate the shared snap cache
|
|
1874
|
+
if (drRubber) drRubber.visible = false; readout.style.display = 'none'; // stays armed for the next member
|
|
1875
|
+
}
|
|
1876
|
+
function drPreview(e) {
|
|
1877
|
+
const r = wpPointAt(e); if (!r) return;
|
|
1878
|
+
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;
|
|
1879
|
+
if (!drDraft) { readout.style.display = 'none'; return; }
|
|
1880
|
+
const p = drShiftLock(r.p, e);
|
|
1881
|
+
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); }
|
|
1882
|
+
drRubber.geometry.setFromPoints([new THREE.Vector3(...drDraft), new THREE.Vector3(...p)]); drRubber.visible = true;
|
|
1883
|
+
const L = Math.hypot(p[0] - drDraft[0], p[1] - drDraft[1], p[2] - drDraft[2]);
|
|
1884
|
+
readout._dist.textContent = api.fmtLen ? api.fmtLen(L) : (L / FT_MM).toFixed(2) + ' ft'; readout._type.textContent = r.snap ? ' · ' + (r.type || 'snap') : '';
|
|
1885
|
+
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
1886
|
+
}
|
|
1521
1887
|
// world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
|
|
1522
1888
|
// BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
|
|
1523
1889
|
// world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
|
|
@@ -1543,7 +1909,7 @@ function dimPointAt(e, anchor) {
|
|
|
1543
1909
|
let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
|
|
1544
1910
|
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; } }
|
|
1545
1911
|
const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
|
|
1546
|
-
if (!dimCandidates3d) dimCandidates3d =
|
|
1912
|
+
if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
|
|
1547
1913
|
const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
|
|
1548
1914
|
return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
|
|
1549
1915
|
}
|
|
@@ -1553,6 +1919,19 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
|
|
|
1553
1919
|
|
|
1554
1920
|
function onDown(e) {
|
|
1555
1921
|
if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
|
|
1922
|
+
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
|
|
1923
|
+
if (wpMode === '3pt') { e.stopPropagation();
|
|
1924
|
+
const r = dimPointAt(e); if (!r) return;
|
|
1925
|
+
wpDraft = wpDraft || []; wpDraft.push(r.p);
|
|
1926
|
+
marker.position.set(r.p[0], r.p[1], r.p[2]); marker.scale.setScalar(markerSize()); marker.visible = true;
|
|
1927
|
+
if (wpDraft.length === 3) { const wp = planeFrom3Points(wpDraft[0], wpDraft[1], wpDraft[2]);
|
|
1928
|
+
wpDraft = null; wpMode = null; marker.visible = false; canvasEl.style.cursor = 'default';
|
|
1929
|
+
if (wp) { wp.kind = 'p3'; workPlane = wp; renderWorkPlane(); if (api && api.toast) api.toast('Working plane set from 3 points'); }
|
|
1930
|
+
else if (api && api.toast) api.toast('Points are collinear — plane not set');
|
|
1931
|
+
reflectWpBar(); }
|
|
1932
|
+
return; }
|
|
1933
|
+
if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
|
|
1934
|
+
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
|
|
1556
1935
|
if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
|
|
1557
1936
|
if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
|
|
1558
1937
|
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
|
|
@@ -1587,7 +1966,7 @@ function onDown(e) {
|
|
|
1587
1966
|
id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
|
|
1588
1967
|
grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
|
|
1589
1968
|
origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
|
|
1590
|
-
candidates:
|
|
1969
|
+
candidates: allCandidates(id),
|
|
1591
1970
|
levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
|
|
1592
1971
|
mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
|
|
1593
1972
|
};
|
|
@@ -1618,7 +1997,7 @@ function startEndpointGrab(ep, e) {
|
|
|
1618
1997
|
pending = {
|
|
1619
1998
|
epDrag: true, id: ep.id, end: ep.end, ppf,
|
|
1620
1999
|
planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
|
|
1621
|
-
candidates:
|
|
2000
|
+
candidates: allCandidates(ep.id),
|
|
1622
2001
|
newPt: [g.line[ep.end][0], g.line[ep.end][1]],
|
|
1623
2002
|
};
|
|
1624
2003
|
dragEp = { id: ep.id, end: ep.end };
|
|
@@ -1756,10 +2135,26 @@ function closestOnSeg3(p, a, b) {
|
|
|
1756
2135
|
function onHoverMove(e) {
|
|
1757
2136
|
lastHoverXY = [e.clientX, e.clientY];
|
|
1758
2137
|
dimLastAlt = e.altKey; // remembered so the rAF body can rebuild a synthetic event
|
|
2138
|
+
tfLastShift = e.shiftKey; // same for the Move/Copy ortho lock
|
|
1759
2139
|
if (hoverRAF || pending || dragging || boxSel) return;
|
|
1760
2140
|
hoverRAF = requestAnimationFrame(() => {
|
|
1761
2141
|
hoverRAF = 0;
|
|
1762
2142
|
if (!lastHoverXY || pending || dragging || boxSel) return;
|
|
2143
|
+
if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
|
|
2144
|
+
canvasEl.style.cursor = 'crosshair';
|
|
2145
|
+
if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
|
|
2146
|
+
if (r && !(wpDraft && wpDraft.length)) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } }
|
|
2147
|
+
updateStatusChip(); return; }
|
|
2148
|
+
if (addActive()) { // Add-member armed: snap marker + rubber preview on the working plane
|
|
2149
|
+
canvasEl.style.cursor = 'crosshair';
|
|
2150
|
+
drPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
|
|
2151
|
+
updateStatusChip(); return; }
|
|
2152
|
+
if (cmActive()) { // Move/Copy armed: previews ride the working plane
|
|
2153
|
+
canvasEl.style.cursor = 'crosshair';
|
|
2154
|
+
if (tfDraft) tfPreview({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt, shiftKey: tfLastShift });
|
|
2155
|
+
else { const r = wpPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
|
|
2156
|
+
if (r && r.snap) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false; }
|
|
2157
|
+
updateStatusChip(); return; }
|
|
1763
2158
|
if (clipMode) { canvasEl.style.cursor = 'crosshair'; if (clipMode === 'box') clipBoxPreviewAt(lastHoverXY[0], lastHoverXY[1]); return; } // armed clip pick: crosshair (+ live box preview while drawing)
|
|
1764
2159
|
if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
|
|
1765
2160
|
canvasEl.style.cursor = 'crosshair';
|
|
@@ -1794,7 +2189,13 @@ function updateStatusChip() {
|
|
|
1794
2189
|
const rect = canvasEl.getBoundingClientRect();
|
|
1795
2190
|
let txt = '';
|
|
1796
2191
|
const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
|
|
1797
|
-
if (
|
|
2192
|
+
if (wpMode === 'face') txt = 'Click a member face — the working plane lands on it · Esc to cancel';
|
|
2193
|
+
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] || '';
|
|
2194
|
+
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';
|
|
2195
|
+
else if (cmActive()) { const st = cmActive(), lock = tfAxis === 'u' ? ' · X locked' : tfAxis === 'v' ? ' · Y locked' : '';
|
|
2196
|
+
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')
|
|
2197
|
+
: (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; }
|
|
2198
|
+
else if (clipMode === 'plane') txt = 'Click a face to set a clip plane · Esc to cancel';
|
|
1798
2199
|
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';
|
|
1799
2200
|
else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
|
|
1800
2201
|
else if (selIds.size > 1) txt = `${selIds.size} members selected`;
|
|
@@ -1810,6 +2211,8 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
|
|
|
1810
2211
|
function hide() {
|
|
1811
2212
|
if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
|
|
1812
2213
|
if (clipMode) setClipMode(null); // disarm the clip-plane pick too (no stale armed state on return)
|
|
2214
|
+
if (wpMode) { wpMode = null; wpDraft = null; if (marker) marker.visible = false; reflectWpBar(); } // drop an in-progress set-plane pick — else a stale face/3pt pick steals the first click on return
|
|
2215
|
+
cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
|
|
1813
2216
|
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
1814
2217
|
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
1815
2218
|
}
|
|
@@ -1826,10 +2229,12 @@ function dispose() {
|
|
|
1826
2229
|
window.removeEventListener('keydown', onKey);
|
|
1827
2230
|
for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
|
|
1828
2231
|
dimLabelHost = null; dimLabelPool.length = 0;
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
2232
|
+
for (const w of [cube, triad]) { // both mini-widgets own a WebGL context — leak one and re-init eventually hits the browser's context cap
|
|
2233
|
+
if (!w) continue;
|
|
2234
|
+
w.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(); } });
|
|
2235
|
+
w.renderer.dispose(); if (w.renderer.domElement.parentNode) w.renderer.domElement.parentNode.removeChild(w.renderer.domElement);
|
|
1832
2236
|
}
|
|
2237
|
+
cube = triad = null;
|
|
1833
2238
|
if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
|
|
1834
2239
|
if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
|
|
1835
2240
|
if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines
|
|
@@ -1838,6 +2243,9 @@ function dispose() {
|
|
|
1838
2243
|
for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
1839
2244
|
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
1840
2245
|
dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
|
|
2246
|
+
clearStructGrid();
|
|
2247
|
+
for (const tex of gridTexCache.values()) tex.dispose();
|
|
2248
|
+
gridTexCache.clear();
|
|
1841
2249
|
clearRoot();
|
|
1842
2250
|
if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
|
|
1843
2251
|
clearClipGizmo(); setClipPreview(null); overlayScene = null;
|
|
@@ -1866,6 +2274,7 @@ function debug() {
|
|
|
1866
2274
|
target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
|
|
1867
2275
|
near: camera?.near, far: camera?.far,
|
|
1868
2276
|
clips: getClips(), clipPlaneCount: renderer && renderer.clippingPlanes ? renderer.clippingPlanes.length : 0, clipMode, workArea: workAreaState(), selectedClips: [...selectedClipIds],
|
|
2277
|
+
workPlane: workPlaneInfo(), tfHasBase: !!tfDraft, tfGhosts: tfGhost ? tfGhost.children.length : 0, drHasStart: !!drDraft,
|
|
1869
2278
|
};
|
|
1870
2279
|
}
|
|
1871
2280
|
// test helper: the current material colour + emissive of a member (verifies deselect restores colour)
|
|
@@ -1891,10 +2300,15 @@ window.Steel3DView = {
|
|
|
1891
2300
|
init, show, hide, rebuild, setSelection, isReady, dispose,
|
|
1892
2301
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
1893
2302
|
setRefLine, refLine: () => refLineOn,
|
|
2303
|
+
refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
|
|
2304
|
+
gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
|
|
1894
2305
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
1895
2306
|
setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
|
|
1896
2307
|
isolateSelected, clearIsolation, isIsolated,
|
|
1897
2308
|
workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
|
|
2309
|
+
armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
|
|
2310
|
+
cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
|
|
2311
|
+
drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
|
|
1898
2312
|
toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
|
|
1899
2313
|
refreshOverlayDims,
|
|
1900
2314
|
overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests
|