@floless/app 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/floless-server.cjs
CHANGED
|
@@ -52856,7 +52856,7 @@ function appVersion() {
|
|
|
52856
52856
|
return resolveVersion({
|
|
52857
52857
|
isSea: isSea2(),
|
|
52858
52858
|
sqVersionXml: readSqVersionXml(),
|
|
52859
|
-
define: true ? "0.
|
|
52859
|
+
define: true ? "0.45.1" : void 0,
|
|
52860
52860
|
pkgVersion: readPkgVersion()
|
|
52861
52861
|
});
|
|
52862
52862
|
}
|
|
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
|
|
|
52866
52866
|
return "dev";
|
|
52867
52867
|
}
|
|
52868
52868
|
function appChannel() {
|
|
52869
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52869
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.45.1" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// workflow-update.ts
|
|
@@ -53850,6 +53850,7 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53850
53850
|
if (idx < 0) throw new Error("no contract-emitting node to bake the contract into");
|
|
53851
53851
|
doc2.setIn(["nodes", idx, "config", "contract"], contract.type);
|
|
53852
53852
|
const baked = { ...contract };
|
|
53853
|
+
delete baked.dims3d;
|
|
53853
53854
|
if (Array.isArray(contract.plans)) {
|
|
53854
53855
|
baked.plans = contract.plans.map((p) => {
|
|
53855
53856
|
if (p && typeof p === "object") {
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"additionalProperties": false
|
|
38
38
|
},
|
|
39
39
|
"plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } },
|
|
40
|
-
"filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage dataset from one compose-time PyMuPDF pass. The saved selection (per-facet `on` flags + `mode`) scopes which lines the reader binds profile labels to; replaces the length heuristic, fallback when absent. CONFIDENTIAL: `page.bg_b64` is machine-local, never committed." }
|
|
40
|
+
"filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage dataset from one compose-time PyMuPDF pass. The saved selection (per-facet `on` flags + `mode`) scopes which lines the reader binds profile labels to; replaces the length heuristic, fallback when absent. CONFIDENTIAL: `page.bg_b64` is machine-local, never committed." },
|
|
41
|
+
"dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." }
|
|
41
42
|
},
|
|
42
43
|
"$defs": {
|
|
43
44
|
"filter": {
|
|
@@ -126,6 +127,13 @@
|
|
|
126
127
|
"maxItems": 2,
|
|
127
128
|
"description": "An [x,y] pair in display space (or [fx,fy] normalized for bubbles)."
|
|
128
129
|
},
|
|
130
|
+
"point3": {
|
|
131
|
+
"type": "array",
|
|
132
|
+
"items": { "type": "number" },
|
|
133
|
+
"minItems": 3,
|
|
134
|
+
"maxItems": 3,
|
|
135
|
+
"description": "An [x,y,z] point in world-scene mm (Z-up), matching contract-to-scene output."
|
|
136
|
+
},
|
|
129
137
|
"plan": {
|
|
130
138
|
"type": "object",
|
|
131
139
|
"required": ["sheet", "members"],
|
|
@@ -176,6 +184,17 @@
|
|
|
176
184
|
"rot": { "type": "number", "description": "Local-frame bearing (radians) baked at creation; 0 = global axes. x/y measure along this rotated frame so resetting the plan frame never moves placed dims." }
|
|
177
185
|
}
|
|
178
186
|
},
|
|
187
|
+
"dim3": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"required": ["id", "a", "b", "axis"],
|
|
190
|
+
"additionalProperties": true,
|
|
191
|
+
"properties": {
|
|
192
|
+
"id": { "type": "string" },
|
|
193
|
+
"a": { "$ref": "#/$defs/point3", "description": "First snapped anchor, world-scene mm." },
|
|
194
|
+
"b": { "$ref": "#/$defs/point3", "description": "Second snapped anchor, world-scene mm." },
|
|
195
|
+
"axis": { "enum": ["free", "x", "y", "z"], "description": "free = 3D distance ‖b-a‖; x|y|z = the component along that world axis. The dim line is drawn axis-aligned for x|y|z." }
|
|
196
|
+
}
|
|
197
|
+
},
|
|
179
198
|
"frame": {
|
|
180
199
|
"type": ["object", "null"],
|
|
181
200
|
"description": "Optional per-plan local coordinate frame for skewed framing. null/absent = global axes. Affects only editor ortho-snapping + X/Y dimensions; NOT baked into the lock / 3D / IFC / BOM.",
|
|
@@ -135,3 +135,22 @@ export function snapPoint(dragged, candidates, toScreen, tolPx) {
|
|
|
135
135
|
}
|
|
136
136
|
return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 3D dimension geometry (pure). a,b are scene mm [x,y,z]. axis ∈ free|x|y|z.
|
|
141
|
+
* free → the straight segment a→b (valueMm = full 3D distance); x|y|z →
|
|
142
|
+
* axis-aligned from a, advancing only that one world component to b
|
|
143
|
+
* (valueMm = that component's length). Returns the drawn endpoints, the
|
|
144
|
+
* midpoint (where the label sits), and the measured length in mm.
|
|
145
|
+
*/
|
|
146
|
+
export function dim3dGeom(a, b, axis) {
|
|
147
|
+
const p1 = [a[0], a[1], a[2]];
|
|
148
|
+
let p2;
|
|
149
|
+
if (axis === 'x') p2 = [b[0], a[1], a[2]];
|
|
150
|
+
else if (axis === 'y') p2 = [a[0], b[1], a[2]];
|
|
151
|
+
else if (axis === 'z') p2 = [a[0], a[1], b[2]];
|
|
152
|
+
else p2 = [b[0], b[1], b[2]]; // free (default for any unrecognised axis)
|
|
153
|
+
const valueMm = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]);
|
|
154
|
+
const mid = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
|
|
155
|
+
return { p1, p2, mid, valueMm };
|
|
156
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import * as THREE from 'three';
|
|
18
18
|
import { OrbitControls } from 'three/addons/OrbitControls.js';
|
|
19
|
-
import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels } from './steel-3d-core.js';
|
|
19
|
+
import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom } from './steel-3d-core.js';
|
|
20
20
|
|
|
21
21
|
let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
|
|
22
22
|
let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
|
|
@@ -44,6 +44,18 @@ const SELECT_EMISSIVE = 0x3b82f6; // --brand: selected members glow blue
|
|
|
44
44
|
const EP_START = 0xfacc15; // start endpoint (end 1) — yellow
|
|
45
45
|
const EP_END = 0xf472b6; // end endpoint (end 2) — magenta
|
|
46
46
|
|
|
47
|
+
// ---- 3D dimension tool ----
|
|
48
|
+
let dimMode3d = false; // tool armed
|
|
49
|
+
let dimAxis3d = 'free'; // sticky axis: free | x | y | z
|
|
50
|
+
let dims3dVisibleFlag = true; // show/hide placed dims
|
|
51
|
+
let dimDraft3d = null; // { a:[x,y,z] } once the first point is placed (awaiting the 2nd)
|
|
52
|
+
let dimLastAlt = false; // last-seen Alt state (the hover rAF rebuilds a synthetic event)
|
|
53
|
+
let dims3dGroup = null; // THREE.Group of placed-dim lines (+ the live preview line)
|
|
54
|
+
let dimPreviewLine = null; // reused preview line (point 1 → cursor)
|
|
55
|
+
let dimLabelHost = null; // fixed-position container for label overlays
|
|
56
|
+
const dimLabelPool = []; // reused <div> labels, one per placed dim, positioned in the loop
|
|
57
|
+
let dimCandidates3d = null; // snap candidates cached for the active placement gesture
|
|
58
|
+
|
|
47
59
|
function init(canvas, theApi) {
|
|
48
60
|
if (renderer) return; // once
|
|
49
61
|
canvasEl = canvas; api = theApi;
|
|
@@ -91,6 +103,14 @@ function init(canvas, theApi) {
|
|
|
91
103
|
readout._type = document.createElement('span'); readout._type.style.color = 'var(--mut,#94a3b8)';
|
|
92
104
|
readout.append(readout._dist, readout._type);
|
|
93
105
|
document.body.appendChild(readout);
|
|
106
|
+
// 3D dimension tool: a group of placed-dim lines (over the steel) + a live preview line + an HTML
|
|
107
|
+
// overlay host for the midpoint labels (positioned each frame so they face the camera as screen text).
|
|
108
|
+
dims3dGroup = new THREE.Group(); scene.add(dims3dGroup);
|
|
109
|
+
dimPreviewLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee }));
|
|
110
|
+
dimPreviewLine.material.depthTest = false; dimPreviewLine.renderOrder = 997; dimPreviewLine.visible = false; scene.add(dimPreviewLine);
|
|
111
|
+
dimLabelHost = document.createElement('div');
|
|
112
|
+
dimLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:57';
|
|
113
|
+
document.body.appendChild(dimLabelHost);
|
|
94
114
|
// persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
|
|
95
115
|
hoverChip = document.createElement('div');
|
|
96
116
|
hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
|
|
@@ -130,6 +150,7 @@ function loop() {
|
|
|
130
150
|
rafId = requestAnimationFrame(loop);
|
|
131
151
|
controls.update();
|
|
132
152
|
sizeEndpoints();
|
|
153
|
+
positionDimLabels();
|
|
133
154
|
renderer.render(scene, camera);
|
|
134
155
|
if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
|
|
135
156
|
}
|
|
@@ -328,6 +349,7 @@ async function rebuild(fit = false) {
|
|
|
328
349
|
const sc = await api.fetchScene(); // editor POSTs the current contract to /scene
|
|
329
350
|
if (seq !== rebuildSeq) return; // a newer rebuild superseded this one → drop the stale scene (no out-of-order apply)
|
|
330
351
|
if (sc) buildFromScene(sc);
|
|
352
|
+
refreshDims(); // persisted dims appear + survive edit-rebuilds (re-coloured for the current selection)
|
|
331
353
|
}
|
|
332
354
|
|
|
333
355
|
// ---- Persp/Ortho + Solid/Wire/X-ray + group visibility (parity with the AWARE viewer) ----
|
|
@@ -406,12 +428,40 @@ function onKey(e) {
|
|
|
406
428
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
|
|
407
429
|
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
408
430
|
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)
|
|
431
|
+
const k = e.key.toLowerCase();
|
|
432
|
+
// Don't touch the dim tool while a member gesture (drag / box-select) owns the shared marker/readout —
|
|
433
|
+
// toggling would clear them mid-gesture. The gesture finishes on pointerup; the key is a no-op until then.
|
|
434
|
+
const midGesture = pending || dragging || boxSel;
|
|
435
|
+
if (k === 'd' && !e.ctrlKey && !e.metaKey && !e.altKey && !midGesture) { e.preventDefault(); toggleDimTool(); return; } // D arms/disarms the dimension tool
|
|
436
|
+
if (dimMode3d && !midGesture) {
|
|
437
|
+
if (k === 'escape') { e.preventDefault(); if (dimDraft3d) { dimDraft3d = null; dimPreviewLine.visible = false; readout.style.display = 'none'; } else toggleDimTool(); return; } // Esc: cancel the in-progress dim, else exit the tool
|
|
438
|
+
if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
|
|
439
|
+
}
|
|
409
440
|
const a = ARROWS[e.key]; if (!a) return;
|
|
410
441
|
const [hx, hy] = a;
|
|
411
442
|
if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
|
|
412
443
|
else if (e.shiftKey) { e.preventDefault(); rotateView(-hx * Math.PI / 36, hy * Math.PI / 36); } // 5°
|
|
413
444
|
else { e.preventDefault(); panView(hx, hy); } // pan
|
|
414
445
|
}
|
|
446
|
+
// ---- 3D dimension tool controls (driven from the toolbar + the keyboard) ----
|
|
447
|
+
function toggleDimTool() {
|
|
448
|
+
dimMode3d = !dimMode3d;
|
|
449
|
+
if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
|
|
450
|
+
else { controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // show the crosshair on arm, before the first mousemove
|
|
451
|
+
reflectDimBar();
|
|
452
|
+
return dimMode3d;
|
|
453
|
+
}
|
|
454
|
+
function setDimAxis(a) { if (['free', 'x', 'y', 'z'].includes(a)) dimAxis3d = a; }
|
|
455
|
+
function dimAxis() { return dimAxis3d; }
|
|
456
|
+
function setDims3dVisible(on) { dims3dVisibleFlag = !!on; refreshDims(); }
|
|
457
|
+
function dims3dVisible() { return dims3dVisibleFlag; }
|
|
458
|
+
function reflectDimBar() {
|
|
459
|
+
const b = document.getElementById('m3dDim'); if (b) b.classList.toggle('on', dimMode3d);
|
|
460
|
+
const ax = document.getElementById('m3dDimAxis'); if (ax) ax.style.display = dimMode3d ? 'flex' : 'none';
|
|
461
|
+
const sh = document.getElementById('m3dDimShow'); if (sh) sh.style.display = dimMode3d ? 'inline-block' : 'none';
|
|
462
|
+
reflectDimAxisBar();
|
|
463
|
+
}
|
|
464
|
+
function reflectDimAxisBar() { document.querySelectorAll('#m3dDimAxis button').forEach((x) => x.classList.toggle('on', x.dataset.d3axis === dimAxis3d)); }
|
|
415
465
|
// Tekla "set view point": double-click a member → make that point the orbit/zoom pivot.
|
|
416
466
|
function onDblClick(e) {
|
|
417
467
|
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
@@ -479,6 +529,65 @@ function sizeEndpoints() {
|
|
|
479
529
|
}
|
|
480
530
|
}
|
|
481
531
|
|
|
532
|
+
// ---- placed 3D dimensions ----
|
|
533
|
+
// Rebuild the placed-dim lines from the editor's dims3d (the single source of truth). Lines render
|
|
534
|
+
// depthTest:false so they're never buried; the selected dim draws amber. Labels are HTML overlays
|
|
535
|
+
// (pool below), so only the line geometry is rebuilt here.
|
|
536
|
+
function refreshDims() {
|
|
537
|
+
if (!dims3dGroup || !api || !api.getDims3d) return;
|
|
538
|
+
dimCandidates3d = null; // a rebuild may follow a member edit → don't keep stale snap targets for the next click
|
|
539
|
+
for (const c of [...dims3dGroup.children]) { dims3dGroup.remove(c); c.geometry.dispose(); c.material.dispose(); }
|
|
540
|
+
const dims = api.getDims3d() || [];
|
|
541
|
+
const sel = api.selDim3d ? api.selDim3d() : null;
|
|
542
|
+
if (dims3dVisibleFlag) {
|
|
543
|
+
for (const d of dims) {
|
|
544
|
+
const g = dim3dGeom(d.a, d.b, d.axis);
|
|
545
|
+
const col = d.id === sel ? 0xf59e0b : 0x67e8f9; // amber (#f59e0b) when selected, else muted cyan-300 — a quieter tint of the 0x22d3ee preview/2D-dim cyan (intentional, not a stray value)
|
|
546
|
+
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]), new THREE.LineBasicMaterial({ color: col }));
|
|
547
|
+
line.material.depthTest = false; line.renderOrder = 996; line.userData.dimId = d.id;
|
|
548
|
+
dims3dGroup.add(line);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
syncDimLabels(dims);
|
|
552
|
+
}
|
|
553
|
+
// Keep one themed label div per placed dim (reused across rebuilds). Click selects the dim.
|
|
554
|
+
function syncDimLabels(dims) {
|
|
555
|
+
while (dimLabelPool.length < dims.length) {
|
|
556
|
+
const el = document.createElement('div');
|
|
557
|
+
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer;background:var(--panel,#0f172a);color:var(--text,#f8fafc);border:1px solid var(--line,#1e293b);border-radius:4px;padding:2px 6px;font:12px system-ui;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5)';
|
|
558
|
+
el.addEventListener('pointerdown', (e) => { e.stopPropagation(); const id = el._dimId; if (api && api.onSelectDim3d) { api.onSelectDim3d(id); refreshDims(); } });
|
|
559
|
+
dimLabelHost.appendChild(el); dimLabelPool.push(el);
|
|
560
|
+
}
|
|
561
|
+
for (let i = 0; i < dimLabelPool.length; i++) {
|
|
562
|
+
const el = dimLabelPool[i], d = dims[i];
|
|
563
|
+
if (!d || !dims3dVisibleFlag) { el.style.display = 'none'; el._dimId = null; el._mid = null; continue; }
|
|
564
|
+
el._dimId = d.id;
|
|
565
|
+
const g = dim3dGeom(d.a, d.b, d.axis);
|
|
566
|
+
el.textContent = api.fmtLen ? api.fmtLen(g.valueMm) : (g.valueMm / 304.8).toFixed(2) + ' ft';
|
|
567
|
+
el.style.borderColor = d.id === (api.selDim3d && api.selDim3d()) ? '#f59e0b' : 'var(--line,#1e293b)';
|
|
568
|
+
el.style.display = 'block'; el._mid = g.mid;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// project each visible label's 3D midpoint → screen px (called from the render loop). Hidden when the
|
|
572
|
+
// midpoint is behind the camera (project z>1).
|
|
573
|
+
function positionDimLabels() {
|
|
574
|
+
if (!dimLabelHost || canvasEl.style.display === 'none') { if (dimLabelHost) dimLabelHost.style.display = 'none'; return; }
|
|
575
|
+
dimLabelHost.style.display = 'block';
|
|
576
|
+
for (const el of dimLabelPool) {
|
|
577
|
+
if (el.style.display === 'none' || !el._mid) continue;
|
|
578
|
+
const [mx, my, mz] = el._mid;
|
|
579
|
+
const v = new THREE.Vector3(mx, my, mz).project(camera);
|
|
580
|
+
// hide when the midpoint is behind the camera (z>1) OR outside the canvas frustum (|x|,|y|>1):
|
|
581
|
+
// the labels are pointer-events:auto, so an off-canvas one would float over and intercept the
|
|
582
|
+
// surrounding editor UI (the inspector panel / toolbar).
|
|
583
|
+
if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
|
|
584
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
585
|
+
el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
|
|
586
|
+
el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
|
|
587
|
+
el.style.visibility = 'visible';
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
482
591
|
// ---- ViewCube (parity with AWARE viewer-3d) — a small labelled cube; a face click snaps the view ----
|
|
483
592
|
const CUBE_FACES = [
|
|
484
593
|
{ label: 'RIGHT', view: 'right' }, { label: 'LEFT', view: 'left' }, // +X, -X
|
|
@@ -593,8 +702,40 @@ function pxToWorldAt(px, pos) {
|
|
|
593
702
|
}
|
|
594
703
|
const markerSize = () => pxToWorldAt(8, marker && marker.position); // snap marker ~8px at its own depth
|
|
595
704
|
|
|
705
|
+
// The snapped 3D point under the cursor for the dim tool. `anchor` (point 1) is set during the 2nd
|
|
706
|
+
// pick: with Alt held the result is locked to the anchor's plan X/Y (a pure vertical / Z measurement,
|
|
707
|
+
// the elevation-drag feel). Returns { p:[x,y,z], type } (type = the snap label, or null).
|
|
708
|
+
function dimPointAt(e, anchor) {
|
|
709
|
+
if (anchor && e.altKey) {
|
|
710
|
+
const hit = rayToVerticalPlane(e.clientX, e.clientY, anchor); if (!hit) return null;
|
|
711
|
+
return { p: [anchor[0], anchor[1], hit[2]], type: 'vertical' };
|
|
712
|
+
}
|
|
713
|
+
let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
|
|
714
|
+
let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
|
|
715
|
+
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; } }
|
|
716
|
+
const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
|
|
717
|
+
if (!dimCandidates3d) dimCandidates3d = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
|
|
718
|
+
const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
|
|
719
|
+
return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
|
|
720
|
+
}
|
|
721
|
+
// The axis to measure on: Alt held during the 2nd pick forces a pure-vertical (Z) measurement,
|
|
722
|
+
// overriding the sticky axis (else a sticky X/Y would collapse the Alt-locked point to zero length).
|
|
723
|
+
function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
|
|
724
|
+
|
|
596
725
|
function onDown(e) {
|
|
597
726
|
if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
|
|
727
|
+
if (dimMode3d) { // 2-click dimension placement (not a drag): pick A, then B → place
|
|
728
|
+
e.stopPropagation(); controls.enabled = true;
|
|
729
|
+
const r = dimPointAt(e, dimDraft3d && dimDraft3d.a); if (!r) return;
|
|
730
|
+
if (!dimDraft3d) { dimDraft3d = { a: r.p }; dimCandidates3d = null; }
|
|
731
|
+
else {
|
|
732
|
+
const a = dimDraft3d.a, b = r.p, axis = dimEffectiveAxis(e.altKey);
|
|
733
|
+
const g = dim3dGeom(a, b, axis);
|
|
734
|
+
dimDraft3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; dimCandidates3d = null;
|
|
735
|
+
if (g.valueMm > 1 && api && api.onAddDim3d) api.onAddDim3d({ id: api.newDim3dId(), a, b, axis });
|
|
736
|
+
}
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
598
739
|
downXY = [e.clientX, e.clientY];
|
|
599
740
|
let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
|
|
600
741
|
// Geometry-edit mode (Trim/Extend or Split armed in the inspector): a click does the edit, no drag.
|
|
@@ -780,10 +921,25 @@ function closestOnSeg3(p, a, b) {
|
|
|
780
921
|
// ---- hover: cursor + readout chip (no drag in progress) ----
|
|
781
922
|
function onHoverMove(e) {
|
|
782
923
|
lastHoverXY = [e.clientX, e.clientY];
|
|
924
|
+
dimLastAlt = e.altKey; // remembered so the rAF body can rebuild a synthetic event
|
|
783
925
|
if (hoverRAF || pending || dragging || boxSel) return;
|
|
784
926
|
hoverRAF = requestAnimationFrame(() => {
|
|
785
927
|
hoverRAF = 0;
|
|
786
928
|
if (!lastHoverXY || pending || dragging || boxSel) return;
|
|
929
|
+
if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
|
|
930
|
+
canvasEl.style.cursor = 'crosshair';
|
|
931
|
+
const ev = { clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt };
|
|
932
|
+
const r = dimPointAt(ev, dimDraft3d && dimDraft3d.a);
|
|
933
|
+
if (r) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
|
|
934
|
+
if (dimDraft3d && r) {
|
|
935
|
+
const g = dim3dGeom(dimDraft3d.a, r.p, dimEffectiveAxis(dimLastAlt));
|
|
936
|
+
dimPreviewLine.geometry.setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]); dimPreviewLine.visible = true;
|
|
937
|
+
readout._dist.textContent = api.fmtLen ? api.fmtLen(g.valueMm) : (g.valueMm / 304.8).toFixed(2) + ' ft';
|
|
938
|
+
readout._type.textContent = r.type ? ' · ' + r.type : (dimLastAlt ? ' ↕' : '');
|
|
939
|
+
readout.style.left = (lastHoverXY[0] + 14) + 'px'; readout.style.top = (lastHoverXY[1] + 14) + 'px'; readout.style.display = 'block';
|
|
940
|
+
} else { dimPreviewLine.visible = false; if (!dimDraft3d) readout.style.display = 'none'; }
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
787
943
|
let ep = null; try { ep = pickEndpoint(lastHoverXY[0], lastHoverXY[1]); } catch { ep = null; }
|
|
788
944
|
if (ep) { // over an end node → ring + move cursor; keep its member's dots shown
|
|
789
945
|
hoverEp = ep; canvasEl.style.cursor = 'move';
|
|
@@ -810,8 +966,12 @@ function updateStatusChip() {
|
|
|
810
966
|
hoverChip.style.display = canvasEl.style.display === 'none' ? 'none' : 'block';
|
|
811
967
|
}
|
|
812
968
|
|
|
813
|
-
function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
|
|
814
|
-
function hide() {
|
|
969
|
+
function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); refreshDims(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
|
|
970
|
+
function hide() {
|
|
971
|
+
if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
|
|
972
|
+
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
973
|
+
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
974
|
+
}
|
|
815
975
|
function isReady() { return built; }
|
|
816
976
|
|
|
817
977
|
function dispose() {
|
|
@@ -822,14 +982,18 @@ function dispose() {
|
|
|
822
982
|
window.removeEventListener('pointermove', onMove);
|
|
823
983
|
window.removeEventListener('pointerup', onUp);
|
|
824
984
|
window.removeEventListener('keydown', onKey);
|
|
825
|
-
for (const ovl of [readout, hoverChip, rubber]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
|
|
985
|
+
for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
|
|
986
|
+
dimLabelHost = null; dimLabelPool.length = 0;
|
|
826
987
|
if (cube) {
|
|
827
988
|
cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
|
|
828
989
|
cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
|
|
829
990
|
}
|
|
830
991
|
if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
|
|
831
|
-
|
|
992
|
+
if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
|
|
993
|
+
if (dimPreviewLine && scene) scene.remove(dimPreviewLine);
|
|
994
|
+
for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
832
995
|
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
996
|
+
dims3dGroup = dimPreviewLine = null;
|
|
833
997
|
clearRoot();
|
|
834
998
|
if (renderer) renderer.dispose();
|
|
835
999
|
renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
|
|
@@ -879,5 +1043,8 @@ window.Steel3DView = {
|
|
|
879
1043
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
880
1044
|
setRefLine, refLine: () => refLineOn,
|
|
881
1045
|
toggleGroup, soloToggle, showAllGroups, groupState, getGroups,
|
|
1046
|
+
toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
|
|
1047
|
+
dims3dCount: () => (api && api.getDims3d ? (api.getDims3d() || []).length : 0),
|
|
1048
|
+
dim3dLabelOf: (i) => { const el = dimLabelPool[i]; if (!el || el.style.display === 'none') return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, text: el.textContent }; },
|
|
882
1049
|
debug, probe, screenOf, centerOf,
|
|
883
1050
|
};
|
|
@@ -242,6 +242,9 @@
|
|
|
242
242
|
<div class=seg-group id=m3dMode title="Display mode"><button data-mode=solid class=on>Solid</button><button data-mode=wire>Wire</button><button data-mode=xray>X-ray</button></div>
|
|
243
243
|
<button id=m3dFit title="Fit all to view">Fit</button>
|
|
244
244
|
<button id=m3dRef title="Show the reference line (work-line between the end points) for every member">Ref line</button>
|
|
245
|
+
<button id=m3dDim title="3D Dimension tool (D) — click two snapped points to measure. Free (3D), or X/Y/Z to lock an axis; hold Alt for a vertical (Z) measurement.">⊢ Dimension</button>
|
|
246
|
+
<div class=seg-group id=m3dDimAxis title="Measurement axis" style="display:none"><button data-d3axis=free class=on title="Free 3D measurement (F)">Free</button><button data-d3axis=x title="Lock to the X axis (X)">X</button><button data-d3axis=y title="Lock to the Y axis (Y)">Y</button><button data-d3axis=z title="Lock to the Z / vertical axis (Z); hold Alt while picking for a quick vertical">Z</button></div>
|
|
247
|
+
<button id=m3dDimShow title="Toggle visibility of placed 3D dimensions" style="display:none">Hide dims</button>
|
|
245
248
|
</div>
|
|
246
249
|
<div id=m3dLegend></div>
|
|
247
250
|
<div id=m3dCube title="Click a face for that view · drag the scene with the right button to orbit"></div>
|
|
@@ -345,6 +348,7 @@ async function boot() {
|
|
|
345
348
|
PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
|
|
346
349
|
C.custom_details = C.custom_details || {};
|
|
347
350
|
C.profile_colors = C.profile_colors || {};
|
|
351
|
+
if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
|
|
348
352
|
main();
|
|
349
353
|
// SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
|
|
350
354
|
// We open our own EventSource to the same /api/events endpoint as the main app.
|
|
@@ -426,6 +430,7 @@ let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisib
|
|
|
426
430
|
let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
|
|
427
431
|
let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
|
|
428
432
|
let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
|
|
433
|
+
let sel3dDimId=null; // the currently-selected 3D dimension id (model-global; 3D view highlights it, Delete removes it)
|
|
429
434
|
let selIds=new Set();
|
|
430
435
|
let undo=[], redo=[];
|
|
431
436
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
@@ -477,14 +482,16 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
|
477
482
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
478
483
|
const autofillTOS=syncDefaults;
|
|
479
484
|
// profs is per-plan — computed in setPlan()
|
|
480
|
-
function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});}
|
|
485
|
+
function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});} // per-plan; dims3d is model-global and rides its OWN persistence (NOT this plan-scoped snapshot — see refreshDims3d)
|
|
486
|
+
function refreshDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();} // repaint the 3D dim lines/labels from C.dims3d
|
|
481
487
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
482
488
|
function apply(json){const d=JSON.parse(json);
|
|
483
489
|
if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
|
|
484
|
-
else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;} // 'frame' present → restore (incl. null = global); absent (legacy snapshot) leaves it alone
|
|
490
|
+
else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;} // 'frame' present → restore (incl. null = global); absent (legacy snapshot) leaves it alone. dims3d is NOT restored here — it's model-global and undo is per-plan.
|
|
485
491
|
updCS();
|
|
486
492
|
selIds=new Set([...selIds].filter(id=>byId(id)));
|
|
487
493
|
selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
|
|
494
|
+
if(sel3dDimId&&!(C.dims3d||[]).some(x=>x.id===sel3dDimId))sel3dDimId=null; // drop a stale 3D-dim selection
|
|
488
495
|
dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
|
|
489
496
|
scheduleSave();render();sync3D();}
|
|
490
497
|
function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
|
|
@@ -500,7 +507,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
500
507
|
else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
|
|
501
508
|
else el.textContent=msg||'Auto-save on';}
|
|
502
509
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
503
|
-
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
|
|
510
|
+
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d,
|
|
504
511
|
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
505
512
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
506
513
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
@@ -531,6 +538,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
531
538
|
if(d.profile_colors)C.profile_colors=d.profile_colors;
|
|
532
539
|
if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
|
|
533
540
|
C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;p.autofilled=true;}});
|
|
541
|
+
if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
|
|
534
542
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
535
543
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
536
544
|
function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
|
|
@@ -1242,10 +1250,11 @@ addEventListener('keydown',e=>{
|
|
|
1242
1250
|
if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
|
|
1243
1251
|
if(e.key==='Escape'&&selDimIds.size){if(dimSplitMode){dimSplitMode=false;snapClear();render();}else{selDimIds.clear();render();}return;} // Esc on a selected dim: end split mode → else deselect
|
|
1244
1252
|
if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
|
|
1253
|
+
if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimId&&!selIds.size&&!inForm){e.preventDefault();view3dApi.onDeleteDim3d(sel3dDimId);return;} // 3D: Delete removes the selected 3D dim — only when no member is selected (selection is exclusive, so a stale dim id never shadows a member delete)
|
|
1245
1254
|
if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
|
|
1246
1255
|
if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
|
|
1247
1256
|
if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
|
|
1248
|
-
if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
|
|
1257
|
+
if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'&&!view3d){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
|
|
1249
1258
|
if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
|
|
1250
1259
|
if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
|
|
1251
1260
|
if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
|
|
@@ -1277,8 +1286,8 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
|
|
|
1277
1286
|
const view3dApi={
|
|
1278
1287
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
1279
1288
|
fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');return j.scene;}, // endpoint shape: {ok, scene, skipped}
|
|
1280
|
-
onSelect:(id,additive)=>{selDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection (clears
|
|
1281
|
-
onSelectMany:(ids)=>{selDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
|
|
1289
|
+
onSelect:(id,additive)=>{selDimIds.clear();sel3dDimId=null;if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
|
|
1290
|
+
onSelectMany:(ids)=>{selDimIds.clear();sel3dDimId=null;selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
|
|
1282
1291
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
1283
1292
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
1284
1293
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
@@ -1290,6 +1299,14 @@ const view3dApi={
|
|
|
1290
1299
|
else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
|
|
1291
1300
|
onTrimExtend:(targetId)=>{const tm=byId(targetId);if(tm){geoMode=null;setGeo();snapEndMulti(selArr(),tm.wp[0],tm.wp[1]);}}, // 3D Trim/Extend → every selected member's nearest end to the target line
|
|
1292
1301
|
onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
|
|
1302
|
+
getDims3d:()=>C.dims3d||[],
|
|
1303
|
+
// dims3d is model-global: mutate + persist directly. NOT via edit() — a plan-scoped undo snapshot would corrupt dims3d across plans (undo on plan A could wipe dims added on plan B), and edit()'s sync3D would needlessly re-extrude every member mesh for a pure annotation. boot() guarantees C.dims3d is an array.
|
|
1304
|
+
onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
|
|
1305
|
+
onDeleteDim3d:(id)=>{C.dims3d=(C.dims3d||[]).filter(x=>x.id!==id);if(sel3dDimId===id)sel3dDimId=null;scheduleSave();refreshDims3d();},
|
|
1306
|
+
onSelectDim3d:(id)=>{sel3dDimId=id||null;if(id){selIds=new Set();selDimIds.clear();}render();}, // selecting a 3D dim is exclusive: clears member + 2D-dim selection (and updates the 3D member highlight via render)
|
|
1307
|
+
selDim3d:()=>sel3dDimId,
|
|
1308
|
+
newDim3dId:()=>'d3'+Date.now()+'_'+(dimSeq++), // same scheme as 2D dim ids (Date.now + dimSeq tie-breaker)
|
|
1309
|
+
fmtLen:(mm)=>fmtFtIn(mm/304.8*12),
|
|
1293
1310
|
};
|
|
1294
1311
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
1295
1312
|
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
@@ -1316,7 +1333,11 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
1316
1333
|
document.querySelectorAll('#m3dProj button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);seg3dActive('#m3dProj','data-proj',b.dataset.proj);});
|
|
1317
1334
|
document.querySelectorAll('#m3dMode button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);seg3dActive('#m3dMode','data-mode',b.dataset.mode);});
|
|
1318
1335
|
document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
|
|
1319
|
-
document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};
|
|
1336
|
+
document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};
|
|
1337
|
+
const d3=window.Steel3DView;
|
|
1338
|
+
document.getElementById('m3dDim').onclick=()=>d3.toggleDimTool(); // toggleDimTool()'s reflectDimBar() owns the button .on class + axis/show visibility (single source, no duplicate DOM toggles)
|
|
1339
|
+
document.querySelectorAll('#m3dDimAxis button').forEach(b=>b.onclick=()=>{d3.setDimAxis(b.dataset.d3axis);seg3dActive('#m3dDimAxis','data-d3axis',b.dataset.d3axis);});
|
|
1340
|
+
document.getElementById('m3dDimShow').onclick=()=>{const on=!d3.dims3dVisible();d3.setDims3dVisible(on);document.getElementById('m3dDimShow').textContent=on?'Hide dims':'Show dims';};}
|
|
1320
1341
|
function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
|
|
1321
1342
|
view3d=on;
|
|
1322
1343
|
const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
|
|
@@ -1332,6 +1353,8 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
|
|
|
1332
1353
|
}
|
|
1333
1354
|
async function setView(on){
|
|
1334
1355
|
if(on){
|
|
1356
|
+
if(dimMode||dimChain||dimSplitMode){dimMode=dimChain=dimSplitMode=false;selDimIds.clear();setDimMode();document.body.classList.remove('dimsplit');} // entering 3D disarms the 2D dim/chain/split tools so their X/Y/F/S/Esc keys don't double-fire with the 3D tool
|
|
1357
|
+
if(selIds.size)sel3dDimId=null; // a member selected in 2D wins over a stale 3D-dim selection
|
|
1335
1358
|
if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
|
|
1336
1359
|
try{
|
|
1337
1360
|
if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
|