reverse3d 0.4.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 (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. reverse3d-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,284 @@
1
+ """Headless render job executed *inside* Blender's embedded Python.
2
+
3
+ Invoked by :mod:`reverse3d.rendering.blender_render` as::
4
+
5
+ blender --background --factory-startup --disable-autoexec \
6
+ --python-exit-code 1 --python _blender_job.py -- job.json
7
+
8
+ This file must not import reverse3d (Blender ships its own interpreter). It only
9
+ reads a JSON job written by the launcher, so no code or expression coming from
10
+ users/agents is ever evaluated here. Compatible with Blender 3.6 LTS → 4.x.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import sys
18
+ import traceback
19
+ from pathlib import Path
20
+
21
+ import bpy
22
+ from mathutils import Vector
23
+
24
+ RESULT_MARKER = "REVERSE3D_RESULT "
25
+
26
+
27
+ def _read_job() -> dict:
28
+ argv = sys.argv
29
+ if "--" not in argv:
30
+ raise SystemExit("Missing job file argument")
31
+ job_path = Path(argv[argv.index("--") + 1])
32
+ return json.loads(job_path.read_text(encoding="utf-8"))
33
+
34
+
35
+ def _hex_to_rgba(value: str, alpha: float = 1.0) -> tuple[float, float, float, float]:
36
+ value = value.lstrip("#")
37
+ r, g, b = (int(value[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
38
+
39
+ # Convert sRGB to linear for Blender's scene-referred colour inputs.
40
+ def lin(c: float) -> float:
41
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
42
+
43
+ return lin(r), lin(g), lin(b), alpha
44
+
45
+
46
+ def _call_first(*candidates: tuple[str, str], **kwargs: object) -> None:
47
+ """Call the first operator that exists (operators were renamed in 4.x)."""
48
+ last_error: Exception | None = None
49
+ for module, name in candidates:
50
+ try:
51
+ getattr(getattr(bpy.ops, module), name)(**kwargs)
52
+ return
53
+ except (AttributeError, RuntimeError) as exc:
54
+ last_error = exc
55
+ raise RuntimeError(f"No importer available: {last_error}")
56
+
57
+
58
+ def import_mesh(path: str) -> bpy.types.Object:
59
+ ext = Path(path).suffix.lower()
60
+ before = set(bpy.data.objects)
61
+ if ext == ".obj":
62
+ _call_first(("wm", "obj_import"), ("import_scene", "obj"), filepath=path)
63
+ elif ext == ".stl":
64
+ _call_first(("wm", "stl_import"), ("import_mesh", "stl"), filepath=path)
65
+ elif ext == ".ply":
66
+ _call_first(("wm", "ply_import"), ("import_mesh", "ply"), filepath=path)
67
+ elif ext in {".glb", ".gltf"}:
68
+ _call_first(("import_scene", "gltf"), filepath=path)
69
+ else:
70
+ raise ValueError(f"Unsupported format for Blender: {ext}")
71
+
72
+ meshes = [o for o in bpy.data.objects if o not in before and o.type == "MESH"]
73
+ if not meshes:
74
+ raise RuntimeError("Imported file contains no mesh objects")
75
+ bpy.ops.object.select_all(action="DESELECT")
76
+ for obj in meshes:
77
+ obj.select_set(True)
78
+ bpy.context.view_layer.objects.active = meshes[0]
79
+ if len(meshes) > 1:
80
+ bpy.ops.object.join()
81
+ obj = bpy.context.view_layer.objects.active
82
+ bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
83
+ return obj
84
+
85
+
86
+ def normalize(obj: bpy.types.Object, ground: bool) -> tuple[float, float]:
87
+ """Center the object and scale its largest dimension to 2 units.
88
+
89
+ Returns the bounding-sphere radius and the height of the object's centre
90
+ (non-zero when it rests on the ground), which is where the camera aims.
91
+ """
92
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
93
+ lo = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
94
+ hi = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
95
+ size = max((hi - lo).x, (hi - lo).y, (hi - lo).z) or 1.0
96
+ scale = 2.0 / size
97
+ center = (lo + hi) / 2
98
+ obj.location = -center * scale
99
+ if ground:
100
+ obj.location.z = -lo.z * scale
101
+ obj.scale = (scale, scale, scale)
102
+ bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
103
+ center_z = (hi.z - lo.z) * scale / 2 if ground else 0.0
104
+ return ((hi - lo) * scale).length / 2, center_z
105
+
106
+
107
+ def shade(obj: bpy.types.Object, mode: str) -> None:
108
+ if mode == "flat":
109
+ bpy.ops.object.shade_flat()
110
+ return
111
+ if mode == "smooth":
112
+ bpy.ops.object.shade_smooth()
113
+ return
114
+ try: # Blender >= 4.1
115
+ bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30))
116
+ except (AttributeError, RuntimeError, TypeError):
117
+ bpy.ops.object.shade_smooth()
118
+ if hasattr(obj.data, "use_auto_smooth"): # Blender <= 4.0
119
+ obj.data.use_auto_smooth = True
120
+ obj.data.auto_smooth_angle = math.radians(30)
121
+
122
+
123
+ def make_material(obj: bpy.types.Object, job: dict) -> None:
124
+ mat = bpy.data.materials.new("reverse3d_material")
125
+ mat.use_nodes = True
126
+ bsdf = mat.node_tree.nodes.get("Principled BSDF")
127
+ bsdf.inputs["Base Color"].default_value = _hex_to_rgba(job["color"])
128
+ bsdf.inputs["Metallic"].default_value = float(job["metallic"])
129
+ bsdf.inputs["Roughness"].default_value = float(job["roughness"])
130
+ attrs = getattr(obj.data, "color_attributes", None)
131
+ if job.get("vertex_colors", True) and attrs is not None and len(attrs) > 0:
132
+ node = mat.node_tree.nodes.new("ShaderNodeVertexColor")
133
+ node.layer_name = attrs[0].name
134
+ mat.node_tree.links.new(node.outputs["Color"], bsdf.inputs["Base Color"])
135
+ obj.data.materials.clear()
136
+ obj.data.materials.append(mat)
137
+
138
+
139
+ def add_light(name: str, location: tuple[float, float, float], energy: float, size: float) -> None:
140
+ data = bpy.data.lights.new(name, type="AREA")
141
+ data.energy = energy
142
+ data.size = size
143
+ light = bpy.data.objects.new(name, data)
144
+ bpy.context.collection.objects.link(light)
145
+ light.location = location
146
+ direction = Vector((0, 0, 0.5)) - light.location
147
+ light.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
148
+
149
+
150
+ def setup_world(job: dict) -> None:
151
+ world = bpy.data.worlds.new("reverse3d_world")
152
+ bpy.context.scene.world = world
153
+ world.use_nodes = True
154
+ bg = world.node_tree.nodes.get("Background")
155
+ bg.inputs["Color"].default_value = _hex_to_rgba(job["background"])
156
+ bg.inputs["Strength"].default_value = 0.6
157
+
158
+
159
+ def setup_camera(radius: float, center_z: float, job: dict) -> bpy.types.Object:
160
+ rig = bpy.data.objects.new("reverse3d_rig", None)
161
+ bpy.context.collection.objects.link(rig)
162
+ rig.location = (0, 0, center_z)
163
+
164
+ cam_data = bpy.data.cameras.new("reverse3d_camera")
165
+ cam_data.lens = 50
166
+ cam = bpy.data.objects.new("reverse3d_camera", cam_data)
167
+ bpy.context.collection.objects.link(cam)
168
+ # camera.angle is the FOV of the longer image side; fit the sphere in the shorter one.
169
+ width, height = int(job["width"]), int(job["height"])
170
+ fov = cam_data.angle
171
+ if width != height:
172
+ fov = 2 * math.atan(math.tan(fov / 2) * min(width, height) / max(width, height))
173
+ distance = radius / math.sin(fov / 2) * 1.1
174
+ elev, azim = math.radians(job["elevation_deg"]), math.radians(job["azimuth_deg"])
175
+ # Parented with an identity parent-inverse: this location is relative to the rig.
176
+ cam.location = (
177
+ distance * math.cos(elev) * math.sin(azim),
178
+ -distance * math.cos(elev) * math.cos(azim),
179
+ distance * math.sin(elev),
180
+ )
181
+ cam.parent = rig
182
+ track = cam.constraints.new(type="TRACK_TO")
183
+ track.target = rig
184
+ track.track_axis = "TRACK_NEGATIVE_Z"
185
+ track.up_axis = "UP_Y"
186
+ bpy.context.scene.camera = cam
187
+ cam_data.clip_end = distance * 10
188
+ return rig
189
+
190
+
191
+ def setup_ground(job: dict) -> None:
192
+ if not job["ground"] or job["engine"] != "cycles":
193
+ return
194
+ bpy.ops.mesh.primitive_plane_add(size=50, location=(0, 0, 0))
195
+ plane = bpy.context.active_object
196
+ plane.is_shadow_catcher = True
197
+
198
+
199
+ def setup_render(job: dict) -> None:
200
+ scene = bpy.context.scene
201
+ engines = {item.identifier for item in scene.render.bl_rna.properties["engine"].enum_items}
202
+ if job["engine"] == "cycles":
203
+ scene.render.engine = "CYCLES"
204
+ scene.cycles.samples = int(job["samples"])
205
+ if job["device"] == "gpu":
206
+ try:
207
+ prefs = bpy.context.preferences.addons["cycles"].preferences
208
+ for backend in ("OPTIX", "CUDA", "HIP", "METAL", "ONEAPI"):
209
+ try:
210
+ prefs.compute_device_type = backend
211
+ prefs.get_devices()
212
+ if any(d.type == backend for d in prefs.devices):
213
+ for d in prefs.devices:
214
+ d.use = True
215
+ scene.cycles.device = "GPU"
216
+ break
217
+ except TypeError:
218
+ continue
219
+ except KeyError:
220
+ pass
221
+ elif job["engine"] == "eevee":
222
+ scene.render.engine = (
223
+ "BLENDER_EEVEE_NEXT" if "BLENDER_EEVEE_NEXT" in engines else "BLENDER_EEVEE"
224
+ )
225
+ scene.eevee.taa_render_samples = int(job["samples"])
226
+ else:
227
+ scene.render.engine = "BLENDER_WORKBENCH"
228
+
229
+ scene.render.resolution_x = int(job["width"])
230
+ scene.render.resolution_y = int(job["height"])
231
+ scene.render.resolution_percentage = 100
232
+ scene.render.film_transparent = bool(job["transparent"])
233
+ scene.render.image_settings.file_format = "PNG"
234
+ scene.render.image_settings.color_mode = "RGBA" if job["transparent"] else "RGB"
235
+ for transform in ("AgX", "Filmic", "Standard"):
236
+ try:
237
+ scene.view_settings.view_transform = transform
238
+ break
239
+ except TypeError:
240
+ continue
241
+
242
+
243
+ def main() -> None:
244
+ job = _read_job()
245
+ bpy.ops.wm.read_factory_settings(use_empty=True)
246
+ obj = import_mesh(job["mesh"])
247
+ radius, center_z = normalize(obj, job["ground"])
248
+ shade(obj, job["shading"])
249
+ make_material(obj, job)
250
+ setup_world(job)
251
+ r = max(radius, 0.1)
252
+ add_light("key", (r * 3, -r * 3, r * 4), 800 * r * r, r * 2)
253
+ add_light("fill", (-r * 4, -r * 2, r * 2), 300 * r * r, r * 3)
254
+ add_light("rim", (0, r * 4, r * 3), 500 * r * r, r * 2)
255
+ rig = setup_camera(radius, center_z, job)
256
+ setup_ground(job)
257
+ setup_render(job)
258
+
259
+ output = Path(job["output"])
260
+ files: list[str] = []
261
+ frames = int(job["turntable_frames"])
262
+ if frames <= 0:
263
+ bpy.context.scene.render.filepath = str(output)
264
+ bpy.ops.render.render(write_still=True)
265
+ files.append(str(output))
266
+ else:
267
+ for i in range(frames):
268
+ rig.rotation_euler.z = 2 * math.pi * i / frames
269
+ path = output.with_name(f"{output.stem}_{i:04d}{output.suffix}")
270
+ bpy.context.scene.render.filepath = str(path)
271
+ bpy.ops.render.render(write_still=True)
272
+ files.append(str(path))
273
+
274
+ print(
275
+ RESULT_MARKER + json.dumps({"files": files, "blender": bpy.app.version_string}), flush=True
276
+ )
277
+
278
+
279
+ if __name__ == "__main__":
280
+ try:
281
+ main()
282
+ except Exception:
283
+ traceback.print_exc()
284
+ sys.exit(1)
@@ -0,0 +1,215 @@
1
+ """Automated headless rendering with Blender (preferred) or PyVista (fallback).
2
+
3
+ Security model of the Blender launcher:
4
+
5
+ * Render parameters are validated here and passed to a **static** script
6
+ (:mod:`reverse3d.rendering._blender_job`) through a JSON file. No Python
7
+ source is generated from user/agent input, so there is nothing to inject.
8
+ * Blender runs with ``--factory-startup`` (ignores user add-ons and startup
9
+ scripts) and ``--disable-autoexec`` (never runs scripts embedded in .blend
10
+ files), under the hard timeout and scrubbed environment of
11
+ :func:`reverse3d.security.run_command`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import re
19
+ import tempfile
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import Literal
23
+
24
+ from reverse3d.config import Settings
25
+ from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
26
+ from reverse3d.mesh_io import load_mesh, save_mesh
27
+ from reverse3d.security import (
28
+ MESH_EXTENSIONS,
29
+ prepare_output_file,
30
+ resolve_executable,
31
+ run_command,
32
+ validate_input_file,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ RenderEngine = Literal["cycles", "eevee", "workbench"]
38
+ Backend = Literal["auto", "blender", "pyvista"]
39
+
40
+ _HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
41
+ _JOB_SCRIPT = Path(__file__).with_name("_blender_job.py")
42
+ _RESULT_MARKER = "REVERSE3D_RESULT "
43
+ _BLENDER_NATIVE = {".obj", ".stl", ".ply", ".glb", ".gltf"}
44
+
45
+
46
+ @dataclass
47
+ class RenderOptions:
48
+ """Validated render parameters (shared by the Blender and PyVista backends)."""
49
+
50
+ width: int = 1600
51
+ height: int = 1200
52
+ engine: RenderEngine = "cycles"
53
+ device: Literal["cpu", "gpu"] = "cpu"
54
+ samples: int = 64
55
+ color: str = "#9aa4b2"
56
+ metallic: float = 0.0
57
+ roughness: float = 0.45
58
+ background: str = "#f4f5f7"
59
+ transparent: bool = False
60
+ elevation_deg: float = 25.0
61
+ azimuth_deg: float = 35.0
62
+ turntable_frames: int = 0
63
+ ground: bool = True
64
+ shading: Literal["auto", "smooth", "flat"] = "auto"
65
+ vertex_colors: bool = True
66
+
67
+ def validate(self) -> None:
68
+ checks = [
69
+ (64 <= self.width <= 8192, "width must be in [64, 8192]"),
70
+ (64 <= self.height <= 8192, "height must be in [64, 8192]"),
71
+ (
72
+ self.engine in ("cycles", "eevee", "workbench"),
73
+ "engine must be cycles, eevee or workbench",
74
+ ),
75
+ (self.device in ("cpu", "gpu"), "device must be cpu or gpu"),
76
+ (1 <= self.samples <= 4096, "samples must be in [1, 4096]"),
77
+ (bool(_HEX_COLOR.match(self.color)), "color must be #RRGGBB"),
78
+ (bool(_HEX_COLOR.match(self.background)), "background must be #RRGGBB"),
79
+ (0 <= self.metallic <= 1, "metallic must be in [0, 1]"),
80
+ (0 <= self.roughness <= 1, "roughness must be in [0, 1]"),
81
+ (-89 <= self.elevation_deg <= 89, "elevation_deg must be in [-89, 89]"),
82
+ (0 <= self.azimuth_deg <= 360, "azimuth_deg must be in [0, 360]"),
83
+ (0 <= self.turntable_frames <= 360, "turntable_frames must be in [0, 360]"),
84
+ (self.shading in ("auto", "smooth", "flat"), "shading must be auto, smooth or flat"),
85
+ ]
86
+ for ok, message in checks:
87
+ if not ok:
88
+ raise InputValidationError(message)
89
+
90
+
91
+ @dataclass
92
+ class RenderResult:
93
+ """Paths of the rendered images and the backend that produced them."""
94
+
95
+ backend: str
96
+ files: list[str] = field(default_factory=list)
97
+ renderer_version: str | None = None
98
+
99
+ def to_dict(self) -> dict[str, object]:
100
+ return asdict(self)
101
+
102
+
103
+ def _expected_outputs(out: Path, frames: int) -> list[Path]:
104
+ if frames <= 0:
105
+ return [out]
106
+ return [out.with_name(f"{out.stem}_{i:04d}{out.suffix}") for i in range(frames)]
107
+
108
+
109
+ def _check_outputs_free(out: Path, frames: int, overwrite: bool) -> None:
110
+ if overwrite:
111
+ return
112
+ for path in _expected_outputs(out, frames):
113
+ if path.exists():
114
+ raise InputValidationError(
115
+ f"Output file already exists: {path.name}", hint="Pass overwrite=true."
116
+ )
117
+
118
+
119
+ def render_with_blender(
120
+ mesh_path: str | Path,
121
+ output_path: str | Path,
122
+ settings: Settings,
123
+ options: RenderOptions | None = None,
124
+ *,
125
+ blender_executable: Path | None = None,
126
+ overwrite: bool = False,
127
+ ) -> RenderResult:
128
+ """Render ``mesh_path`` to PNG with Blender in background mode.
129
+
130
+ With ``turntable_frames > 0`` the files are ``<stem>_0000.png ...``.
131
+
132
+ Raises:
133
+ DependencyMissingError: Blender not found.
134
+ ExternalToolError: Blender failed or timed out.
135
+ """
136
+ options = options or RenderOptions()
137
+ options.validate()
138
+ src = validate_input_file(
139
+ mesh_path, allowed_extensions=MESH_EXTENSIONS, max_bytes=settings.max_input_bytes
140
+ )
141
+ out = prepare_output_file(output_path, allowed_extensions={".png"}, overwrite=True)
142
+ _check_outputs_free(out, options.turntable_frames, overwrite)
143
+ blender = blender_executable or resolve_executable(
144
+ ["blender", "blender.exe"], settings.blender_path, tool="blender"
145
+ )
146
+
147
+ with tempfile.TemporaryDirectory(prefix="reverse3d_render_") as tmp:
148
+ tmp_dir = Path(tmp)
149
+ mesh_for_blender = src
150
+ if src.suffix.lower() not in _BLENDER_NATIVE:
151
+ # Blender has no importer for e.g. .off: convert through trimesh first.
152
+ mesh_for_blender = save_mesh(load_mesh(src, settings), tmp_dir / "mesh.ply")
153
+ job = {**asdict(options), "mesh": str(mesh_for_blender), "output": str(out)}
154
+ job_file = tmp_dir / "job.json"
155
+ job_file.write_text(json.dumps(job), encoding="utf-8")
156
+
157
+ proc = run_command(
158
+ blender,
159
+ [
160
+ "--background",
161
+ "--factory-startup",
162
+ "--disable-autoexec",
163
+ "--python-exit-code",
164
+ "1",
165
+ "--python",
166
+ str(_JOB_SCRIPT),
167
+ "--",
168
+ str(job_file),
169
+ ],
170
+ timeout_s=settings.subprocess_timeout_s,
171
+ tool="blender",
172
+ )
173
+
174
+ payload: dict[str, object] = {}
175
+ for line in (proc.stdout or "").splitlines():
176
+ if line.startswith(_RESULT_MARKER):
177
+ try:
178
+ payload = json.loads(line[len(_RESULT_MARKER) :])
179
+ except json.JSONDecodeError:
180
+ payload = {}
181
+ files = [str(p) for p in _expected_outputs(out, options.turntable_frames) if p.exists()]
182
+ if not files:
183
+ raise ProcessingError("Blender finished but produced no image.")
184
+ version = payload.get("blender")
185
+ logger.info("Blender rendered %d image(s)", len(files))
186
+ return RenderResult(
187
+ backend="blender", files=files, renderer_version=str(version) if version else None
188
+ )
189
+
190
+
191
+ def render_mesh(
192
+ mesh_path: str | Path,
193
+ output_path: str | Path,
194
+ settings: Settings,
195
+ options: RenderOptions | None = None,
196
+ *,
197
+ backend: Backend = "auto",
198
+ overwrite: bool = False,
199
+ ) -> RenderResult:
200
+ """Render with the requested backend. ``auto`` uses Blender when available
201
+ and falls back to PyVista (``[render]`` extra) otherwise."""
202
+ if backend not in ("auto", "blender", "pyvista"):
203
+ raise InputValidationError("backend must be auto, blender or pyvista")
204
+ if backend in ("auto", "blender"):
205
+ try:
206
+ return render_with_blender(
207
+ mesh_path, output_path, settings, options, overwrite=overwrite
208
+ )
209
+ except DependencyMissingError:
210
+ if backend == "blender":
211
+ raise
212
+ logger.info("Blender not available, falling back to PyVista")
213
+ from reverse3d.rendering.preview import render_with_pyvista
214
+
215
+ return render_with_pyvista(mesh_path, output_path, settings, options, overwrite=overwrite)
@@ -0,0 +1,80 @@
1
+ """Lightweight offscreen renderer based on PyVista/VTK (``[render]`` extra).
2
+
3
+ Used when Blender is not installed. Quality is lower (no path tracing) but it
4
+ needs no external binary and is fast enough for previews in agent loops.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from reverse3d.config import Settings
14
+ from reverse3d.errors import DependencyMissingError
15
+ from reverse3d.mesh_io import load_mesh
16
+ from reverse3d.rendering.blender_render import (
17
+ RenderOptions,
18
+ RenderResult,
19
+ _check_outputs_free,
20
+ _expected_outputs,
21
+ )
22
+ from reverse3d.security import prepare_output_file
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _require_pyvista() -> Any:
28
+ try:
29
+ import pyvista as pv
30
+ except ImportError as exc:
31
+ raise DependencyMissingError(
32
+ "Neither Blender nor PyVista is available for rendering.",
33
+ hint='Install Blender (and add it to PATH) or: pip install "reverse3d[render]"',
34
+ ) from exc
35
+ return pv
36
+
37
+
38
+ def render_with_pyvista(
39
+ mesh_path: str | Path,
40
+ output_path: str | Path,
41
+ settings: Settings,
42
+ options: RenderOptions | None = None,
43
+ *,
44
+ overwrite: bool = False,
45
+ ) -> RenderResult:
46
+ """Render ``mesh_path`` offscreen with PyVista. Supports turntables."""
47
+ options = options or RenderOptions()
48
+ options.validate()
49
+ pv = _require_pyvista()
50
+ mesh = load_mesh(mesh_path, settings)
51
+ out = prepare_output_file(output_path, allowed_extensions={".png"}, overwrite=True)
52
+ _check_outputs_free(out, options.turntable_frames, overwrite)
53
+
54
+ plotter = pv.Plotter(off_screen=True, window_size=(options.width, options.height))
55
+ plotter.set_background(options.background)
56
+ plotter.add_mesh(
57
+ pv.wrap(mesh),
58
+ color=options.color,
59
+ smooth_shading=options.shading != "flat",
60
+ pbr=True,
61
+ metallic=options.metallic,
62
+ roughness=options.roughness,
63
+ )
64
+ if options.ground:
65
+ plotter.enable_shadows()
66
+ targets = _expected_outputs(out, options.turntable_frames)
67
+ step = 360.0 / max(len(targets), 1)
68
+ plotter.camera_position = "iso"
69
+ plotter.camera.azimuth = options.azimuth_deg
70
+ plotter.camera.elevation = options.elevation_deg
71
+ plotter.reset_camera()
72
+ files: list[str] = []
73
+ for index, target in enumerate(targets):
74
+ if index:
75
+ plotter.camera.azimuth = options.azimuth_deg + step * index
76
+ plotter.screenshot(str(target), transparent_background=options.transparent)
77
+ files.append(str(target))
78
+ plotter.close()
79
+ logger.info("PyVista rendered %d image(s)", len(files))
80
+ return RenderResult(backend="pyvista", files=files, renderer_version=pv.__version__)
@@ -0,0 +1,33 @@
1
+ """Reverse engineering: mesh repair, measurement, primitive fitting and CAD export."""
2
+
3
+ from reverse3d.reverse_eng.geometry_fit import (
4
+ FitReport,
5
+ PrimitiveFit,
6
+ detect_primitives,
7
+ fit_primitives_file,
8
+ )
9
+ from reverse3d.reverse_eng.measure import MeshStats, compute_stats, measure_mesh_file
10
+ from reverse3d.reverse_eng.mesh_cleaner import (
11
+ CleanOptions,
12
+ CleanReport,
13
+ clean_mesh,
14
+ clean_mesh_file,
15
+ )
16
+ from reverse3d.reverse_eng.parametric import export_parametric, generate_cadquery, generate_openscad
17
+
18
+ __all__ = [
19
+ "CleanOptions",
20
+ "CleanReport",
21
+ "FitReport",
22
+ "MeshStats",
23
+ "PrimitiveFit",
24
+ "clean_mesh",
25
+ "clean_mesh_file",
26
+ "compute_stats",
27
+ "detect_primitives",
28
+ "export_parametric",
29
+ "fit_primitives_file",
30
+ "generate_cadquery",
31
+ "generate_openscad",
32
+ "measure_mesh_file",
33
+ ]