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/agent/tools.py
ADDED
|
@@ -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}
|