@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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.72.1" : void 0,
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.1" : void 0 });
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 OPENABLE_EXTS = /* @__PURE__ */ new Set([".xls", ".xlsx", ".html", ".htm", ".csv"]);
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 OPENABLE_EXTS.has((0, import_node_path11.extname)(path).toLowerCase());
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
- if (!hasOpenableExt(path)) return { ok: false, error: "unsupported file type" };
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 touchProject(id) {
54028
+ function markApproved(id) {
54024
54029
  const meta = readMeta(safeProjectId(id));
54025
- if (meta) writeMeta({ ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
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) touchProject(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
- return { ok: true, result };
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 doc = readContractForApp(req.params.appId);
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 doc = readContract(req.params.appId);
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
- const doc = readContract(appId);
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; }
@@ -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), far = dist + r * 4; // small near so the wheel can zoom right up to a connection detail without clipping
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
- workArea = { enabled: true, planes: boxToPlanes(box), box: box.clone() };
1244
- applyClips(); renderWorkArea();
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.visible) box.expandByObject(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
- function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
1262
- function workAreaState() { return workArea ? { on: workArea.enabled } : null; }
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
- if (isolatedIds === null && soloGroups.size === 0 && groupHidden.size === 0) return false; // nothing hiddenevery dim shows
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 and the Move/Copy → Drag-to-move/copy toggle */
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
- <label data-tip="Show or hide the work-area box"><input type=checkbox id=m3dWorkOn> Show work area</label>
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&&selArr().length){openPropLabels(e.clientX,e.clientY);return;} // a selection → Properties popup
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 "Show work area" checkbox reflect whether a work area is on (api.onWorkAreaChange).
3095
- function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.getElementById('m3dWorkOn');if(!b||!window.Steel3DView||!window.Steel3DView.workAreaState)return;
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);if(ck)ck.checked=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').onchange=e=>{d3.workAreaToggle(e.target.checked);};
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');
@@ -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
- document.getElementById('ws-approve').addEventListener('click', async () => {
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) ────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.72.1",
3
+ "version": "0.72.3",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {