@floless/app 0.46.0 → 0.47.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 +19 -6
- package/dist/schemas/steel.takeoff.v1.schema.json +46 -2
- 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-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.47.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.47.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());
|
|
@@ -37,7 +37,7 @@
|
|
|
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
41
|
"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
42
|
},
|
|
43
43
|
"$defs": {
|
|
@@ -46,10 +46,16 @@
|
|
|
46
46
|
"additionalProperties": true,
|
|
47
47
|
"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
48
|
"properties": {
|
|
49
|
+
"active": { "type": "integer", "description": "Index into `sheets` of the active framing plan (mirrors the top-level plans/active)." },
|
|
50
|
+
"sheets": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": { "$ref": "#/$defs/filterSheet" },
|
|
53
|
+
"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."
|
|
54
|
+
},
|
|
49
55
|
"page": {
|
|
50
56
|
"type": "object",
|
|
51
57
|
"required": ["w", "h"],
|
|
52
|
-
"description": "
|
|
58
|
+
"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
59
|
"properties": {
|
|
54
60
|
"w": { "type": "number", "minimum": 1 },
|
|
55
61
|
"h": { "type": "number", "minimum": 1 },
|
|
@@ -120,6 +126,44 @@
|
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
},
|
|
129
|
+
"filterSheet": {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"required": ["elements"],
|
|
132
|
+
"additionalProperties": true,
|
|
133
|
+
"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.",
|
|
134
|
+
"properties": {
|
|
135
|
+
"sheet": { "type": "string", "description": "Sheet number, e.g. S-201 — the switcher label." },
|
|
136
|
+
"title": { "type": "string" },
|
|
137
|
+
"page": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"required": ["w", "h"],
|
|
140
|
+
"properties": {
|
|
141
|
+
"w": { "type": "number", "minimum": 1 },
|
|
142
|
+
"h": { "type": "number", "minimum": 1 },
|
|
143
|
+
"bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." }
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
"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." },
|
|
147
|
+
"elements": {
|
|
148
|
+
"type": "array",
|
|
149
|
+
"description": "This sheet's drawing elements: lines (SVG path `d`) and text spans (`text`+`bbox`), each tagged layer/thickness/colour.",
|
|
150
|
+
"items": {
|
|
151
|
+
"type": "object",
|
|
152
|
+
"additionalProperties": true,
|
|
153
|
+
"properties": {
|
|
154
|
+
"kind": { "enum": ["line", "text"] },
|
|
155
|
+
"layer": { "type": "string" },
|
|
156
|
+
"w": { "type": "number" },
|
|
157
|
+
"color": { "type": "string" },
|
|
158
|
+
"d": { "type": "string" },
|
|
159
|
+
"text": { "type": "string" },
|
|
160
|
+
"bbox": { "type": "array", "items": { "type": "number" } },
|
|
161
|
+
"dashed": { "type": "boolean" }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
123
167
|
"point2": {
|
|
124
168
|
"type": "array",
|
|
125
169
|
"items": { "type": "number" },
|
|
@@ -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>
|
|
@@ -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
|
};
|