@floless/app 0.32.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52835,7 +52835,7 @@ function appVersion() {
52835
52835
  return resolveVersion({
52836
52836
  isSea: isSea2(),
52837
52837
  sqVersionXml: readSqVersionXml(),
52838
- define: true ? "0.32.0" : void 0,
52838
+ define: true ? "0.33.0" : void 0,
52839
52839
  pkgVersion: readPkgVersion()
52840
52840
  });
52841
52841
  }
@@ -52845,7 +52845,7 @@ function resolveChannel(s) {
52845
52845
  return "dev";
52846
52846
  }
52847
52847
  function appChannel() {
52848
- return resolveChannel({ isSea: isSea2(), define: true ? "0.32.0" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.33.0" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
@@ -53838,6 +53838,18 @@ function bakeSceneIntoApp(sourcePath, scene, agent = "viewer-3d") {
53838
53838
  doc2.setIn(["nodes", idx, "config", "scene"], scene);
53839
53839
  (0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
53840
53840
  }
53841
+ function bakeNodeConfigById(sourcePath, nodeId, patch) {
53842
+ const doc2 = (0, import_yaml5.parseDocument)((0, import_node_fs18.readFileSync)(sourcePath, "utf8"));
53843
+ if (doc2.errors.length > 0) {
53844
+ throw new Error(`node bake: source is not valid YAML: ${doc2.errors[0]?.message ?? "parse error"}`);
53845
+ }
53846
+ const seq = doc2.get("nodes");
53847
+ const items = (0, import_yaml5.isSeq)(seq) ? seq.items : [];
53848
+ const idx = items.findIndex((it) => ((0, import_yaml5.isMap)(it) ? it.toJSON() : null)?.id === nodeId);
53849
+ if (idx < 0) throw new Error(`no node with id "${nodeId}"`);
53850
+ for (const [key, value] of Object.entries(patch)) doc2.setIn(["nodes", idx, "config", key], value);
53851
+ (0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
53852
+ }
53841
53853
  function writeViewer3dApp(dir, appId, scene) {
53842
53854
  (0, import_node_fs18.mkdirSync)(dir, { recursive: true });
53843
53855
  const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
@@ -53861,6 +53873,140 @@ function writeViewer3dApp(dir, appId, scene) {
53861
53873
  bakeSceneIntoApp(flo, scene);
53862
53874
  return flo;
53863
53875
  }
53876
+ function writeIfcApp(dir, appId, scene, outputPath) {
53877
+ (0, import_node_fs18.mkdirSync)(dir, { recursive: true });
53878
+ const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
53879
+ (0, import_node_fs18.writeFileSync)(flo, [
53880
+ `app: ${appId}`,
53881
+ "version: 0.1.0",
53882
+ "display-name: Steel IFC",
53883
+ "description: IFC file written from an approved steel.takeoff/v1 contract (a companion export; the contract is the source of truth).",
53884
+ "exposes-as-agent: false",
53885
+ "requires:",
53886
+ " - ifc@0.1.x",
53887
+ "layout: linear",
53888
+ "nodes:",
53889
+ " - id: bake-ifc",
53890
+ " agent: ifc",
53891
+ " command: write",
53892
+ " config:",
53893
+ ` output-path: ${JSON.stringify(outputPath)}`,
53894
+ // the ifc agent's input key is hyphenated
53895
+ " scene: {}",
53896
+ ""
53897
+ ].join("\n"));
53898
+ bakeSceneIntoApp(flo, scene, "ifc");
53899
+ return flo;
53900
+ }
53901
+ function writeTeklaApp(dir, appId, scene, teklaVersion = "2026.0") {
53902
+ (0, import_node_fs18.mkdirSync)(dir, { recursive: true });
53903
+ const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
53904
+ (0, import_node_fs18.writeFileSync)(flo, [
53905
+ `app: ${appId}`,
53906
+ "version: 0.1.0",
53907
+ "display-name: Steel to Tekla",
53908
+ "description: Native Tekla parts created from an approved steel.takeoff/v1 contract (a companion bake; the contract is the source of truth). Needs Tekla open with a model.",
53909
+ "exposes-as-agent: false",
53910
+ "requires:",
53911
+ " - tekla@0.1.x",
53912
+ "layout: linear",
53913
+ "nodes:",
53914
+ " - id: bake-tekla",
53915
+ " agent: tekla",
53916
+ " command: bake-scene",
53917
+ " mode: write",
53918
+ " safety:",
53919
+ " snapshot: true",
53920
+ // one Tekla Undo reverts the whole bake (required: write-mode needs a safety block)
53921
+ " config:",
53922
+ ` version: ${JSON.stringify(teklaVersion)}`,
53923
+ " scene: {}",
53924
+ ""
53925
+ ].join("\n"));
53926
+ bakeSceneIntoApp(flo, scene, "tekla");
53927
+ return flo;
53928
+ }
53929
+
53930
+ // bom-format.ts
53931
+ var round1 = (n) => Math.round(n * 10) / 10;
53932
+ function contractToBom(contractInput) {
53933
+ const c = contractInput ?? {};
53934
+ const weights = c.weights ?? {};
53935
+ const agg = /* @__PURE__ */ new Map();
53936
+ for (const plan of c.plans ?? []) {
53937
+ const ptPerFt = typeof plan.pt_per_ft === "number" && plan.pt_per_ft > 0 ? plan.pt_per_ft : 12;
53938
+ for (const m of plan.members ?? []) {
53939
+ const profile = m.profile;
53940
+ if (!profile) continue;
53941
+ const plf = weights[profile];
53942
+ if (plf == null) continue;
53943
+ let lenFt;
53944
+ if (m.role === "column") {
53945
+ const tos = m.col?.tos;
53946
+ const bos = m.col?.bos;
53947
+ if (tos == null || bos == null) continue;
53948
+ lenFt = Math.abs(tos - bos) / 12;
53949
+ } else {
53950
+ const wp = m.wp;
53951
+ if (!Array.isArray(wp) || wp.length < 2) continue;
53952
+ const a = wp[0];
53953
+ const b = wp[1];
53954
+ if (!a || !b) continue;
53955
+ lenFt = Math.hypot(a[0] - b[0], a[1] - b[1]) / ptPerFt;
53956
+ }
53957
+ const g = agg.get(profile) ?? { qty: 0, lenFt: 0, plf };
53958
+ g.qty += 1;
53959
+ g.lenFt += lenFt;
53960
+ agg.set(profile, g);
53961
+ }
53962
+ }
53963
+ const rows = [...agg.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([profile, g]) => ({
53964
+ Profile: profile,
53965
+ Qty: g.qty,
53966
+ "Length (ft)": round1(g.lenFt),
53967
+ "lb/ft": g.plf,
53968
+ "Weight (lb)": Math.round(g.lenFt * g.plf)
53969
+ }));
53970
+ const totalLb = rows.reduce((t, r) => t + r["Weight (lb)"], 0);
53971
+ const totalQty = rows.reduce((t, r) => t + r.Qty, 0);
53972
+ const totalLenFt = round1(rows.reduce((t, r) => t + r["Length (ft)"], 0));
53973
+ const totalTons = round1(totalLb / 2e3);
53974
+ rows.push({
53975
+ Profile: "TOTAL",
53976
+ Qty: totalQty,
53977
+ "Length (ft)": totalLenFt,
53978
+ "lb/ft": "",
53979
+ "Weight (lb)": totalLb
53980
+ });
53981
+ return {
53982
+ title: `Bill of Materials \u2014 ${totalTons} tons (${totalLb.toLocaleString("en-US")} lb)`,
53983
+ columns: ["Profile", "Qty", "Length (ft)", "lb/ft", "Weight (lb)"],
53984
+ rows,
53985
+ totalLb,
53986
+ totalTons
53987
+ };
53988
+ }
53989
+
53990
+ // downstream-bake.ts
53991
+ function bakeDownstream(sourcePath, contract) {
53992
+ const { scene } = contractToScene(contract);
53993
+ let sceneElements = 0;
53994
+ try {
53995
+ bakeSceneIntoApp(sourcePath, scene);
53996
+ sceneElements = scene.elements.length;
53997
+ } catch (e) {
53998
+ if (!(e instanceof Error && /no viewer-3d render node/.test(e.message))) throw e;
53999
+ }
54000
+ const bom = contractToBom(contract);
54001
+ let bomRows = 0;
54002
+ try {
54003
+ bakeNodeConfigById(sourcePath, "bom", { data: bom.rows, title: bom.title, columns: bom.columns });
54004
+ bomRows = bom.rows.length;
54005
+ } catch (e) {
54006
+ if (!(e instanceof Error && /no node with id "bom"/.test(e.message))) throw e;
54007
+ }
54008
+ return { sceneElements, bomRows };
54009
+ }
53864
54010
 
53865
54011
  // app-lifecycle.ts
53866
54012
  var import_node_fs21 = require("node:fs");
@@ -54781,7 +54927,7 @@ function isGatedAwareRoute(url, method) {
54781
54927
  if (path === "/api/trigger-run") return m === "POST";
54782
54928
  if (path.startsWith("/api/app/")) return m === "PATCH" || m === "POST" || m === "DELETE";
54783
54929
  if (path.startsWith("/api/contract/")) {
54784
- return m === "POST" && (path.endsWith("/approve") || path.endsWith("/export-3d"));
54930
+ return m === "POST" && (path.endsWith("/approve") || path.endsWith("/export-3d") || path.endsWith("/export-ifc") || path.endsWith("/export-tekla"));
54785
54931
  }
54786
54932
  return AWARE_ROUTES.some((p) => path.startsWith(p));
54787
54933
  }
@@ -58194,6 +58340,7 @@ async function startServer() {
58194
58340
  const sourcePath = readApp(req.params.appId).source.path;
58195
58341
  try {
58196
58342
  bakeContractIntoApp(sourcePath, doc2);
58343
+ bakeDownstream(sourcePath, doc2);
58197
58344
  } catch (e) {
58198
58345
  return reply.status(422).send({ ok: false, error: e instanceof Error ? e.message : "bake failed" });
58199
58346
  }
@@ -58255,6 +58402,102 @@ async function startServer() {
58255
58402
  broadcast({ type: "apps-changed" });
58256
58403
  return { ok: true, report: { ...extracted, interactive: true }, skipped };
58257
58404
  });
58405
+ app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
58406
+ const doc2 = readContract(req.params.appId);
58407
+ if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
58408
+ const v = validateSteelTakeoff(doc2);
58409
+ if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
58410
+ const { scene, skipped } = contractToScene(doc2);
58411
+ if (scene.elements.length === 0) {
58412
+ return reply.status(422).send({ ok: false, error: "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)", skipped });
58413
+ }
58414
+ const companionId = `${req.params.appId}-ifc`;
58415
+ const filename = `${req.params.appId}.ifc`;
58416
+ const outPath = (0, import_node_path26.join)(appPath(companionId), filename);
58417
+ let flo;
58418
+ try {
58419
+ flo = writeIfcApp(appPath(companionId), companionId, scene, outPath);
58420
+ } catch (e) {
58421
+ app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-ifc: companion app write failed");
58422
+ return reply.status(500).send({ ok: false, error: `could not write the IFC companion app: ${e instanceof Error ? e.message : "write error"}` });
58423
+ }
58424
+ try {
58425
+ await aware.compile(flo);
58426
+ } catch (e) {
58427
+ app.log.warn({ companionId, detail: e instanceof AwareError ? e.detail : void 0 }, "IFC compile failed");
58428
+ return reply.status(422).send({ ok: false, error: `IFC compile failed: ${e instanceof Error ? e.message : "compile error"}`, reason: awareStderr(e) });
58429
+ }
58430
+ try {
58431
+ await aware.run(companionId, {});
58432
+ } catch (e) {
58433
+ if (e instanceof AwareError) {
58434
+ app.log.warn({ companionId, detail: e.detail }, "IFC write failed");
58435
+ return reply.send({ ok: false, error: e.message, reason: awareStderr(e) });
58436
+ }
58437
+ throw e;
58438
+ }
58439
+ if (!(0, import_node_fs28.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
58440
+ const content = (0, import_node_fs28.readFileSync)(outPath, "utf8");
58441
+ broadcast({ type: "apps-changed" });
58442
+ return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped };
58443
+ });
58444
+ app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
58445
+ const doc2 = readContract(req.params.appId);
58446
+ if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to bake" });
58447
+ const v = validateSteelTakeoff(doc2);
58448
+ if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
58449
+ const { scene, skipped } = contractToScene(doc2);
58450
+ if (scene.elements.length === 0) {
58451
+ return reply.status(422).send({ ok: false, error: "no resolvable members to bake \u2014 every member is an RFI (no AISC size yet)", skipped });
58452
+ }
58453
+ const companionId = `${req.params.appId}-tekla`;
58454
+ let flo;
58455
+ try {
58456
+ flo = writeTeklaApp(appPath(companionId), companionId, scene);
58457
+ } catch (e) {
58458
+ app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-tekla: companion app write failed");
58459
+ return reply.status(500).send({ ok: false, error: `could not write the Tekla companion app: ${e instanceof Error ? e.message : "write error"}` });
58460
+ }
58461
+ try {
58462
+ await aware.compile(flo);
58463
+ } catch (e) {
58464
+ app.log.warn({ companionId, detail: e instanceof AwareError ? e.detail : void 0 }, "Tekla compile failed");
58465
+ return reply.status(422).send({ ok: false, error: `Tekla compile failed: ${e instanceof Error ? e.message : "compile error"}`, reason: awareStderr(e) });
58466
+ }
58467
+ let result;
58468
+ try {
58469
+ result = await aware.run(companionId, {});
58470
+ } catch (e) {
58471
+ if (e instanceof AwareError) {
58472
+ const reason = awareStderr(e);
58473
+ const needsTekla = /tekla|connect|model|open api/i.test(`${e.message} ${reason ?? ""}`);
58474
+ app.log.warn({ companionId, detail: e.detail }, "Tekla bake failed");
58475
+ return reply.send({
58476
+ ok: false,
58477
+ needsTekla,
58478
+ error: needsTekla ? "Tekla must be open with a model to bake into it." : e.message,
58479
+ reason
58480
+ });
58481
+ }
58482
+ throw e;
58483
+ }
58484
+ const events = result.traceText ? parseTrace(result.traceText) : [];
58485
+ const outEv = [...events].reverse().find((e) => e.kind === "node-output" && e.node === "bake-tekla");
58486
+ const inner = outEv && outEv.data && typeof outEv.data === "object" ? outEv.data.result : void 0;
58487
+ if (!inner || inner.ok !== true) {
58488
+ const msg = inner?.error || "the Tekla bake produced no confirmation that parts were created";
58489
+ const needsTekla = /tekla|connect|model|open api/i.test(msg);
58490
+ app.log.warn({ companionId, error: msg }, "Tekla bake reported failure in the node output");
58491
+ return reply.send({
58492
+ ok: false,
58493
+ needsTekla,
58494
+ error: needsTekla ? "Tekla must be open with a model to bake into it." : msg,
58495
+ reason: msg
58496
+ });
58497
+ }
58498
+ broadcast({ type: "apps-changed" });
58499
+ return { ok: true, message: `Created ${scene.elements.length} native part(s) in the open Tekla model.`, elements: scene.elements.length, skipped };
58500
+ });
58258
58501
  async function stopForegroundSession(id) {
58259
58502
  try {
58260
58503
  const sid = foregroundSessionId(id);
package/dist/web/app.css CHANGED
@@ -2487,6 +2487,11 @@ body {
2487
2487
  background: color-mix(in srgb, var(--accent) 22%, transparent);
2488
2488
  border-color: var(--accent); color: var(--text);
2489
2489
  }
2490
+ /* Hairline separator between node-action groups (view/edit vs export/write). */
2491
+ .agent-card .node-action-sep {
2492
+ height: 0; margin: 8px 2px 0;
2493
+ border-top: 1px solid var(--border-strong);
2494
+ }
2490
2495
  /* Input node: current values at a glance ("phase = 2"), so the user knows what
2491
2496
  the next run uses without opening the dialog. */
2492
2497
  .agent-card .node-inputs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
package/dist/web/aware.js CHANGED
@@ -982,6 +982,15 @@
982
982
  onClick();
983
983
  });
984
984
  card.appendChild(btn);
985
+ return btn;
986
+ }
987
+
988
+ // A hairline separator between groups of node actions (e.g. view/edit vs export),
989
+ // so the export/write actions read as a distinct group without a submenu.
990
+ function addNodeActionDivider(card) {
991
+ const sep = document.createElement('div');
992
+ sep.className = 'node-action-sep';
993
+ card.appendChild(sep);
985
994
  }
986
995
 
987
996
  // Show the input node's current values inline ("phase = 2"), so the user sees
@@ -1052,6 +1061,11 @@
1052
1061
  if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
1053
1062
  addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
1054
1063
  addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1064
+ // Export/write group — visually separated from the look-and-read actions above.
1065
+ addNodeActionDivider(card);
1066
+ addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1067
+ const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1068
+ teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1055
1069
  card.dataset.tip = 'Double-click to open the contract editor';
1056
1070
  }
1057
1071
  });
