@floless/app 0.62.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 +2 -2
- 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 +376 -7
- package/dist/web/steel-editor.html +414 -32
- package/package.json +1 -1
|
@@ -98,8 +98,9 @@ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
|
|
|
98
98
|
return [...set].sort((a, b) => a - b);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Lower wins when two candidates are both within tolerance.
|
|
102
|
-
|
|
101
|
+
// Lower wins when two candidates are both within tolerance. Grid intersections rank with member
|
|
102
|
+
// intersections (columns land on them); grid lines below member centerlines.
|
|
103
|
+
const PRECEDENCE = { vertex: 0, intersection: 1, 'grid-int': 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
|
|
103
104
|
|
|
104
105
|
function closestOnSeg(p, a, b) {
|
|
105
106
|
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
|
@@ -113,7 +114,9 @@ function closestOnSeg(p, a, b) {
|
|
|
113
114
|
function candidatePoint(c, dragged) {
|
|
114
115
|
if (c.type === 'vertex' || c.type === 'intersection' || c.type === 'midpoint' || c.type === 'level') return c.p; // fixed points
|
|
115
116
|
if (c.type === 'vertical-axis') return [c.p[0], c.p[1], dragged[2]]; // lock plan X/Y to the axis, keep dragged Z
|
|
116
|
-
|
|
117
|
+
if (c.type === 'grid-int') return [c.p[0], c.p[1], dragged[2]]; // grid steers PLAN only — never yanks the elevation
|
|
118
|
+
if (c.type === 'grid-line') { const q = closestOnSeg(dragged, c.a, c.b); return [q[0], q[1], dragged[2]]; }
|
|
119
|
+
return closestOnSeg(dragged, c.a, c.b); // centerline
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
/**
|
|
@@ -136,6 +139,49 @@ export function snapPoint(dragged, candidates, toScreen, tolPx) {
|
|
|
136
139
|
return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
// ---- working-plane math (CAD copy/move epic §6) — pure vector helpers, scene-mm space ----
|
|
143
|
+
const _dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
144
|
+
const _cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
145
|
+
const _norm = (a) => { const L = Math.hypot(a[0], a[1], a[2]); return L > 1e-12 ? [a[0] / L, a[1] / L, a[2] / L] : null; };
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Orthonormal in-plane basis {u,v} for a plane normal. xHint (default world-X) projects onto the
|
|
149
|
+
* plane as U; when the hint is ~parallel to the normal it falls back to world-Y so U never
|
|
150
|
+
* degenerates. v = n × u keeps the frame right-handed (u × v = n).
|
|
151
|
+
*/
|
|
152
|
+
export function planeBasis(normal, xHint) {
|
|
153
|
+
const n = _norm(normal) || [0, 0, 1];
|
|
154
|
+
let u = null;
|
|
155
|
+
for (const hint of [xHint, [1, 0, 0], [0, 1, 0]]) {
|
|
156
|
+
if (!hint) continue;
|
|
157
|
+
const d = _dot(hint, n);
|
|
158
|
+
u = _norm([hint[0] - d * n[0], hint[1] - d * n[1], hint[2] - d * n[2]]);
|
|
159
|
+
if (u) break;
|
|
160
|
+
}
|
|
161
|
+
return { u, v: _cross(n, u) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Project p onto the plane (origin,normal): p − ((p−o)·n̂)n̂ — the nearest on-plane point. */
|
|
165
|
+
export function projectToPlane(p, origin, normal) {
|
|
166
|
+
const n = _norm(normal) || [0, 0, 1];
|
|
167
|
+
const d = (p[0] - origin[0]) * n[0] + (p[1] - origin[1]) * n[1] + (p[2] - origin[2]) * n[2];
|
|
168
|
+
return [p[0] - d * n[0], p[1] - d * n[1], p[2] - d * n[2]];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Plane from 3 picked points (Tekla flow): origin=a, xAxis toward b, normal = x̂ × (c−a). null when degenerate. */
|
|
172
|
+
export function planeFrom3Points(a, b, c) {
|
|
173
|
+
const xAxis = _norm([b[0] - a[0], b[1] - a[1], b[2] - a[2]]);
|
|
174
|
+
if (!xAxis) return null;
|
|
175
|
+
const normal = _norm(_cross(xAxis, [c[0] - a[0], c[1] - a[1], c[2] - a[2]]));
|
|
176
|
+
if (!normal) return null;
|
|
177
|
+
return { origin: [a[0], a[1], a[2]], xAxis, normal };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** World vector → components along the plane basis: {du,dv,dn}. Reconstruct via du·u+dv·v+dn·n. */
|
|
181
|
+
export function vecToPlane(vec, u, v, n) {
|
|
182
|
+
return { du: _dot(vec, u), dv: _dot(vec, v), dn: _dot(vec, n) };
|
|
183
|
+
}
|
|
184
|
+
|
|
139
185
|
/**
|
|
140
186
|
* 3D dimension geometry (pure). a,b are scene mm [x,y,z]. axis ∈ free|x|y|z.
|
|
141
187
|
* free → the straight segment a→b (valueMm = full 3D distance); x|y|z →
|
|
@@ -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
|
|
@@ -455,6 +456,7 @@ function buildFromScene(sc) {
|
|
|
455
456
|
dimParts = (sc.elements || []).filter((el) => el && (el.kind === 'plate' || el.kind === 'cut'));
|
|
456
457
|
renderClipGizmo(); // re-anchor the selected clip's gizmo to the (possibly new) sceneBox after a rebuild
|
|
457
458
|
buildGrid(box);
|
|
459
|
+
buildStructGrid();
|
|
458
460
|
buildRefLines();
|
|
459
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
|
|
460
462
|
if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
|
|
@@ -496,6 +498,97 @@ function buildRefLines() {
|
|
|
496
498
|
}
|
|
497
499
|
function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
|
|
498
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
|
+
|
|
499
592
|
// The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
|
|
500
593
|
// to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
|
|
501
594
|
// model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
|
|
@@ -662,6 +755,7 @@ function rebuildClipPlanes(c) { // recompute a clip's .planes from its ed
|
|
|
662
755
|
}
|
|
663
756
|
function setClipMode(m) { // arm/disarm a clip pick: 'plane' (click a face) | 'box' (draw 2 corners) | null
|
|
664
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)
|
|
665
759
|
clipMode = (m === 'plane' || m === 'box') ? m : null;
|
|
666
760
|
clipBoxDraft = null; setClipPreview(null);
|
|
667
761
|
if (canvasEl) canvasEl.style.cursor = clipMode ? 'crosshair' : 'default';
|
|
@@ -984,6 +1078,7 @@ function onKey(e) {
|
|
|
984
1078
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
|
|
985
1079
|
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
986
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
|
|
987
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
|
|
988
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)
|
|
989
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)
|
|
@@ -997,7 +1092,7 @@ function onKey(e) {
|
|
|
997
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
|
|
998
1093
|
}
|
|
999
1094
|
// 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; }
|
|
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)
|
|
1001
1096
|
const a = ARROWS[e.key]; if (!a) return;
|
|
1002
1097
|
const [hx, hy] = a;
|
|
1003
1098
|
if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
|
|
@@ -1008,7 +1103,7 @@ function onKey(e) {
|
|
|
1008
1103
|
function toggleDimTool() {
|
|
1009
1104
|
dimMode3d = !dimMode3d;
|
|
1010
1105
|
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 (
|
|
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)
|
|
1012
1107
|
reflectDimBar();
|
|
1013
1108
|
return dimMode3d;
|
|
1014
1109
|
}
|
|
@@ -1561,6 +1656,234 @@ function rayToVerticalPlane(cx, cy, thru) {
|
|
|
1561
1656
|
}
|
|
1562
1657
|
const members = () => (api && api.getMembers && api.getMembers()) || [];
|
|
1563
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
|
+
}
|
|
1564
1887
|
// world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
|
|
1565
1888
|
// BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
|
|
1566
1889
|
// world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
|
|
@@ -1586,7 +1909,7 @@ function dimPointAt(e, anchor) {
|
|
|
1586
1909
|
let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
|
|
1587
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; } }
|
|
1588
1911
|
const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
|
|
1589
|
-
if (!dimCandidates3d) dimCandidates3d =
|
|
1912
|
+
if (!dimCandidates3d) dimCandidates3d = allCandidates(null);
|
|
1590
1913
|
const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
|
|
1591
1914
|
return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
|
|
1592
1915
|
}
|
|
@@ -1596,6 +1919,19 @@ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
|
|
|
1596
1919
|
|
|
1597
1920
|
function onDown(e) {
|
|
1598
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
|
|
1599
1935
|
if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
|
|
1600
1936
|
if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
|
|
1601
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
|
|
@@ -1630,7 +1966,7 @@ function onDown(e) {
|
|
|
1630
1966
|
id, ctrl: e.ctrlKey || e.metaKey, ppf, planeZ,
|
|
1631
1967
|
grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
|
|
1632
1968
|
origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
|
|
1633
|
-
candidates:
|
|
1969
|
+
candidates: allCandidates(id),
|
|
1634
1970
|
levels: elevationLevels(members(), ppf, dtos, id), // Alt-drag (vertical) snaps to these T.O.S levels
|
|
1635
1971
|
mesh, meshPos0: mesh ? mesh.position.clone() : null, delta: null, mode: null,
|
|
1636
1972
|
};
|
|
@@ -1661,7 +1997,7 @@ function startEndpointGrab(ep, e) {
|
|
|
1661
1997
|
pending = {
|
|
1662
1998
|
epDrag: true, id: ep.id, end: ep.end, ppf,
|
|
1663
1999
|
planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
|
|
1664
|
-
candidates:
|
|
2000
|
+
candidates: allCandidates(ep.id),
|
|
1665
2001
|
newPt: [g.line[ep.end][0], g.line[ep.end][1]],
|
|
1666
2002
|
};
|
|
1667
2003
|
dragEp = { id: ep.id, end: ep.end };
|
|
@@ -1799,10 +2135,26 @@ function closestOnSeg3(p, a, b) {
|
|
|
1799
2135
|
function onHoverMove(e) {
|
|
1800
2136
|
lastHoverXY = [e.clientX, e.clientY];
|
|
1801
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
|
|
1802
2139
|
if (hoverRAF || pending || dragging || boxSel) return;
|
|
1803
2140
|
hoverRAF = requestAnimationFrame(() => {
|
|
1804
2141
|
hoverRAF = 0;
|
|
1805
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; }
|
|
1806
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)
|
|
1807
2159
|
if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
|
|
1808
2160
|
canvasEl.style.cursor = 'crosshair';
|
|
@@ -1837,7 +2189,13 @@ function updateStatusChip() {
|
|
|
1837
2189
|
const rect = canvasEl.getBoundingClientRect();
|
|
1838
2190
|
let txt = '';
|
|
1839
2191
|
const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
|
|
1840
|
-
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';
|
|
1841
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';
|
|
1842
2200
|
else if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
|
|
1843
2201
|
else if (selIds.size > 1) txt = `${selIds.size} members selected`;
|
|
@@ -1853,6 +2211,8 @@ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasE
|
|
|
1853
2211
|
function hide() {
|
|
1854
2212
|
if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
|
|
1855
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
|
|
1856
2216
|
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
1857
2217
|
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
1858
2218
|
}
|
|
@@ -1883,6 +2243,9 @@ function dispose() {
|
|
|
1883
2243
|
for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
1884
2244
|
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
1885
2245
|
dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
|
|
2246
|
+
clearStructGrid();
|
|
2247
|
+
for (const tex of gridTexCache.values()) tex.dispose();
|
|
2248
|
+
gridTexCache.clear();
|
|
1886
2249
|
clearRoot();
|
|
1887
2250
|
if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
|
|
1888
2251
|
clearClipGizmo(); setClipPreview(null); overlayScene = null;
|
|
@@ -1911,6 +2274,7 @@ function debug() {
|
|
|
1911
2274
|
target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
|
|
1912
2275
|
near: camera?.near, far: camera?.far,
|
|
1913
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,
|
|
1914
2278
|
};
|
|
1915
2279
|
}
|
|
1916
2280
|
// test helper: the current material colour + emissive of a member (verifies deselect restores colour)
|
|
@@ -1936,10 +2300,15 @@ window.Steel3DView = {
|
|
|
1936
2300
|
init, show, hide, rebuild, setSelection, isReady, dispose,
|
|
1937
2301
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
1938
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
|
|
1939
2305
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
1940
2306
|
setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
|
|
1941
2307
|
isolateSelected, clearIsolation, isIsolated,
|
|
1942
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; },
|
|
1943
2312
|
toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
|
|
1944
2313
|
refreshOverlayDims,
|
|
1945
2314
|
overlayDimsInfo: () => computeOverlayDims().map((s) => ({ label: s.label, cat: s.cat })), // the dims currently emitted (toggle + visibility applied) — for tests
|