@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.
- package/dist/floless-server.cjs +500 -335
- package/dist/schemas/steel.takeoff.v1.schema.json +20 -1
- package/dist/templates/college-phase-exporter.flo +86 -0
- package/dist/templates/dummy-report.flo +104 -0
- package/dist/templates/hello-world.flo +91 -0
- package/dist/templates/screenshot-to-saved-settings.flo +56 -0
- package/dist/templates/steel-from-drawings.flo +73 -0
- package/dist/templates/steel-takeoff.flo +151 -0
- package/dist/templates/steel-to-ifc.flo +294 -0
- package/dist/templates/steel-to-tekla.flo +293 -0
- package/dist/templates/supabase-drawing-register.flo +63 -0
- package/dist/templates/tekla-bom-by-phase.flo +211 -0
- package/dist/templates/tekla-connection-xray.flo +39 -0
- package/dist/templates/tekla-pdf-takeoff.flo +106 -0
- package/dist/templates/tekla-selection-now.flo +129 -0
- package/dist/templates/tekla-selection-report.flo +145 -0
- package/dist/templates/tekla-watch-logger.flo +57 -0
- package/dist/templates/tekla-watch-smoke.flo +20 -0
- package/dist/templates/trimble-list-projects.flo +41 -0
- package/dist/templates/viewer-3d-demo.flo +65 -0
- package/dist/web/app.css +17 -0
- package/dist/web/aware.js +79 -0
- package/dist/web/index.html +17 -0
- package/dist/web/steel-3d-core.js +19 -0
- package/dist/web/steel-3d-view.js +172 -5
- package/dist/web/steel-editor.html +30 -7
- package/package.json +1 -1
|
@@ -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.
|
package/dist/web/index.html
CHANGED
|
@@ -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 & 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() {
|
|
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
|
-
|
|
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
|
};
|