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",