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.
- eitri/__init__.py +22 -0
- eitri/__main__.py +3 -0
- eitri/_render_cli.py +61 -0
- eitri/_runner.py +300 -0
- eitri/_worker.py +208 -0
- eitri/additive.py +176 -0
- eitri/agent/__init__.py +1 -0
- eitri/agent/api_backend.py +284 -0
- eitri/agent/build123d_ref.md +213 -0
- eitri/agent/claude_cli.py +369 -0
- eitri/agent/electronics_prompt.md +34 -0
- eitri/agent/electronics_ref.md +177 -0
- eitri/agent/firmware_prompt.md +35 -0
- eitri/agent/firmware_ref.md +127 -0
- eitri/agent/harness_prompt.md +33 -0
- eitri/agent/harness_ref.md +190 -0
- eitri/agent/hub.py +471 -0
- eitri/agent/library_docs.py +54 -0
- eitri/agent/mcp_server.py +155 -0
- eitri/agent/mechanism_prompt.md +32 -0
- eitri/agent/mechanism_ref.md +138 -0
- eitri/agent/pneumatics_prompt.md +32 -0
- eitri/agent/pneumatics_ref.md +136 -0
- eitri/agent/prompt.md +231 -0
- eitri/agent/prompt.py +59 -0
- eitri/agent/replay.py +159 -0
- eitri/agent/tools.py +608 -0
- eitri/anchors.py +147 -0
- eitri/assembly.py +231 -0
- eitri/cam.py +497 -0
- eitri/checks.py +43 -0
- eitri/cli.py +816 -0
- eitri/config.py +127 -0
- eitri/demos/__init__.py +152 -0
- eitri/demos/air-clamp/air_clamp.py +27 -0
- eitri/demos/marble-maze/demo.json +584 -0
- eitri/demos/marble-maze/project.tar.xz +0 -0
- eitri/demos/pan-tilt/pan_tilt.py +29 -0
- eitri/demos/quad-power/quad_power.py +57 -0
- eitri/demos/racket-holder/demo.json +319 -0
- eitri/demos/racket-holder/project.tar.xz +0 -0
- eitri/demos/servo-sweep/servo_sweep.py +54 -0
- eitri/demos/uno-arm-circuit/uno_arm_circuit.py +79 -0
- eitri/demos/v8-engine/demo.json +439 -0
- eitri/demos/v8-engine/project.tar.xz +0 -0
- eitri/diagram.py +562 -0
- eitri/direct.py +535 -0
- eitri/doctor.py +201 -0
- eitri/domains.py +93 -0
- eitri/drawing.py +1018 -0
- eitri/edits.py +2183 -0
- eitri/electronics/__init__.py +17 -0
- eitri/electronics/analysis.py +544 -0
- eitri/electronics/build.py +84 -0
- eitri/electronics/catalog.py +293 -0
- eitri/electronics/checks.py +947 -0
- eitri/electronics/diagram.py +410 -0
- eitri/electronics/domain.py +83 -0
- eitri/electronics/edits.py +194 -0
- eitri/electronics/inspect.py +70 -0
- eitri/electronics/model.py +614 -0
- eitri/electronics/nodal.py +237 -0
- eitri/electronics/outputs.py +73 -0
- eitri/electronics/report.py +158 -0
- eitri/electronics/sources.py +198 -0
- eitri/executor.py +162 -0
- eitri/exports.py +38 -0
- eitri/extensions.py +189 -0
- eitri/fastmesh.py +105 -0
- eitri/firmware/__init__.py +16 -0
- eitri/firmware/boards.py +121 -0
- eitri/firmware/build.py +113 -0
- eitri/firmware/checks.py +346 -0
- eitri/firmware/compile.py +122 -0
- eitri/firmware/diagram.py +116 -0
- eitri/firmware/domain.py +79 -0
- eitri/firmware/edits.py +100 -0
- eitri/firmware/inspect.py +53 -0
- eitri/firmware/model.py +263 -0
- eitri/firmware/outputs.py +40 -0
- eitri/firmware/report.py +116 -0
- eitri/form.py +449 -0
- eitri/harness/__init__.py +20 -0
- eitri/harness/build.py +89 -0
- eitri/harness/catalog.py +363 -0
- eitri/harness/checks.py +464 -0
- eitri/harness/diagram.py +349 -0
- eitri/harness/domain.py +82 -0
- eitri/harness/edits.py +175 -0
- eitri/harness/inspect.py +81 -0
- eitri/harness/model.py +551 -0
- eitri/harness/outputs.py +308 -0
- eitri/harness/report.py +138 -0
- eitri/kernel.py +348 -0
- eitri/lib/__init__.py +19 -0
- eitri/lib/bearings.py +52 -0
- eitri/lib/direct.py +93 -0
- eitri/lib/enclosure.py +70 -0
- eitri/lib/fasteners.py +80 -0
- eitri/lib/features.py +325 -0
- eitri/lib/parts.py +161 -0
- eitri/measure.py +731 -0
- eitri/mechanism/__init__.py +15 -0
- eitri/mechanism/build.py +82 -0
- eitri/mechanism/checks.py +355 -0
- eitri/mechanism/diagram.py +278 -0
- eitri/mechanism/domain.py +78 -0
- eitri/mechanism/edits.py +194 -0
- eitri/mechanism/inspect.py +77 -0
- eitri/mechanism/model.py +566 -0
- eitri/mechanism/outputs.py +84 -0
- eitri/mechanism/report.py +148 -0
- eitri/meshio.py +293 -0
- eitri/model.py +696 -0
- eitri/pneumatics/__init__.py +18 -0
- eitri/pneumatics/build.py +81 -0
- eitri/pneumatics/checks.py +474 -0
- eitri/pneumatics/diagram.py +501 -0
- eitri/pneumatics/domain.py +77 -0
- eitri/pneumatics/edits.py +130 -0
- eitri/pneumatics/flow.py +104 -0
- eitri/pneumatics/inspect.py +49 -0
- eitri/pneumatics/model.py +448 -0
- eitri/pneumatics/outputs.py +118 -0
- eitri/pneumatics/report.py +144 -0
- eitri/power.py +126 -0
- eitri/printexport.py +614 -0
- eitri/project.py +584 -0
- eitri/render.py +593 -0
- eitri/report.py +404 -0
- eitri/route.py +163 -0
- eitri/sandbox.py +169 -0
- eitri/setup_firmware.py +195 -0
- eitri/sheetmetal.py +335 -0
- eitri/sketch.py +1025 -0
- eitri/solidops.py +254 -0
- eitri/static.py +43 -0
- eitri/surfaces.py +351 -0
- eitri/update.py +284 -0
- eitri/viewer/__init__.py +1 -0
- eitri/viewer/index.html +4107 -0
- eitri/viewer/server.py +1041 -0
- eitri/viewer/vendor/three/LICENSE +21 -0
- eitri/viewer/vendor/three/addons/controls/OrbitControls.js +1417 -0
- eitri/viewer/vendor/three/addons/controls/TransformControls.js +1573 -0
- eitri/viewer/vendor/three/addons/environments/RoomEnvironment.js +148 -0
- eitri/viewer/vendor/three/addons/loaders/GLTFLoader.js +4663 -0
- eitri/viewer/vendor/three/addons/utils/BufferGeometryUtils.js +1375 -0
- eitri/viewer/vendor/three/three.module.js +53044 -0
- eitri/viewglb.py +748 -0
- eitri/web/__init__.py +1 -0
- eitri/web/app.py +650 -0
- eitri/web/assets/LICENSE-lucide.txt +15 -0
- eitri/web/assets/app.css +571 -0
- eitri/web/assets/design/README.md +207 -0
- eitri/web/assets/design/fonts/OFL-IBM-Plex.txt +93 -0
- eitri/web/assets/design/fonts/OFL-JetBrains-Mono.txt +93 -0
- eitri/web/assets/design/fonts/ibm-plex-sans-400.woff2 +0 -0
- eitri/web/assets/design/fonts/ibm-plex-sans-500.woff2 +0 -0
- eitri/web/assets/design/fonts/ibm-plex-sans-600.woff2 +0 -0
- eitri/web/assets/design/fonts/jetbrains-mono-400.woff2 +0 -0
- eitri/web/assets/design/fonts/jetbrains-mono-500.woff2 +0 -0
- eitri/web/assets/design/fonts.css +6 -0
- eitri/web/assets/design/glyphs.js +36 -0
- eitri/web/assets/design/shell.css +332 -0
- eitri/web/assets/design/shell.js +281 -0
- eitri/web/assets/design/tokens.css +49 -0
- eitri/web/assets/domain_editor.js +169 -0
- eitri/web/assets/electronics.js +163 -0
- eitri/web/assets/firmware.js +79 -0
- eitri/web/assets/harness.js +359 -0
- eitri/web/assets/icons.svg +89 -0
- eitri/web/assets/mark.svg +7 -0
- eitri/web/assets/mechanism.js +106 -0
- eitri/web/assets/pneumatics.js +96 -0
- eitri/web/assets/print.css +332 -0
- eitri/web/assets/print.js +979 -0
- eitri/web/assets/shell-host.js +71 -0
- eitri/web/home.html +584 -0
- eitri/web/message.html +33 -0
- eitri_hardware-0.6.0.dist-info/METADATA +1018 -0
- eitri_hardware-0.6.0.dist-info/RECORD +186 -0
- eitri_hardware-0.6.0.dist-info/WHEEL +5 -0
- eitri_hardware-0.6.0.dist-info/entry_points.txt +2 -0
- eitri_hardware-0.6.0.dist-info/licenses/LICENSE.md +110 -0
- 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
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())
|