eitri-hardware 0.6.0__py3-none-any.whl

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.
Files changed (186) hide show
  1. eitri/__init__.py +22 -0
  2. eitri/__main__.py +3 -0
  3. eitri/_render_cli.py +61 -0
  4. eitri/_runner.py +300 -0
  5. eitri/_worker.py +208 -0
  6. eitri/additive.py +176 -0
  7. eitri/agent/__init__.py +1 -0
  8. eitri/agent/api_backend.py +284 -0
  9. eitri/agent/build123d_ref.md +213 -0
  10. eitri/agent/claude_cli.py +369 -0
  11. eitri/agent/electronics_prompt.md +34 -0
  12. eitri/agent/electronics_ref.md +177 -0
  13. eitri/agent/firmware_prompt.md +35 -0
  14. eitri/agent/firmware_ref.md +127 -0
  15. eitri/agent/harness_prompt.md +33 -0
  16. eitri/agent/harness_ref.md +190 -0
  17. eitri/agent/hub.py +471 -0
  18. eitri/agent/library_docs.py +54 -0
  19. eitri/agent/mcp_server.py +155 -0
  20. eitri/agent/mechanism_prompt.md +32 -0
  21. eitri/agent/mechanism_ref.md +138 -0
  22. eitri/agent/pneumatics_prompt.md +32 -0
  23. eitri/agent/pneumatics_ref.md +136 -0
  24. eitri/agent/prompt.md +231 -0
  25. eitri/agent/prompt.py +59 -0
  26. eitri/agent/replay.py +159 -0
  27. eitri/agent/tools.py +608 -0
  28. eitri/anchors.py +147 -0
  29. eitri/assembly.py +231 -0
  30. eitri/cam.py +497 -0
  31. eitri/checks.py +43 -0
  32. eitri/cli.py +816 -0
  33. eitri/config.py +127 -0
  34. eitri/demos/__init__.py +152 -0
  35. eitri/demos/air-clamp/air_clamp.py +27 -0
  36. eitri/demos/marble-maze/demo.json +584 -0
  37. eitri/demos/marble-maze/project.tar.xz +0 -0
  38. eitri/demos/pan-tilt/pan_tilt.py +29 -0
  39. eitri/demos/quad-power/quad_power.py +57 -0
  40. eitri/demos/racket-holder/demo.json +319 -0
  41. eitri/demos/racket-holder/project.tar.xz +0 -0
  42. eitri/demos/servo-sweep/servo_sweep.py +54 -0
  43. eitri/demos/uno-arm-circuit/uno_arm_circuit.py +79 -0
  44. eitri/demos/v8-engine/demo.json +439 -0
  45. eitri/demos/v8-engine/project.tar.xz +0 -0
  46. eitri/diagram.py +562 -0
  47. eitri/direct.py +535 -0
  48. eitri/doctor.py +201 -0
  49. eitri/domains.py +93 -0
  50. eitri/drawing.py +1018 -0
  51. eitri/edits.py +2183 -0
  52. eitri/electronics/__init__.py +17 -0
  53. eitri/electronics/analysis.py +544 -0
  54. eitri/electronics/build.py +84 -0
  55. eitri/electronics/catalog.py +293 -0
  56. eitri/electronics/checks.py +947 -0
  57. eitri/electronics/diagram.py +410 -0
  58. eitri/electronics/domain.py +83 -0
  59. eitri/electronics/edits.py +194 -0
  60. eitri/electronics/inspect.py +70 -0
  61. eitri/electronics/model.py +614 -0
  62. eitri/electronics/nodal.py +237 -0
  63. eitri/electronics/outputs.py +73 -0
  64. eitri/electronics/report.py +158 -0
  65. eitri/electronics/sources.py +198 -0
  66. eitri/executor.py +162 -0
  67. eitri/exports.py +38 -0
  68. eitri/extensions.py +189 -0
  69. eitri/fastmesh.py +105 -0
  70. eitri/firmware/__init__.py +16 -0
  71. eitri/firmware/boards.py +121 -0
  72. eitri/firmware/build.py +113 -0
  73. eitri/firmware/checks.py +346 -0
  74. eitri/firmware/compile.py +122 -0
  75. eitri/firmware/diagram.py +116 -0
  76. eitri/firmware/domain.py +79 -0
  77. eitri/firmware/edits.py +100 -0
  78. eitri/firmware/inspect.py +53 -0
  79. eitri/firmware/model.py +263 -0
  80. eitri/firmware/outputs.py +40 -0
  81. eitri/firmware/report.py +116 -0
  82. eitri/form.py +449 -0
  83. eitri/harness/__init__.py +20 -0
  84. eitri/harness/build.py +89 -0
  85. eitri/harness/catalog.py +363 -0
  86. eitri/harness/checks.py +464 -0
  87. eitri/harness/diagram.py +349 -0
  88. eitri/harness/domain.py +82 -0
  89. eitri/harness/edits.py +175 -0
  90. eitri/harness/inspect.py +81 -0
  91. eitri/harness/model.py +551 -0
  92. eitri/harness/outputs.py +308 -0
  93. eitri/harness/report.py +138 -0
  94. eitri/kernel.py +348 -0
  95. eitri/lib/__init__.py +19 -0
  96. eitri/lib/bearings.py +52 -0
  97. eitri/lib/direct.py +93 -0
  98. eitri/lib/enclosure.py +70 -0
  99. eitri/lib/fasteners.py +80 -0
  100. eitri/lib/features.py +325 -0
  101. eitri/lib/parts.py +161 -0
  102. eitri/measure.py +731 -0
  103. eitri/mechanism/__init__.py +15 -0
  104. eitri/mechanism/build.py +82 -0
  105. eitri/mechanism/checks.py +355 -0
  106. eitri/mechanism/diagram.py +278 -0
  107. eitri/mechanism/domain.py +78 -0
  108. eitri/mechanism/edits.py +194 -0
  109. eitri/mechanism/inspect.py +77 -0
  110. eitri/mechanism/model.py +566 -0
  111. eitri/mechanism/outputs.py +84 -0
  112. eitri/mechanism/report.py +148 -0
  113. eitri/meshio.py +293 -0
  114. eitri/model.py +696 -0
  115. eitri/pneumatics/__init__.py +18 -0
  116. eitri/pneumatics/build.py +81 -0
  117. eitri/pneumatics/checks.py +474 -0
  118. eitri/pneumatics/diagram.py +501 -0
  119. eitri/pneumatics/domain.py +77 -0
  120. eitri/pneumatics/edits.py +130 -0
  121. eitri/pneumatics/flow.py +104 -0
  122. eitri/pneumatics/inspect.py +49 -0
  123. eitri/pneumatics/model.py +448 -0
  124. eitri/pneumatics/outputs.py +118 -0
  125. eitri/pneumatics/report.py +144 -0
  126. eitri/power.py +126 -0
  127. eitri/printexport.py +614 -0
  128. eitri/project.py +584 -0
  129. eitri/render.py +593 -0
  130. eitri/report.py +404 -0
  131. eitri/route.py +163 -0
  132. eitri/sandbox.py +169 -0
  133. eitri/setup_firmware.py +195 -0
  134. eitri/sheetmetal.py +335 -0
  135. eitri/sketch.py +1025 -0
  136. eitri/solidops.py +254 -0
  137. eitri/static.py +43 -0
  138. eitri/surfaces.py +351 -0
  139. eitri/update.py +284 -0
  140. eitri/viewer/__init__.py +1 -0
  141. eitri/viewer/index.html +4107 -0
  142. eitri/viewer/server.py +1041 -0
  143. eitri/viewer/vendor/three/LICENSE +21 -0
  144. eitri/viewer/vendor/three/addons/controls/OrbitControls.js +1417 -0
  145. eitri/viewer/vendor/three/addons/controls/TransformControls.js +1573 -0
  146. eitri/viewer/vendor/three/addons/environments/RoomEnvironment.js +148 -0
  147. eitri/viewer/vendor/three/addons/loaders/GLTFLoader.js +4663 -0
  148. eitri/viewer/vendor/three/addons/utils/BufferGeometryUtils.js +1375 -0
  149. eitri/viewer/vendor/three/three.module.js +53044 -0
  150. eitri/viewglb.py +748 -0
  151. eitri/web/__init__.py +1 -0
  152. eitri/web/app.py +650 -0
  153. eitri/web/assets/LICENSE-lucide.txt +15 -0
  154. eitri/web/assets/app.css +571 -0
  155. eitri/web/assets/design/README.md +207 -0
  156. eitri/web/assets/design/fonts/OFL-IBM-Plex.txt +93 -0
  157. eitri/web/assets/design/fonts/OFL-JetBrains-Mono.txt +93 -0
  158. eitri/web/assets/design/fonts/ibm-plex-sans-400.woff2 +0 -0
  159. eitri/web/assets/design/fonts/ibm-plex-sans-500.woff2 +0 -0
  160. eitri/web/assets/design/fonts/ibm-plex-sans-600.woff2 +0 -0
  161. eitri/web/assets/design/fonts/jetbrains-mono-400.woff2 +0 -0
  162. eitri/web/assets/design/fonts/jetbrains-mono-500.woff2 +0 -0
  163. eitri/web/assets/design/fonts.css +6 -0
  164. eitri/web/assets/design/glyphs.js +36 -0
  165. eitri/web/assets/design/shell.css +332 -0
  166. eitri/web/assets/design/shell.js +281 -0
  167. eitri/web/assets/design/tokens.css +49 -0
  168. eitri/web/assets/domain_editor.js +169 -0
  169. eitri/web/assets/electronics.js +163 -0
  170. eitri/web/assets/firmware.js +79 -0
  171. eitri/web/assets/harness.js +359 -0
  172. eitri/web/assets/icons.svg +89 -0
  173. eitri/web/assets/mark.svg +7 -0
  174. eitri/web/assets/mechanism.js +106 -0
  175. eitri/web/assets/pneumatics.js +96 -0
  176. eitri/web/assets/print.css +332 -0
  177. eitri/web/assets/print.js +979 -0
  178. eitri/web/assets/shell-host.js +71 -0
  179. eitri/web/home.html +584 -0
  180. eitri/web/message.html +33 -0
  181. eitri_hardware-0.6.0.dist-info/METADATA +1018 -0
  182. eitri_hardware-0.6.0.dist-info/RECORD +186 -0
  183. eitri_hardware-0.6.0.dist-info/WHEEL +5 -0
  184. eitri_hardware-0.6.0.dist-info/entry_points.txt +2 -0
  185. eitri_hardware-0.6.0.dist-info/licenses/LICENSE.md +110 -0
  186. eitri_hardware-0.6.0.dist-info/top_level.txt +1 -0
