@floless/app 0.60.0 → 0.62.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 +227 -5
- package/dist/schemas/drawing.vector.v1.schema.json +93 -5
- package/dist/skills/floless-app-vectorize/SKILL.md +68 -23
- package/dist/skills/floless-app-vectorize/references/vision-inputs.template.json +40 -0
- package/dist/skills/floless-app-vectorize/scripts/vision_to_contract.py +151 -0
- package/dist/templates/vectorize.flo +21 -13
- package/dist/web/aware.js +12 -8
- package/dist/web/renderers.js +4 -3
- package/dist/web/steel-3d-view.js +48 -3
- package/dist/web/steel-editor.html +341 -9
- package/dist/web/vector-editor.html +184 -7
- 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.62.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.62.0" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -54017,7 +54017,219 @@ function postProcess(contract, opts = {}) {
|
|
|
54017
54017
|
const groups = sheet.groups ? assignIds(sheet.groups, "g") : void 0;
|
|
54018
54018
|
return { ...sheet, id, elements, layers, ...groups ? { groups } : {} };
|
|
54019
54019
|
});
|
|
54020
|
-
|
|
54020
|
+
const datums = contract.datums ? assignIds(contract.datums, "dt") : void 0;
|
|
54021
|
+
const links = contract.links ? assignIds(contract.links, "lk") : void 0;
|
|
54022
|
+
return {
|
|
54023
|
+
...contract,
|
|
54024
|
+
type: "drawing.vector/v1",
|
|
54025
|
+
sheets,
|
|
54026
|
+
...datums ? { datums } : {},
|
|
54027
|
+
...links ? { links } : {}
|
|
54028
|
+
};
|
|
54029
|
+
}
|
|
54030
|
+
|
|
54031
|
+
// views-to-scene.ts
|
|
54032
|
+
function mapper(sheet) {
|
|
54033
|
+
const t = sheet.transform;
|
|
54034
|
+
if (!t || typeof t.scale !== "number" || !(t.scale > 0)) return null;
|
|
54035
|
+
const s = t.scale;
|
|
54036
|
+
const [ox, oy] = t.origin ?? [0, 0];
|
|
54037
|
+
const rot = (t.rotation ?? 0) * Math.PI / 180;
|
|
54038
|
+
const cos = Math.cos(rot);
|
|
54039
|
+
const sin = Math.sin(rot);
|
|
54040
|
+
return (p) => {
|
|
54041
|
+
const dx = p[0] - ox;
|
|
54042
|
+
const dy = p[1] - oy;
|
|
54043
|
+
const rx = dx * cos - dy * sin;
|
|
54044
|
+
const ry = dx * sin + dy * cos;
|
|
54045
|
+
return [rx * s + 0, -ry * s + 0];
|
|
54046
|
+
};
|
|
54047
|
+
}
|
|
54048
|
+
function uprightHorizontal(sheet) {
|
|
54049
|
+
const dir = sheet.view?.dir ?? "+y";
|
|
54050
|
+
if (dir === "+y") return { axis: "x", sign: 1 };
|
|
54051
|
+
if (dir === "-y") return { axis: "x", sign: -1 };
|
|
54052
|
+
if (dir === "+x") return { axis: "y", sign: -1 };
|
|
54053
|
+
if (dir === "-x") return { axis: "y", sign: 1 };
|
|
54054
|
+
return null;
|
|
54055
|
+
}
|
|
54056
|
+
function elevationZExtent(sheet, g) {
|
|
54057
|
+
const map = mapper(sheet);
|
|
54058
|
+
if (!map) return null;
|
|
54059
|
+
const pts = collectDisplayPoints(g, sheet);
|
|
54060
|
+
if (pts.length === 0) return null;
|
|
54061
|
+
let lo = Infinity;
|
|
54062
|
+
let hi = -Infinity;
|
|
54063
|
+
for (const p of pts) {
|
|
54064
|
+
const z = map(p)[1];
|
|
54065
|
+
if (z < lo) lo = z;
|
|
54066
|
+
if (z > hi) hi = z;
|
|
54067
|
+
}
|
|
54068
|
+
return lo <= hi ? [lo, hi] : null;
|
|
54069
|
+
}
|
|
54070
|
+
function worldInterval(sheet, g, component, sign = 1) {
|
|
54071
|
+
const map = mapper(sheet);
|
|
54072
|
+
if (!map) return null;
|
|
54073
|
+
const pts = collectDisplayPoints(g, sheet);
|
|
54074
|
+
if (pts.length === 0) return null;
|
|
54075
|
+
let lo = Infinity;
|
|
54076
|
+
let hi = -Infinity;
|
|
54077
|
+
for (const p of pts) {
|
|
54078
|
+
const v = map(p)[component] * sign;
|
|
54079
|
+
if (v < lo) lo = v;
|
|
54080
|
+
if (v > hi) hi = v;
|
|
54081
|
+
}
|
|
54082
|
+
return lo <= hi ? [lo, hi] : null;
|
|
54083
|
+
}
|
|
54084
|
+
function collectDisplayPoints(g, sheet) {
|
|
54085
|
+
if (g.footprint && g.footprint.length) return g.footprint;
|
|
54086
|
+
if (g.axis && g.axis.length === 2) return g.axis;
|
|
54087
|
+
const ids = new Set(g.elementIds ?? []);
|
|
54088
|
+
const pts = [];
|
|
54089
|
+
for (const el of sheet.elements ?? []) {
|
|
54090
|
+
if (!el.id || !ids.has(el.id)) continue;
|
|
54091
|
+
if (el.pts) pts.push(...el.pts);
|
|
54092
|
+
else if (el.bbox && el.bbox.length === 4) {
|
|
54093
|
+
pts.push([el.bbox[0], el.bbox[1]], [el.bbox[2], el.bbox[3]]);
|
|
54094
|
+
}
|
|
54095
|
+
}
|
|
54096
|
+
return pts;
|
|
54097
|
+
}
|
|
54098
|
+
function resolveDatum(v, datums, axis) {
|
|
54099
|
+
if (typeof v === "number") return v;
|
|
54100
|
+
if (typeof v === "string") {
|
|
54101
|
+
const d = datums.find((x) => x.id === v);
|
|
54102
|
+
return d && (d.axis ?? "z") === axis ? d.value : null;
|
|
54103
|
+
}
|
|
54104
|
+
return null;
|
|
54105
|
+
}
|
|
54106
|
+
var PALETTE = ["#3b82f6", "#f59e0b", "#10b981", "#8b5cf6", "#ef4444", "#14b8a6", "#eab308", "#64748b"];
|
|
54107
|
+
function overlapRatio(a, b) {
|
|
54108
|
+
const lo = Math.max(a[0], b[0]);
|
|
54109
|
+
const hi = Math.min(a[1], b[1]);
|
|
54110
|
+
if (hi <= lo) return 0;
|
|
54111
|
+
const shorter = Math.max(1e-9, Math.min(a[1] - a[0], b[1] - b[0]));
|
|
54112
|
+
return (hi - lo) / shorter;
|
|
54113
|
+
}
|
|
54114
|
+
function viewsToScene(contractInput) {
|
|
54115
|
+
const contract = contractInput ?? {};
|
|
54116
|
+
const sheets = contract.sheets ?? [];
|
|
54117
|
+
const datums = contract.datums ?? [];
|
|
54118
|
+
const links = contract.links ?? [];
|
|
54119
|
+
const elements = [];
|
|
54120
|
+
const skipped = [];
|
|
54121
|
+
const groupKeys = [];
|
|
54122
|
+
const seenKey = /* @__PURE__ */ new Set();
|
|
54123
|
+
const plans = sheets.filter((s) => s.view?.kind === "plan");
|
|
54124
|
+
const uprights = sheets.filter((s) => s.view?.kind === "elevation" || s.view?.kind === "section");
|
|
54125
|
+
const uprightEntities = [];
|
|
54126
|
+
for (const sh of uprights) {
|
|
54127
|
+
const hz = uprightHorizontal(sh);
|
|
54128
|
+
if (!hz) continue;
|
|
54129
|
+
for (const g of sh.groups ?? []) {
|
|
54130
|
+
const z = elevationZExtent(sh, g);
|
|
54131
|
+
const h = worldInterval(sh, g, 0, hz.sign);
|
|
54132
|
+
if (z && h) uprightEntities.push({ sheet: sh, g, z, h, axis: hz.axis });
|
|
54133
|
+
}
|
|
54134
|
+
}
|
|
54135
|
+
const linkedAway = /* @__PURE__ */ new Set();
|
|
54136
|
+
for (const l of links) {
|
|
54137
|
+
if (l.kind !== "same-entity") continue;
|
|
54138
|
+
linkedAway.add(`${l.a.sheet}/${l.a.id ?? ""}`);
|
|
54139
|
+
linkedAway.add(`${l.b.sheet}/${l.b.id ?? ""}`);
|
|
54140
|
+
}
|
|
54141
|
+
function resolveZ(planSheet, g) {
|
|
54142
|
+
if (g.extrude && (g.extrude.from !== void 0 || g.extrude.to !== void 0)) {
|
|
54143
|
+
const exFrom = resolveDatum(g.extrude.from, datums, "z");
|
|
54144
|
+
const exTo = resolveDatum(g.extrude.to, datums, "z");
|
|
54145
|
+
if (exFrom != null && exTo != null) return { z: [exFrom, exTo], how: "extrude" };
|
|
54146
|
+
return { error: "extrude reference unresolved \u2014 each end must be a number or the id of a z-axis datum" };
|
|
54147
|
+
}
|
|
54148
|
+
const key = { sheet: planSheet.id, id: g.id };
|
|
54149
|
+
for (const l of links) {
|
|
54150
|
+
if (l.kind !== "same-entity") continue;
|
|
54151
|
+
const other = l.a.sheet === key.sheet && l.a.id === key.id ? l.b : l.b.sheet === key.sheet && l.b.id === key.id ? l.a : null;
|
|
54152
|
+
if (!other) continue;
|
|
54153
|
+
const hit = uprightEntities.find((u) => u.sheet.id === other.sheet && u.g.id === other.id);
|
|
54154
|
+
if (hit) return { z: hit.z, how: "link" };
|
|
54155
|
+
}
|
|
54156
|
+
const mineX = worldInterval(planSheet, g, 0);
|
|
54157
|
+
const mineY = worldInterval(planSheet, g, 1);
|
|
54158
|
+
if (mineX || mineY) {
|
|
54159
|
+
const candidates = uprightEntities.filter((u) => !linkedAway.has(`${u.sheet.id}/${u.g.id ?? ""}`)).map((u) => {
|
|
54160
|
+
const mine = u.axis === "x" ? mineX : mineY;
|
|
54161
|
+
return { u, r: mine ? overlapRatio(mine, u.h) : 0 };
|
|
54162
|
+
}).filter((x) => x.r >= 0.8);
|
|
54163
|
+
if (candidates.length === 1) return { z: candidates[0].u.z, how: "match" };
|
|
54164
|
+
if (candidates.length > 1) return { ambiguous: candidates.length };
|
|
54165
|
+
}
|
|
54166
|
+
return null;
|
|
54167
|
+
}
|
|
54168
|
+
for (const plan of plans) {
|
|
54169
|
+
const map = mapper(plan);
|
|
54170
|
+
const sheetId = plan.id ?? "?";
|
|
54171
|
+
if (!map) {
|
|
54172
|
+
for (const g of plan.groups ?? []) skipped.push({ id: `${sheetId}/${g.id ?? "?"}`, reason: "sheet has no world transform (transform.scale required)" });
|
|
54173
|
+
continue;
|
|
54174
|
+
}
|
|
54175
|
+
for (const g of plan.groups ?? []) {
|
|
54176
|
+
const gid = `${sheetId}/${g.id ?? "?"}`;
|
|
54177
|
+
const isMember = !!(g.axis && g.section);
|
|
54178
|
+
const isFootprint = !!(g.footprint && g.footprint.length >= 3);
|
|
54179
|
+
if (!isMember && !isFootprint) continue;
|
|
54180
|
+
if (isMember && !(g.section.w > 0 && g.section.d > 0)) {
|
|
54181
|
+
skipped.push({ id: gid, reason: "invalid section \u2014 w and d must be positive world units" });
|
|
54182
|
+
continue;
|
|
54183
|
+
}
|
|
54184
|
+
const zr0 = resolveZ(plan, g);
|
|
54185
|
+
if (!zr0) {
|
|
54186
|
+
skipped.push({ id: gid, reason: "no Z extent \u2014 add extrude datums/values, a same-entity link, or a matching elevation entity" });
|
|
54187
|
+
continue;
|
|
54188
|
+
}
|
|
54189
|
+
if ("ambiguous" in zr0) {
|
|
54190
|
+
skipped.push({ id: gid, reason: `ambiguous Z \u2014 ${zr0.ambiguous} elevation entities share this span; add a same-entity link to pick one` });
|
|
54191
|
+
continue;
|
|
54192
|
+
}
|
|
54193
|
+
if ("error" in zr0) {
|
|
54194
|
+
skipped.push({ id: gid, reason: zr0.error });
|
|
54195
|
+
continue;
|
|
54196
|
+
}
|
|
54197
|
+
const zr = zr0;
|
|
54198
|
+
const key = g.kind || (isMember ? "member" : "footprint");
|
|
54199
|
+
if (!seenKey.has(key)) {
|
|
54200
|
+
seenKey.add(key);
|
|
54201
|
+
groupKeys.push(key);
|
|
54202
|
+
}
|
|
54203
|
+
if (isMember) {
|
|
54204
|
+
const [ax, ay] = map(g.axis[0]);
|
|
54205
|
+
const [bx, by] = map(g.axis[1]);
|
|
54206
|
+
const zFrom = zr.how === "extrude" ? zr.z[0] : (zr.z[0] + zr.z[1]) / 2;
|
|
54207
|
+
const zTo = zr.how === "extrude" ? zr.z[1] : zFrom;
|
|
54208
|
+
elements.push({
|
|
54209
|
+
id: g.id ?? gid,
|
|
54210
|
+
group: key,
|
|
54211
|
+
kind: "box",
|
|
54212
|
+
from: [ax, ay, zFrom],
|
|
54213
|
+
to: [bx, by, zTo],
|
|
54214
|
+
section: { w: g.section.w, d: g.section.d },
|
|
54215
|
+
meta: { sheet: sheetId, ...g.label ? { label: g.label } : {} }
|
|
54216
|
+
});
|
|
54217
|
+
} else {
|
|
54218
|
+
elements.push({
|
|
54219
|
+
id: g.id ?? gid,
|
|
54220
|
+
group: key,
|
|
54221
|
+
kind: "extrusion",
|
|
54222
|
+
footprint: g.footprint.map((p) => map(p)),
|
|
54223
|
+
from: Math.min(zr.z[0], zr.z[1]),
|
|
54224
|
+
to: Math.max(zr.z[0], zr.z[1]),
|
|
54225
|
+
meta: { sheet: sheetId, ...g.label ? { label: g.label } : {} }
|
|
54226
|
+
});
|
|
54227
|
+
}
|
|
54228
|
+
}
|
|
54229
|
+
}
|
|
54230
|
+
const groups = groupKeys.map((key, i) => ({ key, label: key, color: PALETTE[i % PALETTE.length] }));
|
|
54231
|
+
const name = contract.source && typeof contract.source.name === "string" ? String(contract.source.name) : "Reconstructed drawing";
|
|
54232
|
+
return { scene: { meta: { name, units: "mm", up: "z" }, groups, elements }, skipped };
|
|
54021
54233
|
}
|
|
54022
54234
|
|
|
54023
54235
|
// steel-joints.ts
|
|
@@ -54516,12 +54728,13 @@ function contractToScene(contractInput) {
|
|
|
54516
54728
|
const ptPerFt = plan.pt_per_ft && plan.pt_per_ft > 0 ? plan.pt_per_ft : 1;
|
|
54517
54729
|
const defaultTosMm = (plan.default_tos ?? 0) * IN_TO_MM;
|
|
54518
54730
|
for (const m of plan.members ?? []) {
|
|
54519
|
-
const
|
|
54731
|
+
const explicit = m.section && typeof m.section.w === "number" && m.section.w > 0 && typeof m.section.d === "number" && m.section.d > 0 ? { w: m.section.w, d: m.section.d, approx: false } : null;
|
|
54732
|
+
const dims = explicit ?? profileDims(m.profile);
|
|
54520
54733
|
if (!dims || !Array.isArray(m.wp) || m.wp.length < 2) {
|
|
54521
54734
|
skipped.push(m.id);
|
|
54522
54735
|
continue;
|
|
54523
54736
|
}
|
|
54524
|
-
const profile = (m.profile ?? "").trim().toUpperCase();
|
|
54737
|
+
const profile = (m.profile ?? "").trim().toUpperCase() || (explicit ? "CUSTOM" : "");
|
|
54525
54738
|
if (!seenProfile.has(profile)) {
|
|
54526
54739
|
seenProfile.add(profile);
|
|
54527
54740
|
profileOrder.push(profile);
|
|
@@ -63865,6 +64078,15 @@ async function startServer() {
|
|
|
63865
64078
|
async (req, reply) => {
|
|
63866
64079
|
const doc = req.body && "contract" in req.body ? req.body.contract : readContract(req.params.appId);
|
|
63867
64080
|
if (doc == null) return reply.status(404).send({ ok: false, error: "no contract to render" });
|
|
64081
|
+
if (doc && typeof doc === "object" && doc.type === "drawing.vector/v1") {
|
|
64082
|
+
const cleaned = postProcess(doc);
|
|
64083
|
+
const v2 = validateContract(cleaned);
|
|
64084
|
+
if (!v2.valid) {
|
|
64085
|
+
const first = v2.errors[0];
|
|
64086
|
+
return reply.status(400).send({ ok: false, error: `contract failed schema validation \u2014 ${first ? `${first.path}: ${first.message}` : "invalid"}` });
|
|
64087
|
+
}
|
|
64088
|
+
return { ok: true, ...viewsToScene(cleaned) };
|
|
64089
|
+
}
|
|
63868
64090
|
const v = validateSteelTakeoff(doc);
|
|
63869
64091
|
if (!v.valid) {
|
|
63870
64092
|
const first = v.errors[0];
|
|
@@ -27,6 +27,16 @@
|
|
|
27
27
|
"type": "array",
|
|
28
28
|
"items": { "$ref": "#/$defs/sheet" },
|
|
29
29
|
"description": "One entry per page/view. Multi-page PDFs and image sets each become a sheet."
|
|
30
|
+
},
|
|
31
|
+
"datums": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"items": { "$ref": "#/$defs/datum" },
|
|
34
|
+
"description": "READER-EMITTED world reference values (levels, grid lines) harvested from elevations/sections — the Z-ladder (or X/Y grid) that places 2D views in 3D. Top-level because a datum is a WORLD fact shared by every sheet; `sheet` records provenance. Multi-view reconstruction (views-to-scene) resolves groups[].extrude datum refs against these."
|
|
35
|
+
},
|
|
36
|
+
"links": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": { "$ref": "#/$defs/link" },
|
|
39
|
+
"description": "READER-EMITTED cross-view correspondence hints: which entities in two sheets are the same object, which mark is a section cut of which region (the steel callouts[] analog). Keys are {sheet,id} composites — element/group ids are only sheet-unique."
|
|
30
40
|
}
|
|
31
41
|
},
|
|
32
42
|
"$defs": {
|
|
@@ -57,7 +67,19 @@
|
|
|
57
67
|
"scale": { "type": "number", "description": "World units per display unit (e.g. mm per pt). Uniform." },
|
|
58
68
|
"origin": { "$ref": "#/$defs/point2", "description": "Display-space point mapped to world [0,0]." },
|
|
59
69
|
"units": { "type": "string", "description": "World units this maps into (overrides the top-level default for this sheet)." },
|
|
60
|
-
"rotation": { "type": "number", "description": "Degrees
|
|
70
|
+
"rotation": { "type": "number", "description": "Degrees to realign a rotated display frame with world axes. Applied by the reconstruction mapper as the standard math-positive matrix [cos -sin; sin cos] on display deltas about `origin` (display coords, Y-down), BEFORE scale and the Y-flip — i.e. content drawn rotated by R(-θ) is recovered with rotation: θ (locked by the views-to-scene rotation test)." }
|
|
71
|
+
},
|
|
72
|
+
"additionalProperties": true
|
|
73
|
+
},
|
|
74
|
+
"view": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"description": "READER-EMITTED view classification + third-axis binding — `transform` maps display->world in TWO axes but never says WHICH world axes; this does (a plan maps to X,Y at some Z; a south elevation maps to X,Z at some Y). Required for multi-view 3D reconstruction (views-to-scene); a sheet without it stays 2D-only. A wrong `dir`/`datum` silently mirrors or misplaces the model — the reader must cross-validate one known dimension per axis pair before emitting.",
|
|
77
|
+
"required": ["kind"],
|
|
78
|
+
"properties": {
|
|
79
|
+
"kind": { "enum": ["plan", "elevation", "section", "detail"], "description": "What this sheet IS. Only plan/elevation/section participate in reconstruction." },
|
|
80
|
+
"dir": { "enum": ["+z", "-z", "+x", "-x", "+y", "-y"], "description": "Observer look direction in world axes (a plan looks -z; a south elevation looks +y). Defaults: plan -z." },
|
|
81
|
+
"datum": { "type": "number", "description": "World offset (units) of the view plane along `dir`'s axis — plan: the projection Z; elevation/section: the plane's X or Y. Default 0." },
|
|
82
|
+
"depth": { "type": ["number", "null"], "description": "Section only: how far past the cut plane the view shows. null/absent = projection (shows everything)." }
|
|
61
83
|
},
|
|
62
84
|
"additionalProperties": true
|
|
63
85
|
},
|
|
@@ -91,7 +113,7 @@
|
|
|
91
113
|
"type": "object",
|
|
92
114
|
"required": ["id", "kind"],
|
|
93
115
|
"additionalProperties": true,
|
|
94
|
-
"description": "One drawing element. STABLE, sheet-unique `id
|
|
116
|
+
"description": "One drawing element. STABLE, sheet-unique `id`, REQUIRED on write (the store validates before the post-processor runs; the post-processor only repairs id collisions on read) — so every element can be selected, exported, and targeted for modification.",
|
|
95
117
|
"properties": {
|
|
96
118
|
"id": { "type": "string", "description": "Stable, sheet-unique element id (e.g. 'e12')." },
|
|
97
119
|
"kind": { "enum": ["line", "arc", "polyline", "spline", "text"], "description": "Geometry class (broader than steel's line/text)." },
|
|
@@ -115,13 +137,79 @@
|
|
|
115
137
|
"type": "object",
|
|
116
138
|
"required": ["id"],
|
|
117
139
|
"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.",
|
|
140
|
+
"description": "A named, ID-BEARING set of elements (a detected entity or a user grouping) — the unit an insert/modify Request can target, and the unit multi-view reconstruction lifts to 3D (via `section`/`axis` for member-like entities, `footprint`+`extrude` for extruded ones).",
|
|
119
141
|
"properties": {
|
|
120
142
|
"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." },
|
|
143
|
+
"kind": { "type": "string", "description": "Free-form entity kind (vocabulary-free; e.g. 'detail', 'wall', 'annotation', 'member', 'footprint'). The generic core does not enforce a taxonomy; views-to-scene keys on `section`/`axis`/`footprint`/`extrude` being present, not on kind." },
|
|
122
144
|
"label": { "type": "string" },
|
|
123
145
|
"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." }
|
|
146
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1, "description": "Reader-emitted grouping confidence 0..1." },
|
|
147
|
+
"section": {
|
|
148
|
+
"type": "object",
|
|
149
|
+
"required": ["w", "d"],
|
|
150
|
+
"description": "READER-EMITTED rectangular cross-section in WORLD units for a member-like entity — the vocabulary-free replacement for steel's AISC lookup; feeds the scene `section` as-is.",
|
|
151
|
+
"properties": { "w": { "type": "number", "exclusiveMinimum": 0 }, "d": { "type": "number", "exclusiveMinimum": 0 } },
|
|
152
|
+
"additionalProperties": true
|
|
153
|
+
},
|
|
154
|
+
"axis": {
|
|
155
|
+
"type": "array",
|
|
156
|
+
"items": { "$ref": "#/$defs/point2" },
|
|
157
|
+
"minItems": 2,
|
|
158
|
+
"maxItems": 2,
|
|
159
|
+
"description": "Member work-line [[x0,y0],[x1,y1]] in THIS SHEET's display coords (mapped to world via transform+view). Paired with `section`."
|
|
160
|
+
},
|
|
161
|
+
"footprint": {
|
|
162
|
+
"type": "array",
|
|
163
|
+
"items": { "$ref": "#/$defs/point2" },
|
|
164
|
+
"minItems": 3,
|
|
165
|
+
"description": "Closed outline (display coords, this sheet) of an extruded entity — a plate, wall, slab. Paired with `extrude`."
|
|
166
|
+
},
|
|
167
|
+
"extrude": {
|
|
168
|
+
"type": "object",
|
|
169
|
+
"description": "How far the entity extends along the sheet's missing axis (a plan's Z). Meaningful ONLY on PLAN-sheet groups — reconstruction builds geometry from plans; on elevation/section groups this field is ignored (those groups contribute extents, not bodies). Ends are datum ids (resolved against the top-level `datums`, z-axis only) or numbers (world units). views-to-scene can also INFER this by fold-line matching against a linked elevation when absent — but a PRESENT extrude that fails to resolve is an error, never silently replaced by inference.",
|
|
170
|
+
"properties": {
|
|
171
|
+
"from": { "type": ["string", "number"], "description": "Datum id or world value — the lower/near end." },
|
|
172
|
+
"to": { "type": ["string", "number"], "description": "Datum id or world value — the upper/far end." }
|
|
173
|
+
},
|
|
174
|
+
"additionalProperties": true
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"datum": {
|
|
179
|
+
"type": "object",
|
|
180
|
+
"required": ["id", "axis", "value"],
|
|
181
|
+
"additionalProperties": true,
|
|
182
|
+
"description": "One world reference value read off a drawing (a level line, a grid line). `value` is WORLD units on `axis`.",
|
|
183
|
+
"properties": {
|
|
184
|
+
"id": { "type": "string", "description": "Document-unique datum id (e.g. 'lv1') — groups[].extrude references it." },
|
|
185
|
+
"kind": { "enum": ["level", "grid"], "description": "level = a horizontal datum (Z); grid = a plan grid line (X or Y)." },
|
|
186
|
+
"label": { "type": "string", "description": "The as-drawn label (e.g. 'T.O.S. 16\\u2032-6\\u2033')." },
|
|
187
|
+
"axis": { "enum": ["x", "y", "z"], "description": "Which world axis the value is on." },
|
|
188
|
+
"value": { "type": "number", "description": "World units (the contract's canonical units)." },
|
|
189
|
+
"sheet": { "type": "string", "description": "Provenance: the sheet id this datum was read from." },
|
|
190
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
"link": {
|
|
194
|
+
"type": "object",
|
|
195
|
+
"required": ["kind", "a", "b"],
|
|
196
|
+
"additionalProperties": true,
|
|
197
|
+
"description": "A cross-view correspondence hint between two {sheet,id} targets (ids are only sheet-unique — always composite keys).",
|
|
198
|
+
"properties": {
|
|
199
|
+
"kind": { "enum": ["same-entity", "cut-line", "datum-ref"], "description": "same-entity = the two targets are one object seen in two views; cut-line = a section mark and the sheet it cuts; datum-ref = an entity bound to a datum." },
|
|
200
|
+
"a": { "$ref": "#/$defs/linkEnd" },
|
|
201
|
+
"b": { "$ref": "#/$defs/linkEnd" },
|
|
202
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"linkEnd": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"required": ["sheet"],
|
|
208
|
+
"additionalProperties": true,
|
|
209
|
+
"description": "One side of a link: a sheet and (usually) a group/element id on it.",
|
|
210
|
+
"properties": {
|
|
211
|
+
"sheet": { "type": "string" },
|
|
212
|
+
"id": { "type": "string", "description": "A groups[].id or elements[].id on that sheet; absent = the whole sheet. NOTE: v1 reconstruction resolves same-entity links between GROUP ids only — an element-level link is kept as data but does not drive Z resolution yet." }
|
|
125
213
|
}
|
|
126
214
|
},
|
|
127
215
|
"point2": {
|
|
@@ -1,26 +1,33 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
3
|
+
description: Vectorize a drawing into an editable drawing.vector/v1 contract in floless.app — the "Vectorize" reader. Triggers on a pending floless `rebake` request for the `vectorize` app (queued from its "Re-read & re-bake ▸" button), or asks like "vectorize this PDF", "turn my sketch into clean CAD linework", "read this detail into vectors", "trace this photo". Teaches the host AI to read a drawing at COMPOSE time — a vector PDF via ONE deterministic PyMuPDF pass (no LLM), a scan/photo/hand sketch via AWARE's fenced vision.extract (schema-bound, cached) — emit the drawing.vector/v1 contract, PUT it to the contract store, and hand the 2D vector editor to the user.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.3.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Vectorize — read a drawing into a drawing.vector/v1 contract
|
|
9
9
|
|
|
10
10
|
## What this is
|
|
11
11
|
|
|
12
|
-
`vectorize` is a FloLess app that turns a drawing
|
|
13
|
-
|
|
12
|
+
`vectorize` is a FloLess app that turns a drawing — a vector PDF, a scan, a photo, a hand
|
|
13
|
+
sketch — into clean, editable **2D vector linework**: a `drawing.vector/v1` contract
|
|
14
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,
|
|
15
|
+
user pans/zooms, toggles Lines/Curves/Text and layers, reviews weak traces, and exports SVG.
|
|
16
16
|
|
|
17
|
-
The drawing is read **at compose time
|
|
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.
|
|
17
|
+
The drawing is read **at compose time**, by one of two paths:
|
|
21
18
|
|
|
22
|
-
**
|
|
23
|
-
|
|
19
|
+
- **Vector PDF → deterministic.** One PyMuPDF pass; the geometry is exact, extracted, never
|
|
20
|
+
guessed. No model, no API key.
|
|
21
|
+
- **Raster (scan/photo/sketch) → AWARE `vision.extract`.** The substrate's fenced extraction
|
|
22
|
+
carve-out: a FIXED schema + prompt + pinned model, content-hash cached (same input → same
|
|
23
|
+
JSON, no repeat model call). Coordinates come from the model, flagged with per-element
|
|
24
|
+
confidence the editor surfaces for review — never from you eyeballing pixels.
|
|
25
|
+
|
|
26
|
+
There is **no model in the run path** either way. The app ships with a tiny hard-coded
|
|
27
|
+
example; this skill is how the user's OWN drawing replaces it.
|
|
28
|
+
|
|
29
|
+
**Later slices (mention as "next", do not attempt here):** multi-view 3D reconstruction,
|
|
30
|
+
DXF export.
|
|
24
31
|
|
|
25
32
|
> **Pasted a request?** If the user pastes a message beginning with a `[floless-request
|
|
26
33
|
> type=rebake id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it
|
|
@@ -39,14 +46,15 @@ prose, skip the lookup and proceed from that path. Resolve the port from the run
|
|
|
39
46
|
floless.app (check `~/.floless/port` or the active server port), as the other floless-app
|
|
40
47
|
skills do.
|
|
41
48
|
|
|
42
|
-
### 2.
|
|
49
|
+
### 2. Pick the path — vector or raster?
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
Probe the file: a PDF whose pages return `page.get_drawings()` paths is a **vector PDF** →
|
|
52
|
+
step 3a (deterministic, always preferred). An image file, or a PDF with no vector paths (a
|
|
53
|
+
scan, a photo, a hand sketch) → step 3b (vision). **Never trace pixels by eye into made-up
|
|
54
|
+
coordinates** — fabricated geometry is worse than no geometry; the raster path exists so the
|
|
55
|
+
extraction is fenced, cached, and confidence-flagged instead.
|
|
48
56
|
|
|
49
|
-
###
|
|
57
|
+
### 3a. Vector path — one deterministic PyMuPDF pass
|
|
50
58
|
|
|
51
59
|
Run the bundled extractor (it lives beside this file):
|
|
52
60
|
|
|
@@ -81,6 +89,36 @@ above is the contract. Hard schema requirements the server enforces on PUT: `typ
|
|
|
81
89
|
dedupe, snap, or derive layers yourself — the server post-processes every read of this
|
|
82
90
|
contract type.
|
|
83
91
|
|
|
92
|
+
### 3b. Raster path — AWARE `vision.extract` (the fenced exception)
|
|
93
|
+
|
|
94
|
+
For each raster image (or rasterized PDF page exported to an image):
|
|
95
|
+
|
|
96
|
+
1. Copy `references/vision-inputs.template.json` (beside this file) to a scratch file and set
|
|
97
|
+
**only** `"file"` to the image's absolute path. The `prompt`, `schema`, and pinned `model`
|
|
98
|
+
are FIXED — together with the file bytes they form the extraction's content-hash cache key
|
|
99
|
+
and its fence; do not tweak them per run.
|
|
100
|
+
2. Invoke the substrate:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
aware agent invoke vision extract --inputs @<scratch>/vision-inputs.json --json > <scratch>/vision-out.json
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A cache miss shells out to the local authenticated `claude`/`codex` CLI (no API key) and can
|
|
107
|
+
take a minute; a repeat of the same input replays from cache instantly. If neither CLI nor a
|
|
108
|
+
`vision-model` credential is available, stop and tell the user what to set up.
|
|
109
|
+
3. Convert to the contract (the model traces in a normalized 0..1000 frame; this maps it onto
|
|
110
|
+
the image's real pixel size and carries per-element confidence):
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
python <skill dir>/scripts/vision_to_contract.py <scratch>/vision-out.json <image> --out contract.json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For a multi-image set, run steps 1–3 per image and append with
|
|
117
|
+
`--merge-into contract.json` instead of `--out`.
|
|
118
|
+
4. Sanity-check the result before PUT: element count > 0, and the traced text you can see in
|
|
119
|
+
the vision output matches what the drawing actually says. A wildly wrong trace usually means
|
|
120
|
+
the image is too low-contrast — say so rather than shipping garbage.
|
|
121
|
+
|
|
84
122
|
### 4. Write to the contract store
|
|
85
123
|
|
|
86
124
|
```
|
|
@@ -101,10 +139,14 @@ Tell the user:
|
|
|
101
139
|
|
|
102
140
|
- **What was read** — e.g. "2 sheets, 214 lines, 12 curves, 96 text spans from `detail.pdf`".
|
|
103
141
|
- **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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
142
|
+
drawing — pan/zoom + Fit, Lines/Curves/Text toggles, per-layer toggles, weak-trace isolation,
|
|
143
|
+
and **Export SVG**. Deterministic reads have no weak traces; after a **vision** read, point
|
|
144
|
+
the user at the **Confidence** panel — each trace the model was unsure about (confidence
|
|
145
|
+
< 0.5) is listed there with **Accept** (mark correct) and **Delete** (remove, with Undo), and
|
|
146
|
+
a "Reviewed N/M" counter tracks progress. Edits auto-save to the contract store.
|
|
147
|
+
- **Approve & bake lock**: when satisfied, the user clicks Approve in the editor header — it
|
|
148
|
+
bakes the (edited) contract into the `read` node's `config.takeoff` and recompiles, arming
|
|
149
|
+
the Run gate. Never auto-approve on their behalf.
|
|
108
150
|
|
|
109
151
|
### 6. Clear the request
|
|
110
152
|
|
|
@@ -119,8 +161,11 @@ DELETE http://localhost:<port>/api/requests/<id>
|
|
|
119
161
|
- **Compose-time only.** The drawing is read HERE, by the terminal AI, then PUT to the store.
|
|
120
162
|
Never add a runtime node that reads the drawing — `aware app validate` rejects it, and it
|
|
121
163
|
breaks determinism.
|
|
122
|
-
- **Deterministic
|
|
123
|
-
|
|
164
|
+
- **Deterministic first, fenced second, eyeballs never.** Vector geometry is extracted
|
|
165
|
+
exactly; raster interpretation goes ONLY through `vision.extract` (fixed schema/prompt/model,
|
|
166
|
+
cached, confidence-flagged). Never hand-write coordinates you read off pixels yourself, and
|
|
167
|
+
never swap the vision path for your own vision — the fence is what makes the extraction
|
|
168
|
+
reproducible.
|
|
124
169
|
- **Thin-UI contract.** The browser recorded intent and the file path; the brain is the
|
|
125
170
|
terminal AI. The UI never reads the drawing and never writes the contract itself.
|
|
126
171
|
- **Stay in scope.** Producing and handing off the contract is the whole job — do not wire
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"file": "<FILL IN: absolute path to the raster image/PDF>",
|
|
3
|
+
"model": "claude-sonnet-5",
|
|
4
|
+
"prompt": "Trace every visible stroke and label in this drawing into vector geometry. Use a coordinate frame where x runs 0..1000 left to right and y runs 0..1000 top to bottom over the full image. Emit each straight stroke as kind 'line' (exactly two points) or a connected run of straight strokes as 'polyline' (a vertex chain); follow smooth curves with a 'polyline' of enough vertices to track the curve (at most 20). Emit every piece of text as kind 'text' with the text content and its bounding box [x0,y0,x1,y1]. Mark a stroke dashed:true only if it is visibly drawn dashed. Do NOT invent geometry that is not visibly drawn; skip shading, hatching texture, smudges and paper artifacts. For every element report confidence between 0 and 1 — report 0.5 or lower whenever you are unsure the stroke exists, its endpoints are ambiguous, or the text is hard to read.",
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["elements"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"elements": {
|
|
10
|
+
"type": "array",
|
|
11
|
+
"items": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["kind", "confidence"],
|
|
14
|
+
"allOf": [
|
|
15
|
+
{ "if": { "properties": { "kind": { "enum": ["line", "polyline"] } } }, "then": { "required": ["pts"] } },
|
|
16
|
+
{ "if": { "properties": { "kind": { "const": "text" } } }, "then": { "required": ["text", "bbox"] } }
|
|
17
|
+
],
|
|
18
|
+
"properties": {
|
|
19
|
+
"kind": { "enum": ["line", "polyline", "text"] },
|
|
20
|
+
"pts": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
|
23
|
+
"description": "Vertices in the 0..1000 frame (line/polyline)"
|
|
24
|
+
},
|
|
25
|
+
"text": { "type": "string" },
|
|
26
|
+
"bbox": {
|
|
27
|
+
"type": "array",
|
|
28
|
+
"items": { "type": "number" },
|
|
29
|
+
"minItems": 4,
|
|
30
|
+
"maxItems": 4,
|
|
31
|
+
"description": "[x0,y0,x1,y1] in the 0..1000 frame (text)"
|
|
32
|
+
},
|
|
33
|
+
"dashed": { "type": "boolean" },
|
|
34
|
+
"confidence": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|