@floless/app 0.35.1 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/floless-server.cjs
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.36.0" : 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.36.0" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// oauth-presets.ts
|
|
@@ -53759,6 +53759,10 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53759
53759
|
// contract-to-scene.ts
|
|
53760
53760
|
var FT_TO_MM = 304.8;
|
|
53761
53761
|
var IN_TO_MM = 25.4;
|
|
53762
|
+
function depthPosition(explicit, dzMm, horizMm) {
|
|
53763
|
+
if (explicit === "top" || explicit === "middle" || explicit === "bottom") return explicit;
|
|
53764
|
+
return dzMm > horizMm * 0.5774 ? "middle" : "top";
|
|
53765
|
+
}
|
|
53762
53766
|
function profileDims(profile) {
|
|
53763
53767
|
if (!profile) return null;
|
|
53764
53768
|
const p = profile.trim().toUpperCase();
|
|
@@ -53826,8 +53830,10 @@ function contractToScene(contractInput) {
|
|
|
53826
53830
|
} else {
|
|
53827
53831
|
const z0 = m.ends?.[0]?.tos != null ? m.ends[0].tos * IN_TO_MM : defaultTosMm;
|
|
53828
53832
|
const z1 = m.ends?.[1]?.tos != null ? m.ends[1].tos * IN_TO_MM : defaultTosMm;
|
|
53829
|
-
|
|
53830
|
-
|
|
53833
|
+
const pos = depthPosition(m.position, Math.abs(z1 - z0), Math.hypot(bx - ax, by - ay));
|
|
53834
|
+
const zoff = pos === "top" ? -dims.d / 2 : pos === "bottom" ? dims.d / 2 : 0;
|
|
53835
|
+
from = [ax, ay, z0 + zoff];
|
|
53836
|
+
to = [bx, by, z1 + zoff];
|
|
53831
53837
|
}
|
|
53832
53838
|
elements.push({ id: m.id, group: profile, kind: "box", from, to, section: { w: dims.w, d: dims.d }, meta: { profile } });
|
|
53833
53839
|
}
|
|
@@ -58653,6 +58659,8 @@ async function startServer() {
|
|
|
58653
58659
|
});
|
|
58654
58660
|
app.post(
|
|
58655
58661
|
"/api/contract/:appId/score",
|
|
58662
|
+
{ bodyLimit: 25 * 1024 * 1024 },
|
|
58663
|
+
// contracts embed rasters (matches /scene + PUT) — the editor/host-AI POSTs the live contract to score
|
|
58656
58664
|
async (req, reply) => {
|
|
58657
58665
|
const doc2 = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
|
|
58658
58666
|
if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to score" });
|
|
@@ -58682,6 +58690,8 @@ async function startServer() {
|
|
|
58682
58690
|
);
|
|
58683
58691
|
app.post(
|
|
58684
58692
|
"/api/contract/:appId/scene",
|
|
58693
|
+
{ bodyLimit: 25 * 1024 * 1024 },
|
|
58694
|
+
// contracts embed rasters (matches PUT /api/contract) — the editor POSTs the live contract on every 3D rebuild
|
|
58685
58695
|
async (req, reply) => {
|
|
58686
58696
|
const doc2 = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
|
|
58687
58697
|
if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
|
|
@@ -125,6 +125,7 @@
|
|
|
125
125
|
},
|
|
126
126
|
"angle": { "enum": ["H", "V", "D"] },
|
|
127
127
|
"role": { "enum": ["beam", "column"], "description": "Defaults to beam when absent." },
|
|
128
|
+
"position": { "enum": ["top", "middle", "bottom"], "description": "Profile placement relative to the reference line (the work-line between the two work-points), like Tekla's depth position. top = line at the section top (steel hangs below); middle = line through the centroid; bottom = line at the section bottom. When absent the editor defaults a flat beam to top (line = top of steel) and a column / steep brace to middle." },
|
|
128
129
|
"rfi": { "type": "boolean", "description": "True when the size is unresolved (excluded from the BOM)." },
|
|
129
130
|
"mf": { "type": "boolean", "description": "Moment-frame member (persistent flag)." },
|
|
130
131
|
"ends": {
|
|
@@ -222,6 +222,14 @@ Important optional fields:
|
|
|
222
222
|
Key field rules:
|
|
223
223
|
- All `wp` / `disp` / `a` / `b` coordinates are **display space** (not native PDF coords, not
|
|
224
224
|
millimetres).
|
|
225
|
+
- **Endpoint direction (canonical order).** `wp[0]`/`ends[0]` is the member's **START** (drawn as a
|
|
226
|
+
**yellow** dot in the editor); `wp[1]`/`ends[1]` is the **END** (drawn **magenta**). Always emit
|
|
227
|
+
endpoints in a consistent direction so start/end read the same across the whole takeoff:
|
|
228
|
+
- **Columns** — bottom → top (start at the lower elevation): `wp`/`col` go `bos` (start) → `tos` (end).
|
|
229
|
+
- **Beams** in plan — **left → right** (start = smaller X); for a vertical-in-plan run, **bottom → up**
|
|
230
|
+
(start = the lower end on the sheet, i.e. the **larger** display-Y since display Y grows downward).
|
|
231
|
+
This is the on-screen reading direction (→ or ↑). It costs nothing to get right at authoring time and
|
|
232
|
+
makes the yellow/magenta end handles meaningful in both the 2D and 3D editor.
|
|
225
233
|
- Elevations are **decimal inches** (canonical); the editor accepts/displays ft-in strings.
|
|
226
234
|
`tosDef: false` means the value came from a drawing callout; `true` means it follows the plan
|
|
227
235
|
default.
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Three.js lives ONLY here (the rest of the editor is import-free). It renders the SAME scene the
|
|
5
5
|
* IFC/Tekla bake uses (fetched from /api/contract/:id/scene → contractToScene), so the editable 3D
|
|
6
|
-
* matches what bakes out.
|
|
7
|
-
*
|
|
6
|
+
* matches what bakes out. This mode is a SUPERSET of AWARE's read-only viewer-3d: same navigation
|
|
7
|
+
* (RIGHT-drag orbit, MIDDLE-drag pan, wheel zoom — LEFT is reserved for editing/select), same chrome
|
|
8
|
+
* (Persp/Ortho, Solid/Wire/X-ray, Fit, a per-profile legend with hide/isolate, a ViewCube, a hover
|
|
9
|
+
* readout) — PLUS editing: drag a member to move it (Alt = elevation), and Trim/Extend + Split. The
|
|
10
|
+
* member's START end shows a YELLOW dot, the END a MAGENTA dot — in 3D these mark direction and show
|
|
11
|
+
* the member is grabbable (the BODY is the grab handle; single-end changes go through Trim/Extend or
|
|
12
|
+
* Split, since free endpoint-drag is depth-ambiguous). In 2D the same dots ARE the draggable handles.
|
|
8
13
|
*
|
|
9
14
|
* Exposes `window.Steel3DView` so the editor's classic script can drive it. The scene is Z-up
|
|
10
15
|
* (contractToScene emits meta.up='z'), so the camera/grid are set up Z-up.
|
|
@@ -13,8 +18,21 @@ import * as THREE from 'three';
|
|
|
13
18
|
import { OrbitControls } from 'three/addons/OrbitControls.js';
|
|
14
19
|
import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels } from './steel-3d-core.js';
|
|
15
20
|
|
|
16
|
-
let renderer, scene, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
|
|
21
|
+
let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
|
|
17
22
|
let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
|
|
23
|
+
let boxSel = null, rubber = null; // LEFT-drag-on-empty rubber-band multi-select
|
|
24
|
+
let hoverId = null, hoverChip = null, hoverRAF = 0, lastHoverXY = null; // hover readout + cursor
|
|
25
|
+
let epGroup = null; // yellow/magenta endpoint markers (start/end direction dots)
|
|
26
|
+
let epGeom = null, epMatStart = null, epMatEnd = null; // shared across all endpoint dots (no per-rebuild churn)
|
|
27
|
+
let epRing = null, epPreview = null; // white ring on the active end node + a rubber line while dragging it
|
|
28
|
+
let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged end node
|
|
29
|
+
let refGroup = null; let refLineOn = false; // optional reference (work) lines between each member's end points
|
|
30
|
+
const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
|
|
31
|
+
let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
|
|
32
|
+
let displayMode = 'solid'; // solid | wire | xray
|
|
33
|
+
const groupHidden = new Set(); // profile keys hidden via the legend
|
|
34
|
+
let soloGroup = null; // profile key isolated via the legend (dbl-click)
|
|
35
|
+
let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
|
|
18
36
|
const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
|
|
19
37
|
const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
|
|
20
38
|
const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
|
|
@@ -23,6 +41,8 @@ const meshById = new Map(); // member id -> THREE.Mesh
|
|
|
23
41
|
const groupColor = new Map(); // group key -> THREE.Color
|
|
24
42
|
const baseMat = new Map(); // group key -> MeshStandardMaterial (shared per profile)
|
|
25
43
|
const SELECT_EMISSIVE = 0x3b82f6; // --brand: selected members glow blue
|
|
44
|
+
const EP_START = 0xfacc15; // start endpoint (end 1) — yellow
|
|
45
|
+
const EP_END = 0xf472b6; // end endpoint (end 2) — magenta
|
|
26
46
|
|
|
27
47
|
function init(canvas, theApi) {
|
|
28
48
|
if (renderer) return; // once
|
|
@@ -31,14 +51,34 @@ function init(canvas, theApi) {
|
|
|
31
51
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
32
52
|
scene = new THREE.Scene();
|
|
33
53
|
scene.background = new THREE.Color(0x020817); // match the app --bg (the mode change reads from the geometry, not a tint)
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
perspCam = new THREE.PerspectiveCamera(50, 1, 1, 50_000_000);
|
|
55
|
+
perspCam.up.set(0, 0, 1); // Z-up to match the scene
|
|
56
|
+
orthoCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 50_000_000);
|
|
57
|
+
orthoCam.up.set(0, 0, 1);
|
|
58
|
+
camera = perspCam;
|
|
36
59
|
controls = new OrbitControls(camera, canvas);
|
|
37
60
|
controls.enableDamping = true;
|
|
61
|
+
// CAD-style mouse map (matches AWARE viewer-3d): LEFT is OURS (pick / drag / box-select — NOT
|
|
62
|
+
// orbit), RIGHT-drag orbits, MIDDLE-drag pans, wheel zooms. This removes the old ambiguity where
|
|
63
|
+
// a left-drag could mean either "orbit" or "move a member" — the source of "how do I drag in 3D?".
|
|
64
|
+
controls.mouseButtons = { LEFT: null, MIDDLE: THREE.MOUSE.PAN, RIGHT: THREE.MOUSE.ROTATE };
|
|
65
|
+
controls.zoomToCursor = true; // Tekla-style: the mouse position is the centre of zooming
|
|
38
66
|
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
|
39
67
|
const key = new THREE.DirectionalLight(0xffffff, 0.85); key.position.set(0.4, -1, 1.2); scene.add(key);
|
|
40
68
|
const fill = new THREE.DirectionalLight(0xffffff, 0.25); fill.position.set(-1, 0.6, 0.4); scene.add(fill);
|
|
41
69
|
root = new THREE.Group(); scene.add(root);
|
|
70
|
+
epGroup = new THREE.Group(); scene.add(epGroup);
|
|
71
|
+
// ONE geometry + two materials shared by every endpoint dot (rebuilt often on hover/selection —
|
|
72
|
+
// sharing avoids allocating/leaking a geometry+material per dot each rebuild).
|
|
73
|
+
epGeom = new THREE.SphereGeometry(1, 16, 12);
|
|
74
|
+
epMatStart = new THREE.MeshBasicMaterial({ color: EP_START }); epMatStart.depthTest = false;
|
|
75
|
+
epMatEnd = new THREE.MeshBasicMaterial({ color: EP_END }); epMatEnd.depthTest = false;
|
|
76
|
+
// white ring drawn on the hovered/dragged end node so it's visibly "selected" (billboarded each frame)
|
|
77
|
+
epRing = new THREE.Mesh(new THREE.RingGeometry(0.78, 1, 28), new THREE.MeshBasicMaterial({ color: 0xf8fafc, side: THREE.DoubleSide }));
|
|
78
|
+
epRing.material.depthTest = false; epRing.renderOrder = 1000; epRing.visible = false; scene.add(epRing);
|
|
79
|
+
// rubber preview line from the fixed end to the dragged end node
|
|
80
|
+
epPreview = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x3b82f6 }));
|
|
81
|
+
epPreview.material.depthTest = false; epPreview.renderOrder = 997; epPreview.visible = false; scene.add(epPreview);
|
|
42
82
|
raycaster = new THREE.Raycaster();
|
|
43
83
|
// a --brand snap marker (one sphere at the winning snap target; the readout carries the type)
|
|
44
84
|
marker = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshBasicMaterial({ color: 0x3b82f6 }));
|
|
@@ -51,13 +91,25 @@ function init(canvas, theApi) {
|
|
|
51
91
|
readout._type = document.createElement('span'); readout._type.style.color = 'var(--mut,#94a3b8)';
|
|
52
92
|
readout.append(readout._dist, readout._type);
|
|
53
93
|
document.body.appendChild(readout);
|
|
94
|
+
// persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
|
|
95
|
+
hoverChip = document.createElement('div');
|
|
96
|
+
hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
|
|
97
|
+
document.body.appendChild(hoverChip);
|
|
98
|
+
// rubber-band overlay for LEFT-drag box-select on empty space
|
|
99
|
+
rubber = document.createElement('div');
|
|
100
|
+
rubber.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:58;border:1px solid var(--brand,#3b82f6);background:rgba(59,130,246,.12)';
|
|
101
|
+
document.body.appendChild(rubber);
|
|
54
102
|
// Pointer gestures: down on a member → grab it (suppress orbit); move → drag with snapping; up →
|
|
55
|
-
// commit the move, or (no move) select. Down on empty space →
|
|
56
|
-
//
|
|
103
|
+
// commit the move, or (no move) select. Down on empty space → rubber-band box-select. RIGHT/MIDDLE
|
|
104
|
+
// fall through to OrbitControls (orbit/pan). Capture phase so we own the LEFT button first.
|
|
57
105
|
canvas.addEventListener('pointerdown', onDown, true);
|
|
58
106
|
window.addEventListener('pointermove', onMove); // window so a drag keeps tracking off-canvas
|
|
59
107
|
window.addEventListener('pointerup', onUp);
|
|
108
|
+
canvas.addEventListener('pointermove', onHoverMove); // canvas-only: cursor + hover readout when idle
|
|
109
|
+
canvas.addEventListener('dblclick', onDblClick); // Tekla "set view point": dbl-click sets the orbit/zoom pivot
|
|
110
|
+
window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
|
|
60
111
|
ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
|
|
112
|
+
initCube();
|
|
61
113
|
// Resize once more on the next frame: the stage may still be laying out when init() runs (the
|
|
62
114
|
// designer flagged a first-render size mismatch as the common rough edge here).
|
|
63
115
|
requestAnimationFrame(resize);
|
|
@@ -70,12 +122,16 @@ function resize() {
|
|
|
70
122
|
const el = canvasEl.parentElement || canvasEl;
|
|
71
123
|
const w = el.clientWidth || 1, h = el.clientHeight || 1;
|
|
72
124
|
renderer.setSize(w, h, false);
|
|
73
|
-
|
|
125
|
+
perspCam.aspect = w / h; perspCam.updateProjectionMatrix();
|
|
126
|
+
if (camera === orthoCam) reframeOrtho();
|
|
74
127
|
}
|
|
75
128
|
|
|
76
129
|
function loop() {
|
|
77
130
|
rafId = requestAnimationFrame(loop);
|
|
78
|
-
controls.update();
|
|
131
|
+
controls.update();
|
|
132
|
+
sizeEndpoints();
|
|
133
|
+
renderer.render(scene, camera);
|
|
134
|
+
if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
const V = (x, y, z) => new THREE.Vector3(x, y, z);
|
|
@@ -167,19 +223,24 @@ function buildFromScene(sc) {
|
|
|
167
223
|
clearRoot();
|
|
168
224
|
for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
|
|
169
225
|
groupColor.clear(); baseMat.clear();
|
|
170
|
-
|
|
226
|
+
sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
|
|
227
|
+
for (const g of sceneGroups) groupColor.set(g.key, new THREE.Color(g.color));
|
|
171
228
|
const box = new THREE.Box3();
|
|
172
229
|
for (const el of sc.elements || []) {
|
|
173
230
|
const mesh = new THREE.Mesh(undefined, materialFor(el.group));
|
|
174
231
|
placeMember(mesh, el.from, el.to, el); // real cross-section (I / channel / angle / tube / box) from the profile
|
|
175
|
-
mesh.userData.id = el.id; mesh.userData.group = el.group;
|
|
232
|
+
mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
|
|
176
233
|
root.add(mesh); meshById.set(el.id, mesh);
|
|
177
234
|
box.expandByObject(mesh);
|
|
178
235
|
}
|
|
236
|
+
sceneBox = box.clone();
|
|
179
237
|
buildGrid(box);
|
|
238
|
+
buildRefLines();
|
|
239
|
+
applyGroupVisibility(); applyDisplayMode();
|
|
180
240
|
built = true;
|
|
181
|
-
if (fitPending
|
|
241
|
+
if (fitPending) { fitCamera(box); fitPending = false; } // fit only on entering 3D (rebuild(true)) — NOT on every edit
|
|
182
242
|
}
|
|
243
|
+
let sceneGroups = [];
|
|
183
244
|
|
|
184
245
|
// A muted reference grid on the ground plane (XY, since Z-up), sized to the model. --line at low
|
|
185
246
|
// opacity — no white grid (per the baseline). Rebuilt with the model so it always fits.
|
|
@@ -196,37 +257,171 @@ function buildGrid(box) {
|
|
|
196
257
|
scene.add(grid);
|
|
197
258
|
}
|
|
198
259
|
|
|
199
|
-
|
|
260
|
+
// Optional reference lines: the work-line (between the two end dots) of every member — the centreline
|
|
261
|
+
// downstream tools (Tekla/Revit/IFC) place steel against. Toggled from the toolbar; rebuilt with the model.
|
|
262
|
+
function buildRefLines() {
|
|
263
|
+
if (refGroup) { scene.remove(refGroup); refGroup.geometry.dispose(); refGroup.material.dispose(); refGroup = null; }
|
|
264
|
+
if (!refLineOn || !api) return;
|
|
265
|
+
const pts = [];
|
|
266
|
+
for (const m of members()) {
|
|
267
|
+
if (!m || !Array.isArray(m.wp) || m.wp.length < 2) continue;
|
|
268
|
+
const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm());
|
|
269
|
+
pts.push(new THREE.Vector3(g.line[0][0], g.line[0][1], g.line[0][2]), new THREE.Vector3(g.line[1][0], g.line[1][1], g.line[1][2]));
|
|
270
|
+
}
|
|
271
|
+
if (!pts.length) return;
|
|
272
|
+
refGroup = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x93c5fd }));
|
|
273
|
+
refGroup.renderOrder = 996; scene.add(refGroup);
|
|
274
|
+
}
|
|
275
|
+
function setRefLine(on) { refLineOn = !!on; buildRefLines(); }
|
|
276
|
+
|
|
277
|
+
// The ortho frustum half-height in world units (set by a fit to the box, or by a projection toggle
|
|
278
|
+
// to match the current perspective view). reframeOrtho applies it aspect-preserving on resize, so the
|
|
279
|
+
// model never ends up under-framed/"cut-off" in ortho and the user's zoom (orthoCam.zoom) is kept.
|
|
280
|
+
let orthoBaseH = 0;
|
|
281
|
+
function reframeOrtho() {
|
|
282
|
+
const aspect = perspCam.aspect || 1;
|
|
283
|
+
const h = orthoBaseH > 0 ? orthoBaseH : Math.tan(THREE.MathUtils.degToRad(perspCam.fov) * 0.5) * (camera.position.distanceTo(controls.target) || 1);
|
|
284
|
+
orthoCam.left = -h * aspect; orthoCam.right = h * aspect; orthoCam.top = h; orthoCam.bottom = -h;
|
|
285
|
+
orthoCam.updateProjectionMatrix();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function fitCamera(box, dir) {
|
|
200
289
|
if (box.isEmpty()) return;
|
|
201
290
|
const c = box.getCenter(new THREE.Vector3());
|
|
202
|
-
const
|
|
291
|
+
const d = dir ? dir.clone().normalize() : new THREE.Vector3(0.55, -0.8, 0.5).normalize(); // default 3/4 iso
|
|
203
292
|
// Build the camera's right/up axes for this view direction, then size the distance so the WIDEST
|
|
204
293
|
// box corner (projected onto right/up) fits the frustum. Projecting corners (not a bounding sphere)
|
|
205
294
|
// fits a flat plan tightly — a sphere over-zooms because it ignores that the slab is thin.
|
|
206
|
-
const fwd =
|
|
295
|
+
const fwd = d.clone().negate();
|
|
207
296
|
const up0 = Math.abs(fwd.z) > 0.95 ? V(0, 1, 0) : V(0, 0, 1);
|
|
208
297
|
const right = new THREE.Vector3().crossVectors(up0, fwd).normalize();
|
|
209
298
|
const up = new THREE.Vector3().crossVectors(fwd, right).normalize();
|
|
210
|
-
const
|
|
211
|
-
|
|
299
|
+
const aspect = perspCam.aspect || 1;
|
|
300
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2), tanH = tanV * aspect;
|
|
301
|
+
let need = 1000, extR = 1, extU = 1;
|
|
212
302
|
for (let i = 0; i < 8; i++) {
|
|
213
303
|
const corner = V(i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z).sub(c);
|
|
214
|
-
|
|
304
|
+
const cr = Math.abs(corner.dot(right)), cu = Math.abs(corner.dot(up)), cd = corner.dot(d); // cd = depth toward the camera
|
|
305
|
+
extR = Math.max(extR, cr); extU = Math.max(extU, cu);
|
|
306
|
+
// include the corner's depth: a corner NEARER the camera projects larger, so it needs MORE distance
|
|
307
|
+
// (omitting cd under-fits the near corners → they clip just outside the frustum).
|
|
308
|
+
need = Math.max(need, cd + Math.max(cr / tanH, cu / tanV));
|
|
215
309
|
}
|
|
216
310
|
const dist = need * 1.15; // small margin
|
|
217
311
|
controls.target.copy(c);
|
|
218
|
-
|
|
312
|
+
perspCam.position.copy(c).addScaledVector(d, dist);
|
|
313
|
+
orthoCam.position.copy(c).addScaledVector(d, dist);
|
|
219
314
|
const r = box.getBoundingSphere(new THREE.Sphere()).radius || dist;
|
|
220
|
-
|
|
315
|
+
const near = Math.max(dist / 1000, 1), far = dist + r * 4;
|
|
316
|
+
perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
|
|
317
|
+
orthoCam.near = near; orthoCam.far = far; orthoCam.zoom = 1;
|
|
318
|
+
orthoBaseH = Math.max(extU, extR / aspect) * 1.15; // tight, aspect-preserving box fit (no ortho under-frame)
|
|
319
|
+
if (camera === orthoCam) reframeOrtho();
|
|
221
320
|
controls.update();
|
|
222
321
|
}
|
|
223
322
|
|
|
224
|
-
|
|
323
|
+
let rebuildSeq = 0;
|
|
324
|
+
async function rebuild(fit = false) {
|
|
225
325
|
if (!api) return;
|
|
326
|
+
if (fit) fitPending = true; // entering 3D re-fits; an edit-triggered rebuild keeps the camera put
|
|
327
|
+
const seq = ++rebuildSeq;
|
|
226
328
|
const sc = await api.fetchScene(); // editor POSTs the current contract to /scene
|
|
329
|
+
if (seq !== rebuildSeq) return; // a newer rebuild superseded this one → drop the stale scene (no out-of-order apply)
|
|
227
330
|
if (sc) buildFromScene(sc);
|
|
228
331
|
}
|
|
229
332
|
|
|
333
|
+
// ---- Persp/Ortho + Solid/Wire/X-ray + group visibility (parity with the AWARE viewer) ----
|
|
334
|
+
function setProjection(mode) {
|
|
335
|
+
const want = mode === 'ortho' ? orthoCam : perspCam;
|
|
336
|
+
if (want === camera) return;
|
|
337
|
+
const tgt = controls.target.clone(), pos = camera.position.clone();
|
|
338
|
+
camera = want; controls.object = camera;
|
|
339
|
+
camera.position.copy(pos); camera.lookAt(tgt);
|
|
340
|
+
if (camera === orthoCam) {
|
|
341
|
+
// match the perspective view on toggle (preserve the user's current zoom) — Fit re-tightens to the box
|
|
342
|
+
orthoBaseH = Math.tan(THREE.MathUtils.degToRad(perspCam.fov) * 0.5) * (pos.distanceTo(tgt) || 1);
|
|
343
|
+
orthoCam.zoom = 1; reframeOrtho();
|
|
344
|
+
} else camera.updateProjectionMatrix();
|
|
345
|
+
controls.update();
|
|
346
|
+
}
|
|
347
|
+
function projection() { return camera === orthoCam ? 'ortho' : 'persp'; }
|
|
348
|
+
function setDisplayMode(m) { displayMode = m; applyDisplayMode(); }
|
|
349
|
+
// solid → opaque; wire → wireframe; xray → translucent, no depth write. Applied to whatever material
|
|
350
|
+
// each mesh currently carries (shared profile material OR its selection clone).
|
|
351
|
+
function applyDisplayMode() {
|
|
352
|
+
for (const mesh of meshById.values()) {
|
|
353
|
+
const mat = mesh.material; if (!mat) continue;
|
|
354
|
+
if (displayMode === 'wire') { mat.wireframe = true; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
|
|
355
|
+
else if (displayMode === 'xray') { mat.wireframe = false; mat.transparent = true; mat.opacity = 0.25; mat.depthWrite = false; }
|
|
356
|
+
else { mat.wireframe = false; mat.transparent = false; mat.opacity = 1; mat.depthWrite = true; }
|
|
357
|
+
mat.needsUpdate = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function applyGroupVisibility() {
|
|
361
|
+
for (const m of meshById.values()) {
|
|
362
|
+
const k = m.userData && m.userData.group;
|
|
363
|
+
m.visible = !groupHidden.has(k) && (soloGroup === null || soloGroup === k);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function toggleGroup(k) { if (groupHidden.has(k)) groupHidden.delete(k); else groupHidden.add(k); soloGroup = null; applyGroupVisibility(); rebuildEndpoints(); }
|
|
367
|
+
function soloToggle(k) { soloGroup = soloGroup === k ? null : k; if (soloGroup) groupHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); }
|
|
368
|
+
function showAllGroups() { groupHidden.clear(); soloGroup = null; applyGroupVisibility(); rebuildEndpoints(); }
|
|
369
|
+
function groupState() { return { hidden: [...groupHidden], solo: soloGroup }; }
|
|
370
|
+
function getGroups() { return sceneGroups.map((g) => ({ ...g })); }
|
|
371
|
+
function frameAll() { fitCamera(sceneBox); }
|
|
372
|
+
const VIEWS = { top: [0, 0, 1], bottom: [0, 0, -1], front: [0, -1, 0], back: [0, 1, 0], right: [1, 0, 0], left: [-1, 0, 0], iso: [0.55, -0.8, 0.5] };
|
|
373
|
+
function applyView(name) { const d = VIEWS[name]; if (d) fitCamera(sceneBox, new THREE.Vector3(d[0], d[1], d[2])); }
|
|
374
|
+
// Fit just the selection (Tekla "Zoom selected" / Shift+Space); falls back to fit-all when nothing's picked.
|
|
375
|
+
function frameSelection() {
|
|
376
|
+
if (!selIds.size) { fitCamera(sceneBox); return; }
|
|
377
|
+
const box = new THREE.Box3();
|
|
378
|
+
for (const id of selIds) { const m = meshById.get(id); if (m && m.visible) box.expandByObject(m); }
|
|
379
|
+
if (!box.isEmpty()) fitCamera(box);
|
|
380
|
+
}
|
|
381
|
+
// ---- Tekla-style keyboard nav: arrows pan, Ctrl+arrows rotate 15°, Shift+arrows rotate 5° ----
|
|
382
|
+
const _UP = new THREE.Vector3(0, 0, 1);
|
|
383
|
+
function rotateView(dAz, dPolar) { // dAz = around world up (Z); dPolar = tilt around the camera-right axis
|
|
384
|
+
camera.updateMatrix();
|
|
385
|
+
const off = camera.position.clone().sub(controls.target);
|
|
386
|
+
off.applyAxisAngle(_UP, dAz);
|
|
387
|
+
const right = new THREE.Vector3().crossVectors(off, _UP);
|
|
388
|
+
if (right.lengthSq() < 1e-9) right.setFromMatrixColumn(camera.matrix, 0); // looking straight down/up → use the camera's screen-right so tilt still works
|
|
389
|
+
right.normalize();
|
|
390
|
+
const tilted = off.clone().applyAxisAngle(right, dPolar);
|
|
391
|
+
if (Math.abs(tilted.clone().normalize().dot(_UP)) < 0.999) off.copy(tilted); // don't flip over the pole
|
|
392
|
+
camera.position.copy(controls.target).add(off); camera.lookAt(controls.target); controls.update();
|
|
393
|
+
}
|
|
394
|
+
function panView(dx, dy) { // dx/dy in "steps" along the camera's screen right/up
|
|
395
|
+
// step ∝ the on-screen world height: distance for perspective, frustum-height/zoom for ortho.
|
|
396
|
+
const step = camera.isOrthographicCamera
|
|
397
|
+
? ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) * 0.06
|
|
398
|
+
: camera.position.distanceTo(controls.target) * 0.06;
|
|
399
|
+
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
|
|
400
|
+
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
|
|
401
|
+
const move = right.multiplyScalar(dx * step).add(up.multiplyScalar(dy * step));
|
|
402
|
+
camera.position.add(move); controls.target.add(move); controls.update();
|
|
403
|
+
}
|
|
404
|
+
const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
|
|
405
|
+
function onKey(e) {
|
|
406
|
+
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
|
|
407
|
+
const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
|
|
408
|
+
if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
|
|
409
|
+
const a = ARROWS[e.key]; if (!a) return;
|
|
410
|
+
const [hx, hy] = a;
|
|
411
|
+
if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
|
|
412
|
+
else if (e.shiftKey) { e.preventDefault(); rotateView(-hx * Math.PI / 36, hy * Math.PI / 36); } // 5°
|
|
413
|
+
else { e.preventDefault(); panView(hx, hy); } // pan
|
|
414
|
+
}
|
|
415
|
+
// Tekla "set view point": double-click a member → make that point the orbit/zoom pivot.
|
|
416
|
+
function onDblClick(e) {
|
|
417
|
+
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
418
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
419
|
+
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
|
|
420
|
+
raycaster.setFromCamera(ndc, camera);
|
|
421
|
+
const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
|
|
422
|
+
if (hits.length) { controls.target.copy(hits[0].point); controls.update(); }
|
|
423
|
+
}
|
|
424
|
+
|
|
230
425
|
function setSelection(ids) {
|
|
231
426
|
const set = ids instanceof Set ? ids : new Set(ids || []);
|
|
232
427
|
for (const [id, mesh] of meshById) {
|
|
@@ -237,22 +432,122 @@ function setSelection(ids) {
|
|
|
237
432
|
mesh.material.emissive = new THREE.Color(on ? SELECT_EMISSIVE : 0x000000);
|
|
238
433
|
mesh.material.emissiveIntensity = on ? 0.6 : 0;
|
|
239
434
|
}
|
|
435
|
+
applyDisplayMode(); // selection swapped the materials → re-apply wire/xray
|
|
436
|
+
selIds = new Set(set);
|
|
437
|
+
rebuildEndpoints(); // endpoint dots follow the selection (+ any hover)
|
|
438
|
+
updateStatusChip();
|
|
240
439
|
}
|
|
440
|
+
let selIds = new Set();
|
|
241
441
|
function selectedMaterial(mesh) {
|
|
242
442
|
if (!mesh.userData._selMat) mesh.userData._selMat = materialFor(mesh.userData.group).clone();
|
|
243
443
|
return mesh.userData._selMat;
|
|
244
444
|
}
|
|
245
445
|
|
|
446
|
+
// ---- yellow (start) / magenta (end) endpoint markers — also the drag handles ----
|
|
447
|
+
// Shown for every selected member, plus the hovered member. The dot at the member's `from` end
|
|
448
|
+
// (start / end 1) is yellow; the `to` end (end 2 / column top) is magenta. Sizes held ~constant on
|
|
449
|
+
// screen in the render loop (sizeEndpoints).
|
|
450
|
+
function rebuildEndpoints() {
|
|
451
|
+
if (!epGroup) return;
|
|
452
|
+
for (const c of [...epGroup.children]) epGroup.remove(c); // shared geom/material → nothing to dispose
|
|
453
|
+
const show = new Set(selIds); if (hoverId) show.add(hoverId);
|
|
454
|
+
for (const id of show) {
|
|
455
|
+
const mesh = meshById.get(id); if (mesh && !mesh.visible) continue; // a legend-hidden member shows no dots
|
|
456
|
+
const m = members().find((x) => x.id === id); if (!m) continue;
|
|
457
|
+
const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm());
|
|
458
|
+
addEndpoint(g.line[0], epMatStart, id, 0); addEndpoint(g.line[1], epMatEnd, id, 1);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function addEndpoint(p, mat, id, end) {
|
|
462
|
+
const s = new THREE.Mesh(epGeom, mat);
|
|
463
|
+
s.position.set(p[0], p[1], p[2]); s.renderOrder = 998; s.userData = { epId: id, epEnd: end }; epGroup.add(s);
|
|
464
|
+
}
|
|
465
|
+
function sizeEndpoints() {
|
|
466
|
+
if (!epGroup) return;
|
|
467
|
+
const r = pxToWorld(EP_PX); // small + screen-constant (shrinks as you zoom in, persp AND ortho)
|
|
468
|
+
const active = dragEp || hoverEp;
|
|
469
|
+
for (const c of epGroup.children) {
|
|
470
|
+
const isActive = active && c.userData.epId === active.id && c.userData.epEnd === active.end;
|
|
471
|
+
c.scale.setScalar(isActive ? r * 1.6 : r); // the targeted end node pops
|
|
472
|
+
}
|
|
473
|
+
// white ring on the active end node so the user sees which start/end is selected
|
|
474
|
+
if (epRing) {
|
|
475
|
+
let dot = null;
|
|
476
|
+
if (active) dot = epGroup.children.find((c) => c.userData.epId === active.id && c.userData.epEnd === active.end);
|
|
477
|
+
if (dot) { epRing.position.copy(dot.position); epRing.quaternion.copy(camera.quaternion); epRing.scale.setScalar(r * 2.4); epRing.visible = true; }
|
|
478
|
+
else epRing.visible = false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- ViewCube (parity with AWARE viewer-3d) — a small labelled cube; a face click snaps the view ----
|
|
483
|
+
const CUBE_FACES = [
|
|
484
|
+
{ label: 'RIGHT', view: 'right' }, { label: 'LEFT', view: 'left' }, // +X, -X
|
|
485
|
+
{ label: 'BACK', view: 'back' }, { label: 'FRONT', view: 'front' }, // +Y, -Y
|
|
486
|
+
{ label: 'TOP', view: 'top' }, { label: 'BOTTOM', view: 'bottom' }, // +Z, -Z
|
|
487
|
+
];
|
|
488
|
+
function faceTexture(label) {
|
|
489
|
+
const c = document.createElement('canvas'); c.width = c.height = 128; const g = c.getContext('2d');
|
|
490
|
+
g.fillStyle = '#1e293b'; g.fillRect(0, 0, 128, 128); g.strokeStyle = '#475569'; g.lineWidth = 6; g.strokeRect(4, 4, 120, 120);
|
|
491
|
+
g.fillStyle = '#e2e8f0'; g.font = 'bold 20px ui-sans-serif,system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
|
|
492
|
+
g.fillText(label, 64, 64); return new THREE.CanvasTexture(c);
|
|
493
|
+
}
|
|
494
|
+
function initCube() {
|
|
495
|
+
const host = document.getElementById('m3dCube'); if (!host) return;
|
|
496
|
+
const PX = 84;
|
|
497
|
+
const cr = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
498
|
+
cr.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); cr.setSize(PX, PX);
|
|
499
|
+
host.appendChild(cr.domElement);
|
|
500
|
+
const cs = new THREE.Scene();
|
|
501
|
+
const cc = new THREE.PerspectiveCamera(40, 1, 0.1, 20); cc.position.set(0, 0, 5); cc.up.set(0, 0, 1);
|
|
502
|
+
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1.9, 1.9, 1.9), CUBE_FACES.map((f) => new THREE.MeshBasicMaterial({ map: faceTexture(f.label) })));
|
|
503
|
+
mesh.add(new THREE.LineSegments(new THREE.EdgesGeometry(mesh.geometry), new THREE.LineBasicMaterial({ color: 0x64748b })));
|
|
504
|
+
cs.add(mesh);
|
|
505
|
+
const ray = new THREE.Raycaster(), mouse = new THREE.Vector2();
|
|
506
|
+
cr.domElement.addEventListener('pointerdown', (e) => {
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
const r = cr.domElement.getBoundingClientRect();
|
|
509
|
+
mouse.x = ((e.clientX - r.left) / r.width) * 2 - 1; mouse.y = -((e.clientY - r.top) / r.height) * 2 + 1;
|
|
510
|
+
ray.setFromCamera(mouse, cc);
|
|
511
|
+
const hit = ray.intersectObject(mesh, false)[0]; if (!hit || !hit.face) return;
|
|
512
|
+
const p = mesh.worldToLocal(hit.point.clone()), snap = (x) => Math.abs(x) > 0.6 ? Math.sign(x) : 0;
|
|
513
|
+
const dv = new THREE.Vector3(snap(p.x), snap(p.y), snap(p.z));
|
|
514
|
+
if (Math.abs(dv.x) + Math.abs(dv.y) + Math.abs(dv.z) <= 1) { const f = CUBE_FACES[hit.face.materialIndex]; if (f) applyView(f.view); } // a face → ortho view
|
|
515
|
+
else fitCamera(sceneBox, dv); // an edge/corner → iso-style view from that world direction
|
|
516
|
+
});
|
|
517
|
+
cube = { renderer: cr, scene: cs, cam: cc, mesh };
|
|
518
|
+
}
|
|
519
|
+
// Mirror the scene from the main camera's direction (the FRONT face turns toward the viewer in a
|
|
520
|
+
// front view, etc.) by orienting the cube by the inverse of the camera's world rotation.
|
|
521
|
+
function syncCube() { cube.mesh.quaternion.copy(camera.quaternion).invert(); }
|
|
522
|
+
|
|
246
523
|
// Raycast the member meshes at a client (px) point → the hit member id, or null for empty space.
|
|
524
|
+
// Only VISIBLE meshes (a legend-hidden group must not swallow the pick for what's behind it).
|
|
247
525
|
function pickAt(cx, cy) {
|
|
248
526
|
camera.updateMatrixWorld(); root.updateMatrixWorld(true); // ensure current after any camera/mesh move
|
|
249
527
|
const rect = canvasEl.getBoundingClientRect();
|
|
250
528
|
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
251
529
|
raycaster.setFromCamera(ndc, camera);
|
|
252
|
-
const hits = raycaster.intersectObjects([...meshById.values()], false);
|
|
530
|
+
const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
|
|
253
531
|
lastPick = hits.length ? hits[0].object.userData.id : null;
|
|
254
532
|
return lastPick;
|
|
255
533
|
}
|
|
534
|
+
// The (currently shown) end-node dot nearest the cursor within a screen tolerance → { id, end } or
|
|
535
|
+
// null. Screen-space (not a raycast) so the small dots are easy to grab at any zoom. Dots win over
|
|
536
|
+
// the member body, letting you grab one end to stretch it.
|
|
537
|
+
function pickEndpoint(cx, cy) {
|
|
538
|
+
if (!epGroup || !epGroup.children.length) return null;
|
|
539
|
+
camera.updateMatrixWorld();
|
|
540
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
541
|
+
const px = cx - rect.left, py = cy - rect.top;
|
|
542
|
+
let best = null, bestD = 14; // px grab tolerance
|
|
543
|
+
for (const c of epGroup.children) {
|
|
544
|
+
const v = c.position.clone().project(camera); if (v.z > 1) continue;
|
|
545
|
+
const sx = (v.x * 0.5 + 0.5) * rect.width, sy = (-v.y * 0.5 + 0.5) * rect.height;
|
|
546
|
+
const d = Math.hypot(sx - px, sy - py);
|
|
547
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
548
|
+
}
|
|
549
|
+
return best ? { id: best.userData.epId, end: best.userData.epEnd } : null;
|
|
550
|
+
}
|
|
256
551
|
|
|
257
552
|
// project a 3D mm point → canvas-relative screen px (for screen-space snapping + the marker)
|
|
258
553
|
function toScreen(p) {
|
|
@@ -284,13 +579,27 @@ function rayToVerticalPlane(cx, cy, thru) {
|
|
|
284
579
|
return raycaster.ray.intersectPlane(plane, hit) ? [hit.x, hit.y, hit.z] : null;
|
|
285
580
|
}
|
|
286
581
|
const members = () => (api && api.getMembers && api.getMembers()) || [];
|
|
287
|
-
const
|
|
582
|
+
const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
|
|
583
|
+
// world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
|
|
584
|
+
// BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
|
|
585
|
+
function worldPerPixel() {
|
|
586
|
+
const h = (canvasEl && canvasEl.clientHeight) || 1;
|
|
587
|
+
if (camera === orthoCam) return ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) / h;
|
|
588
|
+
return (2 * Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2) * camera.position.distanceTo(controls.target)) / h;
|
|
589
|
+
}
|
|
590
|
+
const pxToWorld = (px) => px * worldPerPixel();
|
|
591
|
+
const markerSize = () => pxToWorld(8); // snap marker ~8px radius
|
|
288
592
|
|
|
289
593
|
function onDown(e) {
|
|
290
|
-
if (e.button !== 0) return;
|
|
594
|
+
if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
|
|
291
595
|
downXY = [e.clientX, e.clientY];
|
|
292
596
|
let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
|
|
293
|
-
|
|
597
|
+
// Geometry-edit mode (Trim/Extend or Split armed in the inspector): a click does the edit, no drag.
|
|
598
|
+
if (geoMode() && selIds.size === 1) { e.stopPropagation(); pending = { geo: geoMode(), selId: [...selIds][0], targetId: id }; return; }
|
|
599
|
+
// An end node (the yellow/magenta dot) under the cursor → grab just that endpoint to stretch the member.
|
|
600
|
+
let ep = null; try { ep = pickEndpoint(e.clientX, e.clientY); } catch { ep = null; }
|
|
601
|
+
if (ep) { e.stopPropagation(); controls.enabled = false; startEndpointGrab(ep, e); return; }
|
|
602
|
+
if (!id) { pending = null; boxSel = { x: e.clientX, y: e.clientY }; return; } // empty space → box-select on drag
|
|
294
603
|
e.stopPropagation(); controls.enabled = false; // grabbed a member → suppress orbit (enabled=false is the reliable guard)
|
|
295
604
|
try {
|
|
296
605
|
const m = members().find((x) => x.id === id);
|
|
@@ -324,9 +633,45 @@ function onMoveVertical(e) {
|
|
|
324
633
|
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
325
634
|
}
|
|
326
635
|
|
|
636
|
+
// Grab a single end node: drag it on the member's work-plane (its TOS), snapping to other members.
|
|
637
|
+
function startEndpointGrab(ep, e) {
|
|
638
|
+
try {
|
|
639
|
+
const m = members().find((x) => x.id === ep.id); if (!m) { controls.enabled = true; return; }
|
|
640
|
+
const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
|
|
641
|
+
const g = memberGeometry(m, ppf, dtos);
|
|
642
|
+
pending = {
|
|
643
|
+
epDrag: true, id: ep.id, end: ep.end, ppf,
|
|
644
|
+
planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
|
|
645
|
+
candidates: snapCandidates(members(), ppf, dtos, ep.id),
|
|
646
|
+
newPt: [g.line[ep.end][0], g.line[ep.end][1]],
|
|
647
|
+
};
|
|
648
|
+
dragEp = { id: ep.id, end: ep.end };
|
|
649
|
+
} catch (err) { controls.enabled = true; pending = null; dragEp = null; }
|
|
650
|
+
}
|
|
651
|
+
function onMoveEndpoint(e) {
|
|
652
|
+
if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
|
|
653
|
+
dragging = pending;
|
|
654
|
+
const hit = rayToPlane(e.clientX, e.clientY, pending.planeZ); if (!hit) return;
|
|
655
|
+
const r = snapPoint([hit[0], hit[1], pending.planeZ], pending.candidates, toScreen, SNAP_TOL_PX);
|
|
656
|
+
const np = r.candidate ? r.snapped : [hit[0], hit[1], pending.planeZ];
|
|
657
|
+
pending.newPt = [np[0], np[1]];
|
|
658
|
+
const dot = epGroup.children.find((c) => c.userData.epId === pending.id && c.userData.epEnd === pending.end);
|
|
659
|
+
if (dot) dot.position.set(np[0], np[1], pending.planeZ);
|
|
660
|
+
const f = pending.fixed;
|
|
661
|
+
epPreview.geometry.setFromPoints([new THREE.Vector3(f[0], f[1], f[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
|
|
662
|
+
epPreview.visible = true;
|
|
663
|
+
if (r.candidate) { marker.position.set(np[0], np[1], np[2]); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
|
|
664
|
+
const len = Math.hypot(np[0] - f[0], np[1] - f[1]) / FT_MM;
|
|
665
|
+
readout._dist.textContent = len.toFixed(2) + ' ft'; readout._type.textContent = r.candidate ? ' · ' + r.candidate.type : '';
|
|
666
|
+
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
667
|
+
}
|
|
668
|
+
|
|
327
669
|
function onMove(e) {
|
|
328
670
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // 3D inactive — ignore window pointer events
|
|
329
|
-
if (
|
|
671
|
+
if (boxSel) { onBoxMove(e); return; }
|
|
672
|
+
if (!pending || pending.geo) return;
|
|
673
|
+
if (pending.epDrag) { onMoveEndpoint(e); return; }
|
|
674
|
+
if (!pending.grab || !pending.origMm) return;
|
|
330
675
|
if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
|
|
331
676
|
if (!dragging) pending.mode = e.altKey ? 'vertical' : 'plan'; // lock the mode at drag start (Alt = vertical/elevation)
|
|
332
677
|
dragging = pending;
|
|
@@ -352,14 +697,61 @@ function onMove(e) {
|
|
|
352
697
|
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
353
698
|
}
|
|
354
699
|
|
|
700
|
+
function onBoxMove(e) {
|
|
701
|
+
if (Math.hypot(e.clientX - boxSel.x, e.clientY - boxSel.y) < DRAG_TOL_PX) { rubber.style.display = 'none'; return; }
|
|
702
|
+
boxSel.moved = true;
|
|
703
|
+
const x = Math.min(e.clientX, boxSel.x), y = Math.min(e.clientY, boxSel.y);
|
|
704
|
+
rubber.style.left = x + 'px'; rubber.style.top = y + 'px';
|
|
705
|
+
rubber.style.width = Math.abs(e.clientX - boxSel.x) + 'px'; rubber.style.height = Math.abs(e.clientY - boxSel.y) + 'px';
|
|
706
|
+
rubber.style.display = 'block';
|
|
707
|
+
}
|
|
708
|
+
// every VISIBLE member whose centre projects inside the drag rectangle (centre-point hit-test)
|
|
709
|
+
function membersInRect(x0, y0, x1, y1) {
|
|
710
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
711
|
+
const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
|
|
712
|
+
const out = [];
|
|
713
|
+
for (const [id, m] of meshById) {
|
|
714
|
+
if (!m.visible) continue;
|
|
715
|
+
const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
|
|
716
|
+
const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
|
|
717
|
+
if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(id);
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
355
722
|
function onUp(e) {
|
|
356
|
-
if (marker) marker.visible = false; if (readout) readout.style.display = 'none';
|
|
357
|
-
if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; return; } // 3D
|
|
723
|
+
if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; dragEp = null; // always clear overlays
|
|
724
|
+
if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; boxSel = pending = dragging = null; if (controls) controls.enabled = true; return; } // 3D hidden mid-gesture → drop stale gesture state (no resume on re-show)
|
|
725
|
+
const bs = boxSel; boxSel = null;
|
|
726
|
+
if (bs) { // empty-space gesture: drag = box-select, click = clear selection
|
|
727
|
+
if (bs.moved) { const ids = membersInRect(bs.x, bs.y, e.clientX, e.clientY); if (api && api.onSelectMany) api.onSelectMany(ids); }
|
|
728
|
+
else if (api && api.onSelect) api.onSelect(null, false);
|
|
729
|
+
downXY = null; return;
|
|
730
|
+
}
|
|
358
731
|
const p = pending, wasDragging = dragging; pending = dragging = null;
|
|
359
732
|
controls.enabled = true;
|
|
360
733
|
const moved = downXY ? Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) : Infinity; downXY = null;
|
|
361
|
-
if (!p)
|
|
362
|
-
|
|
734
|
+
if (!p) return;
|
|
735
|
+
if (p.epDrag) { // end-node drag → write just that endpoint's wp; a click (no drag) just selects the member
|
|
736
|
+
if (wasDragging && p.newPt && api && api.onMoveEndpoint) { if (api.onSelect) api.onSelect(p.id, false); api.onMoveEndpoint(p.id, p.end, planPointToWp(p.newPt, p.ppf)); }
|
|
737
|
+
else if (api && api.onSelect) api.onSelect(p.id, false);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (p.geo) { // Trim/Extend or Split — a click (no drag) performs the edit
|
|
741
|
+
if (moved > DRAG_TOL_PX) return;
|
|
742
|
+
if (p.geo === 'el') { if (p.targetId && p.targetId !== p.selId && api && api.onTrimExtend) api.onTrimExtend(p.selId, p.targetId); return; }
|
|
743
|
+
if (p.geo === 'split') { // cut the selected member at the clicked point on its centreline
|
|
744
|
+
const m = members().find((x) => x.id === p.selId); if (!m) return;
|
|
745
|
+
const ppf = api.ptPerFt();
|
|
746
|
+
const g = memberGeometry(m, ppf, api.defaultTosMm());
|
|
747
|
+
const planeZ = (g.line[0][2] + g.line[1][2]) / 2;
|
|
748
|
+
const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return;
|
|
749
|
+
if (p.targetId !== p.selId) return; // the cut click must land on the selected member's body
|
|
750
|
+
const { pt: cut, t } = closestOnSeg3([hit[0], hit[1]], g.line[0], g.line[1]); // nearest point ON the member (plan)
|
|
751
|
+
if (t <= 0.03 || t >= 0.97) return; // too near an end → no degenerate split
|
|
752
|
+
if (api && api.onSplit) api.onSplit(p.selId, planPointToWp(cut, ppf));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
363
755
|
return;
|
|
364
756
|
}
|
|
365
757
|
if (!wasDragging) { if (api && api.onSelect) api.onSelect(p.id, p.ctrl); return; } // member click → select
|
|
@@ -375,21 +767,69 @@ function onUp(e) {
|
|
|
375
767
|
if (api && api.onSelect) api.onSelect(p.id, false); // keep the moved member selected
|
|
376
768
|
if (api && api.onMoveMember) api.onMoveMember(p.id, newWp); // editor writes wp via edit()
|
|
377
769
|
}
|
|
770
|
+
// nearest point on segment a-b to plan point p (XY only) → { pt:[x,y], t }
|
|
771
|
+
function closestOnSeg3(p, a, b) {
|
|
772
|
+
const abx = b[0] - a[0], aby = b[1] - a[1], l2 = abx * abx + aby * aby || 1;
|
|
773
|
+
let t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / l2; t = Math.max(0, Math.min(1, t));
|
|
774
|
+
return { pt: [a[0] + abx * t, a[1] + aby * t], t };
|
|
775
|
+
}
|
|
378
776
|
|
|
379
|
-
|
|
380
|
-
function
|
|
777
|
+
// ---- hover: cursor + readout chip (no drag in progress) ----
|
|
778
|
+
function onHoverMove(e) {
|
|
779
|
+
lastHoverXY = [e.clientX, e.clientY];
|
|
780
|
+
if (hoverRAF || pending || dragging || boxSel) return;
|
|
781
|
+
hoverRAF = requestAnimationFrame(() => {
|
|
782
|
+
hoverRAF = 0;
|
|
783
|
+
if (!lastHoverXY || pending || dragging || boxSel) return;
|
|
784
|
+
let ep = null; try { ep = pickEndpoint(lastHoverXY[0], lastHoverXY[1]); } catch { ep = null; }
|
|
785
|
+
if (ep) { // over an end node → ring + move cursor; keep its member's dots shown
|
|
786
|
+
hoverEp = ep; canvasEl.style.cursor = 'move';
|
|
787
|
+
if (hoverId !== ep.id) { hoverId = ep.id; rebuildEndpoints(); }
|
|
788
|
+
updateStatusChip(); return;
|
|
789
|
+
}
|
|
790
|
+
hoverEp = null;
|
|
791
|
+
let id = null; try { id = pickAt(lastHoverXY[0], lastHoverXY[1]); } catch { id = null; }
|
|
792
|
+
canvasEl.style.cursor = geoMode() ? 'crosshair' : (id ? 'grab' : 'default');
|
|
793
|
+
if (id !== hoverId) { hoverId = id; rebuildEndpoints(); updateStatusChip(); }
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
function updateStatusChip() {
|
|
797
|
+
if (!hoverChip || !canvasEl) return;
|
|
798
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
799
|
+
let txt = '';
|
|
800
|
+
const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
|
|
801
|
+
if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
|
|
802
|
+
else if (selIds.size > 1) txt = `${selIds.size} members selected`;
|
|
803
|
+
else txt = 'Drag a member to move · ends to stretch · Alt+drag elevation · right-drag orbits';
|
|
804
|
+
hoverChip.textContent = txt;
|
|
805
|
+
hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
|
|
806
|
+
hoverChip.style.top = (rect.bottom - 30) + 'px';
|
|
807
|
+
hoverChip.style.display = canvasEl.style.display === 'none' ? 'none' : 'block';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
|
|
811
|
+
function hide() { if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } }
|
|
381
812
|
function isReady() { return built; }
|
|
382
813
|
|
|
383
814
|
function dispose() {
|
|
384
815
|
if (rafId) cancelAnimationFrame(rafId);
|
|
816
|
+
if (hoverRAF) cancelAnimationFrame(hoverRAF);
|
|
385
817
|
if (ro) ro.disconnect();
|
|
386
|
-
if (canvasEl) canvasEl.removeEventListener('pointerdown', onDown, true);
|
|
818
|
+
if (canvasEl) { canvasEl.removeEventListener('pointerdown', onDown, true); canvasEl.removeEventListener('pointermove', onHoverMove); canvasEl.removeEventListener('dblclick', onDblClick); }
|
|
387
819
|
window.removeEventListener('pointermove', onMove);
|
|
388
820
|
window.removeEventListener('pointerup', onUp);
|
|
389
|
-
|
|
821
|
+
window.removeEventListener('keydown', onKey);
|
|
822
|
+
for (const ovl of [readout, hoverChip, rubber]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
|
|
823
|
+
if (cube) {
|
|
824
|
+
cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
|
|
825
|
+
cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
|
|
826
|
+
}
|
|
827
|
+
if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
|
|
828
|
+
for (const o of [epRing, epPreview, refGroup]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
829
|
+
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
390
830
|
clearRoot();
|
|
391
831
|
if (renderer) renderer.dispose();
|
|
392
|
-
renderer = scene = camera = controls = root = api = canvasEl = ro = null; built = false;
|
|
832
|
+
renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
|
|
393
833
|
}
|
|
394
834
|
|
|
395
835
|
function debug() {
|
|
@@ -402,15 +842,21 @@ function debug() {
|
|
|
402
842
|
firstId: meshById.keys().next().value ?? null,
|
|
403
843
|
ids: [...meshById.keys()].slice(0, 12),
|
|
404
844
|
geomTypes: [...meshById.values()].reduce((h, m) => { const t = m.geometry.type; h[t] = (h[t] || 0) + 1; return h; }, {}),
|
|
405
|
-
lastPick,
|
|
845
|
+
lastPick, projection: projection(), mode: displayMode,
|
|
846
|
+
hidden: [...groupHidden], solo: soloGroup, visible: [...meshById.values()].filter((m) => m.visible).length,
|
|
847
|
+
endpoints: epGroup ? epGroup.children.length : 0,
|
|
848
|
+
endpointColors: epGroup ? epGroup.children.map((c) => '#' + c.material.color.getHexString()) : [],
|
|
849
|
+
hoverId, hoverEp, dragEp, refLine: refLineOn,
|
|
850
|
+
inFrustum: (() => { let n = 0, t = 0; for (const m of meshById.values()) { t++; const v = m.getWorldPosition(new THREE.Vector3()).project(camera); if (Math.abs(v.x) <= 1 && Math.abs(v.y) <= 1 && v.z >= -1 && v.z <= 1) n++; } return { n, t }; })(),
|
|
406
851
|
camPos: camera ? [camera.position.x, camera.position.y, camera.position.z] : null,
|
|
852
|
+
target: controls ? [controls.target.x, controls.target.y, controls.target.z] : null,
|
|
407
853
|
near: camera?.near, far: camera?.far,
|
|
408
854
|
};
|
|
409
855
|
}
|
|
410
856
|
// test helper: the current material colour + emissive of a member (verifies deselect restores colour)
|
|
411
857
|
function probe(id) {
|
|
412
858
|
const m = meshById.get(id); if (!m) return null;
|
|
413
|
-
return { color: '#' + m.material.color.getHexString(), emissive: m.material.emissiveIntensity || 0 };
|
|
859
|
+
return { color: '#' + m.material.color.getHexString(), emissive: m.material.emissiveIntensity || 0, visible: m.visible, wireframe: !!m.material.wireframe };
|
|
414
860
|
}
|
|
415
861
|
// test helper: client (px) coords of a member's endpoint (end 0|1) — lets an E2E drive a precise drag
|
|
416
862
|
function screenOf(id, end) {
|
|
@@ -419,4 +865,16 @@ function screenOf(id, end) {
|
|
|
419
865
|
const s = toScreen(g.line[end]); const rect = canvasEl.getBoundingClientRect();
|
|
420
866
|
return { x: rect.left + s.x, y: rect.top + s.y };
|
|
421
867
|
}
|
|
422
|
-
|
|
868
|
+
// test helper: client (px) coords of a member's centre — lets an E2E click/drag the body
|
|
869
|
+
function centerOf(id) {
|
|
870
|
+
const m = meshById.get(id); if (!m) return null;
|
|
871
|
+
const w = m.getWorldPosition(new THREE.Vector3()).project(camera); const rect = canvasEl.getBoundingClientRect();
|
|
872
|
+
return { x: rect.left + (w.x * 0.5 + 0.5) * rect.width, y: rect.top + (-w.y * 0.5 + 0.5) * rect.height };
|
|
873
|
+
}
|
|
874
|
+
window.Steel3DView = {
|
|
875
|
+
init, show, hide, rebuild, setSelection, isReady, dispose,
|
|
876
|
+
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
877
|
+
setRefLine, refLine: () => refLineOn,
|
|
878
|
+
toggleGroup, soloToggle, showAllGroups, groupState, getGroups,
|
|
879
|
+
debug, probe, screenOf, centerOf,
|
|
880
|
+
};
|
|
@@ -144,6 +144,21 @@
|
|
|
144
144
|
#viewToggle .seg.on{background:var(--panel);color:var(--text);box-shadow:inset 0 -2px 0 var(--brand)}
|
|
145
145
|
#stagewrap{position:relative}
|
|
146
146
|
#stage3d{position:absolute;inset:0;width:100%;height:100%;display:none;outline:none}
|
|
147
|
+
/* yellow = START (end 1), magenta = END (end 2) — also the drag handles. Same colours in 3D. */
|
|
148
|
+
circle.handle.ep-start{fill:#facc15;stroke:#0b1220;stroke-width:2}
|
|
149
|
+
circle.handle.ep-end{fill:#f472b6;stroke:#0b1220;stroke-width:2}
|
|
150
|
+
.epdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
|
|
151
|
+
/* 3D-only floating chrome (parity with AWARE viewer-3d): toolbar TL, legend BL, ViewCube BR */
|
|
152
|
+
#m3dBar{position:absolute;top:12px;left:12px;display:none;gap:6px;align-items:center;z-index:6}
|
|
153
|
+
#m3dBar .seg-group,#m3dBar>button{box-shadow:0 4px 14px rgba(0,0,0,.45)}
|
|
154
|
+
#m3dBar>button{font-size:12px;padding:5px 9px}
|
|
155
|
+
#m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;gap:1px;max-height:40%;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
|
|
156
|
+
#m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px}
|
|
157
|
+
#m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap}
|
|
158
|
+
#m3dLegend .lrow:hover{background:#33415580}
|
|
159
|
+
#m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
|
|
160
|
+
#m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
|
|
161
|
+
#m3dCube{position:absolute;right:12px;bottom:12px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
|
|
147
162
|
</style>
|
|
148
163
|
<script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/"}}</script>
|
|
149
164
|
<script type="module" src="./steel-3d-view.js"></script>
|
|
@@ -174,6 +189,14 @@
|
|
|
174
189
|
<div id=stagewrap>
|
|
175
190
|
<div id=stage><svg id=svg></svg></div>
|
|
176
191
|
<canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
|
|
192
|
+
<div id=m3dBar role=group aria-label="3D view controls">
|
|
193
|
+
<div class=seg-group id=m3dProj title="Camera projection"><button data-proj=persp class=on>Persp</button><button data-proj=ortho>Ortho</button></div>
|
|
194
|
+
<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>
|
|
195
|
+
<button id=m3dFit title="Fit all to view">Fit</button>
|
|
196
|
+
<button id=m3dRef title="Show the reference line (work-line between the end points) for every member">Ref line</button>
|
|
197
|
+
</div>
|
|
198
|
+
<div id=m3dLegend></div>
|
|
199
|
+
<div id=m3dCube title="Click a face for that view · drag the scene with the right button to orbit"></div>
|
|
177
200
|
<div id=zoombar>
|
|
178
201
|
<button id=zOut title="Zoom out">−</button>
|
|
179
202
|
<input id=zRange type=range min=10 max=400 step=1 value=100>
|
|
@@ -375,6 +398,17 @@ const fmtDecIn=v=>(v==null||isNaN(v))?'—':((Math.round(v*100)/100)+'"');
|
|
|
375
398
|
function ensureMeta(m){if(!m.role)m.role='beam';
|
|
376
399
|
if(!m.ends){const c=/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':'';m.ends=[{tos:null,note:c,tosDef:true,detail:''},{tos:null,note:c,tosDef:true,detail:''}];}
|
|
377
400
|
if(!m.col)m.col={bos:null,tos:null,note:(/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':''),tosDef:true,detail:''};return m;}
|
|
401
|
+
// Effective profile position on the reference line (the work-line between the end dots), à la Tekla.
|
|
402
|
+
// Explicit m.position wins; else a flat beam → top (top-of-steel), a steep beam (brace) / column → middle.
|
|
403
|
+
// MIRROR of server/contract-to-scene.ts depthPosition (same 0.5774 ≈ 30° threshold, mm units).
|
|
404
|
+
function posDefault(m){
|
|
405
|
+
if(m.position==='top'||m.position==='middle'||m.position==='bottom')return m.position;
|
|
406
|
+
if(m.role==='column')return 'middle';
|
|
407
|
+
const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1);
|
|
408
|
+
const z0=(m.ends&&m.ends[0]&&m.ends[0].tos!=null)?m.ends[0].tos:defaultTOS;
|
|
409
|
+
const z1=(m.ends&&m.ends[1]&&m.ends[1].tos!=null)?m.ends[1].tos:defaultTOS;
|
|
410
|
+
const dz=Math.abs((z0||0)-(z1||0))*25.4, horiz=Math.hypot(m.wp[1][0]-m.wp[0][0],m.wp[1][1]-m.wp[0][1])*k;
|
|
411
|
+
return dz>horiz*0.5774?'middle':'top';}
|
|
378
412
|
// best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
|
|
379
413
|
// 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
|
|
380
414
|
let defaultTOS=198, addProfile='';
|
|
@@ -385,10 +419,10 @@ const autofillTOS=syncDefaults;
|
|
|
385
419
|
// profs is per-plan — computed in setPlan()
|
|
386
420
|
function snapshot(){return JSON.stringify(P.members);}
|
|
387
421
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
388
|
-
function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();}
|
|
422
|
+
function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();sync3D();}
|
|
389
423
|
function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
|
|
390
424
|
function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
|
|
391
|
-
function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();}
|
|
425
|
+
function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
|
|
392
426
|
// --- auto-save: edits persist to this browser (localStorage). Raster stays embedded; only members + TOS are stored. ---
|
|
393
427
|
const LSKEY = 'steeltakeoff:edits:v1:' + APP_ID;
|
|
394
428
|
function dataSig(){return C.plans.map(p=>p.sheet+':'+((p.segments||[]).length)).join('|');} // ignore stored edits if the dataset changed
|
|
@@ -528,7 +562,7 @@ function render(){
|
|
|
528
562
|
for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
|
|
529
563
|
for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
|
|
530
564
|
s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
|
|
531
|
-
{const hsel=selArr();if(hsel.length>=1&&hsel.length<=2){const HR=
|
|
565
|
+
{const hsel=selArr();if(hsel.length>=1&&hsel.length<=2){const HR=5.5/zoom;for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · small + zoom-constant
|
|
532
566
|
if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
|
|
533
567
|
s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
|
|
534
568
|
if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
|
|
@@ -551,7 +585,7 @@ function updDup(){const n=redundantDups().length;
|
|
|
551
585
|
function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
|
|
552
586
|
svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
|
|
553
587
|
svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
|
|
554
|
-
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',
|
|
588
|
+
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',5.5/zoom));}
|
|
555
589
|
function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
|
|
556
590
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
557
591
|
if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
|
|
@@ -595,7 +629,7 @@ function panel(){
|
|
|
595
629
|
document.getElementById('pf').onchange=e=>{const v=e.target.value.toUpperCase().replace(/ /g,'');if(!v)return;edit(()=>{for(const m of selArr()){m.profile=v;m.rfi=(WT[v]==null);}if(!profs.includes(v)){profs.push(v);profs.sort();}});};
|
|
596
630
|
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
|
|
597
631
|
return;}
|
|
598
|
-
const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column');
|
|
632
|
+
const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
|
|
599
633
|
const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
|
|
600
634
|
const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
|
|
601
635
|
const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
|
|
@@ -603,11 +637,12 @@ function panel(){
|
|
|
603
637
|
return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div><input id=${id} inputmode=decimal placeholder="5 3/4" · 1'-0 1/4"" value="${esc(fmtFtIn(v))}"${def?' disabled':''}><span class=edec>${esc(fmtDecIn(v))}</span>`;};
|
|
604
638
|
const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
|
|
605
639
|
const dFld=(id,o)=>{const hasPrev=o.detail&&previewFor(o.detail);return `<div class=elab>Connection detail</div><div style="display:flex;gap:6px"><input id=${id} class=combo data-src=details placeholder="e.g. 5-S504" value="${esc(o.detail||'')}" style="flex:1" autocomplete=off>${hasPrev?`<button id=${id}_open class=ghost title="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" title="Pick a detail callout from the drawing">⌖</button></div>`;};
|
|
640
|
+
const dotS='<span class=epdot style="background:#facc15"></span>',dotE='<span class=epdot style="background:#f472b6"></span>'; // start=yellow, end=magenta — matches the canvas end markers
|
|
606
641
|
const elev = col
|
|
607
|
-
? `<div class="sect f">Elevation</div>${ftFld('bos','Bottom (BOS)',m.col.bos)}${tosFld('tosC','Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
|
|
608
|
-
: `<div class="sect f"
|
|
642
|
+
? `<div class="sect f">Elevation</div>${ftFld('bos',dotS+'Bottom (BOS)',m.col.bos)}${tosFld('tosC',dotE+'Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
|
|
643
|
+
: `<div class="sect f">${dotS}Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
|
|
609
644
|
<div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
|
|
610
|
-
<div class=sect
|
|
645
|
+
<div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
|
|
611
646
|
p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
|
|
612
647
|
<div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
|
|
613
648
|
<div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
|
|
@@ -615,6 +650,9 @@ function panel(){
|
|
|
615
650
|
<div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
|
|
616
651
|
${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
|
|
617
652
|
${elev}
|
|
653
|
+
${col?'':`<div class=divrow><hr><span class=sect style="margin:0">Profile position</span><hr></div>
|
|
654
|
+
<div class=hint style="margin:0 0 6px">Where the section sits on the reference line (between the end dots) — like Tekla. Top = top-of-steel (dots on the top flange).</div>
|
|
655
|
+
<div class=seg2 style="margin-top:0"><button id=posTop class="${pos==='top'?'on':''}">Top</button><button id=posMid class="${pos==='middle'?'on':''}">Middle</button><button id=posBot class="${pos==='bottom'?'on':''}">Bottom</button></div>`}
|
|
618
656
|
<div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
|
|
619
657
|
<div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim</button><button id=geoSplit class="${geoMode==='split'?'on':''}">Split</button></div>
|
|
620
658
|
<div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — another member or a grey segment. The nearest end of this member snaps to where the two lines meet (extends if short, trims if it overshoots).':geoMode==='split'?'Click a <b>point on this member</b> to cut it into two members.':'<b>Extend/Trim</b> (E) an end to meet another line · <b>Split</b> (S) at a point. <b>Esc</b> cancels.'}</div>
|
|
@@ -627,6 +665,9 @@ function panel(){
|
|
|
627
665
|
const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
|
|
628
666
|
if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
|
|
629
667
|
if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
|
|
668
|
+
{const setPos=v=>edit(()=>{m.position=v;}); // profile placement on the reference line (Tekla-style); persists + re-extrudes the 3D
|
|
669
|
+
const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
|
|
670
|
+
if(_pt)_pt.onclick=()=>setPos('top');if(_pm)_pm.onclick=()=>setPos('middle');if(_pb)_pb.onclick=()=>setPos('bottom');}
|
|
630
671
|
const wireTos=(id,o)=>{const ck=document.getElementById(id+'_ck');if(ck)ck.onchange=e=>edit(()=>{o.tosDef=e.target.checked;if(o.tosDef)o.tos=defaultTOS;});
|
|
631
672
|
const inp=document.getElementById(id);if(inp)inp.onchange=e=>edit(()=>{o.tos=parseLen(e.target.value);o.tosDef=false;});};
|
|
632
673
|
const wireDet=(id,o)=>{const i=document.getElementById(id);if(i)i.onchange=e=>edit(()=>{o.detail=e.target.value.trim();});
|
|
@@ -774,7 +815,7 @@ addEventListener('keydown',e=>{
|
|
|
774
815
|
if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
|
|
775
816
|
if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
|
|
776
817
|
if(e.key==='Escape'&&confOpen()){closeConf();return;}
|
|
777
|
-
if(e.key==='Home'){e.preventDefault();fitToWindow();return;}
|
|
818
|
+
if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
|
|
778
819
|
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;}
|
|
779
820
|
if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});return;}
|
|
780
821
|
if(!inForm&&selIds.size===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
|
|
@@ -795,15 +836,46 @@ document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
|
|
|
795
836
|
const view3dApi={
|
|
796
837
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
797
838
|
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}
|
|
798
|
-
onSelect:(id,additive)=>{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
|
|
839
|
+
onSelect:(id,additive)=>{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
|
|
840
|
+
onSelectMany:(ids)=>{selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
|
|
799
841
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
800
842
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
801
843
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
844
|
+
geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
|
|
802
845
|
onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
|
|
846
|
+
onMoveEndpoint:(id,end,wp)=>{edit(()=>{const m=byId(id);if(m&&Array.isArray(m.wp)&&m.wp.length>=2)m.wp[end]=wp;});}, // 3D end-node drag → write just that endpoint
|
|
803
847
|
onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
|
|
804
848
|
if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dIn;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dIn;m.col.tosDef=false;} // rigid raise (bos null→0, both shift) so the column isn't stretched
|
|
805
849
|
else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
|
|
850
|
+
onTrimExtend:(id,targetId)=>{const m=byId(id),tm=byId(targetId);if(m&&tm){snapEnd(m,tm.wp[0],tm.wp[1]);geoMode=null;setGeo();sync3D();}}, // 3D Trim/Extend → nearest end to the target member's line
|
|
851
|
+
onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
|
|
806
852
|
};
|
|
853
|
+
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
854
|
+
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
855
|
+
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile)
|
|
856
|
+
// Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
|
|
857
|
+
// double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
|
|
858
|
+
let leg3dClickT=null;
|
|
859
|
+
function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
|
|
860
|
+
const groups=window.Steel3DView.getGroups();host.replaceChildren();
|
|
861
|
+
if(!groups.length){host.style.display='none';return;}
|
|
862
|
+
const hint=document.createElement('div');hint.className='lhint';hint.textContent='click: hide/show · dbl-click: isolate';host.appendChild(hint);
|
|
863
|
+
for(const g of groups){const row=document.createElement('div');row.className='lrow';row.dataset.key=g.key;
|
|
864
|
+
const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
|
|
865
|
+
row.append(sw,document.createTextNode(g.label));
|
|
866
|
+
row.addEventListener('click',()=>{clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
|
|
867
|
+
row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);window.Steel3DView.soloToggle(g.key);refresh3DLegend();});
|
|
868
|
+
host.appendChild(row);}
|
|
869
|
+
host.style.display='flex';refresh3DLegend();}
|
|
870
|
+
function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden);
|
|
871
|
+
document.querySelectorAll('#m3dLegend .lrow').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(st.solo!==null&&st.solo!==k));});}
|
|
872
|
+
let bar3dWired=false;
|
|
873
|
+
function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
|
|
874
|
+
function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
875
|
+
document.querySelectorAll('#m3dProj button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);seg3dActive('#m3dProj','data-proj',b.dataset.proj);});
|
|
876
|
+
document.querySelectorAll('#m3dMode button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);seg3dActive('#m3dMode','data-mode',b.dataset.mode);});
|
|
877
|
+
document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
|
|
878
|
+
document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};}
|
|
807
879
|
function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
|
|
808
880
|
view3d=on;
|
|
809
881
|
const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
|
|
@@ -812,6 +884,10 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
|
|
|
812
884
|
document.getElementById('stage').style.display=on?'none':'';
|
|
813
885
|
document.getElementById('stage3d').style.display=on?'block':'none';
|
|
814
886
|
document.getElementById('zoombar').style.display=on?'none':''; // zoom slider is 2D-only
|
|
887
|
+
document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
|
|
888
|
+
document.getElementById('m3dBar').style.display=on?'flex':'none';
|
|
889
|
+
document.getElementById('m3dCube').style.display=on?'block':'none';
|
|
890
|
+
if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
|
|
815
891
|
}
|
|
816
892
|
async function setView(on){
|
|
817
893
|
if(on){
|
|
@@ -820,8 +896,10 @@ async function setView(on){
|
|
|
820
896
|
if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
|
|
821
897
|
applyViewState(true);
|
|
822
898
|
window.Steel3DView.show();
|
|
823
|
-
await window.Steel3DView.rebuild();
|
|
899
|
+
await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
|
|
824
900
|
window.Steel3DView.setSelection(selIds);
|
|
901
|
+
wire3DBar();build3DLegend();
|
|
902
|
+
seg3dActive('#m3dProj','data-proj',window.Steel3DView.projection());seg3dActive('#m3dMode','data-mode',window.Steel3DView.mode()); // reflect persisted state
|
|
825
903
|
}catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
|
|
826
904
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
|
|
827
905
|
toast('Could not open 3D view: '+((e&&e.message)||e));
|
|
@@ -1144,6 +1222,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1144
1222
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
1145
1223
|
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
1146
1224
|
defaultTOS=(P.default_tos!=null?P.default_tos:198);
|
|
1225
|
+
P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
|
|
1147
1226
|
if(!P.autofilled){autofillTOS();P.autofilled=true;}
|
|
1148
1227
|
const ds=new Set([...(P.details||[]).map(d=>d.text),...Object.keys(C.custom_details)]);document.getElementById('detailsBtn').textContent='Details ('+ds.size+')';
|
|
1149
1228
|
setMode();render();fitToWindow();
|