@floless/app 0.82.0 → 0.83.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 +96 -2
- package/dist/web/steel-3d-view.js +1 -1
- package/dist/web/steel-editor.html +302 -7
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53093,7 +53093,7 @@ function appVersion() {
|
|
|
53093
53093
|
return resolveVersion({
|
|
53094
53094
|
isSea: isSea2(),
|
|
53095
53095
|
sqVersionXml: readSqVersionXml(),
|
|
53096
|
-
define: true ? "0.
|
|
53096
|
+
define: true ? "0.83.0" : void 0,
|
|
53097
53097
|
pkgVersion: readPkgVersion()
|
|
53098
53098
|
});
|
|
53099
53099
|
}
|
|
@@ -53103,7 +53103,7 @@ function resolveChannel(s) {
|
|
|
53103
53103
|
return "dev";
|
|
53104
53104
|
}
|
|
53105
53105
|
function appChannel() {
|
|
53106
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
53106
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.83.0" : void 0 });
|
|
53107
53107
|
}
|
|
53108
53108
|
|
|
53109
53109
|
// workflow-update.ts
|
|
@@ -53652,6 +53652,7 @@ var import_node_path14 = require("node:path");
|
|
|
53652
53652
|
var ROOT2 = process.env.FLOLESS_HOME ?? (0, import_node_path14.join)((0, import_node_os11.homedir)(), ".floless");
|
|
53653
53653
|
var TEMPLATES_FILE = (0, import_node_path14.join)(ROOT2, "templates.json");
|
|
53654
53654
|
var REQUESTS_DIR = (0, import_node_path14.join)(ROOT2, "requests");
|
|
53655
|
+
var STEEL_FAVS_FILE = (0, import_node_path14.join)(ROOT2, "steel-connections.json");
|
|
53655
53656
|
function ensureRoot() {
|
|
53656
53657
|
if (!(0, import_node_fs16.existsSync)(ROOT2)) (0, import_node_fs16.mkdirSync)(ROOT2, { recursive: true });
|
|
53657
53658
|
}
|
|
@@ -53716,6 +53717,56 @@ function updateTemplate(id, patch2) {
|
|
|
53716
53717
|
writeTemplates(list);
|
|
53717
53718
|
return cur;
|
|
53718
53719
|
}
|
|
53720
|
+
function listSteelFavourites() {
|
|
53721
|
+
if (!(0, import_node_fs16.existsSync)(STEEL_FAVS_FILE)) return [];
|
|
53722
|
+
try {
|
|
53723
|
+
const parsed = JSON.parse((0, import_node_fs16.readFileSync)(STEEL_FAVS_FILE, "utf8"));
|
|
53724
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
53725
|
+
} catch {
|
|
53726
|
+
return [];
|
|
53727
|
+
}
|
|
53728
|
+
}
|
|
53729
|
+
function writeSteelFavourites(list) {
|
|
53730
|
+
ensureRoot();
|
|
53731
|
+
const tmp = `${STEEL_FAVS_FILE}.${process.pid}.tmp`;
|
|
53732
|
+
(0, import_node_fs16.writeFileSync)(tmp, JSON.stringify(list, null, 2));
|
|
53733
|
+
(0, import_node_fs16.renameSync)(tmp, STEEL_FAVS_FILE);
|
|
53734
|
+
}
|
|
53735
|
+
function addSteelFavourite(input) {
|
|
53736
|
+
const list = listSteelFavourites();
|
|
53737
|
+
const fav = {
|
|
53738
|
+
id: (0, import_node_crypto5.randomUUID)(),
|
|
53739
|
+
name: input.name.trim(),
|
|
53740
|
+
category: (input.category || "Uncategorized").trim(),
|
|
53741
|
+
kind: input.kind,
|
|
53742
|
+
...input.params ? { params: input.params } : {},
|
|
53743
|
+
...input.geometry ? { geometry: input.geometry } : {},
|
|
53744
|
+
schemaVersion: typeof input.schemaVersion === "number" ? input.schemaVersion : 1,
|
|
53745
|
+
...input.emittedBy ? { emittedBy: input.emittedBy } : {},
|
|
53746
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
53747
|
+
};
|
|
53748
|
+
list.push(fav);
|
|
53749
|
+
writeSteelFavourites(list);
|
|
53750
|
+
return fav;
|
|
53751
|
+
}
|
|
53752
|
+
function deleteSteelFavourite(id) {
|
|
53753
|
+
const list = listSteelFavourites();
|
|
53754
|
+
const next = list.filter((f) => f.id !== id);
|
|
53755
|
+
if (next.length === list.length) return false;
|
|
53756
|
+
writeSteelFavourites(next);
|
|
53757
|
+
return true;
|
|
53758
|
+
}
|
|
53759
|
+
function updateSteelFavourite(id, patch2) {
|
|
53760
|
+
const list = listSteelFavourites();
|
|
53761
|
+
const cur = list.find((f) => f.id === id);
|
|
53762
|
+
if (!cur) return null;
|
|
53763
|
+
const name = patch2.name?.trim();
|
|
53764
|
+
const category = patch2.category?.trim();
|
|
53765
|
+
cur.name = name || cur.name;
|
|
53766
|
+
cur.category = category || cur.category;
|
|
53767
|
+
writeSteelFavourites(list);
|
|
53768
|
+
return cur;
|
|
53769
|
+
}
|
|
53719
53770
|
function addRequest(req, decoded = []) {
|
|
53720
53771
|
ensureRoot();
|
|
53721
53772
|
if (!(0, import_node_fs16.existsSync)(REQUESTS_DIR)) (0, import_node_fs16.mkdirSync)(REQUESTS_DIR, { recursive: true });
|
|
@@ -65992,6 +66043,49 @@ async function startServer() {
|
|
|
65992
66043
|
broadcast({ type: "templates-changed" });
|
|
65993
66044
|
return { ok: true };
|
|
65994
66045
|
});
|
|
66046
|
+
app.get("/api/steel-favourites", async () => ({ ok: true, favourites: listSteelFavourites() }));
|
|
66047
|
+
app.post("/api/steel-favourites", async (req, reply) => {
|
|
66048
|
+
const { name, category, kind, params, geometry, schemaVersion, emittedBy } = req.body ?? {};
|
|
66049
|
+
if (!name || !name.trim()) return reply.status(400).send({ ok: false, error: "name required" });
|
|
66050
|
+
if (kind !== "base-plate" && kind !== "shear-plate" && kind !== "custom") {
|
|
66051
|
+
return reply.status(400).send({ ok: false, error: "valid kind required" });
|
|
66052
|
+
}
|
|
66053
|
+
if (kind === "custom" ? !Array.isArray(geometry) || geometry.length === 0 : params == null || typeof params !== "object") {
|
|
66054
|
+
return reply.status(400).send({
|
|
66055
|
+
ok: false,
|
|
66056
|
+
error: kind === "custom" ? "geometry required for a custom favourite" : "params required for a recipe favourite"
|
|
66057
|
+
});
|
|
66058
|
+
}
|
|
66059
|
+
const favourite = addSteelFavourite({
|
|
66060
|
+
name,
|
|
66061
|
+
category,
|
|
66062
|
+
kind,
|
|
66063
|
+
params,
|
|
66064
|
+
geometry,
|
|
66065
|
+
schemaVersion,
|
|
66066
|
+
emittedBy
|
|
66067
|
+
});
|
|
66068
|
+
broadcast({ type: "steel-favourites-changed" });
|
|
66069
|
+
return { ok: true, favourite };
|
|
66070
|
+
});
|
|
66071
|
+
app.patch(
|
|
66072
|
+
"/api/steel-favourites/:id",
|
|
66073
|
+
async (req, reply) => {
|
|
66074
|
+
const { name, category } = req.body ?? {};
|
|
66075
|
+
if ((name == null || !name.trim()) && (category == null || !category.trim())) {
|
|
66076
|
+
return reply.status(400).send({ ok: false, error: "name or category required" });
|
|
66077
|
+
}
|
|
66078
|
+
const favourite = updateSteelFavourite(req.params.id, { name, category });
|
|
66079
|
+
if (!favourite) return reply.status(404).send({ ok: false, error: "favourite not found" });
|
|
66080
|
+
broadcast({ type: "steel-favourites-changed" });
|
|
66081
|
+
return { ok: true, favourite };
|
|
66082
|
+
}
|
|
66083
|
+
);
|
|
66084
|
+
app.delete("/api/steel-favourites/:id", async (req, reply) => {
|
|
66085
|
+
if (!deleteSteelFavourite(req.params.id)) return reply.status(404).send({ ok: false, error: "favourite not found" });
|
|
66086
|
+
broadcast({ type: "steel-favourites-changed" });
|
|
66087
|
+
return { ok: true };
|
|
66088
|
+
});
|
|
65995
66089
|
app.get("/api/requests", async () => ({ ok: true, requests: listRequests() }));
|
|
65996
66090
|
app.get("/api/requests/:id/snapshot/:n", async (req, reply) => {
|
|
65997
66091
|
const n = Number.parseInt(req.params.n, 10);
|
|
@@ -2490,7 +2490,7 @@ function onDown(e) {
|
|
|
2490
2490
|
if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
|
|
2491
2491
|
if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
|
|
2492
2492
|
if (basePickCol) { e.stopPropagation(); const r = basePickPoint(e.clientX, e.clientY); if (r) { if (api && api.onBasePick) api.onBasePick(r); setBasePickMode(null); } return; } // Mode B: commit + disarm only on a valid on-column pick; an off-column click is ignored (stays armed) — Esc cancels
|
|
2493
|
-
if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); setInsertMode(false); return; } // armed: place the
|
|
2493
|
+
if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); const sticky = insertPending && insertPending.sticky; if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); if (!sticky) setInsertMode(false); return; } // armed: place at the pick. One-shot for a detail / IFC import; a sticky pending (a favourite) STAYS armed for repeat placement — Esc/onInsertPlace's own cancel disarms.
|
|
2494
2494
|
if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
|
|
2495
2495
|
if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
|
|
2496
2496
|
if (selectedClipIds.size) { let ch = null; try { ch = pickClipHandle(e.clientX, e.clientY); } catch { ch = null; } if (ch) { e.stopPropagation(); controls.enabled = false; downXY = [e.clientX, e.clientY]; startClipDrag(ch, e); return; } } // grab a clip handle (plane dot / box face) → drag it
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
g.cohot:hover circle.cobub{opacity:1;stroke-width:2;filter:drop-shadow(0 0 4px currentColor)}
|
|
95
95
|
text.cotx{fill:#e2e8f0;font:bold 11px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none;opacity:.6}
|
|
96
96
|
g.cohot:hover text.cotx{opacity:1}
|
|
97
|
-
#detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal,#connImportModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
|
|
97
|
+
#detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal,#connImportModal,#favConnModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
|
|
98
98
|
#askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
|
|
99
99
|
.aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
|
|
100
100
|
.aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
|
|
@@ -419,8 +419,47 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
419
419
|
#m3dLegend .m3dbody.on{display:flex;flex:1 1 auto} /* the active body fills the capped panel and scrolls (min-height:0 lets it shrink below content) */
|
|
420
420
|
#m3dLegendBody{padding:8px 10px}
|
|
421
421
|
#m3dViewsBody,#m3dFavBody{padding:8px 10px}
|
|
422
|
-
/* Favourites
|
|
423
|
-
|
|
422
|
+
/* ── Favourites tab (#m3dFavBody) — the Views-tab row/search recipe. No create button (★ lives in the
|
|
423
|
+
Inspector); a kind-filter + search header, per-row kind swatch + portability badge + row-click-to-arm. ── */
|
|
424
|
+
#favHeadRow{display:flex;align-items:stretch;gap:6px;margin-bottom:6px;flex:none}
|
|
425
|
+
#favKindFilter{flex:1;min-width:0;height:28px}
|
|
426
|
+
#favKindFilter button{flex:1;background:transparent;color:var(--mut);font-size:10px;padding:0 2px;box-shadow:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
427
|
+
#favKindFilter button.on{background:var(--brand);color:#fff}
|
|
428
|
+
#favSearchTog{flex:none;width:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;color:var(--mut);border:1px solid var(--line);border-radius:6px;background:transparent;cursor:pointer}
|
|
429
|
+
#favSearchTog.on{color:var(--brand);border-color:var(--brand)}
|
|
430
|
+
#favSearchTog svg{display:block}
|
|
431
|
+
#favSearch{display:none;align-items:center;gap:6px;height:26px;margin-bottom:6px;padding:0 8px;background:var(--bg);border:1px solid var(--line);border-radius:6px;flex:none}
|
|
432
|
+
#favSearch.show{display:flex}
|
|
433
|
+
#favSearch:focus-within{border-color:var(--brand)}
|
|
434
|
+
#favSearch .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
|
|
435
|
+
#favSearch input{flex:1;min-width:0;width:auto;height:auto;background:transparent;border:0;outline:none;color:var(--text);font:12px system-ui;padding:0}
|
|
436
|
+
#favSearch input::placeholder{color:var(--mut)}
|
|
437
|
+
#favSearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden}
|
|
438
|
+
#favSearch.has .lsx{visibility:visible}
|
|
439
|
+
#favSearch .lsx:hover{color:#fecaca;background:#7f1d1d}
|
|
440
|
+
#m3dFavBody .favrow{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:5px;cursor:pointer;user-select:none;white-space:nowrap}
|
|
441
|
+
#m3dFavBody .favrow:hover{background:#33415580}
|
|
442
|
+
#m3dFavBody .favrow:focus-visible{outline:1px solid var(--brand);outline-offset:-1px}
|
|
443
|
+
#m3dFavBody .favrow.armed{box-shadow:inset 2px 0 0 var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
|
|
444
|
+
#m3dFavBody .favsw{flex:none;width:11px;height:11px;border-radius:2px;background:var(--sw,#8a97a8);border:1px solid rgba(0,0,0,.35)}
|
|
445
|
+
#m3dFavBody .favname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text)}
|
|
446
|
+
#m3dFavBody .favbadge{flex:none;font-size:10px;line-height:1.4;padding:0 5px;border-radius:4px;border:1px solid var(--line);color:var(--mut)}
|
|
447
|
+
#m3dFavBody .favbadge.portable{border-color:var(--brand);color:#bfdbfe}
|
|
448
|
+
#m3dFavBody .favrow input.favedit{flex:1;min-width:0;font:12px system-ui;background:#0b1220;color:var(--text);border:1px solid var(--brand);border-radius:4px;padding:0 4px;outline:none}
|
|
449
|
+
#m3dFavBody .favrow .vx{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;visibility:hidden;font-size:13px;line-height:1}
|
|
450
|
+
#m3dFavBody .favrow:hover .vx,#m3dFavBody .favrow:focus-within .vx{visibility:visible}
|
|
451
|
+
#m3dFavBody .favrow .vx:hover{color:#fecaca;background:#7f1d1d}
|
|
452
|
+
#m3dFavBody .favrow .vdots{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;font-size:14px;line-height:1;cursor:pointer}
|
|
453
|
+
#m3dFavBody .favrow .vdots:hover{color:var(--text);background:#334155}
|
|
454
|
+
#m3dFavBody .favempty{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
|
|
455
|
+
#m3dFavBody .favnores{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
|
|
456
|
+
#m3dFavBody .favnores .pilllink{margin-left:4px}
|
|
457
|
+
#favRowMenu{position:fixed;left:0;top:0;z-index:45;min-width:184px}
|
|
458
|
+
.ghostw.faved{color:var(--brand);border-color:var(--brand)} /* the ★ Save button once this recipe is in the library */
|
|
459
|
+
/* #favConnModal — a small sibling modal (askAiModal sizing); Kind is a read-only chip row, not an input. */
|
|
460
|
+
#favConnModal .mpanel{width:min(460px,92vw)}
|
|
461
|
+
#favConnModal .favkindfield{display:flex;align-items:center;gap:8px;background:#0f172a;border:1px solid #475569;border-radius:6px;padding:6px 8px;min-height:34px}
|
|
462
|
+
#favConnModal .favkindfield .chip{margin:0}
|
|
424
463
|
#m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px;max-width:230px;white-space:normal} /* wrap-guard: the hint never drives the panel wider than the rows, so it can't clip on any font */
|
|
425
464
|
#m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:default;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap} /* only the left-hand box toggles now (it sets cursor:pointer); the row still isolates on dbl-click + right-clicks for the menu */
|
|
426
465
|
#m3dLegend .lrow:hover{background:#33415580}
|
|
@@ -716,7 +755,7 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
716
755
|
</div>
|
|
717
756
|
<div id=m3dLegendBody class="m3dbody on" role=tabpanel aria-label=Objects></div>
|
|
718
757
|
<div id=m3dViewsBody class=m3dbody role=tabpanel aria-label=Views></div>
|
|
719
|
-
<div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites
|
|
758
|
+
<div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites></div>
|
|
720
759
|
</div>
|
|
721
760
|
<div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
|
|
722
761
|
<div id=m3dAxes></div>
|
|
@@ -811,6 +850,24 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
811
850
|
</div>
|
|
812
851
|
</div>
|
|
813
852
|
</div>
|
|
853
|
+
<div id=favConnModal><div class=mbackdrop id=favBackdrop></div>
|
|
854
|
+
<div class=mpanel>
|
|
855
|
+
<div class=mhead><b id=favModalTitle>Save to Favourites</b><button id=favClose data-tip="Close">✕</button></div>
|
|
856
|
+
<div style="padding:14px;display:flex;flex-direction:column;gap:4px">
|
|
857
|
+
<label class=elab for=favName>Name</label>
|
|
858
|
+
<input id=favName placeholder="e.g. Standard 4-bolt base plate" autocomplete=off>
|
|
859
|
+
<div class=elab>Kind</div>
|
|
860
|
+
<div class=favkindfield role=group aria-label=Kind><span id=favKind class=chip></span></div>
|
|
861
|
+
<label class=elab for=favCat>Category</label>
|
|
862
|
+
<input id=favCat placeholder="Uncategorized" autocomplete=off list=favCatSuggest>
|
|
863
|
+
<datalist id=favCatSuggest></datalist>
|
|
864
|
+
<div id=favHint class=hint style="margin-top:6px"></div>
|
|
865
|
+
<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:6px">
|
|
866
|
+
<button id=favCancel class=ghost>Cancel</button>
|
|
867
|
+
<button id=favSave>Save to Favourites</button>
|
|
868
|
+
</div>
|
|
869
|
+
</div>
|
|
870
|
+
</div></div>
|
|
814
871
|
<div id=lightbox><div class=mbackdrop id=lbBackdrop></div>
|
|
815
872
|
<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>
|
|
816
873
|
<script>
|
|
@@ -1822,13 +1879,15 @@ function panel(){
|
|
|
1822
1879
|
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1823
1880
|
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to move / replace this connection">Modify connection…</button>
|
|
1824
1881
|
<button class=danger id=cmpDel data-tip="Remove this whole imported connection">Delete connection</button>
|
|
1882
|
+
<button class=ghostw id=cmpFav data-tip="Save this imported geometry to your Favourites — drops in place, will not re-fit">★ Save to Favourites</button>
|
|
1825
1883
|
</div>`;
|
|
1826
1884
|
{const b=document.getElementById('cmpMember');if(b)b.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};}
|
|
1827
1885
|
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1828
1886
|
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1887
|
+
{const b=document.getElementById('cmpFav');if(b)b.onclick=()=>openFavModalFor(j);}
|
|
1829
1888
|
return;
|
|
1830
1889
|
}
|
|
1831
|
-
const isBP=j.kind==='base-plate',pp=j.params||{};
|
|
1890
|
+
const isBP=j.kind==='base-plate',pp=j.params||{};const savedFav=favMatch(j);
|
|
1832
1891
|
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
1833
1892
|
const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
|
|
1834
1893
|
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>`;
|
|
@@ -1848,6 +1907,7 @@ function panel(){
|
|
|
1848
1907
|
${isBP?`<button class=ghostw id=cmpTrim data-tip="Pick a point on ${esc(j.main)} — trim or extend the column so its base seats there (snaps to framing levels)">⭶ Trim / Extend column to base…</button>`:''}
|
|
1849
1908
|
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
|
|
1850
1909
|
<button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
|
|
1910
|
+
<button class="ghostw${savedFav?' faved':''}" id=cmpFav data-tip="${savedFav?'Edit this favourite’s name or category':('Save this connection to your Favourites — reusable on any '+(isBP?'column':'beam'))}">${savedFav?'★ Saved to Favourites':'★ Save to Favourites'}</button>
|
|
1851
1911
|
</div>`;
|
|
1852
1912
|
const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1853
1913
|
{const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
|
|
@@ -1855,6 +1915,7 @@ function panel(){
|
|
|
1855
1915
|
{const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
|
|
1856
1916
|
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1857
1917
|
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1918
|
+
{const b=document.getElementById('cmpFav');if(b)b.onclick=()=>openFavModalFor(j);}
|
|
1858
1919
|
return;
|
|
1859
1920
|
}}
|
|
1860
1921
|
// A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
|
|
@@ -3044,6 +3105,7 @@ addEventListener('keydown',e=>{
|
|
|
3044
3105
|
if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
|
|
3045
3106
|
if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
|
|
3046
3107
|
if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
|
|
3108
|
+
if(e.key==='Escape'&&favModalIsOpen()){favModalClose();return;}
|
|
3047
3109
|
if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
|
|
3048
3110
|
if(e.key==='Escape'&&connLibOpen()){closeConnLib();return;}
|
|
3049
3111
|
if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
|
|
@@ -3222,7 +3284,7 @@ const view3dApi={
|
|
|
3222
3284
|
onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
|
|
3223
3285
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
3224
3286
|
onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
|
|
3225
|
-
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
|
|
3287
|
+
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';} if(!on)favDisarmed();}, // armed → cancel target; disarm also clears any armed favourite (the pinned bar + the .armed row)
|
|
3226
3288
|
onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
|
|
3227
3289
|
onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
|
|
3228
3290
|
const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
|
|
@@ -3241,6 +3303,39 @@ const view3dApi={
|
|
|
3241
3303
|
toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
|
|
3242
3304
|
},
|
|
3243
3305
|
onInsertPlace:(pick,pending)=>{
|
|
3306
|
+
if(pending&&pending.kind==='favourite'&&pending.favourite){const f=pending.favourite;
|
|
3307
|
+
// Slice F1: apply a saved favourite. base-plate → a column; shear-plate → the nearest beam end; custom → drop the
|
|
3308
|
+
// mesh at the pick. Wrong target → a corrective toast + no-op (the sticky arm stays on for a retry). Reuses the
|
|
3309
|
+
// v0.78 recipe-bake path (edit() + pendingConnSel select the new whole connection on the next rebuild).
|
|
3310
|
+
if(f.kind==='base-plate'){
|
|
3311
|
+
const col=pick.anchorId?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='column'):null;
|
|
3312
|
+
if(!col){toast('Click a column to place this base plate');return;}
|
|
3313
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3314
|
+
const joint={id,kind:'base-plate',main:col.id,at:'base',params:Object.assign({},f.params||{}),source:'user'};
|
|
3315
|
+
pendingConnSel=id;const had=(C.joints||[]).some(x=>x&&x.kind==='base-plate'&&x.main===col.id);
|
|
3316
|
+
edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='base-plate'&&x.main===col.id));C.joints.push(joint);selIds=new Set();});
|
|
3317
|
+
toast((had?'Base plate on '+col.id+' replaced with “'+f.name+'”':'“'+f.name+'” applied to '+col.id)+' — click another column, or Esc');return;
|
|
3318
|
+
}
|
|
3319
|
+
if(f.kind==='shear-plate'){
|
|
3320
|
+
const beam=pick.anchorId?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='beam'):null;
|
|
3321
|
+
if(!beam){toast('Click a beam end to place this shear plate');return;}
|
|
3322
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3323
|
+
const g=partsById[beam.id],d3=(a,b)=>a&&b?Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2]):Infinity;
|
|
3324
|
+
const endIdx=(g&&g.from&&g.to&&d3(pick.point,g.to)<d3(pick.point,g.from))?1:0;
|
|
3325
|
+
const joint={id,kind:'shear-plate',main:beam.id,at:'end'+endIdx,params:Object.assign({},f.params||{}),source:'user'};
|
|
3326
|
+
pendingConnSel=id;const had=(C.joints||[]).some(x=>x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx);
|
|
3327
|
+
edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx));C.joints.push(joint);selIds=new Set();});
|
|
3328
|
+
toast((had?'Shear plate on '+beam.id+' '+(endIdx?'end':'start')+' replaced with “'+f.name+'”':'“'+f.name+'” applied to '+beam.id+' '+(endIdx?'end':'start'))+' — click another beam end, or Esc');return;
|
|
3329
|
+
}
|
|
3330
|
+
if(f.kind==='custom'&&Array.isArray(f.geometry)&&f.geometry.length){
|
|
3331
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3332
|
+
const joint={id,kind:'custom',name:f.name||'Imported connection',place:pick.point,geometry:f.geometry,source:'user'};
|
|
3333
|
+
if(pick.anchorId)joint.main=pick.anchorId;
|
|
3334
|
+
edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(f.geometry.map((gg,i)=>id+':'+(gg.id||'m'+i)));});
|
|
3335
|
+
toast('“'+f.name+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — click to place another, or Esc');return;
|
|
3336
|
+
}
|
|
3337
|
+
toast('That favourite has no geometry to place');return;
|
|
3338
|
+
}
|
|
3244
3339
|
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3245
3340
|
const conn=pending.connection;const rc=conn.recipe;
|
|
3246
3341
|
// Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
|
|
@@ -3444,6 +3539,7 @@ function setLegendTab(tab){if(tab!=='objects'&&tab!=='views'&&tab!=='fav')tab='o
|
|
|
3444
3539
|
const bodies={objects:'m3dLegendBody',views:'m3dViewsBody',fav:'m3dFavBody'};
|
|
3445
3540
|
for(const [k,id] of Object.entries(bodies)){const el=document.getElementById(id);if(el)el.classList.toggle('on',k===tab);}
|
|
3446
3541
|
if(tab==='views')renderViewsTab(); // (re)render the Views list when it becomes visible
|
|
3542
|
+
if(tab==='fav')renderFavTab(); // (re)render the Favourites list when it becomes visible
|
|
3447
3543
|
updateViewsBtn();
|
|
3448
3544
|
}
|
|
3449
3545
|
// The 3D-toolbar Views button lights (.on) while the panel is open on the Views tab.
|
|
@@ -3653,6 +3749,199 @@ function undoToast(msg,onUndo){let t=document.getElementById('undoToast');
|
|
|
3653
3749
|
btn.addEventListener('click',()=>{clearTimeout(t._h);t.style.opacity='0';try{onUndo();}catch(e){console.error(e);}});
|
|
3654
3750
|
t.appendChild(btn);t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},5000);
|
|
3655
3751
|
}
|
|
3752
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
3753
|
+
// Favourite Connections (Slice F1) — a GLOBAL, cross-project library of saved connection recipes.
|
|
3754
|
+
// The ★ in the whole-connection Inspector saves here (#favConnModal → POST /api/steel-favourites); this tab
|
|
3755
|
+
// browses/searches/filters them, and applying one is arm→click→bake: click a favourite row → arm the placement
|
|
3756
|
+
// crosshair (sticky, so it repeats) → click a target → bake a JointRecipe into C.joints[] + re-detail. A
|
|
3757
|
+
// favourite is {kind:'base-plate'|'shear-plate', params} (parametric, re-fits any member) or {kind:'custom',
|
|
3758
|
+
// geometry} (imported mesh, drops in place). Mirrors the Views tab; no create button (creation is via ★).
|
|
3759
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
3760
|
+
let steelFavourites=[]; // loaded from /api/steel-favourites on 3D enter (global store → cross-project)
|
|
3761
|
+
let favQuery=''; // transient Favourites search filter — NOT persisted
|
|
3762
|
+
let favSearchOpen=false; // is the search input revealed?
|
|
3763
|
+
let favKindFilter=(()=>{try{const k=localStorage.getItem('floless.favKind');return (k==='portable'||k==='geometry')?k:'all';}catch{return 'all';}})();
|
|
3764
|
+
let armedFavId=null; // the favourite whose placement crosshair is armed (persistent .armed row) — transient
|
|
3765
|
+
const FAV_KIND_LABEL={'base-plate':'Base plate','shear-plate':'Shear plate','custom':'Custom (imported)'};
|
|
3766
|
+
const FAV_KIND_COLOR={'base-plate':'#6b7a8d','shear-plate':'#7d8ba0','custom':'#8a97a8'}; // mirror steel-joints.ts GROUPS
|
|
3767
|
+
const FAV_CAT_SUGGEST={'base-plate':['Base plates','Columns'],'shear-plate':['Shear plates','Beams'],'custom':['Imported','Details']};
|
|
3768
|
+
function favIsPortable(f){return !!f&&(f.kind==='base-plate'||f.kind==='shear-plate');}
|
|
3769
|
+
// The saved-recipe payload captured from a selected whole connection: recipe scalars, or an opaque custom mesh.
|
|
3770
|
+
function favRecipeOf(j){if(!j)return null;
|
|
3771
|
+
if(j.kind==='custom')return {kind:'custom',geometry:Array.isArray(j.geometry)?j.geometry:[],name:j.name||'Imported connection'};
|
|
3772
|
+
if(j.kind==='base-plate'||j.kind==='shear-plate')return {kind:j.kind,params:Object.assign({},j.params||{})};
|
|
3773
|
+
return null;}
|
|
3774
|
+
// A key-order-STABLE JSON of a params object, so the ★ Saved/Save match survives a disk round-trip (a reloaded
|
|
3775
|
+
// favourite's params can serialize its keys in a different order than the live joint's — which would flip the ★
|
|
3776
|
+
// back to "Save" and let a duplicate be saved). Recurses into nested objects; arrays keep their (significant) order.
|
|
3777
|
+
function stableStr(o){if(o&&typeof o==='object'&&!Array.isArray(o))return '{'+Object.keys(o).sort().map(k=>JSON.stringify(k)+':'+stableStr(o[k])).join(',')+'}';return JSON.stringify(o);}
|
|
3778
|
+
// A parametric favourite already saved with identical kind + params → the ★ opens it in edit mode.
|
|
3779
|
+
function favMatch(j){const r=favRecipeOf(j);if(!r||r.kind==='custom')return null;const key=stableStr(r.params||{});
|
|
3780
|
+
return steelFavourites.find(f=>f.kind===r.kind&&stableStr(f.params||{})===key)||null;}
|
|
3781
|
+
async function loadSteelFavourites(){try{const res=await fetch('/api/steel-favourites');const d=await res.json();steelFavourites=(d&&d.ok&&Array.isArray(d.favourites))?d.favourites:[];}catch(_){steelFavourites=[];}
|
|
3782
|
+
if(legendTab==='fav')renderFavTab();}
|
|
3783
|
+
|
|
3784
|
+
// ── The ★ Save/edit modal (#favConnModal) ──────────────────────────────────────────────────────────────────
|
|
3785
|
+
let favModalJoint=null; // the selected joint being saved (create mode)
|
|
3786
|
+
let favModalEditId=null; // an existing favourite id (edit mode — rename/recategorize only; recipe is immutable)
|
|
3787
|
+
function favSuggestName(j){if(!j)return '';
|
|
3788
|
+
if(j.kind==='custom')return j.name||'Imported connection';
|
|
3789
|
+
const prof=((P.members||[]).find(m=>m&&m.id===j.main)||{}).profile||'';
|
|
3790
|
+
return (j.kind==='base-plate'?'Base plate':'Shear plate')+(prof?' — '+prof:'');}
|
|
3791
|
+
function favFillModal({title,kind,name,category,portable,hint}){
|
|
3792
|
+
document.getElementById('favModalTitle').textContent=title;
|
|
3793
|
+
const kf=document.getElementById('favKind');kf.className='chip';kf.textContent=FAV_KIND_LABEL[kind]||kind;
|
|
3794
|
+
kf.style.borderColor=portable?'var(--brand)':'var(--line)';kf.style.color=portable?'#bfdbfe':'var(--mut)';
|
|
3795
|
+
document.getElementById('favName').value=name||'';
|
|
3796
|
+
document.getElementById('favCat').value=category||'';
|
|
3797
|
+
const dl=document.getElementById('favCatSuggest');dl.replaceChildren();for(const s of (FAV_CAT_SUGGEST[kind]||[]))dl.appendChild(Object.assign(document.createElement('option'),{value:s}));
|
|
3798
|
+
document.getElementById('favHint').textContent=hint;
|
|
3799
|
+
document.getElementById('favSave').textContent=favModalEditId?'Save changes':'Save to Favourites';
|
|
3800
|
+
document.getElementById('favConnModal').style.display='flex';
|
|
3801
|
+
const ni=document.getElementById('favName');ni.focus();ni.select();}
|
|
3802
|
+
// From the Inspector ★ — create (or, if this exact recipe is already saved, edit its name/category).
|
|
3803
|
+
function openFavModalFor(j){if(!j){toast('Select a connection first');return;}
|
|
3804
|
+
const existing=favMatch(j);favModalJoint=j;favModalEditId=existing?existing.id:null;
|
|
3805
|
+
const portable=j.kind!=='custom';
|
|
3806
|
+
const hint=j.kind==='custom'?'Imported geometry — drops in place at the clicked point; won’t parametrically re-fit.'
|
|
3807
|
+
:'Parametric — re-fits any column or beam you apply it to.';
|
|
3808
|
+
favFillModal({title:existing?'Edit favourite':'Save to Favourites',kind:j.kind,name:existing?existing.name:favSuggestName(j),category:existing?existing.category:'',portable,hint});}
|
|
3809
|
+
// From a Favourites row ⋯ → rename/recategorize an existing favourite (edit mode; no joint needed).
|
|
3810
|
+
function openFavModalEdit(f){if(!f)return;favModalJoint=null;favModalEditId=f.id;
|
|
3811
|
+
favFillModal({title:'Edit favourite',kind:f.kind,name:f.name,category:f.category,portable:favIsPortable(f),
|
|
3812
|
+
hint:f.kind==='custom'?'Imported geometry — drops in place; won’t parametrically re-fit.':'Parametric — re-fits any column or beam.'});}
|
|
3813
|
+
function favModalClose(){document.getElementById('favConnModal').style.display='none';favModalJoint=null;favModalEditId=null;}
|
|
3814
|
+
function favModalIsOpen(){const m=document.getElementById('favConnModal');return !!m&&m.style.display==='flex';}
|
|
3815
|
+
async function favModalSave(){const name=(document.getElementById('favName').value||'').trim();
|
|
3816
|
+
const category=(document.getElementById('favCat').value||'').trim();
|
|
3817
|
+
if(!name){toast('Give the favourite a name');document.getElementById('favName').focus();return;}
|
|
3818
|
+
try{
|
|
3819
|
+
if(favModalEditId){
|
|
3820
|
+
const res=await fetch('/api/steel-favourites/'+encodeURIComponent(favModalEditId),{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({name,category})});
|
|
3821
|
+
if(!res.ok)throw 0;const d=await res.json();const i=steelFavourites.findIndex(f=>f.id===favModalEditId);if(i>=0&&d.favourite)steelFavourites[i]=d.favourite;
|
|
3822
|
+
toast('Favourite updated');
|
|
3823
|
+
}else{
|
|
3824
|
+
const r=favRecipeOf(favModalJoint);if(!r){toast('That connection can’t be saved');return;}
|
|
3825
|
+
const body=r.kind==='custom'?{name,category,kind:'custom',geometry:r.geometry,schemaVersion:1,emittedBy:'floless-steel-editor'}
|
|
3826
|
+
:{name,category,kind:r.kind,params:r.params,schemaVersion:1,emittedBy:'floless-steel-editor'};
|
|
3827
|
+
const res=await fetch('/api/steel-favourites',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
3828
|
+
if(!res.ok)throw 0;const d=await res.json();if(d.favourite)steelFavourites.push(d.favourite);
|
|
3829
|
+
toast('Saved to Favourites ★');
|
|
3830
|
+
}
|
|
3831
|
+
}catch(_){toast('Could not save the favourite');return;}
|
|
3832
|
+
favModalClose();if(legendTab==='fav')renderFavTab();panel();} // panel() re-renders so the Inspector ★ reflects the saved state
|
|
3833
|
+
|
|
3834
|
+
// ── Render the Favourites tab (mirror renderViewsTab) ──────────────────────────────────────────────────────
|
|
3835
|
+
function favByKind(){return steelFavourites.filter(f=>{
|
|
3836
|
+
if(favKindFilter==='portable'&&!favIsPortable(f))return false;
|
|
3837
|
+
if(favKindFilter==='geometry'&&f.kind!=='custom')return false;
|
|
3838
|
+
return true;});}
|
|
3839
|
+
function toggleFavSearch(){favSearchOpen=!favSearchOpen;if(!favSearchOpen)favQuery='';renderFavTab();
|
|
3840
|
+
if(favSearchOpen){const inp=document.getElementById('favSearchInput');if(inp)inp.focus();}}
|
|
3841
|
+
function setFavKindFilter(k){favKindFilter=k;try{localStorage.setItem('floless.favKind',k);}catch{}renderFavTab();}
|
|
3842
|
+
function renderFavTab(){const host=document.getElementById('m3dFavBody');if(!host)return;host.replaceChildren();
|
|
3843
|
+
// Header: a kind filter (All / Portable / Geometry) + a 🔍 search toggle. No create button — ★ lives in the Inspector.
|
|
3844
|
+
const head=document.createElement('div');head.id='favHeadRow';
|
|
3845
|
+
const seg=document.createElement('div');seg.id='favKindFilter';seg.className='seg-group';seg.setAttribute('role','group');seg.setAttribute('aria-label','Filter favourites by kind');
|
|
3846
|
+
for(const [k,label,tip] of [['all','All','Show all favourites'],['portable','Portable','Parametric base / shear plates that re-fit any member'],['geometry','Geometry','Imported geometry that drops in place']]){
|
|
3847
|
+
const b=document.createElement('button');b.type='button';b.textContent=label;b.dataset.tip=tip;if(favKindFilter===k)b.classList.add('on');b.setAttribute('aria-pressed',String(favKindFilter===k));b.addEventListener('click',()=>setFavKindFilter(k));seg.appendChild(b);}
|
|
3848
|
+
const stog=document.createElement('button');stog.type='button';stog.id='favSearchTog';stog.setAttribute('aria-label','Search favourites');stog.dataset.tip='Search favourites by name';if(favSearchOpen)stog.classList.add('on');stog.appendChild(magnifierSvg());stog.addEventListener('click',toggleFavSearch);
|
|
3849
|
+
head.append(seg,stog);host.appendChild(head);
|
|
3850
|
+
// Search box (revealed on demand)
|
|
3851
|
+
if(favSearchOpen){const sb=document.createElement('div');sb.id='favSearch';sb.className='show'+(favQuery?' has':'');
|
|
3852
|
+
const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');ico.appendChild(magnifierSvg());
|
|
3853
|
+
const inp=document.createElement('input');inp.id='favSearchInput';inp.type='text';inp.placeholder='Search favourites…';inp.autocomplete='off';inp.value=favQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search favourites');
|
|
3854
|
+
const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
|
|
3855
|
+
inp.addEventListener('input',()=>{favQuery=inp.value;applyFavFilter();});
|
|
3856
|
+
inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';favQuery='';applyFavFilter();}else{toggleFavSearch();}}});
|
|
3857
|
+
clr.addEventListener('click',()=>{if(!inp.value&&!favQuery)return;inp.value='';favQuery='';applyFavFilter();inp.focus();});
|
|
3858
|
+
sb.append(ico,inp,clr);host.appendChild(sb);}
|
|
3859
|
+
// Body: empty (nothing saved), empty-in-filter, or the list
|
|
3860
|
+
if(!steelFavourites.length){const e=Object.assign(document.createElement('div'),{className:'favempty'});e.textContent='Select a base plate, shear plate, or imported connection in the model and click ★ Save to build your library.';host.appendChild(e);return;}
|
|
3861
|
+
const rows=favByKind();
|
|
3862
|
+
if(!rows.length){const e=document.createElement('div');e.className='favnores';e.append(document.createTextNode('No favourites in this filter.'));
|
|
3863
|
+
const clr=document.createElement('button');clr.type='button';clr.className='pilllink';clr.textContent='Show all';clr.addEventListener('click',()=>setFavKindFilter('all'));e.appendChild(clr);host.appendChild(e);return;}
|
|
3864
|
+
const list=document.createElement('div');list.id='favList';host.appendChild(list);
|
|
3865
|
+
for(const f of rows)list.appendChild(buildFavRow(f));
|
|
3866
|
+
applyFavFilter();
|
|
3867
|
+
}
|
|
3868
|
+
function buildFavRow(f){const row=document.createElement('div');row.className='favrow'+(armedFavId===f.id?' armed':'');row.dataset.id=f.id;row.tabIndex=0;
|
|
3869
|
+
const sw=Object.assign(document.createElement('span'),{className:'favsw'});sw.style.setProperty('--sw',FAV_KIND_COLOR[f.kind]||'#8a97a8');sw.dataset.tip=FAV_KIND_LABEL[f.kind]||f.kind;
|
|
3870
|
+
const name=Object.assign(document.createElement('span'),{className:'favname',textContent:f.name});name.dataset.tip=(f.name||'')+' — click to place in the model';
|
|
3871
|
+
const badge=Object.assign(document.createElement('span'),{className:'favbadge'+(favIsPortable(f)?' portable':''),textContent:favIsPortable(f)?'Portable':'Geometry only'});badge.dataset.tip=favIsPortable(f)?'Parametric — re-fits any member':'Imported geometry — drops in place';
|
|
3872
|
+
const dots=Object.assign(document.createElement('span'),{className:'vdots',textContent:'⋯'});dots.dataset.tip='More — Rename / recategorize';dots.setAttribute('role','button');dots.addEventListener('click',e=>{e.stopPropagation();openFavRowMenu(f,dots);});
|
|
3873
|
+
const x=Object.assign(document.createElement('span'),{className:'vx',textContent:'×'});x.dataset.tip='Remove from Favourites';x.addEventListener('click',e=>{e.stopPropagation();deleteFav(f);});
|
|
3874
|
+
row.append(sw,name,badge,dots,x);
|
|
3875
|
+
row.addEventListener('click',e=>{if(e.target===dots||e.target===x)return;armFavouriteInsert(f);});
|
|
3876
|
+
row.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();armFavouriteInsert(f);}});
|
|
3877
|
+
return row;}
|
|
3878
|
+
// Filter rows by name (kind is already applied at render); no-results shows a muted line + Clear. Mirrors applyViewsFilter.
|
|
3879
|
+
function applyFavFilter(){const host=document.getElementById('m3dFavBody');if(!host)return;const list=document.getElementById('favList');
|
|
3880
|
+
const old=host.querySelector('.favnores');if(old&&list)old.remove(); // keep the empty-in-filter notice (no list); only drop the search no-results
|
|
3881
|
+
const sb=document.getElementById('favSearch');if(sb)sb.classList.toggle('has',!!favQuery);
|
|
3882
|
+
if(!list)return;const q=(favQuery||'').trim().toLowerCase();const rows=[...list.querySelectorAll('.favrow')];
|
|
3883
|
+
if(!q){rows.forEach(r=>r.style.display='');return;}
|
|
3884
|
+
let any=false;rows.forEach(r=>{const nm=(r.querySelector('.favname')||{}).textContent||'';const hit=nm.toLowerCase().includes(q);r.style.display=hit?'':'none';if(hit)any=true;});
|
|
3885
|
+
if(!any){const e=document.createElement('div');e.className='favnores';e.append(document.createTextNode('No favourites match “'+favQuery.trim()+'”.'));
|
|
3886
|
+
const clr=document.createElement('button');clr.type='button';clr.className='pilllink';clr.textContent='Clear';clr.addEventListener('click',()=>{favQuery='';const inp=document.getElementById('favSearchInput');if(inp)inp.value='';applyFavFilter();if(inp)inp.focus();});
|
|
3887
|
+
e.appendChild(clr);host.appendChild(e);}
|
|
3888
|
+
}
|
|
3889
|
+
// The per-row ⋯ popup — reuses the .m3dmenu skin. F1: Place + Rename/recategorize (the F2 terminal relay lands later).
|
|
3890
|
+
function openFavRowMenu(f,anchorEl){closeFavRowMenu();
|
|
3891
|
+
const m=document.createElement('div');m.id='favRowMenu';m.className='m3dmenu open';m.setAttribute('role','menu');
|
|
3892
|
+
const mk=(label,fn)=>{const b=document.createElement('button');b.type='button';b.textContent=label;b.setAttribute('role','menuitem');b.addEventListener('click',()=>{closeFavRowMenu();fn();});return b;};
|
|
3893
|
+
m.append(mk('Place in model',()=>armFavouriteInsert(f)),mk('Rename / recategorize…',()=>openFavModalEdit(f)));
|
|
3894
|
+
document.body.appendChild(m);
|
|
3895
|
+
const r=anchorEl.getBoundingClientRect();const mw=m.offsetWidth||184,mh=m.offsetHeight||80;
|
|
3896
|
+
let x=r.right-mw,y=r.bottom+4;if(x<6)x=6;if(y+mh>innerHeight-6)y=r.top-mh-4;if(y<6)y=6;
|
|
3897
|
+
m.style.left=x+'px';m.style.top=y+'px';
|
|
3898
|
+
setTimeout(()=>document.addEventListener('mousedown',favRowMenuOutside,true),0);
|
|
3899
|
+
}
|
|
3900
|
+
function favRowMenuOutside(e){const m=document.getElementById('favRowMenu');if(m&&!m.contains(e.target))closeFavRowMenu();}
|
|
3901
|
+
function closeFavRowMenu(){const m=document.getElementById('favRowMenu');if(m)m.remove();document.removeEventListener('mousedown',favRowMenuOutside,true);}
|
|
3902
|
+
// Delete — no confirm (a recipe, never live geometry), with an undo toast that re-POSTs the favourite.
|
|
3903
|
+
async function deleteFav(f){const idx=steelFavourites.findIndex(x=>x.id===f.id);if(idx<0)return;
|
|
3904
|
+
steelFavourites.splice(idx,1);
|
|
3905
|
+
if(armedFavId===f.id&&window.Steel3DView&&window.Steel3DView.setInsertMode)window.Steel3DView.setInsertMode(false); // disarm if the deleted one was armed
|
|
3906
|
+
renderFavTab();
|
|
3907
|
+
// Verify the server actually removed it before promising an undo: a swallowed non-OK (500 / a read-only or full
|
|
3908
|
+
// ~/.floless) would leave the row gone in the UI but still on disk — a durable, cross-project ghost that reappears
|
|
3909
|
+
// next load. 404 = already gone = the desired end state.
|
|
3910
|
+
let ok=false;
|
|
3911
|
+
try{const res=await fetch('/api/steel-favourites/'+encodeURIComponent(f.id),{method:'DELETE'});ok=res.ok||res.status===404;}catch(_){ok=false;}
|
|
3912
|
+
if(!ok){steelFavourites.splice(Math.min(idx,steelFavourites.length),0,f);renderFavTab();toast('Could not remove “'+f.name+'” — it’s still saved. Try again.');return;} // don't lie to the user; skip the undo toast (nothing was removed)
|
|
3913
|
+
// Undo re-POSTs, which mints a NEW id (the restored favourite is value-identical but not id-identical) — F2's
|
|
3914
|
+
// terminal relay must therefore not assume a favourite id survives an undo.
|
|
3915
|
+
undoToast('Removed “'+f.name+'”',async()=>{try{
|
|
3916
|
+
const body=f.kind==='custom'?{name:f.name,category:f.category,kind:'custom',geometry:f.geometry,schemaVersion:f.schemaVersion||1,emittedBy:f.emittedBy}
|
|
3917
|
+
:{name:f.name,category:f.category,kind:f.kind,params:f.params,schemaVersion:f.schemaVersion||1,emittedBy:f.emittedBy};
|
|
3918
|
+
const res=await fetch('/api/steel-favourites',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
3919
|
+
if(res.ok){const d=await res.json();if(d.favourite){steelFavourites.push(d.favourite);renderFavTab();}}
|
|
3920
|
+
}catch(_){toast('Could not restore the favourite');}});
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
// ── Apply a favourite in-editor: arm the placement crosshair (sticky → repeat), then click a target ──────────
|
|
3924
|
+
function armFavouriteInsert(f){if(!f)return;
|
|
3925
|
+
if(!view3d){toast('Switch to the 3D view to place a connection');return;}
|
|
3926
|
+
if(f.kind==='custom'&&(!Array.isArray(f.geometry)||!f.geometry.length)){toast('That favourite has no geometry to place');return;}
|
|
3927
|
+
armedFavId=f.id;window.Steel3DView.setInsertMode(true,{kind:'favourite',favourite:f,sticky:true});
|
|
3928
|
+
const target=f.kind==='base-plate'?'a column':f.kind==='shear-plate'?'a beam end':'the model';
|
|
3929
|
+
const note=f.kind==='custom'?' (imported geometry — drops in place, won’t re-fit)':'';
|
|
3930
|
+
showFavArmBar('Placing “'+f.name+'” — click '+target+note+'. Esc when done.');
|
|
3931
|
+
if(legendTab==='fav')renderFavTab(); // mark the armed row
|
|
3932
|
+
}
|
|
3933
|
+
// Called by view3dApi.onInsertModeChange(false) — the crosshair disarmed (Esc / toolbar cancel / the bar's Cancel).
|
|
3934
|
+
function favDisarmed(){if(armedFavId==null&&!favArmBarOn())return;armedFavId=null;hideFavArmBar();if(legendTab==='fav')renderFavTab();}
|
|
3935
|
+
// A PERSISTENT armed-placement bar, distinct from the transient toast(): it stays up across repeat placements and
|
|
3936
|
+
// is dismissed only on disarm. Built on demand like undoToast; its Cancel mirrors Esc.
|
|
3937
|
+
function showFavArmBar(msg){let b=document.getElementById('favArmBar');
|
|
3938
|
+
if(!b){b=document.createElement('div');b.id='favArmBar';b.setAttribute('role','status');b.setAttribute('aria-live','polite');b.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:none;align-items:center;gap:12px;background:var(--panel);color:var(--text);border:1px solid var(--brand);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;max-width:min(560px,92vw)';document.body.appendChild(b);}
|
|
3939
|
+
b.replaceChildren();b.appendChild(document.createTextNode(msg));
|
|
3940
|
+
const cancel=document.createElement('button');cancel.type='button';cancel.textContent='Cancel';cancel.dataset.tip='Stop placing (Esc)';cancel.style.cssText='background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:4px;padding:2px 8px;font:12px system-ui;cursor:pointer;box-shadow:none;flex:none';
|
|
3941
|
+
cancel.addEventListener('click',()=>{if(window.Steel3DView)window.Steel3DView.setInsertMode(false);}); // disarm → onInsertModeChange → favDisarmed hides this bar
|
|
3942
|
+
b.appendChild(cancel);b.style.display='flex';}
|
|
3943
|
+
function hideFavArmBar(){const b=document.getElementById('favArmBar');if(b)b.style.display='none';}
|
|
3944
|
+
function favArmBarOn(){const b=document.getElementById('favArmBar');return !!b&&b.style.display!=='none';}
|
|
3656
3945
|
// Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
|
|
3657
3946
|
// — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
|
|
3658
3947
|
// base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
|
|
@@ -4131,7 +4420,7 @@ async function setView(on){
|
|
|
4131
4420
|
window.Steel3DView.show();
|
|
4132
4421
|
await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
|
|
4133
4422
|
window.Steel3DView.setSelection(selIds);
|
|
4134
|
-
wire3DBar();wireLegendTabs();build3DLegend();showLegendPanel(); // build the Objects body, then open the panel on the last-used tab
|
|
4423
|
+
wire3DBar();wireLegendTabs();loadSteelFavourites();build3DLegend();showLegendPanel(); // build the Objects body + load the (global) favourites library, then open the panel on the last-used tab
|
|
4135
4424
|
reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
|
|
4136
4425
|
}catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
|
|
4137
4426
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
|
|
@@ -4880,6 +5169,12 @@ document.getElementById('askAiClose').onclick = askAiClose;
|
|
|
4880
5169
|
document.getElementById('askAiCancel').onclick = askAiClose;
|
|
4881
5170
|
document.getElementById('askAiBackdrop').onclick = askAiClose;
|
|
4882
5171
|
document.getElementById('askAiText').oninput = askAiSyncSend;
|
|
5172
|
+
// Favourites modal (#favConnModal) wiring — a local sibling of the Ask-AI modal.
|
|
5173
|
+
document.getElementById('favClose').onclick = favModalClose;
|
|
5174
|
+
document.getElementById('favCancel').onclick = favModalClose;
|
|
5175
|
+
document.getElementById('favBackdrop').onclick = favModalClose;
|
|
5176
|
+
document.getElementById('favSave').onclick = favModalSave;
|
|
5177
|
+
document.getElementById('favName').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); favModalSave(); } });
|
|
4883
5178
|
// Drop zone click → file picker
|
|
4884
5179
|
document.getElementById('askAiDrop').onclick = () => document.getElementById('askAiFile').click();
|
|
4885
5180
|
// File input (supports multiple)
|