@floless/app 0.72.1 → 0.72.3
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 +89 -21
- package/dist/web/app.css +39 -1
- package/dist/web/index.html +4 -0
- package/dist/web/steel-3d-view.js +50 -19
- package/dist/web/steel-editor.html +72 -14
- package/dist/web/workspaces.js +151 -2
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.72.
|
|
53025
|
+
define: true ? "0.72.3" : void 0,
|
|
53026
53026
|
pkgVersion: readPkgVersion()
|
|
53027
53027
|
});
|
|
53028
53028
|
}
|
|
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
|
|
|
53032
53032
|
return "dev";
|
|
53033
53033
|
}
|
|
53034
53034
|
function appChannel() {
|
|
53035
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.72.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.72.3" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -53329,14 +53329,16 @@ function extractReportHtml(events) {
|
|
|
53329
53329
|
var import_node_child_process3 = require("node:child_process");
|
|
53330
53330
|
var import_node_fs12 = require("node:fs");
|
|
53331
53331
|
var import_node_path11 = require("node:path");
|
|
53332
|
-
var
|
|
53332
|
+
var OPEN_EXTS = /* @__PURE__ */ new Set([".xls", ".xlsx", ".html", ".htm", ".csv"]);
|
|
53333
|
+
var REVEAL_EXTS = /* @__PURE__ */ new Set([...OPEN_EXTS, ".ifc", ".nc1", ".dstv"]);
|
|
53333
53334
|
function hasOpenableExt(path) {
|
|
53334
|
-
return
|
|
53335
|
+
return OPEN_EXTS.has((0, import_node_path11.extname)(path).toLowerCase());
|
|
53335
53336
|
}
|
|
53336
|
-
function validateOpenable(path) {
|
|
53337
|
+
function validateOpenable(path, mode = "open") {
|
|
53337
53338
|
if (typeof path !== "string" || !path.trim()) return { ok: false, error: "path required" };
|
|
53338
53339
|
if (path.includes("%")) return { ok: false, error: "unsupported file path" };
|
|
53339
|
-
|
|
53340
|
+
const allowed = mode === "reveal" ? REVEAL_EXTS.has((0, import_node_path11.extname)(path).toLowerCase()) : hasOpenableExt(path);
|
|
53341
|
+
if (!allowed) return { ok: false, error: "unsupported file type" };
|
|
53340
53342
|
let isFile = false;
|
|
53341
53343
|
try {
|
|
53342
53344
|
isFile = (0, import_node_fs12.existsSync)(path) && (0, import_node_fs12.statSync)(path).isFile();
|
|
@@ -53973,6 +53975,9 @@ function slugify(name) {
|
|
|
53973
53975
|
function projectDir(id) {
|
|
53974
53976
|
return (0, import_node_path17.join)(DIR2, safeProjectId(id));
|
|
53975
53977
|
}
|
|
53978
|
+
function projectExportsDir(id) {
|
|
53979
|
+
return (0, import_node_path17.join)(projectDir(id), "exports");
|
|
53980
|
+
}
|
|
53976
53981
|
var metaPath = (id) => (0, import_node_path17.join)(projectDir(id), "project.json");
|
|
53977
53982
|
function readMeta(id) {
|
|
53978
53983
|
const p = metaPath(id);
|
|
@@ -54020,9 +54025,19 @@ function renameProject(id, name) {
|
|
|
54020
54025
|
writeMeta(next);
|
|
54021
54026
|
return next;
|
|
54022
54027
|
}
|
|
54023
|
-
function
|
|
54028
|
+
function markApproved(id) {
|
|
54024
54029
|
const meta = readMeta(safeProjectId(id));
|
|
54025
|
-
if (meta)
|
|
54030
|
+
if (!meta) throw new ProjectError(`unknown project: ${id}`);
|
|
54031
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
54032
|
+
const next = { ...meta, approvedAt: now, updatedAt: now };
|
|
54033
|
+
writeMeta(next);
|
|
54034
|
+
return next;
|
|
54035
|
+
}
|
|
54036
|
+
function clearApproval(id) {
|
|
54037
|
+
const meta = readMeta(safeProjectId(id));
|
|
54038
|
+
if (!meta) return;
|
|
54039
|
+
const { approvedAt: _drop, ...rest } = meta;
|
|
54040
|
+
writeMeta({ ...rest, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
54026
54041
|
}
|
|
54027
54042
|
function duplicateProject(id, name) {
|
|
54028
54043
|
const src = readMeta(safeProjectId(id));
|
|
@@ -63782,6 +63797,9 @@ async function startServer() {
|
|
|
63782
63797
|
if (err2 instanceof ContractError) {
|
|
63783
63798
|
return reply.status(400).send({ ok: false, error: err2.message });
|
|
63784
63799
|
}
|
|
63800
|
+
if (err2 instanceof ProjectError) {
|
|
63801
|
+
return reply.status(400).send({ ok: false, error: err2.message });
|
|
63802
|
+
}
|
|
63785
63803
|
if (err2 instanceof RoutineError) {
|
|
63786
63804
|
return reply.status(err2.status).send({ ok: false, error: err2.message });
|
|
63787
63805
|
}
|
|
@@ -64226,6 +64244,20 @@ async function startServer() {
|
|
|
64226
64244
|
if (p.app !== appId) return `project "${project}" belongs to app "${p.app}", not "${appId}"`;
|
|
64227
64245
|
return null;
|
|
64228
64246
|
}
|
|
64247
|
+
function projectExportFiles(appId) {
|
|
64248
|
+
return [
|
|
64249
|
+
{ kind: "bom-csv", filename: `${appId}-bom.csv` },
|
|
64250
|
+
{ kind: "bom-xlsx", filename: `${appId}-bom.xlsx` },
|
|
64251
|
+
{ kind: "ifc", filename: `${appId}.ifc` }
|
|
64252
|
+
];
|
|
64253
|
+
}
|
|
64254
|
+
function projectExportGate(appId, project) {
|
|
64255
|
+
const p = getProject(project);
|
|
64256
|
+
if (!p) return { status: 404, error: `unknown project: ${project}` };
|
|
64257
|
+
if (p.app !== appId) return { status: 404, error: `project "${project}" belongs to app "${p.app}", not "${appId}"` };
|
|
64258
|
+
if (!p.approvedAt) return { status: 409, error: "Approve the model before exporting \u2014 exports go out only after sign-off." };
|
|
64259
|
+
return null;
|
|
64260
|
+
}
|
|
64229
64261
|
app.get("/api/contract/:appId", async (req, reply) => {
|
|
64230
64262
|
const project = req.query.project || void 0;
|
|
64231
64263
|
if (project) {
|
|
@@ -64255,7 +64287,7 @@ async function startServer() {
|
|
|
64255
64287
|
if (e instanceof ContractError) return reply.status(400).send({ ok: false, error: e.message });
|
|
64256
64288
|
throw e;
|
|
64257
64289
|
}
|
|
64258
|
-
if (project)
|
|
64290
|
+
if (project) clearApproval(project);
|
|
64259
64291
|
broadcast({ type: "contract-changed", appId: req.params.appId, project });
|
|
64260
64292
|
return { ok: true };
|
|
64261
64293
|
}
|
|
@@ -64287,7 +64319,9 @@ async function startServer() {
|
|
|
64287
64319
|
});
|
|
64288
64320
|
}
|
|
64289
64321
|
broadcast({ type: "compiled", id: req.params.appId, lockPath: result.lockPath });
|
|
64290
|
-
|
|
64322
|
+
let approvedAt;
|
|
64323
|
+
if (project) approvedAt = markApproved(project).approvedAt;
|
|
64324
|
+
return { ok: true, result, approvedAt };
|
|
64291
64325
|
});
|
|
64292
64326
|
app.post(
|
|
64293
64327
|
"/api/contract/:appId/score",
|
|
@@ -64351,6 +64385,24 @@ async function startServer() {
|
|
|
64351
64385
|
}
|
|
64352
64386
|
return null;
|
|
64353
64387
|
}
|
|
64388
|
+
app.get("/api/contract/:appId/exports", async (req, reply) => {
|
|
64389
|
+
const project = req.query.project;
|
|
64390
|
+
if (!project) return reply.status(400).send({ ok: false, error: "project required" });
|
|
64391
|
+
const bad = projectAppMismatch(req.params.appId, project);
|
|
64392
|
+
if (bad) return reply.status(404).send({ ok: false, error: bad });
|
|
64393
|
+
const dir = projectExportsDir(project);
|
|
64394
|
+
const exports2 = projectExportFiles(req.params.appId).map(({ kind, filename }) => {
|
|
64395
|
+
const path = (0, import_node_path30.join)(dir, filename);
|
|
64396
|
+
let exportedAt = null;
|
|
64397
|
+
try {
|
|
64398
|
+
const st = (0, import_node_fs32.statSync)(path);
|
|
64399
|
+
if (st.isFile()) exportedAt = st.mtime.toISOString();
|
|
64400
|
+
} catch {
|
|
64401
|
+
}
|
|
64402
|
+
return { kind, filename, path, exportedAt };
|
|
64403
|
+
});
|
|
64404
|
+
return { ok: true, approvedAt: getProject(project)?.approvedAt ?? null, exports: exports2 };
|
|
64405
|
+
});
|
|
64354
64406
|
app.post("/api/contract/:appId/export-3d", async (req, reply) => {
|
|
64355
64407
|
const doc = readContract(req.params.appId);
|
|
64356
64408
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
@@ -64391,7 +64443,12 @@ async function startServer() {
|
|
|
64391
64443
|
return { ok: true, report: { ...extracted, interactive: true }, skipped };
|
|
64392
64444
|
});
|
|
64393
64445
|
app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
|
|
64394
|
-
const
|
|
64446
|
+
const project = req.query.project || void 0;
|
|
64447
|
+
if (project) {
|
|
64448
|
+
const g = projectExportGate(req.params.appId, project);
|
|
64449
|
+
if (g) return reply.status(g.status).send({ ok: false, error: g.error });
|
|
64450
|
+
}
|
|
64451
|
+
const doc = project ? readContract(req.params.appId, project) : readContractForApp(req.params.appId);
|
|
64395
64452
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
64396
64453
|
const isVector = typeof doc === "object" && doc != null && doc.type === "drawing.vector/v1";
|
|
64397
64454
|
let scene;
|
|
@@ -64422,7 +64479,8 @@ async function startServer() {
|
|
|
64422
64479
|
}
|
|
64423
64480
|
const companionId = `${req.params.appId}-ifc`;
|
|
64424
64481
|
const filename = `${req.params.appId}.ifc`;
|
|
64425
|
-
const outPath = (0, import_node_path30.join)(appPath(companionId), filename);
|
|
64482
|
+
const outPath = project ? (0, import_node_path30.join)(projectExportsDir(project), filename) : (0, import_node_path30.join)(appPath(companionId), filename);
|
|
64483
|
+
if (project) (0, import_node_fs32.mkdirSync)((0, import_node_path30.dirname)(outPath), { recursive: true });
|
|
64426
64484
|
let flo;
|
|
64427
64485
|
try {
|
|
64428
64486
|
flo = writeIfcApp(appPath(companionId), companionId, scene, outPath, writeOpts);
|
|
@@ -64448,10 +64506,15 @@ async function startServer() {
|
|
|
64448
64506
|
if (!(0, import_node_fs32.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
|
|
64449
64507
|
const content = (0, import_node_fs32.readFileSync)(outPath, "utf8");
|
|
64450
64508
|
broadcast({ type: "apps-changed" });
|
|
64451
|
-
return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
|
|
64509
|
+
return { ok: true, filename, savedTo: outPath, content, bytes: Buffer.byteLength(content), skipped, ...extrusionsSkipped ? { extrusionsSkipped } : {} };
|
|
64452
64510
|
});
|
|
64453
64511
|
app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
|
|
64454
|
-
const
|
|
64512
|
+
const project = req.query.project || void 0;
|
|
64513
|
+
if (project) {
|
|
64514
|
+
const g = projectExportGate(req.params.appId, project);
|
|
64515
|
+
if (g) return reply.status(g.status).send({ ok: false, error: g.error });
|
|
64516
|
+
}
|
|
64517
|
+
const doc = project ? readContract(req.params.appId, project) : readContract(req.params.appId);
|
|
64455
64518
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to bake" });
|
|
64456
64519
|
const v = validateSteelTakeoff(doc);
|
|
64457
64520
|
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
@@ -64510,13 +64573,18 @@ async function startServer() {
|
|
|
64510
64573
|
});
|
|
64511
64574
|
app.post("/api/contract/:appId/export-bom/:format", async (req, reply) => {
|
|
64512
64575
|
const { appId, format } = req.params;
|
|
64576
|
+
const project = req.query.project || void 0;
|
|
64513
64577
|
if (format !== "csv" && format !== "xlsx") {
|
|
64514
64578
|
return reply.status(400).send({ ok: false, error: `unknown format "${format}" \u2014 use csv or xlsx` });
|
|
64515
64579
|
}
|
|
64516
64580
|
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(appId)) {
|
|
64517
64581
|
return reply.status(400).send({ ok: false, error: `invalid appId: ${appId}` });
|
|
64518
64582
|
}
|
|
64519
|
-
|
|
64583
|
+
if (project) {
|
|
64584
|
+
const g = projectExportGate(appId, project);
|
|
64585
|
+
if (g) return reply.status(g.status).send({ ok: false, error: g.error });
|
|
64586
|
+
}
|
|
64587
|
+
const doc = readContract(appId, project);
|
|
64520
64588
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
64521
64589
|
const v = validateSteelTakeoff(doc);
|
|
64522
64590
|
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
@@ -64524,9 +64592,9 @@ async function startServer() {
|
|
|
64524
64592
|
if (bom.rows.filter((r) => r.Profile !== "TOTAL").length === 0) {
|
|
64525
64593
|
return reply.status(422).send({ ok: false, error: "no priced members to export \u2014 every member is an RFI (no AISC size / weight yet)" });
|
|
64526
64594
|
}
|
|
64527
|
-
const outPath = bomExportPath(appId, format);
|
|
64528
|
-
const dir = (0, import_node_path30.dirname)(outPath);
|
|
64529
64595
|
const filename = `${appId}-bom.${format}`;
|
|
64596
|
+
const outPath = project ? (0, import_node_path30.join)(projectExportsDir(project), filename) : bomExportPath(appId, format);
|
|
64597
|
+
const dir = (0, import_node_path30.dirname)(outPath);
|
|
64530
64598
|
try {
|
|
64531
64599
|
(0, import_node_fs32.mkdirSync)(dir, { recursive: true });
|
|
64532
64600
|
if (format === "csv") {
|
|
@@ -64916,16 +64984,16 @@ async function startServer() {
|
|
|
64916
64984
|
const running = cancelActiveRun();
|
|
64917
64985
|
return { ok: true, running };
|
|
64918
64986
|
});
|
|
64919
|
-
function fileVerb(launch) {
|
|
64987
|
+
function fileVerb(launch, mode) {
|
|
64920
64988
|
return async (req, reply) => {
|
|
64921
|
-
const check = validateOpenable(req.body?.path);
|
|
64989
|
+
const check = validateOpenable(req.body?.path, mode);
|
|
64922
64990
|
if (!check.ok) return reply.status(400).send({ ok: false, error: check.error });
|
|
64923
64991
|
launch(check.path);
|
|
64924
64992
|
return { ok: true };
|
|
64925
64993
|
};
|
|
64926
64994
|
}
|
|
64927
|
-
app.post("/api/open-file", fileVerb(launchFile));
|
|
64928
|
-
app.post("/api/reveal", fileVerb(revealFile));
|
|
64995
|
+
app.post("/api/open-file", fileVerb(launchFile, "open"));
|
|
64996
|
+
app.post("/api/reveal", fileVerb(revealFile, "reveal"));
|
|
64929
64997
|
app.post(
|
|
64930
64998
|
"/api/trigger-run",
|
|
64931
64999
|
async (req, reply) => {
|
package/dist/web/app.css
CHANGED
|
@@ -3312,7 +3312,7 @@ body {
|
|
|
3312
3312
|
/* ── Workspaces (mode shell) — see docs/superpowers/mockups/2026-07-03-workspaces-mockup.html ── */
|
|
3313
3313
|
/* The display rules below (display:flex etc.) would otherwise override the `hidden` attribute's
|
|
3314
3314
|
UA display:none — these guards keep hidden winning so nothing leaks across modes. */
|
|
3315
|
-
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden] { display:none !important; }
|
|
3315
|
+
.ws-landing[hidden], .ws-project[hidden], .ws-spine[hidden], .ws-frame[hidden], .ws-exports[hidden] { display:none !important; }
|
|
3316
3316
|
.mode-switch { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px;
|
|
3317
3317
|
overflow:hidden; background:var(--surface-2); flex:none; }
|
|
3318
3318
|
.mode-switch button { background:transparent; border:none; border-radius:0; color:var(--text-muted);
|
|
@@ -3390,3 +3390,41 @@ body {
|
|
|
3390
3390
|
.tabs.ws-step-tabs { background:transparent; padding:0 6px; }
|
|
3391
3391
|
.tabs.ws-step-tabs button { flex:0 0 auto; padding:11px 16px; }
|
|
3392
3392
|
.ws-frame { flex:1; min-height:0; width:100%; border:none; background:var(--bg); }
|
|
3393
|
+
|
|
3394
|
+
/* ── Workspaces ▸ Exports step (shell-rendered pane; export cards over the project's OWN
|
|
3395
|
+
contract). Ports the mockup's .ecard grid into app tokens; all states baseline-faithful. */
|
|
3396
|
+
.ws-exports { flex:1; min-height:0; display:flex; flex-direction:column; }
|
|
3397
|
+
/* The Approve gate — a project's Model must be signed off before its exports go out. Not an
|
|
3398
|
+
error (accent-dim rail, never a danger color); the inline "Approve" deep-links the sign-off. */
|
|
3399
|
+
.ws-exports-gate { flex:none; margin:12px 20px 0; padding:9px 13px; display:flex; align-items:center; gap:6px;
|
|
3400
|
+
background:var(--surface-2); border:1px solid var(--border); border-left:3px solid var(--accent-dim);
|
|
3401
|
+
border-radius:6px; font-size:12px; color:var(--text-muted); }
|
|
3402
|
+
.ws-exports-gate button { background:none; border:none; padding:0; font:inherit; color:var(--accent); cursor:pointer; }
|
|
3403
|
+
.ws-exports-gate button:hover { color:var(--accent-bright); text-decoration:underline; }
|
|
3404
|
+
.exports-note { flex:none; padding:14px 20px 4px; color:var(--text-muted); font-size:12px; }
|
|
3405
|
+
.exports-note b { color:var(--text); font-weight:600; }
|
|
3406
|
+
.exports-note code { font-family:var(--mono); color:var(--accent-bright); font-size:11px; }
|
|
3407
|
+
.export-grid { flex:1; overflow-y:auto; padding:14px 20px 22px; display:grid; gap:14px;
|
|
3408
|
+
grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); align-content:start;
|
|
3409
|
+
scrollbar-width:thin; scrollbar-color:var(--border-strong) transparent; }
|
|
3410
|
+
.ecard { background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:15px;
|
|
3411
|
+
display:flex; flex-direction:column; gap:10px; }
|
|
3412
|
+
.ecard .e-top { display:flex; align-items:center; gap:10px; }
|
|
3413
|
+
.ecard .e-ico { width:34px; height:34px; border-radius:7px; background:var(--surface-2);
|
|
3414
|
+
border:1px solid var(--border-strong); display:flex; align-items:center; justify-content:center; font-size:15px; }
|
|
3415
|
+
.ecard .e-name { font-size:13px; font-weight:600; color:var(--text); }
|
|
3416
|
+
.ecard .e-fmt { font-size:10.5px; color:var(--text-dim); font-family:var(--mono); }
|
|
3417
|
+
.ecard .e-meta { font-size:11px; color:var(--text-muted); display:flex; align-items:center;
|
|
3418
|
+
justify-content:space-between; gap:8px; min-height:16px; }
|
|
3419
|
+
.ecard .e-status { color:var(--text-dim); }
|
|
3420
|
+
.ecard .e-done .chk { color:var(--ok); } /* the ✓ */
|
|
3421
|
+
.ecard .e-done .t { color:var(--text-muted); font-family:var(--mono); } /* HH:MM — the scannable datum */
|
|
3422
|
+
.ecard .e-links { display:flex; align-items:center; gap:8px; flex:none; }
|
|
3423
|
+
.ecard .e-link { background:none; border:none; padding:0; font:inherit; font-size:11px; color:var(--text-muted); cursor:pointer; }
|
|
3424
|
+
.ecard .e-link:hover { color:var(--accent); }
|
|
3425
|
+
.ecard .e-link + .e-link { border-left:1px solid var(--border); padding-left:8px; } /* thin divider, keeps them tertiary */
|
|
3426
|
+
.ecard .e-act { margin-top:auto; }
|
|
3427
|
+
.ecard .e-act button { width:100%; padding:7px 10px; font-size:12px; border-radius:5px; font-family:var(--ui);
|
|
3428
|
+
background:var(--surface-2); border:1px solid var(--border-strong); color:var(--text-muted); cursor:pointer; transition:all .15s; }
|
|
3429
|
+
.ecard .e-act button:hover:not(:disabled) { color:var(--text); border-color:var(--accent-dim); background:var(--surface); }
|
|
3430
|
+
.ecard .e-act button:disabled { opacity:0.6; cursor:not-allowed; }
|
package/dist/web/index.html
CHANGED
|
@@ -167,11 +167,15 @@
|
|
|
167
167
|
<div class="tabs ws-step-tabs" id="ws-step-tabs" role="tablist" aria-label="Project steps">
|
|
168
168
|
<button type="button" data-step="drawings" role="tab" aria-selected="false">Drawings</button>
|
|
169
169
|
<button type="button" data-step="model" class="active" role="tab" aria-selected="true">Model</button>
|
|
170
|
+
<button type="button" data-step="exports" role="tab" aria-selected="false">Exports</button>
|
|
170
171
|
</div>
|
|
171
172
|
<!-- Two lazily-srced iframes so switching steps never reloads the editor's 3D state.
|
|
172
173
|
Same-origin like #contract-editor-frame (they call /api/contract directly). -->
|
|
173
174
|
<iframe id="ws-frame-model" class="ws-frame" title="Project model editor" hidden></iframe>
|
|
174
175
|
<iframe id="ws-frame-drawings" class="ws-frame" title="Project drawings & filter" hidden></iframe>
|
|
176
|
+
<!-- Exports = a SHELL-rendered pane (not an iframe): export cards over the project's own
|
|
177
|
+
contract. workspaces.js fills it on step-switch (renderExports). -->
|
|
178
|
+
<div class="ws-exports" id="ws-exports" hidden></div>
|
|
175
179
|
</div>
|
|
176
180
|
<div class="hint" id="canvas-hint">Click any node to inspect. Star ★ a node to save it as a reusable Template. Drag the background to pan — or press Home to fit.</div>
|
|
177
181
|
<div class="fav-bar" id="fav-bar">
|
|
@@ -848,7 +848,12 @@ function fitCamera(box, dir) {
|
|
|
848
848
|
perspCam.position.copy(c).addScaledVector(d, dist);
|
|
849
849
|
orthoCam.position.copy(c).addScaledVector(d, dist);
|
|
850
850
|
const r = box.getBoundingSphere(new THREE.Sphere()).radius || dist;
|
|
851
|
-
const near = Math.max(dist / 2000, 0.5)
|
|
851
|
+
const near = Math.max(dist / 2000, 0.5); // small near so the wheel can zoom right up to a connection detail without clipping
|
|
852
|
+
// far must always clear the WHOLE model, not just the framed box — else a tight detail-zoom (dbl-click) puts the
|
|
853
|
+
// rest of the model behind the far plane and slices it with a view-perpendicular cut that reads as a skewed
|
|
854
|
+
// section. Size far from the scene bounds seen from the final camera position; the fit box only sets distance/zoom.
|
|
855
|
+
const sceneSph = sceneBox.getBoundingSphere(new THREE.Sphere());
|
|
856
|
+
const far = Math.max(dist + r * 4, perspCam.position.distanceTo(sceneSph.center) + sceneSph.radius) * 1.02;
|
|
852
857
|
perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
|
|
853
858
|
orthoCam.near = near; orthoCam.far = far; orthoCam.zoom = 1;
|
|
854
859
|
orthoBaseH = Math.max(extU, extR / aspect) * 1.15; // tight, aspect-preserving box fit (no ortho under-frame)
|
|
@@ -915,13 +920,25 @@ function applyDisplayMode() {
|
|
|
915
920
|
mat.needsUpdate = true;
|
|
916
921
|
}
|
|
917
922
|
}
|
|
923
|
+
// A work area in "show whole parts" mode acts as a spatial filter: a part whose world AABB touches the box shows
|
|
924
|
+
// in FULL (never sliced), a part entirely outside hides. Only evaluated when that mode is active (setFromObject
|
|
925
|
+
// per mesh is cheap here — this runs on toggles, not per frame).
|
|
926
|
+
const _waBox = new THREE.Box3();
|
|
927
|
+
function meshInWorkArea(m) {
|
|
928
|
+
if (!(workArea && workArea.enabled && workArea.whole)) return true;
|
|
929
|
+
_waBox.setFromObject(m); return !_waBox.isEmpty() && workArea.box.intersectsBox(_waBox);
|
|
930
|
+
}
|
|
931
|
+
// A mesh's visibility EXCEPT the work-area whole-parts filter — i.e. only the group/solo/isolate/legend hides.
|
|
932
|
+
// Split out so "Define from selection" can still bound a part that's hidden ONLY by the current work area (the
|
|
933
|
+
// new box replaces that area), and so applyGroupVisibility reads as one line. connHidden = per-part legend hide.
|
|
934
|
+
function visibleIgnoringWorkArea(m) {
|
|
935
|
+
const k = m.userData && m.userData.group;
|
|
936
|
+
const byGroup = !groupHidden.has(k) && (soloGroups.size === 0 || soloGroups.has(k));
|
|
937
|
+
const byIso = isolatedIds === null || isolatedIds.has(m.userData.id); // isolate-selected: only the isolated ids show
|
|
938
|
+
return byGroup && byIso && !connHidden.has(m.userData.id);
|
|
939
|
+
}
|
|
918
940
|
function applyGroupVisibility() {
|
|
919
|
-
for (const m of meshById.values())
|
|
920
|
-
const k = m.userData && m.userData.group;
|
|
921
|
-
const byGroup = !groupHidden.has(k) && (soloGroups.size === 0 || soloGroups.has(k));
|
|
922
|
-
const byIso = isolatedIds === null || isolatedIds.has(m.userData.id); // isolate-selected: only the isolated ids show
|
|
923
|
-
m.visible = byGroup && byIso && !connHidden.has(m.userData.id); // connHidden: per-part legend hide (connection rows split a shared part-kind by connection)
|
|
924
|
-
}
|
|
941
|
+
for (const m of meshById.values()) m.visible = visibleIgnoringWorkArea(m) && meshInWorkArea(m); // meshInWorkArea: work-area whole-parts spatial filter
|
|
925
942
|
}
|
|
926
943
|
function toggleGroup(k) { if (groupHidden.has(k)) groupHidden.delete(k); else groupHidden.add(k); soloGroups.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); }
|
|
927
944
|
// Hide/show a SET of groups in one pass (a legend type-category master toggle) — deterministic (not a flip),
|
|
@@ -961,7 +978,7 @@ const CLIP_PLANE_COLOR = 0x3b82f6, CLIP_BOX_COLOR = 0x93c5fd; // brand blue (p
|
|
|
961
978
|
function applyClips() {
|
|
962
979
|
if (!renderer) return;
|
|
963
980
|
const active = clips.filter((c) => c.enabled).flatMap((c) => c.planes);
|
|
964
|
-
if (workArea && workArea.enabled) active.push(...workArea.planes); // the work area sections the view too
|
|
981
|
+
if (workArea && workArea.enabled && !workArea.whole) active.push(...workArea.planes); // the work area sections the view too — unless it's in "show whole parts" mode (parts are hidden/shown whole via applyGroupVisibility, never sliced)
|
|
965
982
|
renderer.clippingPlanes = active.length ? active : EMPTY_CLIPS;
|
|
966
983
|
}
|
|
967
984
|
// 6 inward planes (keep INSIDE) for an axis-aligned Box3 → a section/clip box.
|
|
@@ -1046,7 +1063,7 @@ function clipState() {
|
|
|
1046
1063
|
clips: clips.map((c) => c.kind === 'box'
|
|
1047
1064
|
? { id: c.id, kind: 'box', enabled: c.enabled, label: c.label, box: { min: c.box.min.toArray(), max: c.box.max.toArray() } }
|
|
1048
1065
|
: { id: c.id, kind: 'plane', enabled: c.enabled, label: c.label, n: c.n.toArray(), point: c.point.toArray() }),
|
|
1049
|
-
workArea: workArea ? { enabled: workArea.enabled, box: { min: workArea.box.min.toArray(), max: workArea.box.max.toArray() } } : null,
|
|
1066
|
+
workArea: workArea ? { enabled: workArea.enabled, whole: !!workArea.whole, box: { min: workArea.box.min.toArray(), max: workArea.box.max.toArray() } } : null,
|
|
1050
1067
|
selected: [...selectedClipIds], seq: clipSeq,
|
|
1051
1068
|
};
|
|
1052
1069
|
}
|
|
@@ -1058,12 +1075,13 @@ function setClipState(s) {
|
|
|
1058
1075
|
: { id: d.id, kind: 'plane', enabled: d.enabled, label: d.label, n: new THREE.Vector3(...d.n), point: new THREE.Vector3(...d.point), planes: [] };
|
|
1059
1076
|
rebuildClipPlanes(c); return c;
|
|
1060
1077
|
});
|
|
1061
|
-
if (s.workArea) { const b = new THREE.Box3(new THREE.Vector3(...s.workArea.box.min), new THREE.Vector3(...s.workArea.box.max)); workArea = { enabled: s.workArea.enabled, box: b, planes: boxToPlanes(b) }; }
|
|
1078
|
+
if (s.workArea) { const b = new THREE.Box3(new THREE.Vector3(...s.workArea.box.min), new THREE.Vector3(...s.workArea.box.max)); workArea = { enabled: s.workArea.enabled, whole: s.workArea.whole !== false, box: b, planes: boxToPlanes(b) }; } // whole !== false → older snapshots (no field) restore as show-whole
|
|
1062
1079
|
else workArea = null;
|
|
1063
1080
|
selectedClipIds = new Set(s.selected || []);
|
|
1064
1081
|
if (typeof s.seq === 'number') clipSeq = Math.max(clipSeq, s.seq);
|
|
1065
|
-
applyClips(); renderClipGizmo(); renderWorkArea();
|
|
1082
|
+
applyClips(); renderClipGizmo(); renderWorkArea(); refreshWorkAreaVis();
|
|
1066
1083
|
if (api && api.onClipsChange) api.onClipsChange();
|
|
1084
|
+
if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState()); // undo/redo of a work-area edit must resync the Work-area button + its sliders, not just the model
|
|
1067
1085
|
}
|
|
1068
1086
|
|
|
1069
1087
|
// ---- clip gizmo: the selected clip's outline + draggable handles (a plane move-arrow, or 6 box face handles) ----
|
|
@@ -1237,29 +1255,41 @@ function renderWorkArea() {
|
|
|
1237
1255
|
workAreaHelper.material.depthTest = false; workAreaHelper.renderOrder = 995; // unclipped overlay pass keeps it visible through any clip
|
|
1238
1256
|
overlayScene.add(workAreaHelper);
|
|
1239
1257
|
}
|
|
1258
|
+
function refreshWorkAreaVis() { applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); } // whole-parts mode hides/shows whole meshes → recompute visibility + dependent dims/endpoints (same trio the legend toggles use)
|
|
1240
1259
|
function setWorkAreaBox(box) {
|
|
1241
1260
|
if (!box || box.isEmpty()) return false;
|
|
1242
1261
|
if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
|
|
1243
|
-
|
|
1244
|
-
|
|
1262
|
+
const whole = workArea ? workArea.whole : true; // keep the current cut/whole mode across a re-define; a brand-new work area defaults to "show whole parts" (no surprise slicing)
|
|
1263
|
+
workArea = { enabled: true, whole, planes: boxToPlanes(box), box: box.clone() };
|
|
1264
|
+
applyClips(); renderWorkArea(); refreshWorkAreaVis();
|
|
1245
1265
|
if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
|
|
1246
1266
|
return true;
|
|
1247
1267
|
}
|
|
1248
1268
|
function workAreaSetAll() { return setWorkAreaBox(sceneBox.clone()); } // "set work area to all objects" = model bounds
|
|
1249
1269
|
function workAreaFromSelection(pad = 150) { // define a new work area around the selection
|
|
1250
|
-
const box = new THREE.Box3(); for (const id of selIds) { const m = meshById.get(id); if (m && m
|
|
1270
|
+
const box = new THREE.Box3(); for (const id of selIds) { const m = meshById.get(id); if (m && visibleIgnoringWorkArea(m)) box.expandByObject(m); } // include a part hidden ONLY by the current work area — the new box supersedes it (a group/isolate-hidden part is still excluded)
|
|
1251
1271
|
if (box.isEmpty()) return false; box.expandByScalar(pad); return setWorkAreaBox(box);
|
|
1252
1272
|
}
|
|
1253
1273
|
function workAreaToggle(on) {
|
|
1254
1274
|
if (!workArea) return on === false ? false : workAreaSetAll(); // first toggle-on with no box → bound the whole model
|
|
1255
1275
|
if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
|
|
1256
1276
|
workArea.enabled = on === undefined ? !workArea.enabled : !!on;
|
|
1257
|
-
applyClips(); renderWorkArea();
|
|
1277
|
+
applyClips(); renderWorkArea(); refreshWorkAreaVis();
|
|
1258
1278
|
if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
|
|
1259
1279
|
return workArea.enabled;
|
|
1260
1280
|
}
|
|
1261
|
-
|
|
1262
|
-
|
|
1281
|
+
// "Show whole parts" toggle: whole → a part touching the box shows in full (nothing sliced); cut → hard planar
|
|
1282
|
+
// section at the box faces (the Tekla "cut parts by work area" behaviour). No-op without a work area.
|
|
1283
|
+
function workAreaSetWhole(on) {
|
|
1284
|
+
if (!workArea) return false;
|
|
1285
|
+
if (api && api.beginClipEdit) api.beginClipEdit(); // undoable
|
|
1286
|
+
workArea.whole = on === undefined ? !workArea.whole : !!on;
|
|
1287
|
+
applyClips(); refreshWorkAreaVis();
|
|
1288
|
+
if (api && api.onWorkAreaChange) api.onWorkAreaChange(workAreaState());
|
|
1289
|
+
return workArea.whole;
|
|
1290
|
+
}
|
|
1291
|
+
function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); refreshWorkAreaVis(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
|
|
1292
|
+
function workAreaState() { return workArea ? { on: workArea.enabled, whole: !!workArea.whole } : null; }
|
|
1263
1293
|
|
|
1264
1294
|
function frameAll() { fitCamera(sceneBox); }
|
|
1265
1295
|
const VIEWS = { top: [0, 0, 1], bottom: [0, 0, -1], front: [0, -1, 0], back: [0, 1, 0], right: [1, 0, 0], left: [-1, 0, 0], iso: [0.55, -0.8, 0.5] };
|
|
@@ -1469,7 +1499,8 @@ function endpointUnseen(p) {
|
|
|
1469
1499
|
return true;
|
|
1470
1500
|
}
|
|
1471
1501
|
function dimHiddenByIsolation(d) {
|
|
1472
|
-
|
|
1502
|
+
const waHides = !!(workArea && workArea.enabled && workArea.whole); // a whole-parts work area hides meshes too → a placed dim on a hidden part must drop, not float
|
|
1503
|
+
if (isolatedIds === null && soloGroups.size === 0 && groupHidden.size === 0 && !waHides) return false; // nothing hidden → every dim shows
|
|
1473
1504
|
return endpointUnseen(d.a) || endpointUnseen(d.b);
|
|
1474
1505
|
}
|
|
1475
1506
|
function refreshDims() {
|
|
@@ -2744,7 +2775,7 @@ window.Steel3DView = {
|
|
|
2744
2775
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
2745
2776
|
setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
|
|
2746
2777
|
isolateSelected, clearIsolation, isIsolated,
|
|
2747
|
-
workAreaSetAll, workAreaFromSelection, workAreaToggle, clearWorkArea, workAreaState,
|
|
2778
|
+
workAreaSetAll, workAreaFromSelection, workAreaToggle, workAreaSetWhole, clearWorkArea, workAreaState,
|
|
2748
2779
|
armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
|
|
2749
2780
|
cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
|
|
2750
2781
|
drClear3d, drEscape: () => { if (drDraft) { drDraft = null; drClear3d(); return true; } return false; },
|
|
@@ -126,10 +126,12 @@
|
|
|
126
126
|
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) */
|
|
127
127
|
#moreMenu button.msnap{display:flex;align-items:center;gap:0}
|
|
128
128
|
#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) */
|
|
129
|
-
#moreMenu .mck,.cmmenu .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
|
|
130
|
-
#moreMenu .mck::after,.cmmenu .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}
|
|
131
|
-
#moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
|
|
132
|
-
#moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after{transform:translateX(12px);background:var(--brand)}
|
|
129
|
+
#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 */
|
|
130
|
+
#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}
|
|
131
|
+
#moreMenu button.msnap.on .mck,.cmmenu #dragMoveB.on .mck,.m3dmenu button.wtog.on .mck{background:rgba(59,130,246,.28);border-color:var(--brand)}
|
|
132
|
+
#moreMenu button.msnap.on .mck::after,.cmmenu #dragMoveB.on .mck::after,.m3dmenu button.wtog.on .mck::after{transform:translateX(12px);background:var(--brand)}
|
|
133
|
+
.m3dmenu button.wtog{display:flex;align-items:center;justify-content:flex-start;gap:0}
|
|
134
|
+
.m3dmenu button.wtog.on{color:var(--text)} /* the slider carries the on-state — don't also brand the label text */
|
|
133
135
|
#moreMenu button.msnap .sg{display:inline-block;width:17px;color:#22d3ee;opacity:.5;flex:none;transition:opacity .15s}
|
|
134
136
|
#moreMenu button.msnap.on .sg{opacity:1}
|
|
135
137
|
/* Quick-access snap bar — always-expanded row of glyph toggles, bottom-right of the canvas (both views); reuses the brand-fill "on" toolbar language */
|
|
@@ -308,7 +310,7 @@
|
|
|
308
310
|
.m3dmenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap;font-size:12px;box-shadow:none}
|
|
309
311
|
.m3dmenu button:hover{background:#334155}
|
|
310
312
|
.m3dmenu button.on{color:var(--brand)} /* active choice in a radio-style menu (Camera / Display) */
|
|
311
|
-
.m3dmenu button.on::after{content:'✓';float:right;margin-left:16px;color:var(--brand)}
|
|
313
|
+
.m3dmenu button.on:not(.wtog)::after{content:'✓';float:right;margin-left:16px;color:var(--brand)} /* radio-style tick for Camera/Display choices — NOT the .wtog slider toggles (their .mck knob carries state) */
|
|
312
314
|
.m3dmenu button:disabled{opacity:.4;cursor:default;background:transparent}
|
|
313
315
|
.m3dmenu button.mdanger{color:#fca5a5} .m3dmenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
|
|
314
316
|
.m3dmenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
|
|
@@ -347,6 +349,10 @@
|
|
|
347
349
|
#propPop .ppfoot .seg2{margin-top:0}
|
|
348
350
|
#propPop .ppfoot label{display:flex;align-items:center;gap:7px;color:var(--text);cursor:pointer;font-size:11px}
|
|
349
351
|
#propPop .ppfoot label input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto}
|
|
352
|
+
/* Connection read-only mode: no labeling, so hide the label footer + Clear-all; rows aren't clickable. */
|
|
353
|
+
#propPop.connmode .ppfoot{display:none}
|
|
354
|
+
#propPop.connmode #ppClear{display:none}
|
|
355
|
+
#propPop.connmode .pprow{cursor:default;padding-left:14px} /* no checkbox → nudge the name so it doesn't float where the box was */
|
|
350
356
|
/* thin cluster divider in the 3D toolbar */
|
|
351
357
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
352
358
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -569,7 +575,8 @@
|
|
|
569
575
|
<button data-wa=all data-tip="Bound the work area to the whole model">Set to all objects</button>
|
|
570
576
|
<button data-wa=sel data-tip="Bound the work area to the current selection">Define from selection</button>
|
|
571
577
|
<hr>
|
|
572
|
-
<
|
|
578
|
+
<button id=m3dWorkOn class=wtog role=menuitemcheckbox aria-checked=false data-tip="Show or hide the work-area box"><span class=mck aria-hidden=true></span>Show work area</button>
|
|
579
|
+
<button id=m3dWorkWhole class=wtog role=menuitemcheckbox aria-checked=true style=display:none data-tip="When ON, any part that touches the work area is shown in full — nothing gets cut. When OFF, the work area slices parts cleanly at its box faces (a section cut)."><span class=mck aria-hidden=true></span>Show whole parts</button>
|
|
573
580
|
</div>
|
|
574
581
|
</div>
|
|
575
582
|
</div>
|
|
@@ -2015,6 +2022,7 @@ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabel
|
|
|
2015
2022
|
|
|
2016
2023
|
// ---- The floating Properties popup ----
|
|
2017
2024
|
let propPopPinned=false;
|
|
2025
|
+
let propPopConn=null; // non-null = read-only Connection mode (a connection selected → connPropRows); null = the member label-picker mode
|
|
2018
2026
|
function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
|
|
2019
2027
|
function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
2020
2028
|
el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
|
|
@@ -2029,8 +2037,8 @@ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
|
2029
2037
|
+`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
|
|
2030
2038
|
document.body.appendChild(el);
|
|
2031
2039
|
const list=el.querySelector('#ppList');
|
|
2032
|
-
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis'))return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);});
|
|
2033
|
-
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row)togglePropKey(row.dataset.k,cb.checked);});
|
|
2040
|
+
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // conn-mode rows carry no data-k → not toggleable
|
|
2041
|
+
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row&&row.dataset.k)togglePropKey(row.dataset.k,cb.checked);});
|
|
2034
2042
|
el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
|
|
2035
2043
|
el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
|
|
2036
2044
|
el.querySelector('#ppClose').onclick=()=>closePropPop(true);
|
|
@@ -2050,6 +2058,9 @@ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(
|
|
|
2050
2058
|
refreshPropLabels();}
|
|
2051
2059
|
// rebuild the popup contents against the current selection (chrome + rows), preserving row focus
|
|
2052
2060
|
function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
|
|
2061
|
+
if(propPopPinned){const cs=connSelInfo();if(cs)propPopConn=cs;else if(selArr().length)propPopConn=null;} // a PINNED popup follows the selection across member ↔ connection (an unpinned one re-asserts its mode on each right-click)
|
|
2062
|
+
el.classList.toggle('connmode',!!propPopConn);
|
|
2063
|
+
if(propPopConn){renderConnProps(el);return;} // read-only Connection view
|
|
2053
2064
|
const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
2054
2065
|
el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
|
|
2055
2066
|
el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
|
|
@@ -2069,12 +2080,52 @@ function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el
|
|
|
2069
2080
|
if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
|
|
2070
2081
|
function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
|
|
2071
2082
|
el.style.left=Math.max(4,st.right-el.offsetWidth-12)+'px';el.style.top=(st.top+12)+'px';}
|
|
2072
|
-
function openPropLabels(x,y){if(!selArr().length)return;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2083
|
+
function openPropLabels(x,y){if(!selArr().length)return;propPopConn=null;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2073
2084
|
sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
|
|
2074
2085
|
el.classList.add('open');renderPropPop();
|
|
2075
2086
|
if(propPopPinned){dockPropPop();}
|
|
2076
2087
|
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
2077
2088
|
setTimeout(()=>sr.focus(),0);}
|
|
2089
|
+
// ---- Connection read-only Properties popup (right-click a selected connection). Reuses #propPop in
|
|
2090
|
+
// `connmode`: the connection's props as read-only name/value rows, label controls hidden. Labeling
|
|
2091
|
+
// connection props onto the model is a member-keyed pipeline → deferred; this is a VIEW, not a picker. ----
|
|
2092
|
+
function connPropRows(cs){
|
|
2093
|
+
const j=cs.joint,isBP=cs.kind==='base-plate',pp=j.params||{};
|
|
2094
|
+
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
2095
|
+
const dim=mm=>(mm==null?'—':fmtFtIn(Number(mm)/25.4));
|
|
2096
|
+
const cols=pp.boltCols||(isBP?2:1),rows=pp.boltRows||(isBP?2:3);
|
|
2097
|
+
return [
|
|
2098
|
+
{label:'Type', value:isBP?'Base plate':'Shear plate'},
|
|
2099
|
+
{label:'On member', value:cs.main},
|
|
2100
|
+
{label:'Plate width', value:plate?dim(plate.width):'—'},
|
|
2101
|
+
{label:'Plate height', value:plate?dim(plate.depth):'—'},
|
|
2102
|
+
{label:'Plate thickness', value:plate?dim(plate.thickness):dim(pp[isBP?'thickness':'plateThickness'])},
|
|
2103
|
+
{label:'Weld leg', value:dim(pp.weldLeg||(isBP?8:6))},
|
|
2104
|
+
{label:isBP?'Anchor grid':'Bolt grid', value:cols+' × '+rows},
|
|
2105
|
+
{label:isBP?'Anchor diameter':'Bolt diameter', value:dim(pp.boltDia||(isBP?24:20))},
|
|
2106
|
+
{label:isBP?'Anchor count':'Bolt count', value:String(cols*rows)},
|
|
2107
|
+
{label:'Parts', value:String(cs.childIds.length)},
|
|
2108
|
+
];}
|
|
2109
|
+
function openConnProps(x,y,cs){propPopConn=cs;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
2110
|
+
sr.value='';el.classList.add('open');renderPropPop();
|
|
2111
|
+
if(propPopPinned){dockPropPop();}
|
|
2112
|
+
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
2113
|
+
setTimeout(()=>sr.focus(),0);}
|
|
2114
|
+
// render the connection read-view; re-derives the connection each call so it self-heals if the selection moves
|
|
2115
|
+
function renderConnProps(el){
|
|
2116
|
+
const cs=connSelInfo();
|
|
2117
|
+
if(!cs){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
|
|
2118
|
+
el.querySelector('#ppTitle').textContent='Properties';el.querySelector('#ppLabeled').textContent='';
|
|
2119
|
+
el.querySelector('#ppScope').textContent='Right-click a connection';el.querySelector('#ppMeta').textContent='';
|
|
2120
|
+
el.querySelector('#ppList').innerHTML='<div class=ppempty>No connection selected.</div>';return; }
|
|
2121
|
+
propPopConn=cs;
|
|
2122
|
+
const q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
2123
|
+
el.querySelector('#ppTitle').textContent='Properties · '+(cs.kind==='base-plate'?'Base plate':'Shear plate');
|
|
2124
|
+
el.querySelector('#ppLabeled').textContent='';
|
|
2125
|
+
el.querySelector('#ppScope').textContent='On '+cs.main;
|
|
2126
|
+
const all=connPropRows(cs),rows=all.filter(r=>!q||r.label.toLowerCase().includes(q));
|
|
2127
|
+
el.querySelector('#ppList').innerHTML=rows.length?rows.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join(''):'<div class=ppempty>No properties match your search.</div>';
|
|
2128
|
+
el.querySelector('#ppMeta').textContent=rows.length+' of '+all.length+' shown';}
|
|
2078
2129
|
function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
|
|
2079
2130
|
const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
|
|
2080
2131
|
document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
|
|
@@ -2119,7 +2170,9 @@ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDef
|
|
|
2119
2170
|
document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
|
|
2120
2171
|
if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
|
|
2121
2172
|
if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
|
|
2122
|
-
if(mode==='sel'&&!cmTool&&!picking
|
|
2173
|
+
if(mode==='sel'&&!cmTool&&!picking){const cs=connSelInfo();
|
|
2174
|
+
if(cs){openConnProps(e.clientX,e.clientY,cs);return;} // a connection selected → its read-only Properties popup
|
|
2175
|
+
if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members selected → the member label-picker popup
|
|
2123
2176
|
});
|
|
2124
2177
|
document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
|
|
2125
2178
|
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
@@ -3091,10 +3144,14 @@ function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!wi
|
|
|
3091
3144
|
b.classList.toggle('on',iso);
|
|
3092
3145
|
b.textContent=iso?'Show all':'Isolate';
|
|
3093
3146
|
b.setAttribute('data-tip', iso?'Restore all hidden members':'Isolate selected — hide everything else (Esc to exit)');} // themed tooltip, updated with state (no native title)
|
|
3094
|
-
// The Work area button + its
|
|
3095
|
-
|
|
3147
|
+
// The Work area button + its two slider toggles reflect the live work-area state (api.onWorkAreaChange).
|
|
3148
|
+
// "Show work area" = box drawn / sectioning on; "Show whole parts" = touching parts shown whole vs cut at the box.
|
|
3149
|
+
// The whole-parts row only appears once a work area exists (nothing to act on before that — no dimmed dead control).
|
|
3150
|
+
function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.getElementById('m3dWorkOn'),wh=document.getElementById('m3dWorkWhole');if(!b||!window.Steel3DView||!window.Steel3DView.workAreaState)return;
|
|
3096
3151
|
const st=window.Steel3DView.workAreaState(),on=!!(st&&st.on);
|
|
3097
|
-
b.classList.toggle('on',on);
|
|
3152
|
+
b.classList.toggle('on',on);
|
|
3153
|
+
if(ck){ck.classList.toggle('on',on);ck.setAttribute('aria-checked',String(on));}
|
|
3154
|
+
if(wh){wh.style.display=st?'':'none';wh.classList.toggle('on',!!(st&&st.whole));wh.setAttribute('aria-checked',String(!!(st&&st.whole)));}}
|
|
3098
3155
|
// A type category's master toggle reads its profiles' hide state: all visible = ■ on, all hidden = □ off,
|
|
3099
3156
|
// some = ◪ mixed. Colour comes from data-state (CSS); the glyph from the textContent.
|
|
3100
3157
|
function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
|
|
@@ -3178,7 +3235,8 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
3178
3235
|
function workMenuClose(){workMenu.classList.remove('open');document.removeEventListener('mousedown',workMenuOutside,true);}
|
|
3179
3236
|
workBtn.onclick=e=>{e.stopPropagation();if(workMenu.classList.contains('open'))workMenuClose();else{updateWorkBtn();workMenu.classList.add('open');document.addEventListener('mousedown',workMenuOutside,true);}};
|
|
3180
3237
|
workMenu.querySelectorAll('button[data-wa]').forEach(b=>b.onclick=()=>{workMenuClose();const a=b.dataset.wa;if(a==='all')d3.workAreaSetAll();else if(a==='sel'){if(!d3.workAreaFromSelection())d3.workAreaSetAll();}}); // define from selection; fall back to whole model if nothing's selected
|
|
3181
|
-
document.getElementById('m3dWorkOn').
|
|
3238
|
+
document.getElementById('m3dWorkOn').onclick=()=>d3.workAreaToggle(); // flip box on/off (creates one bound to the whole model on first turn-on); onWorkAreaChange refreshes the slider
|
|
3239
|
+
document.getElementById('m3dWorkWhole').onclick=()=>d3.workAreaSetWhole(); // flip show-whole vs cut-at-boundary; stays open so the state flip is visible
|
|
3182
3240
|
// Working plane: ◇ Plane menu (face pick / 3 points / principal+offset / show / reset). While a pick
|
|
3183
3241
|
// mode is armed the button is a cancel target (reflectWpBar renders '✕' — same pattern as Clip).
|
|
3184
3242
|
const wpBtn=document.getElementById('m3dWp'),wpMenu=document.getElementById('m3dWpMenu');
|
package/dist/web/workspaces.js
CHANGED
|
@@ -30,10 +30,22 @@
|
|
|
30
30
|
const $status = document.getElementById('ws-status');
|
|
31
31
|
const $stepTabs = document.getElementById('ws-step-tabs');
|
|
32
32
|
const $projMenu = document.getElementById('ws-proj-menu');
|
|
33
|
+
const $wsExports = document.getElementById('ws-exports');
|
|
33
34
|
const frames = {
|
|
34
35
|
model: document.getElementById('ws-frame-model'),
|
|
35
36
|
drawings: document.getElementById('ws-frame-drawings'),
|
|
36
37
|
};
|
|
38
|
+
|
|
39
|
+
// The Exports step's cards. `file:true` = writes a file on disk (shown with a "✓ exported HH:MM"
|
|
40
|
+
// line + ⧉ Open / ▤ Reveal); `open:false` (IFC) = reveal-only (no OS default app for a .ifc).
|
|
41
|
+
// Tekla is an ACTION (host mutation, no file). `kind` matches the server's export route + listing.
|
|
42
|
+
const EXPORT_CARDS = [
|
|
43
|
+
{ kind: 'bom-csv', ico: '▤', name: 'Bill of materials', fmt: 'CSV', verb: 'Export CSV', file: true, open: true },
|
|
44
|
+
{ kind: 'bom-xlsx', ico: '▦', name: 'Bill of materials', fmt: 'XLSX', verb: 'Export Excel', file: true, open: true },
|
|
45
|
+
{ kind: 'ifc', ico: '◈', name: 'Model', fmt: 'IFC4', verb: 'Export IFC', file: true, open: false },
|
|
46
|
+
{ kind: 'tekla', ico: '◧', name: 'Tekla', fmt: '.ifc → Open API', verb: 'Send to Tekla', file: false },
|
|
47
|
+
];
|
|
48
|
+
let exportState = null; // kind → { path, exportedAt } after a successful list; null = unknown/failed
|
|
37
49
|
if (!$switch || !$landing || !$app) return; // markup absent — nothing to wire
|
|
38
50
|
|
|
39
51
|
// The picker menu is a `.menu` (display:none by default; `.menu.show` → display:block, which
|
|
@@ -185,6 +197,10 @@
|
|
|
185
197
|
f.hidden = !show;
|
|
186
198
|
if (show && !f.dataset.loaded) { f.src = f.dataset.want; f.dataset.loaded = '1'; }
|
|
187
199
|
}
|
|
200
|
+
// Exports is a shell-rendered pane (not an iframe): show/hide + (re)paint on open.
|
|
201
|
+
const showExports = s === 'exports';
|
|
202
|
+
$wsExports.hidden = !showExports;
|
|
203
|
+
if (showExports) renderExports();
|
|
188
204
|
}
|
|
189
205
|
$stepTabs.addEventListener('click', (e) => {
|
|
190
206
|
const b = e.target.closest('button[data-step]');
|
|
@@ -192,7 +208,10 @@
|
|
|
192
208
|
});
|
|
193
209
|
|
|
194
210
|
// ── Approve (per-project) ───────────────────────────────────────────────────
|
|
195
|
-
|
|
211
|
+
// Shared by the header ✓ Approve and the Exports-tab gate link ("Approve the model"), so the
|
|
212
|
+
// sign-off is one flow. On success it stamps current.approvedAt (server returns it) so the
|
|
213
|
+
// Exports gate unlocks without a reload.
|
|
214
|
+
async function approveProject() {
|
|
196
215
|
if (!current) return;
|
|
197
216
|
const btn = document.getElementById('ws-approve');
|
|
198
217
|
btn.disabled = true;
|
|
@@ -204,11 +223,141 @@
|
|
|
204
223
|
// fires. flushContract() PUTs the live contract and resolves; a save failure aborts the bake.
|
|
205
224
|
const win = frames.model && frames.model.contentWindow;
|
|
206
225
|
if (win && typeof win.flushContract === 'function') await win.flushContract();
|
|
207
|
-
await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
226
|
+
const res = await api(`/api/contract/${encodeURIComponent(current.app)}/approve?project=${encodeURIComponent(current.id)}`, { method: 'POST' });
|
|
227
|
+
if (res && res.approvedAt) current.approvedAt = res.approvedAt; // unlock exports
|
|
208
228
|
showToast(`Approved — "${current.name}" is baked into ${current.app}`, 'ok');
|
|
229
|
+
if (!$wsExports.hidden) renderExports(); // reflect the unlocked gate immediately if it's open
|
|
209
230
|
} catch (err) {
|
|
210
231
|
showToast('Approve failed: ' + (err && err.message || err), 'warn');
|
|
211
232
|
} finally { btn.disabled = false; btn.textContent = prev; }
|
|
233
|
+
}
|
|
234
|
+
document.getElementById('ws-approve').addEventListener('click', approveProject);
|
|
235
|
+
|
|
236
|
+
// ── Exports step ──────────────────────────────────────────────────────────────
|
|
237
|
+
const hhmm = (iso) => {
|
|
238
|
+
const d = new Date(iso);
|
|
239
|
+
if (isNaN(d.getTime())) return '';
|
|
240
|
+
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// The POST export endpoint for a card kind, project-scoped so it reads THIS project's contract
|
|
244
|
+
// and writes into its own exports folder.
|
|
245
|
+
function exportUrl(kind) {
|
|
246
|
+
const qs = `?project=${encodeURIComponent(current.id)}`;
|
|
247
|
+
const base = `/api/contract/${encodeURIComponent(current.app)}`;
|
|
248
|
+
if (kind === 'bom-csv') return `${base}/export-bom/csv${qs}`;
|
|
249
|
+
if (kind === 'bom-xlsx') return `${base}/export-bom/xlsx${qs}`;
|
|
250
|
+
if (kind === 'ifc') return `${base}/export-ifc${qs}`;
|
|
251
|
+
return `${base}/export-tekla${qs}`; // tekla
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function cardHtml(c, approved) {
|
|
255
|
+
let meta;
|
|
256
|
+
if (!approved) {
|
|
257
|
+
meta = `<span class="e-status">Available after approval</span>`;
|
|
258
|
+
} else if (!c.file) {
|
|
259
|
+
meta = `<span class="e-status">Creates parts in the open Tekla model</span>`;
|
|
260
|
+
} else if (exportState == null) {
|
|
261
|
+
// The listing failed — say so honestly; never a fake "not exported yet" (silent-failure rule).
|
|
262
|
+
meta = `<span class="e-status">Status unavailable</span>`;
|
|
263
|
+
} else {
|
|
264
|
+
const st = exportState[c.kind];
|
|
265
|
+
if (st && st.exportedAt) {
|
|
266
|
+
const links = `<span class="e-links">` +
|
|
267
|
+
(c.open ? `<button type="button" class="e-link" data-openfile="${escapeAttr(c.kind)}" data-tip="Open in its default app">⧉ Open</button>` : '') +
|
|
268
|
+
`<button type="button" class="e-link" data-reveal="${escapeAttr(c.kind)}" data-tip="Show the file in its folder">▤ Reveal</button>` +
|
|
269
|
+
`</span>`;
|
|
270
|
+
meta = `<span class="e-done"><span class="chk">✓</span> exported <span class="t">${escapeHtml(hhmm(st.exportedAt))}</span></span>${links}`;
|
|
271
|
+
} else {
|
|
272
|
+
meta = `<span class="e-status">not exported yet</span>`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const exported = approved && c.file && exportState && exportState[c.kind] && exportState[c.kind].exportedAt;
|
|
276
|
+
const label = exported ? 'Re-export' : c.verb;
|
|
277
|
+
const dis = approved ? '' : ' disabled data-tip="Approve the model first"';
|
|
278
|
+
return `<div class="ecard">` +
|
|
279
|
+
`<div class="e-top"><div class="e-ico">${escapeHtml(c.ico)}</div>` +
|
|
280
|
+
`<div><div class="e-name">${escapeHtml(c.name)}</div><div class="e-fmt">${escapeHtml(c.fmt)}</div></div></div>` +
|
|
281
|
+
`<div class="e-meta">${meta}</div>` +
|
|
282
|
+
`<div class="e-act"><button type="button" data-export="${escapeAttr(c.kind)}"${dis}>↗ ${escapeHtml(label)}</button></div>` +
|
|
283
|
+
`</div>`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function renderExports() {
|
|
287
|
+
if (!current) return;
|
|
288
|
+
const id = current.id, app = current.app;
|
|
289
|
+
$wsExports.innerHTML = '<div class="exports-note">Loading exports…</div>'; // avoid a blank flash
|
|
290
|
+
let state = null;
|
|
291
|
+
let approvedAt = current.approvedAt; // fallback if the fetch fails (best-known)
|
|
292
|
+
try {
|
|
293
|
+
const r = await api(`/api/contract/${encodeURIComponent(app)}/exports?project=${encodeURIComponent(id)}`);
|
|
294
|
+
state = {};
|
|
295
|
+
for (const e of r.exports || []) state[e.kind] = e;
|
|
296
|
+
// Authoritative gate state: an in-session edit clears approvedAt server-side, so trust the
|
|
297
|
+
// server, not the possibly-stale current.approvedAt — and re-sync current to match.
|
|
298
|
+
approvedAt = r.approvedAt || undefined;
|
|
299
|
+
current.approvedAt = approvedAt;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
// Surface the load failure — don't let it read as "nothing exported yet" (a lie).
|
|
302
|
+
showToast('Couldn’t load export status: ' + (err && err.message || err), 'warn');
|
|
303
|
+
state = null;
|
|
304
|
+
}
|
|
305
|
+
// The user may have switched project/step during the await — don't paint stale.
|
|
306
|
+
if (!current || current.id !== id || $wsExports.hidden) return;
|
|
307
|
+
exportState = state;
|
|
308
|
+
const approved = !!approvedAt;
|
|
309
|
+
let html = '';
|
|
310
|
+
if (!approved) {
|
|
311
|
+
html += `<div class="ws-exports-gate">Exports go out after sign-off — ` +
|
|
312
|
+
`<button type="button" id="ws-exports-approve">Approve the model</button> to enable them.</div>`;
|
|
313
|
+
}
|
|
314
|
+
html += `<div class="exports-note"><b>Export at any stage</b> — polishing is optional. ` +
|
|
315
|
+
`Each writes to this project’s <code>exports</code> folder.</div>`;
|
|
316
|
+
html += '<div class="export-grid">' + EXPORT_CARDS.map((c) => cardHtml(c, approved)).join('') + '</div>';
|
|
317
|
+
$wsExports.innerHTML = html;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function doExport(kind) {
|
|
321
|
+
if (!current) return;
|
|
322
|
+
const btn = $wsExports.querySelector(`[data-export="${kind}"]`);
|
|
323
|
+
const prev = btn ? btn.textContent : '';
|
|
324
|
+
if (btn) { btn.disabled = true; btn.textContent = kind === 'tekla' ? 'Baking…' : 'Exporting…'; }
|
|
325
|
+
try {
|
|
326
|
+
const res = await api(exportUrl(kind), { method: 'POST' });
|
|
327
|
+
if (kind === 'tekla') {
|
|
328
|
+
const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
|
|
329
|
+
showToast((res.message || 'Baked into the Tekla model.') + (dropped ? ` · ${dropped} RFI member(s) skipped` : ''), 'ok');
|
|
330
|
+
} else {
|
|
331
|
+
showToast(`Exported — ${res.filename || kind}`, 'ok');
|
|
332
|
+
return renderExports(); // repaint with the new "✓ exported HH:MM" + Open/Reveal
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
const needsTekla = err && err.body && err.body.needsTekla;
|
|
336
|
+
showToast(err && err.message ? err.message : String(err), needsTekla ? 'info' : 'warn');
|
|
337
|
+
} finally {
|
|
338
|
+
if (btn && btn.isConnected) { btn.disabled = false; btn.textContent = prev; }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function fileAction(endpoint, kind, failMsg) {
|
|
343
|
+
const st = exportState && exportState[kind];
|
|
344
|
+
if (!st || !st.path) return; // button only shows when a path exists; belt-and-braces
|
|
345
|
+
try {
|
|
346
|
+
await api(endpoint, { method: 'POST', body: JSON.stringify({ path: st.path }) });
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const detail = err && err.body && err.body.error;
|
|
349
|
+
showToast(detail ? `${failMsg} — ${detail}` : failMsg, 'warn');
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
$wsExports.addEventListener('click', (e) => {
|
|
354
|
+
if (e.target.closest('#ws-exports-approve')) { approveProject(); return; }
|
|
355
|
+
const exp = e.target.closest('[data-export]');
|
|
356
|
+
if (exp && !exp.disabled) { doExport(exp.dataset.export); return; }
|
|
357
|
+
const open = e.target.closest('[data-openfile]');
|
|
358
|
+
if (open) { fileAction('/api/open-file', open.dataset.openfile, 'Couldn’t open the file'); return; }
|
|
359
|
+
const rev = e.target.closest('[data-reveal]');
|
|
360
|
+
if (rev) { fileAction('/api/reveal', rev.dataset.reveal, 'Couldn’t reveal the file'); return; }
|
|
212
361
|
});
|
|
213
362
|
|
|
214
363
|
// ── project picker + lifecycle (full set lives HERE; ≡ carries nothing) ────
|