@floless/app 0.31.4 → 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.31.4" : 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.31.4" : void 0 });
52848
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.33.0" : void 0 });
52849
52849
  }
52850
52850
 
52851
52851
  // oauth-presets.ts
@@ -53698,6 +53698,24 @@ function writeContract(appId, doc2) {
53698
53698
  (0, import_node_fs16.writeFileSync)(p, JSON.stringify(doc2));
53699
53699
  }
53700
53700
 
53701
+ // contract-resolve.ts
53702
+ function readContractForApp(appId, readAppFn = readApp) {
53703
+ const draft = readContract(appId);
53704
+ if (draft != null) return draft;
53705
+ let app;
53706
+ try {
53707
+ app = readAppFn(appId);
53708
+ } catch {
53709
+ return null;
53710
+ }
53711
+ const node = app.nodes.find((n) => n.config["contract"] === "steel.takeoff/v1");
53712
+ const t = node?.config["takeoff"];
53713
+ if (t == null || typeof t !== "object" || Array.isArray(t) || Object.keys(t).length === 0) {
53714
+ return null;
53715
+ }
53716
+ return t;
53717
+ }
53718
+
53701
53719
  // contract-bake.ts
53702
53720
  var import_node_fs17 = require("node:fs");
53703
53721
  var import_yaml4 = __toESM(require_dist6(), 1);
