diff --git a/README.md b/README.md
index 4d65b9581..9dd05b7cf 100644
--- a/README.md
+++ b/README.md
@@ -758,7 +758,7 @@ Each application received complete, production-ready CLI interfaces — not demo
✅ 19 |
-| ☁️ CloudCompare |
+🅲🅲 CloudCompare |
3D Point Cloud & Mesh |
cli-anything-cloudcompare |
CloudCompare CLI (headless) |
@@ -766,11 +766,11 @@ Each application received complete, production-ready CLI interfaces — not demo
| Total |
-✅ 1,946 |
+✅ 2,005 |
-> **100% pass rate** across all 1,946 tests — 1,404 unit tests + 523 end-to-end tests + 19 Node.js tests.
+> **100% pass rate** across all 2,005 tests — 1,453 unit tests + 533 end-to-end tests + 19 Node.js tests.
---
diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py
index f6ed4fcb5..448370ff5 100644
--- a/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py
+++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py
@@ -173,7 +173,6 @@ def repl(ctx: click.Context, project: Optional[str]) -> None:
# Build command reference
commands = {
"project new -o ": "Create a new project",
- "project open -p ": "Open an existing project",
"project info": "Show project status",
"cloud add ": "Add a cloud to the project",
"cloud list": "List loaded clouds",
@@ -228,10 +227,21 @@ def repl(ctx: click.Context, project: Optional[str]) -> None:
standalone_mode=False,
obj={"project": session.project_path if session else None, "json": json_mode},
)
- # Update session reference if project was created/opened
- if session is None and ctx.obj and ctx.obj.get("project"):
- project_path = ctx.obj["project"]
- session = Session(project_path)
+ # Update session when project new or explicit -p/--project is used
+ new_path: Optional[str] = None
+ raw = line.split()
+ if len(raw) >= 2 and raw[0] == "project" and raw[1] == "new":
+ for i, tok in enumerate(raw):
+ if tok in ("-o", "--output") and i + 1 < len(raw):
+ new_path = raw[i + 1]
+ break
+ elif "--project" in raw or "-p" in raw:
+ for i, tok in enumerate(raw):
+ if tok in ("--project", "-p") and i + 1 < len(raw):
+ new_path = raw[i + 1]
+ break
+ if new_path:
+ session = Session(new_path)
except SystemExit:
pass
except click.UsageError as e:
diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py
index b9ffde665..3fb3d20fe 100644
--- a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py
+++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py
@@ -76,12 +76,8 @@ def export_cloud(
if not os.path.exists(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
- if os.path.exists(output_path) and not overwrite:
- raise FileExistsError(
- f"Output file already exists: {output_path}. Use overwrite=True."
- )
-
- # Determine format from preset or extension
+ # Determine format from preset or extension (must happen before overwrite check
+ # because a preset can change the output file extension).
if preset:
preset = preset.lower()
if preset not in CLOUD_PRESETS:
@@ -97,6 +93,11 @@ def export_cloud(
fmt = CLOUD_FORMATS.get(out_ext, "ASC")
ext = out_ext
+ if os.path.exists(output_path) and not overwrite:
+ raise FileExistsError(
+ f"Output file already exists: {output_path}. Use overwrite=True."
+ )
+
result = open_and_save(input_path, output_path, extra_args)
if result["returncode"] != 0:
@@ -143,11 +144,7 @@ def export_mesh(
if not os.path.exists(input_path):
raise FileNotFoundError(f"Input file not found: {input_path}")
- if os.path.exists(output_path) and not overwrite:
- raise FileExistsError(
- f"Output file already exists: {output_path}. Use overwrite=True."
- )
-
+ # Resolve preset before overwrite check — preset can change the output extension.
if preset:
preset = preset.lower()
if preset not in MESH_PRESETS:
@@ -162,6 +159,11 @@ def export_mesh(
fmt = MESH_FORMATS.get(out_ext, "OBJ")
ext = out_ext
+ if os.path.exists(output_path) and not overwrite:
+ raise FileExistsError(
+ f"Output file already exists: {output_path}. Use overwrite=True."
+ )
+
args = [
"-O", input_path,
"-M_EXPORT_FMT", fmt,
diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py
index fa3eda3f8..f3126a4a7 100644
--- a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py
+++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py
@@ -7,13 +7,18 @@ A 'project' is a JSON file tracking:
- Operation history for undo/redo
"""
-import fcntl
import json
import os
import time
from pathlib import Path
from typing import Any, Optional
+try:
+ import fcntl as _fcntl
+ _HAS_FCNTL = True
+except ImportError:
+ _HAS_FCNTL = False
+
# ── JSON locking helper ──────────────────────────────────────────────────────
@@ -28,10 +33,11 @@ def _locked_save_json(path: str, data: dict, **dump_kwargs) -> None:
with f:
_locked = False
try:
- fcntl.flock(f.fileno(), fcntl.LOCK_EX)
- _locked = True
- except (ImportError, OSError):
- pass # Windows / unsupported FS — proceed unlocked
+ if _HAS_FCNTL:
+ _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
+ _locked = True
+ except OSError:
+ pass # unsupported FS — proceed unlocked
try:
f.seek(0)
f.truncate()
@@ -39,7 +45,7 @@ def _locked_save_json(path: str, data: dict, **dump_kwargs) -> None:
f.flush()
finally:
if _locked:
- fcntl.flock(f.fileno(), fcntl.LOCK_UN)
+ _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
# ── Project data model ───────────────────────────────────────────────────────
diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md
index 49c6e83e2..5fd164cc7 100644
--- a/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md
+++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md
@@ -34,7 +34,7 @@ cli-anything-cloudcompare
cli-anything-cloudcompare project new -o project.json
# Run with JSON output (for agent consumption)
-cli-anything-cloudcompare --json project info -p project.json
+cli-anything-cloudcompare --project project.json --json project info
```
## Examples
@@ -51,13 +51,13 @@ cli-anything-cloudcompare --json project new -o myproject.json
### Interactive REPL Session
-Start an interactive session with undo/redo support.
+Start an interactive session with undo support.
```bash
cli-anything-cloudcompare
# Enter commands interactively
# Use 'help' to see available commands
-# Use 'undo' and 'redo' for history navigation
+# Use 'session undo' to revert the last operation
```
## For AI Agents
diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py
index 8f9fd5f11..0767a3d42 100644
--- a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py
+++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py
@@ -170,6 +170,13 @@ def subsample(
if method == "OCTREE":
param_str = str(int(parameter))
+ elif method == "RANDOM":
+ count = int(parameter)
+ if count <= 0:
+ raise ValueError(
+ f"RANDOM subsampling parameter must be a positive integer point count, got {parameter!r}"
+ )
+ param_str = str(count)
else:
param_str = str(parameter)
@@ -891,7 +898,9 @@ def extract_connected_components(
returncode.
"""
input_path = os.path.abspath(input_path)
+ output_dir = os.path.abspath(output_dir)
in_dir = os.path.dirname(input_path)
+ input_stem = os.path.splitext(os.path.basename(input_path))[0]
fmt = CLOUD_FORMATS.get(output_fmt.lower(), "ASC")
args = [
@@ -914,10 +923,22 @@ def extract_connected_components(
result = run_cloudcompare(args, cwd=in_dir)
+ # Restrict matching to the current input stem to avoid picking up leftovers.
components = sorted(
- glob.glob(os.path.join(in_dir, f"*_COMPONENT_*.{actual_ext}"))
+ glob.glob(os.path.join(in_dir, f"{input_stem}_COMPONENT_*.{actual_ext}"))
)
- result["output_dir"] = in_dir
+
+ # Move components to output_dir if it differs from in_dir.
+ if os.path.abspath(output_dir) != os.path.abspath(in_dir):
+ os.makedirs(output_dir, exist_ok=True)
+ moved = []
+ for c in components:
+ dest = os.path.join(output_dir, os.path.basename(c))
+ shutil.move(c, dest)
+ moved.append(dest)
+ components = sorted(moved)
+
+ result["output_dir"] = output_dir
result["components"] = components
result["component_count"] = len(components)
return result
diff --git a/cloudcompare/agent-harness/setup.py b/cloudcompare/agent-harness/setup.py
index 4fd1cca5c..a5a55ed83 100644
--- a/cloudcompare/agent-harness/setup.py
+++ b/cloudcompare/agent-harness/setup.py
@@ -1,10 +1,14 @@
+from pathlib import Path
from setuptools import setup, find_namespace_packages
+_readme = Path("cli_anything/cloudcompare/README.md")
+_long_description = _readme.read_text(encoding="utf-8") if _readme.is_file() else ""
+
setup(
name="cli-anything-cloudcompare",
version="1.0.0",
description="Agent-friendly CLI harness for CloudCompare 3D point cloud software",
- long_description=open("cli_anything/cloudcompare/README.md").read(),
+ long_description=_long_description,
long_description_content_type="text/markdown",
author="cli-anything",
python_requires=">=3.10",
diff --git a/registry.json b/registry.json
index df2ca645d..a8c949e07 100644
--- a/registry.json
+++ b/registry.json
@@ -400,7 +400,7 @@
{
"name": "cloudcompare",
"display_name": "CloudCompare",
- "version": "0.1.0",
+ "version": "1.0.0",
"description": "3D point cloud and mesh processing: load/save, color ops, normal estimation, Delaunay meshing, noise filtering, ICP registration, connected component segmentation",
"requires": "cloudcompare (CloudCompare application installed, e.g. via Flatpak or package manager)",
"homepage": "https://cloudcompare.org",