reverse3d 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. reverse3d/__init__.py +39 -0
  2. reverse3d/__main__.py +4 -0
  3. reverse3d/_about.py +15 -0
  4. reverse3d/_version.py +1 -0
  5. reverse3d/agent/__init__.py +5 -0
  6. reverse3d/agent/compact.py +110 -0
  7. reverse3d/agent/mcp_server.py +171 -0
  8. reverse3d/agent/prompts.py +173 -0
  9. reverse3d/agent/skill/SKILL.md +53 -0
  10. reverse3d/agent/tools.py +811 -0
  11. reverse3d/autopilot.py +355 -0
  12. reverse3d/cli.py +1054 -0
  13. reverse3d/config.py +167 -0
  14. reverse3d/doctor.py +113 -0
  15. reverse3d/errors.py +83 -0
  16. reverse3d/hardware.py +115 -0
  17. reverse3d/imaging.py +79 -0
  18. reverse3d/logging_utils.py +41 -0
  19. reverse3d/mesh_io.py +152 -0
  20. reverse3d/offline.py +313 -0
  21. reverse3d/profiles.py +33 -0
  22. reverse3d/py.typed +0 -0
  23. reverse3d/recipes.py +394 -0
  24. reverse3d/reconstruction/__init__.py +22 -0
  25. reverse3d/reconstruction/depth_estimation.py +263 -0
  26. reverse3d/reconstruction/photogrammetry.py +460 -0
  27. reverse3d/reconstruction/pointcloud.py +120 -0
  28. reverse3d/rendering/__init__.py +10 -0
  29. reverse3d/rendering/_blender_job.py +284 -0
  30. reverse3d/rendering/blender_render.py +215 -0
  31. reverse3d/rendering/preview.py +80 -0
  32. reverse3d/reverse_eng/__init__.py +33 -0
  33. reverse3d/reverse_eng/geometry_fit.py +478 -0
  34. reverse3d/reverse_eng/measure.py +87 -0
  35. reverse3d/reverse_eng/mesh_cleaner.py +304 -0
  36. reverse3d/reverse_eng/parametric.py +154 -0
  37. reverse3d/security.py +294 -0
  38. reverse3d/setup_agents.py +517 -0
  39. reverse3d/userconfig.py +156 -0
  40. reverse3d/vision/__init__.py +12 -0
  41. reverse3d/vision/feature_matcher.py +272 -0
  42. reverse3d/vision/mask_extractor.py +352 -0
  43. reverse3d/watch.py +238 -0
  44. reverse3d-0.4.0.dist-info/METADATA +650 -0
  45. reverse3d-0.4.0.dist-info/RECORD +50 -0
  46. reverse3d-0.4.0.dist-info/WHEEL +5 -0
  47. reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
  48. reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
  49. reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
  50. reverse3d-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,811 @@
