@floless/app 0.55.0 → 0.57.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/web/aware.js CHANGED
@@ -27,6 +27,9 @@
27
27
  const $reportClose = document.getElementById('report-close');
28
28
  const $reportStop = document.getElementById('report-stop');
29
29
  const $stopRunBtn = document.getElementById('stop-run-btn');
30
+ const $restoreBtn = document.getElementById('restore-btn');
31
+ const $customizeBtn = document.getElementById('customize-btn');
32
+ const $wfForked = document.getElementById('wf-forked');
30
33
 
31
34
  // Contract editor — a dedicated full-window surface for an editable typed
32
35
  // contract (steel takeoff, …). Unlike the opaque report viewer, this is a
@@ -426,11 +429,14 @@
426
429
  ...app.nodes.map((n) => `**${escapeHtml(n.id)}** → \`${escapeHtml(n.agent || n.kind)}${n.command ? '/' + escapeHtml(n.command) : ''}\` · ${escapeHtml(n.mode)}`),
427
430
  ];
428
431
  if (app.notes.length) narration.push(`ℹ ${app.notes.length} compiler note${app.notes.length === 1 ? '' : 's'} — see the strip over the canvas.`);
432
+ const maintainedDrift = app.publisher === 'floless' && !app.forked && app.runState === 'drift';
429
433
  narration.push(app.runnable
430
434
  ? 'Lock is fresh (source-hash matches). **Run** is armed.'
431
- : app.runState === 'drift'
432
- ? 'The `.flo` changed since the last approve **Compile** to refresh the lock before Run.'
433
- : 'No lock yet — **Compile** to freeze the approved contract.');
435
+ : maintainedDrift
436
+ ? 'The workflow logic changed since it was last approved. **Restore** the official version, or **Customize** it to make it your own.'
437
+ : app.runState === 'drift'
438
+ ? 'The `.flo` changed since the last approve — **Compile** to refresh the lock before Run.'
439
+ : 'No lock yet — **Compile** to freeze the approved contract.');
434
440
 
435
441
  if (app.layout === 'dag') {
436
442
  PROMPTS[app.id] = { name: app.name, layout: 'dag', nodes: layoutDag(app.nodes, app.connections), connections: app.connections, userText, narration };
@@ -475,6 +481,21 @@
475
481
  $compileBtn.disabled = false;
476
482
  $compileBtn.textContent = needsCompile ? '⎙ Compile *' : '⎙ Compile';
477
483
 
484
+ // Maintained = a FloLess-published app the user hasn't forked (so it has an official version to
485
+ // restore + an update channel). Customize (fork) is offered for any maintained app; Restore +
486
+ // suppressing Compile fire only when a maintained app's LOGIC drifted — approving that via Compile
487
+ // would silently accept an edit to a shipped workflow, defeating "shipped apps are form-only".
488
+ const maintained = app.publisher === 'floless' && !app.forked;
489
+ const drifted = app.runState === 'drift';
490
+ if ($wfForked) $wfForked.hidden = !app.forked;
491
+ if ($customizeBtn) $customizeBtn.hidden = !maintained;
492
+ if ($restoreBtn) $restoreBtn.hidden = !(maintained && drifted);
493
+ // Suppress Compile for a maintained, drifted app: approving an out-of-band logic edit via
494
+ // Compile would defeat "shipped apps are form-only". Disable as well as hide, so no programmatic
495
+ // click / queued event / shortcut can fire it through the hidden-but-enabled element.
496
+ if (maintained && drifted) { $compileBtn.hidden = true; $compileBtn.disabled = true; }
497
+ else $compileBtn.hidden = false;
498
+
478
499
  // Bake: package the workflow as a reusable agent. Gated on the same runnable
479
500
  // state as Run (baking a stale/uncompiled app makes no sense — the lock is
480
501
  // what the server reads to mirror inputs). Disabled-with-tooltip (teach the
@@ -669,6 +690,21 @@
669
690
  } catch (e) { reportErr(e); }
670
691
  }
671
692
 
