@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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.59.0" : void 0,
53025
+ define: true ? "0.60.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.59.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.60.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -53783,30 +53783,45 @@ function walk(value, node, root, path, errors) {
53783
53783
  }
53784
53784
  }
53785
53785
  }
53786
- var _cached = null;
53787
- function loadSteelTakeoffSchema() {
53788
- if (_cached) return _cached;
53786
+ var _cache = /* @__PURE__ */ new Map();
53787
+ function loadContractSchema(file) {
53788
+ const hit = _cache.get(file);
53789
+ if (hit) return hit;
53789
53790
  const here2 = (0, import_node_path14.dirname)((0, import_node_url2.fileURLToPath)(__import_meta_url));
53790
53791
  const candidates = [
53791
- (0, import_node_path14.join)(here2, "..", "schemas", "steel.takeoff.v1.schema.json"),
53792
+ (0, import_node_path14.join)(here2, "..", "schemas", file),
53792
53793
  // dev: server/ next to schemas/
53793
- (0, import_node_path14.join)(here2, "schemas", "steel.takeoff.v1.schema.json")
53794
+ (0, import_node_path14.join)(here2, "schemas", file)
53794
53795
  // bundled: dist/ holds ./schemas
53795
53796
  ];
53796
53797
  for (const p of candidates) {
53797
53798
  try {
53798
- const text = (0, import_node_fs16.readFileSync)(p, "utf8");
53799
- _cached = JSON.parse(text);
53800
- return _cached;
53799
+ const parsed = JSON.parse((0, import_node_fs16.readFileSync)(p, "utf8"));
53800
+ _cache.set(file, parsed);
53801
+ return parsed;
53801
53802
  } catch (err2) {
53802
53803
  if (err2.code !== "ENOENT") throw err2;
53803
53804
  }
53804
53805
  }
53805
- throw new Error("steel.takeoff/v1 schema file not found");
53806
+ throw new Error(`contract schema file not found: ${file}`);
53807
+ }
53808
+ function loadSteelTakeoffSchema() {
53809
+ return loadContractSchema("steel.takeoff.v1.schema.json");
53810
+ }
53811
+ function loadDrawingVectorSchema() {
53812
+ return loadContractSchema("drawing.vector.v1.schema.json");
53806
53813
  }
53807
53814
  function validateSteelTakeoff(doc) {
53808
53815
  return validate(doc, loadSteelTakeoffSchema());
53809
53816
  }
53817
+ function validateDrawingVector(doc) {
53818
+ return validate(doc, loadDrawingVectorSchema());
53819
+ }
53820
+ function validateContract(doc) {
53821
+ const type = doc && typeof doc === "object" ? doc.type : void 0;
53822
+ if (type === "drawing.vector/v1") return validateDrawingVector(doc);
53823
+ return validateSteelTakeoff(doc);
53824
+ }
53810
53825
 
53811
53826
  // contract-store.ts
53812
53827
  var ContractError = class extends Error {
@@ -53832,7 +53847,7 @@ function readContract(appId) {
53832
53847
  }
53833
53848
  function writeContract(appId, doc) {
53834
53849
  const p = contractPath(appId);
53835
- const res = validateSteelTakeoff(doc);
53850
+ const res = validateContract(doc);
53836
53851
  if (!res.valid) {
53837
53852
  const first = res.errors.slice(0, 5).map((e) => `${e.path}: ${e.message}`).join("; ");
53838
53853
  throw new ContractError(`contract failed schema validation \u2014 ${first}`);
@@ -53851,11 +53866,12 @@ function readContractForApp(appId, readAppFn = readApp) {
53851
53866
  } catch {
53852
53867
  return null;
53853
53868
  }
53854
- const node = app.nodes.find((n) => n.config["contract"] === "steel.takeoff/v1");
53869
+ const isBaked = (v) => v != null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0;
53870
+ const node = app.nodes.find(
53871
+ (n) => typeof n.config["contract"] === "string" && n.config["contract"] !== "" && isBaked(n.config["takeoff"])
53872
+ );
53855
53873
  const t = node?.config["takeoff"];
53856
- if (t == null || typeof t !== "object" || Array.isArray(t) || Object.keys(t).length === 0) {
53857
- return null;
53858
- }
53874
+ if (!isBaked(t)) return null;
53859
53875
  return t;
53860
53876
  }
53861
53877
 
@@ -53886,29 +53902,124 @@ function bakeContractIntoApp(sourcePath, contract) {
53886
53902
  return p;
53887
53903
  });
53888
53904
  }
53905
+ const stripPageRaster = (p) => {
53906
+ if (p && typeof p === "object") {
53907
+ const page = { ...p };
53908
+ delete page.bg_b64;
53909
+ return page;
53910
+ }
53911
+ return p;
53912
+ };
53889
53913
  const filter = baked.filter;
53890
53914
  if (filter && typeof filter === "object") {
53891
- const stripPage = (p) => {
53892
- if (p && typeof p === "object") {
53893
- const page = { ...p };
53894
- delete page.bg_b64;
53895
- return page;
53896
- }
53897
- return p;
53898
- };
53899
53915
  const next = { ...filter };
53900
- if (filter.page) next.page = stripPage(filter.page);
53916
+ if (filter.page) next.page = stripPageRaster(filter.page);
53901
53917
  if (Array.isArray(filter.sheets)) {
53902
53918
  next.sheets = filter.sheets.map(
53903
- (s) => s && typeof s === "object" ? { ...s, page: stripPage(s.page) } : s
53919
+ (s) => s && typeof s === "object" ? { ...s, page: stripPageRaster(s.page) } : s
53904
53920
  );
53905
53921
  }
53906
53922
  baked.filter = next;
53907
53923
  }
53924
+ if (Array.isArray(baked.sheets)) {
53925
+ baked.sheets = baked.sheets.map(
53926
+ (s) => s && typeof s === "object" ? { ...s, page: stripPageRaster(s.page) } : s
53927
+ );
53928
+ }
53908
53929
  doc.setIn(["nodes", idx, "config", "takeoff"], baked);
53909
53930
  (0, import_node_fs18.writeFileSync)(sourcePath, doc.toString());
53910
53931
  }
