@floless/app 0.40.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-editor.html +39 -4
- 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
|
});
|
|
@@ -151,6 +151,7 @@
|
|
|
151
151
|
text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
|
|
152
152
|
line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
|
|
153
153
|
circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
|
|
154
|
+
body.dimsplit line.member,body.dimsplit line.seg,body.dimsplit line.dim,body.dimsplit line.dimwit,body.dimsplit image{cursor:crosshair}
|
|
154
155
|
.dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
|
|
155
156
|
body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
|
|
156
157
|
body.csaxison #svg,body.csaxison line.member,body.csaxison line.seg,body.csaxison image{cursor:crosshair}
|
|
@@ -424,6 +425,7 @@ let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoM
|
|
|
424
425
|
let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
|
|
425
426
|
let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
|
|
426
427
|
let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
|
|
428
|
+
let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
|
|
427
429
|
let selIds=new Set();
|
|
428
430
|
let undo=[], redo=[];
|
|
429
431
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
@@ -770,6 +772,7 @@ const VARIES=Symbol('varies');
|
|
|
770
772
|
function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
|
|
771
773
|
function panel(){
|
|
772
774
|
const p=document.getElementById('panel');
|
|
775
|
+
if(!selDimIds.size||!dimsVisible)dimSplitMode=false;document.body.classList.toggle('dimsplit',dimSplitMode); // split mode is meaningless without a (visible) dim selected — also disarms when dims are hidden
|
|
773
776
|
if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
|
|
774
777
|
<div class=hint id=csHint style="margin-top:8px">${csDraft?'Click the <b>second</b> point — the local-X direction. Y follows at 90°.':'Click the <b>origin</b> point to start.'}</div>
|
|
775
778
|
<div class=hint style="margin-top:8px">Two clicks define the frame; snaps to member ends & grid (<b>Alt</b> off). <b>Esc</b> cancels.</div>`;return;}
|
|
@@ -785,6 +788,14 @@ function panel(){
|
|
|
785
788
|
document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
|
|
786
789
|
document.getElementById('dimChainB').onclick=()=>toggleDimChain();
|
|
787
790
|
return;}
|
|
791
|
+
if(selDimIds.size&&!selIds.size&&!dimMode&&mode==='sel'){const one=selDimIds.size===1?P.dims.find(x=>x.id===[...selDimIds][0]):null;
|
|
792
|
+
p.innerHTML=`<span class=badge>Dimension${selDimIds.size>1?'s · '+selDimIds.size:''}</span>
|
|
793
|
+
${one?`<div class=hint style="margin-top:8px">Length <b>${esc(dimValueText(dimGeo(one.a,one.b,one.axis,one.off).px))}</b> · ${({free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'})[one.axis]}</div>`:''}
|
|
794
|
+
<div class=sect style="margin-top:12px">Edit</div>
|
|
795
|
+
<div class=seg2><button id=dimSplitB class="${dimSplitMode?'on':''}" title="Add split point (S) — click points along the dimension to insert extra dimension points; each click splits the segment under it. Esc ends.">✂ Add split point</button></div>
|
|
796
|
+
<div class=hint style="margin-top:8px">${dimSplitMode?'Click points along the dimension to split it — keep clicking to add more. <b>Alt</b> = no snap · <b>Esc</b> ends.':'Drag an end handle to re-measure · <b>Del</b> removes · <b>Esc</b> deselects.'}</div>`;
|
|
797
|
+
document.getElementById('dimSplitB').onclick=()=>toggleDimSplit();
|
|
798
|
+
return;}
|
|
788
799
|
if(mode==='add'){
|
|
789
800
|
p.innerHTML=`<span class=badge>Add member</span>
|
|
790
801
|
<div class=sect>Current profile</div>
|
|
@@ -858,7 +869,7 @@ function panel(){
|
|
|
858
869
|
document.getElementById('verifyBtn').onclick=()=>edit(()=>{const v=!allVerified;for(const m of selArr())m.verified=v;});
|
|
859
870
|
{const _gel=document.getElementById('geoEL');if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};}
|
|
860
871
|
{const _sw=document.getElementById('swapEnds');if(_sw)_sw.onclick=()=>edit(()=>{for(const m of selArr())swapMemberEnds(m);});} // reverse start↔end for the whole selection (2D + 3D via render+sync3D)
|
|
861
|
-
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
|
|
872
|
+
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // a mixed marquee selection also holds dims — remove them too (matches keyboard Delete)
|
|
862
873
|
{const sp=v=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.position=v;});
|
|
863
874
|
const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
|
|
864
875
|
if(_pt)_pt.onclick=()=>sp('top');if(_pm)_pm.onclick=()=>sp('middle');if(_pb)_pb.onclick=()=>sp('bottom');}
|
|
@@ -934,7 +945,7 @@ function panel(){
|
|
|
934
945
|
wireDet('dtB',m.ends[1]);
|
|
935
946
|
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};});
|
|
936
947
|
}
|
|
937
|
-
document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
|
|
948
|
+
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
|
|
938
949
|
{const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
|
|
939
950
|
}
|
|
940
951
|
function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const prof=addProfile;const id='m'+Date.now();const pv=snapshot();
|
|
@@ -1018,6 +1029,26 @@ function dimHintText(){if(dimChainPrev)return 'Chaining — click the next point
|
|
|
1018
1029
|
const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
|
|
1019
1030
|
return 'Dimension — '+a+(dimChain?' · Chain ON':'')+(loc?' · local axes':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
|
|
1020
1031
|
function toggleDimChain(){dimChain=!dimChain;if(!dimChain){dimChainPrev=null;dimPrevClear();}render();} // Chain (C): toggle continuous dimensioning; turning it off ends any running chain (render() repaints the panel)
|
|
1032
|
+
function toggleDimSplit(){dimSplitMode=!dimSplitMode;snapClear();render();} // S / "Add split point": arm splitting on the selected dim(s); each click then inserts a point
|
|
1033
|
+
// Split the SELECTED dim nearest the click into two at a point ON its baseline (same axis + offset).
|
|
1034
|
+
// Picks the closest selected dim within a screen tolerance whose interior the click projects into; the
|
|
1035
|
+
// split point is the perpendicular foot, so a free (oblique) dim never kinks and the total is preserved.
|
|
1036
|
+
// Keeps both halves selected so you can keep adding points. No-op (with a hint) if the click isn't on a dim.
|
|
1037
|
+
function dimSplitAt(m){
|
|
1038
|
+
const tol=16/zoom;let best=null;
|
|
1039
|
+
for(const d of P.dims){if(!selDimIds.has(d.id))continue;
|
|
1040
|
+
const pr=projPt(m,d.a,d.b); // foot on the a→b baseline + parameter t (clamped 0..1)
|
|
1041
|
+
if(pr.t<=0.02||pr.t>=0.98)continue; // not interior → can't split here
|
|
1042
|
+
const dist=Math.hypot(m[0]-pr.pt[0],m[1]-pr.pt[1]); // how far the click is from this dim's line
|
|
1043
|
+
if(dist<=tol&&(!best||dist<best.dist))best={d,pt:pr.pt,dist};}
|
|
1044
|
+
if(!best){toast('Click on the dimension (between its ends) to split it');return;}
|
|
1045
|
+
const d=best.d,mid=best.pt; // split point ON the baseline (no kink)
|
|
1046
|
+
const id1='d'+Date.now()+'_'+(dimSeq++),id2='d'+Date.now()+'_'+(dimSeq++);
|
|
1047
|
+
const d1={id:id1,a:d.a.slice(),b:mid.slice(),axis:d.axis,off:d.off,rot:d.rot},d2={id:id2,a:mid.slice(),b:d.b.slice(),axis:d.axis,off:d.off,rot:d.rot}; // halves inherit the parent's baked local-frame rotation (undefined = global)
|
|
1048
|
+
const pv=snapshot();P.dims=P.dims.filter(x=>x.id!==d.id);P.dims.push(d1,d2);
|
|
1049
|
+
selDimIds.delete(d.id);selDimIds.add(id1);selDimIds.add(id2); // both halves stay selected → keep adding points
|
|
1050
|
+
pushUndo(pv);render();sync3D();
|
|
1051
|
+
}
|
|
1021
1052
|
let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
|
|
1022
1053
|
function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
|
|
1023
1054
|
const g=document.getElementById('dimPrevG');if(!g)return;
|
|
@@ -1080,6 +1111,7 @@ function toast(msg){let t=document.getElementById('toast');
|
|
|
1080
1111
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
1081
1112
|
if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
|
|
1082
1113
|
if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
|
|
1114
|
+
if(dimSplitMode&&selDimIds.size&&dimsVisible&&mode==='sel'){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear();dimSplitAt([x,y]);e.preventDefault();return;} // split mode → each click inserts a point into the selected dim under it
|
|
1083
1115
|
if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(null);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps)
|
|
1084
1116
|
if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
|
|
1085
1117
|
if(e.ctrlKey||e.metaKey){selDimIds.has(did)?selDimIds.delete(did):selDimIds.add(did);render();return;} // Ctrl+click toggles this dim within the dim selection
|
|
@@ -1121,6 +1153,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
1121
1153
|
svg.addEventListener('pointermove',e=>{
|
|
1122
1154
|
if(csaxisMode){csPrev(e);return;}
|
|
1123
1155
|
if(dimMode){dimPrev(e);return;}
|
|
1156
|
+
if(dimSplitMode&&dimsVisible){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);sn.hit?snapMark(sn.x,sn.y):snapClear();}else snapClear();return;} // split mode → show the snap marker for the prospective split point
|
|
1124
1157
|
if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
|
|
1125
1158
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
|
|
1126
1159
|
if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1]);}else snapClear();}return;}
|
|
@@ -1196,6 +1229,7 @@ addEventListener('keydown',e=>{
|
|
|
1196
1229
|
if(e.key==='Escape'&&csaxisMode){if(csDraft){csDraft=null;csPrevClear();}else{csaxisMode=false;setCsMode();}render();return;} // 1st Esc drops the in-progress origin, 2nd exits set-axes
|
|
1197
1230
|
if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
|
|
1198
1231
|
if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
|
|
1232
|
+
if(e.key==='Escape'&&selDimIds.size){if(dimSplitMode){dimSplitMode=false;snapClear();render();}else{selDimIds.clear();render();}return;} // Esc on a selected dim: end split mode → else deselect
|
|
1199
1233
|
if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
|
|
1200
1234
|
if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
|
|
1201
1235
|
if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
|
|
@@ -1204,10 +1238,11 @@ addEventListener('keydown',e=>{
|
|
|
1204
1238
|
if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
|
|
1205
1239
|
if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
|
|
1206
1240
|
if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
|
|
1241
|
+
if(!inForm&&selDimIds.size>=1&&!selIds.size&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault();toggleDimSplit();return;} // S — toggle "add split point" on the selected dimension(s)
|
|
1207
1242
|
if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
|
|
1208
1243
|
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
|
|
1209
1244
|
if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
|
|
1210
|
-
if(kk==='s'&&selIds.size===1){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // Split — single only
|
|
1245
|
+
if(kk==='s'&&selIds.size===1&&!selDimIds.size){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // member Split — single member only, and not while dims are also selected (mixed → ambiguous, so neither split fires)
|
|
1211
1246
|
if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
|
|
1212
1247
|
if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
|
|
1213
1248
|
else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
|
|
@@ -1625,7 +1660,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1625
1660
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
1626
1661
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
1627
1662
|
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
1628
|
-
dimMode=false;dimChain=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
1663
|
+
dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
1629
1664
|
csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
|
|
1630
1665
|
defaultTOS=(P.default_tos!=null?P.default_tos:198);
|
|
1631
1666
|
P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
|
|
@@ -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>
|