693
+ // ≡ menu "New workflow…" — the create-from-scratch sibling of requestGuide(). No open
694
+ // workflow to target (none exists yet), so no currentId gate: relay the ask to the
695
+ // terminal AI the same house way (queue a request, copy the marked prompt, toast, light
696
+ // the requests pill). The UI never authors the workflow itself; the AI's
697
+ // floless-app-new-workflow skill does, step by step.
698
+ async function requestNewWorkflow() {
699
+ try {
700
+ const { request } = await api('/api/guide-new', { method: 'POST', body: '{}' });
701
+ const copied = await copyToClipboard(markedInstruction(request));
702
+ showToast(copied ? 'Building your workflow · copied to clipboard' : 'Building your workflow', 'ok');
703
+ appendNarration(`Asked your terminal AI to interview you and <strong>build a new workflow</strong> from scratch — watch the glowing <strong>requests</strong> pill (bottom-right), or switch to your terminal.${copied ? ' The prompt is also on your clipboard.' : ''}`);
704
+ loadRequests();
705
+ } catch (e) { reportErr(e); }
706
+ }
707
+
672
708
  // ── First-open spotlight (one-time per workflow) ────────────────────────────
673
709
  // Dims the canvas, raises the beacon, and points a small caption at it; Esc /
674
710
  // click-away / either button dismiss it for good. Slice 2 of the onboarding beacon.
@@ -852,6 +888,7 @@
852
888
  const aid = escapeAttr(a.id);
853
889
  html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-baked="${a.baked ? '1' : ''}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
854
890
  + `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
891
+ + (a.forked ? `<span class="wf-fork-badge" data-tip="You forked this workflow — it no longer receives FloLess updates. You own its output.">yours</span>` : '')
855
892
  + `<span class="wf-option-actions">`
856
893
  + `<button type="button" class="wf-act act-edit" data-wf-act="rename" data-id="${aid}" tabindex="-1" data-tip="Rename" aria-label="Rename ${aid}">✎</button>`
857
894
  + `<button type="button" class="wf-act act-dup" data-wf-act="duplicate" data-id="${aid}" tabindex="-1" data-tip="Duplicate" aria-label="Duplicate ${aid}">⧉</button>`
@@ -1112,6 +1149,64 @@
1112
1149
  }
1113
1150
  }
1114
1151
 
1152
+ // ── Fork / Restore: the maintained ⇄ owned boundary ──────────────────────────
1153
+ // FORK crosses a maintained app to user-owned: it requires explicit liability acceptance (a ticked
1154
+ // checkbox — informed consent for structural-steel output) and takes the app off the update channel.
1155
+ // The .flo is NOT edited here; the user changes the workflow afterwards (via the terminal AI). RESTORE
1156
+ // re-installs the official version over a forked/edited app and returns it to maintained.
1157
+ async function forkWorkflow(id) {
1158
+ closeCombo(false);
1159
+ const res = await formModal({
1160
+ title: 'Customize this workflow',
1161
+ sub: 'Forking lets you change the workflow logic — but it takes it off the FloLess update channel. If you only need a different project’s inputs, use Duplicate (⧉) instead — duplicates stay maintained.',
1162
+ fields: [{
1163
+ type: 'checkbox',
1164
+ name: 'accept',
1165
+ label: 'I understand this is structural-steel software. I am responsible for verifying that any changes I make produce correct output before using the results on a real project.',
1166
+ required: true,
1167
+ value: false,
1168
+ }],
1169
+ okLabel: 'Fork & customize',
1170
+ });
1171
+ if (!res || res.accept !== true) return; // cancelled, or (defensive) terms not ticked
1172
+ showToast('Forking…', 'ok');
1173
+ try {
1174
+ await api(`/api/app/${encodeURIComponent(id)}/fork`, { method: 'POST', body: JSON.stringify({ accept: true }) });
1175
+ await loadApps();
1176
+ if (currentId === id) await loadApp(id);
1177
+ await refreshWorkflowUpdates(); // it just left the channel — drop any pending update pill
1178
+ showToast('Forked — this workflow is now yours to edit. Run ⎙ Compile when you’re ready.', 'ok');
1179
+ } catch (e) {
1180
+ console.error(e);
1181
+ showToast(e.message || 'Fork failed', 'warn');
1182
+ }
1183
+ }
1184
+
1185
+ async function restoreWorkflow(id) {
1186
+ closeCombo(false);
1187
+ const ok = await formModal({
1188
+ title: 'Restore the official version?',
1189
+ sub: 'Re-installs the official workflow and discards any changes made to its logic. Your saved inputs are kept.',
1190
+ fields: [],
1191
+ okLabel: 'Restore',
1192
+ });
1193
+ if (!ok) return;
1194
+ showToast('Restoring…', 'ok');
1195
+ try {
1196
+ await api(`/api/app/${encodeURIComponent(id)}/restore`, { method: 'POST', body: JSON.stringify({}) });
1197
+ await loadApps();
1198
+ if (currentId === id) await loadApp(id);
1199
+ await refreshWorkflowUpdates(); // back on the channel — re-offer any pending update
1200
+ showToast('Restored to the official version.', 'ok');
1201
+ } catch (e) {
1202
+ console.error(e);
1203
+ showToast(e.message || 'Restore failed', 'warn');
1204
+ }
1205
+ }
1206
+
1207
+ if ($customizeBtn) $customizeBtn.onclick = () => { if (currentId) forkWorkflow(currentId); };
1208
+ if ($restoreBtn) $restoreBtn.onclick = () => { if (currentId) restoreWorkflow(currentId); };
1209
+
1115
1210
  // ── app inputs + HTML Viewer ────────────────────────────────────────────────
1116
1211
 
1117
1212
  // The id of the report-viewer node — the terminal node a user double-clicks to
@@ -2150,6 +2245,11 @@
2150
2245
  const id = `fm-${f.name}`;
2151
2246
  const val = f.value != null ? String(f.value) : '';
2152
2247
  const ph = escapeHtml(f.placeholder || '');
2248
+ // A consent checkbox renders inline (label beside the box, not above) — used for the
2249
+ // fork liability terms. `required: true` gates the OK button until it's ticked.
2250
+ if (f.type === 'checkbox') {
2251
+ return `<label class="modal-check" for="${id}"><input type="checkbox" id="${id}" data-fm-check="${escapeHtml(f.name)}"${f.value ? ' checked' : ''}><span>${escapeHtml(f.label || f.name)}</span></label>`;
2252
+ }
2153
2253
  let ctl;
2154
2254
  if (f.type === 'images') {
2155
2255
  ctl = `<div class="fm-images" data-fm-images="${escapeHtml(f.name)}">
