@floless/app 0.43.0 → 0.45.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.
@@ -0,0 +1,57 @@
1
+ app: tekla-watch-logger
2
+ version: 0.1.0
3
+ display-name: Tekla Watch → file log
4
+ description: |
5
+ Event-triggered demo: a tekla.watch source streams every model change, and a
6
+ downstream exec node appends each event's details (mark, type, change, guid,
7
+ timestamp) to ~/Documents/floless-watch-log.txt — one line per event. Proves the
8
+ "On trigger" chain end-to-end and leaves a durable, app-closed-verifiable record.
9
+ self_test:true drives 5 synthetic events with no live Tekla; against a real model
10
+ it logs forever while the routine is enabled.
11
+ exposes-as-agent: false
12
+ layout: linear
13
+ requires:
14
+ - tekla@0.1.x
15
+ nodes:
16
+ - id: watch # SOURCE — streaming lifecycle:start; one fire per change
17
+ agent: tekla
18
+ command: watch
19
+ config:
20
+ filter: all
21
+ self_test: true
22
+ include_deleted: true
23
+ - id: log # SINK — append this event's details to the file
24
+ agent: tekla
25
+ command: exec
26
+ mode: read # author intent; AWARE defaults exec→write (issue #165), cosmetic
27
+ config:
28
+ version: "2025.0"
29
+ # Per-event payload bound by the streaming runtime as {{ inputs.<field> }}
30
+ # (orchestrator.propagate_from → execute_and_chain inserts the fired event
31
+ # under `inputs`). Fields: mark, type, change, guid, host, delivered_at.
32
+ args:
33
+ mark: "{{ inputs.mark }}"
34
+ type: "{{ inputs.type }}"
35
+ change: "{{ inputs.change }}"
36
+ guid: "{{ inputs.guid }}"
37
+ at: "{{ inputs.delivered_at }}"
38
+ code: |
39
+ // Append one line per fired event to ~/Documents/floless-watch-log.txt.
40
+ // System.IO is available in exec (System.Net / System.Diagnostics.Process are not).
41
+ string Get(string k) => args != null && args.ContainsKey(k) && args[k] != null
42
+ ? args[k].ToString() : "";
43
+ string mark = Get("mark");
44
+ string type = Get("type");
45
+ string change = Get("change");
46
+ string guid = Get("guid");
47
+ string at = Get("at");
48
+
49
+ string dir = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments);
50
+ string path = System.IO.Path.Combine(dir, "floless-watch-log.txt");
51
+ string stampedAt = string.IsNullOrEmpty(at) ? System.DateTime.UtcNow.ToString("o") : at;
52
+ string line = string.Format("{0}\t{1}\t{2}\t{3}\tguid={4}", stampedAt, change, type, mark, guid);
53
+ System.IO.File.AppendAllText(path, line + System.Environment.NewLine);
54
+
55
+ return new { ok = true, logged = line, path = path };
56
+ connections:
57
+ - { from: watch, to: log }
@@ -0,0 +1,20 @@
1
+ app: tekla-watch-smoke
2
+ version: 0.1.0
3
+ display-name: Tekla Watch (smoke)
4
+ description: |
5
+ Minimal tekla.watch event-source app for capturing the streaming run-trace
6
+ shape (self_test:true drives synthetic events with no live Tekla). Used to
7
+ ground floless.app's "On trigger" fire-detection.
8
+ exposes-as-agent: false
9
+ layout: linear
10
+ requires:
11
+ - tekla@0.1.x
12
+ nodes:
13
+ - id: watch
14
+ agent: tekla
15
+ command: watch
16
+ config:
17
+ filter: all
18
+ self_test: true
19
+ include_deleted: true
20
+ connections: []
@@ -0,0 +1,41 @@
1
+ app: trimble-list-projects
2
+ version: 0.1.0
3
+ display-name: Trimble — list my projects
4
+ description: |
5
+ Lists the Trimble Connect projects you can access (read-only) and renders them
6
+ as an HTML report. Uses the connected `trimble-connect` credential — AWARE
7
+ attaches the stored OAuth token as a Bearer automatically, floless.app never sees
8
+ it — then html-report's generic auto-render turns the result into a table the
9
+ in-app Viewer shows. Foundation demo for Trimble workflow support.
10
+ exposes-as-agent: false
11
+ requires:
12
+ - trimble-connect@0.2.x
13
+ - html-report@0.2.x
14
+ requires-permissions:
15
+ network:
16
+ - https://app.connect.trimble.com
17
+ layout: linear
18
+ # Explicit edge so `report` waits for `projects`. As of AWARE 0.59.0 (#208) a ref
19
+ # like `{{ projects.body }}` auto-derives this edge, so it's belt-and-suspenders now —
20
+ # kept for clarity.
21
+ connections:
22
+ - from: projects
23
+ to: report
24
+ nodes:
25
+ - id: projects
26
+ agent: trimble-connect
27
+ command: list-projects
28
+ config:
29
+ top: 100
30
+ - id: report
31
+ agent: html-report
32
+ command: render
33
+ config:
34
+ data: '{{ projects.body }}'
35
+ title: My Trimble Connect projects
36
+ # The API returns ~20 columns (incl. raw thumbnail URLs + createdBy JSON blobs);
37
+ # keep a clean, useful set and lead with the most recently modified.
38
+ # (html-report columns + sort, AWARE 0.61.0 / #210.)
39
+ columns: [name, location, modifiedOn, filesCount, foldersCount, access]
40
+ sort: modifiedOn
41
+ sort-desc: true
@@ -0,0 +1,65 @@
1
+ app: viewer-3d-demo
2
+ version: 0.1.0
3
+ display-name: 3D Viewer Demo
4
+ description: |
5
+ A self-contained demo of the viewer-3d agent — renders a small steel portal frame as an
6
+ interactive 3D model in the in-app viewer (orbit/zoom, click a member to inspect, a legend
7
+ and a takeoff table). No Tekla model required: a single viewer-3d `render` node with an
8
+ inline, domain-agnostic scene. Use it to preview the interactive 3D surface end to end.
9
+ exposes-as-agent: false
10
+ requires:
11
+ - viewer-3d@0.1.x
12
+ layout: linear
13
+ nodes:
14
+ # The viewer-3d render node IS the terminal: it returns the interactive HTML the viewer shows.
15
+ - id: view
16
+ agent: viewer-3d
17
+ command: render
18
+ config:
19
+ scene:
20
+ meta: { name: "Sample portal frame — 20 m × 24 m", units: mm, up: z }
21
+ groups:
22
+ - { key: column, label: Columns, color: "#60a5fa" }
23
+ - { key: rafter, label: Rafters, color: "#38bdf8" }
24
+ - { key: beam, label: Beams, color: "#94a3b8" }
25
+ - { key: brace, label: Bracing, color: "#818cf8" }
26
+ elements:
27
+ # columns (z 0 -> 6000)
28
+ - { id: COL-A1, group: column, kind: box, from: [0,0,0], to: [0,0,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
29
+ - { id: COL-C1, group: column, kind: box, from: [20000,0,0], to: [20000,0,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
30
+ - { id: COL-A2, group: column, kind: box, from: [0,6000,0], to: [0,6000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
31
+ - { id: COL-C2, group: column, kind: box, from: [20000,6000,0], to: [20000,6000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
32
+ - { id: COL-A3, group: column, kind: box, from: [0,12000,0], to: [0,12000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
33
+ - { id: COL-C3, group: column, kind: box, from: [20000,12000,0], to: [20000,12000,6000], section: { w: 310, d: 310 }, meta: { profile: "UC 305x305x97" } }
34
+ # rafters (eave -> apex -> eave) per frame
35
+ - { id: RAF-L1, group: rafter, kind: box, from: [0,0,6000], to: [10000,0,7500], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
36
+ - { id: RAF-R1, group: rafter, kind: box, from: [10000,0,7500], to: [20000,0,6000], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
37
+ - { id: RAF-L2, group: rafter, kind: box, from: [0,6000,6000], to: [10000,6000,7500], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
38
+ - { id: RAF-R2, group: rafter, kind: box, from: [10000,6000,7500], to: [20000,6000,6000], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
39
+ - { id: RAF-L3, group: rafter, kind: box, from: [0,12000,6000], to: [10000,12000,7500], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
40
+ - { id: RAF-R3, group: rafter, kind: box, from: [10000,12000,7500], to: [20000,12000,6000], section: { w: 190, d: 460 }, meta: { profile: "UB 457x191x67" } }
41
+ # eaves beams (along Y, z 6000) + ridge (x 10000, z 7500)
42
+ - { id: EAV-A1, group: beam, kind: box, from: [0,0,6000], to: [0,6000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
43
+ - { id: EAV-A2, group: beam, kind: box, from: [0,6000,6000], to: [0,12000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
44
+ - { id: EAV-C1, group: beam, kind: box, from: [20000,0,6000], to: [20000,6000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
45
+ - { id: EAV-C2, group: beam, kind: box, from: [20000,6000,6000], to: [20000,12000,6000], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
46
+ - { id: RDG-1, group: beam, kind: box, from: [10000,0,7500], to: [10000,6000,7500], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
47
+ - { id: RDG-2, group: beam, kind: box, from: [10000,6000,7500], to: [10000,12000,7500], section: { w: 170, d: 310 }, meta: { profile: "UB 305x165x40" } }
48
+ # vertical X-bracing (end bay, wall A)
49
+ - { id: BR-A1, group: brace, kind: box, from: [0,0,0], to: [0,6000,6000], section: { w: 140, d: 140 }, meta: { profile: "CHS 139.7x5.0" } }
50
+ - { id: BR-A2, group: brace, kind: box, from: [0,0,6000], to: [0,6000,0], section: { w: 140, d: 140 }, meta: { profile: "CHS 139.7x5.0" } }
51
+ grids:
52
+ - { label: A, at: [0,-2000,0] }
53
+ - { label: B, at: [10000,-2000,0] }
54
+ - { label: C, at: [20000,-2000,0] }
55
+ - { label: "1", at: [-2000,0,0] }
56
+ - { label: "3", at: [-2000,12000,0] }
57
+ panels:
58
+ - title: Takeoff
59
+ note: "Counted from the scene — no Tekla licence."
60
+ columns: [Section, "No.", Length]
61
+ rows:
62
+ - ["UC 305x305x97", 6, "36.0 m"]
63
+ - ["UB 457x191x67", 6, "60.7 m"]
64
+ - ["UB 305x165x40", 6, "36.0 m"]
65
+ - ["CHS 139.7x5.0", 2, "17.0 m"]
package/dist/web/app.css CHANGED
@@ -1599,6 +1599,23 @@
1599
1599
  .app-update:hover { border-color: var(--accent); color: var(--accent); }
1600
1600
  .app-update[disabled] { opacity: 0.6; cursor: default; }
1601
1601
 
1602
+ /* Workflow-update pill (a published workflow has a newer version) — mirrors the app-update pill. */
1603
+ .wf-update-pill { display: none; border: 1px solid var(--accent-dim); background: var(--accent-soft); color: var(--accent-bright); font-size: 10px; padding: 1px 7px; border-radius: 999px; cursor: pointer; margin-left: 8px; line-height: 1.5; }
1604
+ .wf-update-pill:not([hidden]) { display: inline-flex; align-items: center; gap: 4px; }
1605
+ .wf-update-pill:hover { border-color: var(--accent); color: var(--accent); }
1606
+ .wf-update-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1607
+ /* Changelog list inside the update modal — themed scroll container (no native bar leaks). */
1608
+ .wf-update-changelog { max-height: 280px; overflow-y: auto; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
1609
+ .wf-update-changelog::-webkit-scrollbar { width: 6px; }
1610
+ .wf-update-changelog::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
1611
+ .wf-update-changelog::-webkit-scrollbar-track { background: transparent; }
1612
+ .wf-update-changelog .wf-ver-head { font-family: var(--mono); font-weight: 700; color: var(--text); font-size: 12px; }
1613
+ .wf-update-changelog .wf-ver-sum { color: var(--text-muted); font-size: 11px; margin: 2px 0 6px; }
1614
+ .wf-update-changelog ul { margin: 0; padding-left: 16px; }
1615
+ .wf-update-changelog li { color: var(--text); font-size: 12px; margin: 2px 0; }
1616
+ #wf-update-sub { color: var(--text-muted); }
1617
+ .wf-update-note { font-size: 11px; color: var(--text-muted); margin: 0 0 16px; line-height: 1.5; padding-top: 12px; border-top: 1px solid var(--border); }
1618
+
1602
1619
  /* Add/edit form */
1603
1620
  .modal.routine-edit { width: 540px; max-width: 92vw; max-height: 90vh; overflow-y: auto; }
1604
1621
  #rtn-workflow, .rtn-kind-select { min-width: 0; width: 100%; }
package/dist/web/aware.js CHANGED
@@ -533,6 +533,8 @@
533
533
 
534
534
  // ── load / select / compile / run ───────────────────────────────────────────
535
535
 
536
+ let _wfUpdates = []; // workflow updates available, keyed by app id — polled by refreshWorkflowUpdates() below
537
+
536
538
  async function loadApp(id) {
537
539
  const { app } = await api(`/api/app/${encodeURIComponent(id)}`);
538
540
  apps.set(id, app);
@@ -554,6 +556,7 @@
554
556
  markSpecialNodes();
555
557
  paintGate(app);
556
558
  refreshDirtyIndicator(); // clean app → dot off; switched back to a dirty one → dot on
559
+ renderWfUpdatePill(); // show the "↑ Update to vX" pill when THIS app has a newer published version
557
560
  }
558
561
 
559
562
  async function loadApps() {
@@ -2826,6 +2829,82 @@
2826
2829
  setInterval(refreshUpdate, 60 * 60 * 1000); // hourly backstop; the focus re-check (below) is the fast path
2827
2830
  }
2828
2831
 
2832
+ // ── Workflow update (module-shaped) ─────────────────────────────────────────────
2833
+ // A FloLess-published workflow (e.g. steel-takeoff) has a newer bundled template. Poll GET
2834
+ // /api/workflows/updates; when the OPEN app has one, show a pill → modal with the changelog →
2835
+ // "Back up & update" POSTs /api/workflows/:id/update (backup → swap structure → re-bake the user's
2836
+ // data from the contract store → recompile) → reload the app. No auto-update. `_wfUpdates` + the
2837
+ // renderWfUpdatePill() call in loadApp() live earlier; renderWfUpdatePill queries the DOM so it's
2838
+ // safe to call before this block initialises.
2839
+ const $wfUpdate = document.getElementById('wf-update');
2840
+ const $wfUpdateModal = document.getElementById('wf-update-modal');
2841
+ let _wfUpdateApplying = false;
2842
+ function wfUpdateFor(id) { return _wfUpdates.find((u) => u.id === id) || null; }
2843
+ function renderWfUpdatePill() {
2844
+ const el = document.getElementById('wf-update');
2845
+ if (!el) return;
2846
+ const u = currentId ? wfUpdateFor(currentId) : null;
2847
+ if (u) {
2848
+ el.textContent = '↑ Update to v' + u.available;
2849
+ el.dataset.tip = 'A newer version of this workflow is available — see what changed';
2850
+ el.hidden = false;
2851
+ } else {
2852
+ el.hidden = true;
2853
+ }
2854
+ }
2855
+ async function refreshWorkflowUpdates() {
2856
+ try {
2857
+ const r = await fetch('/api/workflows/updates');
2858
+ const d = await r.json();
2859
+ _wfUpdates = (r.ok && d.ok && Array.isArray(d.updates)) ? d.updates : [];
2860
+ } catch { _wfUpdates = []; }
2861
+ renderWfUpdatePill();
2862
+ }
2863
+ function openWfUpdateModal(u) {
2864
+ if (!$wfUpdateModal) return;
2865
+ const a = apps.get(u.id);
2866
+ const name = (a && a.displayName) || u.id;
2867
+ document.getElementById('wf-update-title').textContent = `Update “${name}” to v${u.available}`;
2868
+ document.getElementById('wf-update-sub').textContent = `You’re on v${u.installed}. What’s new:`;
2869
+ const changelog = Array.isArray(u.changelog) ? u.changelog : [];
2870
+ document.getElementById('wf-update-changelog').innerHTML = changelog.map((e) => {
2871
+ const changes = Array.isArray(e.changes) && e.changes.length
2872
+ ? '<ul>' + e.changes.map((c) => `<li>${escapeHtml(String(c))}</li>`).join('') + '</ul>' : '';
2873
+ return `<div class="wf-ver"><div class="wf-ver-head">v${escapeHtml(String(e.version))}</div>`
2874
+ + (e.summary ? `<div class="wf-ver-sum">${escapeHtml(String(e.summary))}</div>` : '') + changes + '</div>';
2875
+ }).join('') || '<div class="wf-ver-sum">No changelog provided.</div>';
2876
+ showModal($wfUpdateModal);
2877
+ }
2878
+ async function applyWfUpdate(u) {
2879
+ if (_wfUpdateApplying) return;
2880
+ _wfUpdateApplying = true;
2881
+ const $ok = document.getElementById('wf-update-ok');
2882
+ const prev = $ok ? $ok.textContent : '';
2883
+ if ($ok) { $ok.disabled = true; $ok.textContent = 'Updating…'; }
2884
+ try {
2885
+ const r = await fetch(`/api/workflows/${encodeURIComponent(u.id)}/update`, { method: 'POST', headers: { 'content-type': 'application/json' } });
2886
+ const d = await r.json().catch(() => ({}));
2887
+ if (!r.ok || !d.ok) throw new Error(d.error || 'update failed');
2888
+ hideModal($wfUpdateModal);
2889
+ showToast(`Updated to v${d.toVersion} — your data is preserved, a backup was saved.`, 'ok');
2890
+ await refreshWorkflowUpdates();
2891
+ if (currentId === u.id) await loadApp(currentId); // reload to show the new structure
2892
+ } catch (e) {
2893
+ showToast('Workflow update failed — ' + String((e && e.message) || e).slice(0, 100), 'warn');
2894
+ } finally {
2895
+ _wfUpdateApplying = false;
2896
+ if ($ok) { $ok.disabled = false; $ok.textContent = prev; }
2897
+ }
2898
+ }
2899
+ if ($wfUpdate) {
2900
+ $wfUpdate.onclick = () => { const u = currentId && wfUpdateFor(currentId); if (u) openWfUpdateModal(u); };
2901
+ const $cancel = document.getElementById('wf-update-cancel'); if ($cancel) $cancel.onclick = () => hideModal($wfUpdateModal);
2902
+ const $ok = document.getElementById('wf-update-ok'); if ($ok) $ok.onclick = () => { const u = currentId && wfUpdateFor(currentId); if (u) applyWfUpdate(u); };
2903
+ if ($wfUpdateModal) onBackdropDismiss($wfUpdateModal, () => hideModal($wfUpdateModal));
2904
+ refreshWorkflowUpdates();
2905
+ setInterval(refreshWorkflowUpdates, 60 * 60 * 1000);
2906
+ }
2907
+
2829
2908
  // Dev-override helper — the ONLY forced-visible path. Reveals a pill with a fixed
2830
2909
  // version when localStorage['floless.dev.forceUpdate'] matches the component, so an
2831
2910
  // E2E can exercise the popover without a real pending update. Returns true if forced.
@@ -54,6 +54,7 @@
54
54
  </div>
55
55
  </div>
56
56
  <select id="prompt-select" hidden></select>
57
+ <button id="wf-update" class="wf-update-pill" type="button" hidden aria-haspopup="dialog"></button>
57
58
  <!-- Agents (⊞) and Routines (⏱) buttons moved into the ≡ menu in #149
58
59
  (Ctrl+G / Ctrl+R); the toolbar keeps only the run-critical controls. -->
59
60
  <span class="ctl-sep" aria-hidden="true"></span>
@@ -241,6 +242,22 @@
241
242
  </div>
242
243
  </div>
243
244
 
245
+ <!-- Workflow update: a FloLess-published workflow has a newer version. The changelog (entries
246
+ newer than installed) renders into #wf-update-changelog; "Back up & update" swaps the
247
+ structure but keeps the user's data (re-baked from the contract store). -->
248
+ <div class="modal-backdrop" id="wf-update-modal">
249
+ <div class="modal">
250
+ <div class="modal-title" id="wf-update-title">Workflow update</div>
251
+ <div class="modal-sub" id="wf-update-sub"></div>
252
+ <div class="wf-update-changelog" id="wf-update-changelog"></div>
253
+ <div class="wf-update-note">Your work is safe — this updates the workflow's steps and keeps your data. A backup of the current version is saved first.</div>
254
+ <div class="modal-actions">
255
+ <button id="wf-update-cancel">Not now</button>
256
+ <button id="wf-update-ok" class="primary">↑ Back up &amp; update</button>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
244
261
  <div class="menu" id="menu" role="menu">
245
262
  <button class="menu-item" data-action="open" role="menuitem">
246
263
  <span class="menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg></span>
@@ -135,3 +135,22 @@ export function snapPoint(dragged, candidates, toScreen, tolPx) {
135
135
  }
136
136
  return best ? { snapped: bestPt, candidate: best } : { snapped: dragged, candidate: null };
137
137
  }
138
+
139
+ /**
140
+ * 3D dimension geometry (pure). a,b are scene mm [x,y,z]. axis ∈ free|x|y|z.
141
+ * free → the straight segment a→b (valueMm = full 3D distance); x|y|z →
142
+ * axis-aligned from a, advancing only that one world component to b
143
+ * (valueMm = that component's length). Returns the drawn endpoints, the
144
+ * midpoint (where the label sits), and the measured length in mm.
145
+ */
146
+ export function dim3dGeom(a, b, axis) {
147
+ const p1 = [a[0], a[1], a[2]];
148
+ let p2;
149
+ if (axis === 'x') p2 = [b[0], a[1], a[2]];
150
+ else if (axis === 'y') p2 = [a[0], b[1], a[2]];
151
+ else if (axis === 'z') p2 = [a[0], a[1], b[2]];
152
+ else p2 = [b[0], b[1], b[2]]; // free (default for any unrecognised axis)
153
+ const valueMm = Math.hypot(p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2]);
154
+ const mid = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
155
+ return { p1, p2, mid, valueMm };
156
+ }
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import * as THREE from 'three';
18
18
  import { OrbitControls } from 'three/addons/OrbitControls.js';
19
- import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels } from './steel-3d-core.js';
19
+ import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom } from './steel-3d-core.js';
20
20
 
21
21
  let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
22
22
  let pending = null, dragging = null, marker = null, readout = null; // pending = member gesture; readout = drag dimension overlay
@@ -44,6 +44,18 @@ const SELECT_EMISSIVE = 0x3b82f6; // --brand: selected members glow blue
44
44
  const EP_START = 0xfacc15; // start endpoint (end 1) — yellow
45
45
  const EP_END = 0xf472b6; // end endpoint (end 2) — magenta
46
46
 
47
+ // ---- 3D dimension tool ----
48
+ let dimMode3d = false; // tool armed
49
+ let dimAxis3d = 'free'; // sticky axis: free | x | y | z
50
+ let dims3dVisibleFlag = true; // show/hide placed dims
51
+ let dimDraft3d = null; // { a:[x,y,z] } once the first point is placed (awaiting the 2nd)
52
+ let dimLastAlt = false; // last-seen Alt state (the hover rAF rebuilds a synthetic event)
53
+ let dims3dGroup = null; // THREE.Group of placed-dim lines (+ the live preview line)
54
+ let dimPreviewLine = null; // reused preview line (point 1 → cursor)
55
+ let dimLabelHost = null; // fixed-position container for label overlays
56
+ const dimLabelPool = []; // reused <div> labels, one per placed dim, positioned in the loop
57
+ let dimCandidates3d = null; // snap candidates cached for the active placement gesture
58
+
47
59
  function init(canvas, theApi) {
48
60
  if (renderer) return; // once
49
61
  canvasEl = canvas; api = theApi;
@@ -91,6 +103,14 @@ function init(canvas, theApi) {
91
103
  readout._type = document.createElement('span'); readout._type.style.color = 'var(--mut,#94a3b8)';
92
104
  readout.append(readout._dist, readout._type);
93
105
  document.body.appendChild(readout);
106
+ // 3D dimension tool: a group of placed-dim lines (over the steel) + a live preview line + an HTML
107
+ // overlay host for the midpoint labels (positioned each frame so they face the camera as screen text).
108
+ dims3dGroup = new THREE.Group(); scene.add(dims3dGroup);
109
+ dimPreviewLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x22d3ee }));
110
+ dimPreviewLine.material.depthTest = false; dimPreviewLine.renderOrder = 997; dimPreviewLine.visible = false; scene.add(dimPreviewLine);
111
+ dimLabelHost = document.createElement('div');
112
+ dimLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:57';
113
+ document.body.appendChild(dimLabelHost);
94
114
  // persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
95
115
  hoverChip = document.createElement('div');
96
116
  hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
@@ -130,6 +150,7 @@ function loop() {
130
150
  rafId = requestAnimationFrame(loop);
131
151
  controls.update();
132
152
  sizeEndpoints();
153
+ positionDimLabels();
133
154
  renderer.render(scene, camera);
134
155
  if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
135
156
  }
@@ -328,6 +349,7 @@ async function rebuild(fit = false) {
328
349
  const sc = await api.fetchScene(); // editor POSTs the current contract to /scene
329
350
  if (seq !== rebuildSeq) return; // a newer rebuild superseded this one → drop the stale scene (no out-of-order apply)
330
351
  if (sc) buildFromScene(sc);
352
+ refreshDims(); // persisted dims appear + survive edit-rebuilds (re-coloured for the current selection)
331
353
  }
332
354
 
333
355
  // ---- Persp/Ortho + Solid/Wire/X-ray + group visibility (parity with the AWARE viewer) ----
@@ -406,12 +428,40 @@ function onKey(e) {
406
428
  if (!renderer || !canvasEl || canvasEl.style.display === 'none') return; // only when 3D is active
407
429
  const ae = document.activeElement; if (ae && /^(INPUT|SELECT|TEXTAREA)$/.test(ae.tagName)) return;
408
430
  if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
431
+ const k = e.key.toLowerCase();
432
+ // Don't touch the dim tool while a member gesture (drag / box-select) owns the shared marker/readout —
433
+ // toggling would clear them mid-gesture. The gesture finishes on pointerup; the key is a no-op until then.
434
+ const midGesture = pending || dragging || boxSel;
435
+ if (k === 'd' && !e.ctrlKey && !e.metaKey && !e.altKey && !midGesture) { e.preventDefault(); toggleDimTool(); return; } // D arms/disarms the dimension tool
436
+ if (dimMode3d && !midGesture) {
437
+ if (k === 'escape') { e.preventDefault(); if (dimDraft3d) { dimDraft3d = null; dimPreviewLine.visible = false; readout.style.display = 'none'; } else toggleDimTool(); return; } // Esc: cancel the in-progress dim, else exit the tool
438
+ if (k === 'f' || k === 'x' || k === 'y' || k === 'z') { e.preventDefault(); setDimAxis(k === 'f' ? 'free' : k); reflectDimAxisBar(); return; } // sticky axis: Free / X / Y / Z
439
+ }
409
440
  const a = ARROWS[e.key]; if (!a) return;
410
441
  const [hx, hy] = a;
411
442
  if (e.ctrlKey || e.metaKey) { e.preventDefault(); rotateView(-hx * Math.PI / 12, hy * Math.PI / 12); } // 15°
412
443
  else if (e.shiftKey) { e.preventDefault(); rotateView(-hx * Math.PI / 36, hy * Math.PI / 36); } // 5°
413
444
  else { e.preventDefault(); panView(hx, hy); } // pan
414
445
  }
446
+ // ---- 3D dimension tool controls (driven from the toolbar + the keyboard) ----
447
+ function toggleDimTool() {
448
+ dimMode3d = !dimMode3d;
449
+ if (!dimMode3d) { dimDraft3d = null; dimCandidates3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'default'; }
450
+ else { controls.enabled = true; canvasEl.style.cursor = 'crosshair'; } // show the crosshair on arm, before the first mousemove
451
+ reflectDimBar();
452
+ return dimMode3d;
453
+ }
454
+ function setDimAxis(a) { if (['free', 'x', 'y', 'z'].includes(a)) dimAxis3d = a; }
455
+ function dimAxis() { return dimAxis3d; }
456
+ function setDims3dVisible(on) { dims3dVisibleFlag = !!on; refreshDims(); }
457
+ function dims3dVisible() { return dims3dVisibleFlag; }
458
+ function reflectDimBar() {
459
+ const b = document.getElementById('m3dDim'); if (b) b.classList.toggle('on', dimMode3d);
460
+ const ax = document.getElementById('m3dDimAxis'); if (ax) ax.style.display = dimMode3d ? 'flex' : 'none';
461
+ const sh = document.getElementById('m3dDimShow'); if (sh) sh.style.display = dimMode3d ? 'inline-block' : 'none';
462
+ reflectDimAxisBar();
463
+ }
464
+ function reflectDimAxisBar() { document.querySelectorAll('#m3dDimAxis button').forEach((x) => x.classList.toggle('on', x.dataset.d3axis === dimAxis3d)); }
415
465
  // Tekla "set view point": double-click a member → make that point the orbit/zoom pivot.
416
466
  function onDblClick(e) {
417
467
  camera.updateMatrixWorld(); root.updateMatrixWorld(true);
@@ -479,6 +529,65 @@ function sizeEndpoints() {
479
529
  }
480
530
  }
481
531
 
532
+ // ---- placed 3D dimensions ----
533
+ // Rebuild the placed-dim lines from the editor's dims3d (the single source of truth). Lines render
534
+ // depthTest:false so they're never buried; the selected dim draws amber. Labels are HTML overlays
535
+ // (pool below), so only the line geometry is rebuilt here.
536
+ function refreshDims() {
537
+ if (!dims3dGroup || !api || !api.getDims3d) return;
538
+ dimCandidates3d = null; // a rebuild may follow a member edit → don't keep stale snap targets for the next click
539
+ for (const c of [...dims3dGroup.children]) { dims3dGroup.remove(c); c.geometry.dispose(); c.material.dispose(); }
540
+ const dims = api.getDims3d() || [];
541
+ const sel = api.selDim3d ? api.selDim3d() : null;
542
+ if (dims3dVisibleFlag) {
543
+ for (const d of dims) {
544
+ const g = dim3dGeom(d.a, d.b, d.axis);
545
+ const col = d.id === sel ? 0xf59e0b : 0x67e8f9; // amber (#f59e0b) when selected, else muted cyan-300 — a quieter tint of the 0x22d3ee preview/2D-dim cyan (intentional, not a stray value)
546
+ const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]), new THREE.LineBasicMaterial({ color: col }));
547
+ line.material.depthTest = false; line.renderOrder = 996; line.userData.dimId = d.id;
548
+ dims3dGroup.add(line);
549
+ }
550
+ }
551
+ syncDimLabels(dims);
552
+ }
553
+ // Keep one themed label div per placed dim (reused across rebuilds). Click selects the dim.
554
+ function syncDimLabels(dims) {
555
+ while (dimLabelPool.length < dims.length) {
556
+ const el = document.createElement('div');
557
+ el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer;background:var(--panel,#0f172a);color:var(--text,#f8fafc);border:1px solid var(--line,#1e293b);border-radius:4px;padding:2px 6px;font:12px system-ui;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5)';
558
+ el.addEventListener('pointerdown', (e) => { e.stopPropagation(); const id = el._dimId; if (api && api.onSelectDim3d) { api.onSelectDim3d(id); refreshDims(); } });
559
+ dimLabelHost.appendChild(el); dimLabelPool.push(el);
560
+ }
561
+ for (let i = 0; i < dimLabelPool.length; i++) {
562
+ const el = dimLabelPool[i], d = dims[i];
563
+ if (!d || !dims3dVisibleFlag) { el.style.display = 'none'; el._dimId = null; el._mid = null; continue; }
564
+ el._dimId = d.id;
565
+ const g = dim3dGeom(d.a, d.b, d.axis);
566
+ el.textContent = api.fmtLen ? api.fmtLen(g.valueMm) : (g.valueMm / 304.8).toFixed(2) + ' ft';
567
+ el.style.borderColor = d.id === (api.selDim3d && api.selDim3d()) ? '#f59e0b' : 'var(--line,#1e293b)';
568
+ el.style.display = 'block'; el._mid = g.mid;
569
+ }
570
+ }
571
+ // project each visible label's 3D midpoint → screen px (called from the render loop). Hidden when the
572
+ // midpoint is behind the camera (project z>1).
573
+ function positionDimLabels() {
574
+ if (!dimLabelHost || canvasEl.style.display === 'none') { if (dimLabelHost) dimLabelHost.style.display = 'none'; return; }
575
+ dimLabelHost.style.display = 'block';
576
+ for (const el of dimLabelPool) {
577
+ if (el.style.display === 'none' || !el._mid) continue;
578
+ const [mx, my, mz] = el._mid;
579
+ const v = new THREE.Vector3(mx, my, mz).project(camera);
580
+ // hide when the midpoint is behind the camera (z>1) OR outside the canvas frustum (|x|,|y|>1):
581
+ // the labels are pointer-events:auto, so an off-canvas one would float over and intercept the
582
+ // surrounding editor UI (the inspector panel / toolbar).
583
+ if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
584
+ const rect = canvasEl.getBoundingClientRect();
585
+ el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
586
+ el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
587
+ el.style.visibility = 'visible';
588
+ }
589
+ }
590
+
482
591
  // ---- ViewCube (parity with AWARE viewer-3d) — a small labelled cube; a face click snaps the view ----
483
592
  const CUBE_FACES = [
484
593
  { label: 'RIGHT', view: 'right' }, { label: 'LEFT', view: 'left' }, // +X, -X
@@ -593,8 +702,40 @@ function pxToWorldAt(px, pos) {
593
702
  }
594
703
  const markerSize = () => pxToWorldAt(8, marker && marker.position); // snap marker ~8px at its own depth
595
704
 
705
+ // The snapped 3D point under the cursor for the dim tool. `anchor` (point 1) is set during the 2nd
706
+ // pick: with Alt held the result is locked to the anchor's plan X/Y (a pure vertical / Z measurement,
707
+ // the elevation-drag feel). Returns { p:[x,y,z], type } (type = the snap label, or null).
708
+ function dimPointAt(e, anchor) {
709
+ if (anchor && e.altKey) {
710
+ const hit = rayToVerticalPlane(e.clientX, e.clientY, anchor); if (!hit) return null;
711
+ return { p: [anchor[0], anchor[1], hit[2]], type: 'vertical' };
712
+ }
713
+ let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
714
+ let planeZ = sceneBox.isEmpty() ? 0 : sceneBox.min.z;
715
+ if (id) { const m = members().find((x) => x.id === id); if (m) { const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm()); planeZ = (g.line[0][2] + g.line[1][2]) / 2; } }
716
+ const hit = rayToPlane(e.clientX, e.clientY, planeZ); if (!hit) return null;
717
+ if (!dimCandidates3d) dimCandidates3d = snapCandidates(members(), api.ptPerFt(), api.defaultTosMm());
718
+ const r = snapPoint([hit[0], hit[1], hit[2]], dimCandidates3d, toScreen, SNAP_TOL_PX);
719
+ return { p: r.candidate ? r.snapped : hit, type: r.candidate ? r.candidate.type : null };
720
+ }
721
+ // The axis to measure on: Alt held during the 2nd pick forces a pure-vertical (Z) measurement,
722
+ // overriding the sticky axis (else a sticky X/Y would collapse the Alt-locked point to zero length).
723
+ function dimEffectiveAxis(alt) { return alt && dimDraft3d ? 'z' : dimAxis3d; }
724
+
596
725
  function onDown(e) {
597
726
  if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
727
+ if (dimMode3d) { // 2-click dimension placement (not a drag): pick A, then B → place
728
+ e.stopPropagation(); controls.enabled = true;
729
+ const r = dimPointAt(e, dimDraft3d && dimDraft3d.a); if (!r) return;
730
+ if (!dimDraft3d) { dimDraft3d = { a: r.p }; dimCandidates3d = null; }
731
+ else {
732
+ const a = dimDraft3d.a, b = r.p, axis = dimEffectiveAxis(e.altKey);
733
+ const g = dim3dGeom(a, b, axis);
734
+ dimDraft3d = null; dimPreviewLine.visible = false; marker.visible = false; readout.style.display = 'none'; dimCandidates3d = null;
735
+ if (g.valueMm > 1 && api && api.onAddDim3d) api.onAddDim3d({ id: api.newDim3dId(), a, b, axis });
736
+ }
737
+ return;
738
+ }
598
739
  downXY = [e.clientX, e.clientY];
599
740
  let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
600
741
  // Geometry-edit mode (Trim/Extend or Split armed in the inspector): a click does the edit, no drag.
@@ -780,10 +921,25 @@ function closestOnSeg3(p, a, b) {
780
921
  // ---- hover: cursor + readout chip (no drag in progress) ----
781
922
  function onHoverMove(e) {
782
923
  lastHoverXY = [e.clientX, e.clientY];
924
+ dimLastAlt = e.altKey; // remembered so the rAF body can rebuild a synthetic event
783
925
  if (hoverRAF || pending || dragging || boxSel) return;
784
926
  hoverRAF = requestAnimationFrame(() => {
785
927
  hoverRAF = 0;
786
928
  if (!lastHoverXY || pending || dragging || boxSel) return;
929
+ if (dimMode3d) { // dim tool: snap marker + live preview line + readout (no member hover)
930
+ canvasEl.style.cursor = 'crosshair';
931
+ const ev = { clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt };
932
+ const r = dimPointAt(ev, dimDraft3d && dimDraft3d.a);
933
+ if (r) { marker.position.set(...r.p); marker.scale.setScalar(markerSize()); marker.visible = true; } else marker.visible = false;
934
+ if (dimDraft3d && r) {
935
+ const g = dim3dGeom(dimDraft3d.a, r.p, dimEffectiveAxis(dimLastAlt));
936
+ dimPreviewLine.geometry.setFromPoints([new THREE.Vector3(...g.p1), new THREE.Vector3(...g.p2)]); dimPreviewLine.visible = true;
937
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(g.valueMm) : (g.valueMm / 304.8).toFixed(2) + ' ft';
938
+ readout._type.textContent = r.type ? ' · ' + r.type : (dimLastAlt ? ' ↕' : '');
939
+ readout.style.left = (lastHoverXY[0] + 14) + 'px'; readout.style.top = (lastHoverXY[1] + 14) + 'px'; readout.style.display = 'block';
940
+ } else { dimPreviewLine.visible = false; if (!dimDraft3d) readout.style.display = 'none'; }
941
+ return;
942
+ }
787
943
  let ep = null; try { ep = pickEndpoint(lastHoverXY[0], lastHoverXY[1]); } catch { ep = null; }
788
944
  if (ep) { // over an end node → ring + move cursor; keep its member's dots shown
789
945
  hoverEp = ep; canvasEl.style.cursor = 'move';
@@ -810,8 +966,12 @@ function updateStatusChip() {
810
966
  hoverChip.style.display = canvasEl.style.display === 'none' ? 'none' : 'block';
811
967
  }
812
968
 
813
- function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
814
- function hide() { if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; } }
969
+ function show() { if (controls) controls.enabled = true; if (canvasEl) { canvasEl.style.display = 'block'; resize(); } if (!rafId) loop(); updateStatusChip(); refreshDims(); } // resume the render loop (reset orbit in case a gesture was stranded by a mid-drag hide)
970
+ function hide() {
971
+ if (dimMode3d) toggleDimTool(); // disarm the dim tool + sync its toolbar (no stale draft/armed state on return)
972
+ if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
973
+ if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
974
+ }
815
975
  function isReady() { return built; }
816
976
 
817
977
  function dispose() {
@@ -822,14 +982,18 @@ function dispose() {
822
982
  window.removeEventListener('pointermove', onMove);
823
983
  window.removeEventListener('pointerup', onUp);
824
984
  window.removeEventListener('keydown', onKey);
825
- for (const ovl of [readout, hoverChip, rubber]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
985
+ for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
986
+ dimLabelHost = null; dimLabelPool.length = 0;
826
987
  if (cube) {
827
988
  cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
828
989
  cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
829
990
  }
830
991
  if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
831
- for (const o of [epRing, epPreview, refGroup]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
992
+ if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
993
+ if (dimPreviewLine && scene) scene.remove(dimPreviewLine);
994
+ for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
832
995
  epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
996
+ dims3dGroup = dimPreviewLine = null;
833
997
  clearRoot();
834
998
  if (renderer) renderer.dispose();
835
999
  renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
@@ -879,5 +1043,8 @@ window.Steel3DView = {
879
1043
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
880
1044
  setRefLine, refLine: () => refLineOn,
881
1045
  toggleGroup, soloToggle, showAllGroups, groupState, getGroups,
1046
+ toggleDimTool, setDimAxis, dimAxis, setDims3dVisible, dims3dVisible, refreshDims,
1047
+ dims3dCount: () => (api && api.getDims3d ? (api.getDims3d() || []).length : 0),
1048
+ dim3dLabelOf: (i) => { const el = dimLabelPool[i]; if (!el || el.style.display === 'none') return null; const r = el.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2, text: el.textContent }; },
882
1049
  debug, probe, screenOf, centerOf,
883
1050
  };