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.
- reverse3d/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- 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
|
+
]
|