threecrate-mcp 0.1.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.
- threecrate_mcp/__init__.py +3 -0
- threecrate_mcp/__main__.py +3 -0
- threecrate_mcp/ops.py +563 -0
- threecrate_mcp/server.py +319 -0
- threecrate_mcp/workspace.py +119 -0
- threecrate_mcp-0.1.0.dist-info/METADATA +120 -0
- threecrate_mcp-0.1.0.dist-info/RECORD +9 -0
- threecrate_mcp-0.1.0.dist-info/WHEEL +4 -0
- threecrate_mcp-0.1.0.dist-info/entry_points.txt +2 -0
threecrate_mcp/ops.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Point cloud and mesh operations behind the MCP tools.
|
|
2
|
+
|
|
3
|
+
Each function reads its inputs from files, runs a threecrate operation, writes
|
|
4
|
+
any result to a file, and returns a small summary dict. Point data never goes
|
|
5
|
+
back to the agent: a large scan would flood its context.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import threecrate as tc
|
|
15
|
+
|
|
16
|
+
from .workspace import MESH_FORMATS, POINT_CLOUD_WRITE_FORMATS, Workspace, WorkspaceError
|
|
17
|
+
|
|
18
|
+
# Points sampled when estimating spacing; enough for a stable median.
|
|
19
|
+
SPACING_SAMPLE = 2000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Helpers
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _header_point_count(path: Path) -> Optional[int]:
|
|
28
|
+
"""The point count a PLY or PCD header declares, read without loading the
|
|
29
|
+
points; None for other formats or an unreadable header."""
|
|
30
|
+
suffix = path.suffix.lower()
|
|
31
|
+
if suffix not in {".ply", ".pcd"}:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
with open(path, "rb") as fh:
|
|
35
|
+
for _ in range(200): # headers are short; give up on anything odd
|
|
36
|
+
line = fh.readline()
|
|
37
|
+
if not line:
|
|
38
|
+
return None
|
|
39
|
+
words = line.decode("ascii", errors="replace").split()
|
|
40
|
+
if suffix == ".ply" and words[:2] == ["element", "vertex"] and len(words) == 3:
|
|
41
|
+
return int(words[2])
|
|
42
|
+
if suffix == ".pcd" and words[:1] == ["POINTS"] and len(words) == 2:
|
|
43
|
+
return int(words[1])
|
|
44
|
+
if words[:1] in (["end_header"], ["DATA"]):
|
|
45
|
+
return None
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_cloud(ws: Workspace, path: str) -> tuple[Path, "tc.PointCloud"]:
|
|
52
|
+
resolved = ws.input_path(path)
|
|
53
|
+
# Refuse oversized files before loading them, when the header says how big.
|
|
54
|
+
declared = _header_point_count(resolved)
|
|
55
|
+
if declared is not None:
|
|
56
|
+
ws.check_size(declared, resolved)
|
|
57
|
+
try:
|
|
58
|
+
cloud = tc.read_point_cloud(str(resolved))
|
|
59
|
+
except Exception as exc: # threecrate raises plain exceptions for bad files
|
|
60
|
+
raise WorkspaceError(f"could not read {path} as a point cloud: {exc}") from exc
|
|
61
|
+
ws.check_size(len(cloud), resolved)
|
|
62
|
+
if len(cloud) == 0:
|
|
63
|
+
raise WorkspaceError(f"{path} contains no points")
|
|
64
|
+
return resolved, cloud
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_mesh(ws: Workspace, path: str) -> tuple[Path, "tc.TriangleMesh"]:
|
|
68
|
+
resolved = ws.input_path(path)
|
|
69
|
+
try:
|
|
70
|
+
mesh = tc.read_mesh(str(resolved))
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
raise WorkspaceError(f"could not read {path} as a mesh: {exc}") from exc
|
|
73
|
+
if mesh.is_empty:
|
|
74
|
+
raise WorkspaceError(f"{path} contains no triangles")
|
|
75
|
+
return resolved, mesh
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _bounds(points: np.ndarray) -> dict:
|
|
79
|
+
finite = points[np.isfinite(points).all(axis=1)]
|
|
80
|
+
if len(finite) == 0:
|
|
81
|
+
return {"finite_points": 0}
|
|
82
|
+
lo = finite.min(axis=0)
|
|
83
|
+
hi = finite.max(axis=0)
|
|
84
|
+
return {
|
|
85
|
+
"finite_points": int(len(finite)),
|
|
86
|
+
"min": [round(float(v), 6) for v in lo],
|
|
87
|
+
"max": [round(float(v), 6) for v in hi],
|
|
88
|
+
"extent": [round(float(v), 6) for v in hi - lo],
|
|
89
|
+
"centroid": [round(float(v), 6) for v in finite.mean(axis=0)],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _spacing(cloud: "tc.PointCloud") -> Optional[float]:
|
|
94
|
+
"""Median distance from a point to its nearest neighbour, on a sample."""
|
|
95
|
+
points = cloud.to_numpy()
|
|
96
|
+
finite = points[np.isfinite(points).all(axis=1)]
|
|
97
|
+
if len(finite) < 2:
|
|
98
|
+
return None
|
|
99
|
+
rng = np.random.default_rng(0)
|
|
100
|
+
sample = finite[rng.choice(len(finite), size=min(SPACING_SAMPLE, len(finite)), replace=False)]
|
|
101
|
+
tree = tc.KdTree(cloud)
|
|
102
|
+
distances = []
|
|
103
|
+
for query in sample:
|
|
104
|
+
_, dist = tree.knn(query, 2)
|
|
105
|
+
# The first neighbour is the point itself (distance 0).
|
|
106
|
+
others = [d for d in dist if d > 0]
|
|
107
|
+
if others:
|
|
108
|
+
distances.append(others[0])
|
|
109
|
+
return float(np.median(distances)) if distances else None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cloud_summary(cloud: "tc.PointCloud") -> dict:
|
|
113
|
+
return {"points": len(cloud), **_bounds(cloud.to_numpy())}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _mesh_summary(mesh: "tc.TriangleMesh") -> dict:
|
|
117
|
+
return {
|
|
118
|
+
"vertices": mesh.vertex_count,
|
|
119
|
+
"faces": mesh.face_count,
|
|
120
|
+
**{k: v for k, v in _bounds(mesh.vertices()).items() if k != "finite_points"},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _write_cloud(cloud: "tc.PointCloud", path: Path) -> None:
|
|
125
|
+
tc.write_point_cloud(cloud, str(path))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _write_ply_with_normals(positions: np.ndarray, normals: np.ndarray, path: Path) -> None:
|
|
129
|
+
"""Binary PLY with x y z nx ny nz (threecrate's writer only stores positions)."""
|
|
130
|
+
data = np.hstack([positions, normals]).astype("<f4")
|
|
131
|
+
header = (
|
|
132
|
+
"ply\nformat binary_little_endian 1.0\n"
|
|
133
|
+
f"element vertex {len(data)}\n"
|
|
134
|
+
"property float x\nproperty float y\nproperty float z\n"
|
|
135
|
+
"property float nx\nproperty float ny\nproperty float nz\n"
|
|
136
|
+
"end_header\n"
|
|
137
|
+
)
|
|
138
|
+
with open(path, "wb") as fh:
|
|
139
|
+
fh.write(header.encode("ascii"))
|
|
140
|
+
fh.write(data.tobytes())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _orient(normals: "tc.NormalPointCloud", orientation: str, k: int) -> "tc.NormalPointCloud":
|
|
144
|
+
"""`consistent`: agree all around (outward on closed objects). `viewpoint`:
|
|
145
|
+
keep estimate_normals' choice, facing a point above the cloud (one-sided scans)."""
|
|
146
|
+
if orientation == "consistent":
|
|
147
|
+
return tc.orient_normals(normals, k)
|
|
148
|
+
if orientation == "viewpoint":
|
|
149
|
+
return normals
|
|
150
|
+
raise WorkspaceError(f"unknown normal_orientation {orientation!r}; use 'consistent' or 'viewpoint'")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _matrix(transform: Sequence[Sequence[float]]) -> np.ndarray:
|
|
154
|
+
matrix = np.asarray(transform, dtype=np.float64)
|
|
155
|
+
if matrix.shape != (4, 4):
|
|
156
|
+
raise WorkspaceError("transform must be a 4x4 matrix (a list of 4 rows of 4 numbers)")
|
|
157
|
+
if not np.allclose(matrix[3], [0, 0, 0, 1]):
|
|
158
|
+
raise WorkspaceError("the last row of a rigid transform must be [0, 0, 0, 1]")
|
|
159
|
+
return matrix
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _matrix_list(matrix: np.ndarray) -> list[list[float]]:
|
|
163
|
+
return [[round(float(v), 8) for v in row] for row in np.asarray(matrix)]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Inspection and conversion
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def inspect_point_cloud(ws: Workspace, path: str) -> dict:
|
|
172
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
173
|
+
spacing = _spacing(cloud)
|
|
174
|
+
summary = {
|
|
175
|
+
"path": ws.relative(resolved),
|
|
176
|
+
"file_bytes": resolved.stat().st_size,
|
|
177
|
+
**_cloud_summary(cloud),
|
|
178
|
+
"median_spacing": None if spacing is None else round(spacing, 6),
|
|
179
|
+
}
|
|
180
|
+
if spacing:
|
|
181
|
+
# Rules of thumb the agent can start from; see the skill for details.
|
|
182
|
+
summary["suggested"] = {
|
|
183
|
+
"voxel_size_light": round(2 * spacing, 6),
|
|
184
|
+
"voxel_size_strong": round(5 * spacing, 6),
|
|
185
|
+
"outlier_radius": round(3 * spacing, 6),
|
|
186
|
+
"cluster_tolerance": round(3 * spacing, 6),
|
|
187
|
+
}
|
|
188
|
+
return summary
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def inspect_mesh(ws: Workspace, path: str) -> dict:
|
|
192
|
+
resolved, mesh = _read_mesh(ws, path)
|
|
193
|
+
return {"path": ws.relative(resolved), "file_bytes": resolved.stat().st_size, **_mesh_summary(mesh)}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def convert(
|
|
197
|
+
ws: Workspace,
|
|
198
|
+
path: str,
|
|
199
|
+
output: str,
|
|
200
|
+
kind: Literal["point_cloud", "mesh"] = "point_cloud",
|
|
201
|
+
overwrite: bool = False,
|
|
202
|
+
) -> dict:
|
|
203
|
+
if kind == "mesh":
|
|
204
|
+
resolved, mesh = _read_mesh(ws, path)
|
|
205
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="converted",
|
|
206
|
+
allowed=MESH_FORMATS, overwrite=overwrite)
|
|
207
|
+
tc.write_mesh(mesh, str(out))
|
|
208
|
+
return {"output": ws.relative(out), **_mesh_summary(mesh)}
|
|
209
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
210
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="converted",
|
|
211
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
212
|
+
_write_cloud(cloud, out)
|
|
213
|
+
return {"output": ws.relative(out), "points": len(cloud)}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Cleaning
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def downsample(
|
|
222
|
+
ws: Workspace, path: str, voxel_size: float, output: Optional[str] = None, overwrite: bool = False
|
|
223
|
+
) -> dict:
|
|
224
|
+
if not voxel_size > 0:
|
|
225
|
+
raise WorkspaceError("voxel_size must be positive")
|
|
226
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
227
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="downsampled",
|
|
228
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
229
|
+
result = tc.voxel_downsample(cloud, voxel_size)
|
|
230
|
+
_write_cloud(result, out)
|
|
231
|
+
return {
|
|
232
|
+
"output": ws.relative(out),
|
|
233
|
+
"points_before": len(cloud),
|
|
234
|
+
"points_after": len(result),
|
|
235
|
+
"voxel_size": voxel_size,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def remove_outliers(
|
|
240
|
+
ws: Workspace,
|
|
241
|
+
path: str,
|
|
242
|
+
method: Literal["statistical", "radius"] = "statistical",
|
|
243
|
+
k_neighbors: int = 20,
|
|
244
|
+
std_ratio: float = 2.0,
|
|
245
|
+
radius: Optional[float] = None,
|
|
246
|
+
min_neighbors: int = 5,
|
|
247
|
+
output: Optional[str] = None,
|
|
248
|
+
overwrite: bool = False,
|
|
249
|
+
) -> dict:
|
|
250
|
+
if method == "radius" and (radius is None or not radius > 0):
|
|
251
|
+
raise WorkspaceError("method='radius' needs a positive radius (see inspect_point_cloud's suggestion)")
|
|
252
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
253
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="clean",
|
|
254
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
255
|
+
if method == "statistical":
|
|
256
|
+
result = tc.remove_statistical_outliers(cloud, k_neighbors, std_ratio)
|
|
257
|
+
params = {"k_neighbors": k_neighbors, "std_ratio": std_ratio}
|
|
258
|
+
else:
|
|
259
|
+
result = tc.remove_radius_outliers(cloud, radius, min_neighbors)
|
|
260
|
+
params = {"radius": radius, "min_neighbors": min_neighbors}
|
|
261
|
+
_write_cloud(result, out)
|
|
262
|
+
return {
|
|
263
|
+
"output": ws.relative(out),
|
|
264
|
+
"method": method,
|
|
265
|
+
**params,
|
|
266
|
+
"points_before": len(cloud),
|
|
267
|
+
"points_after": len(result),
|
|
268
|
+
"removed": len(cloud) - len(result),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def estimate_normals(
|
|
273
|
+
ws: Workspace,
|
|
274
|
+
path: str,
|
|
275
|
+
k_neighbors: int = 10,
|
|
276
|
+
output: Optional[str] = None,
|
|
277
|
+
overwrite: bool = False,
|
|
278
|
+
normal_orientation: Literal["consistent", "viewpoint"] = "consistent",
|
|
279
|
+
) -> dict:
|
|
280
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
281
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="normals",
|
|
282
|
+
allowed={".ply"}, default_extension=".ply", overwrite=overwrite)
|
|
283
|
+
normals_cloud = _orient(tc.estimate_normals(cloud, k_neighbors), normal_orientation, k_neighbors)
|
|
284
|
+
_write_ply_with_normals(normals_cloud.positions(), normals_cloud.normals(), out)
|
|
285
|
+
return {
|
|
286
|
+
"output": ws.relative(out),
|
|
287
|
+
"points": len(normals_cloud),
|
|
288
|
+
"k_neighbors": k_neighbors,
|
|
289
|
+
"normal_orientation": normal_orientation,
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Registration and transforms
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def register(
|
|
299
|
+
ws: Workspace,
|
|
300
|
+
source: str,
|
|
301
|
+
target: str,
|
|
302
|
+
method: Literal["icp", "point_to_plane", "gicp", "global"] = "icp",
|
|
303
|
+
max_iterations: int = 50,
|
|
304
|
+
max_correspondence_distance: float = 1.0,
|
|
305
|
+
voxel_size: Optional[float] = None,
|
|
306
|
+
initial_transform: Optional[Sequence[Sequence[float]]] = None,
|
|
307
|
+
aligned_output: Optional[str] = None,
|
|
308
|
+
overwrite: bool = False,
|
|
309
|
+
) -> dict:
|
|
310
|
+
if voxel_size is not None and not voxel_size > 0:
|
|
311
|
+
raise WorkspaceError("voxel_size must be positive")
|
|
312
|
+
if method == "global" and initial_transform is not None:
|
|
313
|
+
raise WorkspaceError("method='global' finds its own starting pose; drop initial_transform")
|
|
314
|
+
init = None if initial_transform is None else _matrix(initial_transform)
|
|
315
|
+
source_path, source_cloud = _read_cloud(ws, source)
|
|
316
|
+
target_path, target_cloud = _read_cloud(ws, target)
|
|
317
|
+
out = None
|
|
318
|
+
if aligned_output is not None:
|
|
319
|
+
out = ws.output_path(aligned_output, inputs=[source_path, target_path], default_suffix="aligned",
|
|
320
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
321
|
+
|
|
322
|
+
# Optional downsampling for speed; the transform still applies to the full clouds.
|
|
323
|
+
src, tgt = source_cloud, target_cloud
|
|
324
|
+
if voxel_size:
|
|
325
|
+
src = tc.voxel_downsample(source_cloud, voxel_size)
|
|
326
|
+
tgt = tc.voxel_downsample(target_cloud, voxel_size)
|
|
327
|
+
|
|
328
|
+
if method == "icp":
|
|
329
|
+
result = tc.icp(src, tgt, max_iterations, init)
|
|
330
|
+
metrics = {"mse": result.mse, "iterations": result.iterations, "converged": result.converged}
|
|
331
|
+
elif method == "point_to_plane":
|
|
332
|
+
result = tc.icp_point_to_plane(src, tc.estimate_normals(tgt, 10), max_iterations, init)
|
|
333
|
+
metrics = {"mse": result.mse, "iterations": result.iterations, "converged": result.converged}
|
|
334
|
+
elif method == "gicp":
|
|
335
|
+
result = tc.gicp(src, tgt, max_iterations=max_iterations,
|
|
336
|
+
max_correspondence_distance=max_correspondence_distance,
|
|
337
|
+
init_transform=init)
|
|
338
|
+
metrics = {"mse": result.mse, "iterations": result.iterations, "converged": result.converged}
|
|
339
|
+
elif method == "global":
|
|
340
|
+
result = tc.global_registration(src, tgt, icp_max_iterations=max_iterations)
|
|
341
|
+
metrics = {
|
|
342
|
+
"inlier_count": result.inlier_count,
|
|
343
|
+
"inlier_ratio": result.inlier_ratio,
|
|
344
|
+
"mse": result.icp_mse,
|
|
345
|
+
"converged": result.icp_converged,
|
|
346
|
+
}
|
|
347
|
+
else:
|
|
348
|
+
raise WorkspaceError(f"unknown method {method!r}")
|
|
349
|
+
|
|
350
|
+
transform = np.asarray(result.transformation(), dtype=np.float64)
|
|
351
|
+
if metrics.get("mse") is not None:
|
|
352
|
+
metrics["rmse"] = round(float(np.sqrt(max(metrics["mse"], 0.0))), 8)
|
|
353
|
+
metrics["mse"] = round(float(metrics["mse"]), 10)
|
|
354
|
+
|
|
355
|
+
summary = {
|
|
356
|
+
"method": method,
|
|
357
|
+
"transform": _matrix_list(transform),
|
|
358
|
+
**metrics,
|
|
359
|
+
"source_points": len(source_cloud),
|
|
360
|
+
"target_points": len(target_cloud),
|
|
361
|
+
}
|
|
362
|
+
if voxel_size:
|
|
363
|
+
summary["registered_on"] = {"voxel_size": voxel_size, "source_points": len(src), "target_points": len(tgt)}
|
|
364
|
+
if out is not None:
|
|
365
|
+
_write_cloud(tc.transform_point_cloud(source_cloud, transform), out)
|
|
366
|
+
summary["aligned_output"] = ws.relative(out)
|
|
367
|
+
return summary
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def transform(
|
|
371
|
+
ws: Workspace,
|
|
372
|
+
path: str,
|
|
373
|
+
transform: Sequence[Sequence[float]],
|
|
374
|
+
output: Optional[str] = None,
|
|
375
|
+
overwrite: bool = False,
|
|
376
|
+
) -> dict:
|
|
377
|
+
matrix = _matrix(transform)
|
|
378
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
379
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="transformed",
|
|
380
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
381
|
+
result = tc.transform_point_cloud(cloud, matrix)
|
|
382
|
+
_write_cloud(result, out)
|
|
383
|
+
return {"output": ws.relative(out), **_cloud_summary(result)}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
# Segmentation
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def segment_plane(
|
|
392
|
+
ws: Workspace,
|
|
393
|
+
path: str,
|
|
394
|
+
distance_threshold: float = 0.01,
|
|
395
|
+
max_iterations: int = 1000,
|
|
396
|
+
inliers_output: Optional[str] = None,
|
|
397
|
+
outliers_output: Optional[str] = None,
|
|
398
|
+
overwrite: bool = False,
|
|
399
|
+
) -> dict:
|
|
400
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
401
|
+
# Check every output before computing or writing anything, so a bad path
|
|
402
|
+
# can't leave half the results on disk.
|
|
403
|
+
outputs = {}
|
|
404
|
+
for key, wanted, suffix in (("inliers_output", inliers_output, "plane"),
|
|
405
|
+
("outliers_output", outliers_output, "rest")):
|
|
406
|
+
if wanted is not None:
|
|
407
|
+
outputs[key] = ws.output_path(wanted, inputs=[resolved], default_suffix=suffix,
|
|
408
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
409
|
+
if len(outputs) == 2 and outputs["inliers_output"] == outputs["outliers_output"]:
|
|
410
|
+
raise WorkspaceError("inliers_output and outliers_output must be different files")
|
|
411
|
+
result = tc.segment_plane(cloud, distance_threshold, max_iterations)
|
|
412
|
+
a, b, c, d = (float(v) for v in result.plane_coefficients())
|
|
413
|
+
inliers = np.asarray(result.inlier_indices(), dtype=np.int64)
|
|
414
|
+
summary = {
|
|
415
|
+
"plane": {"a": round(a, 8), "b": round(b, 8), "c": round(c, 8), "d": round(d, 8)},
|
|
416
|
+
"equation": "a*x + b*y + c*z + d = 0",
|
|
417
|
+
"inliers": int(len(inliers)),
|
|
418
|
+
"inlier_ratio": round(len(inliers) / len(cloud), 6),
|
|
419
|
+
}
|
|
420
|
+
points = cloud.to_numpy()
|
|
421
|
+
mask = np.zeros(len(points), dtype=bool)
|
|
422
|
+
mask[inliers] = True
|
|
423
|
+
for key, keep in (("inliers_output", mask), ("outliers_output", ~mask)):
|
|
424
|
+
if key in outputs:
|
|
425
|
+
_write_cloud(tc.PointCloud(points[keep]), outputs[key])
|
|
426
|
+
summary[key] = ws.relative(outputs[key])
|
|
427
|
+
return summary
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def extract_clusters(
|
|
431
|
+
ws: Workspace,
|
|
432
|
+
path: str,
|
|
433
|
+
tolerance: float,
|
|
434
|
+
min_cluster_size: int = 100,
|
|
435
|
+
max_cluster_size: int = 25000,
|
|
436
|
+
output_dir: Optional[str] = None,
|
|
437
|
+
max_files: int = 50,
|
|
438
|
+
overwrite: bool = False,
|
|
439
|
+
) -> dict:
|
|
440
|
+
if not tolerance > 0:
|
|
441
|
+
raise WorkspaceError("tolerance must be positive (see inspect_point_cloud's suggestion)")
|
|
442
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
443
|
+
clusters = tc.extract_clusters(cloud, tolerance, min_cluster_size, max_cluster_size)
|
|
444
|
+
summary = {
|
|
445
|
+
"clusters": len(clusters),
|
|
446
|
+
"sizes": [len(c) for c in clusters[:max_files]],
|
|
447
|
+
"points_in_clusters": int(sum(len(c) for c in clusters)),
|
|
448
|
+
"points_total": len(cloud),
|
|
449
|
+
}
|
|
450
|
+
if output_dir is not None:
|
|
451
|
+
folder = ws.output_dir(output_dir)
|
|
452
|
+
# Check every file name first, so an existing file can't stop the
|
|
453
|
+
# call halfway with only some clusters written.
|
|
454
|
+
outs = [
|
|
455
|
+
ws.output_path(str(folder / f"{resolved.stem}.cluster{index:03d}.ply"),
|
|
456
|
+
inputs=[resolved], default_suffix="cluster",
|
|
457
|
+
allowed=POINT_CLOUD_WRITE_FORMATS, overwrite=overwrite)
|
|
458
|
+
for index in range(min(len(clusters), max_files))
|
|
459
|
+
]
|
|
460
|
+
for cluster, out in zip(clusters, outs):
|
|
461
|
+
_write_cloud(cluster, out)
|
|
462
|
+
summary["files"] = [ws.relative(out) for out in outs]
|
|
463
|
+
if len(clusters) > max_files:
|
|
464
|
+
summary["note"] = f"wrote the {max_files} largest clusters; raise max_files for more"
|
|
465
|
+
return summary
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
# Meshes
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def reconstruct(
|
|
474
|
+
ws: Workspace,
|
|
475
|
+
path: str,
|
|
476
|
+
method: Literal["auto", "poisson", "ball_pivoting", "alpha_shape", "delaunay"] = "auto",
|
|
477
|
+
normals_k: int = 10,
|
|
478
|
+
radius: Optional[float] = None,
|
|
479
|
+
alpha: Optional[float] = None,
|
|
480
|
+
output: Optional[str] = None,
|
|
481
|
+
overwrite: bool = False,
|
|
482
|
+
depth: int = 7,
|
|
483
|
+
trim_distance: Optional[float] = None,
|
|
484
|
+
normal_orientation: Literal["consistent", "viewpoint"] = "consistent",
|
|
485
|
+
) -> dict:
|
|
486
|
+
if not 3 <= depth <= 8:
|
|
487
|
+
raise WorkspaceError("depth must be between 3 and 8 (7 takes about a second, 8 about 10-20 seconds)")
|
|
488
|
+
resolved, cloud = _read_cloud(ws, path)
|
|
489
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="mesh",
|
|
490
|
+
allowed=MESH_FORMATS, default_extension=".ply", overwrite=overwrite)
|
|
491
|
+
if method == "auto":
|
|
492
|
+
mesh = tc.reconstruct(cloud)
|
|
493
|
+
elif method == "poisson":
|
|
494
|
+
normals = _orient(tc.estimate_normals(cloud, normals_k), normal_orientation, normals_k)
|
|
495
|
+
mesh = tc.poisson_reconstruct(normals, depth=depth, trim_distance=trim_distance)
|
|
496
|
+
elif method == "ball_pivoting":
|
|
497
|
+
if radius is None:
|
|
498
|
+
spacing = _spacing(cloud)
|
|
499
|
+
if not spacing:
|
|
500
|
+
raise WorkspaceError("could not estimate a ball radius; pass radius")
|
|
501
|
+
radius = 2.0 * spacing
|
|
502
|
+
mesh = tc.ball_pivoting_reconstruct(cloud, radius)
|
|
503
|
+
elif method == "alpha_shape":
|
|
504
|
+
mesh = tc.alpha_shape_reconstruct(cloud, 1.0 if alpha is None else alpha)
|
|
505
|
+
elif method == "delaunay":
|
|
506
|
+
mesh = tc.delaunay_triangulate(cloud)
|
|
507
|
+
else:
|
|
508
|
+
raise WorkspaceError(f"unknown method {method!r}")
|
|
509
|
+
if mesh.is_empty:
|
|
510
|
+
raise WorkspaceError(f"{method} produced an empty mesh; try another method or parameters")
|
|
511
|
+
tc.write_mesh(mesh, str(out))
|
|
512
|
+
summary = {"output": ws.relative(out), "method": method, **_mesh_summary(mesh)}
|
|
513
|
+
if method == "ball_pivoting":
|
|
514
|
+
summary["radius"] = round(float(radius), 6)
|
|
515
|
+
if method == "poisson":
|
|
516
|
+
summary["depth"] = depth
|
|
517
|
+
return summary
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def simplify_mesh(
|
|
521
|
+
ws: Workspace,
|
|
522
|
+
path: str,
|
|
523
|
+
reduction_ratio: float = 0.5,
|
|
524
|
+
output: Optional[str] = None,
|
|
525
|
+
overwrite: bool = False,
|
|
526
|
+
preserve_boundary: bool = True,
|
|
527
|
+
) -> dict:
|
|
528
|
+
if not 0 < reduction_ratio < 1:
|
|
529
|
+
raise WorkspaceError("reduction_ratio must be between 0 and 1 (0.5 removes about half the faces)")
|
|
530
|
+
resolved, mesh = _read_mesh(ws, path)
|
|
531
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="simplified",
|
|
532
|
+
allowed=MESH_FORMATS, overwrite=overwrite)
|
|
533
|
+
result = tc.simplify_mesh(mesh, reduction_ratio, preserve_boundary=preserve_boundary)
|
|
534
|
+
tc.write_mesh(result, str(out))
|
|
535
|
+
return {
|
|
536
|
+
"output": ws.relative(out),
|
|
537
|
+
"faces_before": mesh.face_count,
|
|
538
|
+
"faces_after": result.face_count,
|
|
539
|
+
"vertices_after": result.vertex_count,
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def smooth_mesh(
|
|
544
|
+
ws: Workspace,
|
|
545
|
+
path: str,
|
|
546
|
+
method: Literal["taubin", "laplacian", "hc"] = "taubin",
|
|
547
|
+
iterations: int = 10,
|
|
548
|
+
output: Optional[str] = None,
|
|
549
|
+
overwrite: bool = False,
|
|
550
|
+
) -> dict:
|
|
551
|
+
resolved, mesh = _read_mesh(ws, path)
|
|
552
|
+
out = ws.output_path(output, inputs=[resolved], default_suffix="smooth",
|
|
553
|
+
allowed=MESH_FORMATS, overwrite=overwrite)
|
|
554
|
+
if method == "taubin":
|
|
555
|
+
result = tc.smooth_mesh_taubin(mesh, iterations)
|
|
556
|
+
elif method == "laplacian":
|
|
557
|
+
result = tc.smooth_mesh_laplacian(mesh, iterations)
|
|
558
|
+
elif method == "hc":
|
|
559
|
+
result = tc.smooth_mesh_hc(mesh, iterations)
|
|
560
|
+
else:
|
|
561
|
+
raise WorkspaceError(f"unknown method {method!r}")
|
|
562
|
+
tc.write_mesh(result, str(out))
|
|
563
|
+
return {"output": ws.relative(out), "method": method, "iterations": iterations, **_mesh_summary(result)}
|
threecrate_mcp/server.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""MCP server exposing threecrate point cloud and mesh tools.
|
|
2
|
+
|
|
3
|
+
Run with `threecrate-mcp` (or `uvx threecrate-mcp`). It talks MCP over stdio,
|
|
4
|
+
so any MCP client (Claude Code, Claude Desktop, Codex, OpenCode, Cursor, ...)
|
|
5
|
+
can launch it. Tools read and write files inside one workspace folder (the
|
|
6
|
+
current directory, or `--root` / THREECRATE_MCP_ROOT) and return short
|
|
7
|
+
summaries rather than point data.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import functools
|
|
14
|
+
import os
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from typing import Annotated, Any, Callable, Literal, Optional, TypeVar
|
|
17
|
+
|
|
18
|
+
from mcp.server.mcpserver import MCPServer
|
|
19
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
20
|
+
from pydantic import Field
|
|
21
|
+
|
|
22
|
+
from . import ops
|
|
23
|
+
from .workspace import MAX_POINTS_ENV, ROOT_ENV, Workspace
|
|
24
|
+
|
|
25
|
+
INSTRUCTIONS = """\
|
|
26
|
+
Tools for 3D point clouds and meshes (PLY, PCD, XYZ, CSV, OBJ, ...), powered by
|
|
27
|
+
threecrate. All paths are relative to the workspace folder. Tools write results
|
|
28
|
+
to new files and return summaries, never raw points.
|
|
29
|
+
|
|
30
|
+
Typical flow: start with inspect_point_cloud to learn the size, units and point
|
|
31
|
+
spacing (it also suggests voxel sizes and radii), then clean (remove_outliers,
|
|
32
|
+
downsample), then register two scans or reconstruct a mesh. Outputs default to
|
|
33
|
+
a new file next to the input; existing files are only replaced with
|
|
34
|
+
overwrite=true.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
mcp = MCPServer("threecrate", instructions=INSTRUCTIONS)
|
|
38
|
+
|
|
39
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tool(fn: F) -> F:
|
|
43
|
+
"""Register `fn` as an MCP tool whose failures reach the agent.
|
|
44
|
+
|
|
45
|
+
The SDK only passes `ToolError` messages to the model; any other exception
|
|
46
|
+
becomes a bare "Error executing tool". Our messages (a path outside the
|
|
47
|
+
workspace, an output that already exists, a threecrate error on a bad file)
|
|
48
|
+
are what the agent needs to fix its call, so every error becomes a ToolError.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@functools.wraps(fn)
|
|
52
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
return fn(*args, **kwargs)
|
|
55
|
+
except (ToolError, KeyboardInterrupt, SystemExit):
|
|
56
|
+
raise
|
|
57
|
+
# BaseException: a Rust panic arrives as pyo3's PanicException, which
|
|
58
|
+
# is not an Exception and would otherwise take the server down.
|
|
59
|
+
except BaseException as exc: # noqa: BLE001 - surface every failure to the agent
|
|
60
|
+
raise ToolError(str(exc) or type(exc).__name__) from exc
|
|
61
|
+
|
|
62
|
+
mcp.tool()(wrapper)
|
|
63
|
+
return fn
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache(maxsize=1)
|
|
67
|
+
def _ws() -> Workspace:
|
|
68
|
+
return Workspace.from_env()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
Path_ = Annotated[str, Field(description="File path, relative to the workspace folder")]
|
|
72
|
+
Output = Annotated[
|
|
73
|
+
Optional[str],
|
|
74
|
+
Field(description="Where to write the result. Default: a new file next to the input"),
|
|
75
|
+
]
|
|
76
|
+
Overwrite = Annotated[bool, Field(description="Replace the output file if it already exists")]
|
|
77
|
+
NormalOrientation = Annotated[
|
|
78
|
+
Literal["consistent", "viewpoint"],
|
|
79
|
+
Field(
|
|
80
|
+
description=(
|
|
81
|
+
"consistent: normals agree all around, outward on closed objects (default). "
|
|
82
|
+
"viewpoint: face a point above the cloud; better for one-sided scans of walls or facades"
|
|
83
|
+
)
|
|
84
|
+
),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@tool
|
|
89
|
+
def inspect_point_cloud(path: Path_) -> dict:
|
|
90
|
+
"""Summarize a point cloud file: point count, bounds, centroid, median point
|
|
91
|
+
spacing, and suggested voxel sizes and radii for the other tools. Call this
|
|
92
|
+
first on any new cloud."""
|
|
93
|
+
return ops.inspect_point_cloud(_ws(), path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@tool
|
|
97
|
+
def inspect_mesh(path: Path_) -> dict:
|
|
98
|
+
"""Summarize a mesh file: vertex and face counts, bounds and centroid."""
|
|
99
|
+
return ops.inspect_mesh(_ws(), path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@tool
|
|
103
|
+
def convert(
|
|
104
|
+
path: Path_,
|
|
105
|
+
output: Annotated[str, Field(description="Output path; its extension picks the format")],
|
|
106
|
+
kind: Annotated[Literal["point_cloud", "mesh"], Field(description="What the file holds")] = "point_cloud",
|
|
107
|
+
overwrite: Overwrite = False,
|
|
108
|
+
) -> dict:
|
|
109
|
+
"""Convert a point cloud (PLY, PCD, XYZ, CSV) or mesh (PLY, OBJ) to another format."""
|
|
110
|
+
return ops.convert(_ws(), path, output, kind, overwrite)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@tool
|
|
114
|
+
def downsample(
|
|
115
|
+
path: Path_,
|
|
116
|
+
voxel_size: Annotated[
|
|
117
|
+
float,
|
|
118
|
+
Field(gt=0, description="Voxel edge length in the cloud's units; each voxel keeps the centroid of its points"),
|
|
119
|
+
],
|
|
120
|
+
output: Output = None,
|
|
121
|
+
overwrite: Overwrite = False,
|
|
122
|
+
) -> dict:
|
|
123
|
+
"""Reduce point density with a voxel grid. Good before registration or
|
|
124
|
+
reconstruction. 2x the median spacing is a light pass, 5x a strong one."""
|
|
125
|
+
return ops.downsample(_ws(), path, voxel_size, output, overwrite)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@tool
|
|
129
|
+
def remove_outliers(
|
|
130
|
+
path: Path_,
|
|
131
|
+
method: Annotated[
|
|
132
|
+
Literal["statistical", "radius"],
|
|
133
|
+
Field(description="statistical: far from neighbours on average; radius: too few neighbours nearby"),
|
|
134
|
+
] = "statistical",
|
|
135
|
+
k_neighbors: Annotated[int, Field(ge=2, description="Neighbours checked (statistical)")] = 20,
|
|
136
|
+
std_ratio: Annotated[float, Field(gt=0, description="Lower removes more (statistical)")] = 2.0,
|
|
137
|
+
radius: Annotated[Optional[float], Field(gt=0, description="Search radius (radius method, required)")] = None,
|
|
138
|
+
min_neighbors: Annotated[int, Field(ge=1, description="Minimum neighbours within radius (radius method)")] = 5,
|
|
139
|
+
output: Output = None,
|
|
140
|
+
overwrite: Overwrite = False,
|
|
141
|
+
) -> dict:
|
|
142
|
+
"""Remove stray points (sensor noise, reflections) from a point cloud."""
|
|
143
|
+
return ops.remove_outliers(_ws(), path, method, k_neighbors, std_ratio, radius, min_neighbors, output, overwrite)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@tool
|
|
147
|
+
def estimate_normals(
|
|
148
|
+
path: Path_,
|
|
149
|
+
k_neighbors: Annotated[int, Field(ge=3, description="Neighbours used for each normal")] = 10,
|
|
150
|
+
output: Annotated[Optional[str], Field(description="Output .ply path (stores normals as nx ny nz)")] = None,
|
|
151
|
+
overwrite: Overwrite = False,
|
|
152
|
+
normal_orientation: NormalOrientation = "consistent",
|
|
153
|
+
) -> dict:
|
|
154
|
+
"""Estimate a surface normal for every point and save them in a PLY file.
|
|
155
|
+
Not needed before register or reconstruct; those estimate normals themselves."""
|
|
156
|
+
return ops.estimate_normals(_ws(), path, k_neighbors, output, overwrite, normal_orientation)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@tool
|
|
160
|
+
def register(
|
|
161
|
+
source: Annotated[str, Field(description="Cloud to move")],
|
|
162
|
+
target: Annotated[str, Field(description="Cloud to align to")],
|
|
163
|
+
method: Annotated[
|
|
164
|
+
Literal["icp", "point_to_plane", "gicp", "global"],
|
|
165
|
+
Field(
|
|
166
|
+
description=(
|
|
167
|
+
"icp: fast, needs a rough initial alignment. point_to_plane: better on smooth "
|
|
168
|
+
"surfaces. gicp: most robust local method. global: no initial alignment needed "
|
|
169
|
+
"(feature matching + ICP), slower"
|
|
170
|
+
)
|
|
171
|
+
),
|
|
172
|
+
] = "icp",
|
|
173
|
+
max_iterations: Annotated[int, Field(ge=1)] = 50,
|
|
174
|
+
max_correspondence_distance: Annotated[
|
|
175
|
+
float, Field(gt=0, description="Ignore matches farther than this (gicp only)")
|
|
176
|
+
] = 1.0,
|
|
177
|
+
voxel_size: Annotated[
|
|
178
|
+
Optional[float],
|
|
179
|
+
Field(gt=0, description="Downsample both clouds first for speed; the result still applies to the full clouds"),
|
|
180
|
+
] = None,
|
|
181
|
+
initial_transform: Annotated[
|
|
182
|
+
Optional[list[list[float]]], Field(description="Starting 4x4 transform (not for method='global')")
|
|
183
|
+
] = None,
|
|
184
|
+
aligned_output: Annotated[
|
|
185
|
+
Optional[str], Field(description="Also save the source cloud moved into the target's frame here")
|
|
186
|
+
] = None,
|
|
187
|
+
overwrite: Overwrite = False,
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Align a source point cloud to a target. Returns the 4x4 rigid transform
|
|
190
|
+
(source to target), fit error (mse, rmse) and convergence."""
|
|
191
|
+
return ops.register(
|
|
192
|
+
_ws(), source, target, method, max_iterations, max_correspondence_distance,
|
|
193
|
+
voxel_size, initial_transform, aligned_output, overwrite,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@tool
|
|
198
|
+
def transform(
|
|
199
|
+
path: Path_,
|
|
200
|
+
transform: Annotated[list[list[float]], Field(description="4x4 rigid transform, e.g. from register")],
|
|
201
|
+
output: Output = None,
|
|
202
|
+
overwrite: Overwrite = False,
|
|
203
|
+
) -> dict:
|
|
204
|
+
"""Apply a 4x4 rigid transform to every point of a cloud."""
|
|
205
|
+
return ops.transform(_ws(), path, transform, output, overwrite)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@tool
|
|
209
|
+
def segment_plane(
|
|
210
|
+
path: Path_,
|
|
211
|
+
distance_threshold: Annotated[float, Field(gt=0, description="Max distance from the plane for a point to count")] = 0.01,
|
|
212
|
+
max_iterations: Annotated[int, Field(ge=1, description="RANSAC iterations")] = 1000,
|
|
213
|
+
inliers_output: Annotated[Optional[str], Field(description="Save the plane's points here")] = None,
|
|
214
|
+
outliers_output: Annotated[Optional[str], Field(description="Save everything else here (e.g. objects on a floor)")] = None,
|
|
215
|
+
overwrite: Overwrite = False,
|
|
216
|
+
) -> dict:
|
|
217
|
+
"""Find the dominant plane (floor, wall, table) with RANSAC. Returns its
|
|
218
|
+
equation and how many points lie on it; can split the cloud in two."""
|
|
219
|
+
return ops.segment_plane(_ws(), path, distance_threshold, max_iterations, inliers_output, outliers_output, overwrite)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@tool
|
|
223
|
+
def extract_clusters(
|
|
224
|
+
path: Path_,
|
|
225
|
+
tolerance: Annotated[float, Field(gt=0, description="Max gap between points of one cluster")],
|
|
226
|
+
min_cluster_size: Annotated[int, Field(ge=1)] = 100,
|
|
227
|
+
max_cluster_size: Annotated[int, Field(ge=1)] = 25000,
|
|
228
|
+
output_dir: Annotated[Optional[str], Field(description="Folder to save each cluster as its own PLY")] = None,
|
|
229
|
+
max_files: Annotated[int, Field(ge=1, description="Save at most this many clusters, largest first")] = 50,
|
|
230
|
+
overwrite: Overwrite = False,
|
|
231
|
+
) -> dict:
|
|
232
|
+
"""Split a cloud into separate objects by distance (Euclidean clustering).
|
|
233
|
+
Often run after segment_plane removes the floor."""
|
|
234
|
+
return ops.extract_clusters(
|
|
235
|
+
_ws(), path, tolerance, min_cluster_size, max_cluster_size, output_dir, max_files, overwrite
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@tool
|
|
240
|
+
def reconstruct(
|
|
241
|
+
path: Path_,
|
|
242
|
+
method: Annotated[
|
|
243
|
+
Literal["auto", "poisson", "ball_pivoting", "alpha_shape", "delaunay"],
|
|
244
|
+
Field(
|
|
245
|
+
description=(
|
|
246
|
+
"auto: picks a method. poisson: smooth watertight surfaces. ball_pivoting: keeps "
|
|
247
|
+
"detail, may leave holes. alpha_shape: rough hulls. delaunay: near-flat clouds"
|
|
248
|
+
)
|
|
249
|
+
),
|
|
250
|
+
] = "auto",
|
|
251
|
+
normals_k: Annotated[int, Field(ge=3, description="Neighbours for the normals Poisson needs")] = 10,
|
|
252
|
+
radius: Annotated[Optional[float], Field(gt=0, description="Ball radius (ball_pivoting; default 2x spacing)")] = None,
|
|
253
|
+
alpha: Annotated[Optional[float], Field(gt=0, description="Alpha (alpha_shape)")] = None,
|
|
254
|
+
depth: Annotated[
|
|
255
|
+
int,
|
|
256
|
+
Field(ge=3, le=8, description="Poisson detail: grid of 2^depth cells per side. 7 takes about 1 s; 8 is finer but 10-20 s"),
|
|
257
|
+
] = 7,
|
|
258
|
+
trim_distance: Annotated[
|
|
259
|
+
Optional[float],
|
|
260
|
+
Field(gt=0, description="Poisson: drop surface farther than this from any point. Use on open scans (e.g. 2x spacing)"),
|
|
261
|
+
] = None,
|
|
262
|
+
normal_orientation: NormalOrientation = "consistent",
|
|
263
|
+
output: Annotated[Optional[str], Field(description="Output mesh path (.ply or .obj)")] = None,
|
|
264
|
+
overwrite: Overwrite = False,
|
|
265
|
+
) -> dict:
|
|
266
|
+
"""Build a triangle mesh from a point cloud. Clean and downsample first for
|
|
267
|
+
better results. Poisson always closes the surface; set trim_distance for
|
|
268
|
+
scans that only see one side."""
|
|
269
|
+
return ops.reconstruct(
|
|
270
|
+
_ws(), path, method, normals_k, radius, alpha, output, overwrite, depth, trim_distance, normal_orientation
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@tool
|
|
275
|
+
def simplify_mesh(
|
|
276
|
+
path: Path_,
|
|
277
|
+
reduction_ratio: Annotated[float, Field(gt=0, lt=1, description="Share of faces to remove (0.5 removes about half)")] = 0.5,
|
|
278
|
+
preserve_boundary: Annotated[
|
|
279
|
+
bool,
|
|
280
|
+
Field(description="Keep holes and open borders fixed. Set false if the call fails because the mesh is mostly holes"),
|
|
281
|
+
] = True,
|
|
282
|
+
output: Output = None,
|
|
283
|
+
overwrite: Overwrite = False,
|
|
284
|
+
) -> dict:
|
|
285
|
+
"""Reduce a mesh's triangle count while keeping its shape (quadric decimation)."""
|
|
286
|
+
return ops.simplify_mesh(_ws(), path, reduction_ratio, output, overwrite, preserve_boundary)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@tool
|
|
290
|
+
def smooth_mesh(
|
|
291
|
+
path: Path_,
|
|
292
|
+
method: Annotated[
|
|
293
|
+
Literal["taubin", "laplacian", "hc"],
|
|
294
|
+
Field(description="taubin keeps volume best; laplacian is strongest but shrinks the mesh"),
|
|
295
|
+
] = "taubin",
|
|
296
|
+
iterations: Annotated[int, Field(ge=1)] = 10,
|
|
297
|
+
output: Output = None,
|
|
298
|
+
overwrite: Overwrite = False,
|
|
299
|
+
) -> dict:
|
|
300
|
+
"""Smooth a noisy or bumpy mesh, for example after reconstruct. Taubin keeps
|
|
301
|
+
the overall size; Laplacian smooths harder but shrinks the mesh."""
|
|
302
|
+
return ops.smooth_mesh(_ws(), path, method, iterations, output, overwrite)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main() -> None:
|
|
306
|
+
parser = argparse.ArgumentParser(description="threecrate MCP server (stdio)")
|
|
307
|
+
parser.add_argument("--root", help=f"Workspace folder (default: current folder, or ${ROOT_ENV})")
|
|
308
|
+
parser.add_argument("--max-points", type=int, help=f"Refuse larger clouds (default 50M, or ${MAX_POINTS_ENV})")
|
|
309
|
+
args = parser.parse_args()
|
|
310
|
+
if args.root:
|
|
311
|
+
os.environ[ROOT_ENV] = args.root
|
|
312
|
+
if args.max_points:
|
|
313
|
+
os.environ[MAX_POINTS_ENV] = str(args.max_points)
|
|
314
|
+
_ws() # fail fast on a bad root
|
|
315
|
+
mcp.run()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
main()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Where the server may read and write, and how output paths are chosen.
|
|
2
|
+
|
|
3
|
+
Every path an agent passes in is resolved against one root folder and must stay
|
|
4
|
+
inside it. Outputs never overwrite an input, and never overwrite an existing
|
|
5
|
+
file unless the caller asks for it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
ROOT_ENV = "THREECRATE_MCP_ROOT"
|
|
16
|
+
MAX_POINTS_ENV = "THREECRATE_MCP_MAX_POINTS"
|
|
17
|
+
DEFAULT_MAX_POINTS = 50_000_000
|
|
18
|
+
|
|
19
|
+
POINT_CLOUD_WRITE_FORMATS = {".ply", ".pcd", ".xyz", ".csv"}
|
|
20
|
+
MESH_FORMATS = {".ply", ".obj"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WorkspaceError(ValueError):
|
|
24
|
+
"""A path or size the server refuses to work with. The message is shown to the agent."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Workspace:
|
|
29
|
+
root: Path
|
|
30
|
+
max_points: int = DEFAULT_MAX_POINTS
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_env(cls) -> "Workspace":
|
|
34
|
+
root = Path(os.environ.get(ROOT_ENV) or Path.cwd()).resolve()
|
|
35
|
+
if not root.is_dir():
|
|
36
|
+
raise WorkspaceError(f"workspace root {root} is not a folder")
|
|
37
|
+
max_points = int(os.environ.get(MAX_POINTS_ENV, DEFAULT_MAX_POINTS))
|
|
38
|
+
return cls(root=root, max_points=max_points)
|
|
39
|
+
|
|
40
|
+
def _resolve(self, path: str) -> Path:
|
|
41
|
+
candidate = Path(path).expanduser()
|
|
42
|
+
if not candidate.is_absolute():
|
|
43
|
+
candidate = self.root / candidate
|
|
44
|
+
resolved = candidate.resolve()
|
|
45
|
+
if resolved != self.root and not resolved.is_relative_to(self.root):
|
|
46
|
+
raise WorkspaceError(
|
|
47
|
+
f"{path} is outside the workspace {self.root}. "
|
|
48
|
+
f"Set {ROOT_ENV} to allow another folder."
|
|
49
|
+
)
|
|
50
|
+
return resolved
|
|
51
|
+
|
|
52
|
+
def input_path(self, path: str) -> Path:
|
|
53
|
+
"""An existing file inside the workspace."""
|
|
54
|
+
resolved = self._resolve(path)
|
|
55
|
+
if not resolved.is_file():
|
|
56
|
+
raise WorkspaceError(f"{path} does not exist or is not a file")
|
|
57
|
+
return resolved
|
|
58
|
+
|
|
59
|
+
def output_dir(self, path: str) -> Path:
|
|
60
|
+
"""A folder inside the workspace, created if needed."""
|
|
61
|
+
resolved = self._resolve(path)
|
|
62
|
+
if resolved.exists() and not resolved.is_dir():
|
|
63
|
+
raise WorkspaceError(f"{path} exists and is not a folder")
|
|
64
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
return resolved
|
|
66
|
+
|
|
67
|
+
def output_path(
|
|
68
|
+
self,
|
|
69
|
+
path: Optional[str],
|
|
70
|
+
*,
|
|
71
|
+
inputs: list[Path],
|
|
72
|
+
default_suffix: str,
|
|
73
|
+
allowed: set[str],
|
|
74
|
+
default_extension: Optional[str] = None,
|
|
75
|
+
overwrite: bool = False,
|
|
76
|
+
) -> Path:
|
|
77
|
+
"""Where to write a result.
|
|
78
|
+
|
|
79
|
+
Without `path`, the result goes next to the first input, named
|
|
80
|
+
`<input stem>.<default_suffix><extension>`. The extension must be in
|
|
81
|
+
`allowed`. An input is never overwritten; an existing file only when
|
|
82
|
+
`overwrite` is set.
|
|
83
|
+
"""
|
|
84
|
+
if path is None:
|
|
85
|
+
first = inputs[0]
|
|
86
|
+
extension = default_extension or first.suffix.lower()
|
|
87
|
+
if extension not in allowed:
|
|
88
|
+
# e.g. a LAS input: write the result as PLY, which every writer supports
|
|
89
|
+
extension = ".ply"
|
|
90
|
+
resolved = first.with_name(f"{first.stem}.{default_suffix}{extension}")
|
|
91
|
+
else:
|
|
92
|
+
resolved = self._resolve(path)
|
|
93
|
+
|
|
94
|
+
if resolved.suffix.lower() not in allowed:
|
|
95
|
+
raise WorkspaceError(
|
|
96
|
+
f"cannot write {resolved.name}: use one of {', '.join(sorted(allowed))}"
|
|
97
|
+
)
|
|
98
|
+
if any(resolved == item for item in inputs):
|
|
99
|
+
raise WorkspaceError(f"refusing to overwrite the input file {resolved.name}")
|
|
100
|
+
if resolved.exists() and not overwrite:
|
|
101
|
+
raise WorkspaceError(
|
|
102
|
+
f"{self.relative(resolved)} already exists; pass overwrite=true to replace it"
|
|
103
|
+
)
|
|
104
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
return resolved
|
|
106
|
+
|
|
107
|
+
def check_size(self, count: int, path: Path) -> None:
|
|
108
|
+
if count > self.max_points:
|
|
109
|
+
raise WorkspaceError(
|
|
110
|
+
f"{path.name} has {count:,} points, above the limit of {self.max_points:,}. "
|
|
111
|
+
f"Downsample it first or raise {MAX_POINTS_ENV}."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def relative(self, path: Path) -> str:
|
|
115
|
+
"""`path` relative to the workspace root, for messages and results."""
|
|
116
|
+
try:
|
|
117
|
+
return path.relative_to(self.root).as_posix()
|
|
118
|
+
except ValueError:
|
|
119
|
+
return str(path)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: threecrate-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that gives AI agents threecrate's 3D point cloud and mesh tools
|
|
5
|
+
Project-URL: Repository, https://github.com/rajgandhi1/threecrate
|
|
6
|
+
License: MIT OR Apache-2.0
|
|
7
|
+
Keywords: 3d,agents,lidar,mcp,mesh,point-cloud
|
|
8
|
+
Classifier: Intended Audience :: Science/Research
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: mcp<3,>=2
|
|
14
|
+
Requires-Dist: numpy>=1.20
|
|
15
|
+
Requires-Dist: threecrate>=0.10.0
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# threecrate-mcp
|
|
21
|
+
|
|
22
|
+
An [MCP](https://modelcontextprotocol.io) server that lets AI agents work with 3D
|
|
23
|
+
point clouds and meshes using [threecrate](https://github.com/rajgandhi1/threecrate).
|
|
24
|
+
Ask your agent to "clean up scan.ply", "align these two scans" or "turn this cloud
|
|
25
|
+
into a mesh", and it calls these tools.
|
|
26
|
+
|
|
27
|
+
Works with any MCP client: Claude Code, Claude Desktop, Codex, OpenCode, Cursor,
|
|
28
|
+
Gemini CLI and others.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
You need [uv](https://docs.astral.sh/uv/) (for `uvx`) or Python 3.10+.
|
|
33
|
+
|
|
34
|
+
**Claude Code** (from your project folder):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add threecrate -- uvx threecrate-mcp
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Codex** (from your project folder):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
codex mcp add threecrate -- uvx threecrate-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**OpenCode** (`opencode.json` in your project):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"$schema": "https://opencode.ai/config.json",
|
|
51
|
+
"mcp": {
|
|
52
|
+
"threecrate": { "type": "local", "command": ["uvx", "threecrate-mcp"], "enabled": true }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Claude Desktop, Cursor and other apps** (their MCP JSON config). These apps
|
|
58
|
+
don't start servers in your project folder, so pass it with `--root`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"threecrate": {
|
|
64
|
+
"command": "uvx",
|
|
65
|
+
"args": ["threecrate-mcp", "--root", "/path/to/your/scans"]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Without `uv`, install with `pip install threecrate-mcp` and use `threecrate-mcp`
|
|
72
|
+
as the command.
|
|
73
|
+
|
|
74
|
+
## How it behaves
|
|
75
|
+
|
|
76
|
+
- **One workspace folder.** Tools only read and write inside it: the folder the
|
|
77
|
+
server starts in, or `--root` (also `THREECRATE_MCP_ROOT`). Paths are relative
|
|
78
|
+
to it.
|
|
79
|
+
- **New files, never overwrites.** Results go to a new file next to the input
|
|
80
|
+
(for example `scan.downsampled.ply`) unless you name one. Inputs are never
|
|
81
|
+
overwritten, and existing files only with `overwrite=true`.
|
|
82
|
+
- **Summaries, not points.** Tools return counts, bounds, transforms and errors.
|
|
83
|
+
Point data stays in files, so a big scan doesn't flood the agent's context.
|
|
84
|
+
- **Size limit.** Clouds above 50 million points are refused
|
|
85
|
+
(`--max-points` or `THREECRATE_MCP_MAX_POINTS` to change).
|
|
86
|
+
|
|
87
|
+
## Tools
|
|
88
|
+
|
|
89
|
+
| Tool | What it does |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `inspect_point_cloud` | Point count, bounds, spacing, and suggested voxel sizes and radii |
|
|
92
|
+
| `inspect_mesh` | Vertex and face counts, bounds |
|
|
93
|
+
| `convert` | Change format (PLY, PCD, XYZ, CSV; meshes PLY, OBJ) |
|
|
94
|
+
| `downsample` | Voxel grid downsampling |
|
|
95
|
+
| `remove_outliers` | Statistical or radius outlier removal |
|
|
96
|
+
| `estimate_normals` | Save per-point normals to a PLY |
|
|
97
|
+
| `register` | Align two clouds: ICP, point-to-plane, GICP, or global (no starting pose needed) |
|
|
98
|
+
| `transform` | Apply a 4x4 rigid transform |
|
|
99
|
+
| `segment_plane` | Find a floor, wall or table plane; split the cloud |
|
|
100
|
+
| `extract_clusters` | Split a cloud into separate objects |
|
|
101
|
+
| `reconstruct` | Point cloud to mesh: auto, Poisson, ball pivoting, alpha shape, Delaunay |
|
|
102
|
+
| `simplify_mesh` | Reduce triangle count |
|
|
103
|
+
| `smooth_mesh` | Taubin, Laplacian or HC smoothing |
|
|
104
|
+
|
|
105
|
+
Readable point cloud formats: PLY, PCD, XYZ, CSV (and others threecrate
|
|
106
|
+
supports). Writable: PLY, PCD, XYZ, CSV.
|
|
107
|
+
|
|
108
|
+
## Agent Skill
|
|
109
|
+
|
|
110
|
+
If you would rather not run a server, the [threecrate skill](../skills/threecrate/SKILL.md)
|
|
111
|
+
teaches an agent to do the same work with short Python scripts. See
|
|
112
|
+
[INSTALL.md](../INSTALL.md) for Claude Code, Codex, Gemini CLI, OpenCode and
|
|
113
|
+
Cursor.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e ".[test]"
|
|
119
|
+
pytest
|
|
120
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
threecrate_mcp/__init__.py,sha256=0CnpWu6vL_bR2cxWZDoJS7R72P82oI4onD2uyCc4usg,102
|
|
2
|
+
threecrate_mcp/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
|
|
3
|
+
threecrate_mcp/ops.py,sha256=o3P78XKDFhabXop4C5WqNHEqg-zrZo9mgJVcOrMOUpU,22516
|
|
4
|
+
threecrate_mcp/server.py,sha256=bJUR_iZKs15C1jGDVCZmXZVcixczIpqFAqIlCqJzR_k,13147
|
|
5
|
+
threecrate_mcp/workspace.py,sha256=f1qdSOmNmUV8toLb8zjuc-iugWN-8XV_4R2jj5Nvdk0,4455
|
|
6
|
+
threecrate_mcp-0.1.0.dist-info/METADATA,sha256=GAreo0RrynMREIPQ1BGyIBD2C32NLzHl2DevtWK4loQ,4041
|
|
7
|
+
threecrate_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
threecrate_mcp-0.1.0.dist-info/entry_points.txt,sha256=jwGeDXaCftzpA8aJ4uYy_PpuFDenxGL8I4k0FIxmrBQ,62
|
|
9
|
+
threecrate_mcp-0.1.0.dist-info/RECORD,,
|