@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.
- package/dist/floless-server.cjs +145 -29
- package/dist/schemas/drawing.vector.v1.schema.json +135 -0
- package/dist/skills/floless-app-vectorize/SKILL.md +175 -0
- package/dist/skills/floless-app-vectorize/references/vision-inputs.template.json +40 -0
- package/dist/skills/floless-app-vectorize/scripts/extract_pdf.py +240 -0
- package/dist/skills/floless-app-vectorize/scripts/vision_to_contract.py +151 -0
- package/dist/templates/vectorize.flo +69 -0
- package/dist/web/aware.js +15 -11
- package/dist/web/renderers.js +7 -0
- package/dist/web/vector-editor.html +466 -0
- package/dist/web/vector-example.json +107 -0
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.61.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.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.61.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
|
|
53787
|
-
function
|
|
53788
|
-
|
|
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",
|
|
53792
|
+
(0, import_node_path14.join)(here2, "..", "schemas", file),
|
|
53792
53793
|
// dev: server/ next to schemas/
|
|
53793
|
-
(0, import_node_path14.join)(here2, "schemas",
|
|
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
|
|
53799
|
-
|
|
53800
|
-
return
|
|
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(
|
|
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 =
|
|
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
|
|
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 (
|
|
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 =
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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`, REQUIRED on write (the store validates before the post-processor runs; the post-processor only repairs id collisions on read) — so every element 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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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 sketch into clean CAD linework", "read this detail into vectors", "trace this photo". Teaches the host AI to read a drawing at COMPOSE time — a vector PDF via ONE deterministic PyMuPDF pass (no LLM), a scan/photo/hand sketch via AWARE's fenced vision.extract (schema-bound, cached) — 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.3.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 — a vector PDF, a scan, a photo, a hand
|
|
13
|
+
sketch — 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, reviews weak traces, and exports SVG.
|
|
16
|
+
|
|
17
|
+
The drawing is read **at compose time**, by one of two paths:
|
|
18
|
+
|
|
19
|
+
- **Vector PDF → deterministic.** One PyMuPDF pass; the geometry is exact, extracted, never
|
|
20
|
+
guessed. No model, no API key.
|
|
21
|
+
- **Raster (scan/photo/sketch) → AWARE `vision.extract`.** The substrate's fenced extraction
|
|
22
|
+
carve-out: a FIXED schema + prompt + pinned model, content-hash cached (same input → same
|
|
23
|
+
JSON, no repeat model call). Coordinates come from the model, flagged with per-element
|
|
24
|
+
confidence the editor surfaces for review — never from you eyeballing pixels.
|
|
25
|
+
|
|
26
|
+
There is **no model in the run path** either way. The app ships with a tiny hard-coded
|
|
27
|
+
example; this skill is how the user's OWN drawing replaces it.
|
|
28
|
+
|
|
29
|
+
**Later slices (mention as "next", do not attempt here):** multi-view 3D reconstruction,
|
|
30
|
+
DXF export.
|
|
31
|
+
|
|
32
|
+
> **Pasted a request?** If the user pastes a message beginning with a `[floless-request
|
|
33
|
+
> type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
|
|
34
|
+
> via `GET /api/requests` (match the id), don't run the pasted line verbatim, and `DELETE` it
|
|
35
|
+
> when done.
|
|
36
|
+
|
|
37
|
+
## The loop
|
|
38
|
+
|
|
39
|
+
### 1. Find the drawing(s)
|
|
40
|
+
|
|
41
|
+
From a `rebake` request: `GET http://localhost:<port>/api/requests` → the entry with
|
|
42
|
+
`type:"rebake"` and `appId:"vectorize"` carries `snapshots: ["<abs path>", …]` and optionally
|
|
43
|
+
`sourceName` (use it for `source.name` provenance). **`snapshots` may hold multiple files —
|
|
44
|
+
read every one**, each page becoming a sheet. If the user just attaches or names a file in
|
|
45
|
+
prose, skip the lookup and proceed from that path. Resolve the port from the running
|
|
46
|
+
floless.app (check `~/.floless/port` or the active server port), as the other floless-app
|
|
47
|
+
skills do.
|
|
48
|
+
|
|
49
|
+
### 2. Pick the path — vector or raster?
|
|
50
|
+
|
|
51
|
+
Probe the file: a PDF whose pages return `page.get_drawings()` paths is a **vector PDF** →
|
|
52
|
+
step 3a (deterministic, always preferred). An image file, or a PDF with no vector paths (a
|
|
53
|
+
scan, a photo, a hand sketch) → step 3b (vision). **Never trace pixels by eye into made-up
|
|
54
|
+
coordinates** — fabricated geometry is worse than no geometry; the raster path exists so the
|
|
55
|
+
extraction is fenced, cached, and confidence-flagged instead.
|
|
56
|
+
|
|
57
|
+
### 3a. Vector path — one deterministic PyMuPDF pass
|
|
58
|
+
|
|
59
|
+
Run the bundled extractor (it lives beside this file):
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
python <skill dir>/scripts/extract_pdf.py <drawing.pdf> [more.pdf …] --out contract.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Requires PyMuPDF (`pip install pymupdf`). The script forces UTF-8 on its own output; but any
|
|
66
|
+
OTHER ad-hoc Python that prints extracted drawing text must set UTF-8 first — drawings carry
|
|
67
|
+
glyphs (Ø, —) that crash a cp1250 Windows console (PowerShell
|
|
68
|
+
`$env:PYTHONIOENCODING='utf-8'`; bash `PYTHONIOENCODING=utf-8 python …`). What it does, per
|
|
69
|
+
page:
|
|
70
|
+
|
|
71
|
+
- **Geometry** — each `page.get_drawings()` path becomes one element in DISPLAY coords:
|
|
72
|
+
a continuous straight-line chain emits `pts` (explicit vertices — the server snaps shared
|
|
73
|
+
corners through them); everything else emits an SVG `d` (`c` items become cubic béziers,
|
|
74
|
+
`re`/`qu` closed 4-corner polygons). Never both — renderers prefer `d`, which would shadow
|
|
75
|
+
snapped `pts`. Plus stroke `color` (falling back to fill), `w`, `dashed`, and the OCG
|
|
76
|
+
`layer` when the PDF carries one. Kind is `line` (single segment), `polyline`, or `spline`
|
|
77
|
+
(any bézier).
|
|
78
|
+
- **Text** — each `page.get_text("dict")` span becomes `{kind:'text', text, origin (baseline),
|
|
79
|
+
size, angle, color, font, bbox}`. The baseline `origin` and real `size`/`angle` matter — a
|
|
80
|
+
fixed size or bbox-top placement renders wrong.
|
|
81
|
+
- **Rotated pages** — every point is mapped through `page.rotation_matrix` into display space
|
|
82
|
+
(`page.rect` is already display). Never mix native/display coords.
|
|
83
|
+
- **Ids** — the PUT schema **requires** per-element ids; the script assigns `e1…` per sheet
|
|
84
|
+
and `s1…` sheets in document order.
|
|
85
|
+
|
|
86
|
+
If the script needs adapting (odd PDF, extra filtering), patch a local copy — the mapping
|
|
87
|
+
above is the contract. Hard schema requirements the server enforces on PUT: `type`,
|
|
88
|
+
`sheets[].id`, and `sheets[].elements[].{id,kind}` (a missing element id is rejected). Do NOT
|
|
89
|
+
dedupe, snap, or derive layers yourself — the server post-processes every read of this
|
|
90
|
+
contract type.
|
|
91
|
+
|
|
92
|
+
### 3b. Raster path — AWARE `vision.extract` (the fenced exception)
|
|
93
|
+
|
|
94
|
+
For each raster image (or rasterized PDF page exported to an image):
|
|
95
|
+
|
|
96
|
+
1. Copy `references/vision-inputs.template.json` (beside this file) to a scratch file and set
|
|
97
|
+
**only** `"file"` to the image's absolute path. The `prompt`, `schema`, and pinned `model`
|
|
98
|
+
are FIXED — together with the file bytes they form the extraction's content-hash cache key
|
|
99
|
+
and its fence; do not tweak them per run.
|
|
100
|
+
2. Invoke the substrate:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
aware agent invoke vision extract --inputs @<scratch>/vision-inputs.json --json > <scratch>/vision-out.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A cache miss shells out to the local authenticated `claude`/`codex` CLI (no API key) and can
|
|
107
|
+
take a minute; a repeat of the same input replays from cache instantly. If neither CLI nor a
|
|
108
|
+
`vision-model` credential is available, stop and tell the user what to set up.
|
|
109
|
+
3. Convert to the contract (the model traces in a normalized 0..1000 frame; this maps it onto
|
|
110
|
+
the image's real pixel size and carries per-element confidence):
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
python <skill dir>/scripts/vision_to_contract.py <scratch>/vision-out.json <image> --out contract.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For a multi-image set, run steps 1–3 per image and append with
|
|
117
|
+
`--merge-into contract.json` instead of `--out`.
|
|
118
|
+
4. Sanity-check the result before PUT: element count > 0, and the traced text you can see in
|
|
119
|
+
the vision output matches what the drawing actually says. A wildly wrong trace usually means
|
|
120
|
+
the image is too low-contrast — say so rather than shipping garbage.
|
|
121
|
+
|
|
122
|
+
### 4. Write to the contract store
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
PUT http://localhost:<port>/api/contract/vectorize
|
|
126
|
+
Content-Type: application/json
|
|
127
|
+
|
|
128
|
+
<contract JSON body>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The server schema-validates the body; on `400` read the error, fix the offending fields, and
|
|
132
|
+
re-PUT. The stored draft **wins over the baked example immediately** — no compile needed for
|
|
133
|
+
the editor to show it. Read back with `GET /api/contract/vectorize` (served through the pure
|
|
134
|
+
post-processor, so ids/layers come back cleaned).
|
|
135
|
+
|
|
136
|
+
### 5. Hand off to the user
|
|
137
|
+
|
|
138
|
+
Tell the user:
|
|
139
|
+
|
|
140
|
+
- **What was read** — e.g. "2 sheets, 214 lines, 12 curves, 96 text spans from `detail.pdf`".
|
|
141
|
+
- **Open the editor**: double-click the `read` node on the Vectorize canvas. It renders their
|
|
142
|
+
drawing — pan/zoom + Fit, Lines/Curves/Text toggles, per-layer toggles, weak-trace isolation,
|
|
143
|
+
and **Export SVG**. Deterministic reads have no weak traces; after a **vision** read, point
|
|
144
|
+
the user at the **Confidence** panel — each trace the model was unsure about (confidence
|
|
145
|
+
< 0.5) is listed there with **Accept** (mark correct) and **Delete** (remove, with Undo), and
|
|
146
|
+
a "Reviewed N/M" counter tracks progress. Edits auto-save to the contract store.
|
|
147
|
+
- **Approve & bake lock**: when satisfied, the user clicks Approve in the editor header — it
|
|
148
|
+
bakes the (edited) contract into the `read` node's `config.takeoff` and recompiles, arming
|
|
149
|
+
the Run gate. Never auto-approve on their behalf.
|
|
150
|
+
|
|
151
|
+
### 6. Clear the request
|
|
152
|
+
|
|
153
|
+
If the trigger came from a `rebake` request:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
DELETE http://localhost:<port>/api/requests/<id>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Guardrails
|
|
160
|
+
|
|
161
|
+
- **Compose-time only.** The drawing is read HERE, by the terminal AI, then PUT to the store.
|
|
162
|
+
Never add a runtime node that reads the drawing — `aware app validate` rejects it, and it
|
|
163
|
+
breaks determinism.
|
|
164
|
+
- **Deterministic first, fenced second, eyeballs never.** Vector geometry is extracted
|
|
165
|
+
exactly; raster interpretation goes ONLY through `vision.extract` (fixed schema/prompt/model,
|
|
166
|
+
cached, confidence-flagged). Never hand-write coordinates you read off pixels yourself, and
|
|
167
|
+
never swap the vision path for your own vision — the fence is what makes the extraction
|
|
168
|
+
reproducible.
|
|
169
|
+
- **Thin-UI contract.** The browser recorded intent and the file path; the brain is the
|
|
170
|
+
terminal AI. The UI never reads the drawing and never writes the contract itself.
|
|
171
|
+
- **Stay in scope.** Producing and handing off the contract is the whole job — do not wire
|
|
172
|
+
editor Save/edit, 3D, or DXF here.
|
|
173
|
+
- **Confidentiality.** The contract carries `source.path` and the drawing's content; it is
|
|
174
|
+
machine-local (`~/.floless/contracts/vectorize.json`) and never committed. Don't echo client
|
|
175
|
+
or project identifiers into files or commits.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"file": "<FILL IN: absolute path to the raster image/PDF>",
|
|
3
|
+
"model": "claude-sonnet-5",
|
|
4
|
+
"prompt": "Trace every visible stroke and label in this drawing into vector geometry. Use a coordinate frame where x runs 0..1000 left to right and y runs 0..1000 top to bottom over the full image. Emit each straight stroke as kind 'line' (exactly two points) or a connected run of straight strokes as 'polyline' (a vertex chain); follow smooth curves with a 'polyline' of enough vertices to track the curve (at most 20). Emit every piece of text as kind 'text' with the text content and its bounding box [x0,y0,x1,y1]. Mark a stroke dashed:true only if it is visibly drawn dashed. Do NOT invent geometry that is not visibly drawn; skip shading, hatching texture, smudges and paper artifacts. For every element report confidence between 0 and 1 — report 0.5 or lower whenever you are unsure the stroke exists, its endpoints are ambiguous, or the text is hard to read.",
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["elements"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"elements": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["kind", "confidence"],
|
|
14
|
+
"allOf": [
|
|
15
|
+
{ "if": { "properties": { "kind": { "enum": ["line", "polyline"] } } }, "then": { "required": ["pts"] } },
|
|
16
|
+
{ "if": { "properties": { "kind": { "const": "text" } } }, "then": { "required": ["text", "bbox"] } }
|
|
17
|
+
],
|
|
18
|
+
"properties": {
|
|
19
|
+
"kind": { "enum": ["line", "polyline", "text"] },
|
|
20
|
+
"pts": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
|
23
|
+
"description": "Vertices in the 0..1000 frame (line/polyline)"
|
|
24
|
+
},
|
|
25
|
+
"text": { "type": "string" },
|
|
26
|
+
"bbox": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": { "type": "number" },
|
|
29
|
+
"minItems": 4,
|
|
30
|
+
"maxItems": 4,
|
|
31
|
+
"description": "[x0,y0,x1,y1] in the 0..1000 frame (text)"
|
|
32
|
+
},
|
|
33
|
+
"dashed": { "type": "boolean" },
|
|
34
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|