@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.
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """vision.extract result -> drawing.vector/v1 contract (the Slice-2 raster path).
3
+
4
+ Takes the JSON that `aware agent invoke vision extract --json` printed (the invoke envelope
5
+ or the bare `{result: {elements: [...]}}`) plus the image it was extracted from, and emits a
6
+ raw drawing.vector/v1 contract ready to PUT to /api/contract/vectorize. The model traces in a
7
+ normalized 0..1000 frame (see references/vision-inputs.template.json); this script maps that
8
+ frame onto the image's real pixel size, carries each element's model-reported confidence
9
+ (the 2D editor flags traces below 0.5 for review), and assigns the ids the PUT schema requires.
10
+
11
+ Usage:
12
+
13
+ python vision_to_contract.py vision-out.json sketch.png --out contract.json
14
+ python vision_to_contract.py vision-out2.json page2.png --merge-into contract.json
15
+
16
+ --merge-into appends the extraction as the next sheet of an existing contract (multi-image sets).
17
+ Requires PyMuPDF (used only to read the image/PDF pixel size).
18
+ """
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import sys
25
+ from datetime import datetime, timezone
26
+
27
+ import fitz # PyMuPDF
28
+
29
+
30
+ def unwrap(payload):
31
+ """Accept the aware invoke envelope ({ok,data:{result,model}}) or the bare output."""
32
+ if payload.get("ok") is False:
33
+ sys.exit(f"vision.extract failed: {payload.get('error') or 'unknown error'}")
34
+ if isinstance(payload.get("data"), dict):
35
+ payload = payload["data"]
36
+ result = payload.get("result", payload)
37
+ model = payload.get("model", "?")
38
+ if not isinstance(result, dict) or "elements" not in result:
39
+ sys.exit("vision output has no result.elements — did the extraction fail?")
40
+ return result["elements"], model
41
+
42
+
43
+ def to_sheet(elements, image_path):
44
+ # Images only: a multi-page PDF has no single 0..1000 frame, so the caller rasterizes each
45
+ # PDF page to its own image first (SKILL.md step 3b) and runs one extraction per image.
46
+ if image_path.lower().endswith(".pdf"):
47
+ sys.exit("pass the rasterized image, not the PDF — export each PDF page to a PNG first")
48
+ # True pixel dimensions (a Pixmap ignores DPI metadata, which would skew the frame).
49
+ pix = fitz.Pixmap(image_path)
50
+ w, h = float(pix.width), float(pix.height)
51
+ sx, sy = w / 1000.0, h / 1000.0
52
+
53
+ out = []
54
+ for el in elements:
55
+ kind = el.get("kind")
56
+ conf = el.get("confidence")
57
+ if kind == "text":
58
+ bbox = el.get("bbox")
59
+ if not el.get("text") or not bbox or len(bbox) != 4:
60
+ continue
61
+ x0, y0, x1, y1 = (v * s for v, s in zip(bbox, (sx, sy, sx, sy)))
62
+ e = {
63
+ "kind": "text",
64
+ "text": el["text"],
65
+ "bbox": [round(v, 1) for v in (x0, y0, x1, y1)],
66
+ "origin": [round(x0, 1), round(y1, 1)], # baseline ~ bbox bottom-left
67
+ "size": round((y1 - y0) * 0.85, 1), # approx font size from box height
68
+ }
69
+ elif kind in ("line", "polyline"):
70
+ pts = el.get("pts") or []
71
+ if len(pts) < 2:
72
+ continue
73
+ e = {
74
+ "kind": "line" if len(pts) == 2 else "polyline",
75
+ "pts": [[round(p[0] * sx, 1), round(p[1] * sy, 1)] for p in pts],
76
+ }
77
+ if el.get("dashed"):
78
+ e["dashed"] = True
79
+ else:
80
+ continue
81
+ # Schema: confidence is a number when present; ABSENT means trusted. A model that omits
82
+ # it must not become `null` — one null fails the whole PUT (server validates every write).
83
+ if isinstance(conf, (int, float)):
84
+ e["confidence"] = max(0.0, min(1.0, conf))
85
+ out.append(e)
86
+ for i, el in enumerate(out):
87
+ el["id"] = f"e{i + 1}"
88
+ return {"page": {"w": round(w, 2), "h": round(h, 2)}, "elements": out}
89
+
90
+
91
+ def main():
92
+ for stream in (sys.stdout, sys.stderr):
93
+ try:
94
+ stream.reconfigure(encoding="utf-8")
95
+ except AttributeError:
96
+ pass
97
+ ap = argparse.ArgumentParser(description=__doc__)
98
+ ap.add_argument("vision_json", help="output of `aware agent invoke vision extract --json`")
99
+ ap.add_argument("image", help="the raster image the extraction ran on (rasterize PDF pages first)")
100
+ ap.add_argument("--out", help="write a NEW one-sheet contract here")
101
+ ap.add_argument("--merge-into", help="append as the next sheet of this existing contract")
102
+ args = ap.parse_args()
103
+ if bool(args.out) == bool(args.merge_into):
104
+ sys.exit("pass exactly one of --out / --merge-into")
105
+
106
+ with open(args.vision_json, encoding="utf-8") as f:
107
+ elements, model = unwrap(json.load(f))
108
+ sheet = to_sheet(elements, args.image)
109
+ stem = os.path.splitext(os.path.basename(args.image))[0]
110
+
111
+ if args.merge_into:
112
+ with open(args.merge_into, encoding="utf-8") as f:
113
+ contract = json.load(f)
114
+ if not isinstance(contract.get("sheets"), list):
115
+ sys.exit(f"{args.merge_into} is not a drawing.vector contract (no sheets[])")
116
+ used = {s.get("id") for s in contract["sheets"]}
117
+ n = len(contract["sheets"]) + 1
118
+ while f"s{n}" in used:
119
+ n += 1
120
+ sheet["id"] = f"s{n}"
121
+ sheet["label"] = stem
122
+ contract["sheets"].append(sheet)
123
+ path = args.merge_into
124
+ else:
125
+ sheet["id"] = "s1"
126
+ sheet["label"] = stem
127
+ with open(args.image, "rb") as f:
128
+ digest = hashlib.sha256(f.read()).hexdigest()
129
+ contract = {
130
+ "type": "drawing.vector/v1",
131
+ "units": "px", # no transform emitted -> the image's pixel frame is world 1:1
132
+ "source": {
133
+ "name": os.path.basename(args.image),
134
+ "path": os.path.abspath(args.image),
135
+ "kind": "image",
136
+ "sha256": digest,
137
+ "read_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
138
+ "extractor": f"vision.extract@{model}",
139
+ },
140
+ "sheets": [sheet],
141
+ }
142
+ path = args.out
143
+
144
+ with open(path, "w", encoding="utf-8") as f:
145
+ json.dump(contract, f, ensure_ascii=False)
146
+ weak = sum(1 for e in sheet["elements"] if (e.get("confidence") or 1) < 0.5)
147
+ print(f"{path}: sheet {sheet['id']} ({stem}), {len(sheet['elements'])} element(s), {weak} weak", file=sys.stderr)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -1,18 +1,22 @@
1
1
  app: vectorize
