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
reverse3d/mesh_io.py ADDED
@@ -0,0 +1,152 @@
1
+ """Safe mesh / point-cloud I/O on top of trimesh.
2
+
3
+ Security notes:
4
+
5
+ * Only allowlisted formats are parsed (no pickle-based or executable formats).
6
+ * ``.gltf`` and ``.obj`` may reference sibling files (buffers, textures, .mtl).
7
+ :class:`SandboxedResolver` only serves files located inside the model's own
8
+ directory, so a crafted model cannot read ``../../secrets`` (CWE-22).
9
+ * Face counts are bounded by ``Settings.max_mesh_faces`` after loading.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import unquote
18
+
19
+ import numpy as np
20
+ import trimesh
21
+ from trimesh.resolvers import FilePathResolver
22
+
23
+ from reverse3d.config import Settings
24
+ from reverse3d.errors import InputValidationError, SecurityViolationError
25
+ from reverse3d.security import (
26
+ MESH_EXTENSIONS,
27
+ POINT_CLOUD_EXTENSIONS,
28
+ prepare_output_file,
29
+ validate_input_file,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ Geometry = trimesh.Trimesh | trimesh.PointCloud
35
+
36
+
37
+ class SandboxedResolver(FilePathResolver):
38
+ """A trimesh resolver that refuses to read or write outside its directory."""
39
+
40
+ def __init__(self, source: str | Path) -> None:
41
+ super().__init__(str(source))
42
+ self._root = Path(self.parent).resolve()
43
+
44
+ def _check(self, name: str) -> None:
45
+ for variant in {name, unquote(name)}:
46
+ target = (self._root / variant.strip()).resolve()
47
+ if target != self._root and not target.is_relative_to(self._root):
48
+ raise SecurityViolationError("Model references a file outside its own directory.")
49
+
50
+ def get(self, name: str) -> bytes:
51
+ self._check(name)
52
+ return super().get(name)
53
+
54
+ def write(self, name: str, data: bytes | str) -> None:
55
+ self._check(name)
56
+ super().write(name, data)
57
+
58
+
59
+ def _check_faces(mesh: trimesh.Trimesh, settings: Settings) -> None:
60
+ if len(mesh.faces) > settings.max_mesh_faces:
61
+ raise InputValidationError(
62
+ f"Mesh has too many faces ({len(mesh.faces):,} > {settings.max_mesh_faces:,}).",
63
+ hint="Decimate it first or raise REVERSE3D_MAX_MESH_FACES.",
64
+ )
65
+
66
+
67
+ def load_geometry(path: str | Path, settings: Settings) -> Geometry:
68
+ """Load a mesh or point cloud from an allowlisted format.
69
+
70
+ Scenes (glTF/GLB with many nodes) are flattened into a single mesh with
71
+ node transforms applied.
72
+
73
+ Raises:
74
+ InputValidationError: unsupported, unreadable, empty or oversize input.
75
+ """
76
+ allowed = MESH_EXTENSIONS | POINT_CLOUD_EXTENSIONS
77
+ resolved = validate_input_file(
78
+ path, allowed_extensions=allowed, max_bytes=settings.max_input_bytes
79
+ )
80
+ loaded: Any
81
+ try:
82
+ loaded = trimesh.load(
83
+ str(resolved),
84
+ file_type=resolved.suffix.lower().lstrip("."),
85
+ resolver=SandboxedResolver(resolved),
86
+ # Merge duplicated vertices: STL stores unshared triangle soups and
87
+ # topology metrics (watertight, volume, holes) would be meaningless.
88
+ process=True,
89
+ )
90
+ except SecurityViolationError:
91
+ raise
92
+ except Exception as exc: # trimesh raises many exception types for bad files
93
+ raise InputValidationError(f"Could not parse geometry file: {resolved.name}") from exc
94
+
95
+ if isinstance(loaded, trimesh.Scene):
96
+ if not loaded.geometry:
97
+ raise InputValidationError(f"Scene contains no geometry: {resolved.name}")
98
+ loaded = loaded.to_mesh() if hasattr(loaded, "to_mesh") else loaded.dump(concatenate=True)
99
+
100
+ if isinstance(loaded, trimesh.Trimesh):
101
+ if len(loaded.faces) == 0:
102
+ # A PLY with vertices only is a point cloud.
103
+ if len(loaded.vertices) == 0:
104
+ raise InputValidationError(f"Empty geometry: {resolved.name}")
105
+ return trimesh.PointCloud(loaded.vertices)
106
+ _check_faces(loaded, settings)
107
+ return loaded
108
+ if isinstance(loaded, trimesh.PointCloud):
109
+ if len(loaded.vertices) == 0:
110
+ raise InputValidationError(f"Empty point cloud: {resolved.name}")
111
+ return loaded
112
+ raise InputValidationError(f"Unsupported geometry type in {resolved.name}")
113
+
114
+
115
+ def load_mesh(path: str | Path, settings: Settings) -> trimesh.Trimesh:
116
+ """Like :func:`load_geometry` but requires a triangle mesh."""
117
+ geometry = load_geometry(path, settings)
118
+ if not isinstance(geometry, trimesh.Trimesh):
119
+ raise InputValidationError(
120
+ "File contains a point cloud, not a mesh.",
121
+ hint="Use the photogrammetry/meshing tools to build a surface first.",
122
+ )
123
+ return geometry
124
+
125
+
126
+ def save_mesh(mesh: trimesh.Trimesh, path: str | Path, *, overwrite: bool = False) -> Path:
127
+ """Export ``mesh`` to an allowlisted format inferred from the extension."""
128
+ out = prepare_output_file(path, allowed_extensions=MESH_EXTENSIONS, overwrite=overwrite)
129
+ file_type = out.suffix.lower().lstrip(".")
130
+ if file_type == "gltf":
131
+ # Embed buffers so the export is a single self-contained file.
132
+ data = trimesh.exchange.gltf.export_gltf(mesh.scene(), embed_buffers=True)
133
+ out.write_bytes(data["model.gltf"])
134
+ else:
135
+ mesh.export(str(out), file_type=file_type)
136
+ logger.info("Saved mesh %s (%d faces)", out.name, len(mesh.faces))
137
+ return out
138
+
139
+
140
+ def sample_points(
141
+ geometry: Geometry, count: int, *, seed: int = 0
142
+ ) -> tuple[np.ndarray, np.ndarray | None]:
143
+ """Sample up to ``count`` points (and normals for meshes) from ``geometry``."""
144
+ if isinstance(geometry, trimesh.Trimesh):
145
+ points, face_idx = trimesh.sample.sample_surface(geometry, count, seed=seed)
146
+ normals = geometry.face_normals[face_idx]
147
+ return np.asarray(points, dtype=np.float64), np.asarray(normals, dtype=np.float64)
148
+ vertices = np.asarray(geometry.vertices, dtype=np.float64)
149
+ if len(vertices) > count:
150
+ rng = np.random.default_rng(seed)
151
+ vertices = vertices[rng.choice(len(vertices), count, replace=False)]
152
+ return vertices, None
reverse3d/offline.py ADDED
@@ -0,0 +1,313 @@
1
+ """Offline mode: keep working without internet and switch to it automatically.
2
+
3
+ Everything reverse3d computes runs locally (OpenCV, COLMAP, Open3D, trimesh,
4
+ Blender). The only part that ever needs the network is the AI depth model,
5
+ downloaded from Hugging Face the first time it is used. This module removes
6
+ that dependency:
7
+
8
+ * :func:`has_internet` is a cheap, cached TCP probe (no HTTP, no data sent).
9
+ * Models live in ``~/.reverse3d/models``. :func:`prepare` downloads the
10
+ allowlisted ones in advance (``reverse3d offline prepare``; the installer runs
11
+ it too), fetching only ``*.safetensors`` weights and small config files.
12
+ * :func:`resolve_model` always prefers the local copy, so a prepared machine
13
+ never touches the network, and fails with a clear hint when a model is
14
+ missing and there is no connection.
15
+ * :func:`offline_status` tells the user, in one call, what works offline.
16
+
17
+ ``Settings.offline`` (``REVERSE3D_OFFLINE`` / ``reverse3d config set offline``):
18
+ ``auto`` (default) detects the connection, ``on`` never uses the network,
19
+ ``off`` skips the probe and assumes the network is available.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import importlib.util
25
+ import logging
26
+ import os
27
+ import socket
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from reverse3d.config import Settings
33
+ from reverse3d.errors import DependencyMissingError, InputValidationError, Reverse3DError
34
+ from reverse3d.userconfig import home_dir
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ #: Hosts probed to decide whether the machine is online. Hugging Face is the
39
+ #: only service reverse3d downloads from at run time; 1.1.1.1 covers networks
40
+ #: where it is blocked but the internet works.
41
+ _PROBE_HOSTS: tuple[tuple[str, int], ...] = (("huggingface.co", 443), ("1.1.1.1", 443))
42
+ _PROBE_TIMEOUT_S = 1.5
43
+ _CACHE_TTL_S = 60.0
44
+ _cache: tuple[float, bool] | None = None
45
+
46
+ #: Only these files are downloaded: safetensors weights and small configs.
47
+ #: Pickled ``*.bin`` checkpoints (code execution on load) are never fetched.
48
+ MODEL_FILE_PATTERNS = ["*.json", "*.safetensors", "*.txt"]
49
+
50
+ #: Pinned Hugging Face commit of every allowlisted model: only this exact
51
+ #: revision is downloaded or accepted from disk, so a changed upstream
52
+ #: repository cannot alter what runs (bump together with a review).
53
+ MODEL_REVISIONS: dict[str, str] = {
54
+ "depth-anything/Depth-Anything-V2-Small-hf": "5426e4f0f36572d16453bbda7a8389317b1bef99",
55
+ "depth-anything/Depth-Anything-V2-Base-hf": "b1958afc87fb45a9e3746cb387596094de553ed8",
56
+ "depth-anything/Depth-Anything-V2-Large-hf": "7581137eff8d4e94f6e796d3baea0e9fa79b22d2",
57
+ }
58
+
59
+
60
+ def models_dir() -> Path:
61
+ """Folder that holds the downloaded AI models (``~/.reverse3d/models``)."""
62
+ return home_dir() / "models"
63
+
64
+
65
+ def _reachable(hosts: tuple[tuple[str, int], ...]) -> bool:
66
+ for host, port in hosts:
67
+ try:
68
+ with socket.create_connection((host, port), timeout=_PROBE_TIMEOUT_S):
69
+ return True
70
+ except OSError:
71
+ continue
72
+ return False
73
+
74
+
75
+ def _probe() -> bool:
76
+ return _reachable(_PROBE_HOSTS)
77
+
78
+
79
+ def has_internet(*, refresh: bool = False) -> bool:
80
+ """Return True if the internet is reachable. The answer is cached for a minute."""
81
+ global _cache
82
+ now = time.monotonic()
83
+ if not refresh and _cache is not None and now - _cache[0] < _CACHE_TTL_S:
84
+ return _cache[1]
85
+ online = _probe()
86
+ _cache = (now, online)
87
+ if not online:
88
+ logger.info("No internet connection detected: working offline.")
89
+ return online
90
+
91
+
92
+ def is_offline(settings: Settings) -> bool:
93
+ """Whether reverse3d must avoid the network right now."""
94
+ if settings.offline == "on":
95
+ return True
96
+ if settings.offline == "off":
97
+ return False
98
+ return not has_internet()
99
+
100
+
101
+ def _allowed_models() -> dict[str, tuple[str, str]]:
102
+ from reverse3d.reconstruction.depth_estimation import ALLOWED_MODELS
103
+
104
+ return dict(ALLOWED_MODELS)
105
+
106
+
107
+ def _default_model() -> str:
108
+ from reverse3d.reconstruction.depth_estimation import DEFAULT_MODEL
109
+
110
+ return DEFAULT_MODEL
111
+
112
+
113
+ def _cache_folder(model_id: str) -> Path:
114
+ return models_dir() / ("models--" + model_id.replace("/", "--"))
115
+
116
+
117
+ def local_model_path(model_id: str) -> Path | None:
118
+ """Return the local snapshot folder of ``model_id`` if its weights are on disk.
119
+
120
+ Pure filesystem check: it never imports Hugging Face nor touches the network.
121
+ """
122
+ revision = MODEL_REVISIONS.get(model_id)
123
+ if revision is None:
124
+ return None
125
+ snapshot = _cache_folder(model_id) / "snapshots" / revision
126
+ return snapshot if snapshot.is_dir() and any(snapshot.glob("*.safetensors")) else None
127
+
128
+
129
+ def _folder_mb(path: Path) -> float:
130
+ total = 0
131
+ for file in path.rglob("*"):
132
+ try:
133
+ if file.is_file():
134
+ total += file.stat().st_size # resolves the symlinked blobs
135
+ except OSError:
136
+ continue
137
+ return round(total / (1024 * 1024), 1)
138
+
139
+
140
+ def _depth_stack_installed() -> bool:
141
+ return all(
142
+ importlib.util.find_spec(m) is not None
143
+ for m in ("torch", "transformers", "huggingface_hub")
144
+ )
145
+
146
+
147
+ def _download(model_id: str) -> Path:
148
+ from huggingface_hub import snapshot_download
149
+
150
+ path = snapshot_download(
151
+ model_id,
152
+ revision=MODEL_REVISIONS[model_id],
153
+ cache_dir=str(models_dir()),
154
+ allow_patterns=MODEL_FILE_PATTERNS,
155
+ )
156
+ return Path(path)
157
+
158
+
159
+ def resolve_model(model_id: str, settings: Settings) -> Path:
160
+ """Return a local folder with the files of an allowlisted model.
161
+
162
+ Uses the local copy when there is one (no network at all). Otherwise it
163
+ downloads the model if the machine is online.
164
+
165
+ Raises:
166
+ InputValidationError: the model is not in the allowlist.
167
+ DependencyMissingError: the model is not on disk and cannot be downloaded
168
+ (offline, or the download failed).
169
+ """
170
+ if model_id not in _allowed_models() or model_id not in MODEL_REVISIONS:
171
+ raise InputValidationError(
172
+ f"Model '{model_id}' is not in the allowlist.",
173
+ hint=f"Allowed: {', '.join(_allowed_models())}",
174
+ )
175
+ local = local_model_path(model_id)
176
+ if local is not None:
177
+ return local
178
+ if is_offline(settings):
179
+ raise DependencyMissingError(
180
+ "The AI depth model is not on this computer and there is no internet connection.",
181
+ hint="Connect to the internet once and run: reverse3d offline prepare",
182
+ )
183
+ try:
184
+ return _download(model_id)
185
+ except ImportError as exc:
186
+ raise DependencyMissingError(
187
+ "huggingface_hub is required to download AI models.",
188
+ hint='pip install "reverse3d[depth]"',
189
+ ) from exc
190
+ except Exception as exc: # network errors come in many shapes; report them plainly
191
+ has_internet(refresh=True)
192
+ raise DependencyMissingError(
193
+ f"Could not download the AI depth model ({exc.__class__.__name__}).",
194
+ hint="Check your connection and run: reverse3d offline prepare",
195
+ ) from exc
196
+
197
+
198
+ def prepare(settings: Settings, *, include_non_commercial: bool = False) -> dict[str, Any]:
199
+ """Download the allowlisted AI models so reverse3d works without internet.
200
+
201
+ Safe to run any time: models already on disk are skipped. It never raises
202
+ for a missing optional stack; that is reported in the result instead.
203
+ """
204
+ models = [_default_model()]
205
+ if include_non_commercial:
206
+ models += [m for m in _allowed_models() if m not in models]
207
+
208
+ if not _depth_stack_installed():
209
+ return {
210
+ "ready": True,
211
+ "models_dir": str(models_dir()),
212
+ "models": [
213
+ {"model": m, "status": "skipped", "detail": "AI depth extra not installed"}
214
+ for m in models
215
+ ],
216
+ "message": "Nothing to download: AI depth is not installed and everything else "
217
+ "already works offline.",
218
+ }
219
+
220
+ results: list[dict[str, Any]] = []
221
+ online = not is_offline(settings)
222
+ for model_id in models:
223
+ local = local_model_path(model_id)
224
+ if local is not None:
225
+ results.append(
226
+ {
227
+ "model": model_id,
228
+ "status": "ready",
229
+ "path": str(local),
230
+ "size_mb": _folder_mb(local),
231
+ }
232
+ )
233
+ continue
234
+ if not online:
235
+ results.append(
236
+ {
237
+ "model": model_id,
238
+ "status": "missing",
239
+ "detail": "No internet connection: connect and run this again.",
240
+ }
241
+ )
242
+ continue
243
+ try:
244
+ path = resolve_model(model_id, settings)
245
+ except Reverse3DError as exc:
246
+ results.append({"model": model_id, "status": "failed", "detail": exc.message})
247
+ continue
248
+ results.append(
249
+ {
250
+ "model": model_id,
251
+ "status": "downloaded",
252
+ "path": str(path),
253
+ "size_mb": _folder_mb(path),
254
+ }
255
+ )
256
+
257
+ ready = all(r["status"] in ("ready", "downloaded") for r in results)
258
+ return {
259
+ "ready": ready,
260
+ "models_dir": str(models_dir()),
261
+ "models": results,
262
+ "message": (
263
+ "Ready: reverse3d now works without internet."
264
+ if ready
265
+ else "Some models are missing: connect to the internet and run "
266
+ "`reverse3d offline prepare`."
267
+ ),
268
+ }
269
+
270
+
271
+ def offline_status(settings: Settings) -> dict[str, Any]:
272
+ """Report what works right now and what would work without internet."""
273
+ from reverse3d.doctor import environment_report
274
+
275
+ env = environment_report(settings, include_offline=False)
276
+ caps: dict[str, bool] = env["capabilities"] # type: ignore[assignment]
277
+ depth_installed = bool(caps.get("depth_estimation"))
278
+ model_on_disk = local_model_path(_default_model()) is not None
279
+ internet = None if settings.offline != "auto" else has_internet()
280
+
281
+ components = {
282
+ "measure_repair_cad": {"available": True, "offline": True},
283
+ "plan_to_part": {"available": True, "offline": True},
284
+ "photogrammetry": {"available": caps.get("photogrammetry", False), "offline": True},
285
+ "dense_meshing": {"available": caps.get("dense_meshing", False), "offline": True},
286
+ "render": {
287
+ "available": caps.get("render_blender", False) or caps.get("render_pyvista", False),
288
+ "offline": True,
289
+ },
290
+ "ai_depth": {"available": depth_installed, "offline": depth_installed and model_on_disk},
291
+ }
292
+ missing = []
293
+ if depth_installed and not model_on_disk:
294
+ missing.append(
295
+ "AI depth model not downloaded yet: run `reverse3d offline prepare` while online."
296
+ )
297
+ working_offline = is_offline(settings)
298
+ return {
299
+ "mode": settings.offline,
300
+ "internet": internet,
301
+ "working_offline": working_offline,
302
+ "ready_for_offline": not missing,
303
+ "models_dir": str(models_dir()),
304
+ "components": components,
305
+ "missing": missing,
306
+ "message": ("Everything installed works without internet." if not missing else missing[0]),
307
+ }
308
+
309
+
310
+ def force_offline_env() -> None:
311
+ """Tell Hugging Face libraries not to reach the network in this process."""
312
+ os.environ["HF_HUB_OFFLINE"] = "1"
313
+ os.environ["TRANSFORMERS_OFFLINE"] = "1"
reverse3d/profiles.py ADDED
@@ -0,0 +1,33 @@
1
+ """Performance profiles: one knob that trades speed for quality everywhere.
2
+
3
+ ``fast`` is for previews and weak machines, ``balanced`` is the default and
4
+ ``quality`` is for final deliverables. Explicit tool arguments always win over
5
+ the profile.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from reverse3d.config import Settings
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ProfileValues:
17
+ photo_quality: str
18
+ render_samples: int
19
+ render_width: int
20
+ render_height: int
21
+ depth_stride: int
22
+ fit_samples: int
23
+
24
+
25
+ PROFILES: dict[str, ProfileValues] = {
26
+ "fast": ProfileValues("low", 16, 960, 720, 4, 8_000),
27
+ "balanced": ProfileValues("medium", 64, 1600, 1200, 2, 20_000),
28
+ "quality": ProfileValues("high", 256, 2400, 1800, 1, 50_000),
29
+ }
30
+
31
+
32
+ def profile_values(settings: Settings) -> ProfileValues:
33
+ return PROFILES[settings.profile]
reverse3d/py.typed ADDED
File without changes