@floless/app 0.41.0 → 0.42.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 +13 -8
- package/dist/schemas/steel.takeoff.v1.schema.json +81 -1
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +40 -0
- package/dist/web/aware.js +31 -1
- package/dist/web/steel-filter-core.js +79 -0
- package/dist/web/steel-filter.html +384 -0
- 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.42.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.42.0" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// oauth-presets.ts
|
|
@@ -53752,18 +53752,23 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53752
53752
|
if (idx < 0) idx = items.findIndex((it) => nodeOf(it)?.agent === "steel-takeoff-us");
|
|
53753
53753
|
if (idx < 0) throw new Error("no contract-emitting node to bake the contract into");
|
|
53754
53754
|
doc2.setIn(["nodes", idx, "config", "contract"], contract.type);
|
|
53755
|
-
const
|
|
53756
|
-
|
|
53757
|
-
|
|
53758
|
-
plans: plans.map((p) => {
|
|
53755
|
+
const baked = { ...contract };
|
|
53756
|
+
if (Array.isArray(contract.plans)) {
|
|
53757
|
+
baked.plans = contract.plans.map((p) => {
|
|
53759
53758
|
if (p && typeof p === "object") {
|
|
53760
53759
|
const clone = { ...p };
|
|
53761
53760
|
delete clone.dims;
|
|
53762
53761
|
return clone;
|
|
53763
53762
|
}
|
|
53764
53763
|
return p;
|
|
53765
|
-
})
|
|
53766
|
-
}
|
|
53764
|
+
});
|
|
53765
|
+
}
|
|
53766
|
+
const filter = baked.filter;
|
|
53767
|
+
if (filter && typeof filter === "object" && filter.page && typeof filter.page === "object") {
|
|
53768
|
+
const page = { ...filter.page };
|
|
53769
|
+
delete page.bg_b64;
|
|
53770
|
+
baked.filter = { ...filter, page };
|
|
53771
|
+
}
|
|
53767
53772
|
doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
|
|
53768
53773
|
(0, import_node_fs17.writeFileSync)(sourcePath, doc2.toString());
|
|
53769
53774
|
}
|
|
@@ -36,9 +36,89 @@
|
|
|
36
36
|
},
|
|
37
37
|
"additionalProperties": false
|
|
38
38
|
},
|
|
39
|
-
"plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
|
|
39
|
+
"plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } },
|
|
40
|
+
"filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage dataset from one compose-time PyMuPDF pass. The saved selection (per-facet `on` flags + `mode`) scopes which lines the reader binds profile labels to; replaces the length heuristic, fallback when absent. CONFIDENTIAL: `page.bg_b64` is machine-local, never committed." }
|
|
40
41
|
},
|
|
41
42
|
"$defs": {
|
|
43
|
+
"filter": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"additionalProperties": true,
|
|
46
|
+
"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).",
|
|
47
|
+
"properties": {
|
|
48
|
+
"page": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"required": ["w", "h"],
|
|
51
|
+
"description": "When present, w and h are required and positive — the view sets the SVG viewBox from them (an empty page would yield viewBox=\"NaN …\").",
|
|
52
|
+
"properties": {
|
|
53
|
+
"w": { "type": "number", "minimum": 1 },
|
|
54
|
+
"h": { "type": "number", "minimum": 1 },
|
|
55
|
+
"bg_b64": { "type": "string", "description": "Base64 JPEG of the page (title block excluded). CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed." }
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of the steel linework, for Zoom-to-steel." },
|
|
59
|
+
"mode": { "enum": ["thickness", "layer", "color", "thickness+layer", "thickness+color"], "description": "Active match mode." },
|
|
60
|
+
"hide_title_block": { "type": "boolean" },
|
|
61
|
+
"layers": {
|
|
62
|
+
"type": "array",
|
|
63
|
+
"items": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"required": ["name"],
|
|
66
|
+
"additionalProperties": true,
|
|
67
|
+
"properties": {
|
|
68
|
+
"name": { "type": "string" },
|
|
69
|
+
"count": { "type": "number" },
|
|
70
|
+
"steel": { "type": "boolean", "description": "Heuristic steel layer — defaults ON at first load." },
|
|
71
|
+
"drawable": { "type": "boolean", "description": "false = listed but not drawn (e.g. a text/title-block layer)." },
|
|
72
|
+
"on": { "type": "boolean", "description": "Persisted selection state." }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"thicknesses": {
|
|
77
|
+
"type": "array",
|
|
78
|
+
"items": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"required": ["w"],
|
|
81
|
+
"additionalProperties": true,
|
|
82
|
+
"properties": {
|
|
83
|
+
"w": { "type": "number", "description": "Stroke width in pt." },
|
|
84
|
+
"count": { "type": "number" },
|
|
85
|
+
"on": { "type": "boolean" }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"colors": {
|
|
90
|
+
"type": "array",
|
|
91
|
+
"items": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"required": ["hex"],
|
|
94
|
+
"additionalProperties": true,
|
|
95
|
+
"properties": {
|
|
96
|
+
"hex": { "type": "string" },
|
|
97
|
+
"count": { "type": "number" },
|
|
98
|
+
"on": { "type": "boolean" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"elements": {
|
|
103
|
+
"type": "array",
|
|
104
|
+
"description": "Every drawing element: lines (with an SVG path `d`) and text spans (with `text`+`bbox`), each tagged layer/thickness/colour.",
|
|
105
|
+
"items": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"additionalProperties": true,
|
|
108
|
+
"properties": {
|
|
109
|
+
"kind": { "enum": ["line", "text"] },
|
|
110
|
+
"layer": { "type": "string" },
|
|
111
|
+
"w": { "type": "number" },
|
|
112
|
+
"color": { "type": "string" },
|
|
113
|
+
"d": { "type": "string", "description": "SVG path (line elements)." },
|
|
114
|
+
"text": { "type": "string", "description": "Label text (text elements)." },
|
|
115
|
+
"bbox": { "type": "array", "items": { "type": "number" }, "description": "[x0,y0,x1,y1] (text elements)." },
|
|
116
|
+
"dashed": { "type": "boolean" }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
42
122
|
"point2": {
|
|
43
123
|
"type": "array",
|
|
44
124
|
"items": { "type": "number" },
|
|
@@ -39,8 +39,48 @@ file) and optionally `sourceName` (the original filename — use it for the `sou
|
|
|
39
39
|
provenance field). If the user just attaches or points at a drawing in prose, skip the lookup
|
|
40
40
|
and proceed from that path.
|
|
41
41
|
|
|
42
|
+
### 1b. Filter pre-stage — isolate the steel linework FIRST (the `filter` node)
|
|
43
|
+
|
|
44
|
+
Before the heavy read, do **one** deterministic PyMuPDF pass that enumerates *every* drawing
|
|
45
|
+
element by **layer · thickness · colour** and bakes it into the contract's `filter` block. The user
|
|
46
|
+
then opens the **Filter** node (double-click → the steel-filter view) and keeps only the real steel
|
|
47
|
+
lines — by facet (layer/thickness/colour) or the **eyedropper** (click a line or label → Add its
|
|
48
|
+
layer/thickness/colour). The saved selection is what step 2 binds profile labels to, so a clean
|
|
49
|
+
framing plan goes in instead of the whole noisy sheet. This is a big reliability win
|
|
50
|
+
(`S-ANNO-BEAM-STEL` + thick line beats a length-only guess) **and** makes the profile-label search in
|
|
51
|
+
step 2 far cleaner (fewer candidate lines).
|
|
52
|
+
|
|
53
|
+
One pass, display space (`page.rotation_matrix` — same as step 2, never mix native/display coords):
|
|
54
|
+
|
|
55
|
+
1. **Lines** — `page.get_drawings()`: for each path keep `{ kind:"line", layer:<OCG name>, w:<stroke
|
|
56
|
+
width pt>, color:<#rrggbb stroke>, d:<SVG path built from the path items>, dashed:<bool> }`. The
|
|
57
|
+
**layer** is the path's optional-content group (OCG) name; thickness is the stroke width; colour
|
|
58
|
+
the stroke colour.
|
|
59
|
+
2. **Text** — `page.get_text("dict")` (or rawdict): for each span keep `{ kind:"text", layer:<OCG>,
|
|
60
|
+
w:0, color:<#hex>, text:<string>, bbox:[x0,y0,x1,y1] }`. Text elements make profile **labels**
|
|
61
|
+
pickable (the eyedropper) and are the candidate set step 2's label-binding iterates.
|
|
62
|
+
3. **Facets** — aggregate distinct `layers` / `thicknesses` / `colors` with a `count` each. Round
|
|
63
|
+
each element's `w` and the facet `w` the **same** way — matching is exact-equality, so a rounding
|
|
64
|
+
mismatch would silently hide lines. Mark
|
|
65
|
+
`steel: true` on layers whose name reads as steel framing (e.g. `*BEAM*STEL*`, `*COL*`, `*BRACE*`,
|
|
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
|
|
69
|
+
(**title block excluded** — CONFIDENTIAL, machine-local, never committed); `steelBbox` = the bbox
|
|
70
|
+
of the steel-layer elements (for Zoom-to-steel). Set `mode: "layer"`.
|
|
71
|
+
5. **PUT** the contract with this `filter` block (`PUT /api/contract/steel-takeoff`). On a re-read,
|
|
72
|
+
preserve any existing per-facet `on` flags the user already set.
|
|
73
|
+
|
|
74
|
+
Then tell the user to open the **Filter** node, narrow to the steel, and **Save**. The saved `on`
|
|
75
|
+
flags + `mode` are what step 2 reads.
|
|
76
|
+
|
|
42
77
|
### 2. Read it with your own vision — the heavy pipeline
|
|
43
78
|
|
|
79
|
+
> **Candidate geometry comes from the filter (1b) when present.** If the contract carries a saved
|
|
80
|
+
> `filter`, the member candidate set is the **kept** line elements (those whose layer/thickness/colour
|
|
81
|
+
> pass `mode` + the `on` flags) — bind labels to *those*, not to a length-filtered scan of the whole
|
|
82
|
+
> sheet. The length heuristic below is the **fallback** only when no filter was saved.
|
|
83
|
+
|
|
44
84
|
Follow the methodology condensed here (full detail + every gotcha in
|
|
45
85
|
`docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
|
|
46
86
|
|
package/dist/web/aware.js
CHANGED
|
@@ -926,7 +926,12 @@
|
|
|
926
926
|
// An html-report (static) or viewer-3d (interactive 3D) agent node renders HTML — its
|
|
927
927
|
// `render` command returns an `html` field, exactly what extractReportHtml keys on. So
|
|
928
928
|
// it's a report producer regardless of exec code; prefer it directly as the viewer node.
|
|
929
|
-
|
|
929
|
+
// A pre-stage authoring node (config.surface, e.g. the steel `filter` node) renders a summary
|
|
930
|
+
// but is NOT the workflow's report viewer — its affordance is its own surface button. Skip it,
|
|
931
|
+
// or prepending it would steal "View report ▸" from the real first report node (e.g. `read`).
|
|
932
|
+
const htmlReportNode = app.nodes.find(
|
|
933
|
+
(n) => (n.agent === 'html-report' || n.agent === 'viewer-3d') && !(n.config && n.config.surface),
|
|
934
|
+
);
|
|
930
935
|
if (htmlReportNode) return htmlReportNode.id;
|
|
931
936
|
// Otherwise, qualify the app only if some node actually PRODUCES report HTML — its exec
|
|
932
937
|
// code returns an `html` field (the `data.result.html` extractReportHtml keys
|
|
@@ -1068,6 +1073,11 @@
|
|
|
1068
1073
|
teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
|
|
1069
1074
|
card.dataset.tip = 'Double-click to open the contract editor';
|
|
1070
1075
|
}
|
|
1076
|
+
// Filter pre-stage node — opens the served steel-filter view (facets + eyedropper → contract.filter).
|
|
1077
|
+
if (node && node.config && node.config.surface === 'steel-filter') {
|
|
1078
|
+
addNodeAction(card, 'Filter ▸', () => openFilterView(currentId));
|
|
1079
|
+
if (!card.dataset.tip) card.dataset.tip = 'Double-click to open the steel filter';
|
|
1080
|
+
}
|
|
1071
1081
|
});
|
|
1072
1082
|
}
|
|
1073
1083
|
|
|
@@ -1428,6 +1438,9 @@
|
|
|
1428
1438
|
function openContractEditor(appId, type) {
|
|
1429
1439
|
const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
|
|
1430
1440
|
if (!r) return false;
|
|
1441
|
+
// The editor bakes the .lock on Approve — that control belongs to the contract editor, not the
|
|
1442
|
+
// filter view (which has its own Save). Make sure it's visible here (openFilterView hides it).
|
|
1443
|
+
const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = false;
|
|
1431
1444
|
$contractEditorTitle.replaceChildren(
|
|
1432
1445
|
Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
|
|
1433
1446
|
Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
|
|
@@ -1439,6 +1452,22 @@
|
|
|
1439
1452
|
$contractEditor.hidden = false;
|
|
1440
1453
|
return true;
|
|
1441
1454
|
}
|
|
1455
|
+
// The steel filter pre-stage view — reuses the contract-editor overlay/iframe to load the served
|
|
1456
|
+
// steel-filter.html (layer/thickness/colour facets + eyedropper). Same seam as the editor: the
|
|
1457
|
+
// iframe talks to /api/contract directly and Save writes contract.filter.
|
|
1458
|
+
function openFilterView(appId) {
|
|
1459
|
+
// The filter view has its own Save; the overlay's "Approve & bake lock" (editor-only) would
|
|
1460
|
+
// be a confusing no-op here, so hide it. openContractEditor restores it.
|
|
1461
|
+
const ap = document.getElementById('contract-editor-approve'); if (ap) ap.hidden = true;
|
|
1462
|
+
$contractEditorTitle.replaceChildren(
|
|
1463
|
+
Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
|
|
1464
|
+
Object.assign(document.createElement('span'), { textContent: ' · steel filter', style: 'color:var(--text-muted)' }),
|
|
1465
|
+
);
|
|
1466
|
+
$contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
|
|
1467
|
+
$contractEditorFrame.src = '/steel-filter.html?app=' + encodeURIComponent(appId);
|
|
1468
|
+
$contractEditor.hidden = false;
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1442
1471
|
function closeContractEditor() {
|
|
1443
1472
|
$contractEditor.hidden = true;
|
|
1444
1473
|
$contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
|
|
@@ -2061,6 +2090,7 @@
|
|
|
2061
2090
|
const node = app && (app.nodes || []).find((n) => n.id === id);
|
|
2062
2091
|
const ct = window.contractTypeOf && window.contractTypeOf(node);
|
|
2063
2092
|
if (ct && openContractEditor(currentId, ct)) return;
|
|
2093
|
+
if (node && node.config && node.config.surface === 'steel-filter') { openFilterView(currentId); return; }
|
|
2064
2094
|
if (id === reportNodeId()) showReport(id);
|
|
2065
2095
|
else if (id === inputNodeId()) openInputsDialog();
|
|
2066
2096
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* steel-filter-core.js — PURE, framework-free core for the steel filter pre-stage (no DOM, no
|
|
3
|
+
* window). Unit-tested in node (server/steel-filter-core.test.ts) AND loaded by web/steel-filter.html.
|
|
4
|
+
*
|
|
5
|
+
* The filter pre-stage isolates the real steel linework on a drawing before the takeoff reader binds
|
|
6
|
+
* profile labels to it. The host AI runs ONE PyMuPDF pass at compose time and writes a `filter` block
|
|
7
|
+
* onto the steel.takeoff/v1 contract; this module is the pure match/selection logic the view and the
|
|
8
|
+
* reader both reason about. Lifted from the steel-parts-from-pdf.flo spike (`matchesActive`), extended
|
|
9
|
+
* with persistence (`on` flags) and the eyedropper add.
|
|
10
|
+
*
|
|
11
|
+
* Shapes:
|
|
12
|
+
* element : { kind:'line'|'text', layer:string, w:number, color:string, d?:string, text?:string, bbox?:number[], dashed?:boolean }
|
|
13
|
+
* mode : 'thickness' | 'layer' | 'color' | 'thickness+layer' | 'thickness+color'
|
|
14
|
+
* sel : { layers:Set<string>, thicknesses:Set<number>, colors:Set<string> }
|
|
15
|
+
* filter : { mode, layers:[{name,count,steel?,drawable?,on?}], thicknesses:[{w,count,on?}], colors:[{hex,count,on?}], elements:[…] }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The active match predicate — does this element pass the current mode + selection? */
|
|
19
|
+
export function matchesActive(el, mode, sel) {
|
|
20
|
+
const byLayer = () => sel.layers.has(el.layer);
|
|
21
|
+
const byThk = () => sel.thicknesses.has(el.w);
|
|
22
|
+
const byColor = () => sel.colors.has(el.color);
|
|
23
|
+
switch (mode) {
|
|
24
|
+
case 'thickness': return byThk();
|
|
25
|
+
case 'layer': return byLayer();
|
|
26
|
+
case 'color': return byColor();
|
|
27
|
+
case 'thickness+layer': return byThk() && byLayer();
|
|
28
|
+
case 'thickness+color': return byThk() && byColor();
|
|
29
|
+
default: return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the in-memory selection Sets from a persisted filter block's `on` flags. A missing `on`
|
|
35
|
+
* defaults to the sensible first-load state: layers default to their `steel` flag, thicknesses and
|
|
36
|
+
* colors default ON (so switching into those modes shows everything until the user narrows).
|
|
37
|
+
*/
|
|
38
|
+
export function selFromFilter(filter) {
|
|
39
|
+
const onOr = (it, dflt) => (it.on === undefined ? dflt : !!it.on);
|
|
40
|
+
return {
|
|
41
|
+
layers: new Set((filter.layers || []).filter((l) => onOr(l, !!l.steel)).map((l) => l.name)),
|
|
42
|
+
thicknesses: new Set((filter.thicknesses || []).filter((t) => onOr(t, true)).map((t) => t.w)),
|
|
43
|
+
colors: new Set((filter.colors || []).filter((c) => onOr(c, true)).map((c) => c.hex)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Eyedropper: add a picked element's facet value(s) to the selection (additive — never clears).
|
|
49
|
+
* `which` is 'layer' | 'thickness' | 'color' | 'all'. Returns the same (mutated) sel for chaining.
|
|
50
|
+
*/
|
|
51
|
+
export function eyedropperAdd(sel, el, which) {
|
|
52
|
+
// Text spans / hairlines (w<=0) have no meaningful stroke width — the popover hides their
|
|
53
|
+
// Thickness row, so "all" must not slip a w:0 into the thickness selection (which would then
|
|
54
|
+
// match every text/hairline element in any thickness mode — the opposite of isolating steel).
|
|
55
|
+
const hasStroke = el.kind !== 'text' && el.w > 0;
|
|
56
|
+
if (which === 'layer' || which === 'all') sel.layers.add(el.layer);
|
|
57
|
+
if ((which === 'thickness' || which === 'all') && hasStroke) sel.thicknesses.add(el.w);
|
|
58
|
+
if (which === 'color' || which === 'all') sel.colors.add(el.color);
|
|
59
|
+
return sel;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Count how many elements are visible under the current mode + selection. */
|
|
63
|
+
export function countShown(elements, mode, sel) {
|
|
64
|
+
let n = 0;
|
|
65
|
+
for (const el of elements) if (matchesActive(el, mode, sel)) n++;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write the live selection + mode back onto the filter block's `on` flags so it persists on the
|
|
71
|
+
* contract (what Save PUTs, and what the reader reads). Mutates and returns `filter`.
|
|
72
|
+
*/
|
|
73
|
+
export function applySelToFilter(filter, sel, mode) {
|
|
74
|
+
filter.mode = mode;
|
|
75
|
+
for (const l of filter.layers || []) l.on = sel.layers.has(l.name);
|
|
76
|
+
for (const t of filter.thicknesses || []) t.on = sel.thicknesses.has(t.w);
|
|
77
|
+
for (const c of filter.colors || []) c.on = sel.colors.has(c.hex);
|
|
78
|
+
return filter;
|
|
79
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Steel Filter</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
|
|
9
|
+
/* Theme EVERY scrollbar — no native white default may leak (panels, popovers, modal lists). */
|
|
10
|
+
*{scrollbar-width:thin;scrollbar-color:#475569 transparent}
|
|
11
|
+
*::-webkit-scrollbar{width:10px;height:10px}
|
|
12
|
+
*::-webkit-scrollbar-track{background:transparent}
|
|
13
|
+
*::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
14
|
+
*::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
|
|
15
|
+
*::-webkit-scrollbar-corner{background:transparent}
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
html,body{height:100%}
|
|
18
|
+
body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;display:flex;flex-direction:column;overflow:hidden}
|
|
19
|
+
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex:none}
|
|
20
|
+
header b{font-size:14px} header .sp{flex:1}
|
|
21
|
+
.stat{color:var(--mut);font-size:12px} .stat b{color:var(--text)}
|
|
22
|
+
.stat.dirty b{color:#f59e0b} .stat.saved b{color:#34d399} .stat.err b{color:#f87171}
|
|
23
|
+
button{background:#334155;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui}
|
|
24
|
+
button:hover{background:#475569} button:disabled{opacity:.5;cursor:default}
|
|
25
|
+
button.primary{background:var(--brand);border-color:var(--brand);color:#fff} button.primary:hover{background:#2f6fe0}
|
|
26
|
+
button:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
27
|
+
#app{display:flex;flex:1;min-height:0}
|
|
28
|
+
#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
|
+
#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}
|
|
30
|
+
.acts{display:inline-flex;gap:4px}
|
|
31
|
+
.acts button{font-size:11px;padding:2px 7px;background:transparent;border-color:var(--line);color:var(--mut)}
|
|
32
|
+
.acts button:hover{background:transparent;border-color:var(--brand);color:var(--text)}
|
|
33
|
+
.facet{border-top:1px solid var(--line);padding-top:14px}
|
|
34
|
+
#modes label,.opts label{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;cursor:pointer;color:var(--text)}
|
|
35
|
+
.opts label em{margin-left:auto;color:var(--mut);font-style:normal;font-size:11px}
|
|
36
|
+
.opts .sw{width:12px;height:12px;border-radius:3px;flex:0 0 12px;border:1px solid var(--line)}
|
|
37
|
+
.opts label.textlayer{opacity:.55;cursor:default}
|
|
38
|
+
/* Fully custom checkbox/radio — the native unchecked widget is a white/grey square that breaks the dark baseline. */
|
|
39
|
+
input[type=radio],input[type=checkbox]{-webkit-appearance:none;appearance:none;width:14px;height:14px;flex:none;border:1px solid #475569;border-radius:3px;background:transparent;cursor:pointer;margin:0;vertical-align:middle}
|
|
40
|
+
input[type=radio]{border-radius:50%}
|
|
41
|
+
input[type=checkbox]:checked{background:var(--brand);border-color:var(--brand);background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 10 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 4l3 3 5-5' stroke='%23fff' stroke-width='1.6' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center}
|
|
42
|
+
input[type=radio]:checked{background:var(--brand);border-color:var(--brand);box-shadow:inset 0 0 0 3px var(--panel)}
|
|
43
|
+
input:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
44
|
+
.facet.dim{opacity:.4} .facet.dim .opts,.facet.dim .acts{pointer-events:none}
|
|
45
|
+
.hint{color:var(--mut);font-size:11px;line-height:1.5}
|
|
46
|
+
footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
|
|
47
|
+
#count{font-size:11px;color:var(--mut);width:100%;margin-bottom:4px}
|
|
48
|
+
#count b{color:var(--text)}
|
|
49
|
+
#stage{flex:1;position:relative;background:var(--bg);min-width:0}
|
|
50
|
+
#canvas{width:100%;height:100%;cursor:grab;touch-action:none;display:block}
|
|
51
|
+
#canvas.dragging{cursor:grabbing}
|
|
52
|
+
#canvas image.bg{opacity:.28}
|
|
53
|
+
#canvas path.el{pointer-events:stroke}
|
|
54
|
+
#canvas text.el{pointer-events:all;cursor:pointer;user-select:none}
|
|
55
|
+
#empty{position:absolute;inset:0;display:none;align-items:center;justify-content:center;padding:40px;text-align:center;color:var(--mut)}
|
|
56
|
+
#empty.show{display:flex}
|
|
57
|
+
#zoombar{position:absolute;left:12px;bottom:12px;display:flex;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 8px;box-shadow:0 4px 14px rgba(0,0,0,.45)}
|
|
58
|
+
/* eyedropper popover */
|
|
59
|
+
#eye{position:fixed;display:none;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px;box-shadow:0 8px 24px rgba(0,0,0,.5);min-width:210px}
|
|
60
|
+
#eye.show{display:block}
|
|
61
|
+
#eye .ehead{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
|
|
62
|
+
#eye .erow{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px}
|
|
63
|
+
#eye .erow .k{color:var(--mut);min-width:72px;flex:none}
|
|
64
|
+
#eye .erow .v{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
65
|
+
#eye .erow .sw{width:12px;height:12px;border-radius:3px;border:1px solid var(--line);flex:none}
|
|
66
|
+
#eye .erow button{font-size:11px;padding:2px 7px}
|
|
67
|
+
#eye .eall{margin-top:8px;width:100%}
|
|
68
|
+
</style>
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
<header>
|
|
72
|
+
<b>Steel filter</b>
|
|
73
|
+
<span id="src" class="stat"></span>
|
|
74
|
+
<span class="sp"></span>
|
|
75
|
+
<span id="saved" class="stat"></span>
|
|
76
|
+
<button id="save" class="primary" disabled>Save</button>
|
|
77
|
+
</header>
|
|
78
|
+
<div id="app">
|
|
79
|
+
<aside id="panel">
|
|
80
|
+
<section id="modes">
|
|
81
|
+
<h2>Match mode</h2>
|
|
82
|
+
<label><input type="radio" name="mode" value="layer" checked /> Layer only</label>
|
|
83
|
+
<label><input type="radio" name="mode" value="thickness" /> Thickness only</label>
|
|
84
|
+
<label><input type="radio" name="mode" value="color" /> Color only</label>
|
|
85
|
+
<label><input type="radio" name="mode" value="thickness+layer" /> Thickness + Layer</label>
|
|
86
|
+
<label><input type="radio" name="mode" value="thickness+color" /> Thickness + Color</label>
|
|
87
|
+
</section>
|
|
88
|
+
<section class="facet" id="facet-layers">
|
|
89
|
+
<h2>Layers <span class="acts"><button data-all="layers">all</button><button data-none="layers">none</button></span></h2>
|
|
90
|
+
<div class="opts"></div>
|
|
91
|
+
</section>
|
|
92
|
+
<section class="facet" id="facet-thicknesses">
|
|
93
|
+
<h2>Thicknesses <span class="acts"><button data-all="thicknesses">all</button><button data-none="thicknesses">none</button></span></h2>
|
|
94
|
+
<div class="opts"></div>
|
|
95
|
+
</section>
|
|
96
|
+
<section class="facet" id="facet-colors">
|
|
97
|
+
<h2>Colors <span class="acts"><button data-all="colors">all</button><button data-none="colors">none</button></span></h2>
|
|
98
|
+
<div class="opts"></div>
|
|
99
|
+
</section>
|
|
100
|
+
<footer>
|
|
101
|
+
<span id="count"></span>
|
|
102
|
+
<button id="fit">Fit page</button>
|
|
103
|
+
<button id="zoomsteel">Zoom to steel</button>
|
|
104
|
+
</footer>
|
|
105
|
+
</aside>
|
|
106
|
+
<main id="stage">
|
|
107
|
+
<svg id="canvas" preserveAspectRatio="xMidYMid meet">
|
|
108
|
+
<image class="bg"></image>
|
|
109
|
+
<g id="overlay" fill="none"></g>
|
|
110
|
+
</svg>
|
|
111
|
+
<div id="empty">
|
|
112
|
+
<div>
|
|
113
|
+
<p>No filter data yet.</p>
|
|
114
|
+
<p class="hint" style="margin-top:8px;max-width:340px">Your terminal AI reads the drawing once (PyMuPDF) and bakes its layers, thicknesses and colours here. Use <b>Re-read & re-bake ▸</b> on the canvas, then reload.</p>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
<div id="zoombar"><button id="fit2">Fit</button><button id="zoomsteel2">Steel</button></div>
|
|
118
|
+
</main>
|
|
119
|
+
</div>
|
|
120
|
+
<div id="eye">
|
|
121
|
+
<div class="ehead"><span>Picked element</span><button id="eyeClose" style="padding:0 6px">✕</button></div>
|
|
122
|
+
<div id="eyeBody"></div>
|
|
123
|
+
<button class="eall primary" id="eyeAddAll">Add all to filter</button>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<script type="module">
|
|
127
|
+
import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter } from './steel-filter-core.js';
|
|
128
|
+
|
|
129
|
+
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
130
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
131
|
+
const PALETTE = ['#3b82f6','#22d3ee','#a78bfa','#f59e0b','#34d399','#f472b6','#60a5fa','#facc15'];
|
|
132
|
+
|
|
133
|
+
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
134
|
+
const safeColor = c => /^#[0-9a-fA-F]{3,8}$/.test(String(c)) ? c : 'transparent';
|
|
135
|
+
|
|
136
|
+
const svg = document.getElementById('canvas');
|
|
137
|
+
const overlay = document.getElementById('overlay');
|
|
138
|
+
const bgImg = svg.querySelector('image.bg');
|
|
139
|
+
const countEl = document.getElementById('count');
|
|
140
|
+
const savedEl = document.getElementById('saved');
|
|
141
|
+
const saveBtn = document.getElementById('save');
|
|
142
|
+
|
|
143
|
+
let C = null; // the full contract
|
|
144
|
+
let F = null; // C.filter
|
|
145
|
+
let sel = null; // { layers, thicknesses, colors } Sets
|
|
146
|
+
let mode = 'layer';
|
|
147
|
+
let nodes = []; // [{ node, el }]
|
|
148
|
+
let layerColor = {};
|
|
149
|
+
let dirty = false;
|
|
150
|
+
|
|
151
|
+
function setSaved(state){
|
|
152
|
+
savedEl.className = 'stat ' + (state || '');
|
|
153
|
+
savedEl.innerHTML = state === 'dirty' ? 'Unsaved <b>●</b>' : state === 'saved' ? 'Saved <b>✓</b>' : state === 'err' ? 'Save failed <b>✕</b>' : '';
|
|
154
|
+
}
|
|
155
|
+
function markDirty(){ dirty = true; saveBtn.disabled = false; setSaved('dirty'); }
|
|
156
|
+
|
|
157
|
+
async function boot(){
|
|
158
|
+
if(!APP_ID){ showEmpty('No app specified.'); return; }
|
|
159
|
+
let res;
|
|
160
|
+
try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID)); }
|
|
161
|
+
catch(e){ showEmpty('Could not reach the server.'); return; }
|
|
162
|
+
if(!res.ok){ showEmpty(); return; }
|
|
163
|
+
C = await res.json();
|
|
164
|
+
F = C && C.filter;
|
|
165
|
+
if(!F || !Array.isArray(F.elements) || !F.elements.length){ showEmpty(); return; }
|
|
166
|
+
document.getElementById('empty').classList.remove('show');
|
|
167
|
+
document.getElementById('src').textContent = (C.source && C.source.name) ? ('· ' + C.source.name) : '';
|
|
168
|
+
initFromFilter();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function showEmpty(msg){
|
|
172
|
+
const e = document.getElementById('empty');
|
|
173
|
+
if(msg) e.querySelector('p').textContent = msg;
|
|
174
|
+
e.classList.add('show');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function initFromFilter(){
|
|
178
|
+
mode = F.mode || 'layer';
|
|
179
|
+
document.querySelector(`input[name=mode][value="${mode}"]`)?.setAttribute('checked','');
|
|
180
|
+
document.querySelectorAll('input[name=mode]').forEach(r => { r.checked = (r.value === mode); });
|
|
181
|
+
// layer colours: steel layers vivid+distinct, others a single muted slate
|
|
182
|
+
let si = 0;
|
|
183
|
+
layerColor = {};
|
|
184
|
+
for(const l of F.layers || []) layerColor[l.name] = l.steel ? PALETTE[si++ % PALETTE.length] : '#94a3b8';
|
|
185
|
+
sel = selFromFilter(F);
|
|
186
|
+
// page + viewBox
|
|
187
|
+
const pg = (F.page && F.page.w > 0 && F.page.h > 0) ? F.page : { w: 1000, h: 1000 }; // guard an empty page → no NaN viewBox
|
|
188
|
+
svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
|
|
189
|
+
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
|
+
else bgImg.style.display = 'none';
|
|
191
|
+
buildOverlay();
|
|
192
|
+
buildFacet('layers', F.layers || [], 'name', it => it.name, it => it.name);
|
|
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 };
|
|
196
|
+
apply();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function buildOverlay(){
|
|
200
|
+
overlay.replaceChildren();
|
|
201
|
+
nodes = (F.elements || []).map(el => {
|
|
202
|
+
let n;
|
|
203
|
+
if(el.kind === 'text'){
|
|
204
|
+
n = document.createElementNS(SVGNS, 'text');
|
|
205
|
+
const b = el.bbox || [0,0,0,0];
|
|
206
|
+
n.setAttribute('x', b[0]); n.setAttribute('y', b[3] || b[1]);
|
|
207
|
+
n.setAttribute('font-size', Math.max(8, (b[3]-b[1]) || 10));
|
|
208
|
+
n.setAttribute('fill', layerColor[el.layer] || '#cbd5e1');
|
|
209
|
+
n.textContent = el.text || '';
|
|
210
|
+
} else {
|
|
211
|
+
n = document.createElementNS(SVGNS, 'path');
|
|
212
|
+
n.setAttribute('d', el.d || '');
|
|
213
|
+
n.setAttribute('stroke', layerColor[el.layer] || '#3b82f6');
|
|
214
|
+
n.setAttribute('stroke-width', '2.2');
|
|
215
|
+
n.setAttribute('stroke-linecap', 'round');
|
|
216
|
+
n.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
217
|
+
if(el.dashed) n.setAttribute('stroke-dasharray', '7 4');
|
|
218
|
+
}
|
|
219
|
+
n.setAttribute('class', 'el');
|
|
220
|
+
n.__el = el;
|
|
221
|
+
overlay.appendChild(n);
|
|
222
|
+
return { node: n, el };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function setOf(facet){ return facet === 'layers' ? sel.layers : facet === 'thicknesses' ? sel.thicknesses : sel.colors; }
|
|
227
|
+
|
|
228
|
+
function buildFacet(facet, items, valueKey, label, selValue){
|
|
229
|
+
const box = document.querySelector(`#facet-${facet} .opts`);
|
|
230
|
+
box.replaceChildren();
|
|
231
|
+
for(const it of items){
|
|
232
|
+
const val = it[valueKey];
|
|
233
|
+
const isText = facet === 'layers' && it.drawable === false;
|
|
234
|
+
const wrap = document.createElement('label');
|
|
235
|
+
if(isText) wrap.className = 'textlayer';
|
|
236
|
+
const cb = document.createElement('input');
|
|
237
|
+
cb.type = 'checkbox';
|
|
238
|
+
cb.checked = setOf(facet).has(selValue(it));
|
|
239
|
+
cb.value = String(val);
|
|
240
|
+
cb.addEventListener('change', () => { const s = setOf(facet); cb.checked ? s.add(selValue(it)) : s.delete(selValue(it)); markDirty(); apply(); });
|
|
241
|
+
wrap.appendChild(cb);
|
|
242
|
+
if(facet === 'layers'){
|
|
243
|
+
const sw = document.createElement('span'); sw.className = 'sw';
|
|
244
|
+
sw.style.background = layerColor[val] || 'transparent';
|
|
245
|
+
wrap.appendChild(sw);
|
|
246
|
+
} else if(facet === 'colors'){
|
|
247
|
+
const sw = document.createElement('span'); sw.className = 'sw';
|
|
248
|
+
sw.style.background = safeColor(val);
|
|
249
|
+
wrap.appendChild(sw);
|
|
250
|
+
}
|
|
251
|
+
wrap.appendChild(document.createTextNode(' ' + label(it) + ' '));
|
|
252
|
+
const c = document.createElement('em'); c.textContent = it.count; wrap.appendChild(c);
|
|
253
|
+
box.appendChild(wrap);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function syncFacetChecks(){
|
|
258
|
+
for(const facet of ['layers','thicknesses','colors']){
|
|
259
|
+
const items = F[facet] || [];
|
|
260
|
+
const cbs = document.querySelectorAll(`#facet-${facet} input[type=checkbox]`);
|
|
261
|
+
cbs.forEach((cb, i) => {
|
|
262
|
+
const it = items[i]; if(!it) return;
|
|
263
|
+
const v = facet === 'thicknesses' ? it.w : (facet === 'layers' ? it.name : it.hex);
|
|
264
|
+
cb.checked = setOf(facet).has(v);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function apply(){
|
|
270
|
+
for(const { node, el } of nodes){
|
|
271
|
+
const vis = matchesActive(el, mode, sel);
|
|
272
|
+
node.style.display = vis ? '' : 'none';
|
|
273
|
+
}
|
|
274
|
+
countEl.innerHTML = '<b>' + countShown(F.elements, mode, sel) + '</b> of <b>' + nodes.length + '</b> elements shown';
|
|
275
|
+
document.getElementById('facet-layers').classList.toggle('dim', !(mode === 'layer' || mode === 'thickness+layer'));
|
|
276
|
+
document.getElementById('facet-thicknesses').classList.toggle('dim', !mode.startsWith('thickness'));
|
|
277
|
+
document.getElementById('facet-colors').classList.toggle('dim', !(mode === 'color' || mode === 'thickness+color'));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- modes + all/none ---
|
|
281
|
+
document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => { if(r.checked){ mode = r.value; markDirty(); apply(); } }));
|
|
282
|
+
document.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.all, true)));
|
|
283
|
+
document.querySelectorAll('[data-none]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.none, false)));
|
|
284
|
+
function setAllFacet(facet, on){
|
|
285
|
+
const s = setOf(facet);
|
|
286
|
+
const items = F[facet] || [];
|
|
287
|
+
for(const it of items){ const v = facet === 'thicknesses' ? it.w : (facet === 'layers' ? it.name : it.hex); on ? s.add(v) : s.delete(v); }
|
|
288
|
+
syncFacetChecks(); markDirty(); apply();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- eyedropper: pick a line/label -> popover, + Add each / Add all ---
|
|
292
|
+
const eye = document.getElementById('eye');
|
|
293
|
+
let eyeEl = null;
|
|
294
|
+
function openEyedropper(clientX, clientY, el){
|
|
295
|
+
eyeEl = el;
|
|
296
|
+
const body = document.getElementById('eyeBody');
|
|
297
|
+
const rows = [];
|
|
298
|
+
rows.push(eyeRow('Layer', el.layer, 'layer', `<span class="sw" style="background:${safeColor(layerColor[el.layer])}"></span>`));
|
|
299
|
+
if(el.kind !== 'text' && el.w > 0) rows.push(eyeRow('Thickness', el.w + ' pt', 'thickness', ''));
|
|
300
|
+
rows.push(eyeRow('Color', el.color, 'color', `<span class="sw" style="background:${safeColor(el.color)}"></span>`));
|
|
301
|
+
body.innerHTML = rows.join('');
|
|
302
|
+
body.querySelectorAll('button[data-add]').forEach(b => b.onclick = () => { eyedropperAdd(sel, el, b.dataset.add); syncFacetChecks(); markDirty(); apply(); closeEye(); });
|
|
303
|
+
eye.classList.add('show');
|
|
304
|
+
const w = eye.offsetWidth || 220, h = eye.offsetHeight || 160;
|
|
305
|
+
eye.style.left = Math.min(clientX + 8, window.innerWidth - w - 8) + 'px';
|
|
306
|
+
eye.style.top = Math.min(clientY + 8, window.innerHeight - h - 8) + 'px';
|
|
307
|
+
}
|
|
308
|
+
function eyeRow(k, v, which, swatch){
|
|
309
|
+
return `<div class="erow"><span class="k">${k}</span>${swatch}<span class="v" title="${esc(v)}">${esc(v)}</span><button data-add="${which}">+ Add</button></div>`;
|
|
310
|
+
}
|
|
311
|
+
function closeEye(){ eye.classList.remove('show'); eyeEl = null; }
|
|
312
|
+
document.getElementById('eyeClose').onclick = closeEye;
|
|
313
|
+
document.getElementById('eyeAddAll').onclick = () => { if(eyeEl){ eyedropperAdd(sel, eyeEl, 'all'); syncFacetChecks(); markDirty(); apply(); } closeEye(); };
|
|
314
|
+
|
|
315
|
+
// --- pan/zoom via viewBox; a click (no drag) on an element opens the eyedropper ---
|
|
316
|
+
let base = { x:0, y:0, w:1000, h:1000 }, vb = { ...base }, drag = null;
|
|
317
|
+
function setVB(){ svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); }
|
|
318
|
+
svg.addEventListener('wheel', e => {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
const r = svg.getBoundingClientRect();
|
|
321
|
+
const mx = vb.x + ((e.clientX - r.left) / r.width) * vb.w;
|
|
322
|
+
const my = vb.y + ((e.clientY - r.top) / r.height) * vb.h;
|
|
323
|
+
const k = e.deltaY < 0 ? 0.85 : 1 / 0.85;
|
|
324
|
+
vb.w *= k; vb.h *= k;
|
|
325
|
+
vb.x = mx - ((e.clientX - r.left) / r.width) * vb.w;
|
|
326
|
+
vb.y = my - ((e.clientY - r.top) / r.height) * vb.h;
|
|
327
|
+
setVB();
|
|
328
|
+
}, { passive: false });
|
|
329
|
+
svg.addEventListener('pointerdown', e => {
|
|
330
|
+
drag = { x: e.clientX, y: e.clientY, vx: vb.x, vy: vb.y, moved: false, target: e.target };
|
|
331
|
+
svg.setPointerCapture(e.pointerId); svg.classList.add('dragging');
|
|
332
|
+
});
|
|
333
|
+
svg.addEventListener('pointermove', e => {
|
|
334
|
+
if(!drag) return;
|
|
335
|
+
if(Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 3) drag.moved = true;
|
|
336
|
+
const r = svg.getBoundingClientRect();
|
|
337
|
+
vb.x = drag.vx - ((e.clientX - drag.x) / r.width) * vb.w;
|
|
338
|
+
vb.y = drag.vy - ((e.clientY - drag.y) / r.height) * vb.h;
|
|
339
|
+
setVB();
|
|
340
|
+
});
|
|
341
|
+
svg.addEventListener('pointerup', e => {
|
|
342
|
+
svg.classList.remove('dragging');
|
|
343
|
+
if(drag && !drag.moved && drag.target && drag.target.__el) openEyedropper(e.clientX, e.clientY, drag.target.__el);
|
|
344
|
+
else if(drag && !drag.moved) closeEye();
|
|
345
|
+
drag = null;
|
|
346
|
+
});
|
|
347
|
+
function fit(){ vb = { ...base }; setVB(); }
|
|
348
|
+
function zoomSteel(){ const b = F && F.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
|
+
document.getElementById('fit').onclick = fit;
|
|
350
|
+
document.getElementById('fit2').onclick = fit;
|
|
351
|
+
document.getElementById('zoomsteel').onclick = zoomSteel;
|
|
352
|
+
document.getElementById('zoomsteel2').onclick = zoomSteel;
|
|
353
|
+
|
|
354
|
+
// --- save: write the selection + mode onto C.filter and PUT the contract ---
|
|
355
|
+
saveBtn.onclick = save;
|
|
356
|
+
async function save(){
|
|
357
|
+
if(!C || !F) return;
|
|
358
|
+
applySelToFilter(F, sel, mode);
|
|
359
|
+
C.filter = F;
|
|
360
|
+
saveBtn.disabled = true; setSaved('');
|
|
361
|
+
try {
|
|
362
|
+
const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
|
|
363
|
+
if(!res.ok) throw new Error(await res.text());
|
|
364
|
+
dirty = false; setSaved('saved');
|
|
365
|
+
} catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }
|
|
366
|
+
}
|
|
367
|
+
window.addEventListener('beforeunload', e => { if(dirty){ e.preventDefault(); e.returnValue = ''; } });
|
|
368
|
+
|
|
369
|
+
// Test seam (mirrors steel-3d-view's debug()/probe()) — lets a headless E2E drive the eyedropper
|
|
370
|
+
// and inspect state deterministically without fragile sub-pixel clicks on thin SVG strokes.
|
|
371
|
+
window.__filterTest = {
|
|
372
|
+
ready: () => !!F,
|
|
373
|
+
mode: () => mode,
|
|
374
|
+
sel: () => ({ layers: [...sel.layers], thicknesses: [...sel.thicknesses], colors: [...sel.colors] }),
|
|
375
|
+
shown: () => countShown(F.elements, mode, sel),
|
|
376
|
+
total: () => nodes.length,
|
|
377
|
+
pick: (i) => { const el = nodes[i].el; openEyedropper(Math.round(window.innerWidth / 2), 200, el); return el; },
|
|
378
|
+
addAll: () => document.getElementById('eyeAddAll').click(),
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
boot();
|
|
382
|
+
</script>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|