@floless/app 0.59.0 → 0.60.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 +145 -29
- package/dist/schemas/drawing.vector.v1.schema.json +135 -0
- package/dist/schemas/steel.takeoff.v1.schema.json +5 -3
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +6 -3
- package/dist/skills/floless-app-vectorize/SKILL.md +130 -0
- package/dist/skills/floless-app-vectorize/scripts/extract_pdf.py +240 -0
- package/dist/templates/vectorize.flo +61 -0
- package/dist/web/aware.js +3 -3
- package/dist/web/renderers.js +6 -0
- package/dist/web/steel-filter-core.js +44 -13
- package/dist/web/steel-filter.html +31 -19
- package/dist/web/vector-editor.html +289 -0
- package/dist/web/vector-example.json +107 -0
- package/package.json +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic vector-PDF -> drawing.vector/v1 extractor (compose-time, no LLM).
|
|
3
|
+
|
|
4
|
+
One PyMuPDF pass per page: every page.get_drawings() path becomes a geometry element
|
|
5
|
+
(SVG `d` in DISPLAY coords) and every page.get_text("dict") span becomes a text element
|
|
6
|
+
(baseline origin, real size, rotation). Emits a RAW drawing.vector/v1 contract ready to
|
|
7
|
+
PUT to /api/contract/vectorize — the server's postProcess handles endpoint-snap, dedupe
|
|
8
|
+
and layer derivation, but per-element ids are REQUIRED by the PUT schema, so this script
|
|
9
|
+
assigns them (e1.. per sheet, s1.. sheets).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
|
|
13
|
+
python extract_pdf.py drawing.pdf [more.pdf ...] [--out contract.json]
|
|
14
|
+
|
|
15
|
+
The script forces UTF-8 on its own stdout/stderr (drawings contain glyphs like a diameter
|
|
16
|
+
sign that crash Windows cp1250 consoles), so no shell-specific env setup is needed.
|
|
17
|
+
|
|
18
|
+
Requires PyMuPDF (pip install pymupdf). A raster-only page (scan / photo / hand sketch)
|
|
19
|
+
yields no geometry — that is the Slice-2 vision path, NOT this script's job; the caller
|
|
20
|
+
must tell the user honestly instead of tracing pixels.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
import fitz # PyMuPDF
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fmt(v):
|
|
35
|
+
"""Compact numeric formatting for SVG path data."""
|
|
36
|
+
return f"{round(v, 2):g}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rgb_hex(c):
|
|
40
|
+
"""PyMuPDF float RGB tuple -> #rrggbb, or None."""
|
|
41
|
+
if c is None:
|
|
42
|
+
return None
|
|
43
|
+
r, g, b = (max(0, min(255, round(v * 255))) for v in c)
|
|
44
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def int_hex(c):
|
|
48
|
+
"""get_text span colour int (sRGB) -> #rrggbb."""
|
|
49
|
+
return f"#{(c or 0) & 0xFFFFFF:06x}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def path_to_element(pth, m):
|
|
53
|
+
"""One get_drawings() path -> a geometry element dict (or None if it draws nothing).
|
|
54
|
+
|
|
55
|
+
Item -> SVG mapping: 'l' segments chain into M/L, 'c' cubic beziers into C,
|
|
56
|
+
're' rects and 'qu' quads into closed 4-corner polygons. Every point is mapped
|
|
57
|
+
through the page's rotation_matrix so a rotated page lands in display space.
|
|
58
|
+
"""
|
|
59
|
+
d, pts, kinds = [], [], set()
|
|
60
|
+
cur = None
|
|
61
|
+
breaks = 0 # subpath breaks after the first M (disqualifies pts[])
|
|
62
|
+
|
|
63
|
+
def moveto(p):
|
|
64
|
+
nonlocal cur, breaks
|
|
65
|
+
if cur is not None:
|
|
66
|
+
breaks += 1
|
|
67
|
+
d.append(f"M{fmt(p.x)} {fmt(p.y)}")
|
|
68
|
+
pts.append([round(p.x, 2), round(p.y, 2)])
|
|
69
|
+
cur = p
|
|
70
|
+
|
|
71
|
+
def cont(p):
|
|
72
|
+
return cur is not None and abs(cur.x - p.x) < 1e-4 and abs(cur.y - p.y) < 1e-4
|
|
73
|
+
|
|
74
|
+
for item in pth["items"]:
|
|
75
|
+
op = item[0]
|
|
76
|
+
if op == "l":
|
|
77
|
+
p1, p2 = item[1] * m, item[2] * m
|
|
78
|
+
if abs(p1.x - p2.x) < 1e-6 and abs(p1.y - p2.y) < 1e-6:
|
|
79
|
+
continue # zero-length
|
|
80
|
+
if not cont(p1):
|
|
81
|
+
moveto(p1)
|
|
82
|
+
d.append(f"L{fmt(p2.x)} {fmt(p2.y)}")
|
|
83
|
+
pts.append([round(p2.x, 2), round(p2.y, 2)])
|
|
84
|
+
cur = p2
|
|
85
|
+
kinds.add("l")
|
|
86
|
+
elif op == "c":
|
|
87
|
+
p1, c1, c2, p2 = (item[i] * m for i in (1, 2, 3, 4))
|
|
88
|
+
if not cont(p1):
|
|
89
|
+
moveto(p1)
|
|
90
|
+
d.append(f"C{fmt(c1.x)} {fmt(c1.y)} {fmt(c2.x)} {fmt(c2.y)} {fmt(p2.x)} {fmt(p2.y)}")
|
|
91
|
+
cur = p2
|
|
92
|
+
kinds.add("c")
|
|
93
|
+
elif op in ("re", "qu"):
|
|
94
|
+
q = item[1].quad if op == "re" else item[1]
|
|
95
|
+
ul, ur, lr, ll = (p * m for p in (q.ul, q.ur, q.lr, q.ll))
|
|
96
|
+
if cur is not None:
|
|
97
|
+
breaks += 1
|
|
98
|
+
d.append(
|
|
99
|
+
f"M{fmt(ul.x)} {fmt(ul.y)} L{fmt(ur.x)} {fmt(ur.y)} "
|
|
100
|
+
f"L{fmt(lr.x)} {fmt(lr.y)} L{fmt(ll.x)} {fmt(ll.y)} Z"
|
|
101
|
+
)
|
|
102
|
+
cur = None
|
|
103
|
+
kinds.add(op)
|
|
104
|
+
|
|
105
|
+
if not d:
|
|
106
|
+
return None
|
|
107
|
+
closed = bool(pth.get("closePath"))
|
|
108
|
+
if closed and not d[-1].endswith("Z"):
|
|
109
|
+
d.append("Z")
|
|
110
|
+
|
|
111
|
+
kind = "spline" if "c" in kinds else ("line" if kinds == {"l"} and len(pts) == 2 else "polyline")
|
|
112
|
+
el = {"kind": kind}
|
|
113
|
+
if kinds == {"l"} and breaks == 0 and len(pts) >= 2:
|
|
114
|
+
# Continuous straight-line chain: emit VERTICES ONLY (no `d`). Renderers rebuild the path
|
|
115
|
+
# from pts, and the server's endpoint-snap edits pts — a `d` alongside would shadow the
|
|
116
|
+
# snapped geometry (renderers prefer `d` when both are present).
|
|
117
|
+
if closed and pts[0] != pts[-1]:
|
|
118
|
+
pts.append(list(pts[0])) # make the closing edge explicit — pts paths carry no Z
|
|
119
|
+
el["pts"] = pts
|
|
120
|
+
if len(pts) > 2:
|
|
121
|
+
el["kind"] = "polyline"
|
|
122
|
+
else:
|
|
123
|
+
el["d"] = " ".join(d)
|
|
124
|
+
color = rgb_hex(pth.get("color")) or rgb_hex(pth.get("fill"))
|
|
125
|
+
if color is None:
|
|
126
|
+
return None # neither stroked nor filled -> draws nothing
|
|
127
|
+
el["color"] = color
|
|
128
|
+
if pth.get("width"):
|
|
129
|
+
el["w"] = round(pth["width"], 3)
|
|
130
|
+
dashes = (pth.get("dashes") or "").strip()
|
|
131
|
+
if dashes and dashes not in ("[] 0", "[]"):
|
|
132
|
+
el["dashed"] = True
|
|
133
|
+
if pth.get("layer"):
|
|
134
|
+
el["layer"] = pth["layer"]
|
|
135
|
+
return el
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def text_elements(page, m):
|
|
139
|
+
"""get_text('dict') spans -> text elements with baseline origin, size, rotation, colour."""
|
|
140
|
+
rot = fitz.Matrix(m.a, m.b, m.c, m.d, 0, 0) # rotation part only (for direction vectors)
|
|
141
|
+
out = []
|
|
142
|
+
for block in page.get_text("dict")["blocks"]:
|
|
143
|
+
if block.get("type") != 0:
|
|
144
|
+
continue
|
|
145
|
+
for line in block["lines"]:
|
|
146
|
+
dv = fitz.Point(line["dir"]) * rot
|
|
147
|
+
angle = round(math.degrees(math.atan2(dv.y, dv.x)), 2)
|
|
148
|
+
for span in line["spans"]:
|
|
149
|
+
if not span["text"].strip():
|
|
150
|
+
continue
|
|
151
|
+
origin = fitz.Point(span["origin"]) * m
|
|
152
|
+
bbox = fitz.Rect(span["bbox"]) * m
|
|
153
|
+
bbox.normalize()
|
|
154
|
+
el = {
|
|
155
|
+
"kind": "text",
|
|
156
|
+
"text": span["text"],
|
|
157
|
+
"origin": [round(origin.x, 2), round(origin.y, 2)],
|
|
158
|
+
"bbox": [round(v, 2) for v in (bbox.x0, bbox.y0, bbox.x1, bbox.y1)],
|
|
159
|
+
"size": round(span["size"], 2),
|
|
160
|
+
"color": int_hex(span.get("color")),
|
|
161
|
+
}
|
|
162
|
+
if span.get("font"):
|
|
163
|
+
el["font"] = span["font"]
|
|
164
|
+
if abs(angle) > 0.01:
|
|
165
|
+
el["angle"] = angle
|
|
166
|
+
out.append(el)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract(paths):
|
|
171
|
+
sheets = []
|
|
172
|
+
for path in paths:
|
|
173
|
+
doc = fitz.open(path)
|
|
174
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
175
|
+
for pno in range(doc.page_count):
|
|
176
|
+
page = doc[pno]
|
|
177
|
+
m = page.rotation_matrix # unrotated -> display space; page.rect is already display
|
|
178
|
+
elements = []
|
|
179
|
+
for pth in page.get_drawings():
|
|
180
|
+
el = path_to_element(pth, m)
|
|
181
|
+
if el:
|
|
182
|
+
elements.append(el)
|
|
183
|
+
elements.extend(text_elements(page, m))
|
|
184
|
+
for i, el in enumerate(elements):
|
|
185
|
+
el["id"] = f"e{i + 1}" # PUT schema requires ids; stable in document order
|
|
186
|
+
sheets.append({
|
|
187
|
+
"id": f"s{len(sheets) + 1}",
|
|
188
|
+
"label": f"{stem} p{pno + 1}",
|
|
189
|
+
"page": {"w": round(page.rect.width, 2), "h": round(page.rect.height, 2)},
|
|
190
|
+
"elements": elements,
|
|
191
|
+
})
|
|
192
|
+
doc.close()
|
|
193
|
+
|
|
194
|
+
first = paths[0]
|
|
195
|
+
name = os.path.basename(first) + (f" (+{len(paths) - 1} more)" if len(paths) > 1 else "")
|
|
196
|
+
return {
|
|
197
|
+
"type": "drawing.vector/v1",
|
|
198
|
+
"units": "pt", # no transform emitted -> display space (PDF points) is world 1:1
|
|
199
|
+
"source": {
|
|
200
|
+
"name": name,
|
|
201
|
+
"path": os.path.abspath(first),
|
|
202
|
+
"kind": "pdf",
|
|
203
|
+
"sha256": hashlib.sha256(open(first, "rb").read()).hexdigest(),
|
|
204
|
+
"read_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
205
|
+
"extractor": f"pymupdf@compose-time ({getattr(fitz, 'VersionBind', '?')})",
|
|
206
|
+
},
|
|
207
|
+
"sheets": sheets,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
# Self-sufficient UTF-8 output regardless of console codepage (Windows cp1250 crashes on Ø/—).
|
|
213
|
+
for stream in (sys.stdout, sys.stderr):
|
|
214
|
+
try:
|
|
215
|
+
stream.reconfigure(encoding="utf-8")
|
|
216
|
+
except AttributeError:
|
|
217
|
+
pass
|
|
218
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
219
|
+
ap.add_argument("pdfs", nargs="+", help="vector PDF file(s) to extract")
|
|
220
|
+
ap.add_argument("--out", help="write the contract JSON here instead of stdout")
|
|
221
|
+
args = ap.parse_args()
|
|
222
|
+
|
|
223
|
+
contract = extract(args.pdfs)
|
|
224
|
+
total = sum(len(s["elements"]) for s in contract["sheets"])
|
|
225
|
+
if total == 0:
|
|
226
|
+
print(
|
|
227
|
+
"WARNING: no vector geometry or text found — this looks like a raster-only PDF "
|
|
228
|
+
"(scan/photo). The deterministic path cannot read it; do not fabricate linework.",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
if args.out:
|
|
232
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
233
|
+
json.dump(contract, f, ensure_ascii=False)
|
|
234
|
+
print(f"{args.out}: {len(contract['sheets'])} sheet(s), {total} element(s)", file=sys.stderr)
|
|
235
|
+
else:
|
|
236
|
+
json.dump(contract, sys.stdout, ensure_ascii=False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
app: vectorize
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Vectorize
|
|
4
|
+
publisher: floless
|
|
5
|
+
description: |
|
|
6
|
+
Turn any drawing — a sketch, a PDF, a photo — into clean, editable 2D vectors. Your terminal AI
|
|
7
|
+
reads the drawing ONCE at compose time (a deterministic PyMuPDF pass — no LLM) and bakes it into a
|
|
8
|
+
drawing.vector contract; double-click the node to open the 2D vector editor, where you pan and zoom,
|
|
9
|
+
toggle what's shown, flag weak traces, and export SVG. Attach a drawing and use "Re-read & re-bake ▸"
|
|
10
|
+
(or ask your terminal AI) to vectorize your own; the deterministic run just re-renders a short summary
|
|
11
|
+
of what was read. Ships with a tiny example so it works out of the box.
|
|
12
|
+
exposes-as-agent: false
|
|
13
|
+
# Workflow changelog (FloLess-published). Newest first; the app shows entries newer than the installed
|
|
14
|
+
# version when offering an update. Plain-English — it surfaces to the user.
|
|
15
|
+
changelog:
|
|
16
|
+
- version: 0.1.0
|
|
17
|
+
summary: Vectorize a drawing into clean 2D linework you can edit and export
|
|
18
|
+
inputs:
|
|
19
|
+
drawing:
|
|
20
|
+
type: image
|
|
21
|
+
widget: file
|
|
22
|
+
accept: [pdf, png, jpg]
|
|
23
|
+
read-strategy: bake
|
|
24
|
+
description: A drawing to vectorize — read by your terminal AI into clean 2D vectors; swap to re-read & re-bake.
|
|
25
|
+
requires:
|
|
26
|
+
- html-report@0.2.x
|
|
27
|
+
layout: linear
|
|
28
|
+
nodes:
|
|
29
|
+
# ── node 1: read — vectorize the drawing into clean 2D linework ───────────────
|
|
30
|
+
# This is where your drawing becomes editable vectors. Your terminal AI reads the
|
|
31
|
+
# attached drawing once (a deterministic PyMuPDF pass — no LLM) and bakes every line,
|
|
32
|
+
# curve and label into the contract. Double-click this node to open the 2D vector
|
|
33
|
+
# editor: pan and zoom the drawing, toggle Lines / Curves / Text, flag any weak traces,
|
|
34
|
+
# and Export SVG. `contract` tells FloLess which editor to open; `takeoff` is the single
|
|
35
|
+
# source the editor, Approve and the run all read. It ships with a tiny EXAMPLE so it
|
|
36
|
+
# renders out of the box — "Re-read & re-bake ▸" replaces it with your own drawing. The
|
|
37
|
+
# deterministic run re-renders the short summary below.
|
|
38
|
+
- id: read
|
|
39
|
+
agent: html-report
|
|
40
|
+
command: render
|
|
41
|
+
config:
|
|
42
|
+
contract: drawing.vector/v1
|
|
43
|
+
takeoff:
|
|
44
|
+
type: drawing.vector/v1
|
|
45
|
+
units: mm
|
|
46
|
+
source: { name: "Example — sample detail (not your drawing)" }
|
|
47
|
+
sheets:
|
|
48
|
+
- id: s1
|
|
49
|
+
label: "Sample detail"
|
|
50
|
+
page: { w: 300, h: 200 }
|
|
51
|
+
elements:
|
|
52
|
+
- { id: e1, kind: polyline, d: "M60 50 H240 V150 H60 Z", color: "#334155", w: 1.4 }
|
|
53
|
+
- { id: e2, kind: line, d: "M150 30 L150 170", color: "#94a3b8", w: 0.8, dashed: true }
|
|
54
|
+
- { id: e3, kind: line, d: "M60 100 H240", color: "#94a3b8", w: 0.8 }
|
|
55
|
+
- { id: t1, kind: text, text: "SAMPLE DETAIL", size: 11, origin: [66, 44], color: "#334155" }
|
|
56
|
+
- { id: t2, kind: text, text: "PL 13mm", size: 9, origin: [98, 96], color: "#334155" }
|
|
57
|
+
title: "Vectorized drawing"
|
|
58
|
+
data:
|
|
59
|
+
- { Kind: Lines, Count: 3 }
|
|
60
|
+
- { Kind: Text, Count: 2 }
|
|
61
|
+
connections: []
|
package/dist/web/aware.js
CHANGED
|
@@ -1752,9 +1752,9 @@
|
|
|
1752
1752
|
function openContractEditor(appId, type) {
|
|
1753
1753
|
const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
|
|
1754
1754
|
if (!r) return false;
|
|
1755
|
-
// The editor bakes the .lock on Approve — that control belongs to
|
|
1756
|
-
// filter view (
|
|
1757
|
-
const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden =
|
|
1755
|
+
// The editor bakes the .lock on Approve — that control belongs to an editable contract editor, not
|
|
1756
|
+
// the filter view (own Save) nor a view-only renderer. Show it unless the renderer is viewOnly.
|
|
1757
|
+
const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = !!r.viewOnly;
|
|
1758
1758
|
$contractEditorTitle.replaceChildren(
|
|
1759
1759
|
Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
|
|
1760
1760
|
Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
|
package/dist/web/renderers.js
CHANGED
|
@@ -13,6 +13,12 @@ window.CONTRACT_RENDERERS = {
|
|
|
13
13
|
'steel.takeoff/v1': {
|
|
14
14
|
editorUrl: (appId) => '/steel-editor.html?app=' + encodeURIComponent(appId),
|
|
15
15
|
},
|
|
16
|
+
'drawing.vector/v1': {
|
|
17
|
+
editorUrl: (appId) => '/vector-editor.html?app=' + encodeURIComponent(appId),
|
|
18
|
+
// View-only for now: the vector editor renders + exports SVG but has no Save/edit path yet (the
|
|
19
|
+
// edit/re-bake flow is a later slice), so the shell hides its Approve button (see openContractEditor).
|
|
20
|
+
viewOnly: true,
|
|
21
|
+
},
|
|
16
22
|
};
|
|
17
23
|
|
|
18
24
|
// Return the contract type string declared on a node, or null when none.
|
|
@@ -10,24 +10,37 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Shapes:
|
|
12
12
|
* element : { kind:'line'|'text', layer:string, w:number, color:string, d?:string, text?:string, bbox?:number[], dashed?:boolean }
|
|
13
|
-
* mode : '
|
|
13
|
+
* mode : any '+'-joined combo of 'layer'/'thickness'/'color', AND-ed (e.g. 'thickness+layer').
|
|
14
|
+
* '' = match nothing. Canonical order = MODE_ORDER (keeps legacy 'thickness+…' strings).
|
|
14
15
|
* sel : { layers:Set<string>, thicknesses:Set<number>, colors:Set<string> }
|
|
15
16
|
* filter : { mode, layers:[{name,count,steel?,drawable?,on?}], thicknesses:[{w,count,on?}], colors:[{hex,count,on?}], elements:[…] }
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** Canonical dimension order for a mode string — keeps legacy 'thickness+layer'/'thickness+color'. */
|
|
20
|
+
export const MODE_ORDER = ['thickness', 'layer', 'color'];
|
|
21
|
+
|
|
22
|
+
/** The dimensions a mode activates, order-agnostic: 'thickness+layer' -> ['thickness','layer']. */
|
|
23
|
+
export function modeDims(mode) {
|
|
24
|
+
return String(mode || '').split('+').filter((d) => MODE_ORDER.includes(d));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build the canonical mode string from a set/list of checked dimensions. */
|
|
28
|
+
export function canonicalMode(dims) {
|
|
29
|
+
const set = new Set(dims);
|
|
30
|
+
return MODE_ORDER.filter((d) => set.has(d)).join('+');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The active match predicate — an element passes when it matches EVERY checked dimension (AND).
|
|
35
|
+
* Zero checked dimensions match nothing (a valid, visible "0 shown" state, not a fall-through to all).
|
|
36
|
+
*/
|
|
19
37
|
export function matchesActive(el, mode, sel) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
case 'color': return byColor();
|
|
27
|
-
case 'thickness+layer': return byThk() && byLayer();
|
|
28
|
-
case 'thickness+color': return byThk() && byColor();
|
|
29
|
-
default: return false;
|
|
30
|
-
}
|
|
38
|
+
const dims = modeDims(mode);
|
|
39
|
+
if (!dims.length) return false;
|
|
40
|
+
return dims.every((d) =>
|
|
41
|
+
d === 'layer' ? sel.layers.has(el.layer)
|
|
42
|
+
: d === 'thickness' ? sel.thicknesses.has(el.w)
|
|
43
|
+
: sel.colors.has(el.color));
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
/**
|
|
@@ -66,6 +79,24 @@ export function countShown(elements, mode, sel) {
|
|
|
66
79
|
return n;
|
|
67
80
|
}
|
|
68
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Where a sheet's background raster (`page.bg_b64`) maps, in the same vector-frame coords as the
|
|
84
|
+
* elements' `d` paths — so the raster registers 1:1 with the linework. The producer clips the raster
|
|
85
|
+
* to the framing area and records that clip in `page.rect` ([x0,y0,x1,y1]); the renderer places the
|
|
86
|
+
* <image> there (preserveAspectRatio="none") instead of stretching it across the whole page.
|
|
87
|
+
*
|
|
88
|
+
* ponytail: `steelBbox` fallback bridges pre-`rect` contracts (the producer clipped the raster to the
|
|
89
|
+
* steel bbox, so it aligns exactly). Upgrade path: drop it once every baked contract carries page.rect.
|
|
90
|
+
*/
|
|
91
|
+
export function bgRect(sheet) {
|
|
92
|
+
const pg = (sheet && sheet.page) || {};
|
|
93
|
+
const valid = (r) => Array.isArray(r) && r.length === 4 && r[2] - r[0] > 0 && r[3] - r[1] > 0;
|
|
94
|
+
// Try each candidate in order — a degenerate page.rect must NOT skip a valid steelBbox.
|
|
95
|
+
const r = valid(pg.rect) ? pg.rect : (valid(sheet && sheet.steelBbox) ? sheet.steelBbox : null);
|
|
96
|
+
if (r) return { x: r[0], y: r[1], w: r[2] - r[0], h: r[3] - r[1] };
|
|
97
|
+
return { x: 0, y: 0, w: pg.w || 0, h: pg.h || 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
69
100
|
/**
|
|
70
101
|
* Write the live selection + mode back onto the filter block's `on` flags so it persists on the
|
|
71
102
|
* contract (what Save PUTs, and what the reader reads). Mutates and returns `filter`.
|
|
@@ -38,11 +38,9 @@ header select#sheetSel{font-weight:600;min-width:200px;max-width:320px}
|
|
|
38
38
|
.opts label em{margin-left:auto;color:var(--mut);font-style:normal;font-size:11px}
|
|
39
39
|
.opts .sw{width:12px;height:12px;border-radius:3px;flex:0 0 12px;border:1px solid var(--line)}
|
|
40
40
|
.opts label.textlayer{opacity:.55;cursor:default}
|
|
41
|
-
/* Fully custom checkbox
|
|
42
|
-
input[type=
|
|
43
|
-
input[type=radio]{border-radius:50%}
|
|
41
|
+
/* Fully custom checkbox — the native unchecked widget is a white/grey square that breaks the dark baseline. */
|
|
42
|
+
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}
|
|
44
43
|
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}
|
|
45
|
-
input[type=radio]:checked{background:var(--brand);border-color:var(--brand);box-shadow:inset 0 0 0 3px var(--panel)}
|
|
46
44
|
input:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
|
|
47
45
|
.facet.dim{opacity:.4} .facet.dim .opts,.facet.dim .acts{pointer-events:none}
|
|
48
46
|
.hint{color:var(--mut);font-size:11px;line-height:1.5}
|
|
@@ -82,12 +80,11 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
82
80
|
<div id="app">
|
|
83
81
|
<aside id="panel">
|
|
84
82
|
<section id="modes">
|
|
85
|
-
<h2>Match
|
|
86
|
-
<label><input type="
|
|
87
|
-
<label><input type="
|
|
88
|
-
<label><input type="
|
|
89
|
-
<
|
|
90
|
-
<label><input type="radio" name="mode" value="thickness+color" /> Thickness + Color</label>
|
|
83
|
+
<h2>Match on</h2>
|
|
84
|
+
<label><input type="checkbox" name="dim" value="layer" checked /> Layer</label>
|
|
85
|
+
<label><input type="checkbox" name="dim" value="thickness" /> Thickness</label>
|
|
86
|
+
<label><input type="checkbox" name="dim" value="color" /> Color</label>
|
|
87
|
+
<p class="hint">Checked criteria must all match (AND).</p>
|
|
91
88
|
</section>
|
|
92
89
|
<section class="facet" id="facet-layers">
|
|
93
90
|
<h2>Layers <span class="acts"><button data-all="layers">all</button><button data-none="layers">none</button></span></h2>
|
|
@@ -128,7 +125,7 @@ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;bo
|
|
|
128
125
|
</div>
|
|
129
126
|
|
|
130
127
|
<script type="module">
|
|
131
|
-
import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
|
|
128
|
+
import { matchesActive, modeDims, canonicalMode, selFromFilter, eyedropperAdd, countShown, bgRect, applySelToFilter, normalizeFilter } from './steel-filter-core.js';
|
|
132
129
|
|
|
133
130
|
const APP_ID = new URLSearchParams(location.search).get('app') || '';
|
|
134
131
|
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
@@ -183,9 +180,11 @@ function showEmpty(msg){
|
|
|
183
180
|
}
|
|
184
181
|
|
|
185
182
|
function initFromFilter(){
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
183
|
+
// Canonicalize on load (drops unknown tokens, orders dims) so a facet-only save writes a valid
|
|
184
|
+
// enum string; a saved-empty '' round-trips as '', while an absent mode defaults to 'layer'.
|
|
185
|
+
mode = (F.mode == null) ? 'layer' : canonicalMode(modeDims(F.mode));
|
|
186
|
+
const on = new Set(modeDims(mode));
|
|
187
|
+
document.querySelectorAll('input[name=dim]').forEach(cb => { cb.checked = on.has(cb.value); });
|
|
189
188
|
// layer colours: steel layers vivid+distinct, others a single muted slate (GLOBAL facets)
|
|
190
189
|
let si = 0;
|
|
191
190
|
layerColor = {};
|
|
@@ -217,7 +216,16 @@ function setSheet(i){
|
|
|
217
216
|
const sh = sheets[active] || {};
|
|
218
217
|
const pg = (sh.page && sh.page.w > 0 && sh.page.h > 0) ? sh.page : { w: 1000, h: 1000 }; // guard empty page → no NaN viewBox
|
|
219
218
|
svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
|
|
220
|
-
if(pg.bg_b64){
|
|
219
|
+
if(pg.bg_b64){
|
|
220
|
+
// Register the raster to its true extent (the framing-area clip), NOT the whole page — else a
|
|
221
|
+
// crop gets stretched over the full viewBox and floats off-scale from the vector linework.
|
|
222
|
+
const r = bgRect(sh);
|
|
223
|
+
bgImg.setAttribute('href', 'data:image/jpeg;base64,' + pg.bg_b64);
|
|
224
|
+
bgImg.setAttribute('x', r.x); bgImg.setAttribute('y', r.y);
|
|
225
|
+
bgImg.setAttribute('width', r.w); bgImg.setAttribute('height', r.h);
|
|
226
|
+
bgImg.setAttribute('preserveAspectRatio', 'none');
|
|
227
|
+
bgImg.style.display='';
|
|
228
|
+
}
|
|
221
229
|
else bgImg.style.display = 'none';
|
|
222
230
|
buildOverlay();
|
|
223
231
|
base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base }; setVB();
|
|
@@ -309,13 +317,17 @@ function apply(){
|
|
|
309
317
|
html += ' · <b>' + sa + '</b> of <b>' + ta + '</b> total (' + sheets.length + ' sheets)';
|
|
310
318
|
}
|
|
311
319
|
countEl.innerHTML = html;
|
|
312
|
-
|
|
313
|
-
document.getElementById('facet-
|
|
314
|
-
document.getElementById('facet-
|
|
320
|
+
const dims = new Set(modeDims(mode));
|
|
321
|
+
document.getElementById('facet-layers').classList.toggle('dim', !dims.has('layer'));
|
|
322
|
+
document.getElementById('facet-thicknesses').classList.toggle('dim', !dims.has('thickness'));
|
|
323
|
+
document.getElementById('facet-colors').classList.toggle('dim', !dims.has('color'));
|
|
315
324
|
}
|
|
316
325
|
|
|
317
326
|
// --- modes + all/none ---
|
|
318
|
-
document.querySelectorAll('input[name=
|
|
327
|
+
document.querySelectorAll('input[name=dim]').forEach(cb => cb.addEventListener('change', () => {
|
|
328
|
+
mode = canonicalMode([...document.querySelectorAll('input[name=dim]:checked')].map(c => c.value));
|
|
329
|
+
markDirty(); apply();
|
|
330
|
+
}));
|
|
319
331
|
document.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.all, true)));
|
|
320
332
|
document.querySelectorAll('[data-none]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.none, false)));
|
|
321
333
|
function setAllFacet(facet, on){
|