53911
53932
 
53933
+ // vectorize.ts
53934
+ function assignIds(items, prefix) {
53935
+ const used = /* @__PURE__ */ new Set();
53936
+ const keep = items.map((it) => {
53937
+ if (it.id && !used.has(it.id)) {
53938
+ used.add(it.id);
53939
+ return true;
53940
+ }
53941
+ return false;
53942
+ });
53943
+ let counter = 0;
53944
+ const nextId = () => {
53945
+ let id;
53946
+ do {
53947
+ counter += 1;
53948
+ id = `${prefix}${counter}`;
53949
+ } while (used.has(id));
53950
+ used.add(id);
53951
+ return id;
53952
+ };
53953
+ return items.map((it, i) => keep[i] ? it : { ...it, id: nextId() });
53954
+ }
53955
+ function geomKey(el, index) {
53956
+ const layer = el.layer ?? "";
53957
+ if (el.kind === "text") {
53958
+ const pos2 = el.origin ? el.origin.join(",") : (el.bbox ?? []).join(",");
53959
+ return `text|${layer}|${el.text ?? ""}|${pos2}`;
53960
+ }
53961
+ if (el.d) return `${el.kind}|${layer}|${el.d}`;
53962
+ if (el.pts) return `${el.kind}|${layer}|${el.pts.map((p) => p.join(",")).join(" ")}`;
53963
+ return `nogeom|${index}`;
53964
+ }
53965
+ function dedupeElements(elements) {
53966
+ const seen = /* @__PURE__ */ new Set();
53967
+ const out = [];
53968
+ elements.forEach((el, i) => {
53969
+ const key = geomKey(el, i);
53970
+ if (seen.has(key)) return;
53971
+ seen.add(key);
53972
+ out.push(el);
53973
+ });
53974
+ return out;
53975
+ }
53976
+ function snapEndpoints(elements, tol = 1) {
53977
+ const reps = [];
53978
+ const tol2 = tol * tol;
53979
+ const snap = (p) => {
53980
+ for (const r of reps) {
53981
+ const dx = p[0] - r[0];
53982
+ const dy = p[1] - r[1];
53983
+ if (dx * dx + dy * dy <= tol2) return r;
53984
+ }
53985
+ const rep = [p[0], p[1]];
53986
+ reps.push(rep);
53987
+ return rep;
53988
+ };
53989
+ return elements.map((el) => {
53990
+ if (!el.pts || el.pts.length === 0) return el;
53991
+ const pts = el.pts.map((p) => {
53992
+ const s = snap(p);
53993
+ return [s[0], s[1]];
53994
+ });
53995
+ return { ...el, pts };
53996
+ });
53997
+ }
53998
+ function deriveLayers(elements, existing) {
53999
+ const onByName = /* @__PURE__ */ new Map();
54000
+ for (const l of existing ?? []) if (l && l.name && l.on != null) onByName.set(l.name, l.on);
54001
+ const counts = /* @__PURE__ */ new Map();
54002
+ for (const el of elements) {
54003
+ if (!el.layer) continue;
54004
+ counts.set(el.layer, (counts.get(el.layer) ?? 0) + 1);
54005
+ }
54006
+ return [...counts.entries()].map(([name, count]) => onByName.has(name) ? { name, count, on: onByName.get(name) } : { name, count }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
54007
+ }
54008
+ function postProcess(contract, opts = {}) {
54009
+ const snapTol = opts.snapTol ?? 1;
54010
+ const sheets = contract.sheets.map((sheet, i) => {
54011
+ let elements = sheet.elements ?? [];
54012
+ elements = snapEndpoints(elements, snapTol);
54013
+ elements = dedupeElements(elements);
54014
+ elements = assignIds(elements, "e");
54015
+ const layers = deriveLayers(elements, sheet.layers);
54016
+ const id = sheet.id ?? `s${i + 1}`;
54017
+ const groups = sheet.groups ? assignIds(sheet.groups, "g") : void 0;
54018
+ return { ...sheet, id, elements, layers, ...groups ? { groups } : {} };
54019
+ });
54020
+ return { ...contract, type: "drawing.vector/v1", sheets };
54021
+ }
54022
+
53912
54023
  // steel-joints.ts
53913
54024
  var GROUPS = {
53914
54025
  "base-plate": { key: "base-plate", label: "Base plates", color: "#6b7a8d" },
@@ -61279,6 +61390,8 @@ var PRODUCT_SKILLS = [
61279
61390
  // handle a tweak-contract request from the Steel Model contract editor (Slice 2 AI round-trip)
61280
61391
  "floless-app-ui",
61281
61392
  // compose Custom Panels (~/.floless/ui/extensions.json) for the Dashboard
61393
+ "floless-app-vectorize",
61394
+ // read a vector PDF into an editable drawing.vector/v1 contract (the Vectorize reader)
61282
61395
  "floless-app-workflows",
61283
61396
  // author/run .flo workflows
61284
61397
  "reading-structural-drawings",
@@ -63537,7 +63650,7 @@ async function startServer() {
63537
63650
  return reply.status(409).send({ ok: false, error: `"${id}"'s saved data is unreadable \u2014 open it in the editor and re-save before updating`, code: "contract-corrupt" });
63538
63651
  }
63539
63652
  if (contract != null) {
63540
- const v = validateSteelTakeoff(contract);
63653
+ const v = validateContract(contract);
63541
63654
  if (!v.valid) return reply.status(409).send({ ok: false, error: `"${id}"'s saved data is invalid \u2014 re-save it in the editor before updating`, code: "contract-invalid" });
63542
63655
  }
63543
63656
  _wfUpdating.add(id);
@@ -63670,6 +63783,9 @@ async function startServer() {
63670
63783
  app.get("/api/contract/:appId", async (req, reply) => {
63671
63784
  const doc = readContractForApp(req.params.appId);
63672
63785
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract for this app yet" });
63786
+ if (doc && typeof doc === "object" && doc.type === "drawing.vector/v1") {
63787
+ return postProcess(doc);
63788
+ }
63673
63789
  return doc;
63674
63790
  });
63675
63791
  app.put(
@@ -63690,7 +63806,7 @@ async function startServer() {
63690
63806
  app.post("/api/contract/:appId/approve", async (req, reply) => {
63691
63807
  const doc = readContract(req.params.appId);
63692
63808
  if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to approve" });
63693
- const v = validateSteelTakeoff(doc);
63809
+ const v = validateContract(doc);
63694
63810
  if (!v.valid) return reply.status(400).send({ ok: false, error: "stored contract is invalid \u2014 re-save it in the editor" });
63695
63811
  const sourcePath = readApp(req.params.appId).source.path;
63696
63812
  try {
@@ -64001,7 +64117,7 @@ async function startServer() {
64001
64117
  return reply.status(409).send({ ok: false, error: `"${id}"'s saved data is unreadable \u2014 open it in the editor and re-save before restoring`, code: "contract-corrupt" });
64002
64118
  }
64003
64119
  if (contract != null) {
64004
- const v = validateSteelTakeoff(contract);
64120
+ const v = validateContract(contract);
64005
64121
  if (!v.valid) return reply.status(409).send({ ok: false, error: `"${id}"'s saved data is invalid \u2014 re-save it in the editor before restoring`, code: "contract-invalid" });
64006
64122
  }
64007
64123
  _wfUpdating.add(id);
@@ -0,0 +1,135 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://floless.app/schemas/drawing.vector/v1",
4
+ "title": "drawing.vector/v1",
5
+ "description": "Vocabulary-free vectorized-drawing contract: clean 2D linework + text extracted from any sketch / PDF / photo, the general 'draw CAD from anything' core. Mirrors the (steel-agnostic) steel.takeoff/v1 `filter` block but makes every element and group ID-BEARING (so a placed/edited detail can be targeted) and first-class. The contract is the single source of truth; the 2D vectorize editor is its view; server/vectorize.ts is the pure post-processor; exporters (SVG now; viewer-3d/ifc via a generalized scene later) consume it; Approve bakes the .lock. Coordinates are DISPLAY space (the drawing's own frame); `sheets[].transform` carries the reader-emitted display->world mapping a renderer cannot reconstruct. Confidential rasters ride inside as base64 and are NEVER committed (stripped at bake, like steel's page.bg_b64). Design: docs/superpowers/specs/2026-07-01-vectorize-draw-from-anything-design.md.",
6
+ "type": "object",
7
+ "required": ["type", "sheets"],
8
+ "additionalProperties": true,
9
+ "properties": {
10
+ "type": { "const": "drawing.vector/v1", "description": "Renderer-registry discriminator (web/renderers.js contractTypeOf)." },
11
+ "active": { "type": "integer", "description": "Index into `sheets` of the active sheet (mirrors steel's plans/active)." },
12
+ "units": { "type": "string", "description": "World units the `transform` maps into (default 'mm'). The scene/3D reuse is mm, Z-up." },
13
+ "source": {
14
+ "type": "object",
15
+ "description": "Provenance: where this drawing was read from.",
16
+ "properties": {
17
+ "name": { "type": "string" },
18
+ "path": { "type": "string" },
19
+ "kind": { "enum": ["pdf", "image"], "description": "Input modality the extractor read." },
20
+ "sha256": { "type": "string" },
21
+ "read_at": { "type": "string" },
22
+ "extractor": { "type": "string", "description": "How it was read (e.g. 'pymupdf@compose-time', later 'vectorize.extract@<ver>'). Determinism/audit trail." }
23
+ },
24
+ "additionalProperties": true
25
+ },
26
+ "sheets": {
27
+ "type": "array",
28
+ "items": { "$ref": "#/$defs/sheet" },
29
+ "description": "One entry per page/view. Multi-page PDFs and image sets each become a sheet."
30
+ }
31
+ },
32
+ "$defs": {
33
+ "sheet": {
34
+ "type": "object",
35
+ "required": ["id", "elements"],
36
+ "additionalProperties": true,
37
+ "description": "One drawing page/view: its raster backdrop, the display->world transform, and the vectorized elements + optional detected groups. Lines render as SVG paths over a dimmed page raster; text spans stay pickable.",
38
+ "properties": {
39
+ "id": { "type": "string", "description": "Stable, document-unique sheet id (e.g. 's1'). Targets are keyed {sheet,id} (member ids can collide across sheets — task_d1db821f)." },
40
+ "label": { "type": "string", "description": "Human sheet name (page title / detail name)." },
41
+ "page": {
42
+ "type": "object",
43
+ "required": ["w", "h"],
44
+ "description": "Page geometry in DISPLAY space. w/h are required and positive.",
45
+ "properties": {
46
+ "w": { "type": "number", "minimum": 1 },
47
+ "h": { "type": "number", "minimum": 1 },
48
+ "bg_b64": { "type": "string", "description": "Base64 JPEG of the page for the dimmed backdrop. CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed." },
49
+ "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] display-coords the raster was clipped to, so the viewer registers 1:1 with the linework." }
50
+ },
51
+ "additionalProperties": true
52
+ },
53
+ "transform": {
54
+ "type": "object",
55
+ "description": "READER-EMITTED display->world mapping a renderer cannot reconstruct: pixel/point -> `units`. AWARE-owned on graduation (§Graduation). Absent = display space treated as world 1:1.",
56
+ "properties": {
57
+ "scale": { "type": "number", "description": "World units per display unit (e.g. mm per pt). Uniform." },
58
+ "origin": { "$ref": "#/$defs/point2", "description": "Display-space point mapped to world [0,0]." },
59
+ "units": { "type": "string", "description": "World units this maps into (overrides the top-level default for this sheet)." },
60
+ "rotation": { "type": "number", "description": "Degrees the display frame is rotated relative to world (page orientation)." }
61
+ },
62
+ "additionalProperties": true
63
+ },
64
+ "layers": {
65
+ "type": "array",
66
+ "description": "Distinct layers observed (name + count), for the editor's layer toggles. Vocabulary-free: no steel 'is this a beam' flags.",
67
+ "items": {
68
+ "type": "object",
69
+ "required": ["name"],
70
+ "additionalProperties": true,
71
+ "properties": {
72
+ "name": { "type": "string" },
73
+ "count": { "type": "number" },
74
+ "on": { "type": "boolean", "description": "Persisted visibility state." }
75
+ }
76
+ }
77
+ },
78
+ "elements": {
79
+ "type": "array",
80
+ "description": "Every vectorized element, each with a STABLE id. Geometry elements carry an SVG path `d`; text elements carry `text`+`bbox`.",
81
+ "items": { "$ref": "#/$defs/element" }
82
+ },
83
+ "groups": {
84
+ "type": "array",
85
+ "description": "Optional detected entities (the AEC-on-top layer, or user groupings): a named set of element ids. Each group is ID-BEARING so it can be targeted for insert/modify.",
86
+ "items": { "$ref": "#/$defs/group" }
87
+ }
88
+ }
89
+ },
90
+ "element": {
91
+ "type": "object",
92
+ "required": ["id", "kind"],
93
+ "additionalProperties": true,
94
+ "description": "One drawing element. STABLE, sheet-unique `id` (assigned by the post-processor if the extractor omitted it) so it can be selected, exported, and targeted for modification.",
95
+ "properties": {
96
+ "id": { "type": "string", "description": "Stable, sheet-unique element id (e.g. 'e12')." },
97
+ "kind": { "enum": ["line", "arc", "polyline", "spline", "text"], "description": "Geometry class (broader than steel's line/text)." },
98
+ "d": { "type": "string", "description": "SVG path in DISPLAY coords (geometry elements). The SVG export is near-free — this IS the path." },
99
+ "pts": { "type": "array", "items": { "$ref": "#/$defs/point2" }, "description": "Optional explicit vertices (display coords) for line/polyline; a convenience alongside/instead of `d` for snap/edit." },
100
+ "text": { "type": "string", "description": "Label text (text elements)." },
101
+ "bbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] display coords (text + geometry bounds)." },
102
+ "size": { "type": "number", "description": "Text font size in display units (text elements) — reader-emitted; the renderer needs it (a fixed size renders wrong)." },
103
+ "origin": { "$ref": "#/$defs/point2", "description": "Text BASELINE start [x,y] (text elements) — where the glyphs actually sit. The bbox top is NOT the baseline; reader-emitted." },
104
+ "angle": { "type": "number", "description": "Text rotation in degrees from the writing direction (text elements); 0 = horizontal. Reader-emitted." },
105
+ "font": { "type": "string", "description": "Text font family (text elements)." },
106
+ "layer": { "type": "string" },
107
+ "color": { "type": "string", "description": "Stroke colour (#hex or name), reader-emitted." },
108
+ "w": { "type": "number", "description": "Stroke width (display units)." },
109
+ "dashed": { "type": "boolean" },
110
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "READER-EMITTED trace confidence 0..1. The editor flags weak traces (.chip.weak) below a threshold; absent = trusted (1)." },
111
+ "group": { "type": "string", "description": "Optional back-reference to a `groups[].id` this element belongs to." }
112
+ }
113
+ },
114
+ "group": {
115
+ "type": "object",
116
+ "required": ["id"],
117
+ "additionalProperties": true,
118
+ "description": "A named, ID-BEARING set of elements (a detected entity or a user grouping) — the unit an insert/modify Request can target.",
119
+ "properties": {
120
+ "id": { "type": "string", "description": "Stable, sheet-unique group id." },
121
+ "kind": { "type": "string", "description": "Free-form entity kind (vocabulary-free; e.g. 'detail', 'wall', 'annotation'). The generic core does not enforce a taxonomy." },
122
+ "label": { "type": "string" },
123
+ "elementIds": { "type": "array", "items": { "type": "string" }, "description": "The `elements[].id`s in this group." },
124
+ "confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Reader-emitted grouping confidence 0..1." }
125
+ }
126
+ },
127
+ "point2": {
128
+ "type": "array",
129
+ "items": { "type": "number" },
130
+ "minItems": 2,
131
+ "maxItems": 2,
132
+ "description": "[x, y] in display space."
133
+ }
134
+ }
135
+ }
@@ -61,11 +61,12 @@
61
61
  "properties": {
62
62
  "w": { "type": "number", "minimum": 1 },
63
63
  "h": { "type": "number", "minimum": 1 },
64
- "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." }
64
+ "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." },
65
+ "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] in vector-frame coords: the region `bg_b64` was clipped to, so the viewer registers the raster to it (not the whole page). Absent = covers the steelBbox (legacy)." }
65
66
  }
66
67
  },
67
68
  "steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of the steel linework, for Zoom-to-steel." },
68
- "mode": { "enum": ["thickness", "layer", "color", "thickness+layer", "thickness+color"], "description": "Active match mode." },
69
+ "mode": { "enum": ["", "thickness", "layer", "color", "thickness+layer", "thickness+color", "layer+color", "thickness+layer+color"], "description": "Active match dimensions, '+'-joined and AND-ed (canonical order: thickness, layer, color). Any combo of layer/thickness/color; \"\" = match nothing (all boxes unchecked — a valid saved state)." },
69
70
  "hide_title_block": { "type": "boolean" },
70
71
  "layers": {
71
72
  "type": "array",
@@ -142,7 +143,8 @@
142
143
  "properties": {
143
144
  "w": { "type": "number", "minimum": 1 },
144
145
  "h": { "type": "number", "minimum": 1 },
145
- "bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." }
146
+ "bg_b64": { "type": "string", "description": "Base64 JPEG of this sheet (title block excluded). CONFIDENTIAL — stripped before bake, never committed." },
147
+ "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] in the same vector-frame coords as element `d` paths: the region `bg_b64` was clipped to. The viewer registers the raster to THIS rect (not the whole page) so it aligns 1:1 with the linework. Absent = the raster covers the steelBbox (legacy)." }
146
148
  }
147
149
  },
148
150
  "steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of this sheet's steel linework, for Zoom-to-steel." },
@@ -80,10 +80,13 @@ native/display coords):
80
80
  pt>, color:<#rrggbb stroke>, d:<SVG path>, dashed:<bool> }`.
81
81
  2. **Text** — `page.get_text("dict")`: per span keep `{ kind:"text", layer:<OCG>, w:0, color:<#hex>,
82
82
  text:<string>, bbox:[x0,y0,x1,y1] }`.
83
- 3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64 }, steelBbox,
83
+ 3. **Per-sheet entry** — push `{ sheet:<number>, title:<name?>, page:{ w, h, bg_b64, rect }, steelBbox,
84
84
  elements:[…] }` onto `filter.sheets`. `bg_b64` is a dimmed raster of THIS sheet's framing area
85
- (**title block excluded** — CONFIDENTIAL, machine-local, never committed); `steelBbox` = the bbox
86
- of this sheet's steel-layer elements (Zoom-to-steel).
85
+ (**title block excluded** — CONFIDENTIAL, machine-local, never committed). **`page.rect` = the exact
86
+ clip `[x0,y0,x1,y1]` you rendered `bg_b64` from, in the SAME display-space coords as the element `d`
87
+ paths** — the viewer registers the raster to `rect` (not the whole page), so it aligns 1:1 with the
88
+ linework. `w`/`h` stay the full display-space page size (the vectors' coordinate frame). `steelBbox`
89
+ = the bbox of this sheet's steel-layer elements (Zoom-to-steel).
87
90
  4. **Global facets (union across sheets)** — aggregate distinct `layers` / `thicknesses` / `colors`
88
91
  with a summed `count` each, ACROSS all sheets, into the top-level `filter.layers/thicknesses/colors`
89
92
  (NOT per-sheet). Round each element's `w` and the facet `w` the **same** way (matching is
@@ -0,0 +1,130 @@
1
+ ---
2
+ name: floless-app-vectorize
3
+ description: Vectorize a drawing into an editable drawing.vector/v1 contract in floless.app — the "Vectorize" reader. Triggers on a pending floless `rebake` request for the `vectorize` app (queued from its "Re-read & re-bake ▸" button), or asks like "vectorize this PDF", "turn my drawing into clean CAD linework", "read this detail into vectors", "trace this sheet". Teaches the host AI to run ONE deterministic PyMuPDF pass at COMPOSE time (no LLM, no API key) over a vector PDF, emit the drawing.vector/v1 contract, PUT it to the contract store, and hand the 2D vector editor to the user.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Vectorize — read a drawing into a drawing.vector/v1 contract
9
+
10
+ ## What this is
11
+
12
+ `vectorize` is a FloLess app that turns a drawing (PDF today; scans/photos are a later
13
+ slice) into clean, editable **2D vector linework** — a `drawing.vector/v1` contract
14
+ (vocabulary-free; every element has a stable id) rendered in the 2D vector editor, where the
15
+ user pans/zooms, toggles Lines/Curves/Text and layers, inspects weak traces, and exports SVG.
16
+
17
+ The drawing is read **at compose time** with one deterministic PyMuPDF pass. There is **no
18
+ model in the run path and no API key** — and for a vector PDF no vision either: the geometry
19
+ is exact, extracted, never guessed. The app ships with a tiny hard-coded example; this skill
20
+ is how the user's OWN drawing replaces it.
21
+
22
+ **Later slices (mention as "next", do not attempt here):** raster/photo input via AWARE
23
+ `vision.extract`, editor Save/edit, multi-view 3D reconstruction, DXF export.
24
+
25
+ > **Pasted a request?** If the user pastes a message beginning with a `[floless-request
26
+ > type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
27
+ > via `GET /api/requests` (match the id), don't run the pasted line verbatim, and `DELETE` it
28
+ > when done.
29
+
30
+ ## The loop
31
+
32
+ ### 1. Find the drawing(s)
33
+
34
+ From a `rebake` request: `GET http://localhost:<port>/api/requests` → the entry with
35
+ `type:"rebake"` and `appId:"vectorize"` carries `snapshots: ["<abs path>", …]` and optionally
36
+ `sourceName` (use it for `source.name` provenance). **`snapshots` may hold multiple files —
37
+ read every one**, each page becoming a sheet. If the user just attaches or names a file in
38
+ prose, skip the lookup and proceed from that path. Resolve the port from the running
39
+ floless.app (check `~/.floless/port` or the active server port), as the other floless-app
40
+ skills do.
41
+
42
+ ### 2. Gate — is it a VECTOR PDF?
43
+
44
+ This skill's deterministic path reads **vector** PDFs only. If `page.get_drawings()` returns
45
+ nothing on every page (a scan, a photo, a hand sketch), **stop and say so honestly** — the
46
+ raster/vision path is a later slice and is not shipped. Never trace pixels by eye into
47
+ made-up coordinates; fabricated geometry is worse than no geometry.
48
+
49
+ ### 3. Extract — one deterministic PyMuPDF pass
50
+
51
+ Run the bundled extractor (it lives beside this file):
52
+
53
+ ```
54
+ python <skill dir>/scripts/extract_pdf.py <drawing.pdf> [more.pdf …] --out contract.json
55
+ ```
56
+
57
+ Requires PyMuPDF (`pip install pymupdf`). The script forces UTF-8 on its own output; but any
58
+ OTHER ad-hoc Python that prints extracted drawing text must set UTF-8 first — drawings carry
59
+ glyphs (Ø, —) that crash a cp1250 Windows console (PowerShell
60
+ `$env:PYTHONIOENCODING='utf-8'`; bash `PYTHONIOENCODING=utf-8 python …`). What it does, per
61
+ page:
62
+
63
+ - **Geometry** — each `page.get_drawings()` path becomes one element in DISPLAY coords:
64
+ a continuous straight-line chain emits `pts` (explicit vertices — the server snaps shared
65
+ corners through them); everything else emits an SVG `d` (`c` items become cubic béziers,
66
+ `re`/`qu` closed 4-corner polygons). Never both — renderers prefer `d`, which would shadow
67
+ snapped `pts`. Plus stroke `color` (falling back to fill), `w`, `dashed`, and the OCG
68
+ `layer` when the PDF carries one. Kind is `line` (single segment), `polyline`, or `spline`
69
+ (any bézier).
70
+ - **Text** — each `page.get_text("dict")` span becomes `{kind:'text', text, origin (baseline),
71
+ size, angle, color, font, bbox}`. The baseline `origin` and real `size`/`angle` matter — a
72
+ fixed size or bbox-top placement renders wrong.
73
+ - **Rotated pages** — every point is mapped through `page.rotation_matrix` into display space
74
+ (`page.rect` is already display). Never mix native/display coords.
75
+ - **Ids** — the PUT schema **requires** per-element ids; the script assigns `e1…` per sheet
76
+ and `s1…` sheets in document order.
77
+
78
+ If the script needs adapting (odd PDF, extra filtering), patch a local copy — the mapping
79
+ above is the contract. Hard schema requirements the server enforces on PUT: `type`,
80
+ `sheets[].id`, and `sheets[].elements[].{id,kind}` (a missing element id is rejected). Do NOT
81
+ dedupe, snap, or derive layers yourself — the server post-processes every read of this
82
+ contract type.
83
+
84
+ ### 4. Write to the contract store
85
+
86
+ ```
87
+ PUT http://localhost:<port>/api/contract/vectorize
88
+ Content-Type: application/json
89
+
90
+ <contract JSON body>
91
+ ```
92
+
93
+ The server schema-validates the body; on `400` read the error, fix the offending fields, and
94
+ re-PUT. The stored draft **wins over the baked example immediately** — no compile needed for
95
+ the editor to show it. Read back with `GET /api/contract/vectorize` (served through the pure
96
+ post-processor, so ids/layers come back cleaned).
97
+
98
+ ### 5. Hand off to the user
99
+
100
+ Tell the user:
101
+
102
+ - **What was read** — e.g. "2 sheets, 214 lines, 12 curves, 96 text spans from `detail.pdf`".
103
+ - **Open the editor**: double-click the `read` node on the Vectorize canvas. It renders their
104
+ drawing — pan/zoom + Fit, Lines/Curves/Text toggles, per-layer toggles, weak-trace isolation
105
+ (deterministic reads have none), and **Export SVG**.
106
+ - The editor is **view-only for now** — no Save/Approve path yet (a later slice); corrections
107
+ mean re-running this read with a better source.
108
+
109
+ ### 6. Clear the request
110
+
111
+ If the trigger came from a `rebake` request:
112
+
113
+ ```
114
+ DELETE http://localhost:<port>/api/requests/<id>
115
+ ```
116
+
117
+ ## Guardrails
118
+
119
+ - **Compose-time only.** The drawing is read HERE, by the terminal AI, then PUT to the store.
120
+ Never add a runtime node that reads the drawing — `aware app validate` rejects it, and it
121
+ breaks determinism.
122
+ - **Deterministic or nothing.** Vector geometry is extracted exactly; a raster with no vector
123
+ paths is out of scope until the vision slice ships. No eyeballed coordinates, ever.
124
+ - **Thin-UI contract.** The browser recorded intent and the file path; the brain is the
125
+ terminal AI. The UI never reads the drawing and never writes the contract itself.
126
+ - **Stay in scope.** Producing and handing off the contract is the whole job — do not wire
127
+ editor Save/edit, 3D, or DXF here.
128
+ - **Confidentiality.** The contract carries `source.path` and the drawing's content; it is
129
+ machine-local (`~/.floless/contracts/vectorize.json`) and never committed. Don't echo client
130
+ or project identifiers into files or commits.