@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.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.35.1" : void 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.35.1" : void 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
- from = [ax, ay, z0];
53830
- to = [bx, by, z1];
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. Phase B = read-only: orbit + shaded model + selection highlight. Phase C
7
- * adds drag + snapping (it will pull the pure math from ./steel-3d-core.js).
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
- camera = new THREE.PerspectiveCamera(50, 1, 1, 50_000_000);
35
- camera.up.set(0, 0, 1); // Z-up to match the scene
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 → OrbitControls orbits/pans as usual.
56
- // Capture phase on pointerdown so we can stop orbit BEFORE OrbitControls sees a member grab.
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
- camera.aspect = w / h; camera.updateProjectionMatrix();
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(); renderer.render(scene, camera);
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
- for (const g of sc.groups || []) groupColor.set(g.key, new THREE.Color(g.color || '#94a3b8'));
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; // group needed to restore the profile material on deselect
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 || !box.isEmpty()) { fitCamera(box); fitPending = false; }
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
- function fitCamera(box) {
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 dir = new THREE.Vector3(0.55, -0.8, 0.5).normalize(); // 3/4 iso: front-right-above (Z-up)
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 = dir.clone().negate();
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 tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2), tanH = tanV * camera.aspect;
211
- let need = 1000;
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
- need = Math.max(need, Math.abs(corner.dot(right)) / tanH, Math.abs(corner.dot(up)) / tanV);
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
- camera.position.copy(c).addScaledVector(dir, dist);
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
- camera.near = Math.max(dist / 1000, 1); camera.far = dist + r * 4; camera.updateProjectionMatrix();
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
- async function rebuild() {
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 markerSize = () => Math.max(camera.position.distanceTo(controls.target) * 0.012, 10);
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
- if (!id) { pending = null; return; } // empty space OrbitControls orbits/pans
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 (!pending || !pending.grab || !pending.origMm) return;
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'; // always clear drag overlays, even on the early-return paths
357
- if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; return; } // 3D inactive
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) { // empty-space gesture: a click (no move) clears the selection
362
- if (moved <= DRAG_TOL_PX && api && api.onSelect) api.onSelect(null, false);
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
- function show() { if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); } // resume the render loop
380
- function hide() { if (canvasEl) canvasEl.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } // stop rendering while in 2D
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
- if (readout && readout.parentNode) readout.parentNode.removeChild(readout);
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
- window.Steel3DView = { init, show, hide, rebuild, setSelection, isReady, debug, probe, screenOf, dispose };
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=9/zoom;for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class=handle data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${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',9/zoom));}
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&quot; · 1&#39;-0 1/4&quot;" 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&quot; · 1&#39;-0 1/4&quot;" 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">Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
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>End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
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 (Phase B4)
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.35.1",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {