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
reverse3d/cli.py
ADDED
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
"""Command-line interface: ``reverse3d <group> <command> [options]``.
|
|
2
|
+
|
|
3
|
+
Every command prints a JSON document on stdout (pretty in a terminal, compact
|
|
4
|
+
with ``--json``) and logs to stderr, so both humans and shell-driven AI agents
|
|
5
|
+
(Claude Code, Gemini CLI, OpenCode...) can consume it. Exit codes:
|
|
6
|
+
|
|
7
|
+
==== ==============================================
|
|
8
|
+
0 success
|
|
9
|
+
1 unexpected error / tool reported failure
|
|
10
|
+
2 invalid input or security violation
|
|
11
|
+
3 missing dependency (extra or external binary)
|
|
12
|
+
4 external tool (COLMAP/Blender) failed
|
|
13
|
+
==== ==============================================
|
|
14
|
+
|
|
15
|
+
Direct commands act on the paths you pass (you are the trusted operator).
|
|
16
|
+
``reverse3d agent call`` and ``reverse3d agent mcp`` enforce the workspace
|
|
17
|
+
sandbox, because there the arguments come from a model.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, replace
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
|
|
33
|
+
from reverse3d._version import __version__
|
|
34
|
+
from reverse3d.config import Settings
|
|
35
|
+
from reverse3d.errors import (
|
|
36
|
+
DependencyMissingError,
|
|
37
|
+
ExternalToolError,
|
|
38
|
+
InputValidationError,
|
|
39
|
+
Reverse3DError,
|
|
40
|
+
SecurityViolationError,
|
|
41
|
+
)
|
|
42
|
+
from reverse3d.hardware import apply_threads
|
|
43
|
+
from reverse3d.logging_utils import configure_logging
|
|
44
|
+
|
|
45
|
+
_err = Console(stderr=True)
|
|
46
|
+
|
|
47
|
+
app = typer.Typer(
|
|
48
|
+
name="reverse3d",
|
|
49
|
+
help="AI-agent-ready toolkit for 3D reverse engineering — by Voxel Enterprise (voxel.lat).",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
add_completion=False,
|
|
52
|
+
pretty_exceptions_enable=False,
|
|
53
|
+
)
|
|
54
|
+
vision_app = typer.Typer(
|
|
55
|
+
help="2D analysis: image quality, matching, masks and silhouettes.", no_args_is_help=True
|
|
56
|
+
)
|
|
57
|
+
recon_app = typer.Typer(
|
|
58
|
+
help="3D reconstruction: photogrammetry, monocular depth, meshing.", no_args_is_help=True
|
|
59
|
+
)
|
|
60
|
+
mesh_app = typer.Typer(
|
|
61
|
+
help="Reverse engineering: clean, measure and fit primitives.", no_args_is_help=True
|
|
62
|
+
)
|
|
63
|
+
agent_app = typer.Typer(
|
|
64
|
+
help="AI agent integration: tool schemas, sandboxed calls, MCP server.", no_args_is_help=True
|
|
65
|
+
)
|
|
66
|
+
app.add_typer(vision_app, name="vision")
|
|
67
|
+
app.add_typer(recon_app, name="recon")
|
|
68
|
+
app.add_typer(mesh_app, name="mesh")
|
|
69
|
+
app.add_typer(agent_app, name="agent")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class _State:
|
|
74
|
+
settings: Settings
|
|
75
|
+
json_output: bool
|
|
76
|
+
workspace_override: Path | None = None
|
|
77
|
+
device_override: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Choice types as str-Enums: the stable, version-independent way to declare
|
|
81
|
+
# choices in Typer (values are passed to the library as plain strings).
|
|
82
|
+
class Backend(str, Enum):
|
|
83
|
+
auto = "auto"
|
|
84
|
+
blender = "blender"
|
|
85
|
+
pyvista = "pyvista"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Engine(str, Enum):
|
|
89
|
+
cycles = "cycles"
|
|
90
|
+
eevee = "eevee"
|
|
91
|
+
workbench = "workbench"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Shading(str, Enum):
|
|
95
|
+
auto = "auto"
|
|
96
|
+
smooth = "smooth"
|
|
97
|
+
flat = "flat"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Detector(str, Enum):
|
|
101
|
+
sift = "sift"
|
|
102
|
+
orb = "orb"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MaskMethod(str, Enum):
|
|
106
|
+
otsu = "otsu"
|
|
107
|
+
adaptive = "adaptive"
|
|
108
|
+
canny = "canny"
|
|
109
|
+
alpha = "alpha"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Quality(str, Enum):
|
|
113
|
+
low = "low"
|
|
114
|
+
medium = "medium"
|
|
115
|
+
high = "high"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Matcher(str, Enum):
|
|
119
|
+
auto = "auto"
|
|
120
|
+
exhaustive = "exhaustive"
|
|
121
|
+
sequential = "sequential"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Dense(str, Enum):
|
|
125
|
+
auto = "auto"
|
|
126
|
+
on = "on"
|
|
127
|
+
off = "off"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Align(str, Enum):
|
|
131
|
+
# Upper-case member names: "center" would shadow str.center().
|
|
132
|
+
NONE = "none"
|
|
133
|
+
CENTER = "center"
|
|
134
|
+
OBB = "obb"
|
|
135
|
+
PCA = "pca"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class DeviceChoice(str, Enum):
|
|
139
|
+
auto = "auto"
|
|
140
|
+
cpu = "cpu"
|
|
141
|
+
gpu = "gpu"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ProfileChoice(str, Enum):
|
|
145
|
+
fast = "fast"
|
|
146
|
+
balanced = "balanced"
|
|
147
|
+
quality = "quality"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RecipeChoice(str, Enum):
|
|
151
|
+
photos_to_model = "photos_to_model"
|
|
152
|
+
plan_to_part = "plan_to_part"
|
|
153
|
+
scan_to_cad = "scan_to_cad"
|
|
154
|
+
photo_to_relief = "photo_to_relief"
|
|
155
|
+
mesh_report = "mesh_report"
|
|
156
|
+
auto = "auto"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ToolsetChoice(str, Enum):
|
|
160
|
+
full = "full"
|
|
161
|
+
lean = "lean"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ToolFormat(str, Enum):
|
|
165
|
+
anthropic = "anthropic"
|
|
166
|
+
openai = "openai"
|
|
167
|
+
gemini = "gemini"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _state(ctx: typer.Context) -> _State:
|
|
171
|
+
state = ctx.find_root().obj
|
|
172
|
+
if not isinstance(state, _State): # pragma: no cover - callback always sets it
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
return state
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _emit(state: _State, data: Any) -> None:
|
|
178
|
+
text = json.dumps(data, ensure_ascii=False, default=str)
|
|
179
|
+
if state.json_output or not sys.stdout.isatty():
|
|
180
|
+
sys.stdout.write(text + "\n")
|
|
181
|
+
else:
|
|
182
|
+
Console().print_json(text)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _exit_code(exc: Reverse3DError) -> int:
|
|
186
|
+
if isinstance(exc, (InputValidationError, SecurityViolationError)):
|
|
187
|
+
return 2
|
|
188
|
+
if isinstance(exc, DependencyMissingError):
|
|
189
|
+
return 3
|
|
190
|
+
if isinstance(exc, ExternalToolError):
|
|
191
|
+
return 4
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run(ctx: typer.Context, fn: Callable[[Settings], Any]) -> None:
|
|
196
|
+
state = _state(ctx)
|
|
197
|
+
try:
|
|
198
|
+
result = fn(state.settings)
|
|
199
|
+
except Reverse3DError as exc:
|
|
200
|
+
payload = {"ok": False, "error": exc.to_dict()}
|
|
201
|
+
if state.json_output:
|
|
202
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
203
|
+
else:
|
|
204
|
+
_err.print(f"[bold red]Error ({exc.code}):[/] {exc.message}")
|
|
205
|
+
if exc.hint:
|
|
206
|
+
_err.print(f"[yellow]Hint:[/] {exc.hint}")
|
|
207
|
+
if isinstance(exc, ExternalToolError) and exc.stderr_tail:
|
|
208
|
+
_err.print(exc.stderr_tail, markup=False, highlight=False)
|
|
209
|
+
raise typer.Exit(_exit_code(exc)) from None
|
|
210
|
+
data = result.to_dict() if hasattr(result, "to_dict") else result
|
|
211
|
+
_emit(state, {"ok": True, "result": data})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _version_callback(value: bool) -> None:
|
|
215
|
+
if value:
|
|
216
|
+
from reverse3d._about import AUTHOR, AUTHOR_URL, LICENSE, OFFICIAL_REPOSITORY
|
|
217
|
+
|
|
218
|
+
typer.echo(f"reverse3d {__version__} — © {AUTHOR} ({AUTHOR_URL}) · {LICENSE}")
|
|
219
|
+
typer.echo(f"Official source: {OFFICIAL_REPOSITORY}")
|
|
220
|
+
raise typer.Exit()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.callback()
|
|
224
|
+
def _root(
|
|
225
|
+
ctx: typer.Context,
|
|
226
|
+
json_output: bool = typer.Option(
|
|
227
|
+
False, "--json", help="Compact JSON on stdout (for agents/scripts)."
|
|
228
|
+
),
|
|
229
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Debug logging on stderr."),
|
|
230
|
+
workspace: Path | None = typer.Option(
|
|
231
|
+
None,
|
|
232
|
+
"--workspace",
|
|
233
|
+
"-w",
|
|
234
|
+
help="Sandbox root for agent commands (env: REVERSE3D_WORKSPACE).",
|
|
235
|
+
),
|
|
236
|
+
device: DeviceChoice | None = typer.Option(
|
|
237
|
+
None, "--device", "-d", help="Override for this run: auto | cpu | gpu."
|
|
238
|
+
),
|
|
239
|
+
profile: ProfileChoice | None = typer.Option(
|
|
240
|
+
None, "--profile", "-p", help="Override for this run: fast | balanced | quality."
|
|
241
|
+
),
|
|
242
|
+
version: bool = typer.Option(
|
|
243
|
+
False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
244
|
+
),
|
|
245
|
+
) -> None:
|
|
246
|
+
try:
|
|
247
|
+
settings = Settings.load()
|
|
248
|
+
except Reverse3DError as exc:
|
|
249
|
+
_err.print(f"[bold red]Configuration error:[/] {exc.message}")
|
|
250
|
+
if exc.hint:
|
|
251
|
+
_err.print(f"[yellow]Hint:[/] {exc.hint}")
|
|
252
|
+
raise typer.Exit(2) from None
|
|
253
|
+
if workspace is not None:
|
|
254
|
+
settings = settings.with_workspace(workspace)
|
|
255
|
+
if device is not None:
|
|
256
|
+
settings = settings.with_device(device.value)
|
|
257
|
+
if profile is not None:
|
|
258
|
+
settings = replace(settings, profile=profile.value)
|
|
259
|
+
configure_logging("DEBUG" if verbose else settings.log_level, json_lines=settings.log_json)
|
|
260
|
+
apply_threads(settings)
|
|
261
|
+
ctx.obj = _State(
|
|
262
|
+
settings=settings,
|
|
263
|
+
json_output=json_output,
|
|
264
|
+
workspace_override=workspace,
|
|
265
|
+
device_override=device.value if device else None,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ─── top-level ────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.command()
|
|
273
|
+
def doctor(ctx: typer.Context) -> None:
|
|
274
|
+
"""Show available capabilities, optional extras, external tools and limits."""
|
|
275
|
+
from reverse3d.doctor import environment_report
|
|
276
|
+
|
|
277
|
+
_run(ctx, environment_report)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@app.command()
|
|
281
|
+
def render(
|
|
282
|
+
ctx: typer.Context,
|
|
283
|
+
mesh: Path = typer.Argument(..., help="Mesh to render."),
|
|
284
|
+
output: Path = typer.Argument(..., help="Output .png"),
|
|
285
|
+
backend: Backend = typer.Option(Backend.auto),
|
|
286
|
+
width: int | None = typer.Option(None, min=64, max=8192, help="Default: from profile."),
|
|
287
|
+
height: int | None = typer.Option(None, min=64, max=8192, help="Default: from profile."),
|
|
288
|
+
engine: Engine = typer.Option(Engine.cycles),
|
|
289
|
+
samples: int | None = typer.Option(None, min=1, max=4096, help="Default: from profile."),
|
|
290
|
+
color: str = typer.Option("#9aa4b2", help="Base color #RRGGBB."),
|
|
291
|
+
metallic: float = typer.Option(0.0, min=0, max=1),
|
|
292
|
+
roughness: float = typer.Option(0.45, min=0, max=1),
|
|
293
|
+
background: str = typer.Option("#f4f5f7", help="Background #RRGGBB."),
|
|
294
|
+
transparent: bool = typer.Option(False, help="Transparent PNG background."),
|
|
295
|
+
elevation: float = typer.Option(25.0, min=-89, max=89),
|
|
296
|
+
azimuth: float = typer.Option(35.0, min=0, max=360),
|
|
297
|
+
turntable: int = typer.Option(
|
|
298
|
+
0, min=0, max=360, help="Number of turntable frames (0 = still)."
|
|
299
|
+
),
|
|
300
|
+
ground: bool = typer.Option(True, help="Shadow-catcher ground plane (Cycles)."),
|
|
301
|
+
shading: Shading = typer.Option(Shading.auto),
|
|
302
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite outputs."),
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Render a mesh headlessly with Blender (or PyVista as fallback). GPU: use --device gpu."""
|
|
305
|
+
from reverse3d.hardware import wants_gpu
|
|
306
|
+
from reverse3d.profiles import profile_values
|
|
307
|
+
from reverse3d.rendering.blender_render import RenderOptions, render_mesh
|
|
308
|
+
|
|
309
|
+
settings = _state(ctx).settings
|
|
310
|
+
prof = profile_values(settings)
|
|
311
|
+
options = RenderOptions(
|
|
312
|
+
width=width or prof.render_width,
|
|
313
|
+
height=height or prof.render_height,
|
|
314
|
+
engine=engine.value,
|
|
315
|
+
device="gpu" if wants_gpu(settings) else "cpu",
|
|
316
|
+
samples=samples or prof.render_samples,
|
|
317
|
+
color=color,
|
|
318
|
+
metallic=metallic,
|
|
319
|
+
roughness=roughness,
|
|
320
|
+
background=background,
|
|
321
|
+
transparent=transparent,
|
|
322
|
+
elevation_deg=elevation,
|
|
323
|
+
azimuth_deg=azimuth,
|
|
324
|
+
turntable_frames=turntable,
|
|
325
|
+
ground=ground,
|
|
326
|
+
shading=shading.value,
|
|
327
|
+
)
|
|
328
|
+
_run(
|
|
329
|
+
ctx,
|
|
330
|
+
lambda s: render_mesh(
|
|
331
|
+
mesh,
|
|
332
|
+
output,
|
|
333
|
+
s,
|
|
334
|
+
options,
|
|
335
|
+
backend=backend.value,
|
|
336
|
+
overwrite=force,
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ─── vision ───────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@vision_app.command("analyze")
|
|
345
|
+
def vision_analyze(
|
|
346
|
+
ctx: typer.Context,
|
|
347
|
+
image_dir: Path = typer.Argument(..., help="Folder with capture photos."),
|
|
348
|
+
detector: Detector = typer.Option(Detector.sift),
|
|
349
|
+
max_pairs: int = typer.Option(60, min=1, max=1000),
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Assess photo quality and overlap before photogrammetry."""
|
|
352
|
+
from reverse3d.vision.feature_matcher import analyze_image_set
|
|
353
|
+
|
|
354
|
+
_run(
|
|
355
|
+
ctx,
|
|
356
|
+
lambda s: analyze_image_set(
|
|
357
|
+
image_dir,
|
|
358
|
+
s,
|
|
359
|
+
detector=detector.value,
|
|
360
|
+
max_pairs=max_pairs,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@vision_app.command("match")
|
|
366
|
+
def vision_match(
|
|
367
|
+
ctx: typer.Context,
|
|
368
|
+
image_a: Path = typer.Argument(...),
|
|
369
|
+
image_b: Path = typer.Argument(...),
|
|
370
|
+
detector: Detector = typer.Option(Detector.sift),
|
|
371
|
+
ratio: float = typer.Option(0.75, min=0.5, max=0.95),
|
|
372
|
+
viz: Path | None = typer.Option(None, help="Save a PNG/JPG with the inlier matches."),
|
|
373
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Match features between two images (ratio test + RANSAC)."""
|
|
376
|
+
from dataclasses import asdict
|
|
377
|
+
|
|
378
|
+
from reverse3d.vision.feature_matcher import match_pair
|
|
379
|
+
|
|
380
|
+
_run(
|
|
381
|
+
ctx,
|
|
382
|
+
lambda s: asdict(
|
|
383
|
+
match_pair(
|
|
384
|
+
image_a,
|
|
385
|
+
image_b,
|
|
386
|
+
s,
|
|
387
|
+
detector=detector.value,
|
|
388
|
+
ratio=ratio,
|
|
389
|
+
visualization_path=viz,
|
|
390
|
+
overwrite=force,
|
|
391
|
+
)
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@vision_app.command("mask")
|
|
397
|
+
def vision_mask(
|
|
398
|
+
ctx: typer.Context,
|
|
399
|
+
image: Path = typer.Argument(..., help="Photo, plan, stencil or RGBA image."),
|
|
400
|
+
output_dir: Path = typer.Argument(...),
|
|
401
|
+
method: MaskMethod = typer.Option(MaskMethod.otsu),
|
|
402
|
+
invert: bool | None = typer.Option(None, "--invert/--no-invert", help="Default: auto-detect."),
|
|
403
|
+
blur: int = typer.Option(5, min=0, max=51),
|
|
404
|
+
min_area: float = typer.Option(0.001, min=0, max=0.99, help="Min region as image fraction."),
|
|
405
|
+
simplify: float = typer.Option(0.002, min=0, max=0.1),
|
|
406
|
+
units_per_pixel: float | None = typer.Option(
|
|
407
|
+
None, min=0, help="Real-world scale (e.g. mm/px)."
|
|
408
|
+
),
|
|
409
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Extract mask + vector contours (JSON/SVG) from an image."""
|
|
412
|
+
from reverse3d.vision.mask_extractor import extract_mask
|
|
413
|
+
|
|
414
|
+
_run(
|
|
415
|
+
ctx,
|
|
416
|
+
lambda s: extract_mask(
|
|
417
|
+
image,
|
|
418
|
+
output_dir,
|
|
419
|
+
s,
|
|
420
|
+
method=method.value,
|
|
421
|
+
invert=invert,
|
|
422
|
+
blur=blur,
|
|
423
|
+
min_area_ratio=min_area,
|
|
424
|
+
simplify_ratio=simplify,
|
|
425
|
+
units_per_pixel=units_per_pixel,
|
|
426
|
+
overwrite=force,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@vision_app.command("extrude")
|
|
432
|
+
def vision_extrude(
|
|
433
|
+
ctx: typer.Context,
|
|
434
|
+
contours: Path = typer.Argument(..., help="*_contours.json from 'vision mask'."),
|
|
435
|
+
output: Path = typer.Argument(..., help="Output mesh."),
|
|
436
|
+
thickness: float = typer.Option(10.0, min=0),
|
|
437
|
+
units_per_pixel: float | None = typer.Option(None, min=0),
|
|
438
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
439
|
+
) -> None:
|
|
440
|
+
"""Extrude contours into a 3D solid (plan → part)."""
|
|
441
|
+
from reverse3d.vision.mask_extractor import silhouette_to_mesh
|
|
442
|
+
|
|
443
|
+
_run(
|
|
444
|
+
ctx,
|
|
445
|
+
lambda s: silhouette_to_mesh(
|
|
446
|
+
contours,
|
|
447
|
+
output,
|
|
448
|
+
s,
|
|
449
|
+
thickness=thickness,
|
|
450
|
+
units_per_pixel=units_per_pixel,
|
|
451
|
+
overwrite=force,
|
|
452
|
+
),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ─── reconstruction ───────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@recon_app.command("photo")
|
|
460
|
+
def recon_photo(
|
|
461
|
+
ctx: typer.Context,
|
|
462
|
+
image_dir: Path = typer.Argument(..., help="Folder with overlapping photos."),
|
|
463
|
+
output_dir: Path = typer.Argument(..., help="COLMAP workspace / output folder."),
|
|
464
|
+
quality: Quality | None = typer.Option(None, help="Default: from profile."),
|
|
465
|
+
matcher: Matcher = typer.Option(Matcher.auto),
|
|
466
|
+
dense: Dense = typer.Option(Dense.auto),
|
|
467
|
+
camera_model: str = typer.Option("SIMPLE_RADIAL"),
|
|
468
|
+
mesh: bool = typer.Option(True, help="Poisson-mesh the result (needs [recon])."),
|
|
469
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Photogrammetry with COLMAP: photos → point cloud → mesh. GPU: use --device gpu."""
|
|
472
|
+
from reverse3d.hardware import wants_gpu
|
|
473
|
+
from reverse3d.profiles import profile_values
|
|
474
|
+
from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
|
|
475
|
+
|
|
476
|
+
settings = _state(ctx).settings
|
|
477
|
+
options = PhotogrammetryOptions(
|
|
478
|
+
quality=quality.value if quality else profile_values(settings).photo_quality, # type: ignore[arg-type]
|
|
479
|
+
matcher=matcher.value,
|
|
480
|
+
dense=dense.value,
|
|
481
|
+
use_gpu=wants_gpu(settings),
|
|
482
|
+
camera_model=camera_model.upper(),
|
|
483
|
+
build_mesh=mesh,
|
|
484
|
+
)
|
|
485
|
+
_run(ctx, lambda s: run_photogrammetry(image_dir, output_dir, s, options, overwrite=force))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@recon_app.command("depth")
|
|
489
|
+
def recon_depth(
|
|
490
|
+
ctx: typer.Context,
|
|
491
|
+
image: Path = typer.Argument(...),
|
|
492
|
+
output_dir: Path = typer.Argument(...),
|
|
493
|
+
model: str = typer.Option(
|
|
494
|
+
"depth-anything/Depth-Anything-V2-Small-hf", help="Allowlisted model id."
|
|
495
|
+
),
|
|
496
|
+
mesh: bool = typer.Option(True),
|
|
497
|
+
fov: float = typer.Option(60.0, min=10, max=170),
|
|
498
|
+
stride: int | None = typer.Option(None, min=1, max=32, help="Default: from profile."),
|
|
499
|
+
mask: Path | None = typer.Option(None, help="Mask PNG to mesh only the object."),
|
|
500
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Monocular depth estimation (+ optional 2.5D mesh). GPU: use --device gpu."""
|
|
503
|
+
from reverse3d.hardware import torch_device
|
|
504
|
+
from reverse3d.profiles import profile_values
|
|
505
|
+
from reverse3d.reconstruction.depth_estimation import estimate_depth
|
|
506
|
+
|
|
507
|
+
_run(
|
|
508
|
+
ctx,
|
|
509
|
+
lambda s: estimate_depth(
|
|
510
|
+
image,
|
|
511
|
+
output_dir,
|
|
512
|
+
s,
|
|
513
|
+
model=model,
|
|
514
|
+
device=torch_device(s),
|
|
515
|
+
make_mesh=mesh,
|
|
516
|
+
fov_deg=fov,
|
|
517
|
+
stride=stride or profile_values(s).depth_stride,
|
|
518
|
+
mask_path=mask,
|
|
519
|
+
overwrite=force,
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@recon_app.command("mesh")
|
|
525
|
+
def recon_mesh(
|
|
526
|
+
ctx: typer.Context,
|
|
527
|
+
cloud: Path = typer.Argument(..., help="Point cloud .ply/.xyz/.pcd"),
|
|
528
|
+
output: Path = typer.Argument(...),
|
|
529
|
+
depth: int = typer.Option(9, min=5, max=12, help="Poisson octree depth."),
|
|
530
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Point cloud → surface mesh (screened Poisson, Open3D)."""
|
|
533
|
+
from reverse3d.reconstruction.pointcloud import poisson_mesh
|
|
534
|
+
|
|
535
|
+
_run(ctx, lambda s: poisson_mesh(cloud, output, s, depth=depth, overwrite=force))
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ─── mesh ─────────────────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@mesh_app.command("clean")
|
|
542
|
+
def mesh_clean(
|
|
543
|
+
ctx: typer.Context,
|
|
544
|
+
input_path: Path = typer.Argument(..., metavar="INPUT"),
|
|
545
|
+
output: Path = typer.Argument(...),
|
|
546
|
+
keep_largest: bool = typer.Option(False, help="Keep only the largest component."),
|
|
547
|
+
min_component: float = typer.Option(0.01, min=0, max=0.99),
|
|
548
|
+
fill_holes: bool = typer.Option(True),
|
|
549
|
+
max_hole_edges: int = typer.Option(200, min=3, max=100_000),
|
|
550
|
+
fix_normals: bool = typer.Option(True),
|
|
551
|
+
smooth: int = typer.Option(0, min=0, max=200, help="Taubin smoothing iterations."),
|
|
552
|
+
target_faces: int | None = typer.Option(None, min=4, help="Decimate to N faces."),
|
|
553
|
+
align: Align = typer.Option(Align.NONE),
|
|
554
|
+
ground: bool = typer.Option(False, help="Move min Z to 0."),
|
|
555
|
+
scale: float = typer.Option(1.0, min=1e-9, help="Uniform scale (unit conversion)."),
|
|
556
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Repair, simplify and align a mesh."""
|
|
559
|
+
from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
|
|
560
|
+
|
|
561
|
+
options = CleanOptions(
|
|
562
|
+
keep_largest_only=keep_largest,
|
|
563
|
+
min_component_ratio=min_component,
|
|
564
|
+
fix_normals=fix_normals,
|
|
565
|
+
fill_holes=fill_holes,
|
|
566
|
+
max_hole_edges=max_hole_edges,
|
|
567
|
+
smooth_iterations=smooth,
|
|
568
|
+
target_faces=target_faces,
|
|
569
|
+
align=align.value,
|
|
570
|
+
ground=ground,
|
|
571
|
+
scale=scale,
|
|
572
|
+
)
|
|
573
|
+
_run(ctx, lambda s: clean_mesh_file(input_path, output, s, options, overwrite=force))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@mesh_app.command("measure")
|
|
577
|
+
def mesh_measure(
|
|
578
|
+
ctx: typer.Context, input_path: Path = typer.Argument(..., metavar="INPUT")
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Dimensions (AABB/OBB), area, volume and topology of a mesh."""
|
|
581
|
+
from reverse3d.reverse_eng.measure import measure_mesh_file
|
|
582
|
+
|
|
583
|
+
_run(ctx, lambda s: measure_mesh_file(input_path, s))
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@mesh_app.command("fit")
|
|
587
|
+
def mesh_fit(
|
|
588
|
+
ctx: typer.Context,
|
|
589
|
+
input_path: Path = typer.Argument(..., metavar="INPUT"),
|
|
590
|
+
kinds: str = typer.Option("plane,cylinder,sphere", help="Comma-separated primitive kinds."),
|
|
591
|
+
samples: int | None = typer.Option(None, min=100, max=500_000, help="Default: from profile."),
|
|
592
|
+
threshold: float | None = typer.Option(
|
|
593
|
+
None, min=0, help="Inlier distance (default 1% of diagonal)."
|
|
594
|
+
),
|
|
595
|
+
max_primitives: int = typer.Option(8, min=1, max=50),
|
|
596
|
+
min_inlier_ratio: float = typer.Option(0.05, min=0.0001, max=0.99),
|
|
597
|
+
parametric: Path | None = typer.Option(None, help="Write OpenSCAD (.scad) or CadQuery (.py)."),
|
|
598
|
+
seed: int = typer.Option(0),
|
|
599
|
+
force: bool = typer.Option(False, "--force", "-f"),
|
|
600
|
+
) -> None:
|
|
601
|
+
"""Detect planes/cylinders/spheres and optionally emit parametric CAD code."""
|
|
602
|
+
from reverse3d.profiles import profile_values
|
|
603
|
+
from reverse3d.reverse_eng.geometry_fit import ALL_KINDS, fit_primitives_file
|
|
604
|
+
from reverse3d.reverse_eng.parametric import export_parametric
|
|
605
|
+
|
|
606
|
+
requested = dict.fromkeys(k.strip().lower() for k in kinds.split(",") if k.strip())
|
|
607
|
+
parsed = tuple(k for k in ALL_KINDS if k in requested)
|
|
608
|
+
if not parsed or len(parsed) != len(requested):
|
|
609
|
+
raise typer.BadParameter(f"kinds must be a subset of {','.join(ALL_KINDS)}")
|
|
610
|
+
|
|
611
|
+
def job(s: Settings) -> dict[str, Any]:
|
|
612
|
+
report = fit_primitives_file(
|
|
613
|
+
input_path,
|
|
614
|
+
s,
|
|
615
|
+
kinds=parsed,
|
|
616
|
+
sample_count=samples or profile_values(s).fit_samples,
|
|
617
|
+
threshold=threshold,
|
|
618
|
+
max_primitives=max_primitives,
|
|
619
|
+
min_inlier_ratio=min_inlier_ratio,
|
|
620
|
+
seed=seed,
|
|
621
|
+
)
|
|
622
|
+
data = report.to_dict()
|
|
623
|
+
if parametric and report.primitives:
|
|
624
|
+
data["parametric_path"] = str(export_parametric(report, parametric, overwrite=force))
|
|
625
|
+
return data
|
|
626
|
+
|
|
627
|
+
_run(ctx, job)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ─── agent ────────────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@agent_app.command("tools")
|
|
634
|
+
def agent_tools(
|
|
635
|
+
ctx: typer.Context,
|
|
636
|
+
fmt: ToolFormat = typer.Option(ToolFormat.anthropic, "--format"),
|
|
637
|
+
toolset: ToolsetChoice = typer.Option(
|
|
638
|
+
ToolsetChoice.full, help="lean = 5 tools only (fewest schema tokens)."
|
|
639
|
+
),
|
|
640
|
+
output: Path | None = typer.Option(
|
|
641
|
+
None, "--output", "-o", help="Write to a file instead of stdout."
|
|
642
|
+
),
|
|
643
|
+
) -> None:
|
|
644
|
+
"""Print the tool definitions (JSON Schema) for Claude, OpenAI-compatible or Gemini."""
|
|
645
|
+
from reverse3d.agent.tools import tool_definitions
|
|
646
|
+
|
|
647
|
+
definitions = tool_definitions(fmt.value, toolset.value)
|
|
648
|
+
text = json.dumps(definitions, indent=2, ensure_ascii=False) + "\n"
|
|
649
|
+
if output:
|
|
650
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
651
|
+
output.write_text(text, encoding="utf-8")
|
|
652
|
+
_err.print(f"Wrote {output}")
|
|
653
|
+
else:
|
|
654
|
+
sys.stdout.write(text)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@agent_app.command("call")
|
|
658
|
+
def agent_call(
|
|
659
|
+
ctx: typer.Context,
|
|
660
|
+
name: str = typer.Argument(..., help="Tool name, e.g. reverse3d_measure_mesh"),
|
|
661
|
+
args: str | None = typer.Option(None, "--args", help="Tool arguments as a JSON object."),
|
|
662
|
+
args_file: Path | None = typer.Option(
|
|
663
|
+
None, "--args-file", help="JSON file with the arguments."
|
|
664
|
+
),
|
|
665
|
+
) -> None:
|
|
666
|
+
"""Run one tool through the sandboxed dispatcher (paths confined to the workspace)."""
|
|
667
|
+
from reverse3d.agent.tools import execute_tool
|
|
668
|
+
|
|
669
|
+
state = _state(ctx)
|
|
670
|
+
if args and args_file:
|
|
671
|
+
raise typer.BadParameter("Use either --args or --args-file, not both.")
|
|
672
|
+
try:
|
|
673
|
+
raw = args_file.read_text(encoding="utf-8") if args_file else (args or "{}")
|
|
674
|
+
arguments = json.loads(raw)
|
|
675
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
676
|
+
raise typer.BadParameter(f"Invalid JSON arguments: {exc}") from None
|
|
677
|
+
result = execute_tool(name, arguments, state.settings)
|
|
678
|
+
_emit(state, result)
|
|
679
|
+
if not result.get("ok"):
|
|
680
|
+
raise typer.Exit(1)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@agent_app.command("mcp")
|
|
684
|
+
def agent_mcp(
|
|
685
|
+
ctx: typer.Context,
|
|
686
|
+
max_concurrent: int = typer.Option(1, min=1, max=4, help="Concurrent tool executions."),
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Start the MCP server on stdio (for Claude Desktop/Code, Cursor, Gemini CLI...)."""
|
|
689
|
+
from reverse3d.agent.mcp_server import serve
|
|
690
|
+
|
|
691
|
+
state = _state(ctx)
|
|
692
|
+
|
|
693
|
+
def provider() -> Settings:
|
|
694
|
+
# Re-read config on every call so reverse3d_configure applies immediately;
|
|
695
|
+
# command-line overrides given at server start keep priority.
|
|
696
|
+
try:
|
|
697
|
+
fresh = Settings.load()
|
|
698
|
+
except Reverse3DError:
|
|
699
|
+
return state.settings
|
|
700
|
+
if state.workspace_override is not None:
|
|
701
|
+
fresh = fresh.with_workspace(state.workspace_override)
|
|
702
|
+
if state.device_override is not None:
|
|
703
|
+
fresh = fresh.with_device(state.device_override) # type: ignore[arg-type]
|
|
704
|
+
return fresh
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
serve(provider, max_concurrent=max_concurrent)
|
|
708
|
+
except DependencyMissingError as exc:
|
|
709
|
+
_err.print(f"[bold red]{exc.message}[/] {exc.hint or ''}")
|
|
710
|
+
raise typer.Exit(3) from None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
# ─── recipes ──────────────────────────────────────────────────────────────────
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@app.command("run")
|
|
717
|
+
def run_cmd(
|
|
718
|
+
ctx: typer.Context,
|
|
719
|
+
recipe: RecipeChoice = typer.Argument(..., help="Workflow to run."),
|
|
720
|
+
input_path: Path = typer.Argument(..., metavar="INPUT", help="Photo folder, image or mesh."),
|
|
721
|
+
output_dir: Path = typer.Argument(..., help="Folder for all results."),
|
|
722
|
+
thickness: float = typer.Option(5.0, min=0.000001, help="plan_to_part extrusion height."),
|
|
723
|
+
units_per_pixel: float | None = typer.Option(None, min=0, help="plan_to_part scale (mm/px)."),
|
|
724
|
+
scale: float = typer.Option(1.0, min=1e-9, help="Final uniform scale (m→mm: 1000)."),
|
|
725
|
+
render: bool = typer.Option(True, help="Render a PNG preview at the end."),
|
|
726
|
+
quality: Quality | None = typer.Option(
|
|
727
|
+
None, help="photos_to_model quality (default: profile)."
|
|
728
|
+
),
|
|
729
|
+
force_analysis: bool = typer.Option(False, help="Continue even if the photo check fails."),
|
|
730
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite outputs."),
|
|
731
|
+
) -> None:
|
|
732
|
+
"""One-command workflows: photos/plan/scan/photo/mesh → model, dimensions, CAD, render."""
|
|
733
|
+
from reverse3d.recipes import RecipeOptions, run_recipe
|
|
734
|
+
|
|
735
|
+
options = RecipeOptions(
|
|
736
|
+
thickness=thickness,
|
|
737
|
+
units_per_pixel=units_per_pixel,
|
|
738
|
+
scale=scale,
|
|
739
|
+
render=render,
|
|
740
|
+
quality=quality.value if quality else None,
|
|
741
|
+
force_analysis=force_analysis,
|
|
742
|
+
overwrite=force,
|
|
743
|
+
)
|
|
744
|
+
_run(ctx, lambda s: run_recipe(recipe.value, input_path, output_dir, s, options))
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
@app.command("auto")
|
|
748
|
+
def auto_cmd(
|
|
749
|
+
ctx: typer.Context,
|
|
750
|
+
input_path: Path = typer.Argument(
|
|
751
|
+
..., metavar="INPUT", help="Anything: photo folder, drawing, photo or 3D file."
|
|
752
|
+
),
|
|
753
|
+
output_dir: Path | None = typer.Argument(
|
|
754
|
+
None, help="Results folder (default: <workspace>/resultados/<name>)."
|
|
755
|
+
),
|
|
756
|
+
thickness: float = typer.Option(5.0, min=0.000001, help="Extrusion height if it is a drawing."),
|
|
757
|
+
units_per_pixel: float | None = typer.Option(None, min=0, help="Drawing scale (mm/px)."),
|
|
758
|
+
scale: float = typer.Option(1.0, min=1e-9, help="Final uniform scale (m→mm: 1000)."),
|
|
759
|
+
render: bool = typer.Option(True, help="Render a PNG preview at the end."),
|
|
760
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite outputs."),
|
|
761
|
+
) -> None:
|
|
762
|
+
"""Detect what INPUT is and process it automatically (no recipe to choose)."""
|
|
763
|
+
from reverse3d.autopilot import auto_run
|
|
764
|
+
from reverse3d.recipes import RecipeOptions
|
|
765
|
+
from reverse3d.watch import OUTBOX_NAME
|
|
766
|
+
|
|
767
|
+
options = RecipeOptions(
|
|
768
|
+
thickness=thickness,
|
|
769
|
+
units_per_pixel=units_per_pixel,
|
|
770
|
+
scale=scale,
|
|
771
|
+
render=render,
|
|
772
|
+
overwrite=force,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def job(s: Settings) -> Any:
|
|
776
|
+
name = input_path.stem if input_path.is_file() else input_path.name
|
|
777
|
+
out = output_dir or (s.workspace / OUTBOX_NAME / name)
|
|
778
|
+
return auto_run(input_path, out, s, options)
|
|
779
|
+
|
|
780
|
+
_run(ctx, job)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
_WATCH_TEXT = {
|
|
784
|
+
"detected": "📥 Nuevo: {item} (esperando a que termine de copiarse…)",
|
|
785
|
+
"processing": "⚙️ Procesando {item}…",
|
|
786
|
+
"done": "✅ {item} listo → {output_dir}",
|
|
787
|
+
"failed": "⚠️ {item} no se pudo procesar. {advice}\n"
|
|
788
|
+
" Lo moví a «bandeja/_revisar» junto a un LEEME.txt.",
|
|
789
|
+
"retry": "⏳ {item} todavía está en uso; lo intento de nuevo enseguida.",
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _say(text: str) -> None:
|
|
794
|
+
_err.print(text, markup=False, highlight=False, soft_wrap=True)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _short(path: object, base: Path) -> str:
|
|
798
|
+
try:
|
|
799
|
+
return str(Path(str(path)).resolve().relative_to(base.resolve()))
|
|
800
|
+
except ValueError:
|
|
801
|
+
return str(path)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _print_watch_event(event: dict[str, Any], base: Path) -> None:
|
|
805
|
+
template = _WATCH_TEXT.get(str(event.get("event")))
|
|
806
|
+
if template is None:
|
|
807
|
+
return
|
|
808
|
+
fields: dict[str, Any] = {"advice": "", **event}
|
|
809
|
+
if "output_dir" in fields:
|
|
810
|
+
fields["output_dir"] = _short(fields["output_dir"], base)
|
|
811
|
+
_say(template.format(**fields))
|
|
812
|
+
if event.get("event") == "done":
|
|
813
|
+
dims = event.get("dimensions") or {}
|
|
814
|
+
size = dims.get("size_obb")
|
|
815
|
+
if isinstance(size, list) and size:
|
|
816
|
+
_say(" Tamaño: " + " × ".join(f"{float(v):.1f}" for v in size))
|
|
817
|
+
if event.get("summary"):
|
|
818
|
+
_say(f" Abre {_short(event['summary'], base)} para ver medidas y siguiente paso.")
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
@app.command("watch")
|
|
822
|
+
def watch_cmd(
|
|
823
|
+
ctx: typer.Context,
|
|
824
|
+
inbox: Path | None = typer.Argument(
|
|
825
|
+
None, help="Folder to watch (default: <workspace>/bandeja)."
|
|
826
|
+
),
|
|
827
|
+
outbox: Path | None = typer.Option(
|
|
828
|
+
None, "--out", "-o", help="Results folder (default: <workspace>/resultados)."
|
|
829
|
+
),
|
|
830
|
+
interval: float = typer.Option(2.0, min=0.2, max=3600, help="Seconds between checks."),
|
|
831
|
+
once: bool = typer.Option(False, "--once", help="Process what is in the inbox now and exit."),
|
|
832
|
+
) -> None:
|
|
833
|
+
"""Autopilot: drop photos, drawings or 3D files in a folder and get results automatically."""
|
|
834
|
+
from reverse3d.offline import is_offline
|
|
835
|
+
from reverse3d.watch import WatchFolders, watch
|
|
836
|
+
|
|
837
|
+
state = _state(ctx)
|
|
838
|
+
settings = state.settings
|
|
839
|
+
folders = WatchFolders.for_workspace(settings)
|
|
840
|
+
if inbox is not None:
|
|
841
|
+
folders.inbox = inbox.expanduser().resolve()
|
|
842
|
+
if outbox is not None:
|
|
843
|
+
folders.outbox = outbox.expanduser().resolve()
|
|
844
|
+
folders.ensure()
|
|
845
|
+
|
|
846
|
+
def on_event(event: dict[str, Any]) -> None:
|
|
847
|
+
if state.json_output:
|
|
848
|
+
sys.stdout.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
|
|
849
|
+
sys.stdout.flush()
|
|
850
|
+
else:
|
|
851
|
+
_print_watch_event(event, settings.workspace)
|
|
852
|
+
|
|
853
|
+
if not state.json_output:
|
|
854
|
+
import logging
|
|
855
|
+
|
|
856
|
+
if settings.log_level != "DEBUG":
|
|
857
|
+
logging.getLogger("reverse3d").setLevel(logging.WARNING) # keep the console readable
|
|
858
|
+
_say(f"👀 Vigilando: {folders.inbox}")
|
|
859
|
+
_say(f"📂 Resultados en: {folders.outbox}")
|
|
860
|
+
_say("Suelta ahí una carpeta de fotos, un plano, una foto o un STL/OBJ/PLY/GLB.")
|
|
861
|
+
if is_offline(settings):
|
|
862
|
+
_say("🔌 Sin internet: trabajando en modo offline.")
|
|
863
|
+
if not once:
|
|
864
|
+
_say("Pulsa Ctrl+C para detener.\n")
|
|
865
|
+
try:
|
|
866
|
+
events = watch(folders, settings, interval_s=interval, once=once, on_event=on_event)
|
|
867
|
+
except KeyboardInterrupt:
|
|
868
|
+
_say("\nDetenido. ¡Hasta luego!")
|
|
869
|
+
raise typer.Exit(0) from None
|
|
870
|
+
if once and any(e.get("event") == "failed" for e in events):
|
|
871
|
+
raise typer.Exit(1)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ─── offline ──────────────────────────────────────────────────────────────────
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
offline_app = typer.Typer(
|
|
878
|
+
help="Work without internet: readiness check and AI model download.", no_args_is_help=True
|
|
879
|
+
)
|
|
880
|
+
app.add_typer(offline_app, name="offline")
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@offline_app.command("status")
|
|
884
|
+
def offline_status_cmd(ctx: typer.Context) -> None:
|
|
885
|
+
"""Show whether there is internet and what works offline on this computer."""
|
|
886
|
+
from reverse3d.offline import offline_status
|
|
887
|
+
|
|
888
|
+
_run(ctx, offline_status)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
@offline_app.command("prepare")
|
|
892
|
+
def offline_prepare_cmd(
|
|
893
|
+
ctx: typer.Context,
|
|
894
|
+
include_non_commercial: bool = typer.Option(
|
|
895
|
+
False, "--all", help="Also download the larger CC-BY-NC (non-commercial) depth models."
|
|
896
|
+
),
|
|
897
|
+
) -> None:
|
|
898
|
+
"""Download the AI models now so reverse3d works later without internet."""
|
|
899
|
+
from reverse3d.offline import prepare
|
|
900
|
+
|
|
901
|
+
_run(ctx, lambda s: prepare(s, include_non_commercial=include_non_commercial))
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# ─── configuration ────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
config_app = typer.Typer(
|
|
908
|
+
help="Persistent settings (~/.reverse3d/config.json): device, profile...", no_args_is_help=True
|
|
909
|
+
)
|
|
910
|
+
app.add_typer(config_app, name="config")
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
@config_app.command("show")
|
|
914
|
+
def config_show(ctx: typer.Context) -> None:
|
|
915
|
+
"""Show effective settings, the config file and the detected hardware."""
|
|
916
|
+
from reverse3d.hardware import hardware_report
|
|
917
|
+
from reverse3d.userconfig import config_path, env_overrides, read_config
|
|
918
|
+
|
|
919
|
+
def job(s: Settings) -> dict[str, Any]:
|
|
920
|
+
return {
|
|
921
|
+
"config_file": str(config_path()),
|
|
922
|
+
"stored": read_config(),
|
|
923
|
+
"env_overrides": env_overrides(),
|
|
924
|
+
"effective": {
|
|
925
|
+
"workspace": str(s.workspace),
|
|
926
|
+
"device": s.device,
|
|
927
|
+
"profile": s.profile,
|
|
928
|
+
"verbosity": s.verbosity,
|
|
929
|
+
"toolset": s.toolset,
|
|
930
|
+
"threads": s.threads,
|
|
931
|
+
"timeout_s": s.subprocess_timeout_s,
|
|
932
|
+
},
|
|
933
|
+
"hardware": hardware_report(s),
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
_run(ctx, job)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
@config_app.command("set")
|
|
940
|
+
def config_set(
|
|
941
|
+
ctx: typer.Context,
|
|
942
|
+
key: str = typer.Argument(..., help="device | profile | verbosity | toolset | threads | ..."),
|
|
943
|
+
value: str = typer.Argument(...),
|
|
944
|
+
) -> None:
|
|
945
|
+
"""Set a persistent value, e.g. `reverse3d config set device gpu`."""
|
|
946
|
+
from reverse3d.userconfig import update_config
|
|
947
|
+
|
|
948
|
+
_run(ctx, lambda s: {"config": update_config({key: value})})
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
@config_app.command("unset")
|
|
952
|
+
def config_unset(ctx: typer.Context, key: str = typer.Argument(...)) -> None:
|
|
953
|
+
"""Remove a persistent value (back to the default)."""
|
|
954
|
+
from reverse3d.userconfig import update_config
|
|
955
|
+
|
|
956
|
+
_run(ctx, lambda s: {"config": update_config({key: None})})
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@config_app.command("path")
|
|
960
|
+
def config_path_cmd() -> None:
|
|
961
|
+
"""Print the config file location."""
|
|
962
|
+
from reverse3d.userconfig import config_path
|
|
963
|
+
|
|
964
|
+
typer.echo(str(config_path()))
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# ─── agent setup ──────────────────────────────────────────────────────────────
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
@app.command()
|
|
971
|
+
def setup(
|
|
972
|
+
ctx: typer.Context,
|
|
973
|
+
agents: str = typer.Option(
|
|
974
|
+
"auto",
|
|
975
|
+
"--agents",
|
|
976
|
+
"-a",
|
|
977
|
+
help="auto (detected) | all | comma list: claude-code,claude-desktop,cursor,windsurf,"
|
|
978
|
+
"vscode,gemini,opencode,codex",
|
|
979
|
+
),
|
|
980
|
+
workspace: Path | None = typer.Option(
|
|
981
|
+
None, "--workspace", help="Sandbox folder for agents (default: ~/reverse3d-workspace)."
|
|
982
|
+
),
|
|
983
|
+
device: DeviceChoice | None = typer.Option(None, help="Also store the device preference."),
|
|
984
|
+
profile: ProfileChoice | None = typer.Option(None, help="Also store the performance profile."),
|
|
985
|
+
toolset: ToolsetChoice | None = typer.Option(
|
|
986
|
+
None, help="lean = 5 tools (fewest tokens), full = all tools."
|
|
987
|
+
),
|
|
988
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would change."),
|
|
989
|
+
uninstall: bool = typer.Option(False, "--uninstall", help="Remove reverse3d from agents."),
|
|
990
|
+
skill: bool = typer.Option(True, help="Install the Claude Code skill."),
|
|
991
|
+
auto_approve: bool = typer.Option(
|
|
992
|
+
True,
|
|
993
|
+
help="Pre-approve the reverse3d tools (Claude Code, Gemini CLI) so the agent "
|
|
994
|
+
"does not ask on every call. The tools stay confined to the workspace.",
|
|
995
|
+
),
|
|
996
|
+
show_snippets: bool = typer.Option(False, "--snippets", help="Print manual config snippets."),
|
|
997
|
+
) -> None:
|
|
998
|
+
"""Register the reverse3d MCP server in your AI agents (one command, idempotent)."""
|
|
999
|
+
from reverse3d.setup_agents import manual_snippets, setup_agents
|
|
1000
|
+
from reverse3d.userconfig import update_config
|
|
1001
|
+
|
|
1002
|
+
def job(s: Settings) -> dict[str, Any]:
|
|
1003
|
+
ws = workspace or Path.home() / "reverse3d-workspace"
|
|
1004
|
+
if show_snippets:
|
|
1005
|
+
return manual_snippets(ws)
|
|
1006
|
+
updates = {
|
|
1007
|
+
"workspace": None if uninstall else str(ws.expanduser().resolve()),
|
|
1008
|
+
"device": device.value if device else None,
|
|
1009
|
+
"profile": profile.value if profile else None,
|
|
1010
|
+
"toolset": toolset.value if toolset else None,
|
|
1011
|
+
}
|
|
1012
|
+
stored: dict[str, str] | None = None
|
|
1013
|
+
if not dry_run and not uninstall:
|
|
1014
|
+
stored = update_config({k: v for k, v in updates.items() if v is not None})
|
|
1015
|
+
results = setup_agents(
|
|
1016
|
+
agents,
|
|
1017
|
+
workspace=ws,
|
|
1018
|
+
remove=uninstall,
|
|
1019
|
+
dry_run=dry_run,
|
|
1020
|
+
install_skill=skill,
|
|
1021
|
+
auto_approve=auto_approve,
|
|
1022
|
+
)
|
|
1023
|
+
configured = [r.agent for r in results if r.status in ("configured", "unchanged")]
|
|
1024
|
+
if dry_run:
|
|
1025
|
+
planned = [r.agent for r in results if r.status == "dry_run"]
|
|
1026
|
+
return {
|
|
1027
|
+
"workspace": str(ws.expanduser().resolve()),
|
|
1028
|
+
"agents": [r.to_dict() for r in results],
|
|
1029
|
+
"next_step": f"Dry run only. Would configure: {', '.join(planned) or 'nothing'}. "
|
|
1030
|
+
"Re-run without --dry-run to apply.",
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
"workspace": str(ws.expanduser().resolve()),
|
|
1034
|
+
"agents": [r.to_dict() for r in results],
|
|
1035
|
+
"config": stored,
|
|
1036
|
+
"next_step": (
|
|
1037
|
+
"Restart your AI agent(s) and ask: 'usa reverse3d_environment_check'."
|
|
1038
|
+
if configured and not uninstall
|
|
1039
|
+
else "No agent was configured: use --agents <name> or --snippets."
|
|
1040
|
+
if not uninstall
|
|
1041
|
+
else "Removed. Restart your agents."
|
|
1042
|
+
),
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
_run(ctx, job)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def main() -> None:
|
|
1049
|
+
"""Console-script entry point."""
|
|
1050
|
+
app()
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
if __name__ == "__main__":
|
|
1054
|
+
main()
|