@@ -1466,6 +1480,41 @@
1466
1480
  }
1467
1481
  }
1468
1482
 
1483
+ // Export IFC ▸ — write the approved takeoff OUT to a universal .ifc and download it. The server
1484
+ // builds a model-free ifc companion, runs it, and returns the .ifc text (res.content); the browser
1485
+ // turns it into a download. api() throws on any failure (no contract, all-RFI, compile/write error).
1486
+ async function exportContractIfc(appId) {
1487
+ showToast('Generating IFC…', 'info');
1488
+ try {
1489
+ const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-ifc', { method: 'POST' });
1490
+ const blob = new Blob([res.content || ''], { type: 'application/x-step' });
1491
+ const url = URL.createObjectURL(blob);
1492
+ const a = document.createElement('a');
1493
+ a.href = url; a.download = res.filename || (appId + '.ifc');
1494
+ document.body.appendChild(a); a.click(); a.remove();
1495
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
1496
+ const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1497
+ showToast(`IFC downloaded — ${res.filename || appId + '.ifc'}${dropped ? ` · ${dropped} RFI member(s) skipped` : ''}`, 'ok');
1498
+ } catch (e) {
1499
+ showToast('IFC export failed: ' + (e && e.message ? e.message : String(e)), 'warn');
1500
+ }
1501
+ }
1502
+
1503
+ // Send to Tekla ▸ — create native parts in the OPEN Tekla model from the approved takeoff. Needs
1504
+ // Tekla running with a model; when it's not, the server returns a clear needsTekla state (api()
1505
+ // throws with err.body.needsTekla) — surfaced as an informational nudge, not a hard error.
1506
+ async function exportContractTekla(appId) {
1507
+ showToast('Baking into Tekla…', 'info');
1508
+ try {
1509
+ const res = await api('/api/contract/' + encodeURIComponent(appId) + '/export-tekla', { method: 'POST' });
1510
+ const dropped = Array.isArray(res.skipped) ? res.skipped.length : 0;
1511
+ showToast((res.message || 'Baked into the Tekla model.') + (dropped ? ` · ${dropped} RFI member(s) skipped` : ''), 'ok');
1512
+ } catch (e) {
1513
+ const needsTekla = e && e.body && e.body.needsTekla;
1514
+ showToast(e && e.message ? e.message : String(e), needsTekla ? 'info' : 'warn');
1515
+ }
1516
+ }
1517
+
1469
1518
  // ── Right-click Freeze / Unfreeze a node (aware-aeco#261) ─────────────────────
1470
1519
  // A general per-node toggle: right-click a canvas node → Freeze output (Run then
1471
1520
  // REPLAYS the node's last result instead of re-running its agent — so e.g. a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
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": {