2
- version: 0.1.0
2
+ version: 0.2.0
3
3
  display-name: Vectorize
4
4
  publisher: floless
5
5
  description: |
6
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.
7
+ reads the drawing ONCE at compose time and bakes it into a drawing.vector contract: a vector PDF
8
+ is read deterministically (exact geometry, no AI guessing), while a photo, scan or hand sketch is
9
+ traced by a fenced vision extraction that marks every stroke it was unsure about for your review.
10
+ Double-click the node to open the 2D vector editor, where you pan and zoom, toggle what's shown,
11
+ review weak traces, and export SVG. Attach a drawing and use "Re-read & re-bake ▸" (or ask your
12
+ terminal AI) to vectorize your own; the deterministic run just re-renders a short summary of what
13
+ was read. Ships with a tiny example so it works out of the box.
12
14
  exposes-as-agent: false
13
15
  # Workflow changelog (FloLess-published). Newest first; the app shows entries newer than the installed
14
16
  # version when offering an update. Plain-English — it surfaces to the user.
15
17
  changelog:
18
+ - version: 0.2.0
19
+ summary: Photos, scans and hand sketches can now be vectorized too — unsure strokes are flagged for your review
16
20
  - version: 0.1.0
17
21
  summary: Vectorize a drawing into clean 2D linework you can edit and export
18
22
  inputs:
@@ -28,13 +32,17 @@ layout: linear
28
32
  nodes:
29
33
  # ── node 1: read — vectorize the drawing into clean 2D linework ───────────────
30
34
  # 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.
35
+ # attached drawing once at compose time: a vector PDF is read deterministically (exact
36
+ # geometry, no AI guessing), while a photo, scan or hand sketch is traced by a fenced
37
+ # vision extraction that flags every stroke it was unsure about. Every line, curve and
38
+ # label is baked into the contract. Double-click this node to open the 2D vector
39
+ # editor: pan and zoom the drawing, toggle Lines / Curves / Text, review each flagged
40
+ # trace (accept it as correct or delete it edits save automatically), Export SVG,
41
+ # and Approve to freeze the reviewed drawing into the runnable lock. `contract` tells
42
+ # FloLess which editor to open; `takeoff` is the single source the editor, Approve and
43
+ # the run all read. It ships with a tiny EXAMPLE so it renders out of the box —
44
+ # "Re-read & re-bake ▸" replaces it with your own drawing. The deterministic run
45
+ # re-renders the short summary below.
38
46
  - id: read
