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.
@@ -0,0 +1,3 @@
1
+ """MCP server that gives AI agents threecrate's point cloud and mesh tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
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)}
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ threecrate-mcp = threecrate_mcp.server:main