@floless/app 0.59.1 → 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/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/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
|
+
}
|
|
@@ -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.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic vector-PDF -> drawing.vector/v1 extractor (compose-time, no LLM).
|
|
3
|
+
|
|
4
|
+
One PyMuPDF pass per page: every page.get_drawings() path becomes a geometry element
|
|
5
|
+
(SVG `d` in DISPLAY coords) and every page.get_text("dict") span becomes a text element
|
|
6
|
+
(baseline origin, real size, rotation). Emits a RAW drawing.vector/v1 contract ready to
|
|
7
|
+
PUT to /api/contract/vectorize — the server's postProcess handles endpoint-snap, dedupe
|
|
8
|
+
and layer derivation, but per-element ids are REQUIRED by the PUT schema, so this script
|
|
9
|
+
assigns them (e1.. per sheet, s1.. sheets).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
|
|
13
|
+
python extract_pdf.py drawing.pdf [more.pdf ...] [--out contract.json]
|
|
14
|
+
|
|
15
|
+
The script forces UTF-8 on its own stdout/stderr (drawings contain glyphs like a diameter
|
|
16
|
+
sign that crash Windows cp1250 consoles), so no shell-specific env setup is needed.
|
|
17
|
+
|
|
18
|
+
Requires PyMuPDF (pip install pymupdf). A raster-only page (scan / photo / hand sketch)
|
|
19
|
+
yields no geometry — that is the Slice-2 vision path, NOT this script's job; the caller
|
|
20
|
+
must tell the user honestly instead of tracing pixels.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
import fitz # PyMuPDF
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fmt(v):
|
|
35
|
+
"""Compact numeric formatting for SVG path data."""
|
|
36
|
+
return f"{round(v, 2):g}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rgb_hex(c):
|
|
40
|
+
"""PyMuPDF float RGB tuple -> #rrggbb, or None."""
|
|
41
|
+
if c is None:
|
|
42
|
+
return None
|
|
43
|
+
r, g, b = (max(0, min(255, round(v * 255))) for v in c)
|
|
44
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def int_hex(c):
|
|
48
|
+
"""get_text span colour int (sRGB) -> #rrggbb."""
|
|
49
|
+
return f"#{(c or 0) & 0xFFFFFF:06x}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def path_to_element(pth, m):
|
|
53
|
+
"""One get_drawings() path -> a geometry element dict (or None if it draws nothing).
|
|
54
|
+
|
|
55
|
+
Item -> SVG mapping: 'l' segments chain into M/L, 'c' cubic beziers into C,
|
|
56
|
+
're' rects and 'qu' quads into closed 4-corner polygons. Every point is mapped
|
|
57
|
+
through the page's rotation_matrix so a rotated page lands in display space.
|
|
58
|
+
"""
|
|
59
|
+
d, pts, kinds = [], [], set()
|
|
60
|
+
cur = None
|
|
61
|
+
breaks = 0 # subpath breaks after the first M (disqualifies pts[])
|
|
62
|
+
|
|
63
|
+
def moveto(p):
|
|
64
|
+
nonlocal cur, breaks
|
|
65
|
+
if cur is not None:
|
|
66
|
+
breaks += 1
|
|
67
|
+
d.append(f"M{fmt(p.x)} {fmt(p.y)}")
|
|
68
|
+
pts.append([round(p.x, 2), round(p.y, 2)])
|
|
69
|
+
cur = p
|
|
70
|
+
|
|
71
|
+
def cont(p):
|
|
72
|
+
return cur is not None and abs(cur.x - p.x) < 1e-4 and abs(cur.y - p.y) < 1e-4
|
|
73
|
+
|
|
74
|
+
for item in pth["items"]:
|
|
75
|
+
op = item[0]
|
|
76
|
+
if op == "l":
|
|
77
|
+
p1, p2 = item[1] * m, item[2] * m
|
|
78
|
+
if abs(p1.x - p2.x) < 1e-6 and abs(p1.y - p2.y) < 1e-6:
|
|
79
|
+
continue # zero-length
|
|
80
|
+
if not cont(p1):
|
|
81
|
+
moveto(p1)
|
|
82
|
+
d.append(f"L{fmt(p2.x)} {fmt(p2.y)}")
|
|
83
|
+
pts.append([round(p2.x, 2), round(p2.y, 2)])
|
|
84
|
+
cur = p2
|
|
85
|
+
kinds.add("l")
|
|
86
|
+
elif op == "c":
|
|
87
|
+
p1, c1, c2, p2 = (item[i] * m for i in (1, 2, 3, 4))
|
|
88
|
+
if not cont(p1):
|
|
89
|
+
moveto(p1)
|
|
90
|
+
d.append(f"C{fmt(c1.x)} {fmt(c1.y)} {fmt(c2.x)} {fmt(c2.y)} {fmt(p2.x)} {fmt(p2.y)}")
|
|
91
|
+
cur = p2
|
|
92
|
+
kinds.add("c")
|
|
93
|
+
elif op in ("re", "qu"):
|
|
94
|
+
q = item[1].quad if op == "re" else item[1]
|
|
95
|
+
ul, ur, lr, ll = (p * m for p in (q.ul, q.ur, q.lr, q.ll))
|
|
96
|
+
if cur is not None:
|
|
97
|
+
breaks += 1
|
|
98
|
+
d.append(
|
|
99
|
+
f"M{fmt(ul.x)} {fmt(ul.y)} L{fmt(ur.x)} {fmt(ur.y)} "
|
|
100
|
+
f"L{fmt(lr.x)} {fmt(lr.y)} L{fmt(ll.x)} {fmt(ll.y)} Z"
|
|
101
|
+
)
|
|
102
|
+
cur = None
|
|
103
|
+
kinds.add(op)
|
|
104
|
+
|
|
105
|
+
if not d:
|
|
106
|
+
return None
|
|
107
|
+
closed = bool(pth.get("closePath"))
|
|
108
|
+
if closed and not d[-1].endswith("Z"):
|
|
109
|
+
d.append("Z")
|
|
110
|
+
|
|
111
|
+
kind = "spline" if "c" in kinds else ("line" if kinds == {"l"} and len(pts) == 2 else "polyline")
|
|
112
|
+
el = {"kind": kind}
|
|
113
|
+
if kinds == {"l"} and breaks == 0 and len(pts) >= 2:
|
|
114
|
+
# Continuous straight-line chain: emit VERTICES ONLY (no `d`). Renderers rebuild the path
|
|
115
|
+
# from pts, and the server's endpoint-snap edits pts — a `d` alongside would shadow the
|
|
116
|
+
# snapped geometry (renderers prefer `d` when both are present).
|
|
117
|
+
if closed and pts[0] != pts[-1]:
|
|
118
|
+
pts.append(list(pts[0])) # make the closing edge explicit — pts paths carry no Z
|
|
119
|
+
el["pts"] = pts
|
|
120
|
+
if len(pts) > 2:
|
|
121
|
+
el["kind"] = "polyline"
|
|
122
|
+
else:
|
|
123
|
+
el["d"] = " ".join(d)
|
|
124
|
+
color = rgb_hex(pth.get("color")) or rgb_hex(pth.get("fill"))
|
|
125
|
+
if color is None:
|
|
126
|
+
return None # neither stroked nor filled -> draws nothing
|
|
127
|
+
el["color"] = color
|
|
128
|
+
if pth.get("width"):
|
|
129
|
+
el["w"] = round(pth["width"], 3)
|
|
130
|
+
dashes = (pth.get("dashes") or "").strip()
|
|
131
|
+
if dashes and dashes not in ("[] 0", "[]"):
|
|
132
|
+
el["dashed"] = True
|
|
133
|
+
if pth.get("layer"):
|
|
134
|
+
el["layer"] = pth["layer"]
|
|
135
|
+
return el
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def text_elements(page, m):
|
|
139
|
+
"""get_text('dict') spans -> text elements with baseline origin, size, rotation, colour."""
|
|
140
|
+
rot = fitz.Matrix(m.a, m.b, m.c, m.d, 0, 0) # rotation part only (for direction vectors)
|
|
141
|
+
out = []
|
|
142
|
+
for block in page.get_text("dict")["blocks"]:
|
|
143
|
+
if block.get("type") != 0:
|
|
144
|
+
continue
|
|
145
|
+
for line in block["lines"]:
|
|
146
|
+
dv = fitz.Point(line["dir"]) * rot
|
|
147
|
+
angle = round(math.degrees(math.atan2(dv.y, dv.x)), 2)
|
|
148
|
+
for span in line["spans"]:
|
|
149
|
+
if not span["text"].strip():
|
|
150
|
+
continue
|
|
151
|
+
origin = fitz.Point(span["origin"]) * m
|
|
152
|
+
bbox = fitz.Rect(span["bbox"]) * m
|
|
153
|
+
bbox.normalize()
|
|
154
|
+
el = {
|
|
155
|
+
"kind": "text",
|
|
156
|
+
"text": span["text"],
|
|
157
|
+
"origin": [round(origin.x, 2), round(origin.y, 2)],
|
|
158
|
+
"bbox": [round(v, 2) for v in (bbox.x0, bbox.y0, bbox.x1, bbox.y1)],
|
|
159
|
+
"size": round(span["size"], 2),
|
|
160
|
+
"color": int_hex(span.get("color")),
|
|
161
|
+
}
|
|
162
|
+
if span.get("font"):
|
|
163
|
+
el["font"] = span["font"]
|
|
164
|
+
if abs(angle) > 0.01:
|
|
165
|
+
el["angle"] = angle
|
|
166
|
+
out.append(el)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def extract(paths):
|
|
171
|
+
sheets = []
|
|
172
|
+
for path in paths:
|
|
173
|
+
doc = fitz.open(path)
|
|
174
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
175
|
+
for pno in range(doc.page_count):
|
|
176
|
+
page = doc[pno]
|
|
177
|
+
m = page.rotation_matrix # unrotated -> display space; page.rect is already display
|
|
178
|
+
elements = []
|
|
179
|
+
for pth in page.get_drawings():
|
|
180
|
+
el = path_to_element(pth, m)
|
|
181
|
+
if el:
|
|
182
|
+
elements.append(el)
|
|
183
|
+
elements.extend(text_elements(page, m))
|
|
184
|
+
for i, el in enumerate(elements):
|
|
185
|
+
el["id"] = f"e{i + 1}" # PUT schema requires ids; stable in document order
|
|
186
|
+
sheets.append({
|
|
187
|
+
"id": f"s{len(sheets) + 1}",
|
|
188
|
+
"label": f"{stem} p{pno + 1}",
|
|
189
|
+
"page": {"w": round(page.rect.width, 2), "h": round(page.rect.height, 2)},
|
|
190
|
+
"elements": elements,
|
|
191
|
+
})
|
|
192
|
+
doc.close()
|
|
193
|
+
|
|
194
|
+
first = paths[0]
|
|
195
|
+
name = os.path.basename(first) + (f" (+{len(paths) - 1} more)" if len(paths) > 1 else "")
|
|
196
|
+
return {
|
|
197
|
+
"type": "drawing.vector/v1",
|
|
198
|
+
"units": "pt", # no transform emitted -> display space (PDF points) is world 1:1
|
|
199
|
+
"source": {
|
|
200
|
+
"name": name,
|
|
201
|
+
"path": os.path.abspath(first),
|
|
202
|
+
"kind": "pdf",
|
|
203
|
+
"sha256": hashlib.sha256(open(first, "rb").read()).hexdigest(),
|
|
204
|
+
"read_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
205
|
+
"extractor": f"pymupdf@compose-time ({getattr(fitz, 'VersionBind', '?')})",
|
|
206
|
+
},
|
|
207
|
+
"sheets": sheets,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main():
|
|
212
|
+
# Self-sufficient UTF-8 output regardless of console codepage (Windows cp1250 crashes on Ø/—).
|
|
213
|
+
for stream in (sys.stdout, sys.stderr):
|
|
214
|
+
try:
|
|
215
|
+
stream.reconfigure(encoding="utf-8")
|
|
216
|
+
except AttributeError:
|
|
217
|
+
pass
|
|
218
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
219
|
+
ap.add_argument("pdfs", nargs="+", help="vector PDF file(s) to extract")
|
|
220
|
+
ap.add_argument("--out", help="write the contract JSON here instead of stdout")
|
|
221
|
+
args = ap.parse_args()
|
|
222
|
+
|
|
223
|
+
contract = extract(args.pdfs)
|
|
224
|
+
total = sum(len(s["elements"]) for s in contract["sheets"])
|
|
225
|
+
if total == 0:
|
|
226
|
+
print(
|
|
227
|
+
"WARNING: no vector geometry or text found — this looks like a raster-only PDF "
|
|
228
|
+
"(scan/photo). The deterministic path cannot read it; do not fabricate linework.",
|
|
229
|
+
file=sys.stderr,
|
|
230
|
+
)
|
|
231
|
+
if args.out:
|
|
232
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
233
|
+
json.dump(contract, f, ensure_ascii=False)
|
|
234
|
+
print(f"{args.out}: {len(contract['sheets'])} sheet(s), {total} element(s)", file=sys.stderr)
|
|
235
|
+
else:
|
|
236
|
+
json.dump(contract, sys.stdout, ensure_ascii=False)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
app: vectorize
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
display-name: Vectorize
|
|
4
|
+
publisher: floless
|
|
5
|
+
description: |
|
|
6
|
+
Turn any drawing — a sketch, a PDF, a photo — into clean, editable 2D vectors. Your terminal AI
|
|
7
|
+
reads the drawing ONCE at compose time (a deterministic PyMuPDF pass — no LLM) and bakes it into a
|
|
8
|
+
drawing.vector contract; double-click the node to open the 2D vector editor, where you pan and zoom,
|
|
9
|
+
toggle what's shown, flag weak traces, and export SVG. Attach a drawing and use "Re-read & re-bake ▸"
|
|
10
|
+
(or ask your terminal AI) to vectorize your own; the deterministic run just re-renders a short summary
|
|
11
|
+
of what was read. Ships with a tiny example so it works out of the box.
|
|
12
|
+
exposes-as-agent: false
|
|
13
|
+
# Workflow changelog (FloLess-published). Newest first; the app shows entries newer than the installed
|
|
14
|
+
# version when offering an update. Plain-English — it surfaces to the user.
|
|
15
|
+
changelog:
|
|
16
|
+
- version: 0.1.0
|
|
17
|
+
summary: Vectorize a drawing into clean 2D linework you can edit and export
|
|
18
|
+
inputs:
|
|
19
|
+
drawing:
|
|
20
|
+
type: image
|
|
21
|
+
widget: file
|
|
22
|
+
accept: [pdf, png, jpg]
|
|
23
|
+
read-strategy: bake
|
|
24
|
+
description: A drawing to vectorize — read by your terminal AI into clean 2D vectors; swap to re-read & re-bake.
|
|
25
|
+
requires:
|
|
26
|
+
- html-report@0.2.x
|
|
27
|
+
layout: linear
|
|
28
|
+
nodes:
|
|
29
|
+
# ── node 1: read — vectorize the drawing into clean 2D linework ───────────────
|
|
30
|
+
# This is where your drawing becomes editable vectors. Your terminal AI reads the
|
|
31
|
+
# attached drawing once (a deterministic PyMuPDF pass — no LLM) and bakes every line,
|
|
32
|
+
# curve and label into the contract. Double-click this node to open the 2D vector
|
|
33
|
+
# editor: pan and zoom the drawing, toggle Lines / Curves / Text, flag any weak traces,
|
|
34
|
+
# and Export SVG. `contract` tells FloLess which editor to open; `takeoff` is the single
|
|
35
|
+
# source the editor, Approve and the run all read. It ships with a tiny EXAMPLE so it
|
|
36
|
+
# renders out of the box — "Re-read & re-bake ▸" replaces it with your own drawing. The
|
|
37
|
+
# deterministic run re-renders the short summary below.
|
|
38
|
+
- id: read
|
|
39
|
+
agent: html-report
|
|
40
|
+
command: render
|
|
41
|
+
config:
|
|
42
|
+
contract: drawing.vector/v1
|
|
43
|
+
takeoff:
|
|
44
|
+
type: drawing.vector/v1
|
|
45
|
+
units: mm
|
|
46
|
+
source: { name: "Example — sample detail (not your drawing)" }
|
|
47
|
+
sheets:
|
|
48
|
+
- id: s1
|
|
49
|
+
label: "Sample detail"
|
|
50
|
+
page: { w: 300, h: 200 }
|
|
51
|
+
elements:
|
|
52
|
+
- { id: e1, kind: polyline, d: "M60 50 H240 V150 H60 Z", color: "#334155", w: 1.4 }
|
|
53
|
+
- { id: e2, kind: line, d: "M150 30 L150 170", color: "#94a3b8", w: 0.8, dashed: true }
|
|
54
|
+
- { id: e3, kind: line, d: "M60 100 H240", color: "#94a3b8", w: 0.8 }
|
|
55
|
+
- { id: t1, kind: text, text: "SAMPLE DETAIL", size: 11, origin: [66, 44], color: "#334155" }
|
|
56
|
+
- { id: t2, kind: text, text: "PL 13mm", size: 9, origin: [98, 96], color: "#334155" }
|
|
57
|
+
title: "Vectorized drawing"
|
|
58
|
+
data:
|
|
59
|
+
- { Kind: Lines, Count: 3 }
|
|
60
|
+
- { Kind: Text, Count: 2 }
|
|
61
|
+
connections: []
|
package/dist/web/aware.js
CHANGED
|
@@ -1752,9 +1752,9 @@
|
|
|
1752
1752
|
function openContractEditor(appId, type) {
|
|
1753
1753
|
const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
|
|
1754
1754
|
if (!r) return false;
|
|
1755
|
-
// The editor bakes the .lock on Approve — that control belongs to
|
|
1756
|
-
// filter view (
|
|
1757
|
-
const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden =
|
|
1755
|
+
// The editor bakes the .lock on Approve — that control belongs to an editable contract editor, not
|
|
1756
|
+
// the filter view (own Save) nor a view-only renderer. Show it unless the renderer is viewOnly.
|
|
1757
|
+
const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = !!r.viewOnly;
|
|
1758
1758
|
$contractEditorTitle.replaceChildren(
|
|
1759
1759
|
Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
|
|
1760
1760
|
Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
|
package/dist/web/renderers.js
CHANGED
|
@@ -13,6 +13,12 @@ window.CONTRACT_RENDERERS = {
|
|
|
13
13
|
'steel.takeoff/v1': {
|
|
14
14
|
editorUrl: (appId) => '/steel-editor.html?app=' + encodeURIComponent(appId),
|
|
15
15
|
},
|
|
16
|
+
'drawing.vector/v1': {
|
|
17
|
+
editorUrl: (appId) => '/vector-editor.html?app=' + encodeURIComponent(appId),
|
|
18
|
+
// View-only for now: the vector editor renders + exports SVG but has no Save/edit path yet (the
|
|
19
|
+
// edit/re-bake flow is a later slice), so the shell hides its Approve button (see openContractEditor).
|
|
20
|
+
viewOnly: true,
|
|
21
|
+
},
|
|
16
22
|
};
|
|
17
23
|
|
|
18
24
|
// Return the contract type string declared on a node, or null when none.
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Vectorize — 2D editor</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* Locked baseline: shadcn dark slate-blue (identical tokens + scrollbar theming as steel-editor.html). */
|
|
9
|
+
:root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6;--paper:#f8fafc}
|
|
10
|
+
*{scrollbar-width:thin;scrollbar-color:#475569 transparent}
|
|
11
|
+
*::-webkit-scrollbar{width:10px;height:10px}
|
|
12
|
+
*::-webkit-scrollbar-track{background:transparent}
|
|
13
|
+
*::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
|
|
14
|
+
*::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
|
|
15
|
+
*::-webkit-scrollbar-corner{background:transparent}
|
|
16
|
+
*{box-sizing:border-box}
|
|
17
|
+
body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
|
18
|
+
header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex:none}
|
|
19
|
+
header b{font-size:14px}
|
|
20
|
+
header .spacer{flex:1}
|
|
21
|
+
.stat{color:var(--mut)} .stat b{color:var(--text);font-variant-numeric:tabular-nums}
|
|
22
|
+
button{height:28px;padding:0 12px;background:var(--line);color:var(--text);border:1px solid var(--line);border-radius:6px;cursor:pointer;font:inherit}
|
|
23
|
+
button:hover{background:#475569}
|
|
24
|
+
button.primary{background:var(--brand);border-color:var(--brand);color:#fff}
|
|
25
|
+
button.primary:hover{filter:brightness(1.08)}
|
|
26
|
+
#wrap{flex:1;display:flex;min-height:0}
|
|
27
|
+
#stage{flex:1;position:relative;background:var(--paper);overflow:hidden;min-width:0}
|
|
28
|
+
#svg{position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:grab}
|
|
29
|
+
#svg.panning{cursor:grabbing}
|
|
30
|
+
#svg path{stroke-linecap:round;stroke-linejoin:round}
|
|
31
|
+
#svg .weak{stroke:#f59e0b!important} #svg text.weak{fill:#f59e0b!important}
|
|
32
|
+
#svg .hidden{display:none}
|
|
33
|
+
#state{position:absolute;inset:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:10px;background:var(--bg);color:var(--mut);text-align:center;padding:24px}
|
|
34
|
+
#state.show{display:flex}
|
|
35
|
+
#state .spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--brand);border-radius:50%;animation:spin .8s linear infinite}
|
|
36
|
+
@keyframes spin{to{transform:rotate(360deg)}}
|
|
37
|
+
aside{width:244px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto;display:flex;flex-direction:column;gap:14px}
|
|
38
|
+
aside h3{margin:0;font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
|
|
39
|
+
.ck{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:6px;cursor:pointer;color:var(--text)}
|
|
40
|
+
.ck:hover{background:var(--line)}
|
|
41
|
+
.ck input{accent-color:var(--brand);cursor:pointer;margin:0}
|
|
42
|
+
.ck .ct{margin-left:auto;color:var(--mut);font-variant-numeric:tabular-nums;font-size:11px}
|
|
43
|
+
.muted{color:var(--mut);font-size:12px;line-height:1.5}
|
|
44
|
+
hr{border:0;border-top:1px solid var(--line);margin:0}
|
|
45
|
+
#zoombar{position:absolute;left:12px;bottom:12px;display:flex;align-items:center;gap:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:5}
|
|
46
|
+
#zoombar input[type=range]{width:120px;padding:0;accent-color:var(--brand)}
|
|
47
|
+
#zoombar #zpct{min-width:42px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
|
|
48
|
+
#zoombar .ghost{height:24px;padding:0 8px;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:5px;font-size:11px}
|
|
49
|
+
#zoombar .ghost:hover{color:var(--text);border-color:var(--brand)}
|
|
50
|
+
</style>
|
|
51
|
+
</head>
|
|
52
|
+
<body>
|
|
53
|
+
<header>
|
|
54
|
+
<b>Vectorize</b>
|
|
55
|
+
<span class="stat" id="title">—</span>
|
|
56
|
+
<span class="spacer"></span>
|
|
57
|
+
<span class="stat"><b id="nEl">0</b> elements · <b id="nTx">0</b> text</span>
|
|
58
|
+
<button class="primary" id="exportBtn" disabled>Export SVG</button>
|
|
59
|
+
</header>
|
|
60
|
+
<div id="wrap">
|
|
61
|
+
<div id="stage">
|
|
62
|
+
<svg id="svg" xmlns="http://www.w3.org/2000/svg"></svg>
|
|
63
|
+
<div id="state" class="show"><div class="spin"></div><div id="stateMsg">Loading drawing…</div></div>
|
|
64
|
+
<div id="zoombar">
|
|
65
|
+
<button class="ghost" id="fitBtn" title="Fit to view">Fit</button>
|
|
66
|
+
<input type="range" id="zoom" min="10" max="800" value="100" aria-label="Zoom">
|
|
67
|
+
<span id="zpct">100%</span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<aside>
|
|
71
|
+
<div>
|
|
72
|
+
<h3>Show</h3>
|
|
73
|
+
<label class="ck"><input type="checkbox" id="kLines" checked><span>Lines</span><span class="ct" id="cLines">0</span></label>
|
|
74
|
+
<label class="ck"><input type="checkbox" id="kCurves" checked><span>Curves</span><span class="ct" id="cCurves">0</span></label>
|
|
75
|
+
<label class="ck"><input type="checkbox" id="kText" checked><span>Text</span><span class="ct" id="cText">0</span></label>
|
|
76
|
+
</div>
|
|
77
|
+
<hr>
|
|
78
|
+
<div>
|
|
79
|
+
<h3>Layers</h3>
|
|
80
|
+
<div id="layers"><div class="muted">No layers in this drawing.</div></div>
|
|
81
|
+
</div>
|
|
82
|
+
<hr>
|
|
83
|
+
<div>
|
|
84
|
+
<h3>Confidence</h3>
|
|
85
|
+
<label class="ck" id="weakRow" hidden><input type="checkbox" id="weakOnly"><span>Isolate weak traces</span><span class="ct" id="cWeak">0</span></label>
|
|
86
|
+
<div class="muted" id="weakNote">No weak traces — read deterministically.</div>
|
|
87
|
+
</div>
|
|
88
|
+
</aside>
|
|
89
|
+
</div>
|
|
90
|
+
<script>
|
|
91
|
+
(function () {
|
|
92
|
+
'use strict';
|
|
93
|
+
const $ = (id) => document.getElementById(id);
|
|
94
|
+
const SVGNS = 'http://www.w3.org/2000/svg';
|
|
95
|
+
const svg = $('svg');
|
|
96
|
+
const params = new URLSearchParams(location.search);
|
|
97
|
+
const app = params.get('app');
|
|
98
|
+
const src = params.get('src') || (app ? ('/api/contract/' + encodeURIComponent(app)) : '/vector-example.json');
|
|
99
|
+
const WEAK = 0.5;
|
|
100
|
+
|
|
101
|
+
let sheet = null;
|
|
102
|
+
let view = { x: 0, y: 0, w: 100, h: 100 };
|
|
103
|
+
|
|
104
|
+
function showState(msg) { $('stateMsg').textContent = msg; $('state').classList.add('show'); $('state').querySelector('.spin').style.display = msg ? 'none' : ''; }
|
|
105
|
+
function hideState() { $('state').classList.remove('show'); }
|
|
106
|
+
|
|
107
|
+
// Build an SVG polyline path from explicit points (a line/polyline carrying pts but no d).
|
|
108
|
+
function ptsPath(pts) {
|
|
109
|
+
if (!pts || pts.length < 2) return '';
|
|
110
|
+
return pts.map((p, i) => (i === 0 ? 'M' : 'L') + (+p[0] || 0) + ' ' + (+p[1] || 0)).join(' ');
|
|
111
|
+
}
|
|
112
|
+
// Build one element as a real SVG DOM node (safe — setAttribute / textContent, no innerHTML).
|
|
113
|
+
function makeNode(el) {
|
|
114
|
+
const weak = (el.confidence != null && el.confidence < WEAK);
|
|
115
|
+
if (el.kind === 'text') {
|
|
116
|
+
if (!el.text) return null;
|
|
117
|
+
const ox = (el.origin && el.origin[0] != null) ? el.origin[0] : (el.bbox ? el.bbox[0] : 0);
|
|
118
|
+
const oy = (el.origin && el.origin[1] != null) ? el.origin[1] : (el.bbox ? (el.bbox[3] != null ? el.bbox[3] : el.bbox[1]) : 0);
|
|
119
|
+
const t = document.createElementNS(SVGNS, 'text');
|
|
120
|
+
t.setAttribute('x', ox); t.setAttribute('y', oy);
|
|
121
|
+
t.setAttribute('font-size', el.size != null ? el.size : 4);
|
|
122
|
+
t.setAttribute('fill', el.color || '#334155');
|
|
123
|
+
if (el.angle) t.setAttribute('transform', 'rotate(' + el.angle + ' ' + ox + ' ' + oy + ')');
|
|
124
|
+
t.textContent = el.text;
|
|
125
|
+
tag(t, el, 'text', weak);
|
|
126
|
+
return t;
|
|
127
|
+
}
|
|
128
|
+
const d = el.d || ptsPath(el.pts);
|
|
129
|
+
if (!d) return null;
|
|
130
|
+
const p = document.createElementNS(SVGNS, 'path');
|
|
131
|
+
p.setAttribute('d', d);
|
|
132
|
+
p.setAttribute('fill', 'none');
|
|
133
|
+
p.setAttribute('stroke', el.color || '#334155');
|
|
134
|
+
p.setAttribute('stroke-width', el.w || 1);
|
|
135
|
+
p.setAttribute('vector-effect', 'non-scaling-stroke');
|
|
136
|
+
if (el.dashed) p.setAttribute('stroke-dasharray', '3 2');
|
|
137
|
+
tag(p, el, (el.kind === 'line' || el.kind === 'polyline') ? 'lines' : 'curves', weak);
|
|
138
|
+
return p;
|
|
139
|
+
}
|
|
140
|
+
function tag(node, el, group, weak) {
|
|
141
|
+
node.setAttribute('class', 'el' + (weak ? ' weak' : ''));
|
|
142
|
+
node.dataset.kind = el.kind;
|
|
143
|
+
node.dataset.group = group;
|
|
144
|
+
node.dataset.layer = el.layer || '';
|
|
145
|
+
node.dataset.id = el.id || '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function render() {
|
|
149
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
150
|
+
const frag = document.createDocumentFragment();
|
|
151
|
+
for (const el of sheet.elements) { const n = makeNode(el); if (n) frag.appendChild(n); }
|
|
152
|
+
svg.appendChild(frag);
|
|
153
|
+
applyVisibility();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function counts() {
|
|
157
|
+
let lines = 0, curves = 0, text = 0, weak = 0;
|
|
158
|
+
for (const el of sheet.elements) {
|
|
159
|
+
if (el.kind === 'text') text++;
|
|
160
|
+
else if (el.kind === 'line' || el.kind === 'polyline') lines++;
|
|
161
|
+
else curves++;
|
|
162
|
+
if (el.confidence != null && el.confidence < WEAK) weak++;
|
|
163
|
+
}
|
|
164
|
+
return { lines, curves, text, weak };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function applyVisibility() {
|
|
168
|
+
const showLines = $('kLines').checked, showCurves = $('kCurves').checked, showText = $('kText').checked;
|
|
169
|
+
const off = new Set([...document.querySelectorAll('#layers input:not(:checked)')].map((i) => i.dataset.layer));
|
|
170
|
+
const weakOnly = $('weakOnly').checked;
|
|
171
|
+
for (const node of svg.children) {
|
|
172
|
+
const g = node.dataset.group;
|
|
173
|
+
let vis = g === 'text' ? showText : g === 'lines' ? showLines : showCurves;
|
|
174
|
+
if (vis && node.dataset.layer && off.has(node.dataset.layer)) vis = false;
|
|
175
|
+
if (vis && weakOnly) vis = node.classList.contains('weak');
|
|
176
|
+
node.classList.toggle('hidden', !vis);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function pageW() { return (sheet.page && sheet.page.w) || 100; }
|
|
181
|
+
function pageH() { return (sheet.page && sheet.page.h) || 100; }
|
|
182
|
+
function setViewBox() { svg.setAttribute('viewBox', view.x + ' ' + view.y + ' ' + view.w + ' ' + view.h); }
|
|
183
|
+
function baseScale() { const r = svg.getBoundingClientRect(); return Math.min(r.width / (pageW() * 1.08), r.height / (pageH() * 1.08)) || 1; }
|
|
184
|
+
function curScale() { const r = svg.getBoundingClientRect(); return (r.width / view.w) || 1; }
|
|
185
|
+
function syncZoom() { const pct = Math.round((curScale() / baseScale()) * 100); $('zoom').value = Math.max(10, Math.min(800, pct)); $('zpct').textContent = pct + '%'; }
|
|
186
|
+
function fit() { const m = Math.max(pageW(), pageH()) * 0.04; view = { x: -m, y: -m, w: pageW() + 2 * m, h: pageH() + 2 * m }; setViewBox(); syncZoom(); }
|
|
187
|
+
|
|
188
|
+
svg.addEventListener('wheel', (e) => {
|
|
189
|
+
e.preventDefault();
|
|
190
|
+
const r = svg.getBoundingClientRect();
|
|
191
|
+
const px = view.x + ((e.clientX - r.left) / r.width) * view.w;
|
|
192
|
+
const py = view.y + ((e.clientY - r.top) / r.height) * view.h;
|
|
193
|
+
const f = e.deltaY < 0 ? 0.9 : 1.1;
|
|
194
|
+
view.w *= f; view.h *= f;
|
|
195
|
+
view.x = px - ((e.clientX - r.left) / r.width) * view.w;
|
|
196
|
+
view.y = py - ((e.clientY - r.top) / r.height) * view.h;
|
|
197
|
+
setViewBox(); syncZoom();
|
|
198
|
+
}, { passive: false });
|
|
199
|
+
|
|
200
|
+
let pan = null;
|
|
201
|
+
svg.addEventListener('pointerdown', (e) => { pan = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; svg.setPointerCapture(e.pointerId); svg.classList.add('panning'); });
|
|
202
|
+
svg.addEventListener('pointermove', (e) => {
|
|
203
|
+
if (!pan) return;
|
|
204
|
+
const r = svg.getBoundingClientRect();
|
|
205
|
+
view.x = pan.vx - (e.clientX - pan.x) * (view.w / r.width);
|
|
206
|
+
view.y = pan.vy - (e.clientY - pan.y) * (view.h / r.height);
|
|
207
|
+
setViewBox();
|
|
208
|
+
});
|
|
209
|
+
const endPan = () => { pan = null; svg.classList.remove('panning'); };
|
|
210
|
+
svg.addEventListener('pointerup', endPan); svg.addEventListener('pointercancel', endPan);
|
|
211
|
+
|
|
212
|
+
$('zoom').addEventListener('input', (e) => {
|
|
213
|
+
const pct = +e.target.value, target = baseScale() * (pct / 100), r = svg.getBoundingClientRect();
|
|
214
|
+
const cx = view.x + view.w / 2, cy = view.y + view.h / 2;
|
|
215
|
+
view.w = r.width / target; view.h = r.height / target; view.x = cx - view.w / 2; view.y = cy - view.h / 2;
|
|
216
|
+
setViewBox(); $('zpct').textContent = pct + '%';
|
|
217
|
+
});
|
|
218
|
+
$('fitBtn').addEventListener('click', fit);
|
|
219
|
+
['kLines', 'kCurves', 'kText', 'weakOnly'].forEach((id) => $(id).addEventListener('change', applyVisibility));
|
|
220
|
+
|
|
221
|
+
// Export the visible drawing as a standalone SVG — clone the live nodes and serialize (no string HTML).
|
|
222
|
+
$('exportBtn').addEventListener('click', () => {
|
|
223
|
+
if (!sheet) return; // nothing loaded yet — guard against a null-sheet deref
|
|
224
|
+
const out = document.createElementNS(SVGNS, 'svg');
|
|
225
|
+
out.setAttribute('xmlns', SVGNS);
|
|
226
|
+
out.setAttribute('viewBox', '0 0 ' + pageW() + ' ' + pageH());
|
|
227
|
+
out.setAttribute('width', pageW()); out.setAttribute('height', pageH());
|
|
228
|
+
for (const node of svg.children) {
|
|
229
|
+
if (node.classList.contains('hidden')) continue;
|
|
230
|
+
const c = node.cloneNode(true);
|
|
231
|
+
c.removeAttribute('class');
|
|
232
|
+
out.appendChild(c);
|
|
233
|
+
}
|
|
234
|
+
const doc = new XMLSerializer().serializeToString(out);
|
|
235
|
+
const blob = new Blob([doc], { type: 'image/svg+xml' });
|
|
236
|
+
const a = document.createElement('a');
|
|
237
|
+
a.href = URL.createObjectURL(blob);
|
|
238
|
+
a.download = String((sheet.label || 'drawing')).replace(/[^a-z0-9._-]+/gi, '-') + '.svg';
|
|
239
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
240
|
+
URL.revokeObjectURL(a.href);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
function buildLayers() {
|
|
244
|
+
const box = $('layers');
|
|
245
|
+
while (box.firstChild) box.removeChild(box.firstChild);
|
|
246
|
+
const layers = (sheet.layers || []).filter((l) => l && l.name);
|
|
247
|
+
if (!layers.length) {
|
|
248
|
+
const d = document.createElement('div'); d.className = 'muted'; d.textContent = 'No layers in this drawing.'; box.appendChild(d); return;
|
|
249
|
+
}
|
|
250
|
+
for (const l of layers) {
|
|
251
|
+
const lab = document.createElement('label'); lab.className = 'ck';
|
|
252
|
+
const inp = document.createElement('input'); inp.type = 'checkbox'; inp.checked = true; inp.dataset.layer = l.name;
|
|
253
|
+
inp.addEventListener('change', applyVisibility);
|
|
254
|
+
const nm = document.createElement('span'); nm.textContent = l.name;
|
|
255
|
+
const ct = document.createElement('span'); ct.className = 'ct'; ct.textContent = String(l.count || 0);
|
|
256
|
+
lab.append(inp, nm, ct); box.appendChild(lab);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function load(contract) {
|
|
261
|
+
if (!contract || !Array.isArray(contract.sheets) || !contract.sheets.length) { showState('This app has no vectorized drawing yet. Drop a PDF and re-bake to read one.'); return; }
|
|
262
|
+
sheet = contract.sheets[contract.active || 0] || contract.sheets[0];
|
|
263
|
+
if (!sheet || !Array.isArray(sheet.elements)) { showState('The drawing has no elements.'); return; }
|
|
264
|
+
const c = counts();
|
|
265
|
+
const name = (contract.source && contract.source.name) ? contract.source.name : (sheet.label || 'drawing');
|
|
266
|
+
$('title').textContent = name;
|
|
267
|
+
document.title = 'Vectorize — ' + name;
|
|
268
|
+
$('nEl').textContent = sheet.elements.length; $('nTx').textContent = c.text;
|
|
269
|
+
$('cLines').textContent = c.lines; $('cCurves').textContent = c.curves; $('cText').textContent = c.text;
|
|
270
|
+
$('cWeak').textContent = c.weak;
|
|
271
|
+
$('weakNote').textContent = c.weak ? (c.weak + ' trace' + (c.weak === 1 ? '' : 's') + ' below ' + WEAK + ' confidence.') : 'No weak traces — read deterministically.';
|
|
272
|
+
$('weakRow').hidden = c.weak === 0; // hide the filter when there's nothing to filter (not disabled)
|
|
273
|
+
buildLayers();
|
|
274
|
+
render(); fit(); $('exportBtn').disabled = false; hideState();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const ctrl = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => ctrl.abort(), 15000);
|
|
279
|
+
fetch(src, { headers: { accept: 'application/json' }, signal: ctrl.signal })
|
|
280
|
+
.then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
281
|
+
.then(load)
|
|
282
|
+
.catch((e) => showState(e.name === 'AbortError' ? 'Loading timed out — is the server reachable?' : ('Could not load the drawing (' + e.message + ').')))
|
|
283
|
+
.finally(() => clearTimeout(timer));
|
|
284
|
+
|
|
285
|
+
window.addEventListener('resize', () => { if (sheet) syncZoom(); });
|
|
286
|
+
})();
|
|
287
|
+
</script>
|
|
288
|
+
</body>
|
|
289
|
+
</html>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "drawing.vector/v1",
|
|
3
|
+
"units": "mm",
|
|
4
|
+
"source": {
|
|
5
|
+
"name": "vectorize-detail.pdf",
|
|
6
|
+
"kind": "pdf",
|
|
7
|
+
"extractor": "pymupdf@compose-time"
|
|
8
|
+
},
|
|
9
|
+
"sheets": [
|
|
10
|
+
{
|
|
11
|
+
"id": "s1",
|
|
12
|
+
"label": "page 1",
|
|
13
|
+
"page": {
|
|
14
|
+
"w": 300,
|
|
15
|
+
"h": 220
|
|
16
|
+
},
|
|
17
|
+
"elements": [
|
|
18
|
+
{
|
|
19
|
+
"kind": "polyline",
|
|
20
|
+
"d": "M90.0 70.0 H210.0 V170.0 H90.0 Z",
|
|
21
|
+
"color": "#000000",
|
|
22
|
+
"w": 1.4,
|
|
23
|
+
"id": "e1"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"kind": "polyline",
|
|
27
|
+
"d": "M120.0 100.0 H180.0 V140.0 H120.0 Z",
|
|
28
|
+
"color": "#667084",
|
|
29
|
+
"w": 1,
|
|
30
|
+
"id": "e2"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"kind": "spline",
|
|
34
|
+
"d": "M100.0 85.0 C100.0 87.8 102.2 90.0 105.0 90.0 M105.0 90.0 C107.8 90.0 110.0 87.8 110.0 85.0 M110.0 85.0 C110.0 82.2 107.8 80.0 105.0 80.0 M105.0 80.0 C102.2 80.0 100.0 82.2 100.0 85.0",
|
|
35
|
+
"color": "#db2626",
|
|
36
|
+
"w": 1,
|
|
37
|
+
"id": "e3"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"kind": "spline",
|
|
41
|
+
"d": "M190.0 85.0 C190.0 87.8 192.2 90.0 195.0 90.0 M195.0 90.0 C197.8 90.0 200.0 87.8 200.0 85.0 M200.0 85.0 C200.0 82.2 197.8 80.0 195.0 80.0 M195.0 80.0 C192.2 80.0 190.0 82.2 190.0 85.0",
|
|
42
|
+
"color": "#db2626",
|
|
43
|
+
"w": 1,
|
|
44
|
+
"id": "e4"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"kind": "spline",
|
|
48
|
+
"d": "M100.0 155.0 C100.0 157.8 102.2 160.0 105.0 160.0 M105.0 160.0 C107.8 160.0 110.0 157.8 110.0 155.0 M110.0 155.0 C110.0 152.2 107.8 150.0 105.0 150.0 M105.0 150.0 C102.2 150.0 100.0 152.2 100.0 155.0",
|
|
49
|
+
"color": "#db2626",
|
|
50
|
+
"w": 1,
|
|
51
|
+
"id": "e5"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"kind": "spline",
|
|
55
|
+
"d": "M190.0 155.0 C190.0 157.8 192.2 160.0 195.0 160.0 M195.0 160.0 C197.8 160.0 200.0 157.8 200.0 155.0 M200.0 155.0 C200.0 152.2 197.8 150.0 195.0 150.0 M195.0 150.0 C192.2 150.0 190.0 152.2 190.0 155.0",
|
|
56
|
+
"color": "#db2626",
|
|
57
|
+
"w": 1,
|
|
58
|
+
"id": "e6"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"kind": "line",
|
|
62
|
+
"d": "M210.0 70.0 L250.0 55.0",
|
|
63
|
+
"color": "#667084",
|
|
64
|
+
"w": 0.8,
|
|
65
|
+
"id": "e7"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"kind": "text",
|
|
69
|
+
"text": "BASE PLATE PL1/2",
|
|
70
|
+
"bbox": [
|
|
71
|
+
96,
|
|
72
|
+
50.3,
|
|
73
|
+
177,
|
|
74
|
+
62.7
|
|
75
|
+
],
|
|
76
|
+
"origin": [
|
|
77
|
+
96,
|
|
78
|
+
60
|
|
79
|
+
],
|
|
80
|
+
"size": 9,
|
|
81
|
+
"color": "#000000",
|
|
82
|
+
"font": "Helvetica",
|
|
83
|
+
"id": "e8"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"kind": "text",
|
|
87
|
+
"text": "4x Ø22",
|
|
88
|
+
"bbox": [
|
|
89
|
+
214,
|
|
90
|
+
43.4,
|
|
91
|
+
239.8,
|
|
92
|
+
54.4
|
|
93
|
+
],
|
|
94
|
+
"origin": [
|
|
95
|
+
214,
|
|
96
|
+
52
|
|
97
|
+
],
|
|
98
|
+
"size": 8,
|
|
99
|
+
"color": "#db2626",
|
|
100
|
+
"font": "Helvetica",
|
|
101
|
+
"id": "e9"
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"layers": []
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|