39
47
  agent: html-report
40
48
  command: render
package/dist/web/aware.js CHANGED
@@ -1360,14 +1360,18 @@
1360
1360
  // openContractEditor() returns false and the click would silently do nothing.
1361
1361
  if (contractType && window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[contractType]) {
1362
1362
  addNodeAction(card, 'Edit contract ▸', () => openContractEditor(currentId, contractType));
1363
- addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1364
- // Export/write group visually separated from the look-and-read actions above.
1365
- addNodeActionDivider(card);
1366
- addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1367
- addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1368
- addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1369
- const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1370
- teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1363
+ // The 3D/IFC/BOM/Tekla chain derives a scene from the contract — only contract types that
1364
+ // declare `scene` support it (a drawing.vector node would 4xx on these endpoints).
1365
+ if (window.CONTRACT_RENDERERS[contractType].scene) {
1366
+ addNodeAction(card, 'View 3D ▸', () => exportContract3d(currentId));
1367
+ // Export/write group visually separated from the look-and-read actions above.
1368
+ addNodeActionDivider(card);
1369
+ addNodeAction(card, 'Export IFC ▸', () => exportContractIfc(currentId));
1370
+ addNodeAction(card, 'Export BOM (CSV) ▸', () => exportContractBom(currentId, 'csv'));
1371
+ addNodeAction(card, 'Export BOM (Excel) ▸', () => exportContractBom(currentId, 'xlsx'));
1372
+ const teklaBtn = addNodeAction(card, 'Send to Tekla ▸', () => exportContractTekla(currentId));
1373
+ teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1374
+ }
1371
1375
  card.dataset.tip = 'Double-click to open the contract editor';
1372
1376
  }
1373
1377
  // Filter pre-stage node — opens the served steel-filter view (facets + eyedropper → contract.filter).
@@ -9,15 +9,16 @@
9
9
  * ========================================================================== */
10
10
 
11
11
  // contract type → descriptor for the editor surface.
12
+ // `scene: true` marks contracts that derive a 3D scene (contract-to-scene) — only those get the
13
+ // node-card View 3D / Export IFC / Export BOM / Send to Tekla chain; other contract types would
14
+ // 4xx or produce nonsense on those endpoints, so the shell hides the whole group.
12
15
  window.CONTRACT_RENDERERS = {
13
16
  'steel.takeoff/v1': {
14
17
  editorUrl: (appId) => '/steel-editor.html?app=' + encodeURIComponent(appId),
18
+ scene: true,
15
19
  },
16
20
  'drawing.vector/v1': {
17
21
  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
22
  },
22
23
  };
23
24
 
@@ -36,6 +36,7 @@ let soloGroups = new Set(); // profile keys isolated via the leg
36
36
  let isolatedIds = null; // Tekla "isolate selected": Set of ids shown exclusively, or null for off
37
37
  let connHidden = new Set(); // explicit per-PART hide (legend connection rows) — lets a shared part-kind (weld/nut) hide per-connection by id, not per group
38
38
  let cube = null; // ViewCube { renderer, scene, cam, mesh, faces }
39
+ let triad = null; // world-axis triad { renderer, scene, cam, group } — passive X/Y/Z readout
39
40
  const DRAG_TOL_PX = 4; // movement past this = a drag (not a click)
40
41
  const SNAP_TOL_PX = 10; // snap an endpoint to a target within this screen distance
41
42
  const FT_MM = 304.8; // mm per foot (the dimension readout shows feet, matching the editor)
@@ -150,6 +151,7 @@ function init(canvas, theApi) {
150
151
  window.addEventListener('keydown', onKey); // Tekla keyboard nav: arrows pan, Ctrl/Shift+arrows rotate
151
152
  ro = new ResizeObserver(resize); ro.observe(canvas.parentElement || canvas);
152
153
  initCube();
154
+ initTriad();
153
155
  // Resize once more on the next frame: the stage may still be laying out when init() runs (the
154
156
  // designer flagged a first-render size mismatch as the common rough edge here).
155
157
  requestAnimationFrame(resize);
@@ -181,6 +183,7 @@ function loop() {
181
183
  renderer.autoClear = true; renderer.clippingPlanes = saved;
182
184
  }
183
185
  if (cube) { syncCube(); cube.renderer.render(cube.scene, cube.cam); }
186
+ if (triad) { syncTriad(); triad.renderer.render(triad.scene, triad.cam); }
184
187
  }
185
188
 
186
189
  const V = (x, y, z) => new THREE.Vector3(x, y, z);
@@ -1414,6 +1417,46 @@ function initCube() {
1414
1417
  });
1415
1418
  cube = { renderer: cr, scene: cs, cam: cc, mesh };
1416
1419
  }
