@floless/app 0.59.1 → 0.61.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.
@@ -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,151 @@
1
+ #!/usr/bin/env python3
2
+ """vision.extract result -> drawing.vector/v1 contract (the Slice-2 raster path).
3
+
4
+ Takes the JSON that `aware agent invoke vision extract --json` printed (the invoke envelope
5
+ or the bare `{result: {elements: [...]}}`) plus the image it was extracted from, and emits a
6
+ raw drawing.vector/v1 contract ready to PUT to /api/contract/vectorize. The model traces in a
7
+ normalized 0..1000 frame (see references/vision-inputs.template.json); this script maps that
8
+ frame onto the image's real pixel size, carries each element's model-reported confidence
9
+ (the 2D editor flags traces below 0.5 for review), and assigns the ids the PUT schema requires.
10
+
11
+ Usage:
12
+
13
+ python vision_to_contract.py vision-out.json sketch.png --out contract.json
14
+ python vision_to_contract.py vision-out2.json page2.png --merge-into contract.json
15
+
16
+ --merge-into appends the extraction as the next sheet of an existing contract (multi-image sets).
17
+ Requires PyMuPDF (used only to read the image/PDF pixel size).
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import sys
25
+ from datetime import datetime, timezone
26
+
27
+ import fitz # PyMuPDF
28
+
29
+
30
+ def unwrap(payload):
31
+ """Accept the aware invoke envelope ({ok,data:{result,model}}) or the bare output."""
32
+ if payload.get("ok") is False:
33
+ sys.exit(f"vision.extract failed: {payload.get('error') or 'unknown error'}")
34
+ if isinstance(payload.get("data"), dict):
35
+ payload = payload["data"]
36
+ result = payload.get("result", payload)
37
+ model = payload.get("model", "?")
38
+ if not isinstance(result, dict) or "elements" not in result:
39
+ sys.exit("vision output has no result.elements — did the extraction fail?")
40
+ return result["elements"], model
41
+
42
+
43
+ def to_sheet(elements, image_path):
44
+ # Images only: a multi-page PDF has no single 0..1000 frame, so the caller rasterizes each
45
+ # PDF page to its own image first (SKILL.md step 3b) and runs one extraction per image.
46
+ if image_path.lower().endswith(".pdf"):
47
+ sys.exit("pass the rasterized image, not the PDF — export each PDF page to a PNG first")
48
+ # True pixel dimensions (a Pixmap ignores DPI metadata, which would skew the frame).
49
+ pix = fitz.Pixmap(image_path)
50
+ w, h = float(pix.width), float(pix.height)
51
+ sx, sy = w / 1000.0, h / 1000.0
52
+
53
+ out = []
54
+ for el in elements:
55
+ kind = el.get("kind")
56
+ conf = el.get("confidence")
57
+ if kind == "text":
58
+ bbox = el.get("bbox")
59
+ if not el.get("text") or not bbox or len(bbox) != 4:
60
+ continue
61
+ x0, y0, x1, y1 = (v * s for v, s in zip(bbox, (sx, sy, sx, sy)))
62
+ e = {
63
+ "kind": "text",
64
+ "text": el["text"],
65
+ "bbox": [round(v, 1) for v in (x0, y0, x1, y1)],
66
+ "origin": [round(x0, 1), round(y1, 1)], # baseline ~ bbox bottom-left
67
+ "size": round((y1 - y0) * 0.85, 1), # approx font size from box height
68
+ }
69
+ elif kind in ("line", "polyline"):
70
+ pts = el.get("pts") or []
71
+ if len(pts) < 2:
72
+ continue
73
+ e = {
74
+ "kind": "line" if len(pts) == 2 else "polyline",
75
+ "pts": [[round(p[0] * sx, 1), round(p[1] * sy, 1)] for p in pts],
76
+ }
77
+ if el.get("dashed"):
78
+ e["dashed"] = True
79
+ else:
80
+ continue
81
+ # Schema: confidence is a number when present; ABSENT means trusted. A model that omits
82
+ # it must not become `null` — one null fails the whole PUT (server validates every write).
83
+ if isinstance(conf, (int, float)):
84
+ e["confidence"] = max(0.0, min(1.0, conf))
85
+ out.append(e)
86
+ for i, el in enumerate(out):
87
+ el["id"] = f"e{i + 1}"
88
+ return {"page": {"w": round(w, 2), "h": round(h, 2)}, "elements": out}
89
+
90
+
91
+ def main():
92
+ for stream in (sys.stdout, sys.stderr):
93
+ try:
94
+ stream.reconfigure(encoding="utf-8")
95
+ except AttributeError:
96
+ pass
97
+ ap = argparse.ArgumentParser(description=__doc__)
98
+ ap.add_argument("vision_json", help="output of `aware agent invoke vision extract --json`")
99
+ ap.add_argument("image", help="the raster image the extraction ran on (rasterize PDF pages first)")
100
+ ap.add_argument("--out", help="write a NEW one-sheet contract here")
101
+ ap.add_argument("--merge-into", help="append as the next sheet of this existing contract")
102
+ args = ap.parse_args()
103
+ if bool(args.out) == bool(args.merge_into):
104
+ sys.exit("pass exactly one of --out / --merge-into")
105
+
106
+ with open(args.vision_json, encoding="utf-8") as f:
107
+ elements, model = unwrap(json.load(f))
108
+ sheet = to_sheet(elements, args.image)
109
+ stem = os.path.splitext(os.path.basename(args.image))[0]
110
+
111
+ if args.merge_into:
112
+ with open(args.merge_into, encoding="utf-8") as f:
113
+ contract = json.load(f)
114
+ if not isinstance(contract.get("sheets"), list):
115
+ sys.exit(f"{args.merge_into} is not a drawing.vector contract (no sheets[])")
116
+ used = {s.get("id") for s in contract["sheets"]}
117
+ n = len(contract["sheets"]) + 1
118
+ while f"s{n}" in used:
119
+ n += 1
120
+ sheet["id"] = f"s{n}"
121
+ sheet["label"] = stem
122
+ contract["sheets"].append(sheet)
123
+ path = args.merge_into
124
+ else:
125
+ sheet["id"] = "s1"
126
+ sheet["label"] = stem
127
+ with open(args.image, "rb") as f:
128
+ digest = hashlib.sha256(f.read()).hexdigest()
129
+ contract = {
130
+ "type": "drawing.vector/v1",
131
+ "units": "px", # no transform emitted -> the image's pixel frame is world 1:1
132
+ "source": {
133
+ "name": os.path.basename(args.image),
134
+ "path": os.path.abspath(args.image),
135
+ "kind": "image",
136
+ "sha256": digest,
137
+ "read_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
138
+ "extractor": f"vision.extract@{model}",
139
+ },
140
+ "sheets": [sheet],
141
+ }
142
+ path = args.out
143
+
144
+ with open(path, "w", encoding="utf-8") as f:
145
+ json.dump(contract, f, ensure_ascii=False)
146
+ weak = sum(1 for e in sheet["elements"] if (e.get("confidence") or 1) < 0.5)
147
+ print(f"{path}: sheet {sheet['id']} ({stem}), {len(sheet['elements'])} element(s), {weak} weak", file=sys.stderr)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,69 @@
1
+ app: vectorize
2
+ version: 0.2.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 and bakes it into a drawing.vector contract: a vector PDF
8
+ is read deterministically (exact geometry, no AI guessing), while a photo, scan or hand sketch is
9
+ traced by a fenced vision extraction that marks every stroke it was unsure about for your review.
10
+ Double-click the node to open the 2D vector editor, where you pan and zoom, toggle what's shown,
11
+ review weak traces, and export SVG. Attach a drawing and use "Re-read & re-bake ▸" (or ask your
12
+ terminal AI) to vectorize your own; the deterministic run just re-renders a short summary of what
13
+ was read. Ships with a tiny example so it works out of the box.
14
+ exposes-as-agent: false
15
+ # Workflow changelog (FloLess-published). Newest first; the app shows entries newer than the installed
16
+ # version when offering an update. Plain-English — it surfaces to the user.
17
+ changelog:
18
+ - version: 0.2.0
19
+ summary: Photos, scans and hand sketches can now be vectorized too — unsure strokes are flagged for your review
20
+ - version: 0.1.0
21
+ summary: Vectorize a drawing into clean 2D linework you can edit and export
22
+ inputs:
23
+ drawing:
24
+ type: image
25
+ widget: file
26
+ accept: [pdf, png, jpg]
27
+ read-strategy: bake
28
+ description: A drawing to vectorize — read by your terminal AI into clean 2D vectors; swap to re-read & re-bake.
29
+ requires:
30
+ - html-report@0.2.x
31
+ layout: linear
32
+ nodes:
33
+ # ── node 1: read — vectorize the drawing into clean 2D linework ───────────────
34
+ # This is where your drawing becomes editable vectors. Your terminal AI reads the
35
+ # attached drawing once at compose time: a vector PDF is read deterministically (exact
36
+ # geometry, no AI guessing), while a photo, scan or hand sketch is traced by a fenced
37
+ # vision extraction that flags every stroke it was unsure about. Every line, curve and
38
+ # label is baked into the contract. Double-click this node to open the 2D vector
39
+ # editor: pan and zoom the drawing, toggle Lines / Curves / Text, review each flagged
40
+ # trace (accept it as correct or delete it — edits save automatically), Export SVG,
41
+ # and Approve to freeze the reviewed drawing into the runnable lock. `contract` tells
42
+ # FloLess which editor to open; `takeoff` is the single source the editor, Approve and
43
+ # the run all read. It ships with a tiny EXAMPLE so it renders out of the box —
44
+ # "Re-read & re-bake ▸" replaces it with your own drawing. The deterministic run
45
+ # re-renders the short summary below.
46
+ - id: read
47
+ agent: html-report
48
+ command: render
49
+ config:
50
+ contract: drawing.vector/v1
51
+ takeoff:
52
+ type: drawing.vector/v1
53
+ units: mm
54
+ source: { name: "Example — sample detail (not your drawing)" }
55
+ sheets:
56
+ - id: s1
57
+ label: "Sample detail"
58
+ page: { w: 300, h: 200 }
59
+ elements:
60
+ - { id: e1, kind: polyline, d: "M60 50 H240 V150 H60 Z", color: "#334155", w: 1.4 }
61
+ - { id: e2, kind: line, d: "M150 30 L150 170", color: "#94a3b8", w: 0.8, dashed: true }
62
+ - { id: e3, kind: line, d: "M60 100 H240", color: "#94a3b8", w: 0.8 }
63
+ - { id: t1, kind: text, text: "SAMPLE DETAIL", size: 11, origin: [66, 44], color: "#334155" }
64
+ - { id: t2, kind: text, text: "PL 13mm", size: 9, origin: [98, 96], color: "#334155" }
65
+ title: "Vectorized drawing"
66
+ data:
67
+ - { Kind: Lines, Count: 3 }
68
+ - { Kind: Text, Count: 2 }
69
+ connections: []
package/dist/web/aware.js CHANGED
@@ -1360,14 +1360,18 @@
1360
1360
  // openContractEditor() returns false and the click would silently do nothing.
1361
1361
  if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
1362
1362
  addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
1363
- addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1364
- // Export/write group visually separated from the look-and-read actions above.
1365
- addNodeActionDivider(card);
1366
- addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1367
- addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1368
- addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1369
- const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1370
- teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1363
+ // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1364
+ // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
1365
+ if (window.CONTRACT_RENDERERS[contractType].scene) {
1366
+ addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1367
+ // Export/write group visually separated from the look-and-read actions above.
1368
+ addNodeActionDivider(card);
1369
+ addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1370
+ addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1371
+ addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1372
+ const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1373
+ teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1374
+ }
1371
1375
  card.dataset.tip = 'Double-click to open the contract editor';
1372
1376
  }
1373
1377
  // Filter pre-stage node — opens the served steel-filter view (facets + eyedropper → contract.filter).
@@ -1752,9 +1756,9 @@
1752
1756
  function openContractEditor(appId, type) {
1753
1757
  const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1754
1758
  if (!r) return false;
1755
- // The editor bakes the .lock on Approve — that control belongs to the contract editor, not the
1756
- // filter view (which has its own Save). Make sure it's visible here (openFilterView hides it).
1757
- const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = false;
1759
+ // The editor bakes the .lock on Approve — that control belongs to an editable contract editor, not
1760
+ // the filter view (own Save) nor a view-only renderer. Show it unless the renderer is viewOnly.
1761
+ const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = !!r.viewOnly;
1758
1762
  $contractEditorTitle.replaceChildren(
1759
1763
  Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1760
1764
  Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
@@ -9,9 +9,16 @@
9
9
  * ========================================================================== */
10
10
 
11
11
  // contract type → descriptor for the editor surface.
12
+ // `scene: true` marks contracts that derive a 3D scene (contract-to-scene) — only those get the
13
+ // node-card View 3D / Export IFC / Export BOM / Send to Tekla chain; other contract types would
14
+ // 4xx or produce nonsense on those endpoints, so the shell hides the whole group.
12
15
  window.CONTRACT_RENDERERS = {
13
16
  'steel.takeoff/v1': {
14
17
  editorUrl: (appId) => '/steel-editor.html?app=' + encodeURIComponent(appId),
18
+ scene: true,
19
+ },
20
+ 'drawing.vector/v1': {
21
+ editorUrl: (appId) => '/vector-editor.html?app=' + encodeURIComponent(appId),
15
22
  },
16
23
  };
17
24