@floless/app 0.32.0 → 0.33.1
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 +274 -7
- package/dist/web/app.css +5 -0
- package/dist/web/aware.js +72 -3
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -52454,6 +52454,24 @@ function isTrustedFlolessHost(url) {
|
|
|
52454
52454
|
var OFFLINE_GRACE_MS = 24 * 60 * 60 * 1e3;
|
|
52455
52455
|
var MEM_CACHE_MS = 60 * 1e3;
|
|
52456
52456
|
var REFRESH_SKEW_MS = 60 * 1e3;
|
|
52457
|
+
function normalizeSeatReason(raw) {
|
|
52458
|
+
switch ((raw ?? "").toLowerCase().replace(/-/g, "_")) {
|
|
52459
|
+
case "self_takeover":
|
|
52460
|
+
case "takeover":
|
|
52461
|
+
return "takeover";
|
|
52462
|
+
case "inactivity":
|
|
52463
|
+
case "inactive":
|
|
52464
|
+
case "timeout":
|
|
52465
|
+
return "inactivity";
|
|
52466
|
+
case "admin_revoke":
|
|
52467
|
+
case "admin_revoked":
|
|
52468
|
+
case "revoked":
|
|
52469
|
+
case "revoke":
|
|
52470
|
+
return "revoked";
|
|
52471
|
+
default:
|
|
52472
|
+
return "unknown";
|
|
52473
|
+
}
|
|
52474
|
+
}
|
|
52457
52475
|
function isSea() {
|
|
52458
52476
|
try {
|
|
52459
52477
|
return (0, import_node_module2.createRequire)(__import_meta_url)("node:sea").isSea();
|
|
@@ -52584,11 +52602,12 @@ async function accessToken() {
|
|
|
52584
52602
|
}
|
|
52585
52603
|
var mem = null;
|
|
52586
52604
|
var seatLost = false;
|
|
52605
|
+
var seatLostReason;
|
|
52587
52606
|
async function getLicenseStatus(opts = {}) {
|
|
52588
52607
|
const url = signInUrl();
|
|
52589
52608
|
if (seatLost) {
|
|
52590
52609
|
mem = null;
|
|
52591
|
-
return { state: "unlicensed", reason: "seat-taken", signInUrl: url };
|
|
52610
|
+
return { state: "unlicensed", reason: "seat-taken", seatReason: seatLostReason, signInUrl: url };
|
|
52592
52611
|
}
|
|
52593
52612
|
if (!opts.force && mem && Date.now() - mem.at < MEM_CACHE_MS) return mem.status;
|
|
52594
52613
|
const finish = (s) => {
|
|
@@ -52671,7 +52690,7 @@ async function seatHeartbeat() {
|
|
|
52671
52690
|
return;
|
|
52672
52691
|
}
|
|
52673
52692
|
if (res.status === 401) {
|
|
52674
|
-
let reason = "
|
|
52693
|
+
let reason = "unknown";
|
|
52675
52694
|
try {
|
|
52676
52695
|
const b = await res.json();
|
|
52677
52696
|
reason = b.details?.reason ?? b.reason ?? reason;
|
|
@@ -52701,16 +52720,18 @@ async function seatHeartbeat() {
|
|
|
52701
52720
|
}
|
|
52702
52721
|
function loseSeat(reason) {
|
|
52703
52722
|
seatLost = true;
|
|
52723
|
+
seatLostReason = normalizeSeatReason(reason);
|
|
52704
52724
|
seat = null;
|
|
52705
52725
|
mem = null;
|
|
52706
52726
|
if (hbTimer) {
|
|
52707
52727
|
clearInterval(hbTimer);
|
|
52708
52728
|
hbTimer = null;
|
|
52709
52729
|
}
|
|
52710
|
-
onSeatLostCb?.(
|
|
52730
|
+
onSeatLostCb?.(seatLostReason);
|
|
52711
52731
|
}
|
|
52712
52732
|
function resetSeat() {
|
|
52713
52733
|
seatLost = false;
|
|
52734
|
+
seatLostReason = void 0;
|
|
52714
52735
|
seat = null;
|
|
52715
52736
|
if (hbTimer) {
|
|
52716
52737
|
clearInterval(hbTimer);
|
|
@@ -52835,7 +52856,7 @@ function appVersion() {
|
|
|
52835
52856
|
return resolveVersion({
|
|
52836
52857
|
isSea: isSea2(),
|
|
52837
52858
|
sqVersionXml: readSqVersionXml(),
|
|
52838
|
-
define: true ? "0.
|
|
52859
|
+
define: true ? "0.33.1" : void 0,
|
|
52839
52860
|
pkgVersion: readPkgVersion()
|
|
52840
52861
|
});
|
|
52841
52862
|
}
|
|
@@ -52845,7 +52866,7 @@ function resolveChannel(s) {
|
|
|
52845
52866
|
return "dev";
|
|
52846
52867
|
}
|
|
52847
52868
|
function appChannel() {
|
|
52848
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52869
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.33.1" : void 0 });
|
|
52849
52870
|
}
|
|
52850
52871
|
|
|
52851
52872
|
// oauth-presets.ts
|
|
@@ -53838,6 +53859,18 @@ function bakeSceneIntoApp(sourcePath, scene, agent = "viewer-3d") {
|
|
|
53838
53859
|
doc2.setIn(["nodes", idx, "config", "scene"], scene);
|
|
53839
53860
|
(0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
|
|
53840
53861
|
}
|
|
53862
|
+
function bakeNodeConfigById(sourcePath, nodeId, patch) {
|
|
53863
|
+
const doc2 = (0, import_yaml5.parseDocument)((0, import_node_fs18.readFileSync)(sourcePath, "utf8"));
|
|
53864
|
+
if (doc2.errors.length > 0) {
|
|
53865
|
+
throw new Error(`node bake: source is not valid YAML: ${doc2.errors[0]?.message ?? "parse error"}`);
|
|
53866
|
+
}
|
|
53867
|
+
const seq = doc2.get("nodes");
|
|
53868
|
+
const items = (0, import_yaml5.isSeq)(seq) ? seq.items : [];
|
|
53869
|
+
const idx = items.findIndex((it) => ((0, import_yaml5.isMap)(it) ? it.toJSON() : null)?.id === nodeId);
|
|
53870
|
+
if (idx < 0) throw new Error(`no node with id "${nodeId}"`);
|
|
53871
|
+
for (const [key, value] of Object.entries(patch)) doc2.setIn(["nodes", idx, "config", key], value);
|
|
53872
|
+
(0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
|
|
53873
|
+
}
|
|
53841
53874
|
function writeViewer3dApp(dir, appId, scene) {
|
|
53842
53875
|
(0, import_node_fs18.mkdirSync)(dir, { recursive: true });
|
|
53843
53876
|
const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
|
|
@@ -53861,6 +53894,140 @@ function writeViewer3dApp(dir, appId, scene) {
|
|
|
53861
53894
|
bakeSceneIntoApp(flo, scene);
|
|
53862
53895
|
return flo;
|
|
53863
53896
|
}
|
|
53897
|
+
function writeIfcApp(dir, appId, scene, outputPath) {
|
|
53898
|
+
(0, import_node_fs18.mkdirSync)(dir, { recursive: true });
|
|
53899
|
+
const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
|
|
53900
|
+
(0, import_node_fs18.writeFileSync)(flo, [
|
|
53901
|
+
`app: ${appId}`,
|
|
53902
|
+
"version: 0.1.0",
|
|
53903
|
+
"display-name: Steel IFC",
|
|
53904
|
+
"description: IFC file written from an approved steel.takeoff/v1 contract (a companion export; the contract is the source of truth).",
|
|
53905
|
+
"exposes-as-agent: false",
|
|
53906
|
+
"requires:",
|
|
53907
|
+
" - ifc@0.1.x",
|
|
53908
|
+
"layout: linear",
|
|
53909
|
+
"nodes:",
|
|
53910
|
+
" - id: bake-ifc",
|
|
53911
|
+
" agent: ifc",
|
|
53912
|
+
" command: write",
|
|
53913
|
+
" config:",
|
|
53914
|
+
` output-path: ${JSON.stringify(outputPath)}`,
|
|
53915
|
+
// the ifc agent's input key is hyphenated
|
|
53916
|
+
" scene: {}",
|
|
53917
|
+
""
|
|
53918
|
+
].join("\n"));
|
|
53919
|
+
bakeSceneIntoApp(flo, scene, "ifc");
|
|
53920
|
+
return flo;
|
|
53921
|
+
}
|
|
53922
|
+
function writeTeklaApp(dir, appId, scene, teklaVersion = "2026.0") {
|
|
53923
|
+
(0, import_node_fs18.mkdirSync)(dir, { recursive: true });
|
|
53924
|
+
const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
|
|
53925
|
+
(0, import_node_fs18.writeFileSync)(flo, [
|
|
53926
|
+
`app: ${appId}`,
|
|
53927
|
+
"version: 0.1.0",
|
|
53928
|
+
"display-name: Steel to Tekla",
|
|
53929
|
+
"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.",
|
|
53930
|
+
"exposes-as-agent: false",
|
|
53931
|
+
"requires:",
|
|
53932
|
+
" - tekla@0.1.x",
|
|
53933
|
+
"layout: linear",
|
|
53934
|
+
"nodes:",
|
|
53935
|
+
" - id: bake-tekla",
|
|
53936
|
+
" agent: tekla",
|
|
53937
|
+
" command: bake-scene",
|
|
53938
|
+
" mode: write",
|
|
53939
|
+
" safety:",
|
|
53940
|
+
" snapshot: true",
|
|
53941
|
+
// one Tekla Undo reverts the whole bake (required: write-mode needs a safety block)
|
|
53942
|
+
" config:",
|
|
53943
|
+
` version: ${JSON.stringify(teklaVersion)}`,
|
|
53944
|
+
" scene: {}",
|
|
53945
|
+
""
|
|
53946
|
+
].join("\n"));
|
|
53947
|
+
bakeSceneIntoApp(flo, scene, "tekla");
|
|
53948
|
+
return flo;
|
|
53949
|
+
}
|
|
53950
|
+
|
|
53951
|
+
// bom-format.ts
|
|
53952
|
+
var round1 = (n) => Math.round(n * 10) / 10;
|
|
53953
|
+
function contractToBom(contractInput) {
|
|
53954
|
+
const c = contractInput ?? {};
|
|
53955
|
+
const weights = c.weights ?? {};
|
|
53956
|
+
const agg = /* @__PURE__ */ new Map();
|
|
53957
|
+
for (const plan of c.plans ?? []) {
|
|
53958
|
+
const ptPerFt = typeof plan.pt_per_ft === "number" && plan.pt_per_ft > 0 ? plan.pt_per_ft : 12;
|
|
53959
|
+
for (const m of plan.members ?? []) {
|
|
53960
|
+
const profile = m.profile;
|
|
53961
|
+
if (!profile) continue;
|
|
53962
|
+
const plf = weights[profile];
|
|
53963
|
+
if (plf == null) continue;
|
|
53964
|
+
let lenFt;
|
|
53965
|
+
if (m.role === "column") {
|
|
53966
|
+
const tos = m.col?.tos;
|
|
53967
|
+
const bos = m.col?.bos;
|
|
53968
|
+
if (tos == null || bos == null) continue;
|
|
53969
|
+
lenFt = Math.abs(tos - bos) / 12;
|
|
53970
|
+
} else {
|
|
53971
|
+
const wp = m.wp;
|
|
53972
|
+
if (!Array.isArray(wp) || wp.length < 2) continue;
|
|
53973
|
+
const a = wp[0];
|
|
53974
|
+
const b = wp[1];
|
|
53975
|
+
if (!a || !b) continue;
|
|
53976
|
+
lenFt = Math.hypot(a[0] - b[0], a[1] - b[1]) / ptPerFt;
|
|
53977
|
+
}
|
|
53978
|
+
const g = agg.get(profile) ?? { qty: 0, lenFt: 0, plf };
|
|
53979
|
+
g.qty += 1;
|
|
53980
|
+
g.lenFt += lenFt;
|
|
53981
|
+
agg.set(profile, g);
|
|
53982
|
+
}
|
|
53983
|
+
}
|
|
53984
|
+
const rows = [...agg.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([profile, g]) => ({
|
|
53985
|
+
Profile: profile,
|
|
53986
|
+
Qty: g.qty,
|
|
53987
|
+
"Length (ft)": round1(g.lenFt),
|
|
53988
|
+
"lb/ft": g.plf,
|
|
53989
|
+
"Weight (lb)": Math.round(g.lenFt * g.plf)
|
|
53990
|
+
}));
|
|
53991
|
+
const totalLb = rows.reduce((t, r) => t + r["Weight (lb)"], 0);
|
|
53992
|
+
const totalQty = rows.reduce((t, r) => t + r.Qty, 0);
|
|
53993
|
+
const totalLenFt = round1(rows.reduce((t, r) => t + r["Length (ft)"], 0));
|
|
53994
|
+
const totalTons = round1(totalLb / 2e3);
|
|
53995
|
+
rows.push({
|
|
53996
|
+
Profile: "TOTAL",
|
|
53997
|
+
Qty: totalQty,
|
|
53998
|
+
"Length (ft)": totalLenFt,
|
|
53999
|
+
"lb/ft": "",
|
|
54000
|
+
"Weight (lb)": totalLb
|
|
54001
|
+
});
|
|
54002
|
+
return {
|
|
54003
|
+
title: `Bill of Materials \u2014 ${totalTons} tons (${totalLb.toLocaleString("en-US")} lb)`,
|
|
54004
|
+
columns: ["Profile", "Qty", "Length (ft)", "lb/ft", "Weight (lb)"],
|
|
54005
|
+
rows,
|
|
54006
|
+
totalLb,
|
|
54007
|
+
totalTons
|
|
54008
|
+
};
|
|
54009
|
+
}
|
|
54010
|
+
|
|
54011
|
+
// downstream-bake.ts
|
|
54012
|
+
function bakeDownstream(sourcePath, contract) {
|
|
54013
|
+
const { scene } = contractToScene(contract);
|
|
54014
|
+
let sceneElements = 0;
|
|
54015
|
+
try {
|
|
54016
|
+
bakeSceneIntoApp(sourcePath, scene);
|
|
54017
|
+
sceneElements = scene.elements.length;
|
|
54018
|
+
} catch (e) {
|
|
54019
|
+
if (!(e instanceof Error && /no viewer-3d render node/.test(e.message))) throw e;
|
|
54020
|
+
}
|
|
54021
|
+
const bom = contractToBom(contract);
|
|
54022
|
+
let bomRows = 0;
|
|
54023
|
+
try {
|
|
54024
|
+
bakeNodeConfigById(sourcePath, "bom", { data: bom.rows, title: bom.title, columns: bom.columns });
|
|
54025
|
+
bomRows = bom.rows.length;
|
|
54026
|
+
} catch (e) {
|
|
54027
|
+
if (!(e instanceof Error && /no node with id "bom"/.test(e.message))) throw e;
|
|
54028
|
+
}
|
|
54029
|
+
return { sceneElements, bomRows };
|
|
54030
|
+
}
|
|
53864
54031
|
|
|
53865
54032
|
// app-lifecycle.ts
|
|
53866
54033
|
var import_node_fs21 = require("node:fs");
|
|
@@ -54781,7 +54948,7 @@ function isGatedAwareRoute(url, method) {
|
|
|
54781
54948
|
if (path === "/api/trigger-run") return m === "POST";
|
|
54782
54949
|
if (path.startsWith("/api/app/")) return m === "PATCH" || m === "POST" || m === "DELETE";
|
|
54783
54950
|
if (path.startsWith("/api/contract/")) {
|
|
54784
|
-
return m === "POST" && (path.endsWith("/approve") || path.endsWith("/export-3d"));
|
|
54951
|
+
return m === "POST" && (path.endsWith("/approve") || path.endsWith("/export-3d") || path.endsWith("/export-ifc") || path.endsWith("/export-tekla"));
|
|
54785
54952
|
}
|
|
54786
54953
|
return AWARE_ROUTES.some((p) => path.startsWith(p));
|
|
54787
54954
|
}
|
|
@@ -58046,7 +58213,10 @@ async function startServer() {
|
|
|
58046
58213
|
}
|
|
58047
58214
|
return { ok: true, bootstrap: getBootstrapState().status };
|
|
58048
58215
|
});
|
|
58049
|
-
onSeatLost((reason) =>
|
|
58216
|
+
onSeatLost((reason) => {
|
|
58217
|
+
app.log.warn({ reason }, "license seat lost \u2014 gating this session until re-login");
|
|
58218
|
+
broadcast({ type: "seat-taken", reason });
|
|
58219
|
+
});
|
|
58050
58220
|
app.get("/api/apps", async () => {
|
|
58051
58221
|
const apps = await aware.list();
|
|
58052
58222
|
return { ok: true, apps: apps.map((a) => ({ ...a, provider: appProvider(a.id), baked: appBaked(a.id) })) };
|
|
@@ -58194,6 +58364,7 @@ async function startServer() {
|
|
|
58194
58364
|
const sourcePath = readApp(req.params.appId).source.path;
|
|
58195
58365
|
try {
|
|
58196
58366
|
bakeContractIntoApp(sourcePath, doc2);
|
|
58367
|
+
bakeDownstream(sourcePath, doc2);
|
|
58197
58368
|
} catch (e) {
|
|
58198
58369
|
return reply.status(422).send({ ok: false, error: e instanceof Error ? e.message : "bake failed" });
|
|
58199
58370
|
}
|
|
@@ -58255,6 +58426,102 @@ async function startServer() {
|
|
|
58255
58426
|
broadcast({ type: "apps-changed" });
|
|
58256
58427
|
return { ok: true, report: { ...extracted, interactive: true }, skipped };
|
|
58257
58428
|
});
|
|
58429
|
+
app.post("/api/contract/:appId/export-ifc", async (req, reply) => {
|
|
58430
|
+
const doc2 = readContract(req.params.appId);
|
|
58431
|
+
if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to export" });
|
|
58432
|
+
const v = validateSteelTakeoff(doc2);
|
|
58433
|
+
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
58434
|
+
const { scene, skipped } = contractToScene(doc2);
|
|
58435
|
+
if (scene.elements.length === 0) {
|
|
58436
|
+
return reply.status(422).send({ ok: false, error: "no resolvable members to export \u2014 every member is an RFI (no AISC size yet)", skipped });
|
|
58437
|
+
}
|
|
58438
|
+
const companionId = `${req.params.appId}-ifc`;
|
|
58439
|
+
const filename = `${req.params.appId}.ifc`;
|
|
58440
|
+
const outPath = (0, import_node_path26.join)(appPath(companionId), filename);
|
|
58441
|
+
let flo;
|
|
58442
|
+
try {
|
|
58443
|
+
flo = writeIfcApp(appPath(companionId), companionId, scene, outPath);
|
|
58444
|
+
} catch (e) {
|
|
58445
|
+
app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-ifc: companion app write failed");
|
|
58446
|
+
return reply.status(500).send({ ok: false, error: `could not write the IFC companion app: ${e instanceof Error ? e.message : "write error"}` });
|
|
58447
|
+
}
|
|
58448
|
+
try {
|
|
58449
|
+
await aware.compile(flo);
|
|
58450
|
+
} catch (e) {
|
|
58451
|
+
app.log.warn({ companionId, detail: e instanceof AwareError ? e.detail : void 0 }, "IFC compile failed");
|
|
58452
|
+
return reply.status(422).send({ ok: false, error: `IFC compile failed: ${e instanceof Error ? e.message : "compile error"}`, reason: awareStderr(e) });
|
|
58453
|
+
}
|
|
58454
|
+
try {
|
|
58455
|
+
await aware.run(companionId, {});
|
|
58456
|
+
} catch (e) {
|
|
58457
|
+
if (e instanceof AwareError) {
|
|
58458
|
+
app.log.warn({ companionId, detail: e.detail }, "IFC write failed");
|
|
58459
|
+
return reply.send({ ok: false, error: e.message, reason: awareStderr(e) });
|
|
58460
|
+
}
|
|
58461
|
+
throw e;
|
|
58462
|
+
}
|
|
58463
|
+
if (!(0, import_node_fs28.existsSync)(outPath)) return reply.send({ ok: false, error: "the IFC export produced no file" });
|
|
58464
|
+
const content = (0, import_node_fs28.readFileSync)(outPath, "utf8");
|
|
58465
|
+
broadcast({ type: "apps-changed" });
|
|
58466
|
+
return { ok: true, filename, content, bytes: Buffer.byteLength(content), skipped };
|
|
58467
|
+
});
|
|
58468
|
+
app.post("/api/contract/:appId/export-tekla", async (req, reply) => {
|
|
58469
|
+
const doc2 = readContract(req.params.appId);
|
|
58470
|
+
if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to bake" });
|
|
58471
|
+
const v = validateSteelTakeoff(doc2);
|
|
58472
|
+
if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
|
|
58473
|
+
const { scene, skipped } = contractToScene(doc2);
|
|
58474
|
+
if (scene.elements.length === 0) {
|
|
58475
|
+
return reply.status(422).send({ ok: false, error: "no resolvable members to bake \u2014 every member is an RFI (no AISC size yet)", skipped });
|
|
58476
|
+
}
|
|
58477
|
+
const companionId = `${req.params.appId}-tekla`;
|
|
58478
|
+
let flo;
|
|
58479
|
+
try {
|
|
58480
|
+
flo = writeTeklaApp(appPath(companionId), companionId, scene);
|
|
58481
|
+
} catch (e) {
|
|
58482
|
+
app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-tekla: companion app write failed");
|
|
58483
|
+
return reply.status(500).send({ ok: false, error: `could not write the Tekla companion app: ${e instanceof Error ? e.message : "write error"}` });
|
|
58484
|
+
}
|
|
58485
|
+
try {
|
|
58486
|
+
await aware.compile(flo);
|
|
58487
|
+
} catch (e) {
|
|
58488
|
+
app.log.warn({ companionId, detail: e instanceof AwareError ? e.detail : void 0 }, "Tekla compile failed");
|
|
58489
|
+
return reply.status(422).send({ ok: false, error: `Tekla compile failed: ${e instanceof Error ? e.message : "compile error"}`, reason: awareStderr(e) });
|
|
58490
|
+
}
|
|
58491
|
+
let result;
|
|
58492
|
+
try {
|
|
58493
|
+
result = await aware.run(companionId, {});
|
|
58494
|
+
} catch (e) {
|
|
58495
|
+
if (e instanceof AwareError) {
|
|
58496
|
+
const reason = awareStderr(e);
|
|
58497
|
+
const needsTekla = /tekla|connect|model|open api/i.test(`${e.message} ${reason ?? ""}`);
|
|
58498
|
+
app.log.warn({ companionId, detail: e.detail }, "Tekla bake failed");
|
|
58499
|
+
return reply.send({
|
|
58500
|
+
ok: false,
|
|
58501
|
+
needsTekla,
|
|
58502
|
+
error: needsTekla ? "Tekla must be open with a model to bake into it." : e.message,
|
|
58503
|
+
reason
|
|
58504
|
+
});
|
|
58505
|
+
}
|
|
58506
|
+
throw e;
|
|
58507
|
+
}
|
|
58508
|
+
const events = result.traceText ? parseTrace(result.traceText) : [];
|
|
58509
|
+
const outEv = [...events].reverse().find((e) => e.kind === "node-output" && e.node === "bake-tekla");
|
|
58510
|
+
const inner = outEv && outEv.data && typeof outEv.data === "object" ? outEv.data.result : void 0;
|
|
58511
|
+
if (!inner || inner.ok !== true) {
|
|
58512
|
+
const msg = inner?.error || "the Tekla bake produced no confirmation that parts were created";
|
|
58513
|
+
const needsTekla = /tekla|connect|model|open api/i.test(msg);
|
|
58514
|
+
app.log.warn({ companionId, error: msg }, "Tekla bake reported failure in the node output");
|
|
58515
|
+
return reply.send({
|
|
58516
|
+
ok: false,
|
|
58517
|
+
needsTekla,
|
|
58518
|
+
error: needsTekla ? "Tekla must be open with a model to bake into it." : msg,
|
|
58519
|
+
reason: msg
|
|
58520
|
+
});
|
|
58521
|
+
}
|
|
58522
|
+
broadcast({ type: "apps-changed" });
|
|
58523
|
+
return { ok: true, message: `Created ${scene.elements.length} native part(s) in the open Tekla model.`, elements: scene.elements.length, skipped };
|
|
58524
|
+
});
|
|
58258
58525
|
async function stopForegroundSession(id) {
|
|
58259
58526
|
try {
|
|
58260
58527
|
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
|
|
@@ -3693,8 +3742,14 @@
|
|
|
3693
3742
|
loadApp(currentId).catch(reportErr);
|
|
3694
3743
|
}, 250);
|
|
3695
3744
|
} else if (m.type === 'seat-taken') {
|
|
3696
|
-
// This session's seat was
|
|
3697
|
-
|
|
3745
|
+
// This session's seat was lost. m.reason (normalized server-side) says why — only a
|
|
3746
|
+
// genuine takeover should claim "another device"; an inactivity/revoke reads neutrally (#142).
|
|
3747
|
+
const SEAT_TOAST = {
|
|
3748
|
+
takeover: 'Signed in on another device — sign in here to continue.',
|
|
3749
|
+
inactivity: 'Your session timed out — sign in here to continue.',
|
|
3750
|
+
revoked: 'Your session was ended by an administrator — sign in here to continue.',
|
|
3751
|
+
};
|
|
3752
|
+
showToast(SEAT_TOAST[m.reason] || 'Your session ended — sign in here to continue.', 'err');
|
|
3698
3753
|
recheckLicenseAndGate();
|
|
3699
3754
|
}
|
|
3700
3755
|
};
|
|
@@ -5524,7 +5579,21 @@
|
|
|
5524
5579
|
body: 'Sign in with your FloLess account to use floless.app.',
|
|
5525
5580
|
secondary: { label: 'No subscription yet? Subscribe →', href: subUrl } },
|
|
5526
5581
|
};
|
|
5527
|
-
|
|
5582
|
+
let copy = COPY[reason] || COPY['signed-out']; // neutral fallback for an unknown/absent reason
|
|
5583
|
+
// A lost seat carries a sub-reason; only a genuine takeover should claim "another device".
|
|
5584
|
+
// Inactivity/revoke/unknown read neutrally so the gate stops misattributing the cause (#142).
|
|
5585
|
+
if (reason === 'seat-taken') {
|
|
5586
|
+
const SEAT_COPY = {
|
|
5587
|
+
inactivity: { headline: 'Session timed out',
|
|
5588
|
+
body: 'Your session ended after a period of inactivity. Sign in to continue.' },
|
|
5589
|
+
revoked: { headline: 'Session ended by an administrator',
|
|
5590
|
+
body: 'Your session was ended by an administrator. Sign in again to continue.' },
|
|
5591
|
+
unknown: { headline: 'Session ended',
|
|
5592
|
+
body: 'Your session has ended. Sign in to continue using floless.app.' },
|
|
5593
|
+
};
|
|
5594
|
+
const sc = SEAT_COPY[status.seatReason]; // 'takeover'/absent → keep the default takeover copy
|
|
5595
|
+
if (sc) copy = { ...copy, ...sc };
|
|
5596
|
+
}
|
|
5528
5597
|
const style = document.createElement('style');
|
|
5529
5598
|
style.textContent = `
|
|
5530
5599
|
#license-gate{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center;
|