eitri/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """eitri - a Claude-Code-like agent for parametric CAD.
2
+
3
+ Model files start with `from eitri import *`, which gives them build123d (both
4
+ as `bd` and as bare names), the feature library and the measurement helpers.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ try:
9
+ import build123d as bd
10
+ except ImportError as _exc: # the one system library the kernel needs on Linux
11
+ if "libGL" not in str(_exc):
12
+ raise
13
+ raise ImportError(f"{_exc}. The CAD kernel (OpenCASCADE) needs the system OpenGL library: on Debian/Ubuntu "
14
+ "`sudo apt-get install libgl1`, on Fedora `sudo dnf install mesa-libGL`") from _exc
15
+ from build123d import * # noqa: F401,F403 - bare build123d names for model files
16
+
17
+ from .model import PALETTE, Params, Parts, Checker, srgb # noqa: F401
18
+ from .measure import * # noqa: F401,F403
19
+ from .lib import * # noqa: F401,F403
20
+ from .anchors import anchor # noqa: F401 - named points and frames of the model (eitri.anchors)
21
+
22
+ __version__ = "0.6.0"
eitri/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
eitri/_render_cli.py ADDED
@@ -0,0 +1,61 @@
1
+ """Subprocess entry point for extra views (view tool and `eitri view`)."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> int:
10
+ ap = argparse.ArgumentParser()
11
+ ap.add_argument("--model", required=True)
12
+ ap.add_argument("--out", required=True)
13
+ ap.add_argument("--views", default="")
14
+ ap.add_argument("--section", default="")
15
+ ap.add_argument("--exploded", action="store_true")
16
+ ap.add_argument("--hide", default="")
17
+ ap.add_argument("--only", default="")
18
+ ap.add_argument("--size", type=int, default=720)
19
+ ap.add_argument("--set", action="append", default=[])
20
+ ap.add_argument("--turntable", type=int, default=0, help="frames of a turntable GIF instead of a contact sheet")
21
+ args = ap.parse_args(argv)
22
+
23
+ from .model import Parts, load_model
24
+ from .render import contact_sheet, exploded_parts, section_sheet
25
+
26
+ overrides = dict(s.split("=", 1) for s in args.set)
27
+ model = load_model(args.model, overrides)
28
+ parts = model.build()
29
+ hide = {h for h in args.hide.split(",") if h}
30
+ only = {o for o in args.only.split(",") if o}
31
+ unknown = (hide | only) - set(parts.names)
32
+ if unknown:
33
+ print(f"unknown part names {sorted(unknown)}; parts are {parts.names}", file=sys.stderr)
34
+ return 2
35
+ if only:
36
+ parts = Parts([p for p in parts if p.name in only])
37
+ elif hide:
38
+ parts = Parts([p for p in parts if p.name not in hide])
39
+ if args.exploded:
40
+ parts = exploded_parts(parts)
41
+ w = args.size
42
+ cell = (w, int(w * 0.75))
43
+ if args.turntable:
44
+ from .render import turntable
45
+ turntable(parts, args.out, frames=args.turntable, size=(w, int(w * 0.75)))
46
+ print(f"turntable GIF, {args.turntable} frames; parts shown: {parts.names}" + (" (exploded)" if args.exploded else ""))
47
+ return 0
48
+ if args.section:
49
+ plane, _, off = args.section.partition(":")
50
+ offset = float(off) if off else 0.0
51
+ section_sheet(parts, args.out, plane=plane.upper(), offset=offset, cell=cell)
52
+ print(f"section {plane.upper()} at {offset:g}: cut faces are red; parts shown: {parts.names}")
53
+ else:
54
+ views = tuple(v for v in args.views.split(",") if v) or ("iso_back", "bottom")
55
+ contact_sheet(parts, args.out, views=views, cell=cell, title=Path(args.model).stem)
56
+ print(f"views {', '.join(views)}; parts shown: {parts.names}" + (" (exploded)" if args.exploded else ""))
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ sys.exit(main())
eitri/_runner.py ADDED
@@ -0,0 +1,300 @@
1
+ """Subprocess entry point: build one model file and write result.json.
2
+
3
+ Invoked by eitri.kernel.build_model; not meant to be called by hand.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import sys
11
+ import time
12
+ import traceback
13
+ from dataclasses import asdict
14
+ from pathlib import Path
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ ap = argparse.ArgumentParser()
19
+ ap.add_argument("--model", required=True)
20
+ ap.add_argument("--out", required=True)
21
+ ap.add_argument("--formats", default="step,glb")
22
+ ap.add_argument("--views", default="iso,front,top,right")
23
+ ap.add_argument("--quality", type=float, default=1.0)
24
+ ap.add_argument("--wall-samples", type=int, default=600)
25
+ ap.add_argument("--no-render", action="store_true")
26
+ ap.add_argument("--set", action="append", default=[], help="param=value override")
27
+ ap.add_argument("--guards", default="", help="JSON: what the project remembers for this model's extensions (eitri.extensions)")
28
+ ap.add_argument("--max-parts", type=int, default=0, help="refuse a model with more parts than this (0: no limit)")
29
+ args = ap.parse_args(argv)
30
+
31
+ out_dir = Path(args.out)
32
+ out_dir.mkdir(parents=True, exist_ok=True)
33
+ model_path = Path(args.model)
34
+ result: dict = {"ok": False, "outputs": {}, "params": {}, "report": None, "summary": "", "error": None}
35
+
36
+ def finish(code: int) -> int:
37
+ tmp = out_dir / "result.json.tmp" # replaced in one step: a reader never sees half a file
38
+ tmp.write_text(json.dumps(result, indent=1, default=str), encoding="utf-8")
39
+ os.replace(tmp, out_dir / "result.json")
40
+ _Stages(out_dir)("done", done=True)
41
+ return code
42
+
43
+ overrides = {}
44
+ for item in args.set:
45
+ if "=" not in item:
46
+ result["error"] = f"bad --set {item!r}; use name=value"
47
+ return finish(2)
48
+ k, v = item.split("=", 1)
49
+ overrides[k.strip()] = v.strip()
50
+
51
+ stage = _Stages(out_dir)
52
+ stage("building the geometry")
53
+ t0 = time.time()
54
+ try:
55
+ from .model import load_model
56
+ model = load_model(model_path, overrides)
57
+ result["params"] = {k: v for k, v in model.params.items()}
58
+ parts = model.build()
59
+ except Exception:
60
+ result["error"] = _clean_traceback(traceback.format_exc(), model_path)
61
+ return finish(1)
62
+ from .domains import kind_of_object, module as domain_module
63
+ kind = kind_of_object(parts)
64
+ if kind: # another domain (harness, mechanism, ...): no geometry, its own build
65
+ result["build_seconds"] = round(time.time() - t0, 2)
66
+ result["timings"] = {"build": result["build_seconds"]}
67
+ domain_module(kind).build(model, parts, model_path, out_dir, result, stage, _clean_traceback, render=not args.no_render,
68
+ guards=json.loads(args.guards) if args.guards else {})
69
+ result["ok"] = result["error"] is None
70
+ return finish(0 if result["ok"] else 1)
71
+ if args.max_parts and len(parts) > args.max_parts:
72
+ result["error"] = (f"the model has {len(parts)} parts; this deployment allows at most {args.max_parts} per assembly "
73
+ "(combine parts, or build the assembly in smaller projects)")
74
+ return finish(1)
75
+ build_seconds = time.time() - t0
76
+ result["build_seconds"] = round(build_seconds, 2)
77
+ timings = result["timings"] = {"build": round(build_seconds, 3)}
78
+ # exact-geometry fingerprints, taken before meshing: the analysis reuses what it measured on unchanged parts
79
+ from .report import fingerprint
80
+ fingerprints = {p.name: fingerprint(p.shape) for p in parts}
81
+
82
+ # the viewer's model first, announced as a preview: the page shows the new geometry at once while the
83
+ # checks, the exports and the review render (the slow part) are still running
84
+ stage("making the 3D view")
85
+ t = time.time()
86
+ try:
87
+ from .viewglb import write_view_glb
88
+ vg = out_dir / f"{model_path.stem}.view.glb"
89
+ tj = out_dir / f"{model_path.stem}.topology.json"
90
+ pg = out_dir / f"{model_path.stem}.partial.glb"
91
+ pt = out_dir / f"{model_path.stem}.partial.topology.json"
92
+
93
+ def partial(glb: bytes, topo, done: int, total: int) -> None:
94
+ """The parts meshed so far, shown by the page while the rest are meshed (an assembly appears part
95
+ by part; build(p) itself makes every part in one call, so this is as early as parts can appear)."""
96
+ _write_atomic(pg, glb)
97
+ _write_atomic(pt, json.dumps(topo.to_dict()).encode())
98
+ _announce_preview(out_dir, pg, pt, parts_done=done, parts_total=total)
99
+
100
+ # how often a batch of meshed parts is shown while the rest are meshed: EITRI_PREVIEW_EVERY_S seconds
101
+ # (default 1) or EITRI_PREVIEW_EVERY_PARTS parts (default off), whichever comes first
102
+ every_s = _env_float("EITRI_PREVIEW_EVERY_S", 1.0)
103
+ every_parts = int(_env_float("EITRI_PREVIEW_EVERY_PARTS", 0))
104
+ write_view_glb(parts, vg, tj, quality=args.quality, fingerprints=fingerprints, progress=stage, on_partial=partial,
105
+ partial_every=every_s, partial_parts=every_parts)
106
+ result["outputs"]["view_glb"] = str(vg)
107
+ result["outputs"]["topology"] = str(tj)
108
+ _announce_preview(out_dir, vg, tj)
109
+ for f in (pg, pt):
110
+ f.unlink(missing_ok=True)
111
+ except Exception:
112
+ result["error"] = (result.get("error") or "") + "\nviewer export failed:\n" + _clean_traceback(traceback.format_exc(), model_path)
113
+ timings["view"] = round(time.time() - t, 3)
114
+
115
+ stage("running the model's own checks")
116
+ t = time.time()
117
+ try:
118
+ checker = model.verify(parts)
119
+ except Exception:
120
+ from .model import Checker
121
+ checker = Checker()
122
+ checker.true("verify() ran without error", False, _clean_traceback(traceback.format_exc(), model_path, short=True))
123
+ timings["verify"] = round(time.time() - t, 3)
124
+
125
+ stage("exporting " + ", ".join(f.upper() for f in args.formats.split(",") if f))
126
+ t = time.time()
127
+ export_note = ""
128
+ try:
129
+ from .kernel import export_parts
130
+ formats = tuple(f for f in args.formats.split(",") if f)
131
+ outputs = export_parts(parts, out_dir, model_path.stem, formats)
132
+ export_note = outputs.pop("notes", "")
133
+ result["outputs"].update(outputs)
134
+ except Exception:
135
+ result["error"] = "export failed:\n" + _clean_traceback(traceback.format_exc(), model_path)
136
+ timings["export"] = round(time.time() - t, 3)
137
+
138
+ t = time.time()
139
+ try:
140
+ from .report import analyze, summarize
141
+ report = analyze(parts, checker, wall_samples=args.wall_samples, progress=stage, fingerprints=fingerprints)
142
+ _run_extensions(result, report, "cad", model, parts, model_path, out_dir, args.guards, stage)
143
+ if export_note:
144
+ report.issues.append(export_note) if "LOST" in export_note else None
145
+ for note in model.edit_notes:
146
+ if not note.ok:
147
+ report.issues.append(f"direct edit failed: {note.line()}")
148
+ result["report"] = report.to_dict()
149
+ from .anchors import describe as describe_anchors, to_json as anchors_json
150
+ result["anchors"] = anchors_json(parts)
151
+ summary = summarize(report) + (f"\n{export_note}" if export_note else "")
152
+ if result["anchors"]:
153
+ summary += "\n" + describe_anchors(result["anchors"])
154
+ if model.edit_notes:
155
+ summary += "\nDirect edits (EDITS in the model file):\n" + "\n".join(" " + n.line() for n in model.edit_notes)
156
+ result["summary"] = summary
157
+ result["edit_notes"] = [n.__dict__ for n in model.edit_notes]
158
+ result["joints"] = list(getattr(parts, "joints", []) or [])
159
+ result["sheets"] = dict(getattr(parts, "sheets", {}) or {})
160
+ result["flats"] = dict(getattr(parts, "flats", {}) or {})
161
+ result["forms"] = dict(getattr(parts, "forms", {}) or {})
162
+ result["groups"] = {q.name: q.group for q in parts if getattr(q, "group", None)}
163
+ except Exception:
164
+ result["error"] = (result.get("error") or "") + "\nanalysis failed:\n" + _clean_traceback(traceback.format_exc(), model_path)
165
+ timings["analyze"] = round(time.time() - t, 3)
166
+
167
+ t = time.time()
168
+ if not args.no_render:
169
+ stage("rendering the review image")
170
+ try:
171
+ from .render import contact_sheet
172
+ views = tuple(v for v in args.views.split(",") if v)
173
+ png = out_dir / f"{model_path.stem}.png"
174
+ contact_sheet(parts, str(png), views=views, quality=args.quality, title=model_path.stem)
175
+ result["outputs"]["png"] = str(png)
176
+ except Exception:
177
+ result["error"] = (result.get("error") or "") + "\nrender failed:\n" + _clean_traceback(traceback.format_exc(), model_path)
178
+ timings["render"] = round(time.time() - t, 3)
179
+
180
+ result["ok"] = result["error"] is None
181
+ return finish(0 if result["ok"] else 1)
182
+
183
+
184
+ def _run_extensions(result: dict, report, kind: str, model, built, model_path: Path, out_dir: Path, guards_json: str, stage) -> None:
185
+ """Registered build extensions (eitri.extensions): their checks join the report; their data and guards go in the result."""
186
+ from . import extensions
187
+ try:
188
+ ctx = extensions.BuildContext(model_path, out_dir, kind, model.module, dict(model.params), built, report, result,
189
+ json.loads(guards_json) if guards_json else {}, stage)
190
+ checks, data, guards = extensions.run(ctx)
191
+ except Exception:
192
+ report.issues.append("build extensions failed to run: " + _clean_traceback(traceback.format_exc(), model_path, short=True))
193
+ return
194
+ report.checks.extend(checks)
195
+ if data:
196
+ result["extensions"] = data
197
+ if guards:
198
+ result["guards"] = guards
199
+
200
+
201
+ class _Stages:
202
+ """out/progress.json: what the build is doing now, for the page (written when the stage changes)."""
203
+
204
+ def __init__(self, out_dir: Path):
205
+ self.out_dir, self.last = out_dir, None
206
+
207
+ def __call__(self, message: str, done: bool = False) -> None:
208
+ if message == self.last:
209
+ return
210
+ self.last = message
211
+ try:
212
+ tmp = self.out_dir / "progress.json.tmp"
213
+ tmp.write_text(json.dumps({"stage": message, "ts": time.time(), "done": done}), encoding="utf-8")
214
+ os.replace(tmp, self.out_dir / "progress.json")
215
+ except OSError:
216
+ pass
217
+
218
+
219
+ def _env_float(name: str, default: float) -> float:
220
+ try:
221
+ return float(os.environ.get(name, "") or default)
222
+ except ValueError:
223
+ return default
224
+
225
+
226
+ def _write_atomic(path: Path, data: bytes) -> None:
227
+ tmp = path.with_name(path.name + ".tmp")
228
+ tmp.write_bytes(data)
229
+ os.replace(tmp, path)
230
+
231
+
232
+ def _announce_preview(out_dir: Path, glb: Path, topo: Path, parts_done: int | None = None, parts_total: int | None = None) -> None:
233
+ """out/preview.json: the newest geometry, before the build is checked and recorded as a revision (with
234
+ parts_done/parts_total while an assembly is still being meshed)."""
235
+ data = {"glb": glb.name, "topology": topo.name, "ts": time.time()}
236
+ if parts_total:
237
+ data.update(parts_done=parts_done, parts_total=parts_total)
238
+ tmp = out_dir / "preview.json.tmp"
239
+ tmp.write_text(json.dumps(data), encoding="utf-8")
240
+ os.replace(tmp, out_dir / "preview.json")
241
+
242
+
243
+ def _clean_traceback(tb: str, model_path: Path, short: bool = False) -> str:
244
+ """Keep the frames that are in the model file; drop eitri/importlib noise."""
245
+ lines = tb.splitlines()
246
+ keep: list[str] = []
247
+ model_name = model_path.name
248
+ i = 0
249
+ while i < len(lines):
250
+ line = lines[i]
251
+ if line.startswith(" File "):
252
+ in_model = model_name in line or "/build123d/" in line or "/OCP/" in line
253
+ if in_model:
254
+ keep.append(line)
255
+ if i + 1 < len(lines) and not lines[i + 1].startswith(" File "):
256
+ keep.append(lines[i + 1])
257
+ i += 1
258
+ else:
259
+ if i + 1 < len(lines) and not lines[i + 1].startswith(" File ") and not lines[i + 1].startswith("Traceback"):
260
+ i += 1
261
+ i += 1
262
+ continue
263
+ if line.startswith("Traceback"):
264
+ i += 1
265
+ continue
266
+ keep.append(line)
267
+ i += 1
268
+ text = "\n".join(keep).strip()
269
+ if short:
270
+ return text.splitlines()[-1] if text else tb.strip().splitlines()[-1]
271
+ text = text or tb
272
+ hint = _hint_for(tb)
273
+ return text + ("\n\nHint: " + hint if hint else "")
274
+
275
+
276
+ def _hint_for(tb: str) -> str | None:
277
+ """Turn the kernel's opaque errors into advice."""
278
+ low = tb.lower()
279
+ if "fillet" in low or "chamfer" in low:
280
+ return ("the fillet/chamfer failed in the kernel. Usual causes: radius larger than an adjacent face or wall, "
281
+ "edges that meet tangent faces (cylinder seams, previous fillets), or tiny faces left by a boolean. "
282
+ "Use a smaller radius, select fewer edges, fillet the 2D sketch before extruding, or try_fillet(body, "
283
+ "edges, r, fallbacks=(...)).")
284
+ if "command not done" in low or "brep_api" in low:
285
+ return ("an OCCT operation failed (BRep_API: command not done). Check that booleans have overlapping, "
286
+ "non-coincident faces (extend cutters past walls by 1 mm), that lofted sections have the same "
287
+ "orientation, and that sweeps/revolves do not self-intersect.")
288
+ if "no attribute 'to_tuple'" in low:
289
+ return "Vector has no to_tuple() here; use tuple(v) or v.X/v.Y/v.Z."
290
+ if "object has no attribute" in low and "'shapelist'" in low:
291
+ return "ShapeList methods: filter_by(Axis|GeomType|callable), sort_by(Axis|SortBy), group_by(Axis)[i], first/last."
292
+ if "unexpected keyword argument 'align'" in low:
293
+ return "this object takes no align=; place it with Pos(...) * shape instead."
294
+ if "zero_vector" in low or "zero length" in low or ("null" in low and "direction" in low):
295
+ return "a degenerate (zero-length) vector or edge was created; check for coincident points or zero dimensions."
296
+ return None
297
+
298
+
299
+ if __name__ == "__main__":
300
+ sys.exit(main())
eitri/_worker.py ADDED
@@ -0,0 +1,208 @@
1
+ """A warm build worker: one long-lived subprocess that imports the CAD kernel once.
2
+
3
+ The REPL, the MCP server and the viewer each own one worker (it dies with them;
4
+ there is no global daemon, no state folder). A build request is one JSON line on
5
+ the worker's stdin; the worker runs exactly what `eitri._runner` runs and
6
+ answers with one JSON line. If the kernel crashes or a build times out, the
7
+ parent kills the worker and starts a fresh one on the next build.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ def _installed_roots() -> list[str]:
22
+ """Where installed packages live (this interpreter's prefixes and site-packages). A project folder that holds its own venv
23
+ must not have them purged: numpy and the CAD kernel's extension modules cannot be imported twice in one process."""
24
+ import site
25
+ roots = {sys.prefix, sys.base_prefix, sys.exec_prefix}
26
+ try:
27
+ roots.update(site.getsitepackages())
28
+ except AttributeError: # some virtualenv layouts
29
+ pass
30
+ roots.add(site.getusersitepackages() if hasattr(site, "getusersitepackages") else "")
31
+ out = []
32
+ for r in roots:
33
+ if r:
34
+ rp = str(Path(r).resolve())
35
+ out.append(rp if rp.endswith(os.sep) else rp + os.sep)
36
+ return out
37
+
38
+
39
+ def _purge_project_modules(folder: Path) -> None:
40
+ """Forget modules loaded from the model's folder so edits to helpers are picked up.
41
+
42
+ Also drops the folder's __pycache__: Python validates bytecode by mtime (whole
43
+ seconds) and size, so a helper edited within a second of the last build with
44
+ the same length would otherwise keep running the old code.
45
+ """
46
+ import importlib
47
+ import shutil
48
+
49
+ folder_s = str(folder)
50
+ keep = _installed_roots()
51
+ for name, mod in list(sys.modules.items()):
52
+ f = getattr(mod, "__file__", None)
53
+ if not f or name == "eitri" or name.startswith("eitri."):
54
+ continue
55
+ rf = str(Path(f).resolve())
56
+ if rf.startswith(folder_s) and not any(rf.startswith(k) for k in keep):
57
+ del sys.modules[name]
58
+ for cache in list(folder.glob("__pycache__")) + list(folder.glob("*/__pycache__")):
59
+ shutil.rmtree(cache, ignore_errors=True)
60
+ importlib.invalidate_caches()
61
+
62
+
63
+ def serve() -> int:
64
+ """Worker main loop: stdin JSON lines in, JSON lines out."""
65
+ from . import _runner # imports the kernel once, here
66
+ sys.dont_write_bytecode = True
67
+ out = sys.stdout
68
+ sys.stdout = sys.stderr # anything a model prints must not corrupt the protocol
69
+ for line in sys.stdin:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ try:
74
+ req = json.loads(line)
75
+ except json.JSONDecodeError:
76
+ continue
77
+ if req.get("op") == "ping":
78
+ out.write(json.dumps({"id": req.get("id"), "ok": True}) + "\n")
79
+ out.flush()
80
+ continue
81
+ argv = list(req.get("argv", []))
82
+ model = req.get("model")
83
+ if model:
84
+ _purge_project_modules(Path(model).resolve().parent)
85
+ t0 = time.time()
86
+ try:
87
+ code = _runner.main(argv)
88
+ reply = {"id": req.get("id"), "code": code, "seconds": round(time.time() - t0, 2)}
89
+ except SystemExit as exc:
90
+ reply = {"id": req.get("id"), "code": int(exc.code or 0), "seconds": round(time.time() - t0, 2)}
91
+ except BaseException as exc: # noqa: BLE001 - report anything, keep serving
92
+ reply = {"id": req.get("id"), "code": 1, "error": f"{type(exc).__name__}: {exc}",
93
+ "seconds": round(time.time() - t0, 2)}
94
+ out.write(json.dumps(reply) + "\n")
95
+ out.flush()
96
+ return 0
97
+
98
+
99
+ class Worker:
100
+ """Parent-side handle: starts the worker lazily, restarts it after a crash or timeout."""
101
+
102
+ def __init__(self) -> None:
103
+ self.proc: subprocess.Popen | None = None
104
+ self._lock = threading.Lock()
105
+ self._next = 0
106
+ self.restarts = 0
107
+
108
+ def _start(self) -> None:
109
+ env = dict(os.environ)
110
+ env.setdefault("PYTHONUNBUFFERED", "1")
111
+ self.proc = subprocess.Popen([sys.executable, "-m", "eitri._worker"], stdin=subprocess.PIPE,
112
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", bufsize=1, env=env)
113
+ self._stderr_tail: list[str] = []
114
+ threading.Thread(target=self._pump, daemon=True).start()
115
+
116
+ def _pump(self) -> None:
117
+ proc = self.proc
118
+ assert proc is not None and proc.stderr is not None
119
+ for line in proc.stderr:
120
+ self._stderr_tail.append(line.rstrip())
121
+ if len(self._stderr_tail) > 400:
122
+ del self._stderr_tail[:-400]
123
+
124
+ def alive(self) -> bool:
125
+ return self.proc is not None and self.proc.poll() is None
126
+
127
+ def stop(self) -> None:
128
+ if self.proc is not None:
129
+ try:
130
+ if self.proc.stdin:
131
+ self.proc.stdin.close()
132
+ self.proc.wait(timeout=3)
133
+ except Exception:
134
+ try:
135
+ self.proc.kill()
136
+ except Exception:
137
+ pass
138
+ self.proc = None
139
+
140
+ def run(self, argv: list[str], model: str, cwd: str | None = None, timeout: float = 240.0) -> tuple[int, str, str]:
141
+ """Run eitri._runner with argv in the warm worker. Returns (code, stdout_tail, stderr_tail).
142
+
143
+ cwd is not changed in the worker: model paths are absolute and the runner
144
+ resolves everything itself.
145
+ """
146
+ with self._lock:
147
+ if not self.alive():
148
+ if self.proc is not None: # it died on its own (kernel crash)
149
+ self.restarts += 1
150
+ self._start()
151
+ assert self.proc and self.proc.stdin and self.proc.stdout
152
+ self._next += 1
153
+ req = {"id": self._next, "argv": argv, "model": model}
154
+ before = len(self._stderr_tail)
155
+ try:
156
+ self.proc.stdin.write(json.dumps(req) + "\n")
157
+ self.proc.stdin.flush()
158
+ except (BrokenPipeError, OSError):
159
+ self.stop()
160
+ return 1, "", "worker pipe broken"
161
+ reply = self._read_reply(timeout)
162
+ err = "\n".join(self._stderr_tail[before:])
163
+ if reply is None:
164
+ self.proc.kill()
165
+ self.proc = None
166
+ self.restarts += 1 # the next build starts a fresh worker
167
+ return -9, "", err + "\n[worker killed: timeout or crash]"
168
+ if reply.get("error"):
169
+ err += "\n" + reply["error"]
170
+ return int(reply.get("code", 1)), "", err
171
+
172
+ def ping(self, timeout: float = 120.0) -> bool:
173
+ """Start the worker if needed and wait until it answers: the kernel is imported then."""
174
+ with self._lock:
175
+ if not self.alive():
176
+ self._start()
177
+ assert self.proc and self.proc.stdin and self.proc.stdout
178
+ self._next += 1
179
+ try:
180
+ self.proc.stdin.write(json.dumps({"op": "ping", "id": self._next}) + "\n")
181
+ self.proc.stdin.flush()
182
+ except (BrokenPipeError, OSError):
183
+ return False
184
+ reply = self._read_reply(timeout)
185
+ return bool(reply and reply.get("ok"))
186
+
187
+ def _read_reply(self, timeout: float) -> dict | None:
188
+ assert self.proc and self.proc.stdout
189
+ result: dict[str, Any] = {}
190
+
191
+ def reader() -> None:
192
+ try:
193
+ line = self.proc.stdout.readline() # type: ignore[union-attr]
194
+ if line:
195
+ result["reply"] = json.loads(line)
196
+ except Exception:
197
+ pass
198
+
199
+ t = threading.Thread(target=reader, daemon=True)
200
+ t.start()
201
+ t.join(timeout)
202
+ if t.is_alive():
203
+ return None
204
+ return result.get("reply")
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(serve())