@@ -2293,9 +2393,20 @@
2293
2393
  const file = fileItems[0].getAsFile();
2294
2394
  if (entry && file) entry.setFile(file);
2295
2395
  };
2396
+ // Required consent checkboxes (e.g. the fork liability terms) disable OK until ticked.
2397
+ const requiredChecks = Array.from($body.querySelectorAll('[data-fm-check]')).filter((el) => {
2398
+ const f = fields.find((x) => x.name === el.dataset.fmCheck);
2399
+ return f && f.required;
2400
+ });
2401
+ if (requiredChecks.length) {
2402
+ const syncOkGate = () => { $ok.disabled = requiredChecks.some((el) => !el.checked); };
2403
+ requiredChecks.forEach((el) => { el.onchange = syncOkGate; });
2404
+ syncOkGate();
2405
+ }
2296
2406
  const collect = () => {
2297
2407
  const out = {};
2298
2408
  $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
2409
+ $body.querySelectorAll('[data-fm-check]').forEach((el) => { out[el.dataset.fmCheck] = el.checked; });
2299
2410
  imageStates.forEach((entry, name) => { out[name] = entry.state; });
2300
2411
  fileStates.forEach((entry, name) => {
2301
2412
  out[name] = entry.st.mode === 'empty' ? '' : entry.st.value;
@@ -2308,7 +2419,7 @@
2308
2419
  _formModalOpen = false;
2309
2420
  hideModal($formModal);
2310
2421
  $ok.onclick = null; $cancel.onclick = null; $formModal.onclick = null; $formModal.onkeydown = null; $body.onpaste = null;
2311
- $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); // reset chrome for the next caller
2422
+ $ok.classList.remove('danger'); $ok.classList.add('primary'); $cancel.classList.remove('primary'); $ok.disabled = false; // reset chrome for the next caller
2312
2423
  resolve(result);
2313
2424
  };
2314
2425
  $ok.onclick = () => done(collect());
@@ -2319,7 +2430,7 @@
2319
2430
  // commits only for a normal form (never a danger confirm; never inside a textarea).
2320
2431
  $formModal.onkeydown = (e) => {
2321
2432
  if (e.key === 'Escape') { e.preventDefault(); done(null); }
2322
- else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); done(collect()); }
2433
+ else if (e.key === 'Enter' && !danger && e.target.tagName !== 'TEXTAREA' && !$ok.disabled) { e.preventDefault(); done(collect()); }
2323
2434
  };
2324
2435
  // Setup succeeded and the modal is shown — mark it open now. Setting it here (not
2325
2436
  // before the synchronous setup above) means a setup throw can't latch the guard for
@@ -2426,7 +2537,7 @@
2426
2537
  $promptSel.onchange = () => { loadApp($promptSel.value).catch(reportErr); };
2427
2538
 
