@floless/app 0.42.0 → 0.44.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.
- package/dist/floless-server.cjs +499 -335
- 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-editor.html +40 -18
- package/package.json +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
app: tekla-bom-by-phase
|
|
2
|
+
version: 0.3.0
|
|
3
|
+
display-name: Tekla BOM by Phase
|
|
4
|
+
description: |
|
|
5
|
+
Reusable bill-of-materials for any Tekla model, scoped to one construction
|
|
6
|
+
phase. Three REAL data-carrying nodes, like the desktop floless canvas, and the
|
|
7
|
+
data actually flows along the edges:
|
|
8
|
+
(1) Phase input — reads the {{ inputs.phase }} knob, scopes it against the
|
|
9
|
+
live model (counts the parts in that phase), and emits the phase it
|
|
10
|
+
validated;
|
|
11
|
+
(2) BOM core logic — consumes the phase from node 1, selects every part in
|
|
12
|
+
that phase via the Open API, and builds a BOM categorized by part type
|
|
13
|
+
(BEAM / COLUMN / PLATE / ANGLE / ...), grouped by profile + material with
|
|
14
|
+
per-category subtotals, returning the styled HTML inline;
|
|
15
|
+
(3) HTML Report Viewer — consumes the HTML from node 2 and carries it as its
|
|
16
|
+
own output, so it is the genuine terminal the floless.app viewer renders
|
|
17
|
+
on double-click.
|
|
18
|
+
All three nodes run read-only (mode: read — no model mutation, no filesystem
|
|
19
|
+
write), so the app runs on any model with no write permission. The phase
|
|
20
|
+
travels node1 → node2 and the report travels node2 → node3: the canvas story
|
|
21
|
+
and the data flow are the same thing.
|
|
22
|
+
exposes-as-agent: false
|
|
23
|
+
inputs:
|
|
24
|
+
phase:
|
|
25
|
+
type: integer
|
|
26
|
+
default: 1
|
|
27
|
+
description: Construction phase number to report on (Tekla PHASE) — editable per run.
|
|
28
|
+
requires:
|
|
29
|
+
- tekla@0.1.x
|
|
30
|
+
layout: linear
|
|
31
|
+
nodes:
|
|
32
|
+
# ── node 1: Phase input — scope the run to one phase against the live model ──
|
|
33
|
+
- id: phase-input
|
|
34
|
+
agent: tekla
|
|
35
|
+
command: exec
|
|
36
|
+
mode: read
|
|
37
|
+
config:
|
|
38
|
+
version: "2025.0"
|
|
39
|
+
args:
|
|
40
|
+
phase: "{{ inputs.phase }}"
|
|
41
|
+
code: |
|
|
42
|
+
// Phase input — reads args["phase"] (default 1), scopes it against the
|
|
43
|
+
// live model by counting the parts in that phase, and EMITS the phase
|
|
44
|
+
// (plus the count + model name) so the downstream BOM node consumes a
|
|
45
|
+
// value that travels along the edge. Read-only.
|
|
46
|
+
using System;
|
|
47
|
+
using Tekla.Structures.Model;
|
|
48
|
+
|
|
49
|
+
if (model == null) return new { ok = false, error = "no active Tekla model" };
|
|
50
|
+
var m = (Model)model;
|
|
51
|
+
|
|
52
|
+
int phase = 1;
|
|
53
|
+
if (args != null && args.TryGetValue("phase", out var pv) && pv != null)
|
|
54
|
+
{
|
|
55
|
+
if (pv is int pi) phase = pi;
|
|
56
|
+
else if (pv is long pl) phase = (int)pl;
|
|
57
|
+
else if (pv is double pd) phase = (int)pd;
|
|
58
|
+
else { int.TryParse(pv.ToString(), out var ps); if (ps != 0) phase = ps; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
string modelName;
|
|
62
|
+
try { modelName = m.GetInfo().ModelName; } catch { modelName = "Tekla model"; }
|
|
63
|
+
|
|
64
|
+
int partsInPhase = 0;
|
|
65
|
+
var en = m.GetModelObjectSelector().GetAllObjects();
|
|
66
|
+
while (en.MoveNext())
|
|
67
|
+
{
|
|
68
|
+
var part = en.Current as Part;
|
|
69
|
+
if (part == null) continue;
|
|
70
|
+
Phase ph = null;
|
|
71
|
+
try { part.GetPhase(out ph); } catch { ph = null; }
|
|
72
|
+
if (ph != null && ph.PhaseNumber == phase) partsInPhase++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return new { ok = true, phase, partsInPhase, modelName };
|
|
76
|
+
# ── node 2: BOM core logic — consume node 1's phase, build the report ────────
|
|
77
|
+
- id: bom
|
|
78
|
+
agent: tekla
|
|
79
|
+
command: exec
|
|
80
|
+
mode: read
|
|
81
|
+
config:
|
|
82
|
+
version: "2025.0"
|
|
83
|
+
args:
|
|
84
|
+
phase: "{{ phase-input.result.phase }}"
|
|
85
|
+
code: |
|
|
86
|
+
// Tekla BOM by Phase — reusable across models. exec globals: `dynamic model`
|
|
87
|
+
// (Tekla.Structures.Model.Model) + `IDictionary<string,object> args`.
|
|
88
|
+
// Reads args["phase"] (forwarded from the phase-input node, default 1),
|
|
89
|
+
// selects parts in that phase, builds a BOM categorized by part NAME
|
|
90
|
+
// (BEAM/COLUMN/PLATE/...) → grouped by profile+material within each
|
|
91
|
+
// category, and RETURNS the styled HTML inline (no file write — so it runs
|
|
92
|
+
// on any model with no filesystem permission). Read-only on the model.
|
|
93
|
+
using System;
|
|
94
|
+
using System.Collections.Generic;
|
|
95
|
+
using System.Linq;
|
|
96
|
+
using System.Text;
|
|
97
|
+
using Tekla.Structures.Model;
|
|
98
|
+
|
|
99
|
+
if (model == null) return new { ok = false, error = "no active Tekla model" };
|
|
100
|
+
var m = (Model)model;
|
|
101
|
+
|
|
102
|
+
// ── phase input (default 1) — robust to int / long / string templating ────────
|
|
103
|
+
int phase = 1;
|
|
104
|
+
if (args != null && args.TryGetValue("phase", out var pv) && pv != null)
|
|
105
|
+
{
|
|
106
|
+
if (pv is int pi) phase = pi;
|
|
107
|
+
else if (pv is long pl) phase = (int)pl;
|
|
108
|
+
else if (pv is double pd) phase = (int)pd;
|
|
109
|
+
else { int.TryParse(pv.ToString(), out var ps); if (ps != 0) phase = ps; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
string modelName;
|
|
113
|
+
try { modelName = m.GetInfo().ModelName; } catch { modelName = "Tekla model"; }
|
|
114
|
+
|
|
115
|
+
// ── aggregate: category(NAME) → "profile|material" → {qty,weight,length} ──────
|
|
116
|
+
var cats = new Dictionary<string, Dictionary<string, double[]>>(StringComparer.OrdinalIgnoreCase);
|
|
117
|
+
var disp = new Dictionary<string, string[]>(); // line key → [profile, material]
|
|
118
|
+
int totalParts = 0, phaseParts = 0; double totalWeight = 0;
|
|
119
|
+
|
|
120
|
+
var en = m.GetModelObjectSelector().GetAllObjects();
|
|
121
|
+
while (en.MoveNext())
|
|
122
|
+
{
|
|
123
|
+
var part = en.Current as Part;
|
|
124
|
+
if (part == null) continue;
|
|
125
|
+
totalParts++;
|
|
126
|
+
|
|
127
|
+
// phase filter
|
|
128
|
+
Phase ph = null;
|
|
129
|
+
try { part.GetPhase(out ph); } catch { ph = null; }
|
|
130
|
+
if (ph == null || ph.PhaseNumber != phase) continue;
|
|
131
|
+
phaseParts++;
|
|
132
|
+
|
|
133
|
+
string name = ""; part.GetReportProperty("NAME", ref name);
|
|
134
|
+
string profile = ""; part.GetReportProperty("PROFILE", ref profile);
|
|
135
|
+
string material = ""; part.GetReportProperty("MATERIAL", ref material);
|
|
136
|
+
double w = 0; part.GetReportProperty("WEIGHT", ref w);
|
|
137
|
+
double l = 0; part.GetReportProperty("LENGTH", ref l);
|
|
138
|
+
|
|
139
|
+
var cat = string.IsNullOrWhiteSpace(name) ? "(unnamed)" : name.Trim().ToUpperInvariant();
|
|
140
|
+
var line = profile + "|" + material;
|
|
141
|
+
if (!cats.TryGetValue(cat, out var lines)) { lines = new Dictionary<string, double[]>(); cats[cat] = lines; }
|
|
142
|
+
if (!lines.TryGetValue(line, out var agg)) { agg = new double[3]; lines[line] = agg; disp[line] = new[] { profile, material }; }
|
|
143
|
+
agg[0] += 1; agg[1] += w; agg[2] += l;
|
|
144
|
+
totalWeight += w;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// category order: heaviest first
|
|
148
|
+
var catOrder = cats.Keys.OrderByDescending(c => cats[c].Values.Sum(a => a[1])).ToList();
|
|
149
|
+
Func<string, string> E = s => (s ?? "").Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """);
|
|
150
|
+
|
|
151
|
+
var sb = new StringBuilder();
|
|
152
|
+
sb.Append("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">");
|
|
153
|
+
sb.Append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
154
|
+
sb.Append("<title>" + E(modelName) + " - BOM Phase " + phase + "</title><style>");
|
|
155
|
+
sb.Append("*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem;line-height:1.6}.container{max-width:1200px;margin:0 auto}");
|
|
156
|
+
sb.Append(".report-header{border-bottom:2px solid #d946ef;padding-bottom:1rem;margin-bottom:2rem}.report-header h1{font-size:1.75rem;font-weight:700;color:#f8fafc}.report-header .subtitle{color:#94a3b8;font-size:.875rem;margin-top:.25rem}");
|
|
157
|
+
sb.Append(".summary-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}.card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1.25rem}.card .label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin-bottom:.25rem}.card .value{font-size:1.5rem;font-weight:700;color:#f8fafc;font-family:Consolas,monospace}.card .value.accent{color:#d946ef}");
|
|
158
|
+
sb.Append(".cat{margin-bottom:1.75rem}.cat h2{font-size:1rem;font-weight:700;color:#f8fafc;margin-bottom:.5rem;display:flex;align-items:baseline;gap:.5rem}.cat h2 .pill{font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#0f172a;background:#d946ef;border-radius:999px;padding:.1rem .5rem}.cat h2 .sub{font-size:.8rem;font-weight:400;color:#94a3b8;font-family:Consolas,monospace;margin-left:auto}");
|
|
159
|
+
sb.Append(".table-wrapper{overflow-x:auto;border-radius:8px;border:1px solid #334155}table{width:100%;border-collapse:collapse;font-size:.875rem}thead th{background:#1e293b;color:#94a3b8;font-weight:600;text-transform:uppercase;font-size:.7rem;letter-spacing:.05em;padding:.6rem 1rem;text-align:left;border-bottom:2px solid #334155}tbody td{padding:.55rem 1rem;border-bottom:1px solid #1e293b;font-family:Consolas,monospace;font-size:.8125rem}tbody tr{background:#0f172a}tbody tr:nth-child(even){background:#162032}tfoot td{padding:.55rem 1rem;font-family:Consolas,monospace;font-weight:700;color:#e2e8f0;background:#1e293b;border-top:2px solid #334155}.num{text-align:right}");
|
|
160
|
+
sb.Append(".report-footer{margin-top:2rem;padding-top:1rem;border-top:1px solid #334155;color:#64748b;font-size:.75rem;text-align:center}.empty{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:2rem;text-align:center;color:#94a3b8}");
|
|
161
|
+
sb.Append("</style></head><body><div class=\"container\">");
|
|
162
|
+
sb.Append("<div class=\"report-header\"><h1>Bill of Materials · Phase " + phase + "</h1><div class=\"subtitle\">" + E(modelName) + " · generated " + DateTime.Now.ToString("yyyy-MM-dd HH:mm") + " · live Tekla Open API</div></div>");
|
|
163
|
+
sb.Append("<div class=\"summary-cards\">");
|
|
164
|
+
sb.Append("<div class=\"card\"><div class=\"label\">Phase</div><div class=\"value accent\">" + phase + "</div></div>");
|
|
165
|
+
sb.Append("<div class=\"card\"><div class=\"label\">Parts in phase</div><div class=\"value\">" + phaseParts.ToString("N0") + "</div></div>");
|
|
166
|
+
sb.Append("<div class=\"card\"><div class=\"label\">Categories</div><div class=\"value\">" + catOrder.Count.ToString("N0") + "</div></div>");
|
|
167
|
+
sb.Append("<div class=\"card\"><div class=\"label\">Total weight</div><div class=\"value accent\">" + (totalWeight / 1000.0).ToString("N1") + " t</div></div>");
|
|
168
|
+
sb.Append("</div>");
|
|
169
|
+
|
|
170
|
+
if (phaseParts == 0)
|
|
171
|
+
{
|
|
172
|
+
sb.Append("<div class=\"empty\">No parts found in phase " + phase + ". Try a different phase number.</div>");
|
|
173
|
+
}
|
|
174
|
+
else foreach (var cat in catOrder)
|
|
175
|
+
{
|
|
176
|
+
var lines = cats[cat];
|
|
177
|
+
var ordered = lines.Keys.OrderByDescending(k => lines[k][1]).ToList();
|
|
178
|
+
double cq = lines.Values.Sum(a => a[0]), cw = lines.Values.Sum(a => a[1]), cl = lines.Values.Sum(a => a[2]);
|
|
179
|
+
sb.Append("<div class=\"cat\"><h2><span class=\"pill\">" + E(cat) + "</span><span class=\"sub\">" + ((int)cq).ToString("N0") + " pcs · " + cw.ToString("N1") + " kg</span></h2>");
|
|
180
|
+
sb.Append("<div class=\"table-wrapper\"><table><thead><tr><th>Profile</th><th>Material</th><th class=\"num\">Qty</th><th class=\"num\">Weight (kg)</th><th class=\"num\">Length (mm)</th></tr></thead><tbody>");
|
|
181
|
+
foreach (var k in ordered)
|
|
182
|
+
sb.Append("<tr><td>" + E(disp[k][0]) + "</td><td>" + E(disp[k][1]) + "</td><td class=\"num\">" + ((int)lines[k][0]) + "</td><td class=\"num\">" + lines[k][1].ToString("N1") + "</td><td class=\"num\">" + lines[k][2].ToString("N0") + "</td></tr>");
|
|
183
|
+
sb.Append("</tbody><tfoot><tr><td colspan=\"2\">" + E(cat) + " subtotal</td><td class=\"num\">" + ((int)cq) + "</td><td class=\"num\">" + cw.ToString("N1") + "</td><td class=\"num\">" + cl.ToString("N0") + "</td></tr></tfoot></table></div></div>");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sb.Append("<div class=\"report-footer\">FloLess · AWARE - reusable BOM-by-phase, generated from the live Tekla model</div>");
|
|
187
|
+
sb.Append("</div></body></html>");
|
|
188
|
+
|
|
189
|
+
return new { ok = true, phase, html = sb.ToString(), modelName, totalParts, phaseParts, categories = catOrder.Count, totalWeight_kg = Math.Round(totalWeight, 1) };
|
|
190
|
+
# ── node 3: HTML Report Viewer — consume node 2's HTML, be the terminal ──────
|
|
191
|
+
- id: viewer
|
|
192
|
+
agent: tekla
|
|
193
|
+
command: exec
|
|
194
|
+
mode: read
|
|
195
|
+
config:
|
|
196
|
+
version: "2025.0"
|
|
197
|
+
args:
|
|
198
|
+
html: "{{ bom.result.html }}"
|
|
199
|
+
phase: "{{ bom.result.phase }}"
|
|
200
|
+
code: |
|
|
201
|
+
// HTML Report Viewer — the genuine terminal of the chain. It CONSUMES the
|
|
202
|
+
// report HTML built by the bom node ({{ bom.result.html }}) and returns it
|
|
203
|
+
// as its own `html`, so the report travels node2 → node3 along the edge and
|
|
204
|
+
// floless.app renders THIS node's output in the in-app HTML Viewer on
|
|
205
|
+
// double-click. Read-only; touches no model state.
|
|
206
|
+
string html = (args != null && args.TryGetValue("html", out var h) && h != null) ? h.ToString() : "";
|
|
207
|
+
object phase = (args != null && args.TryGetValue("phase", out var p)) ? p : null;
|
|
208
|
+
return new { ok = true, phase, html };
|
|
209
|
+
connections:
|
|
210
|
+
- { from: phase-input, to: bom }
|
|
211
|
+
- { from: bom, to: viewer }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
app: tekla-connection-xray
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Tekla Connection X-Ray
|
|
4
|
+
description: |
|
|
5
|
+
Reproduces the desktop "connection X-ray" outcome on AWARE via the
|
|
6
|
+
host-agnostic ui-inspector agent (#128): walk every tab of an open
|
|
7
|
+
connection dialog capturing controls + screenshots, sentinelize a preset
|
|
8
|
+
to map opaque fields to their named attributes, and emit a self-contained
|
|
9
|
+
interactive HTML overlay. Host-agnostic — Tekla is just the target window.
|
|
10
|
+
exposes-as-agent: false
|
|
11
|
+
requires:
|
|
12
|
+
- ui-inspector@0.1.x
|
|
13
|
+
layout: dag
|
|
14
|
+
nodes:
|
|
15
|
+
- id: walk
|
|
16
|
+
agent: ui-inspector
|
|
17
|
+
command: walk-tabs
|
|
18
|
+
config:
|
|
19
|
+
window: '{{ inputs.dialog-window }}'
|
|
20
|
+
output-dir: '{{ inputs.out-dir }}'
|
|
21
|
+
- id: mapping
|
|
22
|
+
agent: ui-inspector
|
|
23
|
+
command: map-fields
|
|
24
|
+
config:
|
|
25
|
+
window: '{{ inputs.dialog-window }}'
|
|
26
|
+
preset: '{{ inputs.preset-file }}'
|
|
27
|
+
load-via: { control: 'button#load-attributes' }
|
|
28
|
+
sentinel-strategy: numeric-stride
|
|
29
|
+
- id: overlay
|
|
30
|
+
agent: ui-inspector
|
|
31
|
+
command: build-overlay
|
|
32
|
+
config:
|
|
33
|
+
controls: '{{ walk.tabs }}'
|
|
34
|
+
output-path: '{{ inputs.out-dir }}/connection-xray.html'
|
|
35
|
+
title: 'Connection X-ray'
|
|
36
|
+
connections:
|
|
37
|
+
- { from: walk, to: mapping }
|
|
38
|
+
- { from: walk, to: overlay }
|
|
39
|
+
- { from: mapping, to: overlay }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
app: tekla-pdf-takeoff
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: PDF Takeoff (deterministic)
|
|
4
|
+
description: |
|
|
5
|
+
B1 reference (Visual Inputs): a SWAPPABLE image/PDF input read DETERMINISTICALLY
|
|
6
|
+
at run time — no LLM, no model. The parse node opens the file at the run-supplied
|
|
7
|
+
path, sniffs it, and (for PDFs) counts pages with a regex over the raw bytes; the
|
|
8
|
+
viewer renders the summary inline. Swap the file via "Set inputs ▸" and re-run —
|
|
9
|
+
nothing recompiles. Neither node dereferences `model`, so it runs on the host
|
|
10
|
+
bridge with nothing open in Tekla (a true deterministic-parse demo). A real
|
|
11
|
+
geometry takeoff (the PdfPig nearest-neighbour pairing) is the same shape with a
|
|
12
|
+
parser library in place of the page-count.
|
|
13
|
+
exposes-as-agent: false
|
|
14
|
+
inputs:
|
|
15
|
+
drawing:
|
|
16
|
+
type: image
|
|
17
|
+
widget: file
|
|
18
|
+
accept: [pdf, png, jpg]
|
|
19
|
+
read-strategy: parse
|
|
20
|
+
description: The framing PDF / image to take off (swappable per run, no recompile).
|
|
21
|
+
requires:
|
|
22
|
+
- tekla@0.1.x
|
|
23
|
+
layout: linear
|
|
24
|
+
nodes:
|
|
25
|
+
# ── node 1: deterministic parse of the run-supplied file PATH (no model, no LLM) ──
|
|
26
|
+
- id: parse
|
|
27
|
+
agent: tekla
|
|
28
|
+
command: exec
|
|
29
|
+
mode: read
|
|
30
|
+
config:
|
|
31
|
+
version: "2025.0"
|
|
32
|
+
args:
|
|
33
|
+
drawing: "{{ inputs.drawing }}"
|
|
34
|
+
code: |
|
|
35
|
+
// Deterministic read of the file PATH in args["drawing"]. System.IO is legal
|
|
36
|
+
// in exec (only System.Net is banned). Never touches `model`. NO LLM. No
|
|
37
|
+
// System.Text.RegularExpressions (not referenced by the exec sandbox) — count
|
|
38
|
+
// pages with a plain IndexOf scan instead.
|
|
39
|
+
using System;
|
|
40
|
+
using System.Text;
|
|
41
|
+
|
|
42
|
+
string path = null;
|
|
43
|
+
if (args != null && args.TryGetValue("drawing", out var dv) && dv != null)
|
|
44
|
+
path = dv.ToString();
|
|
45
|
+
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
|
|
46
|
+
return new { ok = false, error = "no readable file at: " + (path ?? "(null)") };
|
|
47
|
+
|
|
48
|
+
byte[] bytes = System.IO.File.ReadAllBytes(path);
|
|
49
|
+
bool isPdf = bytes.Length >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D;
|
|
50
|
+
// ASCII view keeps the structural "/Type /Page" markers intact (high bytes → '?').
|
|
51
|
+
string text = Encoding.ASCII.GetString(bytes);
|
|
52
|
+
int pages = 0, idx = 0;
|
|
53
|
+
if (isPdf)
|
|
54
|
+
{
|
|
55
|
+
while (true)
|
|
56
|
+
{
|
|
57
|
+
int hit = text.IndexOf("/Type /Page", idx, StringComparison.Ordinal);
|
|
58
|
+
if (hit < 0) break;
|
|
59
|
+
pages++;
|
|
60
|
+
idx = hit + 11;
|
|
61
|
+
}
|
|
62
|
+
if (pages == 0) pages = 1; // single-page PDFs may not carry the marker form
|
|
63
|
+
}
|
|
64
|
+
string name = System.IO.Path.GetFileName(path);
|
|
65
|
+
return new { ok = true, file = name, kind = isPdf ? "pdf" : "image", bytes = bytes.Length, pages };
|
|
66
|
+
# ── node 2: viewer — build the summary HTML inline; the terminal the HTML Viewer renders ──
|
|
67
|
+
- id: viewer
|
|
68
|
+
agent: tekla
|
|
69
|
+
command: exec
|
|
70
|
+
mode: read
|
|
71
|
+
config:
|
|
72
|
+
version: "2025.0"
|
|
73
|
+
args:
|
|
74
|
+
file: "{{ parse.result.file }}"
|
|
75
|
+
kind: "{{ parse.result.kind }}"
|
|
76
|
+
pages: "{{ parse.result.pages }}"
|
|
77
|
+
bytes: "{{ parse.result.bytes }}"
|
|
78
|
+
code: |
|
|
79
|
+
// Forward the parse summary as a styled report. No System.Net → manual escape.
|
|
80
|
+
using System;
|
|
81
|
+
using System.Text;
|
|
82
|
+
|
|
83
|
+
Func<string, string> E = s => (s ?? "").Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
|
84
|
+
string file = args != null && args.TryGetValue("file", out var f) && f != null ? f.ToString() : "";
|
|
85
|
+
string kind = args != null && args.TryGetValue("kind", out var k) && k != null ? k.ToString() : "";
|
|
86
|
+
string pages = args != null && args.TryGetValue("pages", out var p) && p != null ? p.ToString() : "";
|
|
87
|
+
string bytes = args != null && args.TryGetValue("bytes", out var b) && b != null ? b.ToString() : "";
|
|
88
|
+
|
|
89
|
+
var sb = new StringBuilder();
|
|
90
|
+
sb.Append("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>PDF Takeoff</title><style>");
|
|
91
|
+
sb.Append("body{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#f1f5f9;color:#0f172a;padding:2.5rem;line-height:1.6}");
|
|
92
|
+
sb.Append(".wrap{max-width:560px;margin:0 auto;background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:1.75rem 2rem;box-shadow:0 10px 30px rgba(2,6,23,.06)}");
|
|
93
|
+
sb.Append("h1{font-size:1.5rem;font-weight:800;border-bottom:2px solid #4a9eff;padding-bottom:.75rem;margin-bottom:1.25rem}");
|
|
94
|
+
sb.Append("table{width:100%;border-collapse:collapse;font-size:.92rem}");
|
|
95
|
+
sb.Append("td{padding:.55rem .75rem;border-bottom:1px solid #eef2f7}");
|
|
96
|
+
sb.Append("td:first-child{color:#64748b;width:40%}td:last-child{font-weight:600}");
|
|
97
|
+
sb.Append("</style></head><body><div class=\"wrap\"><h1>PDF Takeoff</h1><table>");
|
|
98
|
+
sb.Append("<tr><td>File</td><td>" + E(file) + "</td></tr>");
|
|
99
|
+
sb.Append("<tr><td>Kind</td><td>" + E(kind) + "</td></tr>");
|
|
100
|
+
sb.Append("<tr><td>Pages</td><td>" + E(pages) + "</td></tr>");
|
|
101
|
+
sb.Append("<tr><td>Bytes</td><td>" + E(bytes) + "</td></tr>");
|
|
102
|
+
sb.Append("</table></div></body></html>");
|
|
103
|
+
string html = sb.ToString();
|
|
104
|
+
return new { ok = true, file, kind, pages, html };
|
|
105
|
+
connections:
|
|
106
|
+
- { from: parse, to: viewer }
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
app: tekla-selection-now
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Tekla Selection → properties (on demand)
|
|
4
|
+
description: |
|
|
5
|
+
On-demand: reports the CURRENTLY-selected objects' key properties (type, mark,
|
|
6
|
+
name, profile, material, weight) from the active Tekla model as an HTML table.
|
|
7
|
+
Select objects in Tekla, then press ▶ Run workflow — the report renders in the
|
|
8
|
+
HTML Viewer. A single read-only exec node, so the Run button returns a report
|
|
9
|
+
immediately (no waiting on an event). The event-triggered sibling
|
|
10
|
+
`tekla-selection-report` fires automatically on each SelectionChange instead
|
|
11
|
+
(set it up as a ⏱ trigger routine). Needs a running Tekla (2025 or 2026) with a
|
|
12
|
+
model open; pick the version via the `version` input.
|
|
13
|
+
exposes-as-agent: false
|
|
14
|
+
inputs:
|
|
15
|
+
version:
|
|
16
|
+
type: string
|
|
17
|
+
default: "2025.0"
|
|
18
|
+
description: Tekla version to attach to (e.g. "2025.0" or "2026.0") — editable per run.
|
|
19
|
+
layout: linear
|
|
20
|
+
requires:
|
|
21
|
+
- tekla@0.1.x
|
|
22
|
+
nodes:
|
|
23
|
+
- id: report # single read-only node — reads the live selection on Run
|
|
24
|
+
agent: tekla
|
|
25
|
+
command: exec
|
|
26
|
+
mode: read # author intent; AWARE defaults exec→write (issue #165), cosmetic
|
|
27
|
+
config:
|
|
28
|
+
version: "{{ inputs.version }}"
|
|
29
|
+
code: |
|
|
30
|
+
// Report the currently-selected objects' key properties. Read-only.
|
|
31
|
+
Func<string,string> E = s => (s ?? "")
|
|
32
|
+
.Replace("&","&").Replace("<","<").Replace(">",">").Replace("\"",""");
|
|
33
|
+
|
|
34
|
+
var selector = new Tekla.Structures.Model.UI.ModelObjectSelector();
|
|
35
|
+
var en = selector.GetSelectedObjects();
|
|
36
|
+
var rows = new System.Collections.Generic.List<object>();
|
|
37
|
+
var sb = new System.Text.StringBuilder(); // tbody rows only; the table chrome is built below
|
|
38
|
+
|
|
39
|
+
int i = 0;
|
|
40
|
+
double totalWeight = 0;
|
|
41
|
+
while (en.MoveNext())
|
|
42
|
+
{
|
|
43
|
+
// FQN: bare `ModelObject` is ambiguous (exec imports both
|
|
44
|
+
// Tekla.Structures.Model and Tekla.Structures.Drawing).
|
|
45
|
+
var mo = en.Current as Tekla.Structures.Model.ModelObject;
|
|
46
|
+
if (mo == null) continue;
|
|
47
|
+
i++;
|
|
48
|
+
string type = mo.GetType().Name;
|
|
49
|
+
string mark = ""; mo.GetReportProperty("ASSEMBLY_POS", ref mark);
|
|
50
|
+
string name = ""; mo.GetReportProperty("NAME", ref name);
|
|
51
|
+
string profile = ""; mo.GetReportProperty("PROFILE", ref profile);
|
|
52
|
+
string material = ""; mo.GetReportProperty("MATERIAL", ref material);
|
|
53
|
+
double weight = 0; mo.GetReportProperty("WEIGHT", ref weight);
|
|
54
|
+
string guid = mo.Identifier.GUID.ToString();
|
|
55
|
+
|
|
56
|
+
rows.Add(new { index = i, type, mark, name, profile, material, weight, guid });
|
|
57
|
+
totalWeight += weight;
|
|
58
|
+
sb.Append("<tr><td class='num'>").Append(i)
|
|
59
|
+
.Append("</td><td>").Append(E(type))
|
|
60
|
+
.Append("</td><td>").Append(E(mark))
|
|
61
|
+
.Append("</td><td class='col-text'>").Append(E(name))
|
|
62
|
+
.Append("</td><td class='col-text'>").Append(E(profile))
|
|
63
|
+
.Append("</td><td class='col-text'>").Append(E(material))
|
|
64
|
+
.Append("</td><td class='num'>").Append(weight.ToString("0.0"))
|
|
65
|
+
.Append("</td><td class='col-guid' title='").Append(E(guid)).Append("'>").Append(E(guid)).Append("</td></tr>");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
string modelName;
|
|
69
|
+
try { modelName = ((Tekla.Structures.Model.Model)model).GetInfo().ModelName; }
|
|
70
|
+
catch { modelName = "Tekla model"; }
|
|
71
|
+
string at = System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
|
|
72
|
+
|
|
73
|
+
// Modern FloLess report skin (shadcn dark slate-blue baseline). Self-contained
|
|
74
|
+
// inline <style> so it renders in the in-app dark iframe, a new tab, and the
|
|
75
|
+
// hosted shared page alike. The "Made with FloLess" badge is injected by the
|
|
76
|
+
// server (report-badge.ts) — never add it here. Accent is brand blue #3b82f6
|
|
77
|
+
// (a selection report is general-purpose; fuchsia is bom-by-phase's phase hue).
|
|
78
|
+
string css =
|
|
79
|
+
"*{margin:0;padding:0;box-sizing:border-box}"
|
|
80
|
+
+ "body{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem;line-height:1.6}"
|
|
81
|
+
+ ".container{max-width:1200px;margin:0 auto}"
|
|
82
|
+
+ ".report-header{border-bottom:2px solid #3b82f6;padding-bottom:1rem;margin-bottom:2rem}"
|
|
83
|
+
+ ".report-header h1{font-size:1.75rem;font-weight:700;color:#f8fafc}"
|
|
84
|
+
+ ".report-header .subtitle{color:#94a3b8;font-size:.875rem;margin-top:.25rem}"
|
|
85
|
+
+ ".summary-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}"
|
|
86
|
+
+ ".card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1.25rem}"
|
|
87
|
+
+ ".card .label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin-bottom:.25rem}"
|
|
88
|
+
+ ".card .value{font-size:1.5rem;font-weight:700;color:#f8fafc;font-family:Consolas,monospace}"
|
|
89
|
+
+ ".card .value.accent{color:#3b82f6}"
|
|
90
|
+
+ ".table-wrapper{overflow-x:auto;border-radius:8px;border:1px solid #334155}"
|
|
91
|
+
+ "table{width:100%;border-collapse:collapse;font-size:.875rem}"
|
|
92
|
+
+ "thead th{background:#1e293b;color:#94a3b8;font-weight:600;text-transform:uppercase;font-size:.7rem;letter-spacing:.05em;padding:.6rem 1rem;text-align:left;border-bottom:2px solid #334155;white-space:nowrap}"
|
|
93
|
+
+ "tbody td{padding:.55rem 1rem;border-bottom:1px solid #1e293b;font-family:Consolas,monospace;font-size:.8125rem}"
|
|
94
|
+
+ "tbody tr{background:#0f172a}tbody tr:nth-child(even){background:#162032}"
|
|
95
|
+
+ ".col-guid{max-width:14rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}"
|
|
96
|
+
+ ".col-text{word-break:break-word}.num{text-align:right}"
|
|
97
|
+
+ ".empty{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:2rem;text-align:center;color:#94a3b8}"
|
|
98
|
+
+ ".empty .empty-icon{font-size:1.5rem;margin-bottom:.5rem;color:#475569}"
|
|
99
|
+
+ ".report-footer{margin-top:2rem;padding-top:1rem;border-top:1px solid #334155;color:#64748b;font-size:.75rem;text-align:center}";
|
|
100
|
+
|
|
101
|
+
var doc = new System.Text.StringBuilder();
|
|
102
|
+
doc.Append("<!doctype html><html lang='en'><head><meta charset='utf-8'>")
|
|
103
|
+
.Append("<meta name='viewport' content='width=device-width, initial-scale=1'>")
|
|
104
|
+
.Append("<title>Selection report — ").Append(E(modelName)).Append("</title>")
|
|
105
|
+
.Append("<style>").Append(css).Append("</style></head><body><div class='container'>");
|
|
106
|
+
doc.Append("<div class='report-header'><h1>Selection report — ").Append(E(modelName)).Append("</h1>")
|
|
107
|
+
.Append("<div class='subtitle'>").Append(i).Append(" object(s) selected · ").Append(E(at)).Append(" · live Tekla Open API</div></div>");
|
|
108
|
+
if (i == 0)
|
|
109
|
+
{
|
|
110
|
+
doc.Append("<div class='empty'><div class='empty-icon'>◻</div>")
|
|
111
|
+
.Append("<p>Nothing selected. Select objects in Tekla, then press ▶ Run workflow.</p></div>");
|
|
112
|
+
}
|
|
113
|
+
else
|
|
114
|
+
{
|
|
115
|
+
doc.Append("<div class='summary-cards'>")
|
|
116
|
+
.Append("<div class='card'><div class='label'>Objects selected</div><div class='value'>").Append(i).Append("</div></div>")
|
|
117
|
+
.Append("<div class='card'><div class='label'>Total weight</div><div class='value accent'>").Append(totalWeight.ToString("N1")).Append(" kg</div></div>")
|
|
118
|
+
.Append("</div>");
|
|
119
|
+
doc.Append("<div class='table-wrapper'><table><thead><tr>")
|
|
120
|
+
.Append("<th class='num'>#</th><th>Type</th><th>Mark</th><th class='col-text'>Name</th>")
|
|
121
|
+
.Append("<th class='col-text'>Profile</th><th class='col-text'>Material</th><th class='num'>Weight (kg)</th><th>GUID</th>")
|
|
122
|
+
.Append("</tr></thead><tbody>").Append(sb.ToString()).Append("</tbody></table></div>");
|
|
123
|
+
}
|
|
124
|
+
doc.Append("<div class='report-footer'>Selection report · ").Append(E(modelName)).Append(" · generated ").Append(E(at)).Append("</div>");
|
|
125
|
+
doc.Append("</div></body></html>");
|
|
126
|
+
string html = doc.ToString();
|
|
127
|
+
|
|
128
|
+
return new { ok = true, count = i, selected = rows, modelName, at, totalWeight, html };
|
|
129
|
+
connections: []
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
app: tekla-selection-report
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Tekla Selection → properties report
|
|
4
|
+
description: |
|
|
5
|
+
Event-triggered: a tekla.watch source streams SelectionChange events from the
|
|
6
|
+
active Tekla model; on each selection change a downstream exec node reads the
|
|
7
|
+
currently-selected objects and reports their key properties (type, mark, name,
|
|
8
|
+
profile, material, weight) as an HTML table. Exercises the full Tekla Events
|
|
9
|
+
surface delivered by aware #219/#220 (watch now receives live events, and
|
|
10
|
+
`events` selects which to stream). Needs a running Tekla (2025 or 2026) with a
|
|
11
|
+
model open; pick the version per run via the `version` input.
|
|
12
|
+
exposes-as-agent: false
|
|
13
|
+
inputs:
|
|
14
|
+
version:
|
|
15
|
+
type: string
|
|
16
|
+
default: "2025.0"
|
|
17
|
+
description: Tekla version to attach to (e.g. "2025.0" or "2026.0") — editable per run.
|
|
18
|
+
layout: linear
|
|
19
|
+
requires:
|
|
20
|
+
- tekla@0.1.x
|
|
21
|
+
nodes:
|
|
22
|
+
- id: watch # SOURCE — streaming lifecycle:start; one fire per SelectionChange
|
|
23
|
+
agent: tekla
|
|
24
|
+
command: watch
|
|
25
|
+
config:
|
|
26
|
+
version: "{{ inputs.version }}"
|
|
27
|
+
events: SelectionChange # full-Events-surface selector (#219/#220)
|
|
28
|
+
filter: all
|
|
29
|
+
- id: report # SINK — read the live selection, report each object's properties
|
|
30
|
+
agent: tekla
|
|
31
|
+
command: exec
|
|
32
|
+
mode: read # author intent; AWARE defaults exec→write (issue #165), cosmetic
|
|
33
|
+
config:
|
|
34
|
+
version: "{{ inputs.version }}"
|
|
35
|
+
# The fired SelectionChange event carries no object payload (the event has no
|
|
36
|
+
# args), so the report reads the CURRENT selection from the live model. The
|
|
37
|
+
# event's own fields are bound under {{ inputs.* }} by the streaming runtime.
|
|
38
|
+
args:
|
|
39
|
+
trigger: "{{ inputs.event }}"
|
|
40
|
+
at: "{{ inputs.delivered_at }}"
|
|
41
|
+
code: |
|
|
42
|
+
// Report the currently-selected objects' key properties. Fires on each
|
|
43
|
+
// SelectionChange from the upstream watch node. Read-only.
|
|
44
|
+
Func<string,string> E = s => (s ?? "")
|
|
45
|
+
.Replace("&","&").Replace("<","<").Replace(">",">").Replace("\"",""");
|
|
46
|
+
string Arg(string k) => args != null && args.ContainsKey(k) && args[k] != null
|
|
47
|
+
? args[k].ToString() : "";
|
|
48
|
+
|
|
49
|
+
var selector = new Tekla.Structures.Model.UI.ModelObjectSelector();
|
|
50
|
+
var en = selector.GetSelectedObjects();
|
|
51
|
+
var rows = new System.Collections.Generic.List<object>();
|
|
52
|
+
var sb = new System.Text.StringBuilder(); // tbody rows only; the table chrome is built below
|
|
53
|
+
|
|
54
|
+
int i = 0;
|
|
55
|
+
double totalWeight = 0;
|
|
56
|
+
while (en.MoveNext())
|
|
57
|
+
{
|
|
58
|
+
// FQN: bare `ModelObject` is ambiguous (exec imports both
|
|
59
|
+
// Tekla.Structures.Model and Tekla.Structures.Drawing).
|
|
60
|
+
var mo = en.Current as Tekla.Structures.Model.ModelObject;
|
|
61
|
+
if (mo == null) continue;
|
|
62
|
+
i++;
|
|
63
|
+
string type = mo.GetType().Name;
|
|
64
|
+
string mark = ""; mo.GetReportProperty("ASSEMBLY_POS", ref mark);
|
|
65
|
+
string name = ""; mo.GetReportProperty("NAME", ref name);
|
|
66
|
+
string profile = ""; mo.GetReportProperty("PROFILE", ref profile);
|
|
67
|
+
string material = ""; mo.GetReportProperty("MATERIAL", ref material);
|
|
68
|
+
double weight = 0; mo.GetReportProperty("WEIGHT", ref weight);
|
|
69
|
+
string guid = mo.Identifier.GUID.ToString();
|
|
70
|
+
|
|
71
|
+
rows.Add(new { index = i, type, mark, name, profile, material, weight, guid });
|
|
72
|
+
totalWeight += weight;
|
|
73
|
+
sb.Append("<tr><td class='num'>").Append(i)
|
|
74
|
+
.Append("</td><td>").Append(E(type))
|
|
75
|
+
.Append("</td><td>").Append(E(mark))
|
|
76
|
+
.Append("</td><td class='col-text'>").Append(E(name))
|
|
77
|
+
.Append("</td><td class='col-text'>").Append(E(profile))
|
|
78
|
+
.Append("</td><td class='col-text'>").Append(E(material))
|
|
79
|
+
.Append("</td><td class='num'>").Append(weight.ToString("0.0"))
|
|
80
|
+
.Append("</td><td class='col-guid' title='").Append(E(guid)).Append("'>").Append(E(guid)).Append("</td></tr>");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
string modelName;
|
|
84
|
+
try { modelName = ((Tekla.Structures.Model.Model)model).GetInfo().ModelName; }
|
|
85
|
+
catch { modelName = "Tekla model"; }
|
|
86
|
+
string at = Arg("at"); if (string.IsNullOrEmpty(at)) at = System.DateTime.UtcNow.ToString("o");
|
|
87
|
+
|
|
88
|
+
// Modern FloLess report skin (shadcn dark slate-blue baseline). Self-contained
|
|
89
|
+
// inline <style> so it renders in the in-app dark iframe, a new tab, and the
|
|
90
|
+
// hosted shared page alike. The "Made with FloLess" badge is injected by the
|
|
91
|
+
// server (report-badge.ts) — never add it here. Accent is brand blue #3b82f6
|
|
92
|
+
// (a selection report is general-purpose; fuchsia is bom-by-phase's phase hue).
|
|
93
|
+
string css =
|
|
94
|
+
"*{margin:0;padding:0;box-sizing:border-box}"
|
|
95
|
+
+ "body{font-family:-apple-system,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:2rem;line-height:1.6}"
|
|
96
|
+
+ ".container{max-width:1200px;margin:0 auto}"
|
|
97
|
+
+ ".report-header{border-bottom:2px solid #3b82f6;padding-bottom:1rem;margin-bottom:2rem}"
|
|
98
|
+
+ ".report-header h1{font-size:1.75rem;font-weight:700;color:#f8fafc}"
|
|
99
|
+
+ ".report-header .subtitle{color:#94a3b8;font-size:.875rem;margin-top:.25rem}"
|
|
100
|
+
+ ".summary-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}"
|
|
101
|
+
+ ".card{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:1.25rem}"
|
|
102
|
+
+ ".card .label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;margin-bottom:.25rem}"
|
|
103
|
+
+ ".card .value{font-size:1.5rem;font-weight:700;color:#f8fafc;font-family:Consolas,monospace}"
|
|
104
|
+
+ ".card .value.accent{color:#3b82f6}"
|
|
105
|
+
+ ".table-wrapper{overflow-x:auto;border-radius:8px;border:1px solid #334155}"
|
|
106
|
+
+ "table{width:100%;border-collapse:collapse;font-size:.875rem}"
|
|
107
|
+
+ "thead th{background:#1e293b;color:#94a3b8;font-weight:600;text-transform:uppercase;font-size:.7rem;letter-spacing:.05em;padding:.6rem 1rem;text-align:left;border-bottom:2px solid #334155;white-space:nowrap}"
|
|
108
|
+
+ "tbody td{padding:.55rem 1rem;border-bottom:1px solid #1e293b;font-family:Consolas,monospace;font-size:.8125rem}"
|
|
109
|
+
+ "tbody tr{background:#0f172a}tbody tr:nth-child(even){background:#162032}"
|
|
110
|
+
+ ".col-guid{max-width:14rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}"
|
|
111
|
+
+ ".col-text{word-break:break-word}.num{text-align:right}"
|
|
112
|
+
+ ".empty{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:2rem;text-align:center;color:#94a3b8}"
|
|
113
|
+
+ ".empty .empty-icon{font-size:1.5rem;margin-bottom:.5rem;color:#475569}"
|
|
114
|
+
+ ".report-footer{margin-top:2rem;padding-top:1rem;border-top:1px solid #334155;color:#64748b;font-size:.75rem;text-align:center}";
|
|
115
|
+
|
|
116
|
+
var doc = new System.Text.StringBuilder();
|
|
117
|
+
doc.Append("<!doctype html><html lang='en'><head><meta charset='utf-8'>")
|
|
118
|
+
.Append("<meta name='viewport' content='width=device-width, initial-scale=1'>")
|
|
119
|
+
.Append("<title>Selection report — ").Append(E(modelName)).Append("</title>")
|
|
120
|
+
.Append("<style>").Append(css).Append("</style></head><body><div class='container'>");
|
|
121
|
+
doc.Append("<div class='report-header'><h1>Selection report — ").Append(E(modelName)).Append("</h1>")
|
|
122
|
+
.Append("<div class='subtitle'>").Append(i).Append(" object(s) selected · ").Append(E(at)).Append(" · on selection change</div></div>");
|
|
123
|
+
if (i == 0)
|
|
124
|
+
{
|
|
125
|
+
doc.Append("<div class='empty'><div class='empty-icon'>◻</div>")
|
|
126
|
+
.Append("<p>Nothing selected.</p></div>");
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
{
|
|
130
|
+
doc.Append("<div class='summary-cards'>")
|
|
131
|
+
.Append("<div class='card'><div class='label'>Objects selected</div><div class='value'>").Append(i).Append("</div></div>")
|
|
132
|
+
.Append("<div class='card'><div class='label'>Total weight</div><div class='value accent'>").Append(totalWeight.ToString("N1")).Append(" kg</div></div>")
|
|
133
|
+
.Append("</div>");
|
|
134
|
+
doc.Append("<div class='table-wrapper'><table><thead><tr>")
|
|
135
|
+
.Append("<th class='num'>#</th><th>Type</th><th>Mark</th><th class='col-text'>Name</th>")
|
|
136
|
+
.Append("<th class='col-text'>Profile</th><th class='col-text'>Material</th><th class='num'>Weight (kg)</th><th>GUID</th>")
|
|
137
|
+
.Append("</tr></thead><tbody>").Append(sb.ToString()).Append("</tbody></table></div>");
|
|
138
|
+
}
|
|
139
|
+
doc.Append("<div class='report-footer'>Selection report · ").Append(E(modelName)).Append(" · generated ").Append(E(at)).Append("</div>");
|
|
140
|
+
doc.Append("</div></body></html>");
|
|
141
|
+
string html = doc.ToString();
|
|
142
|
+
|
|
143
|
+
return new { ok = true, count = i, selected = rows, modelName, at, totalWeight, html };
|
|
144
|
+
connections:
|
|
145
|
+
- { from: watch, to: report }
|