@floless/app 0.59.0 → 0.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +145 -29
- package/dist/schemas/drawing.vector.v1.schema.json +135 -0
- package/dist/schemas/steel.takeoff.v1.schema.json +5 -3
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +6 -3
- package/dist/skills/floless-app-vectorize/SKILL.md +130 -0
- package/dist/skills/floless-app-vectorize/scripts/extract_pdf.py +240 -0
- package/dist/templates/vectorize.flo +61 -0
- package/dist/web/aware.js +3 -3
- package/dist/web/renderers.js +6 -0
- package/dist/web/steel-filter-core.js +44 -13
- package/dist/web/steel-filter.html +31 -19
- package/dist/web/vector-editor.html +289 -0
- package/dist/web/vector-example.json +107 -0
- package/package.json +1 -1
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.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.
|
|
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
|
|
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` (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
|
|
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)
|
|
86
|
-
|
|
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.
|