2428
2539
  $compileBtn.onclick = async () => {
2429
- if (!currentId || $compileBtn.disabled) return;
2540
+ if (!currentId || $compileBtn.disabled || $compileBtn.hidden) return; // hidden = suppressed for a maintained drift
2430
2541
  const id = currentId;
2431
2542
  $compileBtn.disabled = true;
2432
2543
  const prev = $compileBtn.textContent;
@@ -2736,6 +2847,7 @@
2736
2847
  // app.js's doOpen/doSave are reassigned from this file).
2737
2848
  const _handleMenuAction = handleMenuAction;
2738
2849
  handleMenuAction = function (action) {
2850
+ if (action === 'new-workflow') { requestNewWorkflow(); return; }
2739
2851
  if (action === 'graft') { openGraftModal(); return; }
2740
2852
  if (action === 'bake') { openBakeModal(); return; }
2741
2853
  if (action === 'import') { triggerImport(); return; }
@@ -4318,6 +4430,9 @@
4318
4430
  if (req.type === 'guide') {
4319
4431
  return `Walk me through the floless workflow "${req.appId}" — explain in plain English what it does and how to run it (set its inputs → Compile → ▶ Run). Use your floless-app-${req.appId} skill if one exists; otherwise read the workflow's own description and node notes from GET /api/app/${req.appId} and explain from those. This is a guided explanation — don't change anything.`;
4320
4432
  }
4433
+ if (req.type === 'new-workflow') {
4434
+ return `Help me build a brand-new floless workflow from scratch, step by step, with your floless-app-new-workflow skill. Ask me what I want it to do, then before writing any node confirm the agents it needs are installed (offer to install a missing one from the catalogue, or to report a not-yet-existing agent as an idea). Author the .flo node by node, verifying each step is correct (plain-English descriptions, validate, compile, a real run), and finish by installing → Compile → ▶ Run so I can see it work and approve.`;
4435
+ }
4321
4436
  return '';
4322
4437
  }
4323
4438
 
@@ -4331,6 +4446,8 @@
4331
4446
  // The guided-tour ask is picked up by onboarding; the instruction body names the
4332
4447
  // per-app floless-app-<appId> skill for depth when one exists.
4333
4448
  guide: 'floless-app-onboarding',
4449
+ // Build-a-workflow-from-scratch: its own guide skill (interview → author → verify).
4450
+ 'new-workflow': 'floless-app-new-workflow',
4334
4451
  };
4335
4452
 
4336
4453
  // The COPIED form of a request: instructionFor() prefixed with a self-identifying
@@ -4425,7 +4542,7 @@
4425
4542
  return;
4426
4543
  }
4427
4544
  $list.innerHTML = pendingRequests.map((r) => {
4428
- const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : 'tweak';
4545
+ const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : r.type === 'guide' ? 'guide' : r.type === 'new-workflow' ? 'new workflow' : 'tweak';
4429
4546
  const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
4430
4547
  const target = r.type === 'tweak' && r.nodeId
4431
4548
  ? ` · node <code>${escapeHtml(r.nodeId)}</code>`
@@ -55,6 +55,9 @@
55
55
  </div>
56
56
  <select id="prompt-select" hidden></select>
57
57
  <button id="wf-update" class="wf-update-pill" type="button" hidden aria-haspopup="dialog"></button>
58
+ <!-- Shown only when the OPEN workflow is forked (user-owned, off the update channel). Purely
59
+ informational (the badge of ownership); the picker row carries the same "yours" cue. -->
60
+ <span id="wf-forked" class="wf-forked-pill" hidden role="status" data-tip="You forked this workflow — it no longer receives FloLess updates. You are responsible for verifying its output.">yours · off updates</span>
58
61
  <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
59
62
  (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
60
63
  <span class="ctl-sep" aria-hidden="true"></span>
@@ -66,6 +69,12 @@
66
69
  <span class="guide-beacon-label">Start here</span>
67
70
  </button>
68
71
  <span class="run-state" id="run-state" role="status" aria-live="polite"></span>
72
+ <!-- Restore + Customize: shown for a MAINTAINED (publisher:floless, non-forked) app. Restore
73
+ appears only when its logic drifted (re-installs the official version); Customize (fork) is
74
+ always available for a maintained app. When shown on drift they replace Compile, so a
75
+ shipped app's logic edit can't be silently approved (form-only). -->
76
+ <button id="restore-btn" type="button" hidden data-tip="Restore the official workflow — undoes any out-of-band edits and re-arms Run">↺ Restore</button>
77
+ <button id="customize-btn" type="button" hidden data-tip="Fork this workflow to edit it yourself — takes it off the FloLess update channel">Customize…</button>
69
78
  <button id="compile-btn" data-tip="Compile + approve → freeze the .lock">⎙ Compile</button>
70
79
  <button id="sim-btn" data-tip="Simulate: stub every node from its output schema — no live host is contacted. Validates the workflow's composition end-to-end, even when the real agents aren't connected yet.">Simulate</button>
71
80
  <button id="run-btn" data-tip="Run the workflow for real against the live host (uses the inputs; renders the report node in the HTML Viewer)">▶ Run workflow</button>
@@ -276,6 +285,10 @@
276
285
  <span class="menu-label">Save inputs</span>
277
286
  <span class="menu-kbd">Ctrl+S</span>
278
287
  </button>
288
+ <button class="menu-item" data-action="new-workflow" role="menuitem">
289
+ <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M9 15h6"/><path d="M12 18v-6"/></svg></span>
290
+ <span class="menu-label">New workflow…</span>
291
+ </button>
279
292
  <button class="menu-item" data-action="import" role="menuitem">
280
293
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3v12m0 0-4-4m4 4 4-4"/><path d="M3 17v2a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-2"/></svg></span>
281
294
  <span class="menu-label">Import workflow…</span>
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import * as THREE from 'three';
18
18
  import { OrbitControls } from 'three/addons/OrbitControls.js';
19
+ import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
19
20
  import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom } from './steel-3d-core.js';
20
21
 
21
22
  let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
@@ -126,7 +127,7 @@ function init(canvas, theApi) {
126
127
  window.addEventListener('pointermove', onMove); // window so a drag keeps tracking off-canvas
127
128
  window.addEventListener('pointerup', onUp);
128
129
  canvas.addEventListener('pointermove', onHoverMove); // canvas-only: cursor + hover readout when idle
129
- canvas.addEventListener('dblclick', onDblClick); // Tekla "set view point": dbl-click sets the orbit/zoom pivot
130
+ canvas.addEventListener('dblclick', onDblClick); // "zoom to detail": dbl-click a part zooms to it (empty = no-op)
130
131
  window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
131
132
  ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
132
133
  initCube();
@@ -223,10 +224,98 @@ function placeMember(mesh, a, b, el) {
223
224
  mesh.position.copy(A).addScaledVector(dir, len / 2);
224
225
  }
225
226
 
227
+ // ---- connection parts (base-plate slice) — the new scene-element kinds the connection engine
228
+ // (server/steel-joints.ts) emits: a plate with holes, anchor rods, a weld bead. These are DERIVED
229
+ // geometry, not contract members, so buildFromScene marks them non-editable (excluded from picks).
230
+
231
+ // A flat plate (with punched holes) extruded along its normal, oriented by its in-plane axes.
232
+ function placePlate(mesh, el) {
233
+ const w = Math.max(el.width || 10, 1), d = Math.max(el.depth || 10, 1), t = Math.max(el.thickness || 5, 0.5);
234
+ const hw = w / 2, hd = d / 2;
235
+ const shape = new THREE.Shape();
236
+ shape.moveTo(-hw, -hd); shape.lineTo(hw, -hd); shape.lineTo(hw, hd); shape.lineTo(-hw, hd); shape.closePath();
237
+ for (const h of el.holes || []) { // bolts own the holes — punch one per fastener
238
+ const r = Math.max((h.d || 0) / 2, 0.1);
239
+ const hole = new THREE.Path(); hole.absarc(h.u || 0, h.v || 0, r, 0, Math.PI * 2, true);
240
+ shape.holes.push(hole);
241
+ }
242
+ const g = new THREE.ExtrudeGeometry(shape, { depth: t, bevelEnabled: false });
243
+ g.translate(0, 0, -t / 2); // centre the slab on the plate plane
244
+ mesh.geometry = g;
245
+ const u = V(...(el.uDir || [1, 0, 0])).normalize();
246
+ const v = V(...(el.vDir || [0, 1, 0])).normalize();
247
+ const n = new THREE.Vector3().crossVectors(u, v).normalize(); // extrude axis = plate normal
248
+ mesh.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(u, v, n));
249
+ mesh.position.set(...(el.center || [0, 0, 0]));
250
+ }
251
+
252
+ // A cylinder between two points (anchor rod / bolt shank).
253
+ function placeBar(mesh, a, b, r, radial) {
254
+ const A = V(a[0], a[1], a[2]), B = V(b[0], b[1], b[2]);
255
+ const dir = new THREE.Vector3().subVectors(B, A); const len = dir.length() || 1; dir.normalize();
256
+ mesh.geometry = new THREE.CylinderGeometry(r, r, len, radial || 16, 1, false); // axis = local +Y
257
+ mesh.quaternion.setFromUnitVectors(_YA, dir);
258
+ mesh.position.copy(A).addScaledVector(dir, len / 2);
259
+ }
260
+
261
+ // A fillet weld traced as a thin bead along its path — STRAIGHT segments (no CatmullRom rounding) so it
262
+ // hugs the actual edge. Path points are already world mm. `closed` (default true) wraps end→start (the
263
+ // base-plate column outline). `closed:false` = an OPEN polyline (the shear plate's straight edge weld) —
264
+ // must NOT wrap: a 2-point wrap doubles back into a cusp that degenerates the tube (an invisible weld).
265
+ function placeWeld(mesh, el) {
266
+ const pts = (el.path || []).map((p) => V(p[0], p[1], p[2]));
267
+ if (pts.length < 2) { mesh.geometry = new THREE.BufferGeometry(); return; }
268
+ const r = Math.max((el.leg || 6) * 0.6, 2); // enough girth to read as a fillet bead, not a wire ring
269
+ const closed = el.closed !== false; // default = a closed loop (back-compat with the base-plate weld)
270
+ const cp = new THREE.CurvePath();
271
+ const last = closed ? pts.length : pts.length - 1; // closed → wrap the final segment; open → stop one short
272
+ for (let i = 0; i < last; i++) { // skip any zero-length segment (avoids NaN tube frames)
273
+ const a = pts[i], b = pts[(i + 1) % pts.length];
274
+ if (a.distanceToSquared(b) > 1e-6) cp.add(new THREE.LineCurve3(a, b));
275
+ }
276
+ if (!cp.curves.length) { mesh.geometry = new THREE.BufferGeometry(); return; }
277
+ mesh.geometry = new THREE.TubeGeometry(cp, Math.max(pts.length * 6, 24), r, 8, false);
278
+ mesh.position.set(0, 0, 0); // geometry carries world coordinates
279
+ }
280
+
281
+ // A hex nut: a 6-sided prism swept from→to. acrossFlats (wrench size) → circumradius = AF/√3.
282
+ function placeNut(mesh, el) {
283
+ placeBar(mesh, el.from, el.to, Math.max((el.acrossFlats || 20) / Math.sqrt(3), 0.5), 6);
284
+ }
285
+
286
+ // A flat washer: an annulus (outer ⌀ with an inner hole) swept from→to along its axis.
287
+ function placeWasher(mesh, el) {
288
+ const A = V(el.from[0], el.from[1], el.from[2]), B = V(el.to[0], el.to[1], el.to[2]);
289
+ const dir = new THREE.Vector3().subVectors(B, A); const len = dir.length() || 1; dir.normalize();
290
+ const ro = Math.max((el.dOuter || 20) / 2, 0.5), ri = Math.min(Math.max((el.dInner || 10) / 2, 0.1), ro - 0.2);
291
+ const shape = new THREE.Shape(); shape.absarc(0, 0, ro, 0, Math.PI * 2, false);
292
+ const hole = new THREE.Path(); hole.absarc(0, 0, ri, 0, Math.PI * 2, true); shape.holes.push(hole);
293
+ mesh.geometry = new THREE.ExtrudeGeometry(shape, { depth: len, bevelEnabled: false, curveSegments: 24 });
294
+ mesh.quaternion.setFromUnitVectors(_ZA, dir); // shape-plane normal (local +Z) → washer axis
295
+ mesh.position.copy(A); // extrudes from `from` toward `to`
296
+ }
297
+
298
+ // Build a mesh for one scene element by kind: a member box (default) or a connection part. Returns
299
+ // false for an unknown kind so the caller skips it (forward-compatible with later part kinds).
300
+ function placeElement(mesh, el) {
301
+ switch (el.kind || 'box') {
302
+ case 'box': placeMember(mesh, el.from, el.to, el); return true;
303
+ case 'plate': placePlate(mesh, el); return true;
304
+ case 'rod': placeBar(mesh, el.from, el.to, Math.max((el.d || 20) / 2, 0.5), 16); return true;
305
+ case 'nut': placeNut(mesh, el); return true;
306
+ case 'washer': placeWasher(mesh, el); return true;
307
+ case 'weld': placeWeld(mesh, el); return true;
308
+ default: return false;
309
+ }
310
+ }
311
+
226
312
  function materialFor(groupKey) {
227
313
  if (!baseMat.has(groupKey)) {
228
314
  const col = groupColor.get(groupKey) || new THREE.Color(0x94a3b8);
229
- baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: 0.1, roughness: 0.75 }));
315
+ // Fasteners (anchor rods / bolts / nuts / washers) read more metallic (zinc-plated); plates/welds/members stay matte.
316
+ const metallic = groupKey === 'anchor' || groupKey === 'bolt' || groupKey === 'nut' || groupKey === 'washer';
317
+ const metal = metallic ? 0.5 : 0.1, rough = metallic ? 0.4 : 0.75;
318
+ baseMat.set(groupKey, new THREE.MeshStandardMaterial({ color: col, metalness: metal, roughness: rough }));
230
319
  }
231
320
  return baseMat.get(groupKey);
232
321
  }
@@ -240,6 +329,34 @@ function clearRoot() {
240
329
  meshById.clear();
241
330
  }
242
331
 
332
+ // Subtract each cope (a `cut` part) from a member's solid via CSG — a real coped beam end. Cut boxes are
333
+ // world-space (center/uDir/vDir/width/depth/thickness); we transform them into the member's LOCAL frame so
334
+ // the result keeps the member's placement (drag/select still work), then subtract. On any CSG failure the
335
+ // member is left uncoped rather than breaking the whole scene.
336
+ const _csgEval = new Evaluator();
337
+ _csgEval.useGroups = false; // one solid out → the member's single material applies
338
+ function applyCopes(mesh, cuts) {
339
+ try {
340
+ mesh.updateMatrix(); // local TRS (root is identity → local == world mm)
341
+ const invMember = mesh.matrix.clone().invert();
342
+ let result = new Brush(mesh.geometry); result.updateMatrixWorld(true); // member-local, identity
343
+ for (const cut of cuts) {
344
+ const pad = 0.6; // over-cut so coplanar faces don't leave slivers
345
+ const g = new THREE.BoxGeometry(Math.max(cut.width || 1, 1) + pad, Math.max(cut.depth || 1, 1) + pad, Math.max(cut.thickness || 1, 1) + pad);
346
+ const u = V(...(cut.uDir || [1, 0, 0])).normalize(), vv = V(...(cut.vDir || [0, 1, 0])).normalize();
347
+ const n = new THREE.Vector3().crossVectors(u, vv).normalize();
348
+ const world = new THREE.Matrix4().makeBasis(u, vv, n); world.setPosition(cut.center[0], cut.center[1], cut.center[2]);
349
+ const b = new Brush(g);
350
+ invMember.clone().multiply(world).decompose(b.position, b.quaternion, b.scale); // cut → member-local
351
+ b.updateMatrixWorld(true);
352
+ result = _csgEval.evaluate(result, b, SUBTRACTION);
353
+ g.dispose();
354
+ }
355
+ mesh.geometry.dispose();
356
+ mesh.geometry = result.geometry; // member-local solid carrying the notch(es); mesh keeps its placement
357
+ } catch (e) { console.warn('[steel-3d] cope CSG failed; member left uncoped', mesh.userData && mesh.userData.id, e); }
358
+ }
359
+
243
360
  function buildFromScene(sc) {
244
361
  clearRoot();
245
362
  for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
@@ -247,10 +364,22 @@ function buildFromScene(sc) {
247
364
  sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
248
365
  for (const g of sceneGroups) groupColor.set(g.key, new THREE.Color(g.color));
249
366
  const box = new THREE.Box3();
367
+ // Copes (`cut` parts) are SUBTRACTIVE — they notch their target member's mesh, they don't render as
368
+ // standalone parts. Collect them by target member id first, then subtract once the member mesh is built.
369
+ const cutsByMember = new Map();
370
+ for (const el of sc.elements || []) {
371
+ if (el && el.kind === 'cut' && el.member) {
372
+ if (!cutsByMember.has(el.member)) cutsByMember.set(el.member, []);
373
+ cutsByMember.get(el.member).push(el);
374
+ }
375
+ }
250
376
  for (const el of sc.elements || []) {
251
377
  const mesh = new THREE.Mesh(undefined, materialFor(el.group));
252
- placeMember(mesh, el.from, el.to, el); // real cross-section (I / channel / angle / tube / box) from the profile
378
+ if (!placeElement(mesh, el)) continue; // unknown kind → skip (a `cut` is one of these: applied below, never a standalone mesh)
379
+ const memberCuts = cutsByMember.get(el.id);
380
+ if (memberCuts && memberCuts.length) applyCopes(mesh, memberCuts); // notch a coped member end
253
381
  mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
382
+ mesh.userData.derived = !!(el.kind && el.kind !== 'box'); // connection parts: rendered, not member-editable
254
383
  root.add(mesh); meshById.set(el.id, mesh);
255
384
  box.expandByObject(mesh);
256
385
  }
@@ -462,14 +591,25 @@ function reflectDimBar() {
462
591
  reflectDimAxisBar();
463
592
  }
464
593
  function reflectDimAxisBar() { document.querySelectorAll('#m3dDimAxis button').forEach((x) => x.classList.toggle('on', x.dataset.d3axis === dimAxis3d)); }
465
- // Tekla "set view point": double-click a membermake that point the orbit/zoom pivot.
594
+ // "Zoom to detail": double-click a partzoom to a tight region around the click point so a connection
595
+ // fills the view (members AND their derived connection parts are valid targets), keeping the current view
596
+ // direction. Empty space → no-op (use the Fit button / Home for fit-all, so a stray double-click can't
597
+ // teleport the camera). Replaces the old set-orbit-pivot — zoom-to sets the pivot anyway, and
598
+ // zoomToCursor already lets the wheel zoom toward the cursor.
466
599
  function onDblClick(e) {
467
600
  camera.updateMatrixWorld(); root.updateMatrixWorld(true);
468
601
  const rect = canvasEl.getBoundingClientRect();
469
602
  const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
470
603
  raycaster.setFromCamera(ndc, camera);
471
- const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
472
- if (hits.length) { controls.target.copy(hits[0].point); controls.update(); }
604
+ const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false); // incl. connection parts
605
+ if (!hits.length) return; // empty space → no-op (Fit / Home fit-all; avoids an accidental camera teleport)
606
+ const p = hits[0].point, mesh = hits[0].object;
607
+ if (mesh.geometry && !mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
608
+ const s = mesh.geometry && mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getSize(new THREE.Vector3()) : V(400, 400, 400);
609
+ const sect = Math.max(40, Math.min(s.x, s.y, s.z)); // the part's smallest extent ≈ a section / plate scale
610
+ const half = Math.min(Math.max(sect * 3, 300), 2500); // a detail-scale zoom box (clamped sane)
611
+ const viewDir = camera.position.clone().sub(controls.target).normalize(); // keep the current view orientation
612
+ fitCamera(new THREE.Box3().setFromCenterAndSize(p, new THREE.Vector3(half * 2, half * 2, half * 2)), viewDir.lengthSq() > 0.5 ? viewDir : undefined);
473
613
  }
474
614
 
475
615
  function setSelection(ids) {
@@ -636,7 +776,7 @@ function pickAt(cx, cy) {
636
776
  const rect = canvasEl.getBoundingClientRect();
637
777
  const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
638
778
  raycaster.setFromCamera(ndc, camera);
639
- const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false);
779
+ const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible && !m.userData.derived), false);
640
780
  lastPick = hits.length ? hits[0].object.userData.id : null;
641
781
  return lastPick;
642
782
  }
