@floless/app 0.46.0 → 0.48.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 +66 -7
- package/dist/schemas/steel.takeoff.v1.schema.json +66 -4
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +39 -34
- package/dist/web/aware.js +17 -3
- package/dist/web/index.html +1 -1
- package/dist/web/steel-editor.html +41 -5
- package/dist/web/steel-filter-core.js +32 -0
- package/dist/web/steel-filter.html +66 -15
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -52856,7 +52856,7 @@ function appVersion() {
|
|
|
52856
52856
|
return resolveVersion({
|
|
52857
52857
|
isSea: isSea2(),
|
|
52858
52858
|
sqVersionXml: readSqVersionXml(),
|
|
52859
|
-
define: true ? "0.
|
|
52859
|
+
define: true ? "0.48.0" : void 0,
|
|
52860
52860
|
pkgVersion: readPkgVersion()
|
|
52861
52861
|
});
|
|
52862
52862
|
}
|
|
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
|
|
|
52866
52866
|
return "dev";
|
|
52867
52867
|
}
|
|
52868
52868
|
function appChannel() {
|
|
52869
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52869
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.48.0" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// workflow-update.ts
|
|
@@ -53862,10 +53862,23 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53862
53862
|
});
|
|
53863
53863
|
}
|
|
53864
53864
|
const filter = baked.filter;
|
|
53865
|
-
if (filter && typeof filter === "object"
|
|
53866
|
-
const
|
|
53867
|
-
|
|
53868
|
-
|
|
53865
|
+
if (filter && typeof filter === "object") {
|
|
53866
|
+
const stripPage = (p) => {
|
|
53867
|
+
if (p && typeof p === "object") {
|
|
53868
|
+
const page = { ...p };
|
|
53869
|
+
delete page.bg_b64;
|
|
53870
|
+
return page;
|
|
53871
|
+
}
|
|
53872
|
+
return p;
|
|
53873
|
+
};
|
|
53874
|
+
const next = { ...filter };
|
|
53875
|
+
if (filter.page) next.page = stripPage(filter.page);
|
|
53876
|
+
if (Array.isArray(filter.sheets)) {
|
|
53877
|
+
next.sheets = filter.sheets.map(
|
|
53878
|
+
(s) => s && typeof s === "object" ? { ...s, page: stripPage(s.page) } : s
|
|
53879
|
+
);
|
|
53880
|
+
}
|
|
53881
|
+
baked.filter = next;
|
|
53869
53882
|
}
|
|
53870
53883
|
doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
|
|
53871
53884
|
(0, import_node_fs18.writeFileSync)(sourcePath, doc2.toString());
|
|
@@ -54296,9 +54309,54 @@ function rollup(members, countWeighted = false) {
|
|
|
54296
54309
|
}
|
|
54297
54310
|
return { score: den > 0 ? Math.round(num2 / den * 100) : null, tons, counts };
|
|
54298
54311
|
}
|
|
54312
|
+
function bandEnd(end, rows) {
|
|
54313
|
+
const row = end.conn ? rows.get(end.conn) : void 0;
|
|
54314
|
+
const complete = !!(row && row.type && row.detail && row.targets && Object.keys(row.targets).length > 0);
|
|
54315
|
+
const hasNote = !!(end.note && end.note.trim() !== "");
|
|
54316
|
+
const evidence = hasNote || row?.source === "drawing";
|
|
54317
|
+
if (end.connVerified === true) return { band: "verified", reason: "" };
|
|
54318
|
+
if (!end.conn && !hasNote) return { band: "rfi", reason: "no connection assigned" };
|
|
54319
|
+
if (complete && evidence) return { band: "high", reason: "" };
|
|
54320
|
+
if (complete) return { band: "med", reason: "connection type assumed (no drawing note)" };
|
|
54321
|
+
if (hasNote && end.detail && end.detail.trim() !== "") return { band: "med", reason: "free-text note + detail (not a mapped library row)" };
|
|
54322
|
+
if (end.conn && !row) return { band: "low", reason: "references an unknown connection row" };
|
|
54323
|
+
return { band: "low", reason: row ? "library row has a type only (no detail/component id)" : "connection type only (no detail)" };
|
|
54324
|
+
}
|
|
54325
|
+
function scoreConnections(contractInput) {
|
|
54326
|
+
const contract = contractInput ?? {};
|
|
54327
|
+
const rows = /* @__PURE__ */ new Map();
|
|
54328
|
+
for (const c of contract.connections ?? []) if (c && c.id) rows.set(c.id, c);
|
|
54329
|
+
const byEnd = [];
|
|
54330
|
+
(contract.plans ?? []).forEach((plan, planIdx) => {
|
|
54331
|
+
for (const m of plan.members ?? []) {
|
|
54332
|
+
if (m.role === "column") {
|
|
54333
|
+
const c = m.col ?? {};
|
|
54334
|
+
const { band, reason } = bandEnd(c, rows);
|
|
54335
|
+
byEnd.push({ id: `${m.id}#col`, planIdx, sheet: plan.sheet ?? "", memberId: m.id, which: "col", band, reason });
|
|
54336
|
+
} else {
|
|
54337
|
+
const ends = m.ends ?? [];
|
|
54338
|
+
for (const which of [0, 1]) {
|
|
54339
|
+
const { band, reason } = bandEnd(ends[which] ?? {}, rows);
|
|
54340
|
+
byEnd.push({ id: `${m.id}#${which}`, planIdx, sheet: plan.sheet ?? "", memberId: m.id, which, band, reason });
|
|
54341
|
+
}
|
|
54342
|
+
}
|
|
54343
|
+
}
|
|
54344
|
+
});
|
|
54345
|
+
const counts = emptyCounts();
|
|
54346
|
+
let num2 = 0, den = 0;
|
|
54347
|
+
for (const e of byEnd) {
|
|
54348
|
+
counts[e.band]++;
|
|
54349
|
+
if (e.band === "rfi") continue;
|
|
54350
|
+
num2 += BAND_WEIGHT[e.band];
|
|
54351
|
+
den += 1;
|
|
54352
|
+
}
|
|
54353
|
+
const category = { score: den > 0 ? Math.round(num2 / den * 100) : null, tons: 0, counts };
|
|
54354
|
+
return { byEnd, category };
|
|
54355
|
+
}
|
|
54299
54356
|
function scoreContract(contractInput) {
|
|
54300
54357
|
const contract = contractInput ?? {};
|
|
54301
54358
|
const plans = contract.plans ?? [];
|
|
54359
|
+
const conn = scoreConnections(contract);
|
|
54302
54360
|
const byMember = [];
|
|
54303
54361
|
const byDetail = [];
|
|
54304
54362
|
let totalSegments = 0;
|
|
@@ -54344,11 +54402,12 @@ function scoreContract(contractInput) {
|
|
|
54344
54402
|
return {
|
|
54345
54403
|
byMember,
|
|
54346
54404
|
byDetail,
|
|
54405
|
+
byConnection: conn.byEnd,
|
|
54347
54406
|
byCategory: {
|
|
54348
54407
|
beams,
|
|
54349
54408
|
columns,
|
|
54350
54409
|
details,
|
|
54351
|
-
connections:
|
|
54410
|
+
connections: conn.category
|
|
54352
54411
|
},
|
|
54353
54412
|
coverage: {
|
|
54354
54413
|
members: byMember.length,
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"additionalProperties": false
|
|
38
38
|
},
|
|
39
39
|
"plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } },
|
|
40
|
-
"filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage
|
|
40
|
+
"filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage. `sheets[]` holds per-framing-plan geometry; the facets + `mode` + on-flag selection are GLOBAL across sheets. The saved selection scopes which lines the reader binds profile labels to per sheet; replaces the length heuristic, fallback when absent. CONFIDENTIAL: each sheet's `page.bg_b64` is machine-local, never committed." },
|
|
41
|
+
"connections": { "type": "array", "items": { "$ref": "#/$defs/connection" }, "description": "Vendor-neutral connection library: each row maps a connection type → the firm's design detail# → per-platform component ids. Member ends/columns reference a row by id via `ends[].conn` / `col.conn` (legacy `note`/`detail` = fallback when `conn` is empty)." },
|
|
41
42
|
"dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." }
|
|
42
43
|
},
|
|
43
44
|
"$defs": {
|
|
@@ -46,10 +47,16 @@
|
|
|
46
47
|
"additionalProperties": true,
|
|
47
48
|
"description": "Steel filter pre-stage. Lines render as SVG paths over a dimmed page raster; text spans make profile labels pickable (and feed the label-binding step).",
|
|
48
49
|
"properties": {
|
|
50
|
+
"active": { "type": "integer", "description": "Index into `sheets` of the active framing plan (mirrors the top-level plans/active)." },
|
|
51
|
+
"sheets": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": { "$ref": "#/$defs/filterSheet" },
|
|
54
|
+
"description": "Per-sheet filter GEOMETRY — one entry per confirmed framing plan. The facets (layers/thicknesses/colors), `mode` and on-flag selection are GLOBAL (top-level here) and apply to every sheet."
|
|
55
|
+
},
|
|
49
56
|
"page": {
|
|
50
57
|
"type": "object",
|
|
51
58
|
"required": ["w", "h"],
|
|
52
|
-
"description": "
|
|
59
|
+
"description": "LEGACY pre-multisheet single-page geometry (now per-sheet in `sheets[]`). normalizeFilter wraps it into one sheet on load; the view migrates it into `sheets` on Save. When present, w and h are required and positive.",
|
|
53
60
|
"properties": {
|
|
54
61
|
"w": { "type": "number", "minimum": 1 },
|
|
55
62
|
"h": { "type": "number", "minimum": 1 },
|
|
@@ -120,6 +127,57 @@
|
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
},
|
|
130
|
+
"filterSheet": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"required": ["elements"],
|
|
133
|
+
"additionalProperties": true,
|
|
134
|
+
"description": "One framing plan's filter geometry. The dimmed `page.bg_b64` raster is CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed.",
|
|
135
|
+
"properties": {
|
|
136
|
+
"sheet": { "type": "string", "description": "Sheet number, e.g. S-201 — the switcher label." },
|
|
137
|
+
"title": { "type": "string" },
|
|
138
|
+
"page": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"required": ["w", "h"],
|
|
141
|
+
"properties": {
|
|
142
|
+
"w": { "type": "number", "minimum": 1 },
|
|
143
|
+
"h": { "type": "number", "minimum": 1 },
|
|
144
|
+
"bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." }
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of this sheet's steel linework, for Zoom-to-steel." },
|
|
148
|
+
"elements": {
|
|
149
|
+
"type": "array",
|
|
150
|
+
"description": "This sheet's drawing elements: lines (SVG path `d`) and text spans (`text`+`bbox`), each tagged layer/thickness/colour.",
|
|
151
|
+
"items": {
|
|
152
|
+
"type": "object",
|
|
153
|
+
"additionalProperties": true,
|
|
154
|
+
"properties": {
|
|
155
|
+
"kind": { "enum": ["line", "text"] },
|
|
156
|
+
"layer": { "type": "string" },
|
|
157
|
+
"w": { "type": "number" },
|
|
158
|
+
"color": { "type": "string" },
|
|
159
|
+
"d": { "type": "string" },
|
|
160
|
+
"text": { "type": "string" },
|
|
161
|
+
"bbox": { "type": "array", "items": { "type": "number" } },
|
|
162
|
+
"dashed": { "type": "boolean" }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"connection": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"required": ["id", "type"],
|
|
171
|
+
"additionalProperties": true,
|
|
172
|
+
"description": "One connection-library row. `detail` is the firm's VENDOR-NEUTRAL design detail#; `targets` is an open map platform→component id (tekla/sds2/revit/…), never hardcoded to one platform.",
|
|
173
|
+
"properties": {
|
|
174
|
+
"id": { "type": "string", "description": "Stable row id, e.g. cx-moment-1." },
|
|
175
|
+
"type": { "type": "string", "description": "moment | drag | shear | braced | pinned | fixed (free string allowed)." },
|
|
176
|
+
"detail": { "type": "string", "description": "Design detail number, e.g. 50S504 — the firm's document, platform-neutral." },
|
|
177
|
+
"targets": { "type": "object", "additionalProperties": { "type": "string" }, "description": "platform → component id, e.g. { \"tekla\": \"146\" }. Open map; add sds2/revit freely." },
|
|
178
|
+
"source": { "enum": ["company-rules", "user", "drawing"], "description": "Where the row came from (drives the connection confidence band)." }
|
|
179
|
+
}
|
|
180
|
+
},
|
|
123
181
|
"point2": {
|
|
124
182
|
"type": "array",
|
|
125
183
|
"items": { "type": "number" },
|
|
@@ -269,7 +327,9 @@
|
|
|
269
327
|
"tos": { "type": ["number", "null"], "description": "Top-of-steel elevation in inches at this end." },
|
|
270
328
|
"note": { "type": "string", "description": "Connection note (e.g. moment, shear, braced)." },
|
|
271
329
|
"tosDef": { "type": "boolean", "description": "True = follows the plan default; false = read from a drawing callout or set by hand." },
|
|
272
|
-
"detail": { "type": "string", "description": "Connection detail reference, e.g. \"5-S504\"." }
|
|
330
|
+
"detail": { "type": "string", "description": "Connection detail reference, e.g. \"5-S504\"." },
|
|
331
|
+
"conn": { "type": "string", "description": "Connection-library row id this end uses (references `connections[].id`). Empty = fall back to `note`/`detail`." },
|
|
332
|
+
"connVerified": { "type": "boolean", "description": "True = a human confirmed this end's connection (scores Verified)." }
|
|
273
333
|
}
|
|
274
334
|
},
|
|
275
335
|
"col": {
|
|
@@ -279,7 +339,9 @@
|
|
|
279
339
|
"bos": { "type": ["number", "null"], "description": "Bottom-of-steel elevation in inches." },
|
|
280
340
|
"tos": { "type": ["number", "null"], "description": "Top-of-steel elevation in inches." },
|
|
281
341
|
"note": { "type": "string" },
|
|
282
|
-
"detail": { "type": "string" }
|
|
342
|
+
"detail": { "type": "string" },
|
|
343
|
+
"conn": { "type": "string", "description": "Connection-library row id for this column's connection (references `connections[].id`)." },
|
|
344
|
+
"connVerified": { "type": "boolean", "description": "True = a human confirmed this column's connection (scores Verified)." }
|
|
283
345
|
}
|
|
284
346
|
}
|
|
285
347
|
}
|
|
@@ -41,45 +41,50 @@ and proceed from that path.
|
|
|
41
41
|
|
|
42
42
|
### 1b. Filter pre-stage — isolate the steel linework FIRST (the `filter` node)
|
|
43
43
|
|
|
44
|
-
Before the heavy read, do **one** deterministic PyMuPDF pass that enumerates
|
|
45
|
-
element by **layer · thickness · colour** and bakes it into the contract's `filter`
|
|
46
|
-
then opens the **Filter** node (double-click → the steel-filter view)
|
|
47
|
-
lines — by facet (layer/thickness/colour) or the
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`*JOIST*`) → those default **on**; everything else defaults off. Mark `drawable: false` on pure
|
|
67
|
-
text/dimension/title-block layers (listed for completeness, not drawn as lines).
|
|
68
|
-
4. **Page + bbox** — `page: { w, h, bg_b64 }` where `bg_b64` is a dimmed raster of the framing area
|
|
44
|
+
Before the heavy read, do **one** deterministic PyMuPDF pass **per framing-plan sheet** that enumerates
|
|
45
|
+
*every* drawing element by **layer · thickness · colour** and bakes it into the contract's `filter`
|
|
46
|
+
block. The user then opens the **Filter** node (double-click → the steel-filter view), flips through
|
|
47
|
+
the sheets, and keeps only the real steel lines — by facet (layer/thickness/colour) or the
|
|
48
|
+
**eyedropper**. Because a coordinated drawing set uses one CAD template, the facet selection is
|
|
49
|
+
**global**: narrow once, it applies to every sheet. The saved selection is what step 2 binds profile
|
|
50
|
+
labels to, per sheet.
|
|
51
|
+
|
|
52
|
+
**First, pick the plans (confirm gate).** From the sheet index (step 2.1) enumerate the framing-plan
|
|
53
|
+
sheets (Foundation, each Floor, Roof). **Show the user the list and get an explicit OK** —
|
|
54
|
+
"I found these framing plans: S-201, S-202, S-301 — filter all of these?" — before the pass. (The full
|
|
55
|
+
elevation/detail classification is the broader workflow; here we only need the plan set.)
|
|
56
|
+
|
|
57
|
+
Then, for **each** confirmed plan sheet, one pass in display space (`page.rotation_matrix` — never mix
|
|
58
|
+
native/display coords):
|
|
59
|
+
|
|
60
|
+
1. **Lines** — `page.get_drawings()`: per path keep `{ kind:"line", layer:<OCG name>, w:<stroke width
|
|
61
|
+
pt>, color:<#rrggbb stroke>, d:<SVG path>, dashed:<bool> }`.
|
|
62
|
+
2. **Text** — `page.get_text("dict")`: per span keep `{ kind:"text", layer:<OCG>, w:0, color:<#hex>,
|
|
63
|
+
text:<string>, bbox:[x0,y0,x1,y1] }`.
|
|
64
|
+
3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64 }, steelBbox,
|
|
65
|
+
elements:[…] }` onto `filter.sheets`. `bg_b64` is a dimmed raster of THIS sheet's framing area
|
|
69
66
|
(**title block excluded** — CONFIDENTIAL, machine-local, never committed); `steelBbox` = the bbox
|
|
70
|
-
of
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
of this sheet's steel-layer elements (Zoom-to-steel).
|
|
68
|
+
4. **Global facets (union across sheets)** — aggregate distinct `layers` / `thicknesses` / `colors`
|
|
69
|
+
with a summed `count` each, ACROSS all sheets, into the top-level `filter.layers/thicknesses/colors`
|
|
70
|
+
(NOT per-sheet). Round each element's `w` and the facet `w` the **same** way (matching is
|
|
71
|
+
exact-equality). Mark `steel: true` on layers whose name reads as steel framing (e.g.
|
|
72
|
+
`*BEAM*STEL*`, `*COL*`, `*BRACE*`, `*JOIST*`) → default **on**; others off. Mark
|
|
73
|
+
`drawable: false` on pure text/dimension/title-block layers.
|
|
74
|
+
5. Set `filter.mode: "layer"`, `filter.active: 0`. **PUT** the contract
|
|
75
|
+
(`PUT /api/contract/steel-takeoff`). On a re-read, preserve any existing per-facet `on` flags.
|
|
76
|
+
|
|
77
|
+
Then tell the user to open the **Filter** node, flip through the sheets, narrow to the steel, and
|
|
78
|
+
**Save**. The saved `on` flags + `mode` are what step 2 reads — **per sheet** (each sheet's elements
|
|
79
|
+
passing `mode` + the global `on` flags).
|
|
76
80
|
|
|
77
81
|
### 2. Read it with your own vision — the heavy pipeline
|
|
78
82
|
|
|
79
83
|
> **Candidate geometry comes from the filter (1b) when present.** If the contract carries a saved
|
|
80
|
-
> `filter`, the member candidate set is
|
|
81
|
-
> pass `mode` +
|
|
82
|
-
>
|
|
84
|
+
> `filter`, the member candidate set **for each sheet** is that sheet's **kept** line elements
|
|
85
|
+
> (`filter.sheets[i].elements` whose layer/thickness/colour pass the GLOBAL `mode` + `on` flags) —
|
|
86
|
+
> bind labels to *those*, not to a length-filtered scan of the whole sheet. The length heuristic
|
|
87
|
+
> below is the **fallback** only when no filter was saved.
|
|
83
88
|
|
|
84
89
|
Follow the methodology condensed here (full detail + every gotcha in
|
|
85
90
|
`docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
|
package/dist/web/aware.js
CHANGED
|
@@ -1471,7 +1471,19 @@
|
|
|
1471
1471
|
$contractEditor.hidden = false;
|
|
1472
1472
|
return true;
|
|
1473
1473
|
}
|
|
1474
|
-
function closeContractEditor() {
|
|
1474
|
+
async function closeContractEditor() {
|
|
1475
|
+
// A surface inside the iframe (the steel filter) can expose a same-origin dirty-guard so we show
|
|
1476
|
+
// OUR styled confirm modal instead of its native beforeunload "Leave site?" (#170). Cross-origin
|
|
1477
|
+
// (about:blank) access throws — treat that as "no guard".
|
|
1478
|
+
let guard = null;
|
|
1479
|
+
try { guard = $contractEditorFrame.contentWindow && $contractEditorFrame.contentWindow.__floGuard; }
|
|
1480
|
+
catch (_) { guard = null; }
|
|
1481
|
+
if (guard && typeof guard.isDirty === 'function' && guard.isDirty()) {
|
|
1482
|
+
const choice = await confirmUnsavedSwitch('', 'You have unsaved changes to the steel filter. What would you like to do?', 'Unsaved filter changes');
|
|
1483
|
+
if (choice === 'cancel') return; // keep the view open
|
|
1484
|
+
if (choice === 'save') { const ok = await guard.save(); if (!ok) return; } // save failed → stay open
|
|
1485
|
+
else if (choice === 'discard') { try { guard.discard(); } catch (_) {} } // silence the backstop
|
|
1486
|
+
}
|
|
1475
1487
|
$contractEditor.hidden = true;
|
|
1476
1488
|
$contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
|
|
1477
1489
|
}
|
|
@@ -1849,13 +1861,15 @@
|
|
|
1849
1861
|
// Save is the primary (Enter); Esc and the backdrop both cancel (the safe,
|
|
1850
1862
|
// non-destructive default — never lose edits to a stray key/click).
|
|
1851
1863
|
const $confirmModal = document.getElementById('confirm-modal');
|
|
1852
|
-
function confirmUnsavedSwitch(appName) {
|
|
1864
|
+
function confirmUnsavedSwitch(appName, subMsg, titleMsg) {
|
|
1853
1865
|
return new Promise((resolve) => {
|
|
1866
|
+
const $title = document.getElementById('confirm-modal-title');
|
|
1854
1867
|
const $sub = document.getElementById('confirm-modal-sub');
|
|
1855
1868
|
const $save = document.getElementById('confirm-save');
|
|
1856
1869
|
const $dont = document.getElementById('confirm-dont-save');
|
|
1857
1870
|
const $cancel = document.getElementById('confirm-cancel');
|
|
1858
|
-
$
|
|
1871
|
+
$title.textContent = titleMsg || 'Unsaved input changes'; // reset each call (default = the inputs-switch copy)
|
|
1872
|
+
$sub.textContent = subMsg || `You changed the inputs for “${appName}” but haven’t saved them yet. What would you like to do?`;
|
|
1859
1873
|
showModal($confirmModal);
|
|
1860
1874
|
setTimeout(() => $save.focus(), 0);
|
|
1861
1875
|
const done = (result) => {
|
package/dist/web/index.html
CHANGED
|
@@ -426,7 +426,7 @@
|
|
|
426
426
|
input changes. Reuses the shared modal styling; no new backdrop look. -->
|
|
427
427
|
<div class="modal-backdrop" id="confirm-modal">
|
|
428
428
|
<div class="modal">
|
|
429
|
-
<div class="modal-title">Unsaved input changes</div>
|
|
429
|
+
<div class="modal-title" id="confirm-modal-title">Unsaved input changes</div>
|
|
430
430
|
<div class="modal-sub" id="confirm-modal-sub"></div>
|
|
431
431
|
<div class="modal-actions">
|
|
432
432
|
<button id="confirm-dont-save">Don’t save</button>
|
|
@@ -891,7 +891,7 @@ function panel(){
|
|
|
891
891
|
else if(allBeam){const s0=beams.map(m=>m.ends[0]),s1=beams.map(m=>m.ends[1]);
|
|
892
892
|
wMTos('tosA',s0);wMText('ntA',s0,(o,v)=>o.note=v);wMText('dtA',s0,(o,v)=>o.detail=v);
|
|
893
893
|
wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
|
|
894
|
-
{const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});}}
|
|
894
|
+
{const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});}}
|
|
895
895
|
return;}
|
|
896
896
|
const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
|
|
897
897
|
const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
|
|
@@ -951,7 +951,7 @@ function panel(){
|
|
|
951
951
|
wireTos('tosB',m.ends[1]);
|
|
952
952
|
document.getElementById('ntB').onchange=e=>edit(()=>{m.ends[1].note=e.target.value;});
|
|
953
953
|
wireDet('dtB',m.ends[1]);
|
|
954
|
-
document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});
|
|
954
|
+
document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});
|
|
955
955
|
}
|
|
956
956
|
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // also drop any dims a mixed marquee selected
|
|
957
957
|
{const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
|
|
@@ -1527,7 +1527,29 @@ function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;let ft
|
|
|
1527
1527
|
return ft*plf/2000;}
|
|
1528
1528
|
function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
|
|
1529
1529
|
const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
|
|
1530
|
+
function _scoreConnections(){
|
|
1531
|
+
const rows={};for(const c of (C.connections||[]))if(c&&c.id)rows[c.id]=c;
|
|
1532
|
+
const bandEnd=(end)=>{const row=end.conn?rows[end.conn]:null;
|
|
1533
|
+
const complete=!!(row&&row.type&&row.detail&&row.targets&&Object.keys(row.targets).length>0);
|
|
1534
|
+
const hasNote=!!(end.note&&end.note.trim()!=='');
|
|
1535
|
+
// Only a drawing callout counts as evidence — a company-rules/user row assignment is a mapping, not drawing-confirmed.
|
|
1536
|
+
const evidence=hasNote||(row&&row.source==='drawing');
|
|
1537
|
+
if(end.connVerified===true)return{band:'verified',reason:''};
|
|
1538
|
+
if(!end.conn&&!hasNote)return{band:'rfi',reason:'no connection assigned'};
|
|
1539
|
+
if(complete&&evidence)return{band:'high',reason:''};
|
|
1540
|
+
if(complete)return{band:'med',reason:'connection type assumed (no drawing note)'};
|
|
1541
|
+
if(hasNote&&end.detail&&end.detail.trim()!=='')return{band:'med',reason:'free-text note + detail (not a mapped library row)'};
|
|
1542
|
+
if(end.conn&&!row)return{band:'low',reason:'references an unknown connection row'};
|
|
1543
|
+
return{band:'low',reason:row?'library row has a type only (no detail/component id)':'connection type only (no detail)'};};
|
|
1544
|
+
const byEnd=[];
|
|
1545
|
+
(C.plans||[]).forEach((plan,pi)=>{for(const m of (plan.members||[])){
|
|
1546
|
+
if(m.role==='column'){const r=bandEnd(m.col||{});byEnd.push({id:m.id+'#col',pi,sheet:plan.sheet||'',memberId:m.id,which:'col',band:r.band,reason:r.reason});}
|
|
1547
|
+
else{const ends=m.ends||[];for(const w of [0,1]){const r=bandEnd(ends[w]||{});byEnd.push({id:m.id+'#'+w,pi,sheet:plan.sheet||'',memberId:m.id,which:w,band:r.band,reason:r.reason});}}}});
|
|
1548
|
+
const counts=_cnt0();let num=0,den=0;
|
|
1549
|
+
for(const e of byEnd){counts[e.band]++;if(e.band==='rfi')continue;num+=BANDW[e.band];den+=1;}
|
|
1550
|
+
return{cat:{score:den>0?Math.round(num/den*100):null,tons:0,counts},ends:byEnd};}
|
|
1530
1551
|
function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[];let segs=0;
|
|
1552
|
+
const _conn=_scoreConnections();
|
|
1531
1553
|
const known=new Set();for(const p of plans)if(p.sheet)known.add(p.sheet.toUpperCase());for(const s of Object.keys(C.detail_bubbles||{}))known.add(s.toUpperCase());
|
|
1532
1554
|
plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
|
|
1533
1555
|
for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft),factors:r.factors});}
|
|
@@ -1536,7 +1558,7 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
|
|
|
1536
1558
|
const dRoll=arr=>{const c=_cnt0();let n=0,d=0;for(const x of arr){c[x.band]++;n+=BANDW[x.band];d++;}return {score:d>0?Math.round(n/d*100):null,tons:0,counts:c};};
|
|
1537
1559
|
const beams=roll(byMember.filter(m=>m.role==='beam'));const columns=roll(byMember.filter(m=>m.role==='column'));const details=dRoll(byDetail);
|
|
1538
1560
|
const sc=byMember.filter(m=>m.band!=='rfi');const n=sc.reduce((s,m)=>s+m.tons*BANDW[m.band],0),d=sc.reduce((s,m)=>s+m.tons,0);
|
|
1539
|
-
return {byMember,byDetail,byCategory:{beams,columns,details,connections:
|
|
1561
|
+
return {byMember,byDetail,byConnection:_conn.ends,byCategory:{beams,columns,details,connections:_conn.cat},coverage:{members:byMember.length,segments:segs,note:'Approximate in v1 — precise per-segment binding is Slice 2.'},overall:{score:d>0?Math.round(n/d*100):null,tons:d,rfiCount:byMember.filter(m=>m.band==='rfi').length}};}
|
|
1540
1562
|
function bandColorForPct(p){return p==null?'var(--mut)':p>=95?BANDC.verified:p>=80?BANDC.high:p>=50?BANDC.med:BANDC.low;}
|
|
1541
1563
|
// Effective target: the user's per-read override (in the contract) wins; else the app's
|
|
1542
1564
|
// target_confidence input default (read in boot()); else none. Editing the override never touches
|
|
@@ -1569,7 +1591,7 @@ function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
|
|
|
1569
1591
|
'<div class=cc-score style="color:'+(stub?'var(--mut)':bandColorForPct(cs.score))+'">'+(stub||cs.score==null?'—':cs.score+'%')+'</div>'+
|
|
1570
1592
|
_bar(stub?null:cs.score)+
|
|
1571
1593
|
'<div class=cc-counts>'+(stub?'<span>reserved slot</span>':_countsHtml(cs.counts))+'</div></div>';
|
|
1572
|
-
document.getElementById('confCats').innerHTML=card('beams','Beams',cat.beams)+card('columns','Columns',cat.columns)+card('details','Details',cat.details)+card('connections','Connections',cat.connections
|
|
1594
|
+
document.getElementById('confCats').innerHTML=card('beams','Beams',cat.beams)+card('columns','Columns',cat.columns)+card('details','Details',cat.details)+card('connections','Connections',cat.connections);
|
|
1573
1595
|
const bands=['all','verified','high','med','low','rfi'],cats=['all','beams','columns','details','connections'];
|
|
1574
1596
|
document.getElementById('confFilter').innerHTML=
|
|
1575
1597
|
'<span class=glab>Band</span><span class=grp>'+bands.map(b=>'<button data-band="'+b+'" class="'+(confBand===b?'on':'')+'">'+(b==='all'?'All':BANDLAB[b])+'</button>').join('')+'</span>'+
|
|
@@ -1579,7 +1601,21 @@ function renderConf(){const s=scoreContractJS();const cat=s.byCategory;
|
|
|
1579
1601
|
wireConf();}
|
|
1580
1602
|
function confBodyHtml(s){
|
|
1581
1603
|
if(!s.byMember.length&&!s.byDetail.length)return '<div class=hint style="padding:14px 0">No members in the takeoff yet.</div>';
|
|
1582
|
-
if(confCat==='connections')
|
|
1604
|
+
if(confCat==='connections'){
|
|
1605
|
+
// worst-first so the RFIs / unmapped ends surface at the top (the "what to fix next" view)
|
|
1606
|
+
const _ord={rfi:0,low:1,med:2,high:3,verified:4};
|
|
1607
|
+
let conn=s.byConnection.slice().sort((a,b)=>_ord[a.band]-_ord[b.band]);
|
|
1608
|
+
if(confBand!=='all')conn=conn.filter(e=>e.band===confBand);
|
|
1609
|
+
if(!conn.length)return '<div class=hint style="padding:14px 0">Nothing matches this filter.</div>';
|
|
1610
|
+
const whichLabel=w=>w==='col'?'Column':w===0?'End 1':'End 2';
|
|
1611
|
+
let h='<table class=ftab><thead><tr><th>Band</th><th>Member</th><th>End</th><th>Sheet</th><th>Reason</th><th></th></tr></thead><tbody>';
|
|
1612
|
+
conn.forEach(e=>{h+='<tr><td><span class=pill style="'+BANDPILL[e.band]+'">'+BANDLAB[e.band]+'</span></td>'+
|
|
1613
|
+
'<td>'+esc(e.memberId)+'</td>'+
|
|
1614
|
+
'<td>'+whichLabel(e.which)+'</td>'+
|
|
1615
|
+
'<td>'+esc(e.sheet)+'</td>'+
|
|
1616
|
+
'<td><span class=hint>'+esc(e.reason||'—')+'</span></td>'+
|
|
1617
|
+
'<td><button class=ghost data-loc="'+esc(e.memberId)+'" data-pi="'+e.pi+'">Locate</button></td></tr>';});
|
|
1618
|
+
h+='</tbody></table>';return h;}
|
|
1583
1619
|
let mem=s.byMember.slice();
|
|
1584
1620
|
if(confCat==='beams')mem=mem.filter(m=>m.role==='beam');else if(confCat==='columns')mem=mem.filter(m=>m.role==='column');else if(confCat==='details')mem=[];
|
|
1585
1621
|
if(confBand!=='all')mem=mem.filter(m=>m.band===confBand);
|
|
@@ -77,3 +77,35 @@ export function applySelToFilter(filter, sel, mode) {
|
|
|
77
77
|
for (const c of filter.colors || []) c.on = sel.colors.has(c.hex);
|
|
78
78
|
return filter;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Multi-sheet normalizer. The shipped single-sheet filter carries `page`/`steelBbox`/`elements`
|
|
83
|
+
* at the top level; the multi-sheet shape carries `sheets:[{sheet,title?,page,steelBbox,elements}]`
|
|
84
|
+
* + `active`. This returns `{ sheets, active }` for both, wrapping a legacy filter into one sheet so
|
|
85
|
+
* the view has a single code path. Facets (layers/thicknesses/colors) are GLOBAL and stay on the
|
|
86
|
+
* filter object — they are not touched here.
|
|
87
|
+
*
|
|
88
|
+
* ponytail: back-compat bridge for pre-multisheet contracts. Upgrade path: drop the legacy branch
|
|
89
|
+
* once no single-sheet contracts remain in the wild (the view migrates them forward on Save).
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeFilter(filter) {
|
|
92
|
+
if (!filter || typeof filter !== 'object') return { sheets: [], active: 0 };
|
|
93
|
+
let sheets;
|
|
94
|
+
if (Array.isArray(filter.sheets)) {
|
|
95
|
+
sheets = filter.sheets;
|
|
96
|
+
} else if (Array.isArray(filter.elements) && filter.elements.length) {
|
|
97
|
+
sheets = [{
|
|
98
|
+
sheet: filter.sheet || '',
|
|
99
|
+
title: filter.title,
|
|
100
|
+
page: filter.page,
|
|
101
|
+
steelBbox: filter.steelBbox,
|
|
102
|
+
elements: filter.elements,
|
|
103
|
+
}];
|
|
104
|
+
} else {
|
|
105
|
+
sheets = [];
|
|
106
|
+
}
|
|
107
|
+
const n = sheets.length;
|
|
108
|
+
const a = Number.isInteger(filter.active) ? filter.active : 0;
|
|
109
|
+
const active = (n === 0 || a < 0 || a >= n) ? 0 : a;
|
|
110
|
+
return { sheets, active };
|
|
111
|
+
}
|
|
@@ -24,6 +24,9 @@ button{background:#334155;color:var(--text);border:1px solid #475569;border-radi
|
|
|
24
24
|
button:hover{background:#475569} button:disabled{opacity:.5;cursor:default}
|
|
25
25
|
button.primary{background:var(--brand);border-color:var(--brand);color:#fff} button.primary:hover{background:#2f6fe0}
|
|
26
26
|
button:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
27
|
+
select{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 8px;font:13px system-ui;cursor:pointer}
|
|
28
|
+
select:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
29
|
+
header select#sheetSel{font-weight:600;min-width:200px;max-width:320px}
|
|
27
30
|
#app{display:flex;flex:1;min-height:0}
|
|
28
31
|
#panel{width:300px;flex:0 0 300px;background:var(--panel);border-right:1px solid var(--line);padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:18px}
|
|
29
32
|
#panel h2{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 8px;display:flex;align-items:center;justify-content:space-between}
|
|
@@ -70,6 +73,7 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
70
73
|
<body>
|
|
71
74
|
<header>
|
|
72
75
|
<b>Steel filter</b>
|
|
76
|
+
<select id="sheetSel" title="Switch framing-plan sheet" style="display:none"></select>
|
|
73
77
|
<span id="src" class="stat"></span>
|
|
74
78
|
<span class="sp"></span>
|
|
75
79
|
<span id="saved" class="stat"></span>
|
|
@@ -124,7 +128,7 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
124
128
|
</div>
|
|
125
129
|
|
|
126
130
|
<script type="module">
|
|
127
|
-
import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter } from './steel-filter-core.js';
|
|
131
|
+
import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
|
|
128
132
|
|
|
129
133
|
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
130
134
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
@@ -147,6 +151,8 @@ let mode = 'layer';
|
|
|
147
151
|
let nodes = []; // [{ node, el }]
|
|
148
152
|
let layerColor = {};
|
|
149
153
|
let dirty = false;
|
|
154
|
+
let sheets = []; // normalizeFilter(F).sheets — per-sheet geometry
|
|
155
|
+
let active = 0; // index into sheets
|
|
150
156
|
|
|
151
157
|
function setSaved(state){
|
|
152
158
|
savedEl.className = 'stat ' + (state || '');
|
|
@@ -162,7 +168,9 @@ async function boot(){
|
|
|
162
168
|
if(!res.ok){ showEmpty(); return; }
|
|
163
169
|
C = await res.json();
|
|
164
170
|
F = C && C.filter;
|
|
165
|
-
|
|
171
|
+
const norm = normalizeFilter(F);
|
|
172
|
+
sheets = norm.sheets; active = norm.active;
|
|
173
|
+
if(!F || !sheets.some(s => Array.isArray(s.elements) && s.elements.length)){ showEmpty(); return; }
|
|
166
174
|
document.getElementById('empty').classList.remove('show');
|
|
167
175
|
document.getElementById('src').textContent = (C.source && C.source.name) ? ('· ' + C.source.name) : '';
|
|
168
176
|
initFromFilter();
|
|
@@ -178,27 +186,49 @@ function initFromFilter(){
|
|
|
178
186
|
mode = F.mode || 'layer';
|
|
179
187
|
document.querySelector(`input[name=mode][value="${mode}"]`)?.setAttribute('checked','');
|
|
180
188
|
document.querySelectorAll('input[name=mode]').forEach(r => { r.checked = (r.value === mode); });
|
|
181
|
-
// layer colours: steel layers vivid+distinct, others a single muted slate
|
|
189
|
+
// layer colours: steel layers vivid+distinct, others a single muted slate (GLOBAL facets)
|
|
182
190
|
let si = 0;
|
|
183
191
|
layerColor = {};
|
|
184
192
|
for(const l of F.layers || []) layerColor[l.name] = l.steel ? PALETTE[si++ % PALETTE.length] : '#94a3b8';
|
|
185
193
|
sel = selFromFilter(F);
|
|
186
|
-
//
|
|
187
|
-
|
|
194
|
+
// Facets are GLOBAL — one selection for every sheet. The producer (skill step 1b) MUST aggregate
|
|
195
|
+
// the UNION of layers/thicknesses/colors across all sheets here; a facet value present on a sheet
|
|
196
|
+
// but missing from these lists has no toggle, so its elements would silently stay hidden.
|
|
197
|
+
buildFacet('layers', F.layers || [], 'name', it => it.name, it => it.name);
|
|
198
|
+
buildFacet('thicknesses', F.thicknesses || [], 'w', it => it.w + ' pt', it => it.w);
|
|
199
|
+
buildFacet('colors', F.colors || [], 'hex', it => it.hex, it => it.hex);
|
|
200
|
+
// sheet switcher (mirror the editor's planSel) — hidden for a single-sheet set
|
|
201
|
+
const sheetSel = document.getElementById('sheetSel');
|
|
202
|
+
sheetSel.innerHTML = sheets.map((s, i) => {
|
|
203
|
+
const lbl = (s.sheet || ('Sheet ' + (i + 1))) + (s.title ? (' · ' + s.title) : '');
|
|
204
|
+
return `<option value="${i}">${esc(lbl)}</option>`;
|
|
205
|
+
}).join('');
|
|
206
|
+
sheetSel.style.display = sheets.length > 1 ? '' : 'none';
|
|
207
|
+
sheetSel.onchange = e => setSheet(+e.target.value);
|
|
208
|
+
setSheet(active);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Render one framing-plan sheet; facets/sel/mode are global and persist across sheets.
|
|
212
|
+
// Switching sheets is NAVIGATION, not a document edit — it deliberately does not markDirty() (an
|
|
213
|
+
// estimator pages through framing plans constantly; a false "unsaved changes" prompt on read-only
|
|
214
|
+
// browsing is worse than not persisting the viewed-sheet index). `active` is written on the next Save.
|
|
215
|
+
function setSheet(i){
|
|
216
|
+
active = Math.min(Math.max(0, i|0), sheets.length - 1);
|
|
217
|
+
const sh = sheets[active] || {};
|
|
218
|
+
const pg = (sh.page && sh.page.w > 0 && sh.page.h > 0) ? sh.page : { w: 1000, h: 1000 }; // guard empty page → no NaN viewBox
|
|
188
219
|
svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
|
|
189
220
|
if(pg.bg_b64){ bgImg.setAttribute('href', 'data:image/jpeg;base64,' + pg.bg_b64); bgImg.setAttribute('width', pg.w); bgImg.setAttribute('height', pg.h); bgImg.style.display=''; }
|
|
190
221
|
else bgImg.style.display = 'none';
|
|
191
222
|
buildOverlay();
|
|
192
|
-
|
|
193
|
-
buildFacet('thicknesses', F.thicknesses || [], 'w', it => it.w + ' pt', it => it.w);
|
|
194
|
-
buildFacet('colors', F.colors || [], 'hex', it => it.hex, it => it.hex);
|
|
195
|
-
base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base };
|
|
223
|
+
base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base }; setVB();
|
|
196
224
|
apply();
|
|
225
|
+
closeEye();
|
|
226
|
+
const ss = document.getElementById('sheetSel'); if(ss) ss.value = String(active);
|
|
197
227
|
}
|
|
198
228
|
|
|
199
229
|
function buildOverlay(){
|
|
200
230
|
overlay.replaceChildren();
|
|
201
|
-
nodes = (
|
|
231
|
+
nodes = ((sheets[active] && sheets[active].elements) || []).map(el => {
|
|
202
232
|
let n;
|
|
203
233
|
if(el.kind === 'text'){
|
|
204
234
|
n = document.createElementNS(SVGNS, 'text');
|
|
@@ -271,7 +301,14 @@ function apply(){
|
|
|
271
301
|
const vis = matchesActive(el, mode, sel);
|
|
272
302
|
node.style.display = vis ? '' : 'none';
|
|
273
303
|
}
|
|
274
|
-
|
|
304
|
+
const cur = (sheets[active] && sheets[active].elements) || [];
|
|
305
|
+
let html = '<b>' + countShown(cur, mode, sel) + '</b> of <b>' + cur.length + '</b> shown';
|
|
306
|
+
if(sheets.length > 1){
|
|
307
|
+
let sa = 0, ta = 0;
|
|
308
|
+
for(const s of sheets){ const els = s.elements || []; sa += countShown(els, mode, sel); ta += els.length; }
|
|
309
|
+
html += ' · <b>' + sa + '</b> of <b>' + ta + '</b> total (' + sheets.length + ' sheets)';
|
|
310
|
+
}
|
|
311
|
+
countEl.innerHTML = html;
|
|
275
312
|
document.getElementById('facet-layers').classList.toggle('dim', !(mode === 'layer' || mode === 'thickness+layer'));
|
|
276
313
|
document.getElementById('facet-thicknesses').classList.toggle('dim', !mode.startsWith('thickness'));
|
|
277
314
|
document.getElementById('facet-colors').classList.toggle('dim', !(mode === 'color' || mode === 'thickness+color'));
|
|
@@ -345,7 +382,7 @@ svg.addEventListener('pointerup', e => {
|
|
|
345
382
|
drag = null;
|
|
346
383
|
});
|
|
347
384
|
function fit(){ vb = { ...base }; setVB(); }
|
|
348
|
-
function zoomSteel(){ const b =
|
|
385
|
+
function zoomSteel(){ const b = sheets[active] && sheets[active].steelBbox; if(!b){ fit(); return; } const pad = 40; vb = { x: b[0]-pad, y: b[1]-pad, w: (b[2]-b[0])+2*pad, h: (b[3]-b[1])+2*pad }; setVB(); }
|
|
349
386
|
document.getElementById('fit').onclick = fit;
|
|
350
387
|
document.getElementById('fit2').onclick = fit;
|
|
351
388
|
document.getElementById('zoomsteel').onclick = zoomSteel;
|
|
@@ -355,7 +392,9 @@ document.getElementById('zoomsteel2').onclick = zoomSteel;
|
|
|
355
392
|
saveBtn.onclick = save;
|
|
356
393
|
async function save(){
|
|
357
394
|
if(!C || !F) return;
|
|
358
|
-
applySelToFilter(F, sel, mode);
|
|
395
|
+
applySelToFilter(F, sel, mode); // global facet on-flags + mode
|
|
396
|
+
F.sheets = sheets; F.active = active;
|
|
397
|
+
delete F.page; delete F.elements; delete F.steelBbox; // migrate legacy single-sheet shape forward
|
|
359
398
|
C.filter = F;
|
|
360
399
|
saveBtn.disabled = true; setSaved('');
|
|
361
400
|
try {
|
|
@@ -365,6 +404,15 @@ async function save(){
|
|
|
365
404
|
} catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }
|
|
366
405
|
}
|
|
367
406
|
window.addEventListener('beforeunload', e => { if(dirty){ e.preventDefault(); e.returnValue = ''; } });
|
|
407
|
+
// #170: same-origin bridge so the parent (aware.js) can show ITS styled confirm modal on close
|
|
408
|
+
// instead of the iframe's native beforeunload "Leave site?". beforeunload above stays only as the
|
|
409
|
+
// hard tab/browser-close backstop. save() resolves dirty→false on success; discard() silences the
|
|
410
|
+
// backstop so an intentional close doesn't re-trigger the native prompt.
|
|
411
|
+
window.__floGuard = {
|
|
412
|
+
isDirty: () => dirty,
|
|
413
|
+
save: async () => { await save(); return !dirty; },
|
|
414
|
+
discard: () => { dirty = false; },
|
|
415
|
+
};
|
|
368
416
|
|
|
369
417
|
// Test seam (mirrors steel-3d-view's debug()/probe()) — lets a headless E2E drive the eyedropper
|
|
370
418
|
// and inspect state deterministically without fragile sub-pixel clicks on thin SVG strokes.
|
|
@@ -372,8 +420,11 @@ window.__filterTest = {
|
|
|
372
420
|
ready: () => !!F,
|
|
373
421
|
mode: () => mode,
|
|
374
422
|
sel: () => ({ layers: [...sel.layers], thicknesses: [...sel.thicknesses], colors: [...sel.colors] }),
|
|
375
|
-
shown: () => countShown(
|
|
376
|
-
total: () =>
|
|
423
|
+
shown: () => countShown((sheets[active] && sheets[active].elements) || [], mode, sel),
|
|
424
|
+
total: () => ((sheets[active] && sheets[active].elements) || []).length,
|
|
425
|
+
sheetCount: () => sheets.length,
|
|
426
|
+
activeSheet: () => active,
|
|
427
|
+
setSheet: (i) => setSheet(i),
|
|
377
428
|
pick: (i) => { const el = nodes[i].el; openEyedropper(Math.round(window.innerWidth / 2), 200, el); return el; },
|
|
378
429
|
addAll: () => document.getElementById('eyeAddAll').click(),
|
|
379
430
|
};
|