1420
+
1421
+ // ---- World-axis triad (Tekla-style) — a passive bottom-right gizmo showing where world X/Y/Z point.
1422
+ // Same sync-to-camera pattern as the ViewCube, but pointer-events:none: orientation CHANGES stay the
1423
+ // cube's job; this only reads out. Colors are the CAD convention (X red, Y green, Z blue = --brand).
1424
+ const TRIAD_AXES = [['X', '#ef4444', [1, 0, 0]], ['Y', '#22c55e', [0, 1, 0]], ['Z', '#3b82f6', [0, 0, 1]]];
1425
+ function triadTip(label, color, pos) { // free-standing colored letter with a dark halo — legible over any geometry, no disc
1426
+ const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d');
1427
+ g.font = 'bold 46px ui-sans-serif,system-ui,sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle';
1428
+ g.lineWidth = 8; g.lineJoin = 'round'; g.strokeStyle = 'rgba(2,8,23,.9)'; g.strokeText(label, 32, 34);
1429
+ g.fillStyle = color; g.fillText(label, 32, 34);
1430
+ const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c) }));
1431
+ s.position.copy(pos); s.scale.setScalar(0.85);
1432
+ return s;
1433
+ }
1434
+ function initTriad() {
1435
+ const host = document.getElementById('m3dAxes'); if (!host) return;
1436
+ const PX = 78;
1437
+ const tr = new THREE.WebGLRenderer({ antialias: true, alpha: true });
1438
+ tr.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); tr.setSize(PX, PX);
1439
+ host.appendChild(tr.domElement);
1440
+ const ts = new THREE.Scene();
1441
+ const tc = new THREE.OrthographicCamera(-2.1, 2.1, 2.1, -2.1, 0.1, 20); tc.position.set(0, 0, 5);
1442
+ const g = new THREE.Group();
1443
+ const Y = new THREE.Vector3(0, 1, 0); // Cylinder/ConeGeometry's own axis
1444
+ for (const [label, color, dir] of TRIAD_AXES) {
1445
+ const d = new THREE.Vector3(dir[0], dir[1], dir[2]);
1446
+ const mat = new THREE.MeshBasicMaterial({ color });
1447
+ const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 1.05, 8), mat);
1448
+ shaft.quaternion.setFromUnitVectors(Y, d);
1449
+ shaft.position.copy(d).multiplyScalar(0.525);
1450
+ const head = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.34, 12), mat); // arrowhead at the shaft end
1451
+ head.quaternion.copy(shaft.quaternion);
1452
+ head.position.copy(d).multiplyScalar(1.22);
1453
+ g.add(shaft, head, triadTip(label, color, d.clone().multiplyScalar(1.62)));
1454
+ }
1455
+ g.add(new THREE.Mesh(new THREE.SphereGeometry(0.1, 12, 8), new THREE.MeshBasicMaterial({ color: 0xe2e8f0 }))); // origin dot
1456
+ ts.add(g);
1457
+ triad = { renderer: tr, scene: ts, cam: tc, group: g };
1458
+ }
1459
+ function syncTriad() { triad.group.quaternion.copy(camera.quaternion).invert(); }
1417
1460
  // Mirror the scene from the main camera's direction (the FRONT face turns toward the viewer in a
1418
1461
  // front view, etc.) by orienting the cube by the inverse of the camera's world rotation.
1419
1462
  function syncCube() { cube.mesh.quaternion.copy(camera.quaternion).invert(); }
@@ -1826,10 +1869,12 @@ function dispose() {
1826
1869
  window.removeEventListener('keydown', onKey);
1827
1870
  for (const ovl of [readout, hoverChip, rubber, dimLabelHost]) if (ovl && ovl.parentNode) ovl.parentNode.removeChild(ovl);
1828
1871
  dimLabelHost = null; dimLabelPool.length = 0;
1829
- if (cube) {
1830
- cube.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
1831
- cube.renderer.dispose(); if (cube.renderer.domElement.parentNode) cube.renderer.domElement.parentNode.removeChild(cube.renderer.domElement); cube = null;
1872
+ for (const w of [cube, triad]) { // both mini-widgets own a WebGL context — leak one and re-init eventually hits the browser's context cap
1873
+ if (!w) continue;
1874
+ w.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
1875
+ w.renderer.dispose(); if (w.renderer.domElement.parentNode) w.renderer.domElement.parentNode.removeChild(w.renderer.domElement);
1832
1876
  }
1877
+ cube = triad = null;
1833
1878
  if (epGeom) epGeom.dispose(); if (epMatStart) epMatStart.dispose(); if (epMatEnd) epMatEnd.dispose();
1834
1879
  if (dims3dGroup) { if (scene) scene.remove(dims3dGroup); for (const c of dims3dGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // placed-dim lines
1835
1880
  if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines