@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/floless-server.cjs +1353 -736
- package/dist/schemas/steel.takeoff.v1.schema.json +18 -0
- package/dist/skills/floless-app-new-workflow/SKILL.md +115 -0
- package/dist/skills/floless-app-new-workflow/references/build-and-verify.md +117 -0
- package/dist/skills/floless-app-onboarding/SKILL.md +2 -1
- package/dist/skills/floless-app-steel-model/SKILL.md +8 -4
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +84 -1
- package/dist/skills/floless-app-workflows/SKILL.md +8 -0
- package/dist/web/app.css +36 -0
- package/dist/web/app.js +6 -1
- package/dist/web/aware.js +124 -7
- package/dist/web/index.html +13 -0
- package/dist/web/steel-3d-view.js +149 -9
- package/dist/web/steel-editor.html +163 -8
- package/dist/web/vendor/README.md +15 -4
- package/dist/web/vendor/three-bvh-csg.module.js +7619 -0
- package/package.json +1 -1
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
|
-
:
|
|
432
|
-
? 'The
|
|
433
|
-
:
|
|
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>`
|
package/dist/web/index.html
CHANGED
|
@@ -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); //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
594
|
+
// "Zoom to detail": double-click a part → zoom 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)
|
|
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
|
|
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';
|