@@ -855,7 +995,7 @@ function membersInRect(x0, y0, x1, y1) {
855
995
  const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
856
996
  const out = [];
857
997
  for (const [id, m] of meshById) {
858
- if (!m.visible) continue;
998
+ if (!m.visible || m.userData.derived) continue; // derived connection parts aren't member-selectable (mirrors pickAt; dbl-click-zoom deliberately includes them)
859
999
  const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
860
1000
  const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
861
1001
  if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(id);
@@ -959,7 +1099,7 @@ function updateStatusChip() {
959
1099
  const showId = hoverId || (selIds.size === 1 ? [...selIds][0] : null);
960
1100
  if (showId) { const m = members().find((x) => x.id === showId); txt = m ? `${m.id} · ${m.profile || '—'}` : ''; }
961
1101
  else if (selIds.size > 1) txt = `${selIds.size} members selected`;
962
- else txt = 'Drag a member to move · ends to stretch · Alt+drag elevation · right-drag orbits';
1102
+ else txt = 'Drag to move · ends to stretch · Alt+drag elevation · right-drag orbit · dbl-click to zoom in';
963
1103
  hoverChip.textContent = txt;
964
1104
  hoverChip.style.left = (rect.left + rect.width / 2) + 'px'; hoverChip.style.transform = 'translateX(-50%)';
965
1105
  hoverChip.style.top = (rect.bottom - 30) + 'px';