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,460 @@
1
+ """Photogrammetry pipeline built on the COLMAP command-line interface.
2
+
3
+ Stages::
4
+
5
+ stage images → feature_extractor → (exhaustive|sequential)_matcher → mapper
6
+ → model_analyzer (pick largest model) → model_converter (sparse PLY)
7
+ → [dense: image_undistorter → patch_match_stereo → stereo_fusion] (CUDA)
8
+ → [mesh: Poisson via Open3D] ([recon])
9
+
10
+ Design notes:
11
+
12
+ * COLMAP option names changed across releases (e.g. ``SiftExtraction.use_gpu``
13
+ became ``FeatureExtraction.use_gpu`` in 3.12). Instead of pinning a version,
14
+ each stage probes ``<command> -h`` once and uses whichever option exists.
15
+ * Input images are validated and **copied** into ``<output>/images`` so COLMAP
16
+ only ever reads files that passed the allowlist and size checks.
17
+ * Dense MVS requires CUDA. With ``dense="auto"`` a dense failure degrades
18
+ gracefully to the sparse cloud and is reported as a warning.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import re
25
+ import shutil
26
+ import time
27
+ from dataclasses import asdict, dataclass, field, replace
28
+ from pathlib import Path
29
+ from typing import Literal
30
+
31
+ from reverse3d.config import Settings
32
+ from reverse3d.errors import (
33
+ DependencyMissingError,
34
+ ExternalToolError,
35
+ InputValidationError,
36
+ ProcessingError,
37
+ )
38
+ from reverse3d.security import (
39
+ list_images,
40
+ prepare_output_dir,
41
+ resolve_executable,
42
+ run_command,
43
+ validate_input_dir,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ Quality = Literal["low", "medium", "high"]
49
+ MatcherKind = Literal["auto", "exhaustive", "sequential"]
50
+ DenseMode = Literal["auto", "on", "off"]
51
+
52
+ CAMERA_MODELS = frozenset(
53
+ {"SIMPLE_PINHOLE", "PINHOLE", "SIMPLE_RADIAL", "RADIAL", "OPENCV", "FULL_OPENCV"}
54
+ )
55
+
56
+ _PRESETS: dict[str, dict[str, int]] = {
57
+ "low": {"max_image_size": 1000, "max_features": 4096, "poisson_depth": 8},
58
+ "medium": {"max_image_size": 1600, "max_features": 8192, "poisson_depth": 9},
59
+ "high": {"max_image_size": 3200, "max_features": 16384, "poisson_depth": 10},
60
+ }
61
+
62
+ # Above this many images exhaustive matching (O(n²)) becomes impractical.
63
+ _EXHAUSTIVE_LIMIT = 150
64
+
65
+ # Names of the artifacts this pipeline creates; only these are removed on overwrite.
66
+ _ARTIFACTS = ("database.db", "images", "sparse", "dense", "sparse_points.ply", "mesh.ply")
67
+
68
+
69
+ @dataclass
70
+ class PhotogrammetryOptions:
71
+ """User-facing options of :meth:`ColmapPipeline.run`."""
72
+
73
+ quality: Quality = "medium"
74
+ matcher: MatcherKind = "auto"
75
+ dense: DenseMode = "auto"
76
+ use_gpu: bool = False
77
+ camera_model: str = "SIMPLE_RADIAL"
78
+ single_camera: bool = True
79
+ build_mesh: bool = True
80
+
81
+ def validate(self) -> None:
82
+ if self.quality not in _PRESETS:
83
+ raise InputValidationError(f"quality must be one of {sorted(_PRESETS)}")
84
+ if self.matcher not in ("auto", "exhaustive", "sequential"):
85
+ raise InputValidationError("matcher must be auto, exhaustive or sequential")
86
+ if self.dense not in ("auto", "on", "off"):
87
+ raise InputValidationError("dense must be auto, on or off")
88
+ if self.camera_model not in CAMERA_MODELS:
89
+ raise InputValidationError(f"camera_model must be one of {sorted(CAMERA_MODELS)}")
90
+
91
+
92
+ @dataclass
93
+ class StageTiming:
94
+ name: str
95
+ seconds: float
96
+
97
+
98
+ @dataclass
99
+ class PhotogrammetryResult:
100
+ """Outputs of a photogrammetry run (paths are absolute strings)."""
101
+
102
+ output_dir: str
103
+ sparse_model_dir: str
104
+ sparse_points_path: str
105
+ dense_points_path: str | None
106
+ mesh_path: str | None
107
+ total_images: int
108
+ registered_images: int
109
+ sparse_points: int
110
+ stages: list[StageTiming] = field(default_factory=list)
111
+ warnings: list[str] = field(default_factory=list)
112
+
113
+ def to_dict(self) -> dict[str, object]:
114
+ return asdict(self)
115
+
116
+
117
+ class ColmapPipeline:
118
+ """Thin, safe wrapper around the ``colmap`` executable."""
119
+
120
+ def __init__(self, settings: Settings, executable: Path | None = None) -> None:
121
+ self.settings = settings
122
+ self.executable = executable or resolve_executable(
123
+ ["colmap", "COLMAP", "colmap.exe", "COLMAP.bat"], settings.colmap_path, tool="colmap"
124
+ )
125
+ self._help_cache: dict[str, str] = {}
126
+
127
+ # ── helpers ───────────────────────────────────────────────────────────────
128
+
129
+ def _help(self, command: str) -> str:
130
+ if command not in self._help_cache:
131
+ proc = run_command(
132
+ self.executable, [command, "-h"], timeout_s=60, tool="colmap", allow_nonzero=True
133
+ )
134
+ self._help_cache[command] = (proc.stdout or "") + (proc.stderr or "")
135
+ return self._help_cache[command]
136
+
137
+ def _pick_option(self, command: str, candidates: tuple[str, ...]) -> str | None:
138
+ help_text = self._help(command)
139
+ for option in candidates:
140
+ if f"--{option}" in help_text:
141
+ return option
142
+ return None
143
+
144
+ def _with_options(
145
+ self, command: str, args: list[str], options: dict[tuple[str, ...], str]
146
+ ) -> list[str]:
147
+ for candidates, value in options.items():
148
+ name = self._pick_option(command, candidates)
149
+ if name:
150
+ args += [f"--{name}", value]
151
+ else:
152
+ logger.debug("COLMAP %s: none of %s supported, skipping", command, candidates)
153
+ return args
154
+
155
+ def _stage(self, result: PhotogrammetryResult, name: str, args: list[str]) -> str:
156
+ start = time.perf_counter()
157
+ logger.info("COLMAP stage: %s", name)
158
+ proc = run_command(
159
+ self.executable,
160
+ args,
161
+ timeout_s=self.settings.subprocess_timeout_s,
162
+ tool=f"colmap {name}",
163
+ )
164
+ result.stages.append(StageTiming(name, round(time.perf_counter() - start, 2)))
165
+ return (proc.stdout or "") + (proc.stderr or "")
166
+
167
+ def version(self) -> str:
168
+ """Return the COLMAP version banner (first line of ``colmap help``)."""
169
+ proc = run_command(
170
+ self.executable, ["help"], timeout_s=60, tool="colmap", allow_nonzero=True
171
+ )
172
+ text = (proc.stdout or proc.stderr or "").strip()
173
+ return text.splitlines()[0] if text else "unknown"
174
+
175
+ def has_cuda(self) -> bool:
176
+ """False only when COLMAP's banner states it was built ``without CUDA``."""
177
+ proc = run_command(
178
+ self.executable, ["help"], timeout_s=60, tool="colmap", allow_nonzero=True
179
+ )
180
+ return "without cuda" not in ((proc.stdout or "") + (proc.stderr or "")).lower()
181
+
182
+ # ── pipeline ──────────────────────────────────────────────────────────────
183
+
184
+ def _prepare_workspace(self, image_dir: Path, output_dir: Path, overwrite: bool) -> list[Path]:
185
+ images = list_images(
186
+ image_dir, max_images=self.settings.max_images, max_bytes=self.settings.max_input_bytes
187
+ )
188
+ if len(images) < 3:
189
+ raise InputValidationError("Photogrammetry needs at least 3 images.")
190
+ existing = [name for name in _ARTIFACTS if (output_dir / name).exists()]
191
+ if existing and not overwrite:
192
+ raise InputValidationError(
193
+ "Output directory already contains a reconstruction.",
194
+ hint="Use another output_dir or pass overwrite=true.",
195
+ )
196
+ for name in existing:
197
+ target = output_dir / name
198
+ if target.is_dir():
199
+ shutil.rmtree(target)
200
+ else:
201
+ target.unlink()
202
+ staged = output_dir / "images"
203
+ staged.mkdir()
204
+ for img in images:
205
+ shutil.copy2(img, staged / img.name)
206
+ return images
207
+
208
+ @staticmethod
209
+ def _parse_model_stats(text: str) -> tuple[int, int]:
210
+ def grab(label: str) -> int:
211
+ match = re.search(rf"{label}\s*:\s*(\d+)", text)
212
+ return int(match.group(1)) if match else 0
213
+
214
+ return grab("Registered images"), grab("Points")
215
+
216
+ def run(
217
+ self,
218
+ image_dir: str | Path,
219
+ output_dir: str | Path,
220
+ options: PhotogrammetryOptions | None = None,
221
+ *,
222
+ overwrite: bool = False,
223
+ ) -> PhotogrammetryResult:
224
+ """Run the full pipeline and return paths and statistics.
225
+
226
+ Raises:
227
+ InputValidationError: invalid options or image folder.
228
+ ExternalToolError: a COLMAP stage failed.
229
+ ProcessingError: no model could be reconstructed.
230
+ """
231
+ options = options or PhotogrammetryOptions()
232
+ options.validate()
233
+ preset = _PRESETS[options.quality]
234
+ src = validate_input_dir(image_dir)
235
+ out = prepare_output_dir(output_dir)
236
+ # Overwrite deletes our artifact folders; never let that touch the source photos.
237
+ if any(src.is_relative_to(out / name) for name in _ARTIFACTS):
238
+ raise InputValidationError(
239
+ "image_dir must not be inside the reconstruction artifacts of output_dir."
240
+ )
241
+ images = self._prepare_workspace(src, out, overwrite)
242
+
243
+ db = out / "database.db"
244
+ staged = out / "images"
245
+ sparse_root = out / "sparse"
246
+ sparse_root.mkdir()
247
+ result = PhotogrammetryResult(
248
+ output_dir=str(out),
249
+ sparse_model_dir="",
250
+ sparse_points_path="",
251
+ dense_points_path=None,
252
+ mesh_path=None,
253
+ total_images=len(images),
254
+ registered_images=0,
255
+ sparse_points=0,
256
+ )
257
+ if options.use_gpu and not self.has_cuda():
258
+ # A CPU-only COLMAP build fails (or uses headless-incompatible OpenGL)
259
+ # with use_gpu=1, so degrade instead of failing the whole job.
260
+ options = replace(options, use_gpu=False)
261
+ result.warnings.append("COLMAP was built without CUDA; running on CPU.")
262
+ gpu = "1" if options.use_gpu else "0"
263
+
264
+ # 1. Features
265
+ args = [
266
+ "feature_extractor",
267
+ "--database_path",
268
+ str(db),
269
+ "--image_path",
270
+ str(staged),
271
+ "--ImageReader.camera_model",
272
+ options.camera_model,
273
+ "--ImageReader.single_camera",
274
+ "1" if options.single_camera else "0",
275
+ ]
276
+ self._stage(
277
+ result,
278
+ "feature_extractor",
279
+ self._with_options(
280
+ "feature_extractor",
281
+ args,
282
+ {
283
+ ("FeatureExtraction.use_gpu", "SiftExtraction.use_gpu"): gpu,
284
+ ("FeatureExtraction.max_image_size", "SiftExtraction.max_image_size"): str(
285
+ preset["max_image_size"]
286
+ ),
287
+ ("SiftExtraction.max_num_features",): str(preset["max_features"]),
288
+ },
289
+ ),
290
+ )
291
+
292
+ # 2. Matching
293
+ matcher = options.matcher
294
+ if matcher == "auto":
295
+ matcher = "exhaustive" if len(images) <= _EXHAUSTIVE_LIMIT else "sequential"
296
+ command = f"{matcher}_matcher"
297
+ self._stage(
298
+ result,
299
+ command,
300
+ self._with_options(
301
+ command,
302
+ [command, "--database_path", str(db)],
303
+ {("FeatureMatching.use_gpu", "SiftMatching.use_gpu"): gpu},
304
+ ),
305
+ )
306
+
307
+ # 3. Sparse reconstruction (SfM)
308
+ self._stage(
309
+ result,
310
+ "mapper",
311
+ [
312
+ "mapper",
313
+ "--database_path",
314
+ str(db),
315
+ "--image_path",
316
+ str(staged),
317
+ "--output_path",
318
+ str(sparse_root),
319
+ ],
320
+ )
321
+ models = sorted(p for p in sparse_root.iterdir() if p.is_dir())
322
+ if not models:
323
+ raise ProcessingError(
324
+ "COLMAP could not register the images into a 3D model.",
325
+ hint="Run the image analysis tool: more overlap, texture and sharpness are needed.",
326
+ )
327
+ best, best_stats = models[0], (0, 0)
328
+ for model in models:
329
+ stats = self._parse_model_stats(
330
+ self._stage(
331
+ result, f"model_analyzer:{model.name}", ["model_analyzer", "--path", str(model)]
332
+ )
333
+ )
334
+ if stats[0] > best_stats[0]:
335
+ best, best_stats = model, stats
336
+ result.sparse_model_dir = str(best)
337
+ result.registered_images, result.sparse_points = best_stats
338
+ if result.registered_images and result.registered_images < len(images) * 0.5:
339
+ result.warnings.append(
340
+ f"Only {result.registered_images}/{len(images)} images were registered."
341
+ )
342
+
343
+ sparse_ply = out / "sparse_points.ply"
344
+ self._stage(
345
+ result,
346
+ "model_converter",
347
+ [
348
+ "model_converter",
349
+ "--input_path",
350
+ str(best),
351
+ "--output_path",
352
+ str(sparse_ply),
353
+ "--output_type",
354
+ "PLY",
355
+ ],
356
+ )
357
+ result.sparse_points_path = str(sparse_ply)
358
+
359
+ # 4. Dense reconstruction (MVS) — CUDA only
360
+ run_dense = options.dense == "on" or (options.dense == "auto" and options.use_gpu)
361
+ if run_dense:
362
+ try:
363
+ result.dense_points_path = str(self._dense(result, staged, best, out, preset))
364
+ except ExternalToolError as exc:
365
+ if options.dense == "on":
366
+ raise
367
+ result.warnings.append(f"Dense stage skipped ({exc.message}); using sparse cloud.")
368
+ elif options.dense == "auto":
369
+ result.warnings.append("Dense MVS skipped (requires use_gpu=true and CUDA COLMAP).")
370
+
371
+ # 5. Surface
372
+ if options.build_mesh:
373
+ from reverse3d.reconstruction.pointcloud import poisson_mesh
374
+
375
+ cloud = result.dense_points_path or result.sparse_points_path
376
+ try:
377
+ meshing = poisson_mesh(
378
+ cloud,
379
+ out / "mesh.ply",
380
+ self.settings,
381
+ depth=preset["poisson_depth"],
382
+ overwrite=True,
383
+ )
384
+ result.mesh_path = meshing.mesh_path
385
+ except (DependencyMissingError, ProcessingError) as exc:
386
+ result.warnings.append(f"Meshing skipped: {exc.message}")
387
+
388
+ logger.info(
389
+ "Photogrammetry done: %d/%d images registered", result.registered_images, len(images)
390
+ )
391
+ return result
392
+
393
+ def _dense(
394
+ self,
395
+ result: PhotogrammetryResult,
396
+ staged: Path,
397
+ model: Path,
398
+ out: Path,
399
+ preset: dict[str, int],
400
+ ) -> Path:
401
+ dense = out / "dense"
402
+ self._stage(
403
+ result,
404
+ "image_undistorter",
405
+ [
406
+ "image_undistorter",
407
+ "--image_path",
408
+ str(staged),
409
+ "--input_path",
410
+ str(model),
411
+ "--output_path",
412
+ str(dense),
413
+ "--output_type",
414
+ "COLMAP",
415
+ "--max_image_size",
416
+ str(preset["max_image_size"]),
417
+ ],
418
+ )
419
+ self._stage(
420
+ result,
421
+ "patch_match_stereo",
422
+ [
423
+ "patch_match_stereo",
424
+ "--workspace_path",
425
+ str(dense),
426
+ "--workspace_format",
427
+ "COLMAP",
428
+ "--PatchMatchStereo.geom_consistency",
429
+ "true",
430
+ ],
431
+ )
432
+ fused = dense / "fused.ply"
433
+ self._stage(
434
+ result,
435
+ "stereo_fusion",
436
+ [
437
+ "stereo_fusion",
438
+ "--workspace_path",
439
+ str(dense),
440
+ "--workspace_format",
441
+ "COLMAP",
442
+ "--input_type",
443
+ "geometric",
444
+ "--output_path",
445
+ str(fused),
446
+ ],
447
+ )
448
+ return fused
449
+
450
+
451
+ def run_photogrammetry(
452
+ image_dir: str | Path,
453
+ output_dir: str | Path,
454
+ settings: Settings,
455
+ options: PhotogrammetryOptions | None = None,
456
+ *,
457
+ overwrite: bool = False,
458
+ ) -> PhotogrammetryResult:
459
+ """Convenience wrapper: locate COLMAP and run :class:`ColmapPipeline`."""
460
+ return ColmapPipeline(settings).run(image_dir, output_dir, options, overwrite=overwrite)
@@ -0,0 +1,120 @@
1
+ """Point cloud → surface meshing with Open3D (optional ``[recon]`` extra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+ from reverse3d.config import Settings
13
+ from reverse3d.errors import DependencyMissingError, InputValidationError, ProcessingError
14
+ from reverse3d.security import (
15
+ MESH_EXTENSIONS,
16
+ POINT_CLOUD_EXTENSIONS,
17
+ prepare_output_file,
18
+ validate_input_file,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def require_open3d() -> Any:
25
+ """Import Open3D or raise a helpful :class:`DependencyMissingError`."""
26
+ try:
27
+ import open3d as o3d
28
+ except ImportError as exc:
29
+ raise DependencyMissingError(
30
+ "Open3D is required for point-cloud meshing.",
31
+ hint='pip install "reverse3d[recon]" (Open3D wheels exist for Python 3.10-3.12).',
32
+ ) from exc
33
+ return o3d
34
+
35
+
36
+ @dataclass
37
+ class MeshingResult:
38
+ """Outputs of :func:`poisson_mesh`."""
39
+
40
+ mesh_path: str
41
+ input_points: int
42
+ filtered_points: int
43
+ vertices: int
44
+ faces: int
45
+ poisson_depth: int
46
+
47
+ def to_dict(self) -> dict[str, object]:
48
+ return asdict(self)
49
+
50
+
51
+ def poisson_mesh(
52
+ cloud_path: str | Path,
53
+ output_path: str | Path,
54
+ settings: Settings,
55
+ *,
56
+ depth: int = 9,
57
+ voxel_size: float | None = None,
58
+ density_quantile: float = 0.02,
59
+ outlier_std_ratio: float = 2.0,
60
+ overwrite: bool = False,
61
+ ) -> MeshingResult:
62
+ """Reconstruct a watertight-ish surface from a point cloud (screened Poisson).
63
+
64
+ Steps: optional voxel downsampling → statistical outlier removal → normal
65
+ estimation and consistent orientation → Poisson → removal of low-density
66
+ vertices (the "balloon" Poisson extrapolates into empty space) → crop to the
67
+ input bounding box.
68
+
69
+ Raises:
70
+ DependencyMissingError: Open3D not installed.
71
+ ProcessingError: too few points or empty result.
72
+ """
73
+ if not 5 <= depth <= 12:
74
+ raise InputValidationError("depth must be in [5, 12].")
75
+ if not 0 <= density_quantile < 0.5:
76
+ raise InputValidationError("density_quantile must be in [0, 0.5).")
77
+ o3d = require_open3d()
78
+
79
+ src = validate_input_file(
80
+ cloud_path, allowed_extensions=POINT_CLOUD_EXTENSIONS, max_bytes=settings.max_input_bytes
81
+ )
82
+ out = prepare_output_file(output_path, allowed_extensions=MESH_EXTENSIONS, overwrite=overwrite)
83
+
84
+ pcd = o3d.io.read_point_cloud(str(src))
85
+ n_input = len(pcd.points)
86
+ if n_input < 100:
87
+ raise ProcessingError(f"Point cloud too small for meshing ({n_input} points).")
88
+
89
+ if voxel_size:
90
+ pcd = pcd.voxel_down_sample(voxel_size)
91
+ pcd, _ = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=outlier_std_ratio)
92
+ extent = float(np.linalg.norm(pcd.get_axis_aligned_bounding_box().get_extent()))
93
+ radius = max(extent * 0.01, 1e-6)
94
+ if not pcd.has_normals():
95
+ pcd.estimate_normals(o3d.geometry.KDTreeSearchParamHybrid(radius=radius * 3, max_nn=30))
96
+ pcd.orient_normals_consistent_tangent_plane(15)
97
+
98
+ mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(pcd, depth=depth)
99
+ densities = np.asarray(densities)
100
+ if density_quantile > 0 and len(densities):
101
+ mesh.remove_vertices_by_mask(densities < np.quantile(densities, density_quantile))
102
+ mesh = mesh.crop(pcd.get_axis_aligned_bounding_box())
103
+ mesh.remove_degenerate_triangles()
104
+ mesh.remove_unreferenced_vertices()
105
+ if len(mesh.triangles) == 0:
106
+ raise ProcessingError("Poisson reconstruction produced an empty mesh.")
107
+ if len(mesh.triangles) > settings.max_mesh_faces:
108
+ mesh = mesh.simplify_quadric_decimation(settings.max_mesh_faces)
109
+
110
+ if not o3d.io.write_triangle_mesh(str(out), mesh):
111
+ raise ProcessingError(f"Open3D could not write {out.name}")
112
+ logger.info("Poisson mesh %s: %d faces", out.name, len(mesh.triangles))
113
+ return MeshingResult(
114
+ mesh_path=str(out),
115
+ input_points=n_input,
116
+ filtered_points=len(pcd.points),
117
+ vertices=len(mesh.vertices),
118
+ faces=len(mesh.triangles),
119
+ poisson_depth=depth,
120
+ )
@@ -0,0 +1,10 @@
1
+ """Automated rendering: headless Blender (Cycles/Eevee/Workbench) or PyVista."""
2
+
3
+ from reverse3d.rendering.blender_render import (
4
+ RenderOptions,
5
+ RenderResult,
6
+ render_mesh,
7
+ render_with_blender,
8
+ )
9
+
10
+ __all__ = ["RenderOptions", "RenderResult", "render_mesh", "render_with_blender"]