@floless/app 0.70.0 → 0.72.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 +411 -232
- package/dist/web/app.css +82 -0
- package/dist/web/aware.js +1 -0
- package/dist/web/index.html +44 -0
- package/dist/web/steel-3d-view.js +83 -6
- package/dist/web/steel-editor.html +103 -7
- package/dist/web/steel-filter.html +5 -2
- package/dist/web/workspaces.js +270 -0
- package/package.json +1 -1
package/dist/web/app.css
CHANGED
|
@@ -3308,3 +3308,85 @@ body {
|
|
|
3308
3308
|
header { flex-wrap: wrap; gap: 8px; padding: 8px 14px; }
|
|
3309
3309
|
select, .wf-combo { min-width: 0; }
|
|
3310
3310
|
}
|
|
3311
|
+
|
|
3312
|
+
/* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
|
|
3313
|
+
/* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
|
|
3314
|
+
UA display:none — these guards keep hidden winning so nothing leaks across modes. */
|
|
3315
|
+
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden] { display:none !important; }
|
|
3316
|
+
.mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
|
|
3317
|
+
overflow:hidden; background:var(--surface-2); flex:none; }
|
|
3318
|
+
.mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
|
|
3319
|
+
font-size:12px; padding:7px 14px; letter-spacing:.02em; cursor:pointer; transition:all .15s; }
|
|
3320
|
+
.mode-switch button + button { border-left:1px solid var(--border-strong); }
|
|
3321
|
+
.mode-switch button:hover { color:var(--text); }
|
|
3322
|
+
.mode-switch button.active { background:var(--accent-soft); color:var(--accent-bright); }
|
|
3323
|
+
|
|
3324
|
+
/* Mode gating: Workflows chrome hides wholesale in Workspaces (hidden, never disabled).
|
|
3325
|
+
≡ keeps only app-wide items; Dashboard is Workflows-scoped (spec §2). */
|
|
3326
|
+
.app.mode-workspaces .controls > label,
|
|
3327
|
+
.app.mode-workspaces .wf-combo, .app.mode-workspaces #wf-update, .app.mode-workspaces #wf-forked,
|
|
3328
|
+
.app.mode-workspaces #guide-beacon, .app.mode-workspaces #run-state,
|
|
3329
|
+
.app.mode-workspaces #restore-btn, .app.mode-workspaces #customize-btn,
|
|
3330
|
+
.app.mode-workspaces #compile-btn, .app.mode-workspaces #sim-btn,
|
|
3331
|
+
.app.mode-workspaces #run-btn, .app.mode-workspaces #stop-run-btn,
|
|
3332
|
+
.app.mode-workspaces #ext-badge,
|
|
3333
|
+
.app.mode-workspaces #menu .menu-item[data-action="view-switch"],
|
|
3334
|
+
.app.mode-workspaces #menu .menu-item[data-action="find"],
|
|
3335
|
+
.app.mode-workspaces #menu .menu-item[data-action="open"],
|
|
3336
|
+
.app.mode-workspaces #menu .menu-item[data-action="save"],
|
|
3337
|
+
.app.mode-workspaces #menu .menu-item[data-action="new-workflow"],
|
|
3338
|
+
.app.mode-workspaces #menu .menu-item[data-action="import"],
|
|
3339
|
+
.app.mode-workspaces #menu .menu-item[data-action="agents"],
|
|
3340
|
+
.app.mode-workspaces #menu .menu-item[data-action="graft"],
|
|
3341
|
+
.app.mode-workspaces #menu .menu-item[data-action="bake"] { display:none; }
|
|
3342
|
+
.app.mode-workspaces .topology, .app.mode-workspaces .canvas-toolbar,
|
|
3343
|
+
.app.mode-workspaces .hint, .app.mode-workspaces .fav-bar, .app.mode-workspaces .dashboard,
|
|
3344
|
+
.app.mode-workspaces .notes-strip, .app.mode-workspaces .find-overlay,
|
|
3345
|
+
/* the ws surfaces carry their own headings — hide the canvas's "Canvas · transparency layer" label */
|
|
3346
|
+
.app.mode-workspaces .canvas > .panel-label { display:none !important; }
|
|
3347
|
+
/* the editor owns center+right in a project — collapse the inspect column entirely */
|
|
3348
|
+
.app.mode-workspaces { --right-width:0px !important; }
|
|
3349
|
+
.app.mode-workspaces .inspect { display:none; }
|
|
3350
|
+
.app.mode-workspaces .canvas { cursor:default; touch-action:auto; }
|
|
3351
|
+
.ws-spine { display:flex; align-items:center; gap:10px; }
|
|
3352
|
+
#ws-proj-name { max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
3353
|
+
#ws-proj-trigger { display:inline-flex; align-items:center; gap:8px; }
|
|
3354
|
+
|
|
3355
|
+
/* landing */
|
|
3356
|
+
.ws-landing { flex:1; min-height:0; overflow-y:auto; padding:6px 20px 24px; }
|
|
3357
|
+
.ws-head h2 { font-size:17px; font-weight:650; letter-spacing:.01em; }
|
|
3358
|
+
.ws-head .ws-sub { color:var(--text-muted); font-size:12px; margin:2px 0 14px; }
|
|
3359
|
+
.ws-grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); align-content:start; }
|
|
3360
|
+
.ws-card { background:var(--surface); border:1px solid var(--border); border-radius:8px; overflow:hidden;
|
|
3361
|
+
cursor:pointer; transition:all .15s; text-align:left; padding:0; display:flex; flex-direction:column; }
|
|
3362
|
+
.ws-card:hover { border-color:var(--accent-dim); transform:translateY(-1px); }
|
|
3363
|
+
.ws-card .ws-thumb { height:88px; background:var(--surface-2); border-bottom:1px solid var(--border);
|
|
3364
|
+
display:flex; align-items:center; justify-content:center; position:relative; color:var(--accent); font-size:24px; }
|
|
3365
|
+
.ws-card .ws-kind { position:absolute; top:8px; left:8px; font-size:9px; text-transform:uppercase; letter-spacing:.1em;
|
|
3366
|
+
color:var(--accent-bright); background:var(--surface); border:1px solid var(--accent-dim); padding:2px 7px; border-radius:4px; }
|
|
3367
|
+
.ws-card .ws-body { padding:11px 13px 13px; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; }
|
|
3368
|
+
.ws-card .ws-name { font-size:13px; font-weight:600; color:var(--text); }
|
|
3369
|
+
.ws-card .ws-meta { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
|
3370
|
+
.ws-card .ws-meta .t { font-family:var(--mono); font-size:10px; color:var(--text-dim); }
|
|
3371
|
+
.ws-card .ws-kebab { background:transparent; border:1px solid transparent; color:var(--text-dim);
|
|
3372
|
+
padding:2px 7px; border-radius:4px; font-size:14px; line-height:1; flex:none; }
|
|
3373
|
+
.ws-card .ws-kebab:hover { border-color:var(--border-strong); color:var(--text); }
|
|
3374
|
+
.ws-seed { border:2px dashed var(--accent-dim); background:transparent; border-radius:8px; min-height:150px;
|
|
3375
|
+
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; cursor:pointer;
|
|
3376
|
+
color:var(--text-muted); font-size:12px; width:100%; text-align:center; padding:12px; }
|
|
3377
|
+
.ws-seed:hover { background:var(--accent-soft); border-color:var(--accent); }
|
|
3378
|
+
.ws-seed .plus { font-size:24px; color:var(--accent); line-height:1; }
|
|
3379
|
+
.ws-empty-note { color:var(--text-dim); font-size:12px; margin-top:10px; }
|
|
3380
|
+
|
|
3381
|
+
/* open project */
|
|
3382
|
+
.ws-project { flex:1; min-height:0; display:flex; flex-direction:column; }
|
|
3383
|
+
.ws-crumb-row { padding:12px 14px 8px; display:flex; justify-content:space-between; align-items:center; }
|
|
3384
|
+
.ws-crumb { font-size:12px; color:var(--text-muted); }
|
|
3385
|
+
.ws-crumb-link { background:none; border:none; padding:0; color:var(--text-muted); font-size:12px; cursor:pointer; }
|
|
3386
|
+
.ws-crumb-link:hover { color:var(--accent); }
|
|
3387
|
+
.ws-crumb-sep { color:var(--text-dim); margin:0 6px; }
|
|
3388
|
+
.ws-crumb-cur { color:var(--text); }
|
|
3389
|
+
.ws-status { font-size:10px; color:var(--text-dim); font-style:italic; }
|
|
3390
|
+
.tabs.ws-step-tabs { background:transparent; padding:0 6px; }
|
|
3391
|
+
.tabs.ws-step-tabs button { flex:0 0 auto; padding:11px 16px; }
|
|
3392
|
+
.ws-frame { flex:1; min-height:0; width:100%; border:none; background:var(--bg); }
|
package/dist/web/aware.js
CHANGED
|
@@ -6445,6 +6445,7 @@
|
|
|
6445
6445
|
copyToClipboard,
|
|
6446
6446
|
instructionFor,
|
|
6447
6447
|
markedInstruction, // marked (paste-safe) form for clipboard copies — panels.js uses it (#73)
|
|
6448
|
+
formModal, // the shared styled prompt/form modal — workspaces.js reuses it (never a native prompt)
|
|
6448
6449
|
};
|
|
6449
6450
|
|
|
6450
6451
|
// ── boot ──────────────────────────────────────────────────────────────────────
|
package/dist/web/index.html
CHANGED
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
</span>
|
|
34
34
|
<span class="name">FloLess</span>
|
|
35
35
|
</div>
|
|
36
|
+
<!-- Top-level mode switch (Workflows = node canvas + Run; Workspaces = project/document
|
|
37
|
+
apps, no Run). Segmented control, Ctrl+1/Ctrl+2. See docs/superpowers/specs/
|
|
38
|
+
2026-07-03-workspaces-two-mode-shell-design.md. -->
|
|
39
|
+
<div class="mode-switch" id="mode-switch" role="tablist" aria-label="Mode">
|
|
40
|
+
<button type="button" data-mode="workflows" class="active" role="tab" aria-selected="true">Workflows</button>
|
|
41
|
+
<button type="button" data-mode="workspaces" role="tab" aria-selected="false">Workspaces</button>
|
|
42
|
+
</div>
|
|
36
43
|
<!-- Workspace view switch (Canvas = the workflow topology; Dashboard = the user's
|
|
37
44
|
custom panels from ~/.floless/ui/extensions.json) moved into the ≡ menu in #149.
|
|
38
45
|
The "dashboard updated" signal now rides the ≡ hamburger badge (.has-dash-update)
|
|
@@ -61,6 +68,20 @@
|
|
|
61
68
|
<!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
|
|
62
69
|
(Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
|
|
63
70
|
<span class="ctl-sep" aria-hidden="true"></span>
|
|
71
|
+
<!-- Workspaces project spine — swapped in wholesale by mode (the restore/customize
|
|
72
|
+
swap precedent). Approve takes Run's filled-primary slot; Export/Rollback arrive
|
|
73
|
+
in slices 2/3. Hidden (never disabled) outside an open project. -->
|
|
74
|
+
<div class="ws-spine" id="ws-spine" hidden>
|
|
75
|
+
<label id="ws-proj-label">project</label>
|
|
76
|
+
<button id="ws-proj-trigger" class="wf-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
|
|
77
|
+
data-tip="This project — open another, rename, duplicate, archive">
|
|
78
|
+
<span id="ws-proj-name">—</span>
|
|
79
|
+
<svg class="wf-caret" width="10" height="6" viewBox="0 0 10 6" aria-hidden="true"><path fill="currentColor" d="M0 0l5 6 5-6z"/></svg>
|
|
80
|
+
</button>
|
|
81
|
+
<span class="ctl-sep" aria-hidden="true"></span>
|
|
82
|
+
<button id="ws-approve" type="button" class="primary"
|
|
83
|
+
data-tip="Locks in this version of the model — your edits already auto-save; this is the sign-off.">✓ Approve</button>
|
|
84
|
+
</div>
|
|
64
85
|
<!-- Per-workflow onboarding beacon. Pulses "✨ Start here" while this workflow is
|
|
65
86
|
new to the user (no first Run completed, not dismissed); calms to a quiet
|
|
66
87
|
"❔ Guide" after — always clickable for re-reference. Hidden until an app loads. -->
|
|
@@ -134,6 +155,24 @@
|
|
|
134
155
|
~/.floless/ui/extensions.json here; the canvas children hide via
|
|
135
156
|
.canvas.view-dashboard). Composed by the terminal AI, rendered by us. -->
|
|
136
157
|
<div class="dashboard" id="dashboard" hidden></div>
|
|
158
|
+
<!-- Workspaces mode surfaces (web/workspaces.js renders both; hidden outside the mode).
|
|
159
|
+
Landing = project grid; project = step tabs over the editor/filter iframes. -->
|
|
160
|
+
<div class="ws-landing" id="ws-landing" hidden></div>
|
|
161
|
+
<div class="ws-project" id="ws-project" hidden>
|
|
162
|
+
<div class="ws-crumb-row">
|
|
163
|
+
<span class="ws-crumb"><button type="button" id="ws-back" class="ws-crumb-link">Workspaces</button>
|
|
164
|
+
<span class="ws-crumb-sep">›</span><span id="ws-crumb-name" class="ws-crumb-cur">—</span></span>
|
|
165
|
+
<span class="ws-status" id="ws-status"></span>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="tabs ws-step-tabs" id="ws-step-tabs" role="tablist" aria-label="Project steps">
|
|
168
|
+
<button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
|
|
169
|
+
<button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
|
|
170
|
+
</div>
|
|
171
|
+
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
172
|
+
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
173
|
+
<iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
|
|
174
|
+
<iframe id="ws-frame-drawings" class="ws-frame" title="Project drawings & filter" hidden></iframe>
|
|
175
|
+
</div>
|
|
137
176
|
<div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
|
|
138
177
|
<div class="fav-bar" id="fav-bar">
|
|
139
178
|
<div class="fav-bar-label"><span class="star">★</span><span>Templates</span></div>
|
|
@@ -767,9 +806,14 @@
|
|
|
767
806
|
</div>
|
|
768
807
|
</div>
|
|
769
808
|
</div>
|
|
809
|
+
<!-- Project picker + lifecycle. The FULL lifecycle set lives HERE and only here — ≡ carries
|
|
810
|
+
nothing project-specific (spec §2). Populated per-open in workspaces.js. -->
|
|
811
|
+
<div class="menu ws-proj-menu" id="ws-proj-menu" role="menu" hidden></div>
|
|
812
|
+
|
|
770
813
|
<script src="app.js"></script>
|
|
771
814
|
<script src="renderers.js"></script>
|
|
772
815
|
<script src="aware.js"></script>
|
|
773
816
|
<script src="panels.js"></script>
|
|
817
|
+
<script src="workspaces.js"></script>
|
|
774
818
|
</body>
|
|
775
819
|
</html>
|
|
@@ -526,6 +526,7 @@ function applyCopes(mesh, cuts) {
|
|
|
526
526
|
|
|
527
527
|
function buildFromScene(sc) {
|
|
528
528
|
clearRoot();
|
|
529
|
+
resetConnState(); // a fresh scene rebuilds selection from scratch — drop any stale connection envelope/context
|
|
529
530
|
for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
|
|
530
531
|
groupColor.clear(); baseMat.clear();
|
|
531
532
|
sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
|
|
@@ -547,6 +548,7 @@ function buildFromScene(sc) {
|
|
|
547
548
|
if (memberCuts && memberCuts.length) applyCopes(mesh, memberCuts); // notch a coped member end
|
|
548
549
|
mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
|
|
549
550
|
mesh.userData.derived = !!(el.kind && el.kind !== 'box'); // connection parts: rendered, not member-editable
|
|
551
|
+
mesh.userData.conn = el.conn || null; mesh.userData.connKind = el.connKind || null; // Connection Component membership (Slice A) — the whole-select/drill handle
|
|
550
552
|
root.add(mesh); meshById.set(el.id, mesh);
|
|
551
553
|
box.expandByObject(mesh);
|
|
552
554
|
}
|
|
@@ -1302,6 +1304,7 @@ function onKey(e) {
|
|
|
1302
1304
|
if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
|
|
1303
1305
|
if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
|
|
1304
1306
|
if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
|
|
1307
|
+
if (e.key === 'Escape' && !dimMode3d && !cmActive() && ascendConn()) { e.preventDefault(); return; } // Esc ascends the connection drill: part → whole → nothing
|
|
1305
1308
|
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)
|
|
1306
1309
|
const k = e.key.toLowerCase();
|
|
1307
1310
|
// Don't touch the dim tool while a member gesture (drag / box-select) owns the shared marker/readout —
|
|
@@ -1354,6 +1357,16 @@ function onDblClick(e) {
|
|
|
1354
1357
|
const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false); // incl. connection parts
|
|
1355
1358
|
if (!hits.length) return; // empty space → no-op (Fit / Home fit-all; avoids an accidental camera teleport)
|
|
1356
1359
|
const p = hits[0].point, mesh = hits[0].object;
|
|
1360
|
+
// Connection drill-down (Slice A): double-clicking a part of a connection we're NOT already inside ENTERS
|
|
1361
|
+
// that connection (selects the part under the cursor) and frames it. A part of the connection we're
|
|
1362
|
+
// already in, or a bare member, falls through to the classic zoom-to-part below (non-breaking).
|
|
1363
|
+
const dblConn = mesh.userData && mesh.userData.conn;
|
|
1364
|
+
if (dblConn && ctxConn !== dblConn) {
|
|
1365
|
+
enterConn(dblConn, mesh.userData.id);
|
|
1366
|
+
const cb = connBox(dblConn);
|
|
1367
|
+
if (!cb.isEmpty()) { const vDir = camera.position.clone().sub(controls.target).normalize(); fitCamera(cb, vDir.lengthSq() > 0.5 ? vDir : undefined); }
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1357
1370
|
if (mesh.geometry && !mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
|
|
1358
1371
|
const s = mesh.geometry && mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getSize(new THREE.Vector3()) : V(400, 400, 400);
|
|
1359
1372
|
const sect = Math.max(40, Math.min(s.x, s.y, s.z)); // the part's smallest extent ≈ a section / plate scale
|
|
@@ -1378,6 +1391,7 @@ function setSelection(ids) {
|
|
|
1378
1391
|
}
|
|
1379
1392
|
applyDisplayMode(); // selection swapped the materials → re-apply wire/xray
|
|
1380
1393
|
selIds = new Set(set);
|
|
1394
|
+
reconcileConnState(set); // any selection path (2D click, box-select, keyboard) must not leave a stale connection envelope/drill
|
|
1381
1395
|
rebuildEndpoints(); // endpoint dots follow the selection (+ any hover)
|
|
1382
1396
|
updateStatusChip();
|
|
1383
1397
|
}
|
|
@@ -1817,14 +1831,19 @@ const CYCLE_TOL_PX = 8;
|
|
|
1817
1831
|
function resetCycle() { cycleAnchor = null; cycleIds = []; cycleIdx = 0; }
|
|
1818
1832
|
function clickSelect(cx, cy, ctrl) {
|
|
1819
1833
|
let hits = []; try { hits = pickAllAt(cx, cy); } catch { hits = []; }
|
|
1820
|
-
if (!hits.length) { resetCycle();
|
|
1821
|
-
if (ctrl) { resetCycle(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest
|
|
1834
|
+
if (!hits.length) { resetCycle(); clearConnSel(); return; } // empty → deselect (clears any connection too)
|
|
1835
|
+
if (ctrl) { resetCycle(); resetConnState(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest RAW part (leaves connection mode)
|
|
1822
1836
|
const same = cycleAnchor && Math.hypot(cx - cycleAnchor[0], cy - cycleAnchor[1]) <= CYCLE_TOL_PX
|
|
1823
1837
|
&& cycleIds.length === hits.length && cycleIds.every((v, i) => v === hits[i]);
|
|
1824
1838
|
if (same) cycleIdx = (cycleIdx + 1) % hits.length; else { cycleIds = hits; cycleIdx = 0; cycleAnchor = [cx, cy]; }
|
|
1825
|
-
const pick = hits[cycleIdx],
|
|
1826
|
-
if (
|
|
1827
|
-
|
|
1839
|
+
const pick = hits[cycleIdx], conn = connOf(pick);
|
|
1840
|
+
if (!conn) { resetConnState(); if (api && api.onSelect) api.onSelect(pick, false); return; } // a bare member → normal single select
|
|
1841
|
+
if (ctxConn === conn) { // drilled INTO this connection → clicks land on its parts (bolt array or a single part)
|
|
1842
|
+
const grp = boltGroupOf(pick);
|
|
1843
|
+
if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); } else if (api && api.onSelect) api.onSelect(pick, false);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
selectWholeConn(conn); // at root (or over a different connection) → select the WHOLE connection
|
|
1828
1847
|
}
|
|
1829
1848
|
// A bolt/head/nut id → all bolt-group part ids of the same joint (the connection's bolt array); else just [id].
|
|
1830
1849
|
function boltGroupOf(id) {
|
|
@@ -1834,6 +1853,63 @@ function boltGroupOf(id) {
|
|
|
1834
1853
|
const ids = [...meshById.keys()].filter((k) => { const c = k.indexOf(':'); return c >= 0 && k.slice(0, c) === jid && /^(bolt|head|nut)/.test(k.slice(c + 1)); });
|
|
1835
1854
|
return ids.length ? ids : [id];
|
|
1836
1855
|
}
|
|
1856
|
+
|
|
1857
|
+
// ── Connection Components (Slice A): select/drill a whole connection (base-plate / shear-plate) as ONE
|
|
1858
|
+
// unit. `selConn` = the connection currently whole-selected at root; `ctxConn` = the connection we've
|
|
1859
|
+
// DRILLED INTO (double-click) so subsequent clicks land on its individual parts. Both derive from the
|
|
1860
|
+
// `conn` tag every ConnPart carries (buildFromScene stashes el.conn on userData). A bare member (no conn)
|
|
1861
|
+
// clears both. The host editor re-derives its breadcrumb + component inspector from the selection ids each
|
|
1862
|
+
// render() — no view→editor callback needed; reconcileConnState() (from setSelection) keeps this honest.
|
|
1863
|
+
let selConn = null, ctxConn = null;
|
|
1864
|
+
function connOf(id) { const m = id && meshById.get(id); return m && m.userData ? (m.userData.conn || null) : null; }
|
|
1865
|
+
function connChildIds(conn) { const out = []; for (const [id, m] of meshById) { if (m.userData && m.userData.conn === conn) out.push(id); } return out; } // every rendered part of this connection
|
|
1866
|
+
function connBox(conn) { const b = new THREE.Box3(); for (const m of meshById.values()) { if (m.userData && m.userData.conn === conn && m.visible) b.expandByObject(m); } return b; }
|
|
1867
|
+
// The dashed brand-blue envelope = the single "this is a group" cue for a whole-connection selection.
|
|
1868
|
+
let connEnvelope = null;
|
|
1869
|
+
function clearConnEnvelope() { if (connEnvelope) { if (overlayScene) overlayScene.remove(connEnvelope); connEnvelope.geometry.dispose(); connEnvelope.material.dispose(); connEnvelope = null; } }
|
|
1870
|
+
function renderConnEnvelope(conn) {
|
|
1871
|
+
clearConnEnvelope();
|
|
1872
|
+
if (!conn || !overlayScene) return;
|
|
1873
|
+
const b = connBox(conn); if (b.isEmpty()) return;
|
|
1874
|
+
b.expandByScalar(Math.max(6, b.getSize(new THREE.Vector3()).length() * 0.02)); // a little breathing room around the parts
|
|
1875
|
+
connEnvelope = new THREE.Box3Helper(b, new THREE.Color(SELECT_EMISSIVE)); // --brand
|
|
1876
|
+
connEnvelope.material.depthTest = false; connEnvelope.material.transparent = true; connEnvelope.material.opacity = 0.6; connEnvelope.renderOrder = 996;
|
|
1877
|
+
overlayScene.add(connEnvelope);
|
|
1878
|
+
}
|
|
1879
|
+
function resetConnState() { selConn = null; ctxConn = null; clearConnEnvelope(); } // internal reset, no callbacks
|
|
1880
|
+
// Select the WHOLE connection at root (single-click a part, or a breadcrumb click). Clears any drill context.
|
|
1881
|
+
function selectWholeConn(conn) {
|
|
1882
|
+
if (!conn || !connChildIds(conn).length) return clearConnSel();
|
|
1883
|
+
selConn = conn; ctxConn = null;
|
|
1884
|
+
renderConnEnvelope(conn);
|
|
1885
|
+
if (api && api.onSelectMany) api.onSelectMany(connChildIds(conn));
|
|
1886
|
+
}
|
|
1887
|
+
// Clear any connection selection/drill (back to bare Model root — deselects).
|
|
1888
|
+
function clearConnSel() { resetConnState(); if (api && api.onSelect) api.onSelect(null, false); }
|
|
1889
|
+
// Enter a connection (double-click) and select the part under the cursor — the drill-in step.
|
|
1890
|
+
function enterConn(conn, partId) {
|
|
1891
|
+
ctxConn = conn; selConn = conn; clearConnEnvelope(); // inside → the part-level highlight carries; no whole envelope
|
|
1892
|
+
const grp = boltGroupOf(partId);
|
|
1893
|
+
if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); }
|
|
1894
|
+
else if (api && api.onSelect) api.onSelect(partId, false);
|
|
1895
|
+
}
|
|
1896
|
+
// Ascend one level: drilled part → whole connection → nothing. Returns true if it consumed the gesture.
|
|
1897
|
+
function ascendConn() {
|
|
1898
|
+
if (ctxConn) { selectWholeConn(ctxConn); return true; } // part → whole
|
|
1899
|
+
if (selConn) { clearConnSel(); return true; } // whole → nothing
|
|
1900
|
+
return false;
|
|
1901
|
+
}
|
|
1902
|
+
// Keep the connection state honest against ANY selection change — not just the 3D click paths but a 2D
|
|
1903
|
+
// member click, box-select, keyboard, or Delete that route through setSelection(). Whole: the full child
|
|
1904
|
+
// set must still be selected, else drop the stale envelope; drilled: the selection must stay WITHIN the
|
|
1905
|
+
// connection, else exit the drill. Callback-free (resetConnState) so it can't recurse through render().
|
|
1906
|
+
function reconcileConnState(set) {
|
|
1907
|
+
if (!selConn) return;
|
|
1908
|
+
const kids = connChildIds(selConn);
|
|
1909
|
+
if (ctxConn) { if (!set.size || ![...set].every((id) => kids.includes(id))) resetConnState(); } // drilled: any pick outside the connection → exit
|
|
1910
|
+
else if (!kids.length || !kids.every((k) => set.has(k))) resetConnState(); // whole: must remain the full set, else drop the envelope
|
|
1911
|
+
}
|
|
1912
|
+
function connContext() { return { selConn, ctxConn }; } // test/editor read
|
|
1837
1913
|
// The (currently shown) end-node dot nearest the cursor within a screen tolerance → { id, end } or
|
|
1838
1914
|
// null. Screen-space (not a raycast) so the small dots are easy to grab at any zoom. Dots win over
|
|
1839
1915
|
// the member body, letting you grab one end to stretch it.
|
|
@@ -2575,7 +2651,7 @@ function dispose() {
|
|
|
2575
2651
|
gridTexCache.clear();
|
|
2576
2652
|
clearRoot();
|
|
2577
2653
|
if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
|
|
2578
|
-
clearClipGizmo(); setClipPreview(null); overlayScene = null;
|
|
2654
|
+
clearConnEnvelope(); clearClipGizmo(); setClipPreview(null); overlayScene = null;
|
|
2579
2655
|
clips = []; workArea = null; clipMode = null; selectedClipIds.clear(); clipBoxDraft = null; // clips live on the renderer; drop them with the renderer
|
|
2580
2656
|
if (renderer) renderer.dispose();
|
|
2581
2657
|
renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
|
|
@@ -2637,6 +2713,7 @@ window.Steel3DView = {
|
|
|
2637
2713
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
2638
2714
|
setRefLine, refLine: () => refLineOn,
|
|
2639
2715
|
setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
|
|
2716
|
+
selectWholeConn, clearConnSel, ascendConn, connContext, connEnvelopeOn: () => !!connEnvelope, // Connection Components (Slice A): whole-select / drill / ascend + test probes
|
|
2640
2717
|
setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
|
|
2641
2718
|
syncMemberLabels, // editor calls after a mark/id edit to refresh labels
|
|
2642
2719
|
setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
|
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
.detf input{width:100%}
|
|
34
34
|
#detOpacity{accent-color:var(--brand);flex:1;min-width:0}
|
|
35
35
|
#zoombar #zPct{min-width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
|
|
36
|
+
/* Connection Component breadcrumb (Slice A) — a floating chip over the 3D canvas, same recipe as #zoombar. */
|
|
37
|
+
#connCrumb{position:absolute;left:50%;top:48px;transform:translateX(-50%);display:none;align-items:center;gap:1px;max-width:min(72%,560px);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:58;font-size:12px;white-space:nowrap;overflow:hidden} /* below #m3dBar (top:12,h~29,z:59); z:58 keeps it clickable above the dim-label chips (57) */
|
|
38
|
+
#connCrumb .seg{color:var(--mut);cursor:pointer;padding:1px 4px;border-radius:4px;background:none;border:0;font:inherit;max-width:260px;overflow:hidden;text-overflow:ellipsis}
|
|
39
|
+
#connCrumb .seg:hover{color:var(--text);text-decoration:underline}
|
|
40
|
+
#connCrumb .seg.cur{color:var(--brand);font-weight:600;cursor:default;text-decoration:none}
|
|
41
|
+
#connCrumb .sep{color:var(--mut);opacity:.7;padding:0 2px}
|
|
42
|
+
.pilllink{background:none;border:0;color:var(--brand);cursor:pointer;font:inherit;padding:0;text-decoration:underline}
|
|
36
43
|
aside{width:240px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto}
|
|
37
44
|
aside h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
|
|
38
45
|
select,input{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:6px;width:100%;font:13px system-ui}
|
|
@@ -496,6 +503,7 @@
|
|
|
496
503
|
<div id=stagewrap>
|
|
497
504
|
<div id=stage><svg id=svg></svg></div>
|
|
498
505
|
<canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
|
|
506
|
+
<div id=connCrumb role=navigation aria-label="Connection breadcrumb"></div>
|
|
499
507
|
<div id=m3dBar role=group aria-label="3D view controls">
|
|
500
508
|
<!-- Camera projection — dropdown (like Plane / Work area); the button shows the current mode -->
|
|
501
509
|
<div class=m3dwrap>
|
|
@@ -639,6 +647,11 @@
|
|
|
639
647
|
<div class=lbpanel><div class=mhead><b id=lbCap></b><div style="display:flex;gap:10px;align-items:center"><span class=hint>scroll = zoom · drag = pan · dbl-click = reset</span><button id=lbClose>✕</button></div></div><div id=lbView><img id=lbImg></div></div></div>
|
|
640
648
|
<script>
|
|
641
649
|
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
650
|
+
// Workspaces: a project-keyed contract lives at projects/<id>/contract.json. Empty PROJECT
|
|
651
|
+
// = legacy app-keyed behaviour, byte-for-byte today's URLs. PROJECT_QS is appended to every
|
|
652
|
+
// /api/contract call; the contract-request (Ask AI) bodies carry `project` for slice-4 context.
|
|
653
|
+
const PROJECT = new URLSearchParams(location.search).get('project') || '';
|
|
654
|
+
const PROJECT_QS = PROJECT ? ('?project=' + encodeURIComponent(PROJECT)) : '';
|
|
642
655
|
let C, PAL, WT;
|
|
643
656
|
let TARGET_CONF = null; // the app's target_confidence input default (%), or null when undeclared — drives the chip's "· target N%" goal
|
|
644
657
|
let view3d = false, view3dReady = false; // 2D|3D toggle state; view3dReady once Steel3DView.init has run
|
|
@@ -659,7 +672,7 @@ function showEmpty(icon, headline, bodyText, appIdText) {
|
|
|
659
672
|
}
|
|
660
673
|
async function boot() {
|
|
661
674
|
if (!APP_ID) { showEmpty('⊞', 'No workflow selected', 'Open a workflow in the FloLess canvas, then open the contract editor from that workflow’s node.'); return; }
|
|
662
|
-
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID));
|
|
675
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS);
|
|
663
676
|
if (!res.ok) {
|
|
664
677
|
if (res.status === 404) {
|
|
665
678
|
showEmpty('⊞', 'No takeoff for', 'Read a structural drawing first — ask your terminal AI to run the workflow, then open this editor again.', APP_ID);
|
|
@@ -920,7 +933,7 @@ function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),
|
|
|
920
933
|
// fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
|
|
921
934
|
async function persistServer(){try{
|
|
922
935
|
lastLocalPut = Date.now();
|
|
923
|
-
const res=await fetch('/api/contract/'+encodeURIComponent(APP_ID),{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(C)});
|
|
936
|
+
const res=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+PROJECT_QS,{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify(C)});
|
|
924
937
|
setSaved(res.ok?'ok':'err');
|
|
925
938
|
if(!res.ok)console.error('server save rejected ('+res.status+')',await res.text().catch(()=>'')); // 400 = schema reject: edits won't be what Approve bakes
|
|
926
939
|
}catch(e){setSaved('err');console.error('server save failed',e);}}
|
|
@@ -932,7 +945,7 @@ function scheduleSave(){setSaved('dirty');clearTimeout(saveT);saveT=setTimeout((
|
|
|
932
945
|
window.flushContract = async () => {
|
|
933
946
|
clearTimeout(saveT);
|
|
934
947
|
lastLocalPut = Date.now();
|
|
935
|
-
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), {
|
|
948
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS, {
|
|
936
949
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C),
|
|
937
950
|
});
|
|
938
951
|
setSaved(res.ok ? 'ok' : 'err');
|
|
@@ -1377,6 +1390,7 @@ function render(){
|
|
|
1377
1390
|
if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
|
|
1378
1391
|
svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle();
|
|
1379
1392
|
if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
|
|
1393
|
+
try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
|
|
1380
1394
|
syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
|
|
1381
1395
|
}
|
|
1382
1396
|
function updDup(){const n=redundantDups().length;
|
|
@@ -1417,6 +1431,54 @@ function stats(){
|
|
|
1417
1431
|
// "Varies" placeholders + the indeterminate "default" checkbox in the multi-edit panel. get() must return a primitive.
|
|
1418
1432
|
const VARIES=Symbol('varies');
|
|
1419
1433
|
function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
|
|
1434
|
+
// ── Connection Components (Slice A). Derive the current connection-selection state from selIds + the
|
|
1435
|
+
// resolved scene parts (partsById carries each part's `conn` tag). Returns {conn,kind,main,joint,childIds,
|
|
1436
|
+
// whole,mode} or null when the selection isn't one connection's parts. `whole` = every selectable part of
|
|
1437
|
+
// the connection is selected (copes are subtractive → not rendered/selectable, so excluded). Robust: no
|
|
1438
|
+
// dependence on cross-view callback timing — every render() re-derives it.
|
|
1439
|
+
function connSelInfo(){
|
|
1440
|
+
const ids=[...selIds]; if(!ids.length) return null;
|
|
1441
|
+
let conn=null;
|
|
1442
|
+
for(const id of ids){ const el=(partsById||{})[id]; const c=el&&el.conn; if(!c) return null; if(conn==null) conn=c; else if(conn!==c) return null; }
|
|
1443
|
+
if(!conn) return null;
|
|
1444
|
+
const j=(C.joints||[]).find(x=>x&&x.id===conn); if(!j) return null;
|
|
1445
|
+
const childIds=Object.keys(partsById||{}).filter(id=>{const el=partsById[id];return el&&el.conn===conn&&el.kind!=='cut';});
|
|
1446
|
+
const whole=childIds.length>0&&childIds.every(id=>selIds.has(id));
|
|
1447
|
+
return {conn,kind:j.kind,main:j.main,joint:j,childIds,whole,mode:whole?'whole':'part'};
|
|
1448
|
+
}
|
|
1449
|
+
// The floating breadcrumb over the 3D canvas: Model ▸ <Connection> [▸ <Part>]. Segments jump levels via the
|
|
1450
|
+
// 3D view's own ascend/whole-select so the canvas selection + envelope stay in lockstep. 3D-only; hidden at root.
|
|
1451
|
+
function updateConnCrumb(){
|
|
1452
|
+
const el=document.getElementById('connCrumb'); if(!el) return;
|
|
1453
|
+
const cs=view3d?connSelInfo():null;
|
|
1454
|
+
if(!cs){ el.style.display='none'; el.innerHTML=''; return; }
|
|
1455
|
+
const name=(cs.kind==='base-plate'?'Base plate':cs.kind==='shear-plate'?'Shear plate':'Connection')+' · '+cs.main;
|
|
1456
|
+
let html='<button class=seg data-lvl=root data-tip="Back to the model (deselect)">Model</button><span class=sep>▸</span>';
|
|
1457
|
+
if(cs.whole){ html+='<span class="seg cur">'+esc(name)+'</span>'; }
|
|
1458
|
+
else{
|
|
1459
|
+
html+='<button class=seg data-lvl=whole data-tip="Select the whole connection">'+esc(name)+'</button><span class=sep>▸</span>';
|
|
1460
|
+
const partId=[...selIds].find(id=>/:bolt\d+$/.test(id))||[...selIds][0];
|
|
1461
|
+
const pel=(partsById||{})[partId]; const plbl=(pel&&pel.meta&&pel.meta.label)||'Part';
|
|
1462
|
+
html+='<span class="seg cur">'+esc(plbl)+'</span>';
|
|
1463
|
+
}
|
|
1464
|
+
el.innerHTML=html; el.style.display='flex';
|
|
1465
|
+
{const b=el.querySelector('[data-lvl=root]'); if(b)b.onclick=()=>{ if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel(); else{selIds=new Set();render();} };}
|
|
1466
|
+
{const b=el.querySelector('[data-lvl=whole]'); if(b)b.onclick=()=>{ if(window.Steel3DView&&window.Steel3DView.selectWholeConn)window.Steel3DView.selectWholeConn(cs.conn); };}
|
|
1467
|
+
}
|
|
1468
|
+
// Route a "modify this connection" ask through the Request relay (intent+target). A recipe connection's
|
|
1469
|
+
// geometry is member-derived, so move/replace/adjust go to the terminal AI (the UI relays intent) — unlike
|
|
1470
|
+
// Delete, which is a direct, deterministic contract edit.
|
|
1471
|
+
async function connModifyRequest(j){
|
|
1472
|
+
if(!j) return;
|
|
1473
|
+
try{await window.flushContract();}catch(_){}
|
|
1474
|
+
try{persist();}catch(_){}
|
|
1475
|
+
const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':'connection';
|
|
1476
|
+
const instruction='Modify the '+kindName+' connection "'+j.id+'" on member '+j.main+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
|
|
1477
|
+
try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
|
|
1478
|
+
body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main]}})});
|
|
1479
|
+
toast(res.ok?'Change queued for your terminal AI session':'Could not queue the request');
|
|
1480
|
+
}catch(_){toast('Could not queue the request');}
|
|
1481
|
+
}
|
|
1420
1482
|
function panel(){
|
|
1421
1483
|
const p=document.getElementById('panel');
|
|
1422
1484
|
if(!selDimIds.size||!dimsVisible)dimSplitMode=false;document.body.classList.toggle('dimsplit',dimSplitMode); // split mode is meaningless without a (visible) dim selected — also disarms when dims are hidden
|
|
@@ -1499,6 +1561,38 @@ function panel(){
|
|
|
1499
1561
|
{const rm=document.getElementById('detRemove');if(rm)rm.onclick=()=>edit(()=>{C.detail_placements=(C.detail_placements||[]).filter(x=>x&&x.id!==detId);selIds.clear();});}
|
|
1500
1562
|
return;
|
|
1501
1563
|
}}
|
|
1564
|
+
// A WHOLE connection selected (Slice A) — the Component inspector: type badge + editability chip +
|
|
1565
|
+
// on-member link + part count + a read-only param summary, then Delete (direct contract edit) /
|
|
1566
|
+
// Modify (relay) / Edit-on-member. Precedes the single-part branch below (which handles the DRILLED case).
|
|
1567
|
+
{const cs=connSelInfo();
|
|
1568
|
+
if(cs&&cs.whole){
|
|
1569
|
+
const j=cs.joint,isBP=j.kind==='base-plate',pp=j.params||{};
|
|
1570
|
+
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
1571
|
+
const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
|
|
1572
|
+
const kv=(l,val)=>`<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;margin:3px 0"><span style="color:var(--mut)">${esc(l)}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
|
|
1573
|
+
const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${esc(t)}</span><hr></div>`;
|
|
1574
|
+
const sz=plate?dim(plate.width)+' × '+dim(plate.depth):'<span style="color:var(--mut)">auto</span>';
|
|
1575
|
+
let body='';
|
|
1576
|
+
if(isBP)body=sec('Plate')+kv('Size',sz)+kv('Thickness',plate?dim(plate.thickness):dim(pp.thickness))+sec('Anchors')+kv('Grid (cols × rows)',esc(`${pp.boltCols||2} × ${pp.boltRows||2}`))+kv('Diameter',pp.boltDia?dim(pp.boltDia):dim(24));
|
|
1577
|
+
else body=sec('Plate')+kv('Size',sz)+kv('Thickness',plate?dim(plate.thickness):dim(pp.plateThickness))+sec('Bolts')+kv('Grid (cols × rows)',esc(`${pp.boltCols||1} × ${pp.boltRows||3}`))+kv('Diameter',pp.boltDia?dim(pp.boltDia):dim(20))+sec('Weld')+kv('Leg',pp.weldLeg?dim(pp.weldLeg):dim(6));
|
|
1578
|
+
p.innerHTML=`<span class=badge>${isBP?'Base plate':'Shear plate'}</span>
|
|
1579
|
+
<div class=row style="margin:0 0 6px"><span class=chip style="border-color:var(--brand);color:#bfdbfe">Parametric — editable</span></div>
|
|
1580
|
+
<div class="row hint" style="margin:0 0 2px">On <button class=pilllink id=cmpMember data-tip="Select ${esc(j.main)}">${esc(j.main)}</button> · ${cs.childIds.length} parts</div>
|
|
1581
|
+
<div class="row hint" style="margin:0 0 6px;font-size:11px">Double-click to enter and pick a part · <b>Esc</b> steps back.</div>
|
|
1582
|
+
${body}
|
|
1583
|
+
<div class=divrow><hr></div>
|
|
1584
|
+
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1585
|
+
<button class=ghostw id=cmpEdit data-tip="Edit this connection's parameters on ${esc(j.main)}">✎ Edit parameters on ${esc(j.main)} →</button>
|
|
1586
|
+
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
|
|
1587
|
+
<button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
|
|
1588
|
+
</div>`;
|
|
1589
|
+
const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1590
|
+
{const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
|
|
1591
|
+
{const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
|
|
1592
|
+
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1593
|
+
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1594
|
+
return;
|
|
1595
|
+
}}
|
|
1502
1596
|
// A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
|
|
1503
1597
|
// read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
|
|
1504
1598
|
{const selList=[...selIds];
|
|
@@ -1523,7 +1617,7 @@ function panel(){
|
|
|
1523
1617
|
const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${t}</span><hr></div>`;
|
|
1524
1618
|
let body='';
|
|
1525
1619
|
if(pk==='plate'&&j.kind==='shear-plate')body=sec('Plate')+kv('Width',dim(el&&el.width))+kv('Height',dim(el&&el.depth))+kv('Thickness',dim(el&&el.thickness))+kv('Weld leg',v('weldLeg','mm'))+kv('Clearance',v('clearance','mm'));
|
|
1526
|
-
else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)'
|
|
1620
|
+
else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)',esc(`${pp.boltCols||1} × ${pp.boltRows||3}`))+kv('Diameter',v('boltDia','mm'))+kv('Grade',pp.boltGrade?esc(pp.boltGrade):'A325'+dft)+kv('Pitch',v('boltPitch','mm'))+kv('Length','<span style="color:var(--mut)">auto (from grip)</span>');
|
|
1527
1621
|
else if(pk==='weld')body=sec('Weld')+kv('Leg',v('weldLeg','mm'));
|
|
1528
1622
|
else if(pk==='cope')body=sec('Cope')+kv('Length',dim(el&&el.width))+kv('Depth',dim(el&&el.depth))+kv('Re-entrant radius',v('copeRadius','mm'));
|
|
1529
1623
|
else if(pk==='stiff')body=sec('Stiffener')+`<div class=hint style="margin:0">Opposite-side web stiffener on the support.</div>`;
|
|
@@ -1538,8 +1632,9 @@ function panel(){
|
|
|
1538
1632
|
${lbl?`<div class="row" style="margin:3px 0 0;font-size:12px;color:var(--brand);font-variant-numeric:tabular-nums">${esc(lbl)}</div>`:''}
|
|
1539
1633
|
${body}
|
|
1540
1634
|
<div class=divrow><hr></div>
|
|
1541
|
-
<div class="row f"><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
|
|
1542
|
-
const
|
|
1635
|
+
<div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=partBack data-tip="Back to the whole connection (Esc)">◂ Connection</button><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
|
|
1636
|
+
{const bb=document.getElementById('partBack');if(bb)bb.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.ascendConn)window.Steel3DView.ascendConn();};}
|
|
1637
|
+
const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1543
1638
|
return;
|
|
1544
1639
|
}}
|
|
1545
1640
|
const arr=selArr();
|
|
@@ -2763,7 +2858,7 @@ async function detailRequest(intent,place,note){
|
|
|
2763
2858
|
: 'Update the placed "'+place.detailName+'" detail (id '+place.id+') on sheet '+(place.sheet||'?')+' to match the attached image / my adjustments.');
|
|
2764
2859
|
const ids=[intent==='create'?(place.anchorId||('det:'+place.id)):('det:'+place.id)];
|
|
2765
2860
|
try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
|
|
2766
|
-
body:JSON.stringify({appId:APP_ID,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
|
|
2861
|
+
body:JSON.stringify({appId:APP_ID,project:PROJECT||undefined,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
|
|
2767
2862
|
toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
|
|
2768
2863
|
}catch(_){toast('Could not queue the request');}}
|
|
2769
2864
|
// Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
|
|
@@ -3886,6 +3981,7 @@ document.getElementById('askAiSend').onclick = async () => {
|
|
|
3886
3981
|
headers: { 'content-type': 'application/json' },
|
|
3887
3982
|
body: JSON.stringify({
|
|
3888
3983
|
appId: APP_ID,
|
|
3984
|
+
project: PROJECT || undefined,
|
|
3889
3985
|
instruction,
|
|
3890
3986
|
snapshots: askAiSnapshots.map(s => ({ name: s.name, dataUrl: s.dataUrl })),
|
|
3891
3987
|
}),
|
|
@@ -128,6 +128,9 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
128
128
|
import { matchesActive, modeDims, canonicalMode, selFromFilter, eyedropperAdd, countShown, bgRect, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
|
|
129
129
|
|
|
130
130
|
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
131
|
+
// Workspaces: project-keyed contract (see steel-editor.html). Empty = legacy app-keyed URLs.
|
|
132
|
+
const PROJECT = new URLSearchParams(location.search).get('project') || '';
|
|
133
|
+
const PROJECT_QS = PROJECT ? ('?project=' + encodeURIComponent(PROJECT)) : '';
|
|
131
134
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
132
135
|
const PALETTE = ['#3b82f6','#22d3ee','#a78bfa','#f59e0b','#34d399','#f472b6','#60a5fa','#facc15'];
|
|
133
136
|
|
|
@@ -160,7 +163,7 @@ function markDirty(){ dirty = true; saveBtn.disabled = false; setSaved('dirty');
|
|
|
160
163
|
async function boot(){
|
|
161
164
|
if(!APP_ID){ showEmpty('No app specified.'); return; }
|
|
162
165
|
let res;
|
|
163
|
-
try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID)); }
|
|
166
|
+
try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS); }
|
|
164
167
|
catch(e){ showEmpty('Could not reach the server.'); return; }
|
|
165
168
|
if(!res.ok){ showEmpty(); return; }
|
|
166
169
|
C = await res.json();
|
|
@@ -410,7 +413,7 @@ async function save(){
|
|
|
410
413
|
C.filter = F;
|
|
411
414
|
saveBtn.disabled = true; setSaved('');
|
|
412
415
|
try {
|
|
413
|
-
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
|
|
416
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID) + PROJECT_QS, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
|
|
414
417
|
if(!res.ok) throw new Error(await res.text());
|
|
415
418
|
dirty = false; setSaved('saved');
|
|
416
419
|
} catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }
|