@@ -53820,6 +53838,18 @@ function bakeSceneIntoApp(sourcePath, scene, agent = "viewer-3d") {
53820
53838
  doc2.setIn(["nodes", idx, "config", "scene"], scene);
53821
53839
  (0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
53822
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
+ }
53823
53853
  function writeViewer3dApp(dir, appId, scene) {
53824
53854
  (0, import_node_fs18.mkdirSync)(dir, { recursive: true });
53825
53855
  const flo = (0, import_node_path15.join)(dir, `${appId}.flo`);
@@ -53843,6 +53873,140 @@ function writeViewer3dApp(dir, appId, scene) {
53843
53873
  bakeSceneIntoApp(flo, scene);
53844
53874
  return flo;
53845
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
+ }
53846
54010
 
53847
54011
  // app-lifecycle.ts
53848
54012
  var import_node_fs21 = require("node:fs");
@@ -54763,7 +54927,7 @@ function isGatedAwareRoute(url, method) {
54763
54927
  if (path === "/api/trigger-run") return m === "POST";
54764
54928
  if (path.startsWith("/api/app/")) return m === "PATCH" || m === "POST" || m === "DELETE";
54765
54929
  if (path.startsWith("/api/contract/")) {
54766
- 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"));
54767
54931
  }
54768
54932
  return AWARE_ROUTES.some((p) => path.startsWith(p));
54769
54933
  }
@@ -55854,6 +56018,8 @@ var PRODUCT_SKILLS = [
55854
56018
  // author event-driven ("on trigger") routines
55855
56019
  "floless-app-steel-from-drawings",
55856
56020
  // read a steel drawing into an interactive 3D model (bake the viewer-3d scene)
56021
+ "floless-app-steel-takeoff",
56022
+ // read a steel drawing into an editable steel.takeoff/v1 contract (the Steel Takeoff reader)
55857
56023
  "floless-app-tweak-contract",
55858
56024
  // handle a tweak-contract request from the steel-takeoff contract editor (Slice 2 AI round-trip)
55859
56025
  "floless-app-ui",
@@ -58147,7 +58313,7 @@ async function startServer() {
58147
58313
  return { ok: true, app: readApp(req.params.id) };
58148
58314
  });
58149
58315
  app.get("/api/contract/:appId", async (req, reply) => {
58150
- const doc2 = readContract(req.params.appId);
58316
+ const doc2 = readContractForApp(req.params.appId);
58151
58317
  if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract for this app yet" });
58152
58318
  return doc2;
58153
58319
  });
@@ -58174,6 +58340,7 @@ async function startServer() {
58174
58340
  const sourcePath = readApp(req.params.appId).source.path;
58175
58341
  try {
58176
58342
  bakeContractIntoApp(sourcePath, doc2);
58343
+ bakeDownstream(sourcePath, doc2);
58177
58344
  } catch (e) {
58178
58345
  return reply.status(422).send({ ok: false, error: e instanceof Error ? e.message : "bake failed" });
58179
58346
  }
@@ -58235,6 +58402,102 @@ async function startServer() {
58235
58402
  broadcast({ type: "apps-changed" });
58236
58403
  return { ok: true, report: { ...extracted, interactive: true }, skipped };
58237
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
+ });
58238
58501
  async function stopForegroundSession(id) {
58239
58502
  try {
58240
58503
  const sid = foregroundSessionId(id);
@@ -58732,6 +58995,7 @@ async function startServer() {
58732
58995
  { bodyLimit: 25 * 1024 * 1024 },
58733
58996
  async (req, reply) => {
58734
58997
  const { appId, inputName, instruction, snapshots } = req.body ?? {};
58998
+ const sourceName = typeof req.body?.sourceName === "string" ? req.body.sourceName : void 0;
58735
58999
  if (!appId) return reply.status(400).send({ ok: false, error: "appId required" });
58736
59000
  let decoded;
58737
59001
  try {
@@ -58740,7 +59004,7 @@ async function startServer() {
58740
59004
  return reply.status(400).send({ ok: false, error: e instanceof Error ? e.message : "bad snapshot" });
58741
59005
  }
58742
59006
  const request = addRequest(
58743
- { type: "rebake", appId, ...inputName ? { inputName } : {}, instruction: instruction || REBAKE_INSTRUCTION },
59007
+ { type: "rebake", appId, ...inputName ? { inputName } : {}, ...sourceName ? { sourceName } : {}, instruction: instruction || REBAKE_INSTRUCTION },
58744
59008
  decoded
58745
59009
  );
58746
59010
  broadcast({ type: "request-added", request });
@@ -23,6 +23,18 @@
23
23
  },
24
24
  "description": "sheet -> { detail number -> normalized [fx,fy] bubble anchor }."
25
25
  },
26
+ "source": {
27
+ "type": "object",
28
+ "description": "Provenance: where this takeoff was read from.",
29
+ "properties": {
30
+ "name": { "type": "string" },
31
+ "path": { "type": "string" },
32
+ "sheet": { "type": "string" },
33
+ "sha256": { "type": "string" },
34
+ "read_at": { "type": "string" }
35
+ },
36
+ "additionalProperties": false
37
+ },
26
38
  "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
27
39
  },
28
40
  "$defs": {
@@ -0,0 +1,266 @@
1
+ ---
2
+ name: floless-app-steel-takeoff
3
+ description: Read a structural steel drawing into an editable steel.takeoff/v1 contract in floless.app — the "Steel Takeoff" reader. Triggers on a pending floless `rebake` request for the `steel-takeoff` app (queued from its "Re-read & re-bake ▸" button), or asks like "read this framing plan into a takeoff", "turn this structural drawing into a takeoff", "bake my drawing into steel-takeoff", "extract members from this PDF". Teaches the host AI to READ a steel drawing (PDF/image) with its own vision at COMPOSE time, build a steel.takeoff/v1 contract (members + profiles + elevations + weights from AISC lookup), write it to the contract store, and hand control to the editor — no model in the run path, no API key.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Steel Takeoff — read a drawing into a steel.takeoff/v1 contract
9
+
10
+ ## What this is
11
+
12
+ `steel-takeoff` is a FloLess app that turns a structural steel drawing into an editable
13
+ **takeoff contract** — members, profiles, AISC weights, T.O. elevations, connection detail refs
14
+ — rendered in the overlay editor, then baked at Approve into the app's `read` node.
15
+
16
+ The drawing is read **at compose time** with your own vision. There is **no model in the run
17
+ path and no API key** — the brain is you, in the terminal (the FloLess thin-UI contract). The
18
+ deterministic run only renders the approved result.
19
+
20
+ **Downstream outputs** (on the same canvas, triggered separately) are a 3D stick model
21
+ (`viewer-3d`, from the Approve bake), an IFC file, a Tekla bake, and a formatted BOM — but
22
+ this skill's job ends at producing and handing off the takeoff contract. Mention those as "next"
23
+ but do not attempt them here.
24
+
25
+ > **Pasted a request?** If the user pastes a message beginning with a `[floless-request
26
+ > type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
27
+ > via `GET /api/requests` (match the id), don't run the pasted line verbatim, and `DELETE` it
28
+ > when done.
29
+
30
+ ---
31
+
32
+ ## The loop
33
+
34
+ ### 1. Find the drawing
35
+
36
+ From a `rebake` request: `GET http://localhost:<port>/api/requests` → the entry with
37
+ `type:"rebake"` and `appId:"steel-takeoff"` carries `snapshots: ["<abs path>"]` (the drawing
38
+ file) and optionally `sourceName` (the original filename — use it for the `source.name`
39
+ provenance field). If the user just attaches or points at a drawing in prose, skip the lookup
40
+ and proceed from that path.
41
+
42
+ ### 2. Read it with your own vision — the heavy pipeline
43
+
44
+ Follow the methodology condensed here (full detail + every gotcha in
45
+ `docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
46
+
47
+ 1. **Sheet index.** If it's a multi-sheet PDF, read the index sheet (e.g. `S-100`) once with
48
+ vision to build a `sheet → page` map. Focus the takeoff on one or a few framing plan sheets
49
+ (Foundation, Floor, Roof); say which.
50
+ 2. **Tile the framing area** in display space (e.g. 4×3 grid, render scale ~2.0). Record each
51
+ tile's display origin and extent.
52
+ 3. **Geometry (exact).** `page.get_drawings()` → keep member-scale line segments (~8–45 ft) in
53
+ display coords, tagged H/V/D. These are the candidate members and the snap targets.
54
+ 4. **Labels (vision — parallel subagents).** Read section labels per tile; each subagent returns
55
+ **normalized fractions [0..1]** (not pixel coordinates — pixels from a subagent are
56
+ scale-ambiguous) plus orientation (`H`/`V`/`D`). Convert to display, dedup.
57
+ - **Skew bays get their OWN dedicated pass — they are the most-skipped members.** A skewed/angled
58
+ framing bay read together with the orthogonal grid loses its diagonal members (the orthogonal
59
+ pass "swallows" them, and free-hand label positions don't bind to the diagonal lines).
60
+ - **Reliable skew method = bind to the EXACT diagonal geometry, don't free-place.** This is the
61
+ technique that works (proven on S-202):
62
+ 1. From step 3, take the `D` member-scale segments (the exact diagonal geometry) and their
63
+ bounding box — that's the skew bay.
64
+ 2. Render that bbox at high DPI and **draw the `D` segments over it, each NUMBERED** at its
65
+ midpoint (red line + index 0..N).
66
+ 3. One vision pass over that numbered image maps **each segment index → its label** (chords are
67
+ typically the short collinear sub-segments = `W16X26`; the long perpendicular rungs =
68
+ `W21X62`/`W12X19`; `MF` where it's a moment mark). Give the reader the per-segment length to
69
+ disambiguate chord-vs-rung.
70
+ 4. Build one member per `D` segment using the segment's OWN endpoints as the work-points (so the
71
+ member lands exactly on the drawn line), profile from the map, AISC weight via lookup.
72
+ 5. Replace any earlier free-placed diagonal members in the bay (members whose *real* orientation
73
+ is diagonal) with these exact ones. Cross-check: `D`-segment count ≈ diagonal members — a
74
+ pile of unbound `D` segments means skew is still missed.
75
+ 5. **Bind** each label to the nearest matching segment (within tolerance, orientation penalty on
76
+ mismatch). **Bind `D` labels to `D` segments** (don't let a diagonal label grab a nearby
77
+ orthogonal grid line). Segments that remain unbound are the coverage queue (the human's markup
78
+ worklist) — a pile of unbound `D` segments means skew was missed; go back to step 4.
79
+ 6. **Work-point extend.** Extend each bound segment's drawn-short ends to the centerline
80
+ intersection of connecting members (line–line intersection, capped reach). This closes joints
81
+ and is skew-proof.
82
+ 7. **AISC lookup for weights.** Call the `steel-detailer-us` AWARE agent's `lookup` verb for
83
+ each distinct profile designation → `plf` (pounds per linear foot). Build `weights: {
84
+ profile → plf }`. Unresolved → `rfi: true`, weight omitted. Verify the lookup is live with a
85
+ known section (e.g. W16X26 → 26 lb/ft) before trusting a full run — `~/.aware` rules can
86
+ vanish on agent re-update (see gotcha §6 of the guidance spec).
87
+ 8. **Dedupe coincident members.** Two labels binding the same segment (a W-size + its `MF` mark,
88
+ or overlapping tile reads) emit coincident members that double-count the BOM. Collapse members
89
+ with identical work-points (order-independent, ~3px tol), keeping the most-resolved one. (The
90
+ editor ALSO auto-dedupes coincident members on load as a safety net — but produce a clean
91
+ contract here; don't lean on it.)
92
+ 9. **Cross-sheet sources.** Frame Elevation sheets → moment-frame schedule (resolve `MF` marks),
93
+ level datums (Ground / Second Floor / Roof elevations). Roof framing → per-zone T.O. STEEL
94
+ callouts. See guidance spec §5 for what each sheet gives you.
95
+
96
+ **Hard-won gotchas (from the guidance spec § 6 — do not skip these):**
97
+ - **Text is outlined, not real text.** `get_text()` returns almost nothing. Everything textual
98
+ needs vision.
99
+ - **Pages may be rotated (e.g. 270°).** Work entirely in display space via
100
+ `page.rotation_matrix`. Never mix native/display coords — this is the single biggest source of
101
+ misplaced members.
102
+ - **Subagent reads must return normalized fractions, one pass, no sub-cropping.** Pixel coords
103
+ from a subagent collapse after downscaling.
104
+ - **`MF` / `BF` are marks, not profiles.** No AISC section until resolved from Frame Elevations
105
+ by grid + level. Leave as `rfi` and `mf: true` until auto-resolved or the user picks.
106
+ - **AISC rules can vanish.** Verify `steel-detailer-us` with a known section before a full run
107
+ (`W16X26 → 26 lb/ft`); if every member comes back weightless, restore `rules/aisc-shapes-v15.json`
108
+ into the installed agent dir from the aware-aeco repo. Seen cause: the agent was renamed
109
+ `steel-detailer-aisc → steel-detailer-us`, so an old lookup binary points at a dir that no longer
110
+ exists — make sure you're calling the current `steel-detailer-us` and its rules dir is populated.
111
+ - **Half-grids have no plan coordinate.** Auto-resolve only confident cases; surface the rest in
112
+ the `rfi` panel.
113
+ - **Detail callout detection misreads numbers/sheets.** Verify at high zoom before trusting
114
+ (both the number and the sheet can be wrong).
115
+ - **`(N)` = new, `(E)` = existing (often out of scope).** Read the full set for context before
116
+ deciding.
117
+
118
+ ### 3. Honor `target_confidence`
119
+
120
+ Read the app's `target_confidence` input (default 70). The confidence score is a **deterministic
121
+ function of observable evidence** — not model self-report. Full method in
122
+ `docs/superpowers/specs/2026-06-20-steel-takeoff-confidence-report.md`; the short summary:
123
+
124
+ - **Verified (100%)** — human-confirmed.
125
+ - **High (≥80%)** — fully resolved from drawing evidence, no contradiction.
126
+ - **Medium (50–79%)** — one indirection (mark→schedule) or assumed elevation.
127
+ - **Low (<50%)** — duplicate group, weak bind, half-grid, conflicting sources.
128
+ - **RFI (0%)** — no AISC size resolved.
129
+
130
+ Aggregate confidence = average of per-element scores over the detected set (members with
131
+ resolved profiles + elevations; RFI members drag it down). **Loop**: re-read low-confidence
132
+ regions (re-tile at higher zoom, re-run AISC lookup) until aggregate ≥ `target_confidence` OR
133
+ the returns are clearly diminishing. Be honest if you stop below target — it's a triage guide,
134
+ not a hard gate. The user decides whether to Approve.
135
+
136
+ ### 4. Assemble the `steel.takeoff/v1` contract
137
+
138
+ Emit a JSON object matching the schema in `schemas/steel.takeoff.v1.schema.json`. Top-level
139
+ required fields: `type: "steel.takeoff/v1"` (discriminator), `plans` (array of plan objects).
140
+ Important optional fields:
141
+
142
+ ```jsonc
143
+ {
144
+ "type": "steel.takeoff/v1",
145
+ "source": {
146
+ "name": "<sourceName or basename of the drawing path>",
147
+ "path": "<absolute path you read>",
148
+ "sheet": "<e.g. S-202>",
149
+ "sha256": "<sha256 of the file if available>",
150
+ "read_at": "<ISO 8601 timestamp>"
151
+ },
152
+ "weights": { "W16X26": 26, "HSS6X6X3/8": 27.48 }, // profile → plf; null for unresolved
153
+ "moment_frames": [ … ], // from Frame Elevation sheets
154
+ "detail_bubbles": { "S-504": { "5": [0.32, 0.71] } }, // sheet → { number → [fx,fy] }
155
+ "plans": [
156
+ {
157
+ "sheet": "S-202",
158
+ "title": "Second Floor Framing Plan",
159
+ "clip": [x0, y0, x1, y1], // display-space crop of framing area
160
+ "pt_per_ft": 12.3,
161
+ "default_tos": 198, // decimal inches (16'-6" = 198)
162
+ "raster_b64": "…", // CONFIDENTIAL — machine-local, never committed
163
+ "segments": [ { "id": "seg-1", "a": [x,y], "b": [x,y], "o": "H" } ],
164
+ "labels": [ { "text": "W16X26", "disp": [x,y] } ],
165
+ "details": [ { "text": "5-S504", "disp": [x,y] } ],
166
+ "tos_callouts": [ { "elev_in": 198, "type": "TOS", "disp": [x,y] } ],
167
+ "members": [
168
+ {
169
+ "id": "m1",
170
+ "profile": "W16X26",
171
+ "wp": [[x0,y0],[x1,y1]],
172
+ "angle": "H",
173
+ "role": "beam",
174
+ "rfi": false,
175
+ "mf": false,
176
+ "ends": [
177
+ { "tos": 198, "note": "moment", "tosDef": false, "detail": "5-S504" },
178
+ { "tos": 198, "note": "shear", "tosDef": true, "detail": "" }
179
+ ]
180
+ },
181
+ {
182
+ "id": "c1",
183
+ "profile": "HSS6X6X3/8",
184
+ "wp": [[x,y0],[x,y1]],
185
+ "angle": "V",
186
+ "role": "column",
187
+ "rfi": false,
188
+ "col": { "bos": 0, "tos": 198, "note": "", "detail": "" }
189
+ }
190
+ ]
191
+ }
192
+ ]
193
+ }
194
+ ```
195
+
196
+ Key field rules:
197
+ - All `wp` / `disp` / `a` / `b` coordinates are **display space** (not native PDF coords, not
198
+ millimetres).
199
+ - Elevations are **decimal inches** (canonical); the editor accepts/displays ft-in strings.
200
+ `tosDef: false` means the value came from a drawing callout; `true` means it follows the plan
201
+ default.
202
+ - `rfi: true` means no AISC size resolved — excluded from the BOM. `mf: true` marks a
203
+ moment-frame member (persistent; survives AISC resolution).
204
+ - `raster_b64` is a base64 JPEG of the framing area (title block excluded). It is
205
+ **machine-local** and **never committed**.
206
+
207
+ ### 5. Write to the contract store
208
+
209
+ ```
210
+ PUT http://localhost:<port>/api/contract/steel-takeoff
211
+ Content-Type: application/json
212
+
213
+ <contract JSON body>
214
+ ```
215
+
216
+ The server schema-validates the body. If it responds with `400`, read the error, fix the
217
+ offending fields, and re-PUT. You can read back the current contract with
218
+ `GET /api/contract/steel-takeoff`.
219
+
220
+ ### 6. Hand off to the user
221
+
222
+ Tell the user:
223
+ - What you read (e.g. "read 14 beams and 6 columns on sheets S-201/S-202; 3 MF members await
224
+ Frame-Elevation resolution; aggregate confidence 78% — above your 70% target").
225
+ - **Open the editor**: double-click the `read` node (or the takeoff node) in the FloLess
226
+ canvas. The editor renders the overlay, the member list, the RFI panel, and the confidence
227
+ report so they can verify and correct.
228
+ - **Approve & bake lock**: when satisfied, click Approve in the editor. Approve bakes the
229
+ contract into the `read` node's `config.takeoff` and recompiles; the Run gate disarms until
230
+ approved. After approval, the 3D view and BOM nodes render from the baked contract.
231
+ - **Freeze** the `read` node (right-click → Freeze, or `aware app freeze`) once it's approved
232
+ so a future re-read won't overwrite it until they explicitly unfreeze.
233
+ - What's next: the 3D view (`view` node), IFC export, Tekla bake, and formatted BOM are on the
234
+ same canvas and follow from Approve.
235
+
236
+ ### 7. Clear the request
237
+
238
+ If the trigger came from a `rebake` request:
239
+
240
+ ```
241
+ DELETE http://localhost:<port>/api/requests/<id>
242
+ ```
243
+
244
+ ### 8. Confidentiality
245
+
246
+ Never write client, firm, or project identifiers into files, commits, or responses. The
247
+ contract carries the source path in `source.path` and an embedded raster in `raster_b64` — both
248
+ are machine-local (stored under `~/.floless/contracts/steel-takeoff.json` by the server and
249
+ never committed). Clip title blocks from any embedded preview.
250
+
251
+ ---
252
+
253
+ ## Guardrails
254
+
255
+ - **Compose-time only.** The drawing is read HERE, by you, then PUT to the store. Never add a
256
+ runtime node that reads the drawing — `aware app validate` rejects it, and it breaks
257
+ determinism.
258
+ - **Contract, not scene.** This skill produces a `steel.takeoff/v1` contract. The 3D scene
259
+ derives from it at Approve (`contract-to-scene`); do not manually assemble the 3D scene here.
260
+ - **Thin-UI contract.** The browser recorded intent and the drawing path; the brain is you, in
261
+ the terminal. The UI never reads the drawing and never writes the contract directly.
262
+ - **Honest about gaps.** Unbound segments are the human's worklist, not an error. State
263
+ assumptions (plan-default elevations, assumed heights, unresolved MF marks). Never fabricate
264
+ sizes or grid positions the drawing does not show.
265
+ - **Resolve the port** from the running floless.app the same way other floless-app skills do
266
+ (check `~/.floless/port` or the active server port).
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
  });
@@ -1073,12 +1087,13 @@
1073
1087
  });
1074
1088
  if (!res) return;
1075
1089
  const dataUrl = res[name];
1090
+ const sourceName = res[name + '__filename'] || undefined;
1076
1091
  if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
1077
1092
  try {
1078
1093
  const r = await fetch('/api/rebake', {
1079
1094
  method: 'POST',
1080
1095
  headers: { 'content-type': 'application/json' },
1081
- body: JSON.stringify({ appId: app.id, inputName: name, snapshots: [{ dataUrl }] }),
1096
+ body: JSON.stringify({ appId: app.id, inputName: name, ...(sourceName ? { sourceName } : {}), snapshots: [{ dataUrl }] }),
1082
1097
  });
1083
1098
  const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
1084
1099
  if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
@@ -1417,6 +1432,9 @@
1417
1432
  Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1418
1433
  Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
1419
1434
  );
1435
+ // Focus the iframe once it loads so its keyboard shortcuts (Ctrl+D, etc.) work without
1436
+ // the user first having to click inside it.
1437
+ $contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
1420
1438
  $contractEditorFrame.src = r.editorUrl(appId);
1421
1439
  $contractEditor.hidden = false;
1422
1440
  return true;
@@ -1462,6 +1480,41 @@
1462
1480
  }
1463
1481
  }
1464
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
+
1465
1518
  // ── Right-click Freeze / Unfreeze a node (aware-aeco#261) ─────────────────────
1466
1519
  // A general per-node toggle: right-click a canvas node → Freeze output (Run then
1467
1520
  // REPLAYS the node's last result instead of re-running its agent — so e.g. a
@@ -1956,7 +2009,11 @@
1956
2009
  const out = {};
1957
2010
  $body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
1958
2011
  imageStates.forEach((entry, name) => { out[name] = entry.state; });
1959
- fileStates.forEach((entry, name) => { out[name] = entry.st.mode === 'empty' ? '' : entry.st.value; });
2012
+ fileStates.forEach((entry, name) => {
2013
+ out[name] = entry.st.mode === 'empty' ? '' : entry.st.value;
2014
+ // expose filename alongside so callers can capture provenance (e.g. rebake → sourceName)
2015
+ out[name + '__filename'] = entry.st.name || '';
2016
+ });
1960
2017
  return out;
1961
2018
  };
1962
2019
  const done = (result) => {
@@ -2,6 +2,13 @@
2
2
  <title>Steel takeoff — editor</title>
3
3
  <style>
4
4
  :root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
5
+ /* Theme EVERY scrollbar — no native white default may leak (applies to the canvas, panels, and every modal list). */
6
+ *{scrollbar-width:thin;scrollbar-color:#475569 transparent}
7
+ *::-webkit-scrollbar{width:10px;height:10px}
8
+ *::-webkit-scrollbar-track{background:transparent}
9
+ *::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
10
+ *::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
11
+ *::-webkit-scrollbar-corner{background:transparent}
5
12
  *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column}
6
13
  header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line)}
7
14
  header b{font-size:14px} .stat{color:var(--mut)} .stat b{color:var(--text)}
@@ -134,7 +141,8 @@
134
141
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
135
142
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
136
143
  <span class=stat id=confStat title="Confidence report — score each AI-read element by evidence" style="display:none">Confidence <b id=confPct>—</b></span>
137
- <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span><span style="flex:1"></span>
144
+ <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
145
+ <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
138
146
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
139
147
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
140
148
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
@@ -207,6 +215,9 @@
207
215
  <script>
208
216
  const APP_ID = new URLSearchParams(location.search).get('app') || '';
209
217
  let C, PAL, WT;
218
+ // Fallback palette (colorFor indexes this array by profile order) when a contract ships no `palette`
219
+ // — e.g. a contract shared/committed with raster_b64 + palette stripped for confidentiality.
220
+ const DEFAULT_PAL=['#60a5fa','#94a3b8','#f59e0b','#34d399','#f472b6','#a78bfa','#fb7185','#22d3ee','#facc15','#4ade80'];
210
221
  let lastLocalPut = 0; // timestamp of our own PUT — used to suppress the SSE echo
211
222
  function showEmpty(icon, headline, bodyText, appIdText) {
212
223
  const wrap = document.createElement('div');
@@ -231,7 +242,7 @@ async function boot() {
231
242
  return;
232
243
  }
233
244
  C = await res.json();
234
- PAL = C.palette; WT = C.weights;
245
+ PAL = (Array.isArray(C.palette) && C.palette.length) ? C.palette : DEFAULT_PAL; WT = C.weights || {};
235
246
  C.custom_details = C.custom_details || {};
236
247
  C.profile_colors = C.profile_colors || {};
237
248
  main();
@@ -421,6 +432,12 @@ function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;retur
421
432
  // --- zoom + pan ---
422
433
  const ZMIN=0.1, ZMAX=4;
423
434
  let zoom=1; const stage=document.getElementById('stage');
435
+ // The editor runs in an iframe; keyboard shortcuts (Ctrl+D etc.) only reach our handler when the
436
+ // iframe has focus. The SVG canvas isn't focusable, so clicking it wouldn't grab focus and a real
437
+ // Ctrl+D would hit the browser (bookmark) instead. Make the canvas focusable + grab focus on any
438
+ // pointerdown so shortcuts work after the user clicks into the drawing.
439
+ stage.tabIndex=-1; stage.style.outline='none';
440
+ stage.addEventListener('pointerdown',()=>{try{stage.focus({preventScroll:true});}catch(_){}}, true);
424
441
  function applyZoom(z,ax,ay){z=Math.max(ZMIN,Math.min(ZMAX,z));
425
442
  const pt=(ax!=null)?toSvg({clientX:ax,clientY:ay}):null;
426
443
  zoom=z;svg.setAttribute('width',EXTX*zoom);svg.setAttribute('height',EXTY*zoom);
@@ -437,7 +454,7 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
437
454
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
438
455
  function render(){
439
456
  if(geoMode&&selIds.size!==1){geoMode=null;document.body.classList.remove('geo');} // geo edit needs exactly one member
440
- let s=`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`;
457
+ let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
441
458
  for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
442
459
  for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
443
460
  s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
@@ -574,17 +591,28 @@ function rectHit(p0,p1,r){
574
591
  for(let i=0;i<4;i++) if(segSeg(p0,p1,c[i],c[(i+1)%4])) return true;
575
592
  return false;}
576
593
  // --- snap to existing member/segment endpoints (members lie on grids → snaps to grid intersections); Alt = off ---
577
- const SNAP_PX=9; let snapPts=[];
594
+ const SNAP_PX=9; let snapPts=[], snapSegs=[];
578
595
  function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
579
- snapPts=[];for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);}for(const s of P.segments){snapPts.push(s.a,s.b);}}
596
+ snapPts=[];snapSegs=[];
597
+ for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);}
598
+ for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);}}
599
+ // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
600
+ function nearestOnLine(x,y,tol){let bp=null,bd=tol;
601
+ for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
602
+ return bp?{x:bp[0],y:bp[1],d:bd}:null;}
580
603
  function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
581
604
  for(const q of snapPts){const d=Math.hypot(q[0]-x,q[1]-y);if(d<bd){bd=d;bp=q;}}
582
- if(bp)return {x:bp[0],y:bp[1],hit:true};
583
- let sx=x,sy=y,bx=tol,by=tol;
605
+ if(bp)return {x:bp[0],y:bp[1],hit:true}; // 1) snap to an endpoint/grid intersection
606
+ const ln=nearestOnLine(x,y,tol);if(ln)return {x:ln.x,y:ln.y,hit:true}; // 2) else snap ONTO a line (not only its endpoints)
607
+ let sx=x,sy=y,bx=tol,by=tol; // 3) else axis-align to a nearby point
584
608
  for(const q of snapPts){const dx=Math.abs(q[0]-x);if(dx<bx){bx=dx;sx=q[0];}const dy=Math.abs(q[1]-y);if(dy<by){by=dy;sy=q[1];}}
585
609
  return {x:sx,y:sy,hit:false};}
586
610
  function snapMark(x,y){let c=document.getElementById('snapMark');if(!c){c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.id='snapMark';c.setAttribute('class','snapmk');svg.appendChild(c);}c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r',6/zoom);}
587
611
  function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
612
+ // themed transient toast (baseline tokens — never a native alert)
613
+ function toast(msg){let t=document.getElementById('toast');
614
+ if(!t){t=document.createElement('div');t.id='toast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s;pointer-events:none';document.body.appendChild(t);}
615
+ t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
588
616
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
589
617
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
590
618
  if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
@@ -631,9 +659,10 @@ svg.addEventListener('pointermove',e=>{
631
659
  drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
632
660
  if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
633
661
  if(e.shiftKey){if(Math.abs(dx)>=Math.abs(dy))dy=0;else dx=0;snapClear();}
634
- else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so its nearest endpoint lands on a snap target
662
+ else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint OR line)
635
663
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
636
- for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}}
664
+ for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}
665
+ const ln=nearestOnLine(nx,ny,bd);if(ln){bd=ln.d;best={q:[ln.x,ln.y],cx:ln.x-nx,cy:ln.y-ny};}} // also snap onto other lines
637
666
  if(best){dx+=best.cx;dy+=best.cy;snapMark(best.q[0],best.q[1]);}else snapClear();}
638
667
  else snapClear();
639
668
  for(const it of drag.items){const m=byId(it.id);if(!m)continue;
@@ -956,9 +985,27 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
956
985
  e.preventDefault();e.stopPropagation();opts.forEach((o,i)=>o.classList.toggle('active',i===comboIdx));if(comboIdx>=0)opts[comboIdx].scrollIntoView({block:'nearest'});},true);
957
986
  // --- multi-plan: switch between all steel plan views ---
958
987
  function setPlan(i){C.active=i;P=C.plans[i];
959
- X0=P.clip[0];Y0=P.clip[1];X1=P.clip[2];Y1=P.clip[3];FT=P.pt_per_ft;RB64=P.raster_b64;EXTX=X1-X0;EXTY=Y1-Y0;
988
+ if(!Array.isArray(P.members))P.members=[];
989
+ if(!Array.isArray(P.segments))P.segments=[];
990
+ // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
991
+ // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
992
+ // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
993
+ let clip=P.clip;
994
+ if(!Array.isArray(clip)||clip.length<4){
995
+ const xs=[],ys=[];for(const m of P.members)for(const pt of (m.wp||[])){xs.push(pt[0]);ys.push(pt[1]);}
996
+ if(xs.length){const pad=Math.max(200,(Math.max(...xs)-Math.min(...xs))*0.1);
997
+ clip=[Math.min(...xs)-pad,Math.min(...ys)-pad,Math.max(...xs)+pad,Math.max(...ys)+pad];}
998
+ else clip=[0,0,1000,1000];}
999
+ X0=clip[0];Y0=clip[1];X1=clip[2];Y1=clip[3];FT=P.pt_per_ft||12;RB64=P.raster_b64||'';EXTX=X1-X0;EXTY=Y1-Y0;
960
1000
  svg.setAttribute('viewBox',`${X0} ${Y0} ${X1-X0} ${Y1-Y0}`);
961
1001
  P.members.forEach(ensureMeta);
1002
+ // Auto-dedupe coincident members on first load of each plan — the read can bind one segment to
1003
+ // two labels (a W-size + its MF mark) or overlap tile reads, producing stacked duplicates that
1004
+ // double-count the BOM. Collapse them automatically (keep the most-resolved copy), undoable, once
1005
+ // per plan. The "Duplicates" button stays for any made while editing.
1006
+ if(!P.deduped){P.deduped=true;const red=new Set(redundantDups());
1007
+ if(red.size){(P.undo=P.undo||[]).push(JSON.stringify(P.members));P.members=P.members.filter(m=>!red.has(m.id));
1008
+ scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
962
1009
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
963
1010
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
964
1011
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
@@ -974,6 +1021,18 @@ document.getElementById('revertB').onclick=()=>{if(confirm('Discard ALL saved ed
974
1021
  const _restored=restoreSaved(); // bring back this browser's saved edits before first paint
975
1022
  setPlan(C.active==null?0:C.active);
976
1023
  setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
1024
+ try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on open so shortcuts work before the first click
1025
+ // --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
1026
+ (function(){
1027
+ const src=C.source;if(!src||typeof src!=='object')return;
1028
+ const parts=[];
1029
+ if(src.name)parts.push('read from '+src.name);
1030
+ if(src.sheet)parts.push(src.sheet);
1031
+ if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
1032
+ if(!parts.length)return;
1033
+ const el=document.getElementById('srcStat');if(!el)return;
1034
+ el.textContent=parts.join(' · ');el.style.display='';
1035
+ })();
977
1036
  // --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
978
1037
  // The server records a tweak-contract request; the terminal AI picks it up async and PUTs
979
1038
  // back a revised contract. This is NOT a chat — the status message says so honestly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.31.4",
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": {