1
+ """Tool definitions and a sandboxed dispatcher for AI agents.
2
+
3
+ One registry feeds every integration:
4
+
5
+ * :func:`tool_definitions` exports JSON-Schema tool specs for Anthropic (Claude
6
+ tool use), OpenAI-compatible function calling and Google Gemini.
7
+ * :func:`execute_tool` validates the arguments an agent produced and runs the
8
+ tool. It is used by the MCP server, by ``reverse3d agent call`` and by
9
+ in-process agent loops.
10
+
11
+ Security controls (OWASP LLM05/LLM06/LLM10, Agentic ASI02):
12
+
13
+ * Arguments are parsed with strict Pydantic models (``extra="forbid"``, bounds
14
+ on every number, enums for every mode). Agent output is untrusted input.
15
+ * Every path is resolved inside ``Settings.workspace``; traversal and symlink
16
+ escapes are rejected. Existing files are never overwritten unless the agent
17
+ explicitly sets ``overwrite: true``.
18
+ * Tools only read/write files and launch COLMAP/Blender with fixed argument
19
+ lists; there is no generic shell, eval or network tool.
20
+ * Errors are returned as structured ``{"ok": false, "error": {...}}`` objects;
21
+ unexpected exceptions are logged server-side and reported generically.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import copy
27
+ import logging
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Any, Literal
32
+
33
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
34
+
35
+ from reverse3d.config import Settings
36
+ from reverse3d.errors import Reverse3DError
37
+ from reverse3d.security import resolve_in_workspace
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ SchemaDialect = Literal["anthropic", "openai", "gemini"]
42
+
43
+
44
+ _STR_MAX = 4096
45
+
46
+
47
+ class _Args(BaseModel):
48
+ model_config = ConfigDict(extra="forbid", str_max_length=_STR_MAX)
49
+
50
+
51
+ _OVERWRITE = Field(False, description="Replace existing output files. Default false.")
52
+ _ALL_KINDS: tuple[Literal["plane", "cylinder", "sphere"], ...] = ("plane", "cylinder", "sphere")
53
+
54
+
55
+ # ─── argument models ──────────────────────────────────────────────────────────
56
+
57
+
58
+ class EnvironmentArgs(_Args):
59
+ pass
60
+
61
+
62
+ class AnalyzeImagesArgs(_Args):
63
+ image_dir: str = Field(description="Folder (inside the workspace) with the capture photos.")
64
+ detector: Literal["sift", "orb"] = Field("sift", description="Feature detector.")
65
+ max_pairs: int = Field(60, ge=1, le=1000, description="Max consecutive pairs to match.")
66
+
67
+
68
+ class MatchImagesArgs(_Args):
69
+ image_a: str = Field(description="First image path.")
70
+ image_b: str = Field(description="Second image path.")
71
+ detector: Literal["sift", "orb"] = "sift"
72
+ ratio: float = Field(0.75, ge=0.5, le=0.95, description="Lowe ratio test threshold.")
73
+ visualization_path: str | None = Field(
74
+ None, description="Optional .png/.jpg to save a drawing of the inlier matches."
75
+ )
76
+ overwrite: bool = _OVERWRITE
77
+
78
+
79
+ class ExtractMaskArgs(_Args):
80
+ image_path: str = Field(description="Photo, technical plan, stencil or RGBA image.")
81
+ output_dir: str = Field(
82
+ description="Folder for <stem>_mask.png, _contours.json, _contours.svg."
83
+ )
84
+ method: Literal["otsu", "adaptive", "canny", "alpha"] = Field(
85
+ "otsu",
86
+ description="otsu: plain background; adaptive: uneven light / line drawings; "
87
+ "canny: edge-based; alpha: use PNG transparency.",
88
+ )
89
+ invert: bool | None = Field(None, description="Force inversion; null = auto-detect.")
90
+ blur: int = Field(5, ge=0, le=51)
91
+ min_area_ratio: float = Field(
92
+ 0.001, ge=0, lt=1, description="Drop regions smaller than this image fraction."
93
+ )
94
+ simplify_ratio: float = Field(
95
+ 0.002, ge=0, le=0.1, description="Contour simplification tolerance."
96
+ )
97
+ units_per_pixel: float | None = Field(
98
+ None, gt=0, description="Real-world scale, e.g. mm per pixel from a known dimension."
99
+ )
100
+ overwrite: bool = _OVERWRITE
101
+
102
+
103
+ class SilhouetteToMeshArgs(_Args):
104
+ contours_path: str = Field(description="*_contours.json produced by reverse3d_extract_mask.")
105
+ output_path: str = Field(description="Output mesh (.stl/.obj/.ply/.glb/.gltf/.off).")
106
+ thickness: float = Field(10.0, gt=0, le=1e6, description="Extrusion height in output units.")
107
+ units_per_pixel: float | None = Field(
108
+ None, gt=0, description="Overrides the scale stored in the JSON."
109
+ )
110
+ overwrite: bool = _OVERWRITE
111
+
112
+
113
+ class PhotogrammetryArgs(_Args):
114
+ image_dir: str = Field(description="Folder with >=3 overlapping photos of the object.")
115
+ output_dir: str = Field(description="Folder for the COLMAP workspace and outputs.")
116
+ quality: Literal["low", "medium", "high"] | None = Field(
117
+ None, description="null = from the performance profile."
118
+ )
119
+ matcher: Literal["auto", "exhaustive", "sequential"] = "auto"
120
+ dense: Literal["auto", "on", "off"] = Field(
121
+ "auto", description="Dense MVS needs a CUDA build of COLMAP. auto = only if GPU."
122
+ )
123
+ use_gpu: bool | None = Field(None, description="null = from the device setting.")
124
+ camera_model: Literal[
125
+ "SIMPLE_PINHOLE", "PINHOLE", "SIMPLE_RADIAL", "RADIAL", "OPENCV", "FULL_OPENCV"
126
+ ] = "SIMPLE_RADIAL"
127
+ build_mesh: bool = Field(True, description="Poisson-mesh the cloud (needs the [recon] extra).")
128
+ overwrite: bool = _OVERWRITE
129
+
130
+
131
+ class EstimateDepthArgs(_Args):
132
+ image_path: str = Field(description="Single image to estimate depth from.")
133
+ output_dir: str
134
+ model: Literal[
135
+ "depth-anything/Depth-Anything-V2-Small-hf",
136
+ "depth-anything/Depth-Anything-V2-Base-hf",
137
+ "depth-anything/Depth-Anything-V2-Large-hf",
138
+ ] = Field("depth-anything/Depth-Anything-V2-Small-hf", description="Allowlisted model id.")
139
+ device: Literal["auto", "cpu", "cuda", "mps"] = Field(
140
+ "auto", description="auto = from the device setting."
141
+ )
142
+ make_mesh: bool = True
143
+ fov_deg: float = Field(
144
+ 60.0, ge=10, le=170, description="Horizontal field of view of the camera."
145
+ )
146
+ stride: int | None = Field(
147
+ None, ge=1, le=32, description="Pixel step for the mesh grid; null = profile."
148
+ )
149
+ mask_path: str | None = Field(None, description="Optional mask PNG to mesh only the object.")
150
+ overwrite: bool = _OVERWRITE
151
+
152
+
153
+ class PointCloudMeshArgs(_Args):
154
+ cloud_path: str = Field(description="Point cloud (.ply/.xyz/.pcd).")
155
+ output_path: str = Field(description="Output mesh path.")
156
+ depth: int = Field(9, ge=5, le=12, description="Poisson octree depth (detail).")
157
+ overwrite: bool = _OVERWRITE
158
+
159
+
160
+ class CleanMeshArgs(_Args):
161
+ input_path: str = Field(description="Input mesh.")
162
+ output_path: str = Field(description="Output mesh (.stl/.obj/.ply/.glb/.gltf/.off).")
163
+ keep_largest_only: bool = False
164
+ min_component_ratio: float = Field(0.01, ge=0, lt=1)
165
+ fill_holes: bool = True
166
+ max_hole_edges: int = Field(200, ge=3, le=100_000)
167
+ fix_normals: bool = True
168
+ smooth_iterations: int = Field(0, ge=0, le=200)
169
+ target_faces: int | None = Field(None, ge=4, description="Decimate to this many faces.")
170
+ align: Literal["none", "center", "obb", "pca"] = "none"
171
+ ground: bool = Field(False, description="Translate so min Z = 0.")
172
+ scale: float = Field(1.0, gt=1e-9, lt=1e9, description="Uniform scale (e.g. 1000 for m→mm).")
173
+ overwrite: bool = _OVERWRITE
174
+
175
+
176
+ class MeasureMeshArgs(_Args):
177
+ input_path: str = Field(description="Mesh to measure.")
178
+
179
+
180
+ class FitPrimitivesArgs(_Args):
181
+ input_path: str = Field(description="Mesh or point cloud.")
182
+ kinds: list[Literal["plane", "cylinder", "sphere"]] = Field(
183
+ default_factory=lambda: list(_ALL_KINDS), min_length=1, max_length=3
184
+ )
185
+ sample_count: int | None = Field(None, ge=100, le=500_000, description="null = profile.")
186
+ threshold: float | None = Field(
187
+ None, gt=0, description="Inlier distance; null = 1% of bbox diagonal."
188
+ )
189
+ max_primitives: int = Field(8, ge=1, le=50)
190
+ min_inlier_ratio: float = Field(0.05, gt=0, lt=1)
191
+ parametric_output: str | None = Field(
192
+ None, description="Optional .scad (OpenSCAD) or .py (CadQuery) file to generate."
193
+ )
194
+ overwrite: bool = _OVERWRITE
195
+
196
+
197
+ class RenderArgs(_Args):
198
+ mesh_path: str = Field(description="Mesh to render.")
199
+ output_path: str = Field(description="Output .png (turntables add _0000 suffixes).")
200
+ backend: Literal["auto", "blender", "pyvista"] = "auto"
201
+ width: int | None = Field(None, ge=64, le=8192, description="null = profile.")
202
+ height: int | None = Field(None, ge=64, le=8192, description="null = profile.")
203
+ engine: Literal["cycles", "eevee", "workbench"] = "cycles"
204
+ device: Literal["auto", "cpu", "gpu"] = Field("auto", description="auto = device setting.")
205
+ samples: int | None = Field(None, ge=1, le=4096, description="null = profile.")
206
+ color: str = Field("#9aa4b2", pattern=r"^#[0-9a-fA-F]{6}$")
207
+ metallic: float = Field(0.0, ge=0, le=1)
208
+ roughness: float = Field(0.45, ge=0, le=1)
209
+ background: str = Field("#f4f5f7", pattern=r"^#[0-9a-fA-F]{6}$")
210
+ transparent: bool = False
211
+ elevation_deg: float = Field(25.0, ge=-89, le=89)
212
+ azimuth_deg: float = Field(35.0, ge=0, le=360)
213
+ turntable_frames: int = Field(0, ge=0, le=360)
214
+ ground: bool = True
215
+ shading: Literal["auto", "smooth", "flat"] = "auto"
216
+ overwrite: bool = _OVERWRITE
217
+
218
+
219
+ class RunRecipeArgs(_Args):
220
+ recipe: Literal[
221
+ "photos_to_model", "plan_to_part", "scan_to_cad", "photo_to_relief", "mesh_report", "auto"
222
+ ] = Field(
223
+ description="auto: detect the input type and pick/retry the right workflow (use it when "
224
+ "unsure). photos_to_model: photo folder→measured 3D model+render. "
225
+ "plan_to_part: plan/stencil image→extruded part. "
226
+ "scan_to_cad: mesh→clean+measure+primitives+OpenSCAD/CadQuery. "
227
+ "photo_to_relief: one photo→2.5D relief. mesh_report: mesh→dimensions+render."
228
+ )
229
+ input_path: str = Field(description="Photo folder (photos_to_model), image, or mesh.")
230
+ output_dir: str = Field(description="Folder for all results (model.stl, render.png...).")
231
+ thickness: float = Field(5.0, gt=0, le=1e6, description="plan_to_part extrusion height.")
232
+ units_per_pixel: float | None = Field(
233
+ None, gt=0, description="plan_to_part scale, e.g. mm per pixel."
234
+ )
235
+ scale: float = Field(1.0, gt=1e-9, lt=1e9, description="Final uniform scale (m→mm: 1000).")
236
+ render: bool = Field(True, description="Render a PNG preview at the end.")
237
+ quality: Literal["low", "medium", "high"] | None = Field(
238
+ None, description="photos_to_model quality; null = profile."
239
+ )
240
+ force_analysis: bool = Field(
241
+ False, description="photos_to_model: continue even if the photo check fails."
242
+ )
243
+ overwrite: bool = _OVERWRITE
244
+
245
+
246
+ class ConfigureArgs(_Args):
247
+ device: Literal["auto", "cpu", "gpu"] | None = Field(
248
+ None, description="Compute device preference. auto picks GPU when available."
249
+ )
250
+ profile: Literal["fast", "balanced", "quality"] | None = Field(
251
+ None, description="Speed/quality trade-off for every tool."
252
+ )
253
+ verbosity: Literal["compact", "full"] | None = Field(
254
+ None, description="compact saves tokens; full returns every detail."
255
+ )
256
+ threads: int | None = Field(None, ge=0, le=1024, description="CPU threads; 0 = automatic.")
257
+
258
+
259
+ # ─── handlers ─────────────────────────────────────────────────────────────────
260
+
261
+
262
+ def _p(path: str, settings: Settings) -> Path:
263
+ return resolve_in_workspace(path, settings.workspace)
264
+
265
+
266
+ def _opt(path: str | None, settings: Settings) -> Path | None:
267
+ return _p(path, settings) if path else None
268
+
269
+
270
+ def _environment(_: EnvironmentArgs, s: Settings) -> dict[str, Any]:
271
+ from reverse3d.doctor import environment_report
272
+
273
+ return environment_report(s)
274
+
275
+
276
+ def _analyze_images(a: AnalyzeImagesArgs, s: Settings) -> dict[str, Any]:
277
+ from reverse3d.vision.feature_matcher import analyze_image_set
278
+
279
+ return analyze_image_set(
280
+ _p(a.image_dir, s), s, detector=a.detector, max_pairs=a.max_pairs
281
+ ).to_dict()
282
+
283
+
284
+ def _match_images(a: MatchImagesArgs, s: Settings) -> dict[str, Any]:
285
+ from dataclasses import asdict
286
+
287
+ from reverse3d.vision.feature_matcher import match_pair
288
+
289
+ return asdict(
290
+ match_pair(
291
+ _p(a.image_a, s),
292
+ _p(a.image_b, s),
293
+ s,
294
+ detector=a.detector,
295
+ ratio=a.ratio,
296
+ visualization_path=_opt(a.visualization_path, s),
297
+ overwrite=a.overwrite,
298
+ )
299
+ )
300
+
301
+
302
+ def _extract_mask(a: ExtractMaskArgs, s: Settings) -> dict[str, Any]:
303
+ from reverse3d.vision.mask_extractor import extract_mask
304
+
305
+ return extract_mask(
306
+ _p(a.image_path, s),
307
+ _p(a.output_dir, s),
308
+ s,
309
+ method=a.method,
310
+ invert=a.invert,
311
+ blur=a.blur,
312
+ min_area_ratio=a.min_area_ratio,
313
+ simplify_ratio=a.simplify_ratio,
314
+ units_per_pixel=a.units_per_pixel,
315
+ overwrite=a.overwrite,
316
+ ).to_dict()
317
+
318
+
319
+ def _silhouette_to_mesh(a: SilhouetteToMeshArgs, s: Settings) -> dict[str, Any]:
320
+ from reverse3d.vision.mask_extractor import silhouette_to_mesh
321
+
322
+ return silhouette_to_mesh(
323
+ _p(a.contours_path, s),
324
+ _p(a.output_path, s),
325
+ s,
326
+ thickness=a.thickness,
327
+ units_per_pixel=a.units_per_pixel,
328
+ overwrite=a.overwrite,
329
+ )
330
+
331
+
332
+ def _photogrammetry(a: PhotogrammetryArgs, s: Settings) -> dict[str, Any]:
333
+ from reverse3d.hardware import wants_gpu
334
+ from reverse3d.profiles import profile_values
335
+ from reverse3d.reconstruction.photogrammetry import PhotogrammetryOptions, run_photogrammetry
336
+
337
+ options = PhotogrammetryOptions(
338
+ quality=a.quality or profile_values(s).photo_quality, # type: ignore[arg-type]
339
+ matcher=a.matcher,
340
+ dense=a.dense,
341
+ use_gpu=wants_gpu(s) if a.use_gpu is None else a.use_gpu,
342
+ camera_model=a.camera_model,
343
+ build_mesh=a.build_mesh,
344
+ )
345
+ return run_photogrammetry(
346
+ _p(a.image_dir, s), _p(a.output_dir, s), s, options, overwrite=a.overwrite
347
+ ).to_dict()
348
+
349
+
350
+ def _estimate_depth(a: EstimateDepthArgs, s: Settings) -> dict[str, Any]:
351
+ from reverse3d.hardware import torch_device
352
+ from reverse3d.profiles import profile_values
353
+ from reverse3d.reconstruction.depth_estimation import estimate_depth
354
+
355
+ return estimate_depth(
356
+ _p(a.image_path, s),
357
+ _p(a.output_dir, s),
358
+ s,
359
+ model=a.model,
360
+ device=torch_device(s) if a.device == "auto" else a.device,
361
+ make_mesh=a.make_mesh,
362
+ fov_deg=a.fov_deg,
363
+ stride=a.stride or profile_values(s).depth_stride,
364
+ mask_path=_opt(a.mask_path, s),
365
+ overwrite=a.overwrite,
366
+ ).to_dict()
367
+
368
+
369
+ def _point_cloud_mesh(a: PointCloudMeshArgs, s: Settings) -> dict[str, Any]:
370
+ from reverse3d.reconstruction.pointcloud import poisson_mesh
371
+
372
+ return poisson_mesh(
373
+ _p(a.cloud_path, s), _p(a.output_path, s), s, depth=a.depth, overwrite=a.overwrite
374
+ ).to_dict()
375
+
376
+
377
+ def _clean_mesh(a: CleanMeshArgs, s: Settings) -> dict[str, Any]:
378
+ from reverse3d.reverse_eng.mesh_cleaner import CleanOptions, clean_mesh_file
379
+
380
+ options = CleanOptions(
381
+ keep_largest_only=a.keep_largest_only,
382
+ min_component_ratio=a.min_component_ratio,
383
+ fix_normals=a.fix_normals,
384
+ fill_holes=a.fill_holes,
385
+ max_hole_edges=a.max_hole_edges,
386
+ smooth_iterations=a.smooth_iterations,
387
+ target_faces=a.target_faces,
388
+ align=a.align,
389
+ ground=a.ground,
390
+ scale=a.scale,
391
+ )
392
+ return clean_mesh_file(
393
+ _p(a.input_path, s), _p(a.output_path, s), s, options, overwrite=a.overwrite
394
+ ).to_dict()
395
+
396
+
397
+ def _measure_mesh(a: MeasureMeshArgs, s: Settings) -> dict[str, Any]:
398
+ from reverse3d.reverse_eng.measure import measure_mesh_file
399
+
400
+ return measure_mesh_file(_p(a.input_path, s), s).to_dict()
401
+
402
+
403
+ def _fit_primitives(a: FitPrimitivesArgs, s: Settings) -> dict[str, Any]:
404
+ from reverse3d.profiles import profile_values
405
+ from reverse3d.reverse_eng.geometry_fit import fit_primitives_file
406
+ from reverse3d.reverse_eng.parametric import export_parametric
407
+
408
+ report = fit_primitives_file(
409
+ _p(a.input_path, s),
410
+ s,
411
+ kinds=tuple(dict.fromkeys(a.kinds)),
412
+ sample_count=a.sample_count or profile_values(s).fit_samples,
413
+ threshold=a.threshold,
414
+ max_primitives=a.max_primitives,
415
+ min_inlier_ratio=a.min_inlier_ratio,
416
+ )
417
+ result = report.to_dict()
418
+ if a.parametric_output and report.primitives:
419
+ result["parametric_path"] = str(
420
+ export_parametric(report, _p(a.parametric_output, s), overwrite=a.overwrite)
421
+ )
422
+ return result
423
+
424
+
425
+ def _render(a: RenderArgs, s: Settings) -> dict[str, Any]:
426
+ from reverse3d.hardware import wants_gpu
427
+ from reverse3d.profiles import profile_values
428
+ from reverse3d.rendering.blender_render import RenderOptions, render_mesh
429
+
430
+ prof = profile_values(s)
431
+ device = ("gpu" if wants_gpu(s) else "cpu") if a.device == "auto" else a.device
432
+ options = RenderOptions(
433
+ width=a.width or prof.render_width,
434
+ height=a.height or prof.render_height,
435
+ engine=a.engine,
436
+ device=device,
437
+ samples=a.samples or prof.render_samples,
438
+ color=a.color,
439
+ metallic=a.metallic,
440
+ roughness=a.roughness,
441
+ background=a.background,
442
+ transparent=a.transparent,
443
+ elevation_deg=a.elevation_deg,
444
+ azimuth_deg=a.azimuth_deg,
445
+ turntable_frames=a.turntable_frames,
446
+ ground=a.ground,
447
+ shading=a.shading,
448
+ )
449
+ return render_mesh(
450
+ _p(a.mesh_path, s),
451
+ _p(a.output_path, s),
452
+ s,
453
+ options,
454
+ backend=a.backend,
455
+ overwrite=a.overwrite,
456
+ ).to_dict()
457
+
458
+
459
+ def _run_recipe(a: RunRecipeArgs, s: Settings) -> dict[str, Any]:
460
+ from reverse3d.recipes import RecipeOptions, run_recipe
461
+
462
+ return run_recipe(
463
+ a.recipe,
464
+ _p(a.input_path, s),
465
+ _p(a.output_dir, s),
466
+ s,
467
+ RecipeOptions(
468
+ thickness=a.thickness,
469
+ units_per_pixel=a.units_per_pixel,
470
+ scale=a.scale,
471
+ render=a.render,
472
+ quality=a.quality,
473
+ force_analysis=a.force_analysis,
474
+ overwrite=a.overwrite,
475
+ ),
476
+ )
477
+
478
+
479
+ def _configure(a: ConfigureArgs, s: Settings) -> dict[str, Any]:
480
+ from reverse3d.hardware import hardware_report
481
+ from reverse3d.userconfig import AGENT_WRITABLE, config_path, env_overrides, update_config
482
+
483
+ updates = {k: v for k, v in a.model_dump().items() if v is not None}
484
+ if updates:
485
+ # Agents may only touch harmless keys; never the workspace, limits or binaries.
486
+ update_config(updates, allowed=AGENT_WRITABLE)
487
+ effective = Settings.load().with_workspace(s.workspace)
488
+ overridden = [k for k in env_overrides() if k in updates]
489
+ result: dict[str, Any] = {
490
+ "changed": updates,
491
+ "effective": {
492
+ "device": effective.device,
493
+ "profile": effective.profile,
494
+ "verbosity": effective.verbosity,
495
+ "threads": effective.threads,
496
+ "toolset": effective.toolset,
497
+ },
498
+ "hardware": hardware_report(effective),
499
+ "config_file": str(config_path()),
500
+ }
501
+ if overridden:
502
+ result["warning"] = (
503
+ f"{overridden} are fixed by environment variables in the MCP/client config; "
504
+ "remove them there for this change to take effect."
505
+ )
506
+ return result
507
+
508
+
509
+ # ─── registry ─────────────────────────────────────────────────────────────────
510
+
511
+
512
+ @dataclass(frozen=True)
513
+ class ToolSpec:
514
+ name: str
515
+ description: str
516
+ model: type[_Args]
517
+ handler: Callable[[Any, Settings], dict[str, Any]]
518
+
519
+
520
+ TOOLS: dict[str, ToolSpec] = {
521
+ spec.name: spec
522
+ for spec in (
523
+ ToolSpec(
524
+ "reverse3d_environment_check",
525
+ "Report which reverse3d capabilities are available on this machine (COLMAP, Blender, "
526
+ "Open3D, depth models, PyVista, GPU), the sandbox workspace and resource limits. Call "
527
+ "this first to plan a pipeline.",
528
+ EnvironmentArgs,
529
+ _environment,
530
+ ),
531
+ ToolSpec(
532
+ "reverse3d_run_recipe",
533
+ "PREFERRED entry point: run a complete 3D workflow in ONE call and get a short summary "
534
+ "(output files, dimensions, warnings); full details are saved to report.json. Use the "
535
+ "individual tools only for custom steps.",
536
+ RunRecipeArgs,
537
+ _run_recipe,
538
+ ),
539
+ ToolSpec(
540
+ "reverse3d_configure",
541
+ "View or change persistent settings: device (auto/cpu/gpu), profile "
542
+ "(fast/balanced/quality), verbosity (compact/full), CPU threads. Call with no "
543
+ "arguments to see the current settings and detected GPU.",
544
+ ConfigureArgs,
545
+ _configure,
546
+ ),
547
+ ToolSpec(
548
+ "reverse3d_analyze_images",
549
+ "Assess a folder of multi-view photos before photogrammetry: sharpness, exposure, "
550
+ "texture, keypoints and feature overlap between consecutive images. Returns per-image "
551
+ "issues, weak links and recommendations.",
552
+ AnalyzeImagesArgs,
553
+ _analyze_images,
554
+ ),
555
+ ToolSpec(
556
+ "reverse3d_match_images",
557
+ "Match features between two images (ratio test + RANSAC) and report how many "
558
+ "geometrically consistent correspondences they share. Optionally draws the matches.",
559
+ MatchImagesArgs,
560
+ _match_images,
561
+ ),
562
+ ToolSpec(
563
+ "reverse3d_extract_mask",
564
+ "Extract a binary mask and vector contours (outer outline + holes) from a photo, "
565
+ "technical plan, stencil or RGBA image. Writes a mask PNG, contours JSON and SVG.",
566
+ ExtractMaskArgs,
567
+ _extract_mask,
568
+ ),
569
+ ToolSpec(
570
+ "reverse3d_silhouette_to_mesh",
571
+ "Extrude the contours JSON from reverse3d_extract_mask into a 3D solid (plan to part). "
572
+ "Use units_per_pixel for real-world scale.",
573
+ SilhouetteToMeshArgs,
574
+ _silhouette_to_mesh,
575
+ ),
576
+ ToolSpec(
577
+ "reverse3d_photogrammetry",
578
+ "Reconstruct a 3D point cloud (and optionally a mesh) from overlapping photos using "
579
+ "COLMAP. Long-running (minutes to hours). Run reverse3d_analyze_images first.",
580
+ PhotogrammetryArgs,
581
+ _photogrammetry,
582
+ ),
583
+ ToolSpec(
584
+ "reverse3d_estimate_depth",
585
+ "Estimate relative depth from a single image with an allowlisted Depth Anything V2 "
586
+ "model and optionally build a 2.5D mesh. Shape is approximate, not metric.",
587
+ EstimateDepthArgs,
588
+ _estimate_depth,
589
+ ),
590
+ ToolSpec(
591
+ "reverse3d_mesh_from_point_cloud",
592
+ "Build a surface mesh from a point cloud with screened Poisson reconstruction "
593
+ "(requires Open3D).",
594
+ PointCloudMeshArgs,
595
+ _point_cloud_mesh,
596
+ ),
597
+ ToolSpec(
598
+ "reverse3d_clean_mesh",
599
+ "Repair a mesh: remove degenerate faces and debris, fix normals, fill holes, smooth, "
600
+ "decimate, align (OBB/PCA), place on ground and rescale. Returns before/after stats.",
601
+ CleanMeshArgs,
602
+ _clean_mesh,
603
+ ),
604
+ ToolSpec(
605
+ "reverse3d_measure_mesh",
606
+ "Measure a mesh: bounding boxes (axis-aligned and minimum oriented), surface area, "
607
+ "volume, watertightness, components and open boundary edges.",
608
+ MeasureMeshArgs,
609
+ _measure_mesh,
610
+ ),
611
+ ToolSpec(
612
+ "reverse3d_fit_primitives",
613
+ "Detect planes, cylinders and spheres (RANSAC) on a mesh or point cloud and report "
614
+ "their parameters (radii, axes, normals). Optionally writes OpenSCAD/CadQuery code.",
615
+ FitPrimitivesArgs,
616
+ _fit_primitives,
617
+ ),
618
+ ToolSpec(
619
+ "reverse3d_render",
620
+ "Render a mesh to PNG headlessly with Blender (Cycles/Eevee/Workbench) or PyVista, "
621
+ "with studio lighting, material color and optional turntable frames.",
622
+ RenderArgs,
623
+ _render,
624
+ ),
625
+ )
626
+ }
627
+
628
+
629
+ #: Minimal tool set for ``toolset=lean``: recipes cover the common workflows, so
630
+ #: agents only pay the schema tokens of 5 tools instead of all of them.
631
+ LEAN_TOOLS = (
632
+ "reverse3d_environment_check",
633
+ "reverse3d_run_recipe",
634
+ "reverse3d_configure",
635
+ "reverse3d_measure_mesh",
636
+ "reverse3d_render",
637
+ )
638
+
639
+
640
+ # ─── schema export ────────────────────────────────────────────────────────────
641
+
642
+
643
+ # Gemini function declarations accept an OpenAPI 3 subset of JSON Schema.
644
+ _GEMINI_KEYS = frozenset(
645
+ {
646
+ "type",
647
+ "format",
648
+ "description",
649
+ "nullable",
650
+ "enum",
651
+ "properties",
652
+ "required",
653
+ "items",
654
+ "minimum",
655
+ "maximum",
656
+ "minItems",
657
+ "maxItems",
658
+ "minLength",
659
+ "maxLength",
660
+ "pattern",
661
+ "anyOf",
662
+ }
663
+ )
664
+
665
+
666
+ def _clean_schema(node: Any, dialect: SchemaDialect, *, in_properties: bool = False) -> Any:
667
+ """Strip noise (titles) and adapt the schema to Gemini's OpenAPI subset."""
668
+ if isinstance(node, dict):
669
+ if in_properties: # keys are property names, not schema keywords
670
+ return {k: _clean_schema(v, dialect) for k, v in node.items()}
671
+ node = {
672
+ k: _clean_schema(v, dialect, in_properties=(k == "properties"))
673
+ for k, v in node.items()
674
+ # The global 4096-char string cap is enforced server-side; repeating it
675
+ # on every field only costs schema tokens.
676
+ if k != "title" and not (k == "maxLength" and v == _STR_MAX)
677
+ }
678
+ if dialect == "gemini":
679
+ if "exclusiveMinimum" in node:
680
+ node.setdefault("minimum", node["exclusiveMinimum"])
681
+ if "exclusiveMaximum" in node:
682
+ node.setdefault("maximum", node["exclusiveMaximum"])
683
+ if "anyOf" in node:
684
+ variants = [v for v in node["anyOf"] if v.get("type") != "null"]
685
+ if len(variants) == 1 and len(node["anyOf"]) == 2:
686
+ merged = {k: v for k, v in node.items() if k != "anyOf"}
687
+ merged.update(variants[0])
688
+ merged["nullable"] = True
689
+ node = merged
690
+ node = {k: v for k, v in node.items() if k in _GEMINI_KEYS}
691
+ return node
692
+ if isinstance(node, list):
693
+ return [_clean_schema(v, dialect) for v in node]
694
+ return node
695
+
696
+
697
+ def input_schema(spec: ToolSpec, dialect: SchemaDialect = "anthropic") -> dict[str, Any]:
698
+ """JSON Schema of a tool's arguments."""
699
+ schema = copy.deepcopy(spec.model.model_json_schema())
700
+ schema.setdefault("properties", {})
701
+ schema["additionalProperties"] = False
702
+ cleaned: dict[str, Any] = _clean_schema(schema, dialect)
703
+ if dialect == "gemini":
704
+ cleaned.pop("additionalProperties", None)
705
+ return cleaned
706
+
707
+
708
+ def visible_tools(toolset: str = "full") -> list[ToolSpec]:
709
+ """Tools exposed to agents for a toolset (``full`` or ``lean``)."""
710
+ if toolset == "lean":
711
+ return [TOOLS[name] for name in LEAN_TOOLS]
712
+ return list(TOOLS.values())
713
+
714
+
715
+ def tool_definitions(
716
+ dialect: SchemaDialect = "anthropic", toolset: str = "full"
717
+ ) -> list[dict[str, Any]]:
718
+ """Return the tool list in the format expected by each provider.
719
+
720
+ * ``anthropic``: ``[{"name", "description", "input_schema"}]``
721
+ * ``openai``: ``[{"type": "function", "function": {"name", "description", "parameters"}}]``
722
+ * ``gemini``: ``[{"name", "description", "parameters"}]`` (function declarations)
723
+ """
724
+ tools: list[dict[str, Any]] = []
725
+ for spec in visible_tools(toolset):
726
+ schema = input_schema(spec, dialect)
727
+ if dialect == "anthropic":
728
+ tools.append(
729
+ {"name": spec.name, "description": spec.description, "input_schema": schema}
730
+ )
731
+ elif dialect == "openai":
732
+ tools.append(
733
+ {
734
+ "type": "function",
735
+ "function": {
736
+ "name": spec.name,
737
+ "description": spec.description,
738
+ "parameters": schema,
739
+ },
740
+ }
741
+ )
742
+ elif dialect == "gemini":
743
+ tools.append({"name": spec.name, "description": spec.description, "parameters": schema})
744
+ else:
745
+ raise ValueError(f"Unknown dialect: {dialect}")
746
+ return tools
747
+
748
+
749
+ # ─── dispatcher ───────────────────────────────────────────────────────────────
750
+
751
+
752
+ def _validation_message(exc: ValidationError) -> str:
753
+ parts = []
754
+ for err in exc.errors(include_url=False, include_input=False)[:10]:
755
+ loc = ".".join(str(x) for x in err.get("loc", ())) or "(root)"
756
+ parts.append(f"{loc}: {err.get('msg')}")
757
+ return "; ".join(parts)
758
+
759
+
760
+ def execute_tool(name: str, arguments: dict[str, Any] | None, settings: Settings) -> dict[str, Any]:
761
+ """Validate ``arguments`` and run tool ``name`` inside the sandbox.
762
+
763
+ Always returns a JSON-serialisable dict: ``{"ok": true, "tool", "result"}``
764
+ or ``{"ok": false, "tool", "error": {"code", "message", ...}}``. Never raises
765
+ for tool-level failures, so agent loops can feed the error back to the model.
766
+ With ``settings.verbosity == "compact"`` results are shrunk for token economy
767
+ (see :mod:`reverse3d.agent.compact`).
768
+ """
769
+ spec = TOOLS.get(name)
770
+ if spec is None:
771
+ return {
772
+ "ok": False,
773
+ "tool": name,
774
+ "error": {
775
+ "code": "unknown_tool",
776
+ "message": f"Unknown tool. Available: {', '.join(TOOLS)}",
777
+ },
778
+ }
779
+ if arguments is not None and not isinstance(arguments, dict):
780
+ return {
781
+ "ok": False,
782
+ "tool": name,
783
+ "error": {"code": "invalid_input", "message": "arguments must be an object"},
784
+ }
785
+ try:
786
+ args = spec.model.model_validate(arguments or {})
787
+ except ValidationError as exc:
788
+ return {
789
+ "ok": False,
790
+ "tool": name,
791
+ "error": {"code": "invalid_input", "message": _validation_message(exc)},
792
+ }
793
+
794
+ logger.info("Tool call: %s", name)
795
+ try:
796
+ result = spec.handler(args, settings)
797
+ except Reverse3DError as exc:
798
+ logger.warning("Tool %s failed: %s", name, exc.code)
799
+ return {"ok": False, "tool": name, "error": exc.to_dict()}
800
+ except Exception:
801
+ logger.exception("Unexpected error in tool %s", name)
802
+ return {
803
+ "ok": False,
804
+ "tool": name,
805
+ "error": {"code": "internal_error", "message": "Unexpected error; see reverse3d logs."},
806
+ }
807
+ if settings.verbosity == "compact":
808
+ from reverse3d.agent.compact import compact_result
809
+
810
+ result = compact_result(name, result, settings.workspace)
811
+ return {"ok": True, "tool": name, "result": result}