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/autopilot.py ADDED
@@ -0,0 +1,355 @@
1
+ """Autopilot: give it anything, it works out what it is and how to process it.
2
+
3
+ :func:`auto_run` is the engine behind ``reverse3d auto``, the ``auto`` recipe
4
+ and the watched inbox (:mod:`reverse3d.watch`). It is deterministic (plain
5
+ rules, no AI model), so it works the same offline and on any machine:
6
+
7
+ 1. :func:`detect_input` classifies the input: a folder of photos, a drawing or
8
+ silhouette, a single photo, a mesh or a point cloud.
9
+ 2. It runs the matching recipe and retries only when another setting can fix
10
+ the failure (another mask method, higher photogrammetry quality, stronger
11
+ mesh repair). Problems that need the user (blurry photos, a missing tool)
12
+ stop immediately with a clear hint.
13
+ 3. It writes ``RESUMEN.md``: what was detected, sizes, files and next steps.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from dataclasses import asdict, dataclass, replace
20
+ from pathlib import Path
21
+ from typing import Any, Literal
22
+
23
+ import cv2
24
+ import numpy as np
25
+
26
+ from reverse3d.config import Settings
27
+ from reverse3d.errors import (
28
+ CaptureQualityError,
29
+ DependencyMissingError,
30
+ ExternalToolError,
31
+ InputValidationError,
32
+ ProcessingError,
33
+ Reverse3DError,
34
+ )
35
+ from reverse3d.recipes import MaskMethod, RecipeOptions, run_recipe
36
+ from reverse3d.security import IMAGE_EXTENSIONS, MESH_EXTENSIONS, POINT_CLOUD_EXTENSIONS
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ InputKind = Literal["photos", "plan", "photo", "mesh", "point_cloud", "unknown"]
41
+
42
+ _RECIPE_FOR: dict[str, str | None] = {
43
+ "photos": "photos_to_model",
44
+ "plan": "plan_to_part",
45
+ "photo": "photo_to_relief",
46
+ "mesh": "scan_to_cad",
47
+ "point_cloud": "scan_to_cad",
48
+ "unknown": None,
49
+ }
50
+
51
+ KIND_LABEL_ES: dict[str, str] = {
52
+ "photos": "Fotos de un objeto (fotogrametría)",
53
+ "plan": "Plano, careta o silueta",
54
+ "photo": "Una foto (relieve con IA)",
55
+ "mesh": "Malla o escaneo 3D",
56
+ "point_cloud": "Nube de puntos",
57
+ "unknown": "No reconocido",
58
+ }
59
+
60
+ _MIN_PHOTOS = 3
61
+ _ANALYSIS_SIDE = 256
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Detection:
66
+ """What :func:`detect_input` found."""
67
+
68
+ kind: InputKind
69
+ recipe: str | None
70
+ reason: str
71
+ source: str
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return asdict(self)
75
+
76
+
77
+ def _visible(path: Path) -> bool:
78
+ return not path.name.startswith((".", "_", "~$"))
79
+
80
+
81
+ def _has_mesh_faces(ply: Path) -> bool:
82
+ """Read the PLY header only: a PLY with faces is a mesh, without is a point cloud."""
83
+ try:
84
+ with ply.open("rb") as fh:
85
+ header = fh.read(4096).decode("latin-1", errors="ignore")
86
+ except OSError:
87
+ return True
88
+ for line in header.splitlines():
89
+ parts = line.split()
90
+ if len(parts) == 3 and parts[:2] == ["element", "face"]:
91
+ return parts[2] != "0"
92
+ if line.strip() == "end_header":
93
+ break
94
+ return False
95
+
96
+
97
+ def looks_like_plan(image_path: Path) -> tuple[bool, str]:
98
+ """Heuristic: drawings, stencils and silhouettes are near-binary images or
99
+ have a transparent background; photos have many tones and colors."""
100
+ image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED)
101
+ if image is None:
102
+ return False, "unreadable image"
103
+ if image.ndim == 3 and image.shape[2] == 4:
104
+ transparent = float(np.mean(image[:, :, 3] < 16))
105
+ if transparent > 0.05:
106
+ return True, f"transparent background ({transparent:.0%})"
107
+ image = image[:, :, :3]
108
+ scale = _ANALYSIS_SIDE / max(image.shape[:2])
109
+ if scale < 1:
110
+ image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
111
+ if image.ndim == 2:
112
+ gray, saturation = image, 0.0
113
+ else:
114
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
115
+ saturation = float(np.mean(cv2.cvtColor(image, cv2.COLOR_BGR2HSV)[:, :, 1]))
116
+ extremes = float(np.mean((gray < 60) | (gray > 195)))
117
+ if extremes > 0.85 and saturation < 45:
118
+ return True, f"high-contrast drawing ({extremes:.0%} black/white pixels)"
119
+ return False, f"photo-like image ({extremes:.0%} black/white pixels)"
120
+
121
+
122
+ def detect_input(path: str | Path) -> Detection:
123
+ """Classify an input file or folder. Never raises for unknown content."""
124
+ src = Path(path).expanduser()
125
+ if src.is_dir():
126
+ files = [p for p in sorted(src.iterdir()) if p.is_file() and _visible(p)]
127
+ images = [p for p in files if p.suffix.lower() in IMAGE_EXTENSIONS]
128
+ meshes = [p for p in files if p.suffix.lower() in MESH_EXTENSIONS | POINT_CLOUD_EXTENSIONS]
129
+ if len(images) >= _MIN_PHOTOS:
130
+ return Detection(
131
+ "photos", "photos_to_model", f"folder with {len(images)} photos", str(src)
132
+ )
133
+ if len(images) == 1 and not meshes:
134
+ return detect_input(images[0])
135
+ if len(meshes) == 1 and not images:
136
+ return detect_input(meshes[0])
137
+ if images:
138
+ return Detection(
139
+ "unknown",
140
+ None,
141
+ f"only {len(images)} photos: photogrammetry needs at least {_MIN_PHOTOS} "
142
+ "(ideally 20-60)",
143
+ str(src),
144
+ )
145
+ return Detection("unknown", None, "no photos, drawings or 3D files in the folder", str(src))
146
+
147
+ if not src.is_file():
148
+ return Detection("unknown", None, "the path does not exist", str(src))
149
+ ext = src.suffix.lower()
150
+ if ext in IMAGE_EXTENSIONS:
151
+ is_plan, why = looks_like_plan(src)
152
+ kind: InputKind = "plan" if is_plan else "photo"
153
+ return Detection(kind, _RECIPE_FOR[kind], why, str(src))
154
+ if ext == ".ply":
155
+ kind = "mesh" if _has_mesh_faces(src) else "point_cloud"
156
+ return Detection(
157
+ kind, "scan_to_cad", f"PLY {'mesh' if kind == 'mesh' else 'point cloud'}", str(src)
158
+ )
159
+ if ext in MESH_EXTENSIONS:
160
+ return Detection("mesh", "scan_to_cad", f"{ext[1:].upper()} mesh", str(src))
161
+ if ext in POINT_CLOUD_EXTENSIONS:
162
+ return Detection("point_cloud", "scan_to_cad", f"{ext[1:].upper()} point cloud", str(src))
163
+ return Detection("unknown", None, f"unsupported file type '{ext or src.name}'", str(src))
164
+
165
+
166
+ @dataclass
167
+ class _Attempt:
168
+ label: str
169
+ options: RecipeOptions
170
+
171
+
172
+ def _attempt_plan(detection: Detection, base: RecipeOptions) -> list[_Attempt]:
173
+ if detection.kind == "plan":
174
+ _, why = looks_like_plan(Path(detection.source))
175
+ methods: list[MaskMethod] = (
176
+ ["alpha", "otsu"] if why.startswith("transparent") else ["otsu", "adaptive", "canny"]
177
+ )
178
+ return [
179
+ _Attempt(f"mask={m}", replace(base, mask_method=m, overwrite=base.overwrite or i > 0))
180
+ for i, m in enumerate(methods)
181
+ ]
182
+ if detection.kind == "photos" and base.quality != "high":
183
+ return [
184
+ _Attempt("profile quality", base),
185
+ _Attempt("quality=high", replace(base, quality="high", overwrite=True)),
186
+ ]
187
+ return [_Attempt("default", base)]
188
+
189
+
190
+ def _mesh_from_points(detection: Detection, out: Path, settings: Settings, overwrite: bool) -> Path:
191
+ from reverse3d.reconstruction.pointcloud import poisson_mesh
192
+
193
+ target = out / "mesh_from_points.ply"
194
+ poisson_mesh(detection.source, target, settings, overwrite=overwrite)
195
+ return target
196
+
197
+
198
+ def auto_run(
199
+ input_path: str | Path,
200
+ output_dir: str | Path,
201
+ settings: Settings,
202
+ options: RecipeOptions | None = None,
203
+ ) -> dict[str, Any]:
204
+ """Detect the input, run the right recipe (with smart retries) and write RESUMEN.md.
205
+
206
+ Returns the recipe summary plus an ``auto`` block (detection and attempts).
207
+
208
+ Raises:
209
+ InputValidationError: the input is not something reverse3d can process.
210
+ CaptureQualityError: the photos must be retaken (with recommendations).
211
+ DependencyMissingError: a required tool or model is not available.
212
+ Reverse3DError: every attempt failed (the last error is raised).
213
+ """
214
+ base = options or RecipeOptions()
215
+ detection = detect_input(input_path)
216
+ if detection.recipe is None:
217
+ raise InputValidationError(
218
+ f"Cannot process this input: {detection.reason}.",
219
+ hint="Use a folder with 20-60 photos, a drawing or photo, or an STL/OBJ/PLY/GLB file.",
220
+ )
221
+
222
+ out = Path(output_dir).expanduser().resolve()
223
+ out.mkdir(parents=True, exist_ok=True)
224
+ source = Path(detection.source)
225
+ if detection.kind == "point_cloud":
226
+ source = _mesh_from_points(detection, out, settings, base.overwrite)
227
+
228
+ history: list[dict[str, Any]] = []
229
+ summary: dict[str, Any] | None = None
230
+ last_error: Reverse3DError | None = None
231
+ for attempt in _attempt_plan(detection, base):
232
+ try:
233
+ summary = run_recipe(detection.recipe, source, out, settings, attempt.options)
234
+ except (CaptureQualityError, DependencyMissingError, InputValidationError) as exc:
235
+ history.append({"attempt": attempt.label, "ok": False, "error": exc.message})
236
+ raise # the user has to act: retrying with other settings will not help
237
+ except (ProcessingError, ExternalToolError) as exc:
238
+ history.append({"attempt": attempt.label, "ok": False, "error": exc.message})
239
+ last_error = exc
240
+ logger.info("Autopilot attempt '%s' failed: %s", attempt.label, exc.message)
241
+ continue
242
+ history.append({"attempt": attempt.label, "ok": True})
243
+ if (
244
+ detection.kind in ("mesh", "point_cloud")
245
+ and summary["dimensions"].get("watertight") is False
246
+ and attempt.options.repair == "normal"
247
+ ):
248
+ try:
249
+ stronger = run_recipe(
250
+ detection.recipe,
251
+ source,
252
+ out,
253
+ settings,
254
+ replace(attempt.options, repair="strong", overwrite=True),
255
+ )
256
+ except Reverse3DError as exc:
257
+ history.append({"attempt": "repair=strong", "ok": False, "error": exc.message})
258
+ else:
259
+ history.append({"attempt": "repair=strong", "ok": True})
260
+ if stronger["dimensions"].get("watertight"):
261
+ summary = stronger
262
+ break
263
+
264
+ if summary is None:
265
+ raise last_error or ProcessingError("No processing attempt could run.")
266
+
267
+ summary["auto"] = {"detected": detection.to_dict(), "attempts": history}
268
+ summary["next_steps"] = next_steps(detection, summary, base)
269
+ summary["outputs"]["summary"] = str(write_summary(out, detection, summary))
270
+ return summary
271
+
272
+
273
+ def next_steps(detection: Detection, summary: dict[str, Any], options: RecipeOptions) -> list[str]:
274
+ """Plain-language suggestions (Spanish) for what to do with the results."""
275
+ outputs = summary.get("outputs", {})
276
+ steps: list[str] = []
277
+ if "cad_openscad" in outputs:
278
+ steps.append(
279
+ "Edita cad.scad en OpenSCAD (o cad.py con CadQuery) para ajustar medidas "
280
+ "y exportar STEP."
281
+ )
282
+ if "model" in outputs:
283
+ steps.append(
284
+ "Abre model.stl en tu laminador (Cura, PrusaSlicer, Bambu Studio…) para imprimirlo."
285
+ )
286
+ if detection.kind == "plan" and options.units_per_pixel is None:
287
+ steps.append(
288
+ "Las medidas están en píxeles: indica la escala con --units-per-pixel "
289
+ "para tener medidas reales."
290
+ )
291
+ if detection.kind == "photo":
292
+ steps.append(
293
+ "La profundidad con IA es relativa: usa --scale si necesitas un tamaño concreto."
294
+ )
295
+ if detection.kind == "photo":
296
+ steps.append(
297
+ "Un relieve es una superficie abierta: dale grosor en tu programa 3D "
298
+ "si lo vas a imprimir."
299
+ )
300
+ elif summary.get("dimensions", {}).get("watertight") is False:
301
+ steps.append("La malla no quedó cerrada (no estanca): revisa los avisos antes de imprimir.")
302
+ if "render" not in outputs:
303
+ steps.append(
304
+ "Instala Blender (o el extra render) para obtener una vista previa render.png."
305
+ )
306
+ return steps
307
+
308
+
309
+ def _fmt(value: Any) -> str:
310
+ if isinstance(value, (list, tuple)):
311
+ return " × ".join(f"{float(v):.2f}" for v in value)
312
+ if isinstance(value, float):
313
+ return f"{value:.2f}"
314
+ return str(value)
315
+
316
+
317
+ def write_summary(out: Path, detection: Detection, summary: dict[str, Any]) -> Path:
318
+ """Write ``RESUMEN.md``: a human-readable report next to the results."""
319
+ dims = summary.get("dimensions", {})
320
+ lines = [
321
+ f"# reverse3d · {Path(detection.source).name}",
322
+ "",
323
+ f"- **Detectado:** {KIND_LABEL_ES[detection.kind]}",
324
+ f"- **Flujo:** `{summary.get('recipe')}` · {str(summary.get('device', 'cpu')).upper()}"
325
+ f" · perfil {summary.get('profile')}",
326
+ f"- **Tiempo:** {summary.get('seconds')} s",
327
+ "",
328
+ "## Medidas (unidades del modelo)",
329
+ "",
330
+ f"- Tamaño (caja orientada): {_fmt(dims.get('size_obb', '—'))}",
331
+ f"- Volumen: {_fmt(dims.get('volume', '—'))}",
332
+ f"- Superficie: {_fmt(dims.get('area', '—'))}",
333
+ f"- Cerrada (estanca): {'sí' if dims.get('watertight') else 'no'}",
334
+ "",
335
+ "## Archivos",
336
+ "",
337
+ ]
338
+ lines += [
339
+ f"- {key}: `{Path(path).name}`" for key, path in summary.get("outputs", {}).items() if path
340
+ ]
341
+ lines.append("- summary: `RESUMEN.md`")
342
+ if summary.get("primitives"):
343
+ lines += ["", "## Geometría detectada", ""]
344
+ lines += [
345
+ f"- {p.get('kind')}: "
346
+ + ", ".join(f"{k}={_fmt(v)}" for k, v in p.items() if k != "kind")
347
+ for p in summary["primitives"]
348
+ ]
349
+ if summary.get("warnings"):
350
+ lines += ["", "## Avisos", ""] + [f"- {w}" for w in summary["warnings"]]
351
+ if summary.get("next_steps"):
352
+ lines += ["", "## Siguiente paso", ""] + [f"- {s}" for s in summary["next_steps"]]
353
+ path = out / "RESUMEN.md"
354
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
355
+ return path