@floless/app 0.34.2 → 0.35.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.
@@ -50822,7 +50822,7 @@ function deriveSandboxLicenseDir() {
50822
50822
  deriveSandboxLicenseDir();
50823
50823
 
50824
50824
  // main.ts
50825
- var import_node_child_process8 = require("node:child_process");
50825
+ var import_node_child_process9 = require("node:child_process");
50826
50826
  var import_node_readline2 = require("node:readline");
50827
50827
 
50828
50828
  // index.ts
@@ -50830,7 +50830,7 @@ var import_node_url4 = require("node:url");
50830
50830
  var import_node_path26 = require("node:path");
50831
50831
  var import_node_os18 = require("node:os");
50832
50832
  var import_node_fs28 = require("node:fs");
50833
- var import_node_child_process6 = require("node:child_process");
50833
+ var import_node_child_process7 = require("node:child_process");
50834
50834
 
50835
50835
  // log.mjs
50836
50836
  var import_node_path = require("node:path");
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.34.2" : void 0,
52859
+ define: true ? "0.35.0" : void 0,
52860
52860
  pkgVersion: readPkgVersion()
52861
52861
  });
52862
52862
  }
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
52866
52866
  return "dev";
52867
52867
  }
52868
52868
  function appChannel() {
52869
- return resolveChannel({ isSea: isSea2(), define: true ? "0.34.2" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.35.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -53948,6 +53948,42 @@ function writeTeklaApp(dir, appId, scene, teklaVersion = "2026.0") {
53948
53948
  return flo;
53949
53949
  }
53950
53950
 
53951
+ // tekla-version.ts
53952
+ var import_node_child_process3 = require("node:child_process");
53953
+ var import_node_util = require("node:util");
53954
+ var pexec = (0, import_node_util.promisify)(import_node_child_process3.execFile);
53955
+ var DEFAULT_TEKLA_VERSION = "2026.0";
53956
+ function parseTeklaVersionFromExePath(p) {
53957
+ const m = /Tekla Structures[\\/]+([^\\/]+)[\\/]+bin\b/i.exec(p);
53958
+ return m ? m[1].trim() : null;
53959
+ }
53960
+ async function detectRunningTeklaVersion() {
53961
+ if (process.platform !== "win32") return null;
53962
+ try {
53963
+ const { stdout } = await pexec(
53964
+ "powershell",
53965
+ [
53966
+ "-NoProfile",
53967
+ "-NonInteractive",
53968
+ "-Command",
53969
+ `Get-CimInstance Win32_Process -Filter "Name='TeklaStructures.exe'" | Select-Object -First 1 -ExpandProperty ExecutablePath`
53970
+ ],
53971
+ { timeout: 5e3, windowsHide: true }
53972
+ );
53973
+ const exePath = stdout.trim();
53974
+ if (!exePath) return null;
53975
+ const version = parseTeklaVersionFromExePath(exePath);
53976
+ if (!version) console.warn(`tekla-version: Tekla is running but no version parsed from "${exePath}" \u2014 baking ${DEFAULT_TEKLA_VERSION}`);
53977
+ return version;
53978
+ } catch (e) {
53979
+ console.warn(`tekla-version: could not detect the running Tekla version (${e instanceof Error ? e.message : String(e)}) \u2014 baking ${DEFAULT_TEKLA_VERSION}`);
53980
+ return null;
53981
+ }
53982
+ }
53983
+ async function teklaVersionToBake() {
53984
+ return await detectRunningTeklaVersion() ?? DEFAULT_TEKLA_VERSION;
53985
+ }
53986
+
53951
53987
  // bom-format.ts
53952
53988
  var round1 = (n) => Math.round(n * 10) / 10;
53953
53989
  function contractToBom(contractInput) {
@@ -55187,7 +55223,7 @@ function isGatedAwareRoute(url, method) {
55187
55223
  }
55188
55224
 
55189
55225
  // autostart.mjs
55190
- var import_node_child_process3 = require("node:child_process");
55226
+ var import_node_child_process4 = require("node:child_process");
55191
55227
  var import_node_fs22 = require("node:fs");
55192
55228
  var import_node_os14 = require("node:os");
55193
55229
  var import_node_path19 = require("node:path");
@@ -55296,7 +55332,7 @@ function currentUserId() {
55296
55332
  }
55297
55333
  function removeLegacyRunKey() {
55298
55334
  try {
55299
- (0, import_node_child_process3.execFileSync)("reg", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"], { stdio: "ignore", windowsHide: true });
55335
+ (0, import_node_child_process4.execFileSync)("reg", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"], { stdio: "ignore", windowsHide: true });
55300
55336
  } catch {
55301
55337
  }
55302
55338
  }
@@ -55314,7 +55350,7 @@ function registerAutostart(exePath) {
55314
55350
  const tmp = (0, import_node_path19.join)((0, import_node_os14.tmpdir)(), `floless-autostart-${process.pid}-${Date.now()}.xml`);
55315
55351
  (0, import_node_fs22.writeFileSync)(tmp, "\uFEFF" + xml, { encoding: "utf16le" });
55316
55352
  try {
55317
- (0, import_node_child_process3.execFileSync)("schtasks", ["/Create", "/TN", TASK_NAME, "/XML", tmp, "/F"], {
55353
+ (0, import_node_child_process4.execFileSync)("schtasks", ["/Create", "/TN", TASK_NAME, "/XML", tmp, "/F"], {
55318
55354
  stdio: ["ignore", "ignore", "ignore"],
55319
55355
  windowsHide: true
55320
55356
  });
@@ -55332,7 +55368,7 @@ function registerAutostart(exePath) {
55332
55368
  function autostartPresent() {
55333
55369
  if (!isWin) return false;
55334
55370
  try {
55335
- (0, import_node_child_process3.execFileSync)("schtasks", ["/query", "/tn", TASK_NAME], { stdio: "ignore", windowsHide: true });
55371
+ (0, import_node_child_process4.execFileSync)("schtasks", ["/query", "/tn", TASK_NAME], { stdio: "ignore", windowsHide: true });
55336
55372
  return true;
55337
55373
  } catch {
55338
55374
  return false;
@@ -55349,14 +55385,14 @@ function ensureAutostart(exePath) {
55349
55385
  function unregisterAutostart() {
55350
55386
  if (!isWin) return;
55351
55387
  try {
55352
- (0, import_node_child_process3.execFileSync)("schtasks", ["/delete", "/tn", TASK_NAME, "/f"], { stdio: "ignore", windowsHide: true });
55388
+ (0, import_node_child_process4.execFileSync)("schtasks", ["/delete", "/tn", TASK_NAME, "/f"], { stdio: "ignore", windowsHide: true });
55353
55389
  } catch {
55354
55390
  }
55355
55391
  removeLegacyRunKey();
55356
55392
  }
55357
55393
 
55358
55394
  // updater.ts
55359
- var import_node_child_process4 = require("node:child_process");
55395
+ var import_node_child_process5 = require("node:child_process");
55360
55396
  var import_node_crypto6 = require("node:crypto");
55361
55397
  var import_node_fs24 = require("node:fs");
55362
55398
  var import_node_stream = require("node:stream");
@@ -55547,7 +55583,7 @@ async function applyUpdate(check, opts) {
55547
55583
  const pkg = await downloadPackage(check.asset);
55548
55584
  if (opts?.onBeforeApply) await opts.onBeforeApply();
55549
55585
  await new Promise((resolve6, reject) => {
55550
- const child = (0, import_node_child_process4.spawn)(exe, ["apply", "--package", pkg, "--waitPid", String(process.pid)], {
55586
+ const child = (0, import_node_child_process5.spawn)(exe, ["apply", "--package", pkg, "--waitPid", String(process.pid)], {
55551
55587
  cwd: installRoot(),
55552
55588
  detached: true,
55553
55589
  stdio: "ignore",
@@ -55780,7 +55816,7 @@ function isTraceCorrupt(events) {
55780
55816
  }
55781
55817
 
55782
55818
  // launch.mjs
55783
- var import_node_child_process5 = require("node:child_process");
55819
+ var import_node_child_process6 = require("node:child_process");
55784
55820
  var import_node_path22 = require("node:path");
55785
55821
  var import_node_url2 = require("node:url");
55786
55822
  var import_node_fs25 = require("node:fs");
@@ -55866,7 +55902,7 @@ function startServerDetached() {
55866
55902
  const { cmd, args, shell } = resolveServerStart();
55867
55903
  rotateLog();
55868
55904
  const fd = openLogFd();
55869
- const child = (0, import_node_child_process5.spawn)(cmd, args, {
55905
+ const child = (0, import_node_child_process6.spawn)(cmd, args, {
55870
55906
  cwd: __dirname2,
55871
55907
  detached: true,
55872
55908
  stdio: fd == null ? "ignore" : ["ignore", fd, fd],
@@ -55876,13 +55912,13 @@ function startServerDetached() {
55876
55912
  child.unref();
55877
55913
  }
55878
55914
  function openBrowser2(url) {
55879
- if (isWin2) (0, import_node_child_process5.spawn)("cmd", ["/c", "start", "", url], { windowsHide: true }).unref();
55880
- else (0, import_node_child_process5.spawn)(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true }).unref();
55915
+ if (isWin2) (0, import_node_child_process6.spawn)("cmd", ["/c", "start", "", url], { windowsHide: true }).unref();
55916
+ else (0, import_node_child_process6.spawn)(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true }).unref();
55881
55917
  }
55882
55918
  function stopServer() {
55883
55919
  if (!isWin2) {
55884
55920
  try {
55885
- (0, import_node_child_process5.execSync)(`bash -lc "fuser -k ${PORT}/tcp"`, { stdio: "ignore" });
55921
+ (0, import_node_child_process6.execSync)(`bash -lc "fuser -k ${PORT}/tcp"`, { stdio: "ignore" });
55886
55922
  return true;
55887
55923
  } catch {
55888
55924
  return false;
@@ -55890,7 +55926,7 @@ function stopServer() {
55890
55926
  }
55891
55927
  try {
55892
55928
  const ps = `$p = Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -Expand OwningProcess -Unique; if ($p) { $p | ForEach-Object { taskkill /PID $_ /T /F } } else { exit 9 }`;
55893
- (0, import_node_child_process5.execSync)(`powershell -NoProfile -Command "${ps}"`, { stdio: "ignore" });
55929
+ (0, import_node_child_process6.execSync)(`powershell -NoProfile -Command "${ps}"`, { stdio: "ignore" });
55894
55930
  return true;
55895
55931
  } catch {
55896
55932
  return false;
@@ -55942,7 +55978,7 @@ async function cmdSupervise() {
55942
55978
  if (isSeaChannel && !process.env[SUPERVISE_RESPAWN_ENV]) {
55943
55979
  rotateLog();
55944
55980
  const fd = openLogFd();
55945
- const child = (0, import_node_child_process5.spawn)(process.execPath, ["--supervise"], {
55981
+ const child = (0, import_node_child_process6.spawn)(process.execPath, ["--supervise"], {
55946
55982
  detached: true,
55947
55983
  stdio: fd == null ? "ignore" : ["ignore", fd, fd],
55948
55984
  windowsHide: true,
@@ -55997,7 +56033,7 @@ function enumerateProcesses() {
55997
56033
  if (!isWin2) return [];
55998
56034
  try {
55999
56035
  const ps = "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress";
56000
- const out = (0, import_node_child_process5.execSync)(`powershell -NoProfile -Command "${ps}"`, {
56036
+ const out = (0, import_node_child_process6.execSync)(`powershell -NoProfile -Command "${ps}"`, {
56001
56037
  encoding: "utf8",
56002
56038
  windowsHide: true,
56003
56039
  maxBuffer: 16 * 1024 * 1024
@@ -56020,7 +56056,7 @@ function killSupervisor({ tree = true } = {}) {
56020
56056
  for (const pid of pids) {
56021
56057
  log(`stopping supervisor (pid ${pid})\u2026`);
56022
56058
  try {
56023
- (0, import_node_child_process5.execFileSync)("taskkill", taskkillArgs(pid, { tree }), { stdio: "ignore", windowsHide: true });
56059
+ (0, import_node_child_process6.execFileSync)("taskkill", taskkillArgs(pid, { tree }), { stdio: "ignore", windowsHide: true });
56024
56060
  } catch {
56025
56061
  }
56026
56062
  }
@@ -56111,11 +56147,11 @@ async function cmdUpdate() {
56111
56147
  function removeRegistryFootprint() {
56112
56148
  if (!isWin2) return;
56113
56149
  try {
56114
- (0, import_node_child_process5.execFileSync)("reg", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"], { stdio: "ignore", windowsHide: true });
56150
+ (0, import_node_child_process6.execFileSync)("reg", ["delete", RUN_KEY, "/v", RUN_VALUE, "/f"], { stdio: "ignore", windowsHide: true });
56115
56151
  } catch {
56116
56152
  }
56117
56153
  try {
56118
- (0, import_node_child_process5.execFileSync)("reg", ["delete", PROTOCOL_KEY, "/f"], { stdio: "ignore", windowsHide: true });
56154
+ (0, import_node_child_process6.execFileSync)("reg", ["delete", PROTOCOL_KEY, "/f"], { stdio: "ignore", windowsHide: true });
56119
56155
  } catch {
56120
56156
  }
56121
56157
  }
@@ -56141,13 +56177,13 @@ async function cmdUninstall(flags = {}) {
56141
56177
  if (removeAware) {
56142
56178
  log("removing the AWARE runtime \u2014 this will affect ANY other tool that uses @aware-aeco/cli.");
56143
56179
  try {
56144
- (0, import_node_child_process5.execSync)("npm uninstall -g @aware-aeco/cli", { stdio: "inherit", shell: isWin2 });
56180
+ (0, import_node_child_process6.execSync)("npm uninstall -g @aware-aeco/cli", { stdio: "inherit", shell: isWin2 });
56145
56181
  } catch {
56146
56182
  log('warning: "npm uninstall -g @aware-aeco/cli" failed \u2014 see the output above.');
56147
56183
  }
56148
56184
  let lsJson = "";
56149
56185
  try {
56150
- lsJson = (0, import_node_child_process5.execSync)("npm ls -g @aware-aeco/cli --depth=0 --json", {
56186
+ lsJson = (0, import_node_child_process6.execSync)("npm ls -g @aware-aeco/cli --depth=0 --json", {
56151
56187
  encoding: "utf8",
56152
56188
  stdio: ["ignore", "pipe", "ignore"],
56153
56189
  shell: isWin2
@@ -58226,7 +58262,7 @@ async function startServer() {
58226
58262
  const isWin3 = process.platform === "win32";
58227
58263
  const has = (cmd) => {
58228
58264
  try {
58229
- (0, import_node_child_process6.execFileSync)(isWin3 ? "where" : "which", [cmd], { stdio: "ignore", windowsHide: true, timeout: 5e3 });
58265
+ (0, import_node_child_process7.execFileSync)(isWin3 ? "where" : "which", [cmd], { stdio: "ignore", windowsHide: true, timeout: 5e3 });
58230
58266
  return true;
58231
58267
  } catch {
58232
58268
  return false;
@@ -58235,7 +58271,7 @@ async function startServer() {
58235
58271
  function installAwareGlobal(spec, onLine = () => {
58236
58272
  }) {
58237
58273
  return new Promise((resolve6, reject) => {
58238
- const child = (0, import_node_child_process6.spawn)("npm", ["i", "-g", `@aware-aeco/cli@${spec}`], { shell: isWin3, windowsHide: true });
58274
+ const child = (0, import_node_child_process7.spawn)("npm", ["i", "-g", `@aware-aeco/cli@${spec}`], { shell: isWin3, windowsHide: true });
58239
58275
  let stderrTail = "";
58240
58276
  let combinedTail = "";
58241
58277
  const tail = (buf, s) => (buf + s).slice(-4e3);
@@ -58625,7 +58661,14 @@ async function startServer() {
58625
58661
  const first = v.errors[0];
58626
58662
  return reply.status(400).send({ ok: false, error: `contract failed schema validation \u2014 ${first ? `${first.path}: ${first.message}` : "invalid"}` });
58627
58663
  }
58628
- const target = typeof req.body?.target === "number" ? req.body.target : 70;
58664
+ const docTarget = doc2 && typeof doc2 === "object" ? doc2.target_confidence : void 0;
58665
+ let appDefault = 70;
58666
+ try {
58667
+ const inp = readApp(req.params.appId).inputs.find((i) => i.name === "target_confidence");
58668
+ if (typeof inp?.default === "number") appDefault = inp.default;
58669
+ } catch {
58670
+ }
58671
+ const target = typeof req.body?.target === "number" ? req.body.target : typeof docTarget === "number" ? docTarget : appDefault;
58629
58672
  const result = scoreContract2(doc2);
58630
58673
  const meetsTarget = result.score != null && result.score >= target;
58631
58674
  return {
@@ -58637,6 +58680,19 @@ async function startServer() {
58637
58680
  };
58638
58681
  }
58639
58682
  );
58683
+ app.post(
58684
+ "/api/contract/:appId/scene",
58685
+ async (req, reply) => {
58686
+ const doc2 = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
58687
+ if (doc2 == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
58688
+ const v = validateSteelTakeoff(doc2);
58689
+ if (!v.valid) {
58690
+ const first = v.errors[0];
58691
+ return reply.status(400).send({ ok: false, error: `contract failed schema validation \u2014 ${first ? `${first.path}: ${first.message}` : "invalid"}` });
58692
+ }
58693
+ return { ok: true, ...contractToScene(doc2) };
58694
+ }
58695
+ );
58640
58696
  function awareStderr(e) {
58641
58697
  if (e instanceof AwareError && e.detail && typeof e.detail === "object") {
58642
58698
  const s = e.detail.stderr;
@@ -58732,9 +58788,10 @@ async function startServer() {
58732
58788
  return reply.status(422).send({ ok: false, error: "no resolvable members to bake \u2014 every member is an RFI (no AISC size yet)", skipped });
58733
58789
  }
58734
58790
  const companionId = `${req.params.appId}-tekla`;
58791
+ const teklaVersion = await teklaVersionToBake();
58735
58792
  let flo;
58736
58793
  try {
58737
- flo = writeTeklaApp(appPath(companionId), companionId, scene);
58794
+ flo = writeTeklaApp(appPath(companionId), companionId, scene, teklaVersion);
58738
58795
  } catch (e) {
58739
58796
  app.log.error({ companionId, err: e instanceof Error ? e.message : e }, "export-tekla: companion app write failed");
58740
58797
  return reply.status(500).send({ ok: false, error: `could not write the Tekla companion app: ${e instanceof Error ? e.message : "write error"}` });
@@ -59526,10 +59583,10 @@ async function startServer() {
59526
59583
  }
59527
59584
 
59528
59585
  // protocol.ts
59529
- var import_node_child_process7 = require("node:child_process");
59586
+ var import_node_child_process8 = require("node:child_process");
59530
59587
  var BASE = PROTOCOL_KEY;
59531
59588
  function reg(args) {
59532
- (0, import_node_child_process7.execFileSync)("reg", args, { stdio: "ignore", windowsHide: true });
59589
+ (0, import_node_child_process8.execFileSync)("reg", args, { stdio: "ignore", windowsHide: true });
59533
59590
  }
59534
59591
  function registerProtocol(exePath) {
59535
59592
  if (process.platform !== "win32") return;
@@ -59569,13 +59626,13 @@ async function removeAwarePerDecision(removeAwareFlag, prompt) {
59569
59626
  );
59570
59627
  const shell = process.platform === "win32" ? process.env.ComSpec || "cmd.exe" : "/bin/sh";
59571
59628
  try {
59572
- (0, import_node_child_process8.execSync)("npm uninstall -g @aware-aeco/cli", { stdio: "inherit", shell });
59629
+ (0, import_node_child_process9.execSync)("npm uninstall -g @aware-aeco/cli", { stdio: "inherit", shell });
59573
59630
  } catch {
59574
59631
  process.stdout.write('floless: warning: "npm uninstall -g @aware-aeco/cli" failed \u2014 see the output above.\n');
59575
59632
  }
59576
59633
  let lsJson = "";
59577
59634
  try {
59578
- lsJson = (0, import_node_child_process8.execSync)("npm ls -g @aware-aeco/cli --depth=0 --json", {
59635
+ lsJson = (0, import_node_child_process9.execSync)("npm ls -g @aware-aeco/cli --depth=0 --json", {
59579
59636
  encoding: "utf8",
59580
59637
  stdio: ["ignore", "pipe", "ignore"],
59581
59638
  shell
@@ -8,6 +8,7 @@
8
8
  "additionalProperties": true,
9
9
  "properties": {
10
10
  "type": { "const": "steel.takeoff/v1", "description": "Renderer-registry discriminator." },
11
+ "target_confidence": { "type": "number", "minimum": 0, "maximum": 100, "description": "User's per-read confidence target (%); overrides the app's target_confidence input default. The editor chip and the /score endpoint use it." },
11
12
  "active": { "type": "integer", "description": "Index into plans of the active sheet." },
12
13
  "palette": { "type": "array", "items": { "type": "string" }, "description": "Default per-index profile colours." },
13
14
  "weights": { "type": "object", "additionalProperties": { "type": ["number", "null"] }, "description": "profile -> plf (pounds per linear foot), from the AISC lookup. null for an unresolved profile (e.g. an unsized moment-frame mark) that has no weight yet." },
@@ -117,9 +117,12 @@ Follow the methodology condensed here (full detail + every gotcha in
117
117
 
118
118
  ### 3. Honor `target_confidence` — score, then LOOP to the goal
119
119
 
120
- Read the app's `target_confidence` input (default 70). The confidence score is a **deterministic
121
- function of observable evidence** not a model self-report so you measure it with the server,
122
- never by eyeballing. Per-member bands (full method in
120
+ Read the app's `target_confidence` input (default 70). The user may also set a **per-read override**
121
+ via the editor (stored as a top-level `target_confidence` on the contract); it wins over the input
122
+ default. On a **re-read / rebake**, preserve any `target_confidence` already on the existing contract
123
+ (carry it into the new draft) so the user's chosen target isn't reset. The confidence score is a
124
+ **deterministic function of observable evidence** — not a model self-report — so you measure it with
125
+ the server, never by eyeballing. Per-member bands (full method in
123
126
  `docs/superpowers/specs/2026-06-20-steel-takeoff-confidence-report.md`):
124
127
 
125
128
  - **Verified (1.0)** — human-confirmed. **High (0.9)** — resolved from drawing evidence, no contradiction.
@@ -0,0 +1,5 @@
1
+ {
2
+ "private": true,
3
+ "type": "module",
4
+ "//": "Marks web/*.js as ES modules so node (the steel-3d-core unit tests) and the browser agree. Not an npm package — just a module-type marker. The browser ignores it; the bundle copies it harmlessly."
5
+ }
@@ -0,0 +1,137 @@
1
+ /*
2
+ * steel-3d-core.js — PURE, framework-free core for the steel 3D editor (no Three.js, no DOM, no
3
+ * window). Unit-tested in node (server/steel-3d-core.test.ts) AND loaded by web/steel-3d-view.js.
4
+ * This is the extraction-ready unit: the generic snap engine + the typed-candidate model are what
5
+ * later lift into AWARE viewer-3d; only `snapCandidates` (beam top-flange centerline, column
6
+ * plan-center) is steel-specific.
7
+ *
8
+ * Coordinate map MUST match server/contract-to-scene.ts (the bake) so editing-space == render-space:
9
+ * scene is mm, Z-up. mmX = dispX·k, mmY = -dispY·k (k = FT_TO_MM / pt_per_ft, Y flipped),
10
+ * mmZ = elevation_inches · IN_TO_MM. planPointToWp is the inverse (editing-only; the bake never
11
+ * needs it).
12
+ */
13
+
14
+ export const FT_TO_MM = 304.8;
15
+ export const IN_TO_MM = 25.4;
16
+
17
+ const kOf = (ptPerFt) => FT_TO_MM / (ptPerFt > 0 ? ptPerFt : 1);
18
+
19
+ const nz = (n) => n + 0; // normalize -0 → 0 (negating 0 yields -0, which surprises equality checks)
20
+
21
+ /** display-space wp point → scene mm [x,y] (Y flipped, matching contractToScene.dispToMm). */
22
+ export function dispToMm(pt, ptPerFt) { const k = kOf(ptPerFt); return [nz(pt[0] * k), nz(-pt[1] * k)]; }
23
+
24
+ /** scene mm point [x,y,…] → display-space wp [px,py] (inverse of dispToMm). */
25
+ export function planPointToWp(p, ptPerFt) { const k = kOf(ptPerFt); return [nz(p[0] / k), nz(-p[1] / k)]; }
26
+
27
+ /**
28
+ * member → its 3D centerline `{ kind, line:[[x,y,z],[x,y,z]] }` in mm, matching contractToScene's
29
+ * from/to. Beam: work-line at T.O.S. Column: vertical at the plan point, bos→tos.
30
+ */
31
+ export function memberGeometry(m, ptPerFt, defaultTosMm = 0) {
32
+ const a = dispToMm(m.wp[0], ptPerFt), b = dispToMm(m.wp[1], ptPerFt);
33
+ if (m.role === 'column') {
34
+ const bos = m.col && m.col.bos != null ? m.col.bos * IN_TO_MM : 0;
35
+ const tos = m.col && m.col.tos != null ? m.col.tos * IN_TO_MM : defaultTosMm;
36
+ return { kind: 'column', line: [[a[0], a[1], bos], [a[0], a[1], tos]] };
37
+ }
38
+ const e = m.ends || [];
39
+ const z0 = e[0] && e[0].tos != null ? e[0].tos * IN_TO_MM : defaultTosMm;
40
+ const z1 = e[1] && e[1].tos != null ? e[1].tos * IN_TO_MM : defaultTosMm;
41
+ return { kind: 'beam', line: [[a[0], a[1], z0], [b[0], b[1], z1]] };
42
+ }
43
+
44
+ /** XY intersection point of segments a-b and c-d (within both), or null (parallel / outside). */
45
+ function segXY(a, b, c, d) {
46
+ const rx = b[0] - a[0], ry = b[1] - a[1], sx = d[0] - c[0], sy = d[1] - c[1];
47
+ const rxs = rx * sy - ry * sx;
48
+ if (Math.abs(rxs) < 1e-9) return null; // parallel/collinear
49
+ const qpx = c[0] - a[0], qpy = c[1] - a[1];
50
+ const t = (qpx * sy - qpy * sx) / rxs, u = (qpx * ry - qpy * rx) / rxs;
51
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null; // crossing lies outside a segment
52
+ return [a[0] + rx * t, a[1] + ry * t];
53
+ }
54
+
55
+ /**
56
+ * Snap targets from the other members (the dragged one excluded). Beam → its top-flange centerline
57
+ * (the work-line at T.O.S) + endpoint vertices + a midpoint. Column → a vertical axis at its plan
58
+ * point + endpoint vertices. Plus member-member centerline INTERSECTIONS (Tekla-standard). Each
59
+ * tagged `fromId`. Generic `grid-line` targets (from plan grids) plug in here too.
60
+ */
61
+ export function snapCandidates(members, ptPerFt, defaultTosMm, excludeId) {
62
+ const out = [];
63
+ const lines = []; // beam centerlines, for pairwise intersections
64
+ for (const m of members || []) {
65
+ if (!m || m.id === excludeId || !Array.isArray(m.wp) || m.wp.length < 2) continue;
66
+ const { kind, line } = memberGeometry(m, ptPerFt, defaultTosMm);
67
+ const [p0, p1] = line;
68
+ if (kind === 'column') { out.push({ type: 'vertical-axis', p: p0, fromId: m.id }); }
69
+ else {
70
+ out.push({ type: 'centerline', a: p0, b: p1, fromId: m.id });
71
+ out.push({ type: 'midpoint', p: [(p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2], fromId: m.id });
72
+ lines.push({ id: m.id, p0, p1 });
73
+ }
74
+ out.push({ type: 'vertex', p: p0, fromId: m.id });
75
+ out.push({ type: 'vertex', p: p1, fromId: m.id });
76
+ }
77
+ for (let i = 0; i < lines.length; i++) {
78
+ for (let j = i + 1; j < lines.length; j++) {
79
+ const x = segXY(lines[i].p0, lines[i].p1, lines[j].p0, lines[j].p1);
80
+ if (x) out.push({ type: 'intersection', p: [x[0], x[1], (lines[i].p0[2] + lines[j].p0[2]) / 2], fromIds: [lines[i].id, lines[j].id] });
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * Distinct elevation levels (mm, sorted) among the OTHER members' endpoints — the snap targets for a
88
+ * vertical (Alt) drag, so a member can be raised/lowered onto another's T.O.S level. The caller turns
89
+ * each level into a point candidate at the dragged member's plan X/Y and reuses snapPoint().
90
+ */
91
+ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
92
+ const set = new Set();
93
+ for (const m of members || []) {
94
+ if (!m || m.id === excludeId || !Array.isArray(m.wp) || m.wp.length < 2) continue;
95
+ const { line } = memberGeometry(m, ptPerFt, defaultTosMm);
96
+ set.add(Math.round(line[0][2])); set.add(Math.round(line[1][2]));
97
+ }
98
+ return [...set].sort((a, b) => a - b);
99
+ }
100
+
101
+ // Lower wins when two candidates are both within tolerance.
102
+ const PRECEDENCE = { vertex: 0, intersection: 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
103
+
104
+ function closestOnSeg(p, a, b) {
105
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
106
+ const len2 = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2] || 1;
107
+ let t = ((p[0] - a[0]) * ab[0] + (p[1] - a[1]) * ab[1] + (p[2] - a[2]) * ab[2]) / len2;
108
+ t = Math.max(0, Math.min(1, t));
109
+ return [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t];
110
+ }
111
+
112
+ /** The 3D point a candidate proposes for the dragged point. */
113
+ function candidatePoint(c, dragged) {
114
+ if (c.type === 'vertex' || c.type === 'intersection' || c.type === 'midpoint' || c.type === 'level') return c.p; // fixed points
115
+ if (c.type === 'vertical-axis') return [c.p[0], c.p[1], dragged[2]]; // lock plan X/Y to the axis, keep dragged Z
116
+ return closestOnSeg(dragged, c.a, c.b); // centerline / grid-line
117
+ }
118
+
119
+ /**
120
+ * Snap `dragged` (3D mm) to the nearest candidate within `tolPx` SCREEN pixels. `toScreen` maps a 3D
121
+ * mm point → `{x,y}` screen px (injected — keeps this DOM-free). Ties within tolerance break by
122
+ * PRECEDENCE (endpoint > intersection > centerline > axis > grid). Returns `{ snapped, candidate }`;
123
+ * candidate is null (and snapped === dragged) when nothing is in range.
124
+ */
125
+ export function snapPoint(dragged, candidates, toScreen, tolPx) {
126
+ const ds = toScreen(dragged);
127
+ let best = null, bestPt = null, bestPrec = Infinity, bestD = Infinity;
128
+ for (const c of candidates || []) {
129
+ const pt = candidatePoint(c, dragged);
130
+ const s = toScreen(pt);
131
+ const d = Math.hypot(s.x - ds.x, s.y - ds.y);
132
+ if (d > tolPx) continue;
133
+ const prec = PRECEDENCE[c.type] ?? 9;
134
+ if (prec < bestPrec || (prec === bestPrec && d < bestD)) { best = c; bestPt = pt; bestPrec = prec; bestD = d; }
135
+ }
136
+ return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
137
+ }