@floless/app 0.81.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 +321 -25
- 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}
|
|
@@ -125,12 +125,13 @@
|
|
|
125
125
|
#moreMenu #m3dInsert.on{color:var(--brand)}
|
|
126
126
|
#moreMenu #m3dInsertMenu{left:auto;right:calc(100% + 4px);top:0}
|
|
127
127
|
body:not(.v3d) #moreMenu #insWrap{display:none} /* Insert detail places into the 3D scene — hide it in 2D (needs 2 ids to beat the .m3dwrap.ins-in-menu display:block) */
|
|
128
|
-
#moreMenu button.msnap{display:flex;align-items:center;gap:0}
|
|
129
|
-
#moreMenu button.msnap.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
|
|
128
|
+
#moreMenu button.msnap,#moreMenu button.dtog{display:flex;align-items:center;gap:0} /* .dtog = the Display show/hide toggles, same slider switch as Snapping (.msnap) */
|
|
129
|
+
#moreMenu button.msnap.on,#moreMenu button.dtog.on{color:var(--text)} /* the switch carries the state — don't also brand the text (reads as an armed tool elsewhere in this menu) */
|
|
130
130
|
#moreMenu .mck,.cmmenu .mck,.m3dmenu .mck{position:relative;width:26px;height:14px;margin-right:9px;border-radius:7px;border:1px solid var(--line);background:#0b1220;flex:none;transition:background-color .15s,border-color .15s} /* delicate CSS-only slider switch — shared by the ⋯ Snapping rows, the Move/Copy → Drag-to-move/copy toggle, and the Work-area toggles */
|
|
131
131
|
#moreMenu .mck::after,.cmmenu .mck::after,.m3dmenu .mck::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--mut);transition:transform .15s,background-color .15s}
|
|
132
|
-
#moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
|
|
133
|
-
#moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
|
|
132
|
+
#moreMenu button.msnap.on .mck,#moreMenu button.dtog.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
|
|
133
|
+
#moreMenu button.msnap.on .mck::after,#moreMenu button.dtog.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
|
|
134
|
+
#moreMenu button.dtog:disabled{opacity:.45;cursor:default} /* e.g. the grid toggle when the model has no grid */
|
|
134
135
|
.m3dmenu button.wtog{display:flex;align-items:center;justify-content:flex-start;gap:0}
|
|
135
136
|
.m3dmenu button.wtog.on{color:var(--text)} /* the slider carries the on-state — don't also brand the label text */
|
|
136
137
|
#moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
|
|
@@ -418,8 +419,47 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
418
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) */
|
|
419
420
|
#m3dLegendBody{padding:8px 10px}
|
|
420
421
|
#m3dViewsBody,#m3dFavBody{padding:8px 10px}
|
|
421
|
-
/* Favourites
|
|
422
|
-
|
|
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}
|
|
423
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 */
|
|
424
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 */
|
|
425
465
|
#m3dLegend .lrow:hover{background:#33415580}
|
|
@@ -591,10 +631,10 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
591
631
|
</div>
|
|
592
632
|
<button class=msec-hdr data-sec=display aria-expanded=false data-tip="Show/hide plan layers, and edit grid lines">Display<span class=chev aria-hidden=true>▸</span></button>
|
|
593
633
|
<div class=msec-body>
|
|
594
|
-
<button id=dimToggleB data-tip="Show or hide all placed dimensions on the plan">
|
|
595
|
-
<button id=calloutToggleB data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">
|
|
596
|
-
<button id=gridToggleB data-tip="Show or hide the grid lines in 2D and 3D">
|
|
597
|
-
<button id=lenToggleB data-tip="Show each selected member's length on the canvas">
|
|
634
|
+
<button id=dimToggleB class=dtog role=menuitemcheckbox aria-checked=true data-tip="Show or hide all placed dimensions on the plan"><span class=mck aria-hidden=true></span>Dimensions</button>
|
|
635
|
+
<button id=calloutToggleB class=dtog role=menuitemcheckbox aria-checked=true data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan"><span class=mck aria-hidden=true></span>Callouts</button>
|
|
636
|
+
<button id=gridToggleB class=dtog role=menuitemcheckbox aria-checked=false data-tip="Show or hide the grid lines in 2D and 3D"><span class=mck aria-hidden=true></span>Grid lines</button>
|
|
637
|
+
<button id=lenToggleB class=dtog role=menuitemcheckbox aria-checked=false data-tip="Show each selected member's length on the canvas"><span class=mck aria-hidden=true></span>Member length</button>
|
|
598
638
|
<button id=gridEditB data-tip="Grid lines — a plan reference with structural bay spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
|
|
599
639
|
</div>
|
|
600
640
|
<button class=msec-hdr data-sec=detailing aria-expanded=false data-tip="Connection details, plates, frames, and inserted detail images">Detailing<span class=chev aria-hidden=true>▸</span></button>
|
|
@@ -715,7 +755,7 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
715
755
|
</div>
|
|
716
756
|
<div id=m3dLegendBody class="m3dbody on" role=tabpanel aria-label=Objects></div>
|
|
717
757
|
<div id=m3dViewsBody class=m3dbody role=tabpanel aria-label=Views></div>
|
|
718
|
-
<div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites
|
|
758
|
+
<div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites></div>
|
|
719
759
|
</div>
|
|
720
760
|
<div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
|
|
721
761
|
<div id=m3dAxes></div>
|
|
@@ -810,6 +850,24 @@ text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-famil
|
|
|
810
850
|
</div>
|
|
811
851
|
</div>
|
|
812
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>
|
|
813
871
|
<div id=lightbox><div class=mbackdrop id=lbBackdrop></div>
|
|
814
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>
|
|
815
873
|
<script>
|
|
@@ -1329,7 +1387,7 @@ function gridDefaultOrigin(){const xs=[],ys=[];for(const m of ((P&&P.members)||[
|
|
|
1329
1387
|
if(xs.length)return [Math.min(...xs),Math.max(...ys)]; // bottom-left of the steel
|
|
1330
1388
|
return [(typeof X0==='number'?X0:0)+50,(typeof Y1==='number'?Y1:300)-50];} // empty plan → near the sheet corner
|
|
1331
1389
|
function refresh3DGrid(){if(view3dReady&&window.Steel3DView&&window.Steel3DView.refreshGrid)window.Steel3DView.refreshGrid();}
|
|
1332
|
-
function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.
|
|
1390
|
+
function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.classList.toggle('on',gridOn());b.setAttribute('aria-checked',String(gridOn()));}
|
|
1333
1391
|
// ONE visibility switch, THREE surfaces: the grid panel's checkbox, the 3D legend's "Grid lines" row,
|
|
1334
1392
|
// and the ⋯ menu item — all call this. grid.on is contract data, so the flip is undoable like every
|
|
1335
1393
|
// other grid operation.
|
|
@@ -1593,7 +1651,7 @@ function render(){
|
|
|
1593
1651
|
s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
|
|
1594
1652
|
s+=renderSelLenLabels(); // on-select member-length chips (2D), gated by the ⋯ Display "Show member length" toggle
|
|
1595
1653
|
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)
|
|
1596
|
-
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(); updLenToggle();
|
|
1654
|
+
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(); updLenToggle(); updDimToggle(); updCalloutToggle();
|
|
1597
1655
|
if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);refreshLegendSel();} // keep the 3D highlight + legend selection in sync; selecting a member clears any clip selection (exclusive)
|
|
1598
1656
|
try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
|
|
1599
1657
|
syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
|
|
@@ -1821,13 +1879,15 @@ function panel(){
|
|
|
1821
1879
|
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1822
1880
|
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to move / replace this connection">Modify connection…</button>
|
|
1823
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>
|
|
1824
1883
|
</div>`;
|
|
1825
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();};}
|
|
1826
1885
|
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1827
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);}
|
|
1828
1888
|
return;
|
|
1829
1889
|
}
|
|
1830
|
-
const isBP=j.kind==='base-plate',pp=j.params||{};
|
|
1890
|
+
const isBP=j.kind==='base-plate',pp=j.params||{};const savedFav=favMatch(j);
|
|
1831
1891
|
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
1832
1892
|
const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
|
|
1833
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>`;
|
|
@@ -1847,6 +1907,7 @@ function panel(){
|
|
|
1847
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>`:''}
|
|
1848
1908
|
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
|
|
1849
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>
|
|
1850
1911
|
</div>`;
|
|
1851
1912
|
const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1852
1913
|
{const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
|
|
@@ -1854,6 +1915,7 @@ function panel(){
|
|
|
1854
1915
|
{const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
|
|
1855
1916
|
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1856
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);}
|
|
1857
1919
|
return;
|
|
1858
1920
|
}}
|
|
1859
1921
|
// A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
|
|
@@ -3011,14 +3073,14 @@ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMod
|
|
|
3011
3073
|
document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
|
|
3012
3074
|
document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
|
|
3013
3075
|
document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
|
|
3014
|
-
function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.
|
|
3015
|
-
document.getElementById('dimToggleB').onclick=()
|
|
3016
|
-
function updLenToggle(){const b=document.getElementById('lenToggleB');if(b)b.
|
|
3017
|
-
document.getElementById('lenToggleB').onclick=()
|
|
3018
|
-
function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.
|
|
3019
|
-
document.getElementById('calloutToggleB').onclick=()
|
|
3076
|
+
function updDimToggle(){const b=document.getElementById('dimToggleB');if(b){b.classList.toggle('on',dimsVisible);b.setAttribute('aria-checked',String(dimsVisible));}}
|
|
3077
|
+
document.getElementById('dimToggleB').onclick=e=>{e.stopPropagation();dimsVisible=!dimsVisible;updDimToggle();render();};
|
|
3078
|
+
function updLenToggle(){const b=document.getElementById('lenToggleB');if(b){b.classList.toggle('on',showSelLen);b.setAttribute('aria-checked',String(showSelLen));}}
|
|
3079
|
+
document.getElementById('lenToggleB').onclick=e=>{e.stopPropagation();showSelLen=!showSelLen;try{localStorage.setItem('steel:selLen:v1',showSelLen?'1':'0');}catch(_){}updLenToggle();render();}; // render() repaints the 2D chips + pushes labels to 3D
|
|
3080
|
+
function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b){b.classList.toggle('on',calloutsVisible);b.setAttribute('aria-checked',String(calloutsVisible));}}
|
|
3081
|
+
document.getElementById('calloutToggleB').onclick=e=>{e.stopPropagation();calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
|
|
3020
3082
|
document.getElementById('gridEditB').onclick=()=>{setGridMode(!gridMode);render();};
|
|
3021
|
-
document.getElementById('gridToggleB').onclick=()
|
|
3083
|
+
document.getElementById('gridToggleB').onclick=e=>{e.stopPropagation();gridSetVisible(!gridOn());};
|
|
3022
3084
|
document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
|
|
3023
3085
|
if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
|
|
3024
3086
|
setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
|
|
@@ -3043,6 +3105,7 @@ addEventListener('keydown',e=>{
|
|
|
3043
3105
|
if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
|
|
3044
3106
|
if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
|
|
3045
3107
|
if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
|
|
3108
|
+
if(e.key==='Escape'&&favModalIsOpen()){favModalClose();return;}
|
|
3046
3109
|
if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
|
|
3047
3110
|
if(e.key==='Escape'&&connLibOpen()){closeConnLib();return;}
|
|
3048
3111
|
if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
|
|
@@ -3105,7 +3168,7 @@ function moreOpen(){return moreMenu.classList.contains('open');}
|
|
|
3105
3168
|
function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
|
|
3106
3169
|
function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
|
|
3107
3170
|
moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
|
|
3108
|
-
moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
|
|
3171
|
+
moreMenu.addEventListener('click',e=>{if(e.target.closest('button')&&!e.target.closest('.msnap')&&!e.target.closest('.dtog')&&!e.target.closest('.msec-hdr')&&!e.target.closest('.ins-in-menu'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu; the snap toggles, section headers, and the Insert picker keep the menu open (settings, not one-shot actions)
|
|
3109
3172
|
// "Snapping" is collapsible to save menu space — the header expands the running-snap switches below it
|
|
3110
3173
|
// Every ⋯ section is a collapse/expand accordion (Snapping + Display + Detailing + …); state persists in localStorage.
|
|
3111
3174
|
{const SEC_KEY='steelMoreSections';let openSecs;try{openSecs=new Set(JSON.parse(localStorage.getItem(SEC_KEY)||'[]'));}catch(e){openSecs=new Set();}
|
|
@@ -3221,7 +3284,7 @@ const view3dApi={
|
|
|
3221
3284
|
onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
|
|
3222
3285
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
3223
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)
|
|
3224
|
-
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)
|
|
3225
3288
|
onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
|
|
3226
3289
|
onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
|
|
3227
3290
|
const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
|
|
@@ -3240,6 +3303,39 @@ const view3dApi={
|
|
|
3240
3303
|
toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
|
|
3241
3304
|
},
|
|
3242
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
|
+
}
|
|
3243
3339
|
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3244
3340
|
const conn=pending.connection;const rc=conn.recipe;
|
|
3245
3341
|
// Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
|
|
@@ -3443,6 +3539,7 @@ function setLegendTab(tab){if(tab!=='objects'&&tab!=='views'&&tab!=='fav')tab='o
|
|
|
3443
3539
|
const bodies={objects:'m3dLegendBody',views:'m3dViewsBody',fav:'m3dFavBody'};
|
|
3444
3540
|
for(const [k,id] of Object.entries(bodies)){const el=document.getElementById(id);if(el)el.classList.toggle('on',k===tab);}
|
|
3445
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
|
|
3446
3543
|
updateViewsBtn();
|
|
3447
3544
|
}
|
|
3448
3545
|
// The 3D-toolbar Views button lights (.on) while the panel is open on the Views tab.
|
|
@@ -3652,6 +3749,199 @@ function undoToast(msg,onUndo){let t=document.getElementById('undoToast');
|
|
|
3652
3749
|
btn.addEventListener('click',()=>{clearTimeout(t._h);t.style.opacity='0';try{onUndo();}catch(e){console.error(e);}});
|
|
3653
3750
|
t.appendChild(btn);t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},5000);
|
|
3654
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';}
|
|
3655
3945
|
// Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
|
|
3656
3946
|
// — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
|
|
3657
3947
|
// base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
|
|
@@ -4130,7 +4420,7 @@ async function setView(on){
|
|
|
4130
4420
|
window.Steel3DView.show();
|
|
4131
4421
|
await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
|
|
4132
4422
|
window.Steel3DView.setSelection(selIds);
|
|
4133
|
-
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
|
|
4134
4424
|
reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
|
|
4135
4425
|
}catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
|
|
4136
4426
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
|
|
@@ -4879,6 +5169,12 @@ document.getElementById('askAiClose').onclick = askAiClose;
|
|
|
4879
5169
|
document.getElementById('askAiCancel').onclick = askAiClose;
|
|
4880
5170
|
document.getElementById('askAiBackdrop').onclick = askAiClose;
|
|
4881
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(); } });
|
|
4882
5178
|
// Drop zone click → file picker
|
|
4883
5179
|
document.getElementById('askAiDrop').onclick = () => document.getElementById('askAiFile').click();
|
|
4884
5180
|
// File input (supports multiple)
|