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/watch.py ADDED
@@ -0,0 +1,238 @@
1
+ """Watched inbox: drop files in a folder, get results without typing anything.
2
+
3
+ ``reverse3d watch`` keeps an eye on ``<workspace>/bandeja``. Whatever you drop
4
+ there (a folder of photos, a drawing, a photo, an STL...) is processed with the
5
+ autopilot (:func:`reverse3d.autopilot.auto_run`) once it has finished copying:
6
+
7
+ * results go to ``<workspace>/resultados/<name>/`` together with ``RESUMEN.md``;
8
+ * the original moves to ``bandeja/_procesado/``;
9
+ * if it cannot be processed it moves to ``bandeja/_revisar/`` with a
10
+ ``<name>.LEEME.txt`` explaining what to change.
11
+
12
+ It needs no internet and no AI model, so it keeps working offline. Polling is
13
+ used instead of OS file events: it is portable and has no extra dependency.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import shutil
20
+ import time
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from reverse3d.config import Settings
27
+ from reverse3d.errors import (
28
+ CaptureQualityError,
29
+ DependencyMissingError,
30
+ InputValidationError,
31
+ Reverse3DError,
32
+ )
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ INBOX_NAME = "bandeja"
37
+ OUTBOX_NAME = "resultados"
38
+ DONE_DIR = "_procesado"
39
+ REVIEW_DIR = "_revisar"
40
+ #: Partial downloads and editor temp files are never picked up.
41
+ _TEMP_SUFFIXES = (".part", ".crdownload", ".tmp", ".download", ".partial", ".lock")
42
+ _MAX_SCAN_FILES = 2000
43
+
44
+ Event = dict[str, Any]
45
+
46
+
47
+ @dataclass
48
+ class WatchFolders:
49
+ inbox: Path
50
+ outbox: Path
51
+
52
+ @classmethod
53
+ def for_workspace(cls, settings: Settings) -> WatchFolders:
54
+ return cls(settings.workspace / INBOX_NAME, settings.workspace / OUTBOX_NAME)
55
+
56
+ def ensure(self) -> None:
57
+ for folder in (self.inbox, self.outbox, self.inbox / DONE_DIR, self.inbox / REVIEW_DIR):
58
+ folder.mkdir(parents=True, exist_ok=True)
59
+
60
+
61
+ @dataclass
62
+ class _Tracker:
63
+ """Remembers each item's fingerprint to know when it has finished copying."""
64
+
65
+ seen: dict[str, tuple[int, int, int]] = field(default_factory=dict)
66
+
67
+
68
+ def _is_candidate(path: Path) -> bool:
69
+ name = path.name
70
+ if name.startswith((".", "_", "~$")) or name.lower().endswith(_TEMP_SUFFIXES):
71
+ return False
72
+ return path.is_dir() or path.is_file()
73
+
74
+
75
+ def _fingerprint(path: Path) -> tuple[int, int, int]:
76
+ if path.is_file():
77
+ stat = path.stat()
78
+ return (1, stat.st_size, stat.st_mtime_ns)
79
+ count = size = newest = 0
80
+ for i, file in enumerate(path.rglob("*")):
81
+ if i >= _MAX_SCAN_FILES:
82
+ break
83
+ if file.is_file():
84
+ stat = file.stat()
85
+ count += 1
86
+ size += stat.st_size
87
+ newest = max(newest, stat.st_mtime_ns)
88
+ return (count, size, newest)
89
+
90
+
91
+ def _unique(path: Path) -> Path:
92
+ if not path.exists():
93
+ return path
94
+ for n in range(2, 10_000):
95
+ candidate = (
96
+ path.with_name(f"{path.stem}-{n}{path.suffix}")
97
+ if path.suffix
98
+ else path.with_name(f"{path.name}-{n}")
99
+ )
100
+ if not candidate.exists():
101
+ return candidate
102
+ raise RuntimeError(f"Too many items named {path.name}") # pragma: no cover
103
+
104
+
105
+ def advice_es(error: Reverse3DError) -> str:
106
+ """Plain Spanish advice for the user, by kind of problem."""
107
+ if isinstance(error, CaptureQualityError):
108
+ return (
109
+ "Las fotos no sirven todavía para reconstruir el objeto. Repítelas con buena luz, "
110
+ "sin desenfoque, rodeando el objeto y con mucho solape entre una foto y la siguiente "
111
+ "(20-60 fotos)."
112
+ )
113
+ if isinstance(error, DependencyMissingError):
114
+ return f"Falta un componente en este equipo. {error.hint or ''}".strip()
115
+ if isinstance(error, InputValidationError):
116
+ return (
117
+ "Deja una carpeta con 20-60 fotos del objeto, una imagen (plano, silueta o foto) "
118
+ "o un archivo 3D: STL, OBJ, PLY, GLB, XYZ o PCD."
119
+ )
120
+ return "Revisa el detalle técnico de abajo; si el problema sigue, abre un issue en GitHub."
121
+
122
+
123
+ def _review_note(item: Path, error: Reverse3DError) -> str:
124
+ lines = [
125
+ f"reverse3d no pudo procesar «{item.name}».",
126
+ "",
127
+ f"Qué hacer: {advice_es(error)}",
128
+ "",
129
+ "Cuando lo corrijas, vuelve a dejarlo en la carpeta «bandeja»: se procesará solo.",
130
+ "",
131
+ "Detalle técnico:",
132
+ f" {error.message}",
133
+ ]
134
+ if error.hint:
135
+ lines.append(f" {error.hint}")
136
+ return "\n".join(lines) + "\n"
137
+
138
+
139
+ def process_item(item: Path, folders: WatchFolders, settings: Settings) -> Event:
140
+ """Process one inbox item and move it to _procesado or _revisar."""
141
+ from reverse3d.autopilot import auto_run, detect_input
142
+
143
+ detection = detect_input(item)
144
+ stem = item.stem if item.is_file() else item.name
145
+ out = _unique(folders.outbox / stem)
146
+ try:
147
+ summary = auto_run(item, out, settings)
148
+ except Reverse3DError as exc:
149
+ target = _unique(folders.inbox / REVIEW_DIR / item.name)
150
+ shutil.move(str(item), str(target))
151
+ note = target.with_name(f"{target.name}.LEEME.txt")
152
+ note.write_text(_review_note(item, exc), encoding="utf-8")
153
+ if out.exists() and not any(out.iterdir()):
154
+ out.rmdir()
155
+ return {
156
+ "event": "failed",
157
+ "item": item.name,
158
+ "kind": detection.kind,
159
+ "error": exc.to_dict(),
160
+ "advice": advice_es(exc),
161
+ "moved_to": str(target),
162
+ "note": str(note),
163
+ }
164
+ target = _unique(folders.inbox / DONE_DIR / item.name)
165
+ shutil.move(str(item), str(target))
166
+ return {
167
+ "event": "done",
168
+ "item": item.name,
169
+ "kind": detection.kind,
170
+ "recipe": summary.get("recipe"),
171
+ "output_dir": str(out),
172
+ "summary": summary["outputs"].get("summary"),
173
+ "dimensions": summary.get("dimensions", {}),
174
+ "warnings": summary.get("warnings", []),
175
+ "moved_to": str(target),
176
+ }
177
+
178
+
179
+ def scan(
180
+ folders: WatchFolders,
181
+ settings: Settings,
182
+ tracker: _Tracker,
183
+ *,
184
+ settle: bool = True,
185
+ on_event: Callable[[Event], None] | None = None,
186
+ ) -> list[Event]:
187
+ """Look at the inbox once and process every item that has finished copying.
188
+
189
+ Args:
190
+ settle: when True an item is processed only after two scans with the
191
+ same size and modification time (it is still being copied otherwise).
192
+ """
193
+ emit = on_event or (lambda _e: None)
194
+ events: list[Event] = []
195
+ for item in sorted(p for p in folders.inbox.iterdir() if _is_candidate(p)):
196
+ key = str(item)
197
+ try:
198
+ fingerprint = _fingerprint(item)
199
+ except OSError:
200
+ continue
201
+ if settle and tracker.seen.get(key) != fingerprint:
202
+ if key not in tracker.seen:
203
+ emit({"event": "detected", "item": item.name})
204
+ tracker.seen[key] = fingerprint
205
+ continue
206
+ tracker.seen.pop(key, None)
207
+ emit({"event": "processing", "item": item.name})
208
+ try:
209
+ event = process_item(item, folders, settings)
210
+ except OSError as exc: # e.g. the file is still locked by another program
211
+ event = {"event": "retry", "item": item.name, "error": str(exc)}
212
+ events.append(event)
213
+ emit(event)
214
+ return events
215
+
216
+
217
+ def watch(
218
+ folders: WatchFolders,
219
+ settings: Settings,
220
+ *,
221
+ interval_s: float = 2.0,
222
+ once: bool = False,
223
+ on_event: Callable[[Event], None] | None = None,
224
+ stop: Callable[[], bool] | None = None,
225
+ ) -> list[Event]:
226
+ """Watch the inbox until ``stop()`` returns True (or Ctrl+C).
227
+
228
+ With ``once=True`` it processes what is already in the inbox and returns.
229
+ """
230
+ folders.ensure()
231
+ tracker = _Tracker()
232
+ if once:
233
+ return scan(folders, settings, tracker, settle=False, on_event=on_event)
234
+ events: list[Event] = []
235
+ while not (stop and stop()):
236
+ events.extend(scan(folders, settings, tracker, on_event=on_event))
237
+ time.sleep(interval_s)
238
+ return events