From fc5794ddbae5483d668f873af8424b3cecb8d6e9 Mon Sep 17 00:00:00 2001 From: Taeyoung96 Date: Sat, 28 Mar 2026 23:21:48 +0900 Subject: [PATCH 1/3] feat: add CloudCompare CLI harness for 3D point cloud processing - Adds cli-anything-cloudcompare with 88 tests (49 unit + 39 e2e) - Supports point cloud I/O, color ops, normal estimation, Delaunay meshing, noise filtering, ICP registration, and CC segmentation - Updates registry.json with cloudcompare entry - Updates README.md table and test summary - Updates .gitignore to allow cloudcompare/agent-harness/ Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + README.md | 17 +- cloudcompare/agent-harness/CLOUDCOMPARE.md | 142 ++ .../cli_anything/cloudcompare/README.md | 117 ++ .../cli_anything/cloudcompare/__init__.py | 2 + .../cli_anything/cloudcompare/__main__.py | 5 + .../cloudcompare/cloudcompare_cli.py | 1638 +++++++++++++++++ .../cloudcompare/core/__init__.py | 0 .../cli_anything/cloudcompare/core/export.py | 225 +++ .../cli_anything/cloudcompare/core/project.py | 279 +++ .../cli_anything/cloudcompare/core/session.py | 172 ++ .../cli_anything/cloudcompare/skills/SKILL.md | 75 + .../cli_anything/cloudcompare/tests/TEST.md | 298 +++ .../cloudcompare/tests/__init__.py | 0 .../cloudcompare/tests/test_core.py | 434 +++++ .../cloudcompare/tests/test_full_e2e.py | 815 ++++++++ .../cloudcompare/utils/__init__.py | 0 .../cloudcompare/utils/cc_backend.py | 960 ++++++++++ .../cloudcompare/utils/repl_skin.py | 521 ++++++ cloudcompare/agent-harness/setup.py | 30 + registry.json | 14 + 21 files changed, 5744 insertions(+), 4 deletions(-) create mode 100644 cloudcompare/agent-harness/CLOUDCOMPARE.md create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/README.md create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/__init__.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/__main__.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/core/__init__.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/core/session.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/tests/TEST.md create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/tests/__init__.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_core.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_full_e2e.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/utils/__init__.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py create mode 100644 cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py create mode 100644 cloudcompare/agent-harness/setup.py diff --git a/.gitignore b/.gitignore index 08e627bf2..970e66d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ !/iterm2/ !/rms/ !/renderdoc/ +!/cloudcompare/ # Step 5: Inside each software dir, ignore everything (including dotfiles) /gimp/* @@ -112,6 +113,8 @@ /rms/.* /renderdoc/* /renderdoc/.* +/cloudcompare/* +/cloudcompare/.* # Step 6: ...except agent-harness/ !/gimp/agent-harness/ @@ -140,6 +143,7 @@ !/iterm2/agent-harness/ !/rms/agent-harness/ !/renderdoc/agent-harness/ +!/cloudcompare/agent-harness/ # Step 7: Ignore build artifacts within allowed dirs **/__pycache__/ diff --git a/README.md b/README.md index 84a461776..4d65b9581 100644 --- a/README.md +++ b/README.md @@ -758,12 +758,19 @@ Each application received complete, production-ready CLI interfaces — not demo ✅ 19 +☁️ CloudCompare +3D Point Cloud & Mesh +cli-anything-cloudcompare +CloudCompare CLI (headless) +✅ 88 + + Total -✅ 1,858 +✅ 1,946 -> **100% pass rate** across all 1,858 tests — 1,355 unit tests + 484 end-to-end tests + 19 Node.js tests. +> **100% pass rate** across all 1,946 tests — 1,404 unit tests + 523 end-to-end tests + 19 Node.js tests. --- @@ -799,8 +806,9 @@ adguardhome 36 passed ✅ (24 unit + 12 e2e) ollama 98 passed ✅ (87 unit + 11 e2e) sketch 19 passed ✅ (19 jest, Node.js) renderdoc 59 passed ✅ (45 unit + 14 e2e) +cloudcompare 88 passed ✅ (49 unit + 39 e2e) ────────────────────────────────────────────────────────────────────────────── -TOTAL 1,917 passed ✅ 100% pass rate +TOTAL 2,005 passed ✅ 100% pass rate ``` --- @@ -870,7 +878,8 @@ cli-anything/ ├── 🛡️ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests) ├── 🦙 ollama/agent-harness/ # Ollama CLI (98 tests) ├── 🎨 sketch/agent-harness/ # Sketch CLI (19 tests, Node.js) -└── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests) +├── 🔬 renderdoc/agent-harness/ # RenderDoc CLI (59 tests) +└── ☁️ cloudcompare/agent-harness/ # CloudCompare CLI (88 tests) ``` Each `agent-harness/` contains an installable Python package under `cli_anything./` with Click CLI, core modules, utils (including `repl_skin.py` and backend wrapper), and comprehensive tests. diff --git a/cloudcompare/agent-harness/CLOUDCOMPARE.md b/cloudcompare/agent-harness/CLOUDCOMPARE.md new file mode 100644 index 000000000..2cf2775d6 --- /dev/null +++ b/cloudcompare/agent-harness/CLOUDCOMPARE.md @@ -0,0 +1,142 @@ +# CloudCompare CLI Harness — SOP + +## Overview + +CloudCompare is a 3D point cloud (and triangular mesh) processing software. +It was originally designed for comparison between two 3D point clouds (e.g. laser scanner data). +It supports a full **command-line mode** via the `-SILENT` flag, which this harness wraps. + +## Backend + +**CloudCompare itself** is the backend. The Python harness constructs valid `-SILENT` command +strings and invokes them as subprocesses. It does NOT reimplement any 3D processing. + +``` +CloudCompare -SILENT -O input.las -SS SPATIAL 0.05 -C_EXPORT_FMT LAS -NO_TIMESTAMP -SAVE_CLOUDS FILE output.las +``` + +### Detecting CloudCompare + +The `find_cloudcompare()` function tries (in order): +1. Native binary (`CloudCompare` or `cloudcompare` in PATH) +2. Flatpak (`flatpak run org.cloudcompare.CloudCompare`) +3. Snap (`/snap/bin/cloudcompare`) + +### Installation (required) + +```bash +# Flatpak (recommended for Linux) +flatpak install flathub org.cloudcompare.CloudCompare + +# Debian/Ubuntu +sudo apt install cloudcompare + +# macOS +brew install --cask cloudcompare + +# Windows +# https://cloudcompare.org/release/index.html +``` + +## Native CLI Mode + +CloudCompare's `-SILENT` mode processes commands sequentially: + +``` +CloudCompare -SILENT [commands...] +``` + +Key commands used by this harness: + +| CC Command | Purpose | +|-----------|---------| +| `-O ` | Open/load a file | +| `-SILENT` | Suppress GUI, run headlessly | +| `-SS METHOD PARAM` | Subsample (RANDOM/SPATIAL/OCTREE) | +| `-ROUGH ` | Compute roughness SF | +| `-DENSITY ` | Compute density SF | +| `-CURV TYPE ` | Compute curvature SF | +| `-SOR ` | Statistical Outlier Removal | +| `-NOISE ...` | Noise filter | +| `-CROP x:y:z:X:Y:Z` | Crop to bounding box | +| `-MERGE_CLOUDS` | Merge all loaded clouds | +| `-C2C_DIST` | Cloud-to-cloud distance | +| `-C2M_DIST` | Cloud-to-mesh distance | +| `-ICP` | Iterative Closest Point registration | +| `-OCTREE_NORMALS ` | Compute normals | +| `-C_EXPORT_FMT ` | Set cloud output format | +| `-M_EXPORT_FMT ` | Set mesh output format | +| `-NO_TIMESTAMP` | No timestamp suffix on output files | +| `-SAVE_CLOUDS FILE ` | Save to specific file | +| `-SAVE_MESHES FILE ` | Save mesh to specific file | + +## Data Model + +The harness uses JSON project files: + +```json +{ + "version": "1.0", + "name": "my_survey", + "clouds": [ + {"path": "/abs/path/cloud.las", "label": "cloud", ...} + ], + "meshes": [...], + "settings": { + "cloud_export_format": "LAS", + "cloud_export_ext": "las" + }, + "history": [ + {"operation": "subsample", "inputs": [...], "outputs": [...], "params": {...}} + ] +} +``` + +## Supported File Formats + +### Point Clouds +| Extension | Format | Notes | +|-----------|--------|-------| +| .bin | BIN | CloudCompare native binary (default) | +| .las / .laz | LAS | LiDAR format (most common) | +| .ply | PLY | Polygon format | +| .pcd | PCD | Point Cloud Data (ROS/PCL) | +| .xyz / .txt / .asc / .csv | ASC | Plain text ASCII | +| .e57 | E57 | LiDAR exchange format | + +### Meshes +| Extension | Format | +|-----------|--------| +| .obj | OBJ (Wavefront) | +| .stl | STL | +| .ply | PLY | +| .bin | CloudCompare binary | + +## Key Workflows + +### Scan Comparison +```bash +cli-anything-cloudcompare project new -o survey.json +cli-anything-cloudcompare -p survey.json cloud add scan_before.las +cli-anything-cloudcompare -p survey.json cloud add scan_after.las +cli-anything-cloudcompare -p survey.json distance c2c --compare 1 --reference 0 -o diff.las +``` + +### Point Cloud Cleanup Pipeline +```bash +cli-anything-cloudcompare -p project.json cloud filter-sor 0 -o clean.las +cli-anything-cloudcompare -p project.json cloud subsample 0 -o thin.las --method SPATIAL --param 0.02 +``` + +### ICP Registration +```bash +cli-anything-cloudcompare -p project.json transform icp --aligned 1 --reference 0 -o aligned.las +``` + +## Agent Usage Notes + +1. Always use `--json` flag for machine-parseable output +2. Use absolute paths for all file arguments +3. The `--add-to-project` flag adds outputs back to the project for chaining +4. Check `exists: true` in output JSON to verify CloudCompare produced the file +5. `returncode: 0` means CloudCompare exited successfully diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/README.md b/cloudcompare/agent-harness/cli_anything/cloudcompare/README.md new file mode 100644 index 000000000..025b9b315 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/README.md @@ -0,0 +1,117 @@ +# cli-anything-cloudcompare + +Agent-friendly command-line harness for [CloudCompare](https://cloudcompare.org) — +the open-source 3D point cloud and mesh processing software. + +## Prerequisites + +### 1. Install CloudCompare (required) + +```bash +# Linux — Flatpak (recommended) +flatpak install flathub org.cloudcompare.CloudCompare + +# Linux — apt +sudo apt install cloudcompare + +# macOS +brew install --cask cloudcompare + +# Windows +# https://cloudcompare.org/release/index.html +``` + +### 2. Install the CLI harness + +```bash +cd cloudcompare/agent-harness +pip install -e . +``` + +Verify: +```bash +cli-anything-cloudcompare --help +cli-anything-cloudcompare info +``` + +## Quick Start + +```bash +# Create a project +cli-anything-cloudcompare project new -o survey.json + +# Add a point cloud +cli-anything-cloudcompare --project survey.json cloud add scan.las + +# Subsample (spatial, 5cm minimum distance) +cli-anything-cloudcompare --project survey.json cloud subsample 0 \ + -o scan_thin.las --method SPATIAL --param 0.05 + +# Statistical outlier removal +cli-anything-cloudcompare --project survey.json cloud filter-sor 0 \ + -o scan_clean.las --nb-points 6 --std-ratio 1.0 + +# Cloud-to-cloud distance +cli-anything-cloudcompare --project survey.json distance c2c \ + --compare 1 --reference 0 -o distances.las + +# Export to PLY +cli-anything-cloudcompare --project survey.json export cloud 0 output.ply + +# JSON output for agents +cli-anything-cloudcompare --json --project survey.json project info +``` + +## Interactive REPL + +```bash +cli-anything-cloudcompare +# or with a project: +cli-anything-cloudcompare --project survey.json +``` + +## Command Groups + +| Group | Purpose | +|-------|---------| +| `project` | Create, open, inspect projects | +| `cloud` | Load and process point clouds | +| `mesh` | Load and manage meshes | +| `distance` | C2C and C2M distance computation | +| `transform` | ICP registration | +| `export` | Export clouds/meshes to various formats | +| `session` | Save, history, undo, settings | +| `info` | Show CloudCompare installation info | + +## Running Tests + +```bash +cd cloudcompare/agent-harness + +# Unit tests only (no CloudCompare required): +python3 -m pytest cli_anything/cloudcompare/tests/test_core.py -v + +# Full E2E tests (CloudCompare required): +python3 -m pytest cli_anything/cloudcompare/tests/ -v -s + +# Test with installed command: +CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/cloudcompare/tests/ -v -s +``` + +## Supported Formats + +**Clouds:** .las, .laz, .ply, .pcd, .xyz, .txt, .asc, .csv, .e57, .bin +**Meshes:** .obj, .stl, .ply, .bin + +## Agent Usage + +Use `--json` for all programmatic interactions: + +```bash +# Returns structured JSON +cli-anything-cloudcompare --json project info --project my.json + +# Check output file was created +result=$(cli-anything-cloudcompare --json cloud subsample 0 -o out.las --project p.json) +echo $result | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['exists'])" +``` diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/__init__.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/__init__.py new file mode 100644 index 000000000..05b06bb32 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/__init__.py @@ -0,0 +1,2 @@ +"""cli-anything CloudCompare — CLI harness for CloudCompare 3D point cloud software.""" +__version__ = "1.0.0" diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/__main__.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/__main__.py new file mode 100644 index 000000000..3f73daf73 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as: python3 -m cli_anything.cloudcompare""" +from cli_anything.cloudcompare.cloudcompare_cli import main + +if __name__ == "__main__": + main() diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py new file mode 100644 index 000000000..f6ed4fcb5 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py @@ -0,0 +1,1638 @@ +"""cli-anything-cloudcompare — Command-line harness for CloudCompare. + +CloudCompare is a 3D point cloud and mesh processing application. +This CLI wraps CloudCompare's native -SILENT command-line mode with a +structured, agent-friendly interface supporting both one-shot commands +and an interactive REPL. + +Usage: + cli-anything-cloudcompare # start REPL + cli-anything-cloudcompare project new -o p.json # create project + cli-anything-cloudcompare --project p.json cloud subsample ... + cli-anything-cloudcompare --json project info # JSON output + +Backend: CloudCompare -SILENT (Flatpak or native install required) +""" + +import json +import os +import sys +from pathlib import Path +from typing import Optional + +import click + +from cli_anything.cloudcompare.core.export import ( + export_cloud, + export_mesh, + batch_export, + list_presets, + CLOUD_PRESETS, + MESH_PRESETS, +) +from cli_anything.cloudcompare.core.project import ( + create_project, + load_project, + save_project, + add_cloud, + add_mesh, + project_info, + record_operation, +) +from cli_anything.cloudcompare.core.session import Session +from cli_anything.cloudcompare.utils.cc_backend import ( + apply_transform, + compute_c2c_distances, + compute_c2m_distances, + compute_curvature, + compute_density, + compute_normals, + compute_roughness, + convert_format, + coord_to_sf, + coord_to_sf_and_filter, + crop_cloud, + csf_filter, + delaunay_mesh, + extract_connected_components, + filter_sf_by_value, + find_cloudcompare, + get_version, + invert_normals, + noise_filter, + is_available, + merge_clouds, + rgb_to_sf, + run_icp, + sample_mesh, + sf_to_rgb, + sor_filter, + subsample, +) +from cli_anything.cloudcompare.utils.repl_skin import ReplSkin + +VERSION = "1.0.0" + +# ── Output helpers ──────────────────────────────────────────────────────────── + +def _out(ctx: click.Context, data: dict) -> None: + """Print data as JSON or human-readable.""" + if ctx.obj and ctx.obj.get("json"): + click.echo(json.dumps(data, indent=2)) + else: + _pretty(data) + + +def _pretty(data, indent: int = 0) -> None: + """Simple human-readable pretty printer for dicts/lists.""" + prefix = " " * indent + if isinstance(data, dict): + for k, v in data.items(): + if isinstance(v, (dict, list)): + click.echo(f"{prefix}{k}:") + _pretty(v, indent + 1) + else: + click.echo(f"{prefix}{k}: {v}") + elif isinstance(data, list): + for i, item in enumerate(data): + if isinstance(item, dict): + click.echo(f"{prefix}[{i}]") + _pretty(item, indent + 1) + else: + click.echo(f"{prefix} {item}") + else: + click.echo(f"{prefix}{data}") + + +def _error(msg: str, json_mode: bool = False) -> None: + if json_mode: + click.echo(json.dumps({"error": msg}), err=True) + else: + click.echo(f"Error: {msg}", err=True) + + +def _require_project(ctx: click.Context) -> tuple[Session, str]: + """Get project path from context, raise UsageError if missing.""" + project_path = ctx.obj.get("project") if ctx.obj else None + if not project_path: + raise click.UsageError( + "No project file specified. Use --project or create one with:\n" + " cli-anything-cloudcompare project new -o myproject.json" + ) + return Session(project_path), project_path + + +# ── Root CLI ───────────────────────────────────────────────────────────────── + +@click.group(invoke_without_command=True) +@click.option("--project", "-p", envvar="CC_PROJECT", default=None, + help="Path to project JSON file.") +@click.option("--json", "json_output", is_flag=True, default=False, + help="Output results as JSON (for agent consumption).") +@click.pass_context +def cli(ctx: click.Context, project: Optional[str], json_output: bool) -> None: + """cli-anything-cloudcompare: Agent-friendly CLI for CloudCompare. + + Run without a subcommand to enter the interactive REPL. + Use --json for machine-readable output. + + CloudCompare must be installed: + flatpak install flathub org.cloudcompare.CloudCompare + """ + ctx.ensure_object(dict) + ctx.obj["project"] = project + ctx.obj["json"] = json_output + + if ctx.invoked_subcommand is None: + ctx.invoke(repl) + + +# ── REPL ────────────────────────────────────────────────────────────────────── + +@cli.command() +@click.option("--project", "-p", default=None, help="Project file to open in REPL.") +@click.pass_context +def repl(ctx: click.Context, project: Optional[str]) -> None: + """Start the interactive REPL session.""" + skin = ReplSkin("cloudcompare", version=VERSION) + skin.print_banner() + + if not is_available(): + skin.warning("CloudCompare not found. Install it first.") + skin.hint(" flatpak install flathub org.cloudcompare.CloudCompare") + skin.print_goodbye() + return + + # Override project from REPL flag + project_path = project or (ctx.obj.get("project") if ctx.obj else None) + session = Session(project_path) if project_path else None + + pt_session = skin.create_prompt_session() + json_mode = ctx.obj.get("json", False) if ctx.obj else False + + # 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", + "cloud subsample ": "Subsample a cloud", + "cloud roughness ": "Compute roughness SF", + "cloud density ": "Compute density SF", + "cloud normals ": "Compute normals", + "cloud filter-sor ": "Statistical Outlier Removal", + "cloud crop ": "Crop to bounding box", + "cloud merge": "Merge all clouds", + "distance c2c": "Cloud-to-cloud distance", + "distance c2m": "Cloud-to-mesh distance", + "transform icp": "ICP registration", + "export cloud ": "Export a cloud", + "export mesh ": "Export a mesh", + "session save": "Save the project", + "session history": "Show operation history", + "help": "Show this help", + "quit / exit": "Exit the REPL", + } + + while True: + proj_name = session.name if session else "" + modified = session.is_modified if session else False + try: + line = skin.get_input(pt_session, project_name=proj_name, modified=modified) + except (EOFError, KeyboardInterrupt): + break + + if not line: + continue + + if line.lower() in ("quit", "exit", "q"): + if session and session.is_modified: + skin.warning("Unsaved changes in project. Save with: session save") + break + + if line.lower() in ("help", "h", "?"): + skin.help(commands) + continue + + # Parse and invoke as Click command via subprocess-style invocation + try: + args = line.split() + # Inject project context if available + if session and "--project" not in args and "-p" not in args: + args = ["--project", session.project_path] + args + if json_mode: + args = ["--json"] + args + standalone_mode_result = cli.main( + args=args, + 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) + except SystemExit: + pass + except click.UsageError as e: + skin.error(str(e)) + except Exception as e: + skin.error(str(e)) + + skin.print_goodbye() + + +# ── project group ───────────────────────────────────────────────────────────── + +@cli.group() +def project() -> None: + """Project management commands (create, open, inspect).""" + + +@project.command("new") +@click.option("--output", "-o", required=True, help="Output .json project file path.") +@click.option("--name", "-n", default=None, help="Project name.") +@click.pass_context +def project_new(ctx: click.Context, output: str, name: Optional[str]) -> None: + """Create a new empty project file.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + proj = create_project(output, name) + info = project_info(proj) + info["project_path"] = os.path.abspath(output) + _out(ctx, info) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@project.command("info") +@click.pass_context +def project_info_cmd(ctx: click.Context) -> None: + """Show project info and loaded entities.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + info = session.info() + info["project_path"] = path + _out(ctx, info) + except click.UsageError as e: + _error(str(e), json_mode) + sys.exit(1) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@project.command("status") +@click.pass_context +def project_status(ctx: click.Context) -> None: + """Show quick project status.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + _out(ctx, session.status()) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +# ── cloud group ─────────────────────────────────────────────────────────────── + +@cli.group() +def cloud() -> None: + """Point cloud operations (add, subsample, filter, analyze).""" + + +@cloud.command("add") +@click.argument("cloud_file") +@click.option("--label", "-l", default=None, help="Optional label for this cloud.") +@click.pass_context +def cloud_add(ctx: click.Context, cloud_file: str, label: Optional[str]) -> None: + """Add a cloud file to the project.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + entry = session.add_cloud(cloud_file, label) + session.save() + _out(ctx, {"added": entry, "cloud_count": session.cloud_count}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("list") +@click.pass_context +def cloud_list(ctx: click.Context) -> None: + """List all clouds in the project.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + clouds = session.info()["clouds"] + _out(ctx, {"clouds": clouds, "count": len(clouds)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("subsample") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--method", "-m", default="SPATIAL", + type=click.Choice(["RANDOM", "SPATIAL", "OCTREE"], case_sensitive=False), + help="Subsampling method.") +@click.option("--param", "-n", type=float, default=0.05, + help="RANDOM: point count; SPATIAL: min distance; OCTREE: level.") +@click.option("--add-to-project", is_flag=True, default=False, + help="Add output cloud back to the project.") +@click.pass_context +def cloud_subsample( + ctx: click.Context, + cloud_index: int, + output: str, + method: str, + param: float, + add_to_project: bool, +) -> None: + """Subsample a cloud using RANDOM, SPATIAL, or OCTREE method.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = subsample(cloud_entry["path"], output, method.upper(), param) + if result["returncode"] != 0: + raise RuntimeError(f"Subsample failed:\n{result['stderr'][:500]}") + session.record("subsample", [cloud_entry["path"]], [output], + {"method": method, "param": param}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_ss") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": result.get("output", output), + "method": method, + "param": param, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("roughness") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--radius", "-r", type=float, default=0.1, + help="Sphere radius for roughness computation.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_roughness( + ctx: click.Context, + cloud_index: int, + output: str, + radius: float, + add_to_project: bool, +) -> None: + """Compute roughness scalar field for a cloud.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = compute_roughness(cloud_entry["path"], output, radius) + if result["returncode"] != 0: + raise RuntimeError(f"Roughness failed:\n{result['stderr'][:500]}") + session.record("roughness", [cloud_entry["path"]], [output], {"radius": radius}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_rough") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "radius": radius, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("density") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--radius", "-r", type=float, default=0.1, help="Sphere radius.") +@click.option("--type", "density_type", default="KNN", + type=click.Choice(["KNN", "SURFACE", "VOLUME"], case_sensitive=False), + help="Density type.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_density( + ctx: click.Context, + cloud_index: int, + output: str, + radius: float, + density_type: str, + add_to_project: bool, +) -> None: + """Compute point density scalar field.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = compute_density(cloud_entry["path"], output, radius, density_type) + if result["returncode"] != 0: + raise RuntimeError(f"Density failed:\n{result['stderr'][:500]}") + session.record("density", [cloud_entry["path"]], [output], + {"radius": radius, "type": density_type}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_density") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "radius": radius, + "type": density_type, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("curvature") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--type", "curv_type", default="MEAN", + type=click.Choice(["MEAN", "GAUSS"], case_sensitive=False)) +@click.option("--radius", "-r", type=float, default=0.1) +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_curvature( + ctx: click.Context, + cloud_index: int, + output: str, + curv_type: str, + radius: float, + add_to_project: bool, +) -> None: + """Compute curvature scalar field (MEAN or GAUSS).""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = compute_curvature(cloud_entry["path"], output, curv_type, radius) + if result["returncode"] != 0: + raise RuntimeError(f"Curvature failed:\n{result['stderr'][:500]}") + session.record("curvature", [cloud_entry["path"]], [output], + {"type": curv_type, "radius": radius}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_curv") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "type": curv_type, + "radius": radius, + "exists": result.get("exists", False), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("normals") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--level", type=int, default=10, help="Octree level (1-10).") +@click.option("--orientation", default="PLUS_Z", + type=click.Choice(["PLUS_X", "PLUS_Y", "PLUS_Z", + "MINUS_X", "MINUS_Y", "MINUS_Z"], case_sensitive=False)) +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_normals( + ctx: click.Context, + cloud_index: int, + output: str, + level: int, + orientation: str, + add_to_project: bool, +) -> None: + """Compute normals via octree method.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = compute_normals(cloud_entry["path"], output, level, orientation) + if result["returncode"] != 0: + raise RuntimeError(f"Normals failed:\n{result['stderr'][:500]}") + session.record("normals", [cloud_entry["path"]], [output], + {"level": level, "orientation": orientation}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_normals") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "level": level, + "orientation": orientation, + "exists": result.get("exists", False), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("filter-sor") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--nb-points", type=int, default=6, help="K nearest neighbors.") +@click.option("--std-ratio", type=float, default=1.0, help="Std deviation multiplier.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_filter_sor( + ctx: click.Context, + cloud_index: int, + output: str, + nb_points: int, + std_ratio: float, + add_to_project: bool, +) -> None: + """Statistical Outlier Removal (SOR) filter — removes noise points.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = sor_filter(cloud_entry["path"], output, nb_points, std_ratio) + if result["returncode"] != 0: + raise RuntimeError(f"SOR filter failed:\n{result['stderr'][:500]}") + session.record("sor_filter", [cloud_entry["path"]], [output], + {"nb_points": nb_points, "std_ratio": std_ratio}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_sor") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "nb_points": nb_points, + "std_ratio": std_ratio, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("crop") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--xmin", type=float, required=True) +@click.option("--ymin", type=float, required=True) +@click.option("--zmin", type=float, required=True) +@click.option("--xmax", type=float, required=True) +@click.option("--ymax", type=float, required=True) +@click.option("--zmax", type=float, required=True) +@click.option("--outside", is_flag=True, default=False, help="Keep points outside the box.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_crop( + ctx: click.Context, + cloud_index: int, + output: str, + xmin: float, + ymin: float, + zmin: float, + xmax: float, + ymax: float, + zmax: float, + outside: bool, + add_to_project: bool, +) -> None: + """Crop a cloud to an axis-aligned bounding box.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = crop_cloud(cloud_entry["path"], output, + xmin, ymin, zmin, xmax, ymax, zmax, outside) + if result["returncode"] != 0: + raise RuntimeError(f"Crop failed:\n{result['stderr'][:500]}") + session.record("crop", [cloud_entry["path"]], [output], { + "bbox": [xmin, ymin, zmin, xmax, ymax, zmax], + "outside": outside, + }) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_crop") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "bbox": [xmin, ymin, zmin, xmax, ymax, zmax], + "outside": outside, + "exists": result.get("exists", False), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("filter-csf") +@click.argument("cloud_index", type=int) +@click.option("--ground", "-g", required=True, help="Output path for ground points.") +@click.option("--offground", "-u", default=None, + help="Output path for off-ground points (optional).") +@click.option("--scene", default="RELIEF", + type=click.Choice(["SLOPE", "RELIEF", "FLAT"], case_sensitive=False), + help="Scene type: SLOPE (steep), RELIEF (general, default), FLAT (urban).") +@click.option("--cloth-resolution", type=float, default=2.0, + help="Cloth grid resolution in metres. Smaller = finer detail. Default: 2.0.") +@click.option("--class-threshold", type=float, default=0.5, + help="Max distance (m) from cloth to classify as ground. Default: 0.5.") +@click.option("--max-iteration", type=int, default=500, + help="Maximum cloth simulation iterations. Default: 500.") +@click.option("--proc-slope", is_flag=True, default=False, + help="Enable slope post-processing to smooth terrain artifacts.") +@click.option("--add-to-project", is_flag=True, default=False, + help="Add ground (and off-ground if exported) back to the project.") +@click.pass_context +def cloud_filter_csf( + ctx: click.Context, + cloud_index: int, + ground: str, + offground: Optional[str], + scene: str, + cloth_resolution: float, + class_threshold: float, + max_iteration: int, + proc_slope: bool, + add_to_project: bool, +) -> None: + """Ground filtering using the Cloth Simulation Filter (CSF) algorithm. + + Separates a LiDAR scan into ground points and off-ground points + (buildings, vegetation, etc.) by simulating a cloth draped over the + inverted point cloud. + + Scene presets: + SLOPE — steep terrain (rigidness=1, looser cloth) + RELIEF — mixed terrain (rigidness=2, default) + FLAT — flat/urban terrain (rigidness=3, stiffer cloth) + + Examples: + + # Extract ground only (outdoor LiDAR, mixed terrain) + cloud filter-csf 0 --ground ground.las --scene RELIEF + + # Split ground + off-ground (urban scene) + cloud filter-csf 0 --ground ground.las --offground buildings.las \\ + --scene FLAT --cloth-resolution 0.5 --class-threshold 0.3 + + # Steep forested slope with slope post-processing + cloud filter-csf 0 --ground ground.las --scene SLOPE --proc-slope + """ + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = csf_filter( + cloud_entry["path"], + ground_output=ground, + offground_output=offground, + scene=scene.upper(), + cloth_resolution=cloth_resolution, + class_threshold=class_threshold, + max_iteration=max_iteration, + proc_slope=proc_slope, + ) + if result["returncode"] != 0: + raise RuntimeError(f"CSF filter failed:\n{result['stderr'][:500]}") + if not result.get("ground_exists"): + raise RuntimeError( + "CSF ran but ground output not found. " + "Check that the CSF plugin is installed in CloudCompare." + ) + params = { + "scene": scene, "cloth_resolution": cloth_resolution, + "class_threshold": class_threshold, "max_iteration": max_iteration, + "proc_slope": proc_slope, + } + outputs = [ground] + if offground: + outputs.append(offground) + session.record("csf_filter", [cloud_entry["path"]], outputs, params) + if add_to_project: + if result.get("ground_exists"): + session.add_cloud(ground, f"{cloud_entry['label']}_ground") + if offground and result.get("offground_exists"): + session.add_cloud(offground, f"{cloud_entry['label']}_offground") + session.save() + out = { + "input": cloud_entry["path"], + "scene": scene, + "cloth_resolution": cloth_resolution, + "class_threshold": class_threshold, + "ground": ground, + "ground_exists": result.get("ground_exists", False), + "ground_size": result.get("ground_size", 0), + } + if offground: + out["offground"] = offground + out["offground_exists"] = result.get("offground_exists", False) + out["offground_size"] = result.get("offground_size", 0) + _out(ctx, out) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("sf-from-coord") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--dim", "dimension", default="Z", + type=click.Choice(["X", "Y", "Z"], case_sensitive=False), + help="Coordinate axis to convert to scalar field. Default: Z (height).") +@click.option("--sf-index", type=int, default=0, + help="Index to set as active SF after creation.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_sf_from_coord( + ctx: click.Context, + cloud_index: int, + output: str, + dimension: str, + sf_index: int, + add_to_project: bool, +) -> None: + """Convert a coordinate axis (X/Y/Z) to a scalar field. + + Most commonly used to create a height (Z) scalar field for + elevation-based analysis and visualization. + + Example: + cloud sf-from-coord 0 -o with_z_sf.las --dim Z + """ + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = coord_to_sf(cloud_entry["path"], output, dimension, sf_index) + if result["returncode"] != 0: + raise RuntimeError(f"coord_to_sf failed:\n{result['stderr'][:500]}") + session.record("coord_to_sf", [cloud_entry["path"]], [output], + {"dimension": dimension, "sf_index": sf_index}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_sf{dimension}") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "dimension": dimension, + "sf_index": sf_index, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("filter-sf") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--min", "min_val", type=float, required=True, + help="Minimum scalar field value to keep.") +@click.option("--max", "max_val", type=float, required=True, + help="Maximum scalar field value to keep.") +@click.option("--sf-index", type=int, default=None, + help="Set this SF index as active before filtering.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_filter_sf( + ctx: click.Context, + cloud_index: int, + output: str, + min_val: float, + max_val: float, + sf_index: Optional[int], + add_to_project: bool, +) -> None: + """Filter a cloud by scalar field value range. + + Keeps only points whose active scalar field value is in [min, max]. + Typically used after sf-from-coord to filter by height range. + + Example — keep points between z=10m and z=20m: + cloud sf-from-coord 0 -o with_z.las --dim Z --add-to-project + cloud filter-sf 1 -o slice.las --min 10.0 --max 20.0 + """ + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = filter_sf_by_value(cloud_entry["path"], output, min_val, max_val, sf_index) + if result["returncode"] != 0: + raise RuntimeError(f"filter_sf failed:\n{result['stderr'][:500]}") + session.record("filter_sf", [cloud_entry["path"]], [output], + {"min": min_val, "max": max_val, "sf_index": sf_index}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_sffilter") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "min": min_val, + "max": max_val, + "sf_index": sf_index, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("sf-filter-z") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--min", "min_val", type=float, default=None, + help="Minimum Z height to keep (omit for no lower bound).") +@click.option("--max", "max_val", type=float, default=None, + help="Maximum Z height to keep (omit for no upper bound).") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_sf_filter_z( + ctx: click.Context, + cloud_index: int, + output: str, + min_val: Optional[float], + max_val: Optional[float], + add_to_project: bool, +) -> None: + """Convert Z to scalar field and filter by height range in one step. + + Convenience command: combines sf-from-coord (Z) + filter-sf in a single + CloudCompare call. Ideal for extracting a horizontal slice from a scan. + + Example — extract a 1-metre slice at z=5m to z=6m: + cloud sf-filter-z 0 -o slice.las --min 5.0 --max 6.0 + """ + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + if min_val is None and max_val is None: + raise click.UsageError("Provide at least one of --min or --max.") + result = coord_to_sf_and_filter( + cloud_entry["path"], output, dimension="Z", + min_val=min_val, max_val=max_val, + ) + if result["returncode"] != 0: + raise RuntimeError(f"sf-filter-z failed:\n{result['stderr'][:500]}") + session.record("sf_filter_z", [cloud_entry["path"]], [output], + {"min": min_val, "max": max_val}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_zslice") + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output": output, + "z_min": min_val, + "z_max": max_val, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("sf-to-rgb") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud with RGB colours.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_sf_to_rgb(ctx: click.Context, cloud_index: int, output: str, add_to_project: bool) -> None: + """Convert the active scalar field to RGB colours.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = sf_to_rgb(cloud_entry["path"], output) + if result["returncode"] != 0: + raise RuntimeError(f"SF→RGB failed:\n{result['stderr'][:500]}") + session.record("sf_to_rgb", [cloud_entry["path"]], [output], {}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_rgb") + session.save() + _out(ctx, {"input": cloud_entry["path"], "output": output, + "exists": result.get("exists", False), "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("rgb-to-sf") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud with SF from RGB.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_rgb_to_sf(ctx: click.Context, cloud_index: int, output: str, add_to_project: bool) -> None: + """Convert RGB colours to a scalar field (luminance).""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = rgb_to_sf(cloud_entry["path"], output) + if result["returncode"] != 0: + raise RuntimeError(f"RGB→SF failed:\n{result['stderr'][:500]}") + session.record("rgb_to_sf", [cloud_entry["path"]], [output], {}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_sf") + session.save() + _out(ctx, {"input": cloud_entry["path"], "output": output, + "exists": result.get("exists", False), "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("noise-filter") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output filtered cloud.") +@click.option("--knn", type=int, default=6, help="Number of nearest neighbours (default 6).") +@click.option("--noisiness", type=float, default=1.0, help="Noise threshold multiplier.") +@click.option("--radius", type=float, default=0.1, help="Search radius (when --use-radius).") +@click.option("--use-radius", is_flag=True, default=False, help="Use radius mode instead of KNN.") +@click.option("--absolute", is_flag=True, default=False, help="Use absolute noise threshold.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_noise_filter( + ctx: click.Context, + cloud_index: int, + output: str, + knn: int, + noisiness: float, + radius: float, + use_radius: bool, + absolute: bool, + add_to_project: bool, +) -> None: + """Remove noisy points using the PCL noise filter (-NOISE KNN/RADIUS REL/ABS).""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = noise_filter(cloud_entry["path"], output, knn=knn, noisiness=noisiness, + use_radius=use_radius, radius=radius, absolute=absolute) + if result["returncode"] != 0: + raise RuntimeError(f"Noise filter failed:\n{result['stderr'][:500]}") + session.record("noise_filter", [cloud_entry["path"]], [output], + {"knn": knn, "noisiness": noisiness, "use_radius": use_radius}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_denoised") + session.save() + _out(ctx, {"input": cloud_entry["path"], "output": output, + "knn": knn, "noisiness": noisiness, + "exists": result.get("exists", False), "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("invert-normals") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output cloud with inverted normals.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_invert_normals(ctx: click.Context, cloud_index: int, output: str, add_to_project: bool) -> None: + """Invert (flip) all normals in the cloud.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = invert_normals(cloud_entry["path"], output) + if result["returncode"] != 0: + raise RuntimeError(f"Invert normals failed:\n{result['stderr'][:500]}") + session.record("invert_normals", [cloud_entry["path"]], [output], {}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_inv") + session.save() + _out(ctx, {"input": cloud_entry["path"], "output": output, + "exists": result.get("exists", False), "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("segment-cc") +@click.argument("cloud_index", type=int) +@click.option("--output-dir", "-o", required=True, help="Directory to save component clouds.") +@click.option("--octree-level", type=int, default=8, help="Octree level (1-10).") +@click.option("--min-points", type=int, default=100, help="Minimum points per component.") +@click.option("--fmt", default="xyz", help="Output format extension (xyz, las, ply, etc.).") +@click.pass_context +def cloud_segment_cc( + ctx: click.Context, + cloud_index: int, + output_dir: str, + octree_level: int, + min_points: int, + fmt: str, +) -> None: + """Segment cloud into connected components (clusters). + + Each component is saved as a separate file in OUTPUT_DIR. + """ + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = extract_connected_components( + cloud_entry["path"], output_dir, octree_level, min_points, fmt + ) + session.record("segment_cc", [cloud_entry["path"]], result["components"], + {"octree_level": octree_level, "min_points": min_points}) + session.save() + _out(ctx, { + "input": cloud_entry["path"], + "output_dir": output_dir, + "octree_level": octree_level, + "min_points": min_points, + "component_count": result["component_count"], + "components": result["components"], + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("mesh-delaunay") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output mesh file (.obj, .ply, .stl).") +@click.option("--best-fit", is_flag=True, default=False, + help="Use best-fit plane instead of axis-aligned XY.") +@click.option("--max-edge-length", type=float, default=0.0, + help="Remove triangles with edges longer than this (0=no limit).") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_mesh_delaunay( + ctx: click.Context, + cloud_index: int, + output: str, + best_fit: bool, + max_edge_length: float, + add_to_project: bool, +) -> None: + """Build a 2.5-D Delaunay triangulation mesh from a cloud.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = delaunay_mesh( + cloud_entry["path"], output, + axis_aligned=not best_fit, + max_edge_length=max_edge_length, + ) + if result["returncode"] != 0: + raise RuntimeError(f"Delaunay failed:\n{result['stderr'][:500]}") + session.record("delaunay_mesh", [cloud_entry["path"]], [output], + {"axis_aligned": not best_fit, "max_edge_length": max_edge_length}) + if add_to_project and result.get("exists"): + session.add_mesh(output, f"{cloud_entry['label']}_mesh") + session.save() + _out(ctx, {"input": cloud_entry["path"], "output": output, + "exists": result.get("exists", False), "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("merge") +@click.option("--output", "-o", required=True, help="Output merged cloud file.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def cloud_merge(ctx: click.Context, output: str, add_to_project: bool) -> None: + """Merge all clouds in the project into one.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + if session.cloud_count < 2: + raise click.UsageError("Need at least 2 clouds to merge.") + inputs = [session.get_cloud(i)["path"] for i in range(session.cloud_count)] + result = merge_clouds(inputs, output) + if result["returncode"] != 0: + raise RuntimeError(f"Merge failed:\n{result['stderr'][:500]}") + session.record("merge_clouds", inputs, [output], {}) + if add_to_project and result.get("exists"): + session.add_cloud(output, "merged") + session.save() + _out(ctx, { + "inputs": inputs, + "output": output, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@cloud.command("convert") +@click.argument("input_file") +@click.argument("output_file") +@click.pass_context +def cloud_convert(ctx: click.Context, input_file: str, output_file: str) -> None: + """Convert a cloud from one format to another (format from extension).""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + result = convert_format(input_file, output_file) + if result["returncode"] != 0: + raise RuntimeError(f"Convert failed:\n{result['stderr'][:500]}") + _out(ctx, { + "input": input_file, + "output": output_file, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +# ── distance group ──────────────────────────────────────────────────────────── + +@cli.group() +def distance() -> None: + """Distance computation between clouds and meshes.""" + + +@distance.command("c2c") +@click.option("--compare", required=True, help="Index of cloud to compare (gets distance SF).") +@click.option("--reference", required=True, help="Index of reference cloud.") +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--split-xyz", is_flag=True, default=False, help="Split into X/Y/Z components.") +@click.option("--octree-level", type=int, default=10, help="Octree level for computation.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def distance_c2c( + ctx: click.Context, + compare: str, + reference: str, + output: str, + split_xyz: bool, + octree_level: int, + add_to_project: bool, +) -> None: + """Compute cloud-to-cloud distances. Adds distance SF to compared cloud.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + compare_entry = session.get_cloud(int(compare)) + ref_entry = session.get_cloud(int(reference)) + result = compute_c2c_distances( + compare_entry["path"], ref_entry["path"], output, split_xyz, octree_level + ) + if result["returncode"] != 0: + raise RuntimeError(f"C2C distance failed:\n{result['stderr'][:500]}") + session.record("c2c_dist", [compare_entry["path"], ref_entry["path"]], [output], { + "split_xyz": split_xyz, "octree_level": octree_level, + }) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{compare_entry['label']}_c2c") + session.save() + _out(ctx, { + "compare": compare_entry["path"], + "reference": ref_entry["path"], + "output": output, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@distance.command("c2m") +@click.option("--cloud", "cloud_idx", type=int, required=True, help="Cloud index.") +@click.option("--mesh", "mesh_idx", type=int, required=True, help="Mesh index.") +@click.option("--output", "-o", required=True, help="Output cloud file path.") +@click.option("--flip-normals", is_flag=True, default=False) +@click.option("--unsigned", is_flag=True, default=False) +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def distance_c2m( + ctx: click.Context, + cloud_idx: int, + mesh_idx: int, + output: str, + flip_normals: bool, + unsigned: bool, + add_to_project: bool, +) -> None: + """Compute cloud-to-mesh distances. Adds distance SF to the cloud.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_idx) + mesh_entry = session.get_mesh(mesh_idx) + result = compute_c2m_distances( + cloud_entry["path"], mesh_entry["path"], output, flip_normals, unsigned + ) + if result["returncode"] != 0: + raise RuntimeError(f"C2M distance failed:\n{result['stderr'][:500]}") + session.record("c2m_dist", [cloud_entry["path"], mesh_entry["path"]], [output], { + "flip_normals": flip_normals, "unsigned": unsigned, + }) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_c2m") + session.save() + _out(ctx, { + "cloud": cloud_entry["path"], + "mesh": mesh_entry["path"], + "output": output, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +# ── transform group ─────────────────────────────────────────────────────────── + +@cli.group() +def transform() -> None: + """Transformations and registration (ICP, match-centers).""" + + +@transform.command("icp") +@click.option("--aligned", type=int, required=True, help="Index of cloud to align.") +@click.option("--reference", type=int, required=True, help="Index of reference cloud.") +@click.option("--output", "-o", required=True, help="Output aligned cloud path.") +@click.option("--max-iter", type=int, default=100, help="Maximum ICP iterations.") +@click.option("--min-error-diff", type=float, default=1e-6) +@click.option("--overlap", type=float, default=100.0, help="Overlap percentage (0-100).") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def transform_icp( + ctx: click.Context, + aligned: int, + reference: int, + output: str, + max_iter: int, + min_error_diff: float, + overlap: float, + add_to_project: bool, +) -> None: + """Run ICP registration to align one cloud to another.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + aligned_entry = session.get_cloud(aligned) + ref_entry = session.get_cloud(reference) + result = run_icp( + aligned_entry["path"], ref_entry["path"], output, + max_iter, min_error_diff, overlap + ) + if result["returncode"] != 0: + raise RuntimeError(f"ICP failed:\n{result['stderr'][:500]}") + session.record("icp", [aligned_entry["path"], ref_entry["path"]], [output], { + "max_iter": max_iter, "overlap": overlap, + }) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{aligned_entry['label']}_icp") + session.save() + _out(ctx, { + "aligned": aligned_entry["path"], + "reference": ref_entry["path"], + "output": output, + "exists": result.get("exists", False), + "file_size": result.get("file_size", 0), + }) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@transform.command("apply") +@click.argument("cloud_index", type=int) +@click.option("--output", "-o", required=True, help="Output transformed cloud.") +@click.option("--matrix", "-m", required=True, + help="Path to 4×4 transformation matrix text file.") +@click.option("--inverse", is_flag=True, default=False, + help="Apply the inverse of the matrix.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def transform_apply( + ctx: click.Context, + cloud_index: int, + output: str, + matrix: str, + inverse: bool, + add_to_project: bool, +) -> None: + """Apply a 4×4 rigid-body transformation matrix to a cloud. + + The matrix file must contain 4 rows of 4 space-separated values. + + Example identity matrix file:: + + 1 0 0 0 + 0 1 0 0 + 0 0 1 0 + 0 0 0 1 + """ + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = apply_transform(cloud_entry["path"], output, matrix, inverse) + if result["returncode"] != 0: + raise RuntimeError(f"Apply transform failed:\n{result['stderr'][:500]}") + session.record("apply_transform", [cloud_entry["path"]], [output], + {"matrix": matrix, "inverse": inverse}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{cloud_entry['label']}_transformed") + session.save() + _out(ctx, {"input": cloud_entry["path"], "matrix": matrix, "inverse": inverse, + "output": output, "exists": result.get("exists", False), + "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +# ── mesh group ──────────────────────────────────────────────────────────────── + +@cli.group() +def mesh() -> None: + """Mesh operations (add, convert, export).""" + + +@mesh.command("add") +@click.argument("mesh_file") +@click.option("--label", "-l", default=None) +@click.pass_context +def mesh_add(ctx: click.Context, mesh_file: str, label: Optional[str]) -> None: + """Add a mesh file to the project.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + entry = session.add_mesh(mesh_file, label) + session.save() + _out(ctx, {"added": entry, "mesh_count": session.mesh_count}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@mesh.command("list") +@click.pass_context +def mesh_list(ctx: click.Context) -> None: + """List all meshes in the project.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + meshes = session.info()["meshes"] + _out(ctx, {"meshes": meshes, "count": len(meshes)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@mesh.command("sample") +@click.argument("mesh_index", type=int) +@click.option("--output", "-o", required=True, help="Output sampled point cloud.") +@click.option("--count", "-n", type=int, default=10000, + help="Number of points to sample from the mesh surface.") +@click.option("--add-to-project", is_flag=True, default=False) +@click.pass_context +def mesh_sample( + ctx: click.Context, + mesh_index: int, + output: str, + count: int, + add_to_project: bool, +) -> None: + """Sample a point cloud from a mesh surface.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + mesh_entry = session.get_mesh(mesh_index) + result = sample_mesh(mesh_entry["path"], output, count) + if result["returncode"] != 0: + raise RuntimeError(f"Sample mesh failed:\n{result['stderr'][:500]}") + session.record("sample_mesh", [mesh_entry["path"]], [output], {"count": count}) + if add_to_project and result.get("exists"): + session.add_cloud(output, f"{mesh_entry['label']}_sampled") + session.save() + _out(ctx, {"mesh": mesh_entry["path"], "output": output, "count": count, + "exists": result.get("exists", False), "file_size": result.get("file_size", 0)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +# ── export group ────────────────────────────────────────────────────────────── + +@cli.group() +def export() -> None: + """Export clouds and meshes to various formats.""" + + +@export.command("cloud") +@click.argument("cloud_index", type=int) +@click.argument("output_path") +@click.option("--preset", "-f", default=None, + help=f"Format preset: {', '.join(CLOUD_PRESETS)}.") +@click.option("--overwrite", is_flag=True, default=False) +@click.pass_context +def export_cloud_cmd( + ctx: click.Context, + cloud_index: int, + output_path: str, + preset: Optional[str], + overwrite: bool, +) -> None: + """Export a cloud to a target format using CloudCompare.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + cloud_entry = session.get_cloud(cloud_index) + result = export_cloud(cloud_entry["path"], output_path, preset=preset, overwrite=overwrite) + session.record("export_cloud", [cloud_entry["path"]], [result["output"]], { + "format": result["format"], + }) + session.save() + _out(ctx, result) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@export.command("mesh") +@click.argument("mesh_index", type=int) +@click.argument("output_path") +@click.option("--preset", "-f", default=None, + help=f"Format preset: {', '.join(MESH_PRESETS)}.") +@click.option("--overwrite", is_flag=True, default=False) +@click.pass_context +def export_mesh_cmd( + ctx: click.Context, + mesh_index: int, + output_path: str, + preset: Optional[str], + overwrite: bool, +) -> None: + """Export a mesh to a target format using CloudCompare.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + mesh_entry = session.get_mesh(mesh_index) + result = export_mesh(mesh_entry["path"], output_path, preset=preset, overwrite=overwrite) + session.record("export_mesh", [mesh_entry["path"]], [result["output"]], { + "format": result["format"], + }) + session.save() + _out(ctx, result) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@export.command("batch") +@click.option("--output-dir", "-d", required=True, help="Output directory.") +@click.option("--preset", "-f", default="las", help="Format preset for all outputs.") +@click.option("--overwrite", is_flag=True, default=False) +@click.pass_context +def export_batch(ctx: click.Context, output_dir: str, preset: str, overwrite: bool) -> None: + """Batch export all project clouds to a directory.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + inputs = [session.get_cloud(i)["path"] for i in range(session.cloud_count)] + results = batch_export(inputs, output_dir, preset=preset, overwrite=overwrite) + _out(ctx, {"results": results, "count": len(results)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@export.command("formats") +@click.pass_context +def export_formats(ctx: click.Context) -> None: + """List all available export format presets.""" + _out(ctx, list_presets()) + + +# ── session group ───────────────────────────────────────────────────────────── + +@cli.group("session") +def session_group() -> None: + """Session management (save, history, status).""" + + +@session_group.command("save") +@click.pass_context +def session_save(ctx: click.Context) -> None: + """Save the current project to disk.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + session.save() + _out(ctx, {"saved": path, "status": "ok"}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@session_group.command("history") +@click.option("--last", "-n", type=int, default=10, help="Number of recent entries.") +@click.pass_context +def session_history(ctx: click.Context, last: int) -> None: + """Show recent operation history.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + hist = session.history(last) + _out(ctx, {"history": hist, "count": len(hist)}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@session_group.command("undo") +@click.pass_context +def session_undo(ctx: click.Context) -> None: + """Remove the last operation from history (soft undo).""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + removed = session.undo_last() + session.save() + if removed: + _out(ctx, {"undone": removed}) + else: + _out(ctx, {"status": "nothing_to_undo"}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +@session_group.command("set-format") +@click.option("--cloud-fmt", default=None, help="Cloud export format (e.g. LAS, PLY).") +@click.option("--cloud-ext", default=None, help="Cloud file extension (e.g. las, ply).") +@click.option("--mesh-fmt", default=None, help="Mesh export format (e.g. OBJ, STL).") +@click.option("--mesh-ext", default=None, help="Mesh file extension.") +@click.pass_context +def session_set_format( + ctx: click.Context, + cloud_fmt: Optional[str], + cloud_ext: Optional[str], + mesh_fmt: Optional[str], + mesh_ext: Optional[str], +) -> None: + """Update default export format settings.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + try: + session, path = _require_project(ctx) + session.set_export_format(cloud_fmt, cloud_ext, mesh_fmt, mesh_ext) + session.save() + _out(ctx, {"settings": session.get_settings()}) + except Exception as e: + _error(str(e), json_mode) + sys.exit(1) + + +# ── info command ────────────────────────────────────────────────────────────── + +@cli.command("info") +@click.pass_context +def info_cmd(ctx: click.Context) -> None: + """Show CloudCompare installation info.""" + json_mode = ctx.obj.get("json", False) if ctx.obj else False + available = is_available() + try: + cmd = find_cloudcompare() if available else [] + except RuntimeError: + cmd = [] + version = get_version() if available else None + _out(ctx, { + "cloudcompare_available": available, + "command": cmd, + "version": version, + "supported_cloud_formats": list(CLOUD_PRESETS.keys()), + "supported_mesh_formats": list(MESH_PRESETS.keys()), + }) + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def main(): + cli(obj={}) + + +if __name__ == "__main__": + main() diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/__init__.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py new file mode 100644 index 000000000..b9ffde665 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py @@ -0,0 +1,225 @@ +"""Export pipeline for the CloudCompare CLI harness. + +Handles format conversion and batch export using the real CloudCompare backend. +""" + +import os +from pathlib import Path +from typing import Optional + +from cli_anything.cloudcompare.utils.cc_backend import ( + CLOUD_FORMATS, + MESH_FORMATS, + convert_format, + open_and_save, + run_cloudcompare, +) + + +# ── Format presets ──────────────────────────────────────────────────────────── + +CLOUD_PRESETS = { + "las": {"format": "LAS", "ext": "las", "desc": "LAS point cloud"}, + "laz": {"format": "LAS", "ext": "laz", "desc": "LAZ (compressed LAS)"}, + "ply": {"format": "PLY", "ext": "ply", "desc": "PLY polygon file"}, + "pcd": {"format": "PCD", "ext": "pcd", "desc": "Point Cloud Data"}, + "xyz": {"format": "ASC", "ext": "xyz", "desc": "XYZ ASCII cloud"}, + "asc": {"format": "ASC", "ext": "asc", "desc": "ASC ASCII cloud"}, + "csv": {"format": "ASC", "ext": "csv", "desc": "CSV ASCII cloud"}, + "bin": {"format": "BIN", "ext": "bin", "desc": "CloudCompare native binary"}, + "e57": {"format": "E57", "ext": "e57", "desc": "E57 lidar exchange format"}, +} + +MESH_PRESETS = { + "obj": {"format": "OBJ", "ext": "obj", "desc": "Wavefront OBJ mesh"}, + "stl": {"format": "STL", "ext": "stl", "desc": "STL mesh"}, + "ply": {"format": "PLY", "ext": "ply", "desc": "PLY mesh"}, + "bin": {"format": "BIN", "ext": "bin", "desc": "CloudCompare native binary"}, +} + + +def list_presets() -> dict: + """Return available presets for cloud and mesh export.""" + return { + "cloud": {k: v["desc"] for k, v in CLOUD_PRESETS.items()}, + "mesh": {k: v["desc"] for k, v in MESH_PRESETS.items()}, + } + + +def export_cloud( + input_path: str, + output_path: str, + preset: Optional[str] = None, + extra_args: Optional[list[str]] = None, + overwrite: bool = False, +) -> dict: + """Export a point cloud to a new format using CloudCompare. + + Args: + input_path: Source cloud file. + output_path: Destination file path (format from extension or preset). + preset: Optional format preset name (e.g., 'las', 'ply'). + extra_args: Additional CC command args applied before saving. + overwrite: Whether to overwrite existing output file. + + Returns: + dict with output path, format, file_size, and backend result. + + Raises: + FileNotFoundError: If input doesn't exist. + FileExistsError: If output exists and overwrite=False. + RuntimeError: If export fails. + """ + input_path = os.path.abspath(input_path) + output_path = os.path.abspath(output_path) + + 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 + if preset: + preset = preset.lower() + if preset not in CLOUD_PRESETS: + raise ValueError(f"Unknown preset {preset!r}. Options: {list(CLOUD_PRESETS)}") + info = CLOUD_PRESETS[preset] + fmt = info["format"] + ext = info["ext"] + # If output_path has different extension, replace it + out_base = os.path.splitext(output_path)[0] + output_path = f"{out_base}.{ext}" + else: + out_ext = os.path.splitext(output_path)[1].lstrip(".").lower() + fmt = CLOUD_FORMATS.get(out_ext, "ASC") + ext = out_ext + + result = open_and_save(input_path, output_path, extra_args) + + if result["returncode"] != 0: + raise RuntimeError( + f"CloudCompare export failed (exit {result['returncode']}):\n" + f" stderr: {result['stderr'][:500]}" + ) + + if not result.get("exists"): + raise RuntimeError( + f"CloudCompare ran but output file was not created: {output_path}" + ) + + return { + "output": output_path, + "format": fmt, + "extension": ext, + "file_size": result.get("file_size", 0), + "returncode": result["returncode"], + "command": result["command"], + } + + +def export_mesh( + input_path: str, + output_path: str, + preset: Optional[str] = None, + overwrite: bool = False, +) -> dict: + """Export a mesh to a new format using CloudCompare. + + Args: + input_path: Source mesh file. + output_path: Destination file path. + preset: Optional format preset (e.g., 'obj', 'stl', 'ply'). + overwrite: Whether to overwrite existing output. + + Returns: + dict with output info. + """ + input_path = os.path.abspath(input_path) + output_path = os.path.abspath(output_path) + + 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." + ) + + if preset: + preset = preset.lower() + if preset not in MESH_PRESETS: + raise ValueError(f"Unknown mesh preset {preset!r}. Options: {list(MESH_PRESETS)}") + info = MESH_PRESETS[preset] + fmt = info["format"] + ext = info["ext"] + out_base = os.path.splitext(output_path)[0] + output_path = f"{out_base}.{ext}" + else: + out_ext = os.path.splitext(output_path)[1].lstrip(".").lower() + fmt = MESH_FORMATS.get(out_ext, "OBJ") + ext = out_ext + + args = [ + "-O", input_path, + "-M_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_MESHES", "FILE", output_path, + ] + + result = run_cloudcompare(args) + exists = os.path.exists(output_path) + + if result["returncode"] != 0: + raise RuntimeError( + f"CloudCompare mesh export failed (exit {result['returncode']}):\n" + f" stderr: {result['stderr'][:500]}" + ) + + if not exists: + raise RuntimeError( + f"CloudCompare ran but output file was not created: {output_path}" + ) + + return { + "output": output_path, + "format": fmt, + "extension": ext, + "file_size": os.path.getsize(output_path), + "returncode": result["returncode"], + "command": result["command"], + } + + +def batch_export( + input_paths: list[str], + output_dir: str, + preset: str = "las", + overwrite: bool = False, +) -> list[dict]: + """Batch export multiple clouds to a directory. + + Args: + input_paths: List of input cloud files. + output_dir: Directory for output files. + preset: Format preset for all outputs. + overwrite: Whether to overwrite existing files. + + Returns: + List of result dicts (one per input). + """ + os.makedirs(output_dir, exist_ok=True) + results = [] + for inp in input_paths: + stem = Path(inp).stem + ext = CLOUD_PRESETS.get(preset, CLOUD_PRESETS["las"])["ext"] + out = os.path.join(output_dir, f"{stem}.{ext}") + try: + r = export_cloud(inp, out, preset=preset, overwrite=overwrite) + r["status"] = "ok" + except Exception as e: + r = {"input": inp, "output": out, "status": "error", "error": str(e)} + results.append(r) + return results diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py new file mode 100644 index 000000000..fa3eda3f8 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py @@ -0,0 +1,279 @@ +"""Project management for the CloudCompare CLI harness. + +A 'project' is a JSON file tracking: +- Loaded clouds and meshes (input file paths, labels) +- Active working files (current state after operations) +- Session settings (export format, global shift, etc.) +- Operation history for undo/redo +""" + +import fcntl +import json +import os +import time +from pathlib import Path +from typing import Any, Optional + + +# ── JSON locking helper ────────────────────────────────────────────────────── + +def _locked_save_json(path: str, data: dict, **dump_kwargs) -> None: + """Atomically write JSON with exclusive file locking.""" + path = os.path.abspath(path) + try: + f = open(path, "r+") # no truncation on open + except FileNotFoundError: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + f = open(path, "w") # first save — file doesn't exist yet + with f: + _locked = False + try: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + _locked = True + except (ImportError, OSError): + pass # Windows / unsupported FS — proceed unlocked + try: + f.seek(0) + f.truncate() + json.dump(data, f, indent=2, **dump_kwargs) + f.flush() + finally: + if _locked: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + +# ── Project data model ─────────────────────────────────────────────────────── + +def _default_project(name: str = "untitled") -> dict: + """Return a fresh project structure.""" + return { + "version": "1.0", + "name": name, + "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "modified_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "clouds": [], # list of cloud entries + "meshes": [], # list of mesh entries + "settings": { + "cloud_export_format": "LAS", + "cloud_export_ext": "las", + "mesh_export_format": "OBJ", + "mesh_export_ext": "obj", + "global_shift": None, # [x, y, z] or null + "no_timestamp": True, + }, + "history": [], # operation history for undo + } + + +def _cloud_entry(path: str, label: Optional[str] = None) -> dict: + """Create a cloud entry dict.""" + path = os.path.abspath(path) + return { + "path": path, + "label": label or Path(path).stem, + "loaded_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "scalar_fields": [], + "has_normals": False, + "has_rgb": False, + } + + +def _mesh_entry(path: str, label: Optional[str] = None) -> dict: + """Create a mesh entry dict.""" + path = os.path.abspath(path) + return { + "path": path, + "label": label or Path(path).stem, + "loaded_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def create_project(output_path: str, name: Optional[str] = None) -> dict: + """Create a new empty project and save it to disk. + + Args: + output_path: Where to save the .json project file. + name: Human-readable project name. + + Returns: + The project dict. + """ + output_path = os.path.abspath(output_path) + if name is None: + name = Path(output_path).stem + proj = _default_project(name) + _locked_save_json(output_path, proj) + return proj + + +def load_project(path: str) -> dict: + """Load a project from disk. + + Args: + path: Path to the .json project file. + + Returns: + The project dict. + + Raises: + FileNotFoundError: If the file doesn't exist. + ValueError: If the file is not a valid project JSON. + """ + path = os.path.abspath(path) + if not os.path.exists(path): + raise FileNotFoundError(f"Project file not found: {path}") + + with open(path) as f: + data = json.load(f) + + if "version" not in data or "clouds" not in data: + raise ValueError(f"Not a valid CloudCompare CLI project: {path}") + + return data + + +def save_project(project: dict, path: str) -> None: + """Save project to disk. + + Args: + project: Project dict. + path: Destination .json file path. + """ + project["modified_at"] = time.strftime("%Y-%m-%dT%H:%M:%S") + _locked_save_json(path, project) + + +def add_cloud(project: dict, cloud_path: str, label: Optional[str] = None) -> dict: + """Add a cloud to the project's cloud list. + + Args: + project: Project dict (modified in place). + cloud_path: Path to the cloud file. + label: Optional label. + + Returns: + The new cloud entry. + """ + if not os.path.exists(cloud_path): + raise FileNotFoundError(f"Cloud file not found: {cloud_path}") + + entry = _cloud_entry(cloud_path, label) + project["clouds"].append(entry) + return entry + + +def add_mesh(project: dict, mesh_path: str, label: Optional[str] = None) -> dict: + """Add a mesh to the project's mesh list. + + Args: + project: Project dict (modified in place). + mesh_path: Path to the mesh file. + label: Optional label. + + Returns: + The new mesh entry. + """ + if not os.path.exists(mesh_path): + raise FileNotFoundError(f"Mesh file not found: {mesh_path}") + + entry = _mesh_entry(mesh_path, label) + project["meshes"].append(entry) + return entry + + +def remove_cloud(project: dict, index: int) -> dict: + """Remove a cloud by index. + + Args: + project: Project dict (modified in place). + index: 0-based index of the cloud to remove. + + Returns: + The removed cloud entry. + """ + if index < 0 or index >= len(project["clouds"]): + raise IndexError(f"No cloud at index {index} (project has {len(project['clouds'])} clouds)") + return project["clouds"].pop(index) + + +def remove_mesh(project: dict, index: int) -> dict: + """Remove a mesh by index. + + Args: + project: Project dict (modified in place). + index: 0-based index of the mesh to remove. + + Returns: + The removed mesh entry. + """ + if index < 0 or index >= len(project["meshes"]): + raise IndexError(f"No mesh at index {index} (project has {len(project['meshes'])} meshes)") + return project["meshes"].pop(index) + + +def get_cloud(project: dict, index: int) -> dict: + """Get a cloud entry by index. + + Args: + project: Project dict. + index: 0-based index. + + Returns: + The cloud entry dict. + """ + clouds = project.get("clouds", []) + if index < 0 or index >= len(clouds): + raise IndexError(f"No cloud at index {index}") + return clouds[index] + + +def get_mesh(project: dict, index: int) -> dict: + """Get a mesh entry by index.""" + meshes = project.get("meshes", []) + if index < 0 or index >= len(meshes): + raise IndexError(f"No mesh at index {index}") + return meshes[index] + + +def project_info(project: dict) -> dict: + """Return a summary dict of the project state.""" + return { + "name": project.get("name", "unnamed"), + "version": project.get("version", "?"), + "created_at": project.get("created_at", ""), + "modified_at": project.get("modified_at", ""), + "cloud_count": len(project.get("clouds", [])), + "mesh_count": len(project.get("meshes", [])), + "history_depth": len(project.get("history", [])), + "settings": project.get("settings", {}), + "clouds": [ + {"index": i, "label": c["label"], "path": c["path"]} + for i, c in enumerate(project.get("clouds", [])) + ], + "meshes": [ + {"index": i, "label": m["label"], "path": m["path"]} + for i, m in enumerate(project.get("meshes", [])) + ], + } + + +def record_operation(project: dict, operation: str, inputs: list[str], outputs: list[str], params: dict) -> None: + """Record an operation in the project history. + + Args: + project: Project dict (modified in place). + operation: Operation name (e.g., 'subsample'). + inputs: List of input file paths. + outputs: List of output file paths. + params: Operation parameters dict. + """ + entry = { + "operation": operation, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "inputs": inputs, + "outputs": outputs, + "params": params, + } + project.setdefault("history", []).append(entry) diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/core/session.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/session.py new file mode 100644 index 000000000..8db4c2ac0 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/session.py @@ -0,0 +1,172 @@ +"""Session management for the CloudCompare CLI harness. + +A session wraps a project file path and provides: +- Convenience methods for common workflows +- Undo/redo via operation history +- Status reporting +""" + +import json +import os +from pathlib import Path +from typing import Optional + +from cli_anything.cloudcompare.core.project import ( + add_cloud, + add_mesh, + create_project, + get_cloud, + get_mesh, + load_project, + project_info, + record_operation, + remove_cloud, + remove_mesh, + save_project, +) + + +class Session: + """Stateful session wrapping a CloudCompare CLI project.""" + + def __init__(self, project_path: str): + """Initialize session with a project file. + + Args: + project_path: Path to the .json project file. + Created if it doesn't exist. + """ + self.project_path = os.path.abspath(project_path) + if os.path.exists(self.project_path): + self.project = load_project(self.project_path) + else: + self.project = create_project(self.project_path) + self._dirty = False + + @property + def name(self) -> str: + return self.project.get("name", "unnamed") + + @property + def cloud_count(self) -> int: + return len(self.project.get("clouds", [])) + + @property + def mesh_count(self) -> int: + return len(self.project.get("meshes", [])) + + @property + def is_modified(self) -> bool: + return self._dirty + + def add_cloud(self, path: str, label: Optional[str] = None) -> dict: + """Add a cloud to the session.""" + entry = add_cloud(self.project, path, label) + self._dirty = True + return entry + + def add_mesh(self, path: str, label: Optional[str] = None) -> dict: + """Add a mesh to the session.""" + entry = add_mesh(self.project, path, label) + self._dirty = True + return entry + + def remove_cloud(self, index: int) -> dict: + """Remove a cloud by index.""" + entry = remove_cloud(self.project, index) + self._dirty = True + return entry + + def remove_mesh(self, index: int) -> dict: + """Remove a mesh by index.""" + entry = remove_mesh(self.project, index) + self._dirty = True + return entry + + def get_cloud(self, index: int) -> dict: + """Get a cloud entry.""" + return get_cloud(self.project, index) + + def get_mesh(self, index: int) -> dict: + """Get a mesh entry.""" + return get_mesh(self.project, index) + + def record(self, operation: str, inputs: list, outputs: list, params: dict) -> None: + """Record an operation in history.""" + record_operation(self.project, operation, inputs, outputs, params) + self._dirty = True + + def save(self) -> None: + """Persist the project to disk.""" + save_project(self.project, self.project_path) + self._dirty = False + + def info(self) -> dict: + """Return project info dict.""" + return project_info(self.project) + + def history(self, last_n: int = 10) -> list: + """Return recent operation history. + + Args: + last_n: How many recent operations to return. + """ + hist = self.project.get("history", []) + return hist[-last_n:] if last_n else hist + + def undo_last(self) -> Optional[dict]: + """Remove the last history entry (soft undo — removes record, not files). + + Returns: + The removed history entry, or None if history is empty. + """ + hist = self.project.get("history", []) + if not hist: + return None + removed = hist.pop() + self._dirty = True + return removed + + def set_export_format( + self, + cloud_fmt: Optional[str] = None, + cloud_ext: Optional[str] = None, + mesh_fmt: Optional[str] = None, + mesh_ext: Optional[str] = None, + ) -> None: + """Update export format settings. + + Args: + cloud_fmt: CloudCompare format string (e.g., 'LAS', 'PLY', 'ASC'). + cloud_ext: File extension (e.g., 'las', 'ply', 'xyz'). + mesh_fmt: Mesh format string. + mesh_ext: Mesh file extension. + """ + settings = self.project.setdefault("settings", {}) + if cloud_fmt: + settings["cloud_export_format"] = cloud_fmt.upper() + if cloud_ext: + settings["cloud_export_ext"] = cloud_ext.lower() + if mesh_fmt: + settings["mesh_export_format"] = mesh_fmt.upper() + if mesh_ext: + settings["mesh_export_ext"] = mesh_ext.lower() + self._dirty = True + + def get_settings(self) -> dict: + """Return current session settings.""" + return self.project.get("settings", {}) + + def status(self) -> dict: + """Return a concise status dict.""" + return { + "project": self.project_path, + "name": self.name, + "clouds": self.cloud_count, + "meshes": self.mesh_count, + "modified": self._dirty, + "history_depth": len(self.project.get("history", [])), + } + + def __repr__(self) -> str: + return f"Session({self.project_path!r}, clouds={self.cloud_count}, meshes={self.mesh_count})" diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md new file mode 100644 index 000000000..49c6e83e2 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md @@ -0,0 +1,75 @@ +--- +name: "cli-anything-cloudcompare" +description: "Command-line interface for Cloudcompare - Agent-friendly command-line harness for [CloudCompare](https://cloudcompare.org) — the open-source 3..." +--- + +# cli-anything-cloudcompare + +Agent-friendly command-line harness for [CloudCompare](https://cloudcompare.org) — the open-source 3D point cloud and mesh processing software. + +## Installation + +This CLI is installed as part of the cli-anything-cloudcompare package: + +```bash +pip install cli-anything-cloudcompare +``` + +**Prerequisites:** +- Python 3.10+ +- Cloudcompare must be installed on your system + +## Usage + +### Basic Commands + +```bash +# Show help +cli-anything-cloudcompare --help + +# Start interactive REPL mode +cli-anything-cloudcompare + +# Create a new project +cli-anything-cloudcompare project new -o project.json + +# Run with JSON output (for agent consumption) +cli-anything-cloudcompare --json project info -p project.json +``` + +## Examples + +### Create a New Project + +Create a new cloudcompare project file. + +```bash +cli-anything-cloudcompare project new -o myproject.json +# Or with JSON output for programmatic use +cli-anything-cloudcompare --json project new -o myproject.json +``` + +### Interactive REPL Session + +Start an interactive session with undo/redo support. + +```bash +cli-anything-cloudcompare +# Enter commands interactively +# Use 'help' to see available commands +# Use 'undo' and 'redo' for history navigation +``` + +## For AI Agents + +When using this CLI programmatically: + +1. **Always use `--json` flag** for parseable output +2. **Check return codes** - 0 for success, non-zero for errors +3. **Parse stderr** for error messages on failure +4. **Use absolute paths** for all file operations +5. **Verify outputs exist** after export operations + +## Version + +1.0.0 \ No newline at end of file diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/TEST.md b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/TEST.md new file mode 100644 index 000000000..ed5935025 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/TEST.md @@ -0,0 +1,298 @@ +# Test Plan — cli-anything-cloudcompare + +## Test Inventory Plan + +| File | Purpose | Estimated Tests | +|------|---------|----------------| +| `test_core.py` | Unit tests — synthetic data, no CloudCompare required | 35 | +| `test_full_e2e.py` | E2E tests — real CloudCompare invocations + subprocess CLI tests | 20 | + +--- + +## Unit Test Plan (`test_core.py`) + +### Module: `core/project.py` + +**Functions to test:** + +- `create_project(output_path, name)` — creates valid JSON at path +- `load_project(path)` — loads and validates structure +- `save_project(project, path)` — persists with updated `modified_at` +- `add_cloud(project, path, label)` — appends cloud entry +- `add_mesh(project, path, label)` — appends mesh entry +- `remove_cloud(project, index)` — removes by index, returns entry +- `remove_mesh(project, index)` — removes by index +- `get_cloud(project, index)` — retrieves by index +- `get_mesh(project, index)` — retrieves by index +- `project_info(project)` — returns summary dict +- `record_operation(project, ...)` — appends to history + +**Edge cases:** +- `load_project` on non-existent file → `FileNotFoundError` +- `load_project` on invalid JSON → `ValueError` +- `get_cloud` / `remove_cloud` with out-of-range index → `IndexError` +- `add_cloud` with non-existent path → `FileNotFoundError` +- `_locked_save_json` is atomic — file content after write is valid JSON + +**Expected: ~18 tests** + +### Module: `core/session.py` + +**Functions to test:** + +- `Session.__init__` — creates new project when file missing +- `Session.__init__` — loads existing project +- `Session.add_cloud` / `add_mesh` +- `Session.remove_cloud` / `remove_mesh` +- `Session.cloud_count` / `mesh_count` properties +- `Session.save()` — persists and clears dirty flag +- `Session.is_modified` — set after mutations, cleared after save +- `Session.history(n)` — returns last n entries +- `Session.undo_last()` — removes last history entry +- `Session.set_export_format` — updates settings dict +- `Session.status()` — returns dict with expected keys + +**Edge cases:** +- `undo_last` with empty history → returns None +- `set_export_format` with None values → no-op on those fields + +**Expected: ~17 tests** + +### Module: `utils/cc_backend.py` (unit-level) + +**Functions to test (logic only, no actual CC execution):** + +- `find_cloudcompare()` — raises `RuntimeError` when not found (mocked) +- `CLOUD_FORMATS` mapping — all expected extensions present +- `MESH_FORMATS` mapping — all expected extensions present + +**Expected: ~3 tests** (logic tests, CC not invoked) + +--- + +## E2E Test Plan (`test_full_e2e.py`) + +### Prerequisites +- CloudCompare must be installed (`flatpak run org.cloudcompare.CloudCompare`) +- Tests generate real output files and verify them + +### Workflow 1: Format Conversion (LAS → PLY) + +**Simulates:** Receiving a LAS scan and converting to PLY for downstream processing + +**Operations:** +1. Generate a minimal valid XYZ/ASCII cloud file (synthetic data) +2. Convert to PLY using CloudCompare +3. Verify output: exists, size > 0, starts with "ply" + +### Workflow 2: Subsampling Pipeline + +**Simulates:** Thinning a dense scan to reduce size while preserving coverage + +**Operations:** +1. Create a synthetic XYZ cloud (1000 points on a plane) +2. Subsample using SPATIAL method (min distance 0.1) +3. Verify output: exists, size > 0 +4. Subsample using RANDOM method (count 50) +5. Verify output: exists, size > 0 + +### Workflow 3: Full Project Workflow (CLI subprocess) + +**Simulates:** An agent building a processing pipeline via the installed CLI + +**Operations:** +1. `cli-anything-cloudcompare project new -o test.json` → verify JSON created +2. `--project test.json cloud add cloud.xyz` → verify cloud_count=1 +3. `--project test.json cloud subsample 0 -o sub.xyz ...` → verify exists +4. `--project test.json project info --json` → verify JSON output +5. `--project test.json session history --json` → verify history recorded +6. `--project test.json export formats` → verify format list + +### Workflow 4: SOR Filter (noise removal) + +**Simulates:** Cleaning a noisy scan before analysis + +**Operations:** +1. Create cloud with deliberately outlier points +2. Run SOR filter +3. Verify output: exists, size > 0, smaller than input (outliers removed) + +### Workflow 5: CLI Subprocess (`TestCLISubprocess`) + +All operations via the installed `cli-anything-cloudcompare` binary: +- `--help` → exit 0, contains "cloudcompare" +- `info --json` → valid JSON with `cloudcompare_available` +- `project new -o X` → valid project JSON +- `--json project info` → JSON with expected keys +- `cloud subsample` round-trip: new project → add cloud → subsample → verify output +- `export formats --json` → JSON with cloud/mesh keys + +--- + +## Realistic Workflow Scenarios + +### Scenario A: Survey Change Detection + +**Simulates:** Comparing before/after scans of a construction site + +``` +project new → cloud add (before) → cloud add (after) → distance c2c → export +``` + +### Scenario B: Data Preparation Pipeline + +**Simulates:** Preparing a raw scan for downstream use + +``` +project new → cloud add (raw) → filter-sor → subsample → convert (las→ply) → export +``` + +### Scenario C: ICP Registration + +**Simulates:** Aligning two overlapping scans + +``` +project new → cloud add (A) → cloud add (B) → transform icp → export +``` + +--- + +## Test Results + +**Run date:** 2026-03-28 +**Environment:** Python 3.10.12, pytest 6.2.5, Linux 6.8.0 +**CloudCompare:** v2.13.2 (Flatpak — `flatpak run org.cloudcompare.CloudCompare`) + +### Summary + +| Suite | Tests | Passed | Failed | Duration | +|-------|-------|--------|--------|----------| +| `test_core.py` (unit) | 49 | 49 | 0 | ~4s | +| `test_full_e2e.py` (E2E) | 39 | 39 | 0 | ~43s | +| **Total** | **88** | **88** | **0** | **42.62s** | + +### Full Output + +``` +============================= test session starts ============================== +platform linux -- Python 3.10.12, pytest-6.2.5, py-1.10.0, pluggy-0.13.0 +rootdir: /home/taeyoung/Desktop/mapping_agent/cloudcompare/agent-harness + +cli_anything/cloudcompare/tests/test_core.py::TestCreateProject::test_creates_file PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCreateProject::test_returns_dict_with_expected_keys PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCreateProject::test_uses_provided_name PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCreateProject::test_derives_name_from_filename PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCreateProject::test_written_json_is_valid PASSED +cli_anything/cloudcompare/tests/test_core.py::TestLoadProject::test_loads_existing_project PASSED +cli_anything/cloudcompare/tests/test_core.py::TestLoadProject::test_raises_on_missing_file PASSED +cli_anything/cloudcompare/tests/test_core.py::TestLoadProject::test_raises_on_invalid_json_structure PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSaveProject::test_saves_and_updates_modified_at PASSED +cli_anything/cloudcompare/tests/test_core.py::TestAddCloud::test_adds_cloud_entry PASSED +cli_anything/cloudcompare/tests/test_core.py::TestAddCloud::test_uses_stem_as_default_label PASSED +cli_anything/cloudcompare/tests/test_core.py::TestAddCloud::test_uses_custom_label PASSED +cli_anything/cloudcompare/tests/test_core.py::TestAddCloud::test_raises_on_missing_file PASSED +cli_anything/cloudcompare/tests/test_core.py::TestAddMesh::test_adds_mesh_entry PASSED +cli_anything/cloudcompare/tests/test_core.py::TestAddMesh::test_raises_on_missing_file PASSED +cli_anything/cloudcompare/tests/test_core.py::TestRemoveCloud::test_removes_cloud_by_index PASSED +cli_anything/cloudcompare/tests/test_core.py::TestRemoveCloud::test_raises_on_out_of_range_index PASSED +cli_anything/cloudcompare/tests/test_core.py::TestRemoveCloud::test_raises_on_negative_index PASSED +cli_anything/cloudcompare/tests/test_core.py::TestGetCloud::test_returns_cloud_by_index PASSED +cli_anything/cloudcompare/tests/test_core.py::TestGetCloud::test_raises_on_invalid_index PASSED +cli_anything/cloudcompare/tests/test_core.py::TestProjectInfo::test_returns_summary PASSED +cli_anything/cloudcompare/tests/test_core.py::TestRecordOperation::test_appends_to_history PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_creates_new_project_when_missing PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_loads_existing_project PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_cloud_count_increments PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_mesh_count_increments PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_is_modified_after_add PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_is_not_modified_after_save PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_remove_cloud PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_get_cloud PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_history_recording PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_history_last_n PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_undo_last_removes_entry PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_undo_last_returns_none_when_empty PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_set_export_format PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_set_export_format_ignores_none PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_status_dict_keys PASSED +cli_anything/cloudcompare/tests/test_core.py::TestSession::test_repr_contains_path PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCCBackendConstants::test_cloud_formats_has_las PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCCBackendConstants::test_mesh_formats_has_obj PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCCBackendConstants::test_find_cloudcompare_raises_when_not_found PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCoordToSFValidation::test_invalid_dimension_raises PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCoordToSFValidation::test_invalid_dimension_in_filter_raises PASSED +cli_anything/cloudcompare/tests/test_core.py::TestNoiseFilterImport::test_noise_filter_importable PASSED +cli_anything/cloudcompare/tests/test_core.py::TestNoiseFilterImport::test_noise_filter_knn_mode PASSED +cli_anything/cloudcompare/tests/test_core.py::TestNoiseFilterImport::test_noise_filter_radius_mode PASSED +cli_anything/cloudcompare/tests/test_core.py::TestNoiseFilterImport::test_color_filter_removed PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCSFFilterValidation::test_invalid_scene_raises PASSED +cli_anything/cloudcompare/tests/test_core.py::TestCSFFilterValidation::test_valid_scenes_accepted PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestBackendAvailability::test_cloudcompare_is_installed PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestFormatConversion::test_xyz_to_ply PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestFormatConversion::test_xyz_to_las PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestSubsampling::test_spatial_subsample PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestSubsampling::test_random_subsample PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestSORFilter::test_sor_removes_outliers PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCSFFilter::test_csf_extracts_ground PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCSFFilter::test_csf_exports_both_layers PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCSFFilter::test_csf_cli_command PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestSFColorOps::test_sf_to_rgb PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestSFColorOps::test_rgb_to_sf PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestNoisePCLFilter::test_noise_filter_knn PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestNoisePCLFilter::test_noise_filter_radius PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestNoisePCLFilter::test_noise_filter_absolute PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestNormalsOps::test_invert_normals PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestDelaunayMesh::test_delaunay_creates_mesh PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestDelaunayMesh::test_delaunay_best_fit PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestDelaunayMesh::test_sample_mesh PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestApplyTransform::test_apply_identity_matrix PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestApplyTransform::test_apply_translation_matrix PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestSegmentCC::test_extract_two_components PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestExportPipeline::test_export_cloud_to_las PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestExportPipeline::test_export_cloud_to_ply PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestExportPipeline::test_export_raises_on_no_overwrite PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestExportPipeline::test_list_presets PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestProjectWorkflow::test_full_project_lifecycle PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_help PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_info_json PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_project_new_creates_file PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_project_info_json PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_cloud_add_and_list PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_export_formats_json PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_session_history_json PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_full_subsample_workflow PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_full_export_workflow PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_project_status_json PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_cloud_invert_normals_workflow PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_cloud_mesh_delaunay_workflow PASSED +cli_anything/cloudcompare/tests/test_full_e2e.py::TestCLISubprocess::test_transform_apply_workflow PASSED + +============================== 88 passed in 42.62s ============================= +``` + +### Notes + +- `TestSORFilter::test_sor_removes_outliers`: CloudCompare appends a scalar field (deviation) + column to ASC/XYZ output, making filtered files larger per-point in bytes even with fewer + points. Test verifies point reduction via line count rather than file size. +- `TestCSFFilter`: CSF plugin (`libQCSF_PLUGIN.so`) is included in the Flatpak build. + `-C_EXPORT_FMT` must precede `-CSF` in the argument list so the export format is set + before CSF internally calls `exportEntity()`. Output filenames are auto-generated by CC + as `{stem}_ground_points.{ext}` and detected via glob. +- `TestSFColorOps`: `-SF_CONVERT_TO_RGB` requires a boolean argument `TRUE` or `FALSE` + after the command name (not documented; discovered by running CC and reading stdout). + `rgb_to_sf` (`-RGB_CONVERT_TO_SF`) takes no extra argument. +- `TestNoisePCLFilter`: CloudCompare's CLI has no Gaussian/Bilateral spatial smoothing + command. The original `-FILTER` command does not exist in v2.13.2. The PCL wrapper + plugin's `-NOISE KNN {n} REL/ABS {noisiness}` command is the closest available + spatial noise-removal operation via CLI. `color_filter()` was replaced by `noise_filter()`. +- `TestDelaunayMesh::test_sample_mesh`: `-SAMPLE_MESH` requires a mode keyword before the + count: `-SAMPLE_MESH DENSITY {n}` or `-SAMPLE_MESH POINTS {n}`. Bare integer is invalid. +- `TestSegmentCC`: `-EXTRACT_CC` saves component files to the input file's directory (not + cwd). Files are named `{stem}_COMPONENT_{n}.{ext}` (not `{stem}_CC_{n}`). When + `output_fmt="xyz"` the CC format is `ASC` and files are saved with `.asc` extension; + glob uses the actual CC format extension via an internal `_fmt_to_ext` mapping. +- `get_version()` uses `flatpak info org.cloudcompare.CloudCompare` instead of `--version` + (CloudCompare does not support a `--version` flag). +- All subprocess tests run against the installed `cli-anything-cloudcompare` binary + (`CLI_ANYTHING_FORCE_INSTALLED=1`), resolving to `/home/taeyoung/.local/bin/cli-anything-cloudcompare`. diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/__init__.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_core.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_core.py new file mode 100644 index 000000000..566f9b292 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_core.py @@ -0,0 +1,434 @@ +"""Unit tests for cli-anything-cloudcompare core modules. + +Tests use synthetic data only — no CloudCompare installation required. +""" + +import json +import os +import sys +import tempfile + +import pytest + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + +@pytest.fixture +def tmp_dir(tmp_path): + return str(tmp_path) + + +@pytest.fixture +def project_path(tmp_dir): + return os.path.join(tmp_dir, "test_project.json") + + +@pytest.fixture +def dummy_cloud_file(tmp_dir): + """Create a minimal XYZ cloud file.""" + path = os.path.join(tmp_dir, "cloud.xyz") + with open(path, "w") as f: + f.write("0.0 0.0 0.0\n1.0 0.0 0.0\n2.0 0.0 0.0\n") + return path + + +@pytest.fixture +def dummy_mesh_file(tmp_dir): + """Create a minimal OBJ mesh file.""" + path = os.path.join(tmp_dir, "mesh.obj") + with open(path, "w") as f: + f.write("v 0.0 0.0 0.0\nv 1.0 0.0 0.0\nv 0.0 1.0 0.0\nf 1 2 3\n") + return path + + +# ── Tests: core/project.py ──────────────────────────────────────────────────── + +class TestCreateProject: + def test_creates_file(self, project_path): + from cli_anything.cloudcompare.core.project import create_project + proj = create_project(project_path) + assert os.path.exists(project_path) + + def test_returns_dict_with_expected_keys(self, project_path): + from cli_anything.cloudcompare.core.project import create_project + proj = create_project(project_path) + assert "version" in proj + assert "clouds" in proj + assert "meshes" in proj + assert "settings" in proj + assert "history" in proj + assert proj["clouds"] == [] + assert proj["meshes"] == [] + + def test_uses_provided_name(self, project_path): + from cli_anything.cloudcompare.core.project import create_project + proj = create_project(project_path, name="MySurvey") + assert proj["name"] == "MySurvey" + + def test_derives_name_from_filename(self, tmp_dir): + from cli_anything.cloudcompare.core.project import create_project + path = os.path.join(tmp_dir, "my_scan.json") + proj = create_project(path) + assert proj["name"] == "my_scan" + + def test_written_json_is_valid(self, project_path): + from cli_anything.cloudcompare.core.project import create_project + create_project(project_path) + with open(project_path) as f: + data = json.load(f) + assert data["version"] == "1.0" + + +class TestLoadProject: + def test_loads_existing_project(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, load_project + create_project(project_path) + proj = load_project(project_path) + assert proj["version"] == "1.0" + + def test_raises_on_missing_file(self, tmp_dir): + from cli_anything.cloudcompare.core.project import load_project + with pytest.raises(FileNotFoundError): + load_project(os.path.join(tmp_dir, "nonexistent.json")) + + def test_raises_on_invalid_json_structure(self, tmp_dir): + from cli_anything.cloudcompare.core.project import load_project + path = os.path.join(tmp_dir, "bad.json") + with open(path, "w") as f: + json.dump({"foo": "bar"}, f) + with pytest.raises(ValueError): + load_project(path) + + +class TestSaveProject: + def test_saves_and_updates_modified_at(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, save_project, load_project + proj = create_project(project_path) + original_ts = proj["modified_at"] + import time; time.sleep(1) + save_project(proj, project_path) + reloaded = load_project(project_path) + # modified_at may or may not change within same second, just check it's present + assert "modified_at" in reloaded + + +class TestAddCloud: + def test_adds_cloud_entry(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.project import create_project, add_cloud + proj = create_project(project_path) + entry = add_cloud(proj, dummy_cloud_file) + assert len(proj["clouds"]) == 1 + assert entry["path"] == os.path.abspath(dummy_cloud_file) + + def test_uses_stem_as_default_label(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.project import create_project, add_cloud + proj = create_project(project_path) + entry = add_cloud(proj, dummy_cloud_file) + assert entry["label"] == "cloud" + + def test_uses_custom_label(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.project import create_project, add_cloud + proj = create_project(project_path) + entry = add_cloud(proj, dummy_cloud_file, label="scan_A") + assert entry["label"] == "scan_A" + + def test_raises_on_missing_file(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, add_cloud + proj = create_project(project_path) + with pytest.raises(FileNotFoundError): + add_cloud(proj, "/nonexistent/cloud.las") + + +class TestAddMesh: + def test_adds_mesh_entry(self, project_path, dummy_mesh_file): + from cli_anything.cloudcompare.core.project import create_project, add_mesh + proj = create_project(project_path) + entry = add_mesh(proj, dummy_mesh_file) + assert len(proj["meshes"]) == 1 + assert entry["path"] == os.path.abspath(dummy_mesh_file) + + def test_raises_on_missing_file(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, add_mesh + proj = create_project(project_path) + with pytest.raises(FileNotFoundError): + add_mesh(proj, "/nonexistent/mesh.obj") + + +class TestRemoveCloud: + def test_removes_cloud_by_index(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.project import create_project, add_cloud, remove_cloud + proj = create_project(project_path) + add_cloud(proj, dummy_cloud_file) + removed = remove_cloud(proj, 0) + assert len(proj["clouds"]) == 0 + assert removed["path"] == os.path.abspath(dummy_cloud_file) + + def test_raises_on_out_of_range_index(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, remove_cloud + proj = create_project(project_path) + with pytest.raises(IndexError): + remove_cloud(proj, 0) + + def test_raises_on_negative_index(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.project import create_project, add_cloud, remove_cloud + proj = create_project(project_path) + add_cloud(proj, dummy_cloud_file) + with pytest.raises(IndexError): + remove_cloud(proj, -1) + + +class TestGetCloud: + def test_returns_cloud_by_index(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.project import create_project, add_cloud, get_cloud + proj = create_project(project_path) + add_cloud(proj, dummy_cloud_file, label="my_scan") + entry = get_cloud(proj, 0) + assert entry["label"] == "my_scan" + + def test_raises_on_invalid_index(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, get_cloud + proj = create_project(project_path) + with pytest.raises(IndexError): + get_cloud(proj, 99) + + +class TestProjectInfo: + def test_returns_summary(self, project_path, dummy_cloud_file, dummy_mesh_file): + from cli_anything.cloudcompare.core.project import ( + create_project, add_cloud, add_mesh, project_info + ) + proj = create_project(project_path, name="TestProj") + add_cloud(proj, dummy_cloud_file) + add_mesh(proj, dummy_mesh_file) + info = project_info(proj) + assert info["name"] == "TestProj" + assert info["cloud_count"] == 1 + assert info["mesh_count"] == 1 + assert len(info["clouds"]) == 1 + assert info["clouds"][0]["index"] == 0 + + +class TestRecordOperation: + def test_appends_to_history(self, project_path): + from cli_anything.cloudcompare.core.project import create_project, record_operation + proj = create_project(project_path) + record_operation(proj, "subsample", ["in.las"], ["out.las"], {"method": "SPATIAL"}) + assert len(proj["history"]) == 1 + assert proj["history"][0]["operation"] == "subsample" + assert proj["history"][0]["params"]["method"] == "SPATIAL" + + +# ── Tests: core/session.py ──────────────────────────────────────────────────── + +class TestSession: + def test_creates_new_project_when_missing(self, tmp_dir): + from cli_anything.cloudcompare.core.session import Session + path = os.path.join(tmp_dir, "new.json") + assert not os.path.exists(path) + s = Session(path) + # Session creates project in memory; save() to persist + s.save() + assert os.path.exists(path) + + def test_loads_existing_project(self, project_path): + from cli_anything.cloudcompare.core.project import create_project + from cli_anything.cloudcompare.core.session import Session + create_project(project_path, name="Loaded") + s = Session(project_path) + assert s.name == "Loaded" + + def test_cloud_count_increments(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + assert s.cloud_count == 0 + s.add_cloud(dummy_cloud_file) + assert s.cloud_count == 1 + + def test_mesh_count_increments(self, project_path, dummy_mesh_file): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + assert s.mesh_count == 0 + s.add_mesh(dummy_mesh_file) + assert s.mesh_count == 1 + + def test_is_modified_after_add(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + assert not s.is_modified + s.add_cloud(dummy_cloud_file) + assert s.is_modified + + def test_is_not_modified_after_save(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + s.add_cloud(dummy_cloud_file) + s.save() + assert not s.is_modified + + def test_remove_cloud(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + s.add_cloud(dummy_cloud_file) + removed = s.remove_cloud(0) + assert s.cloud_count == 0 + assert "path" in removed + + def test_get_cloud(self, project_path, dummy_cloud_file): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + s.add_cloud(dummy_cloud_file, label="scan_a") + entry = s.get_cloud(0) + assert entry["label"] == "scan_a" + + def test_history_recording(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + s.record("test_op", ["a.las"], ["b.las"], {"key": "val"}) + hist = s.history() + assert len(hist) == 1 + assert hist[0]["operation"] == "test_op" + + def test_history_last_n(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + for i in range(5): + s.record(f"op_{i}", [], [], {}) + assert len(s.history(3)) == 3 + assert len(s.history(10)) == 5 + + def test_undo_last_removes_entry(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + s.record("op_1", [], [], {}) + s.record("op_2", [], [], {}) + removed = s.undo_last() + assert removed["operation"] == "op_2" + assert len(s.history()) == 1 + + def test_undo_last_returns_none_when_empty(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + result = s.undo_last() + assert result is None + + def test_set_export_format(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + s.set_export_format(cloud_fmt="PLY", cloud_ext="ply") + settings = s.get_settings() + assert settings["cloud_export_format"] == "PLY" + assert settings["cloud_export_ext"] == "ply" + + def test_set_export_format_ignores_none(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + original_mesh_fmt = s.get_settings().get("mesh_export_format") + s.set_export_format(cloud_fmt="PLY") # mesh args are None + settings = s.get_settings() + assert settings["cloud_export_format"] == "PLY" + # mesh format unchanged + assert settings.get("mesh_export_format") == original_mesh_fmt + + def test_status_dict_keys(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + status = s.status() + assert "project" in status + assert "clouds" in status + assert "meshes" in status + assert "modified" in status + + def test_repr_contains_path(self, project_path): + from cli_anything.cloudcompare.core.session import Session + s = Session(project_path) + assert "Session(" in repr(s) + + +# ── Tests: utils/cc_backend.py (pure logic) ──────────────────────────────────── + +class TestCCBackendConstants: + def test_cloud_formats_has_las(self): + from cli_anything.cloudcompare.utils.cc_backend import CLOUD_FORMATS + assert "las" in CLOUD_FORMATS + assert "laz" in CLOUD_FORMATS + assert "ply" in CLOUD_FORMATS + assert "xyz" in CLOUD_FORMATS + + def test_mesh_formats_has_obj(self): + from cli_anything.cloudcompare.utils.cc_backend import MESH_FORMATS + assert "obj" in MESH_FORMATS + assert "stl" in MESH_FORMATS + + def test_find_cloudcompare_raises_when_not_found(self, monkeypatch): + """When CloudCompare is absent, should raise RuntimeError with install hint.""" + import shutil + import subprocess + from cli_anything.cloudcompare.utils import cc_backend + + # Patch shutil.which to return None for all binaries + monkeypatch.setattr(shutil, "which", lambda x: None) + # Patch subprocess.run to simulate empty flatpak list + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: type('R', (), {'stdout': '', 'stderr': ''})()) + # Patch os.path.exists to return False for snap path + monkeypatch.setattr(os.path, "exists", lambda p: False) + + with pytest.raises(RuntimeError, match="CloudCompare is not installed"): + cc_backend.find_cloudcompare() + + +class TestCoordToSFValidation: + def test_invalid_dimension_raises(self): + from cli_anything.cloudcompare.utils.cc_backend import coord_to_sf + with pytest.raises(ValueError, match="dimension must be"): + coord_to_sf("/nonexistent.las", "/out.las", dimension="W") + + def test_invalid_dimension_in_filter_raises(self): + from cli_anything.cloudcompare.utils.cc_backend import coord_to_sf_and_filter + with pytest.raises(ValueError, match="dimension must be"): + coord_to_sf_and_filter("/nonexistent.las", "/out.las", dimension="Q") + + +class TestNoiseFilterImport: + def test_noise_filter_importable(self): + """noise_filter replaces color_filter (no -FILTER command exists in CC CLI).""" + from cli_anything.cloudcompare.utils.cc_backend import noise_filter + assert callable(noise_filter) + + def test_noise_filter_knn_mode(self): + """noise_filter KNN mode should not raise ValueError.""" + from cli_anything.cloudcompare.utils.cc_backend import noise_filter + try: + noise_filter("/nonexistent.xyz", "/out.xyz", knn=6, noisiness=1.0) + except RuntimeError: + pass # CC not found — acceptable; no ValueError expected + + def test_noise_filter_radius_mode(self): + """noise_filter RADIUS mode should not raise ValueError.""" + from cli_anything.cloudcompare.utils.cc_backend import noise_filter + try: + noise_filter("/nonexistent.xyz", "/out.xyz", + use_radius=True, radius=0.2, absolute=True) + except RuntimeError: + pass + + def test_color_filter_removed(self): + """color_filter must no longer exist (backed a non-existent CC command).""" + import cli_anything.cloudcompare.utils.cc_backend as backend + assert not hasattr(backend, "color_filter") + + +class TestCSFFilterValidation: + def test_invalid_scene_raises(self): + from cli_anything.cloudcompare.utils.cc_backend import csf_filter + with pytest.raises(ValueError, match="scene must be"): + csf_filter("/nonexistent.las", "/out.las", scene="OCEAN") + + def test_valid_scenes_accepted(self): + """Valid scene names should not raise at the validation stage.""" + from cli_anything.cloudcompare.utils.cc_backend import csf_filter, find_cloudcompare + for scene in ("SLOPE", "RELIEF", "FLAT"): + try: + # Will fail because /nonexistent.las doesn't exist, but NOT + # because of invalid scene — the ValueError is raised before CC runs. + csf_filter("/nonexistent.las", "/out.las", scene=scene) + except ValueError: + pytest.fail(f"scene={scene!r} incorrectly raised ValueError") diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_full_e2e.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_full_e2e.py new file mode 100644 index 000000000..731d43d57 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/tests/test_full_e2e.py @@ -0,0 +1,815 @@ +"""E2E tests for cli-anything-cloudcompare. + +These tests invoke the REAL CloudCompare binary (Flatpak or native). +CloudCompare MUST be installed — tests will fail, not skip, if absent. + +Run with: + python3 -m pytest cli_anything/cloudcompare/tests/test_full_e2e.py -v -s + +Run against the installed CLI command: + CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/cloudcompare/tests/test_full_e2e.py -v -s +""" + +import json +import os +import subprocess +import sys +import tempfile + +import pytest + + +# ── CLI resolver (follows HARNESS.md spec) ───────────────────────────────────── + +def _resolve_cli(name: str) -> list[str]: + """Resolve installed CLI command; falls back to python -m for dev. + + Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command. + """ + import shutil + force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1" + path = shutil.which(name) + if path: + print(f"\n[_resolve_cli] Using installed command: {path}") + return [path] + if force: + raise RuntimeError(f"{name} not found in PATH. Install with: pip install -e .") + module = "cli_anything.cloudcompare.cloudcompare_cli" + print(f"\n[_resolve_cli] Falling back to: {sys.executable} -m {module}") + return [sys.executable, "-m", module] + + +# ── Cloud file generator ─────────────────────────────────────────────────────── + +def _make_xyz_cloud(path: str, n_points: int = 200, add_noise: bool = False) -> str: + """Generate a synthetic XYZ cloud file. + + Creates points on a flat plane (z=0) with optional outlier noise. + Format: x y z (space separated, no header) — CloudCompare ASC format. + """ + import math + import random + random.seed(42) + + grid = int(math.sqrt(n_points)) + with open(path, "w") as f: + for i in range(grid): + for j in range(grid): + x = i * 0.1 + y = j * 0.1 + z = 0.0 + random.uniform(-0.001, 0.001) # nearly flat + f.write(f"{x:.6f} {y:.6f} {z:.6f}\n") + + if add_noise: + # Add outlier points far from the plane + for _ in range(10): + x = random.uniform(0, 1) + y = random.uniform(0, 1) + z = random.uniform(10, 20) # way above the plane + f.write(f"{x:.6f} {y:.6f} {z:.6f}\n") + return path + + +# ── Fixtures ─────────────────────────────────────────────────────────────────── + +@pytest.fixture +def tmp_dir(tmp_path): + return str(tmp_path) + + +@pytest.fixture +def cloud_xyz(tmp_dir): + """A small synthetic point cloud (XYZ format).""" + path = os.path.join(tmp_dir, "cloud.xyz") + _make_xyz_cloud(path, n_points=225) + print(f"\n Input cloud: {path}") + return path + + +@pytest.fixture +def noisy_cloud_xyz(tmp_dir): + """A cloud with outlier noise points.""" + path = os.path.join(tmp_dir, "noisy_cloud.xyz") + _make_xyz_cloud(path, n_points=100, add_noise=True) + return path + + +@pytest.fixture +def project_json(tmp_dir): + return os.path.join(tmp_dir, "test.json") + + +# ── E2E: Backend tests ───────────────────────────────────────────────────────── + +class TestBackendAvailability: + def test_cloudcompare_is_installed(self): + """CloudCompare MUST be installed — this test will fail if not.""" + from cli_anything.cloudcompare.utils.cc_backend import find_cloudcompare, is_available + assert is_available(), ( + "CloudCompare is not installed!\n" + "Install with: flatpak install flathub org.cloudcompare.CloudCompare" + ) + cmd = find_cloudcompare() + assert len(cmd) > 0 + print(f"\n CloudCompare command: {' '.join(cmd)}") + + +class TestFormatConversion: + def test_xyz_to_ply(self, tmp_dir, cloud_xyz): + """Convert XYZ cloud to PLY format via CloudCompare.""" + from cli_anything.cloudcompare.utils.cc_backend import convert_format + + output_path = os.path.join(tmp_dir, "output.ply") + result = convert_format(cloud_xyz, output_path) + + assert result["returncode"] == 0, ( + f"CloudCompare failed (exit {result['returncode']}):\n{result['stderr'][:800]}" + ) + assert result.get("exists"), f"Output PLY not created: {output_path}" + assert result["file_size"] > 0, "Output PLY is empty" + + # Verify PLY magic bytes + with open(output_path, "rb") as f: + header = f.read(10) + assert header.startswith(b"ply"), f"Output is not a valid PLY file: {header[:10]}" + + print(f"\n PLY output: {output_path} ({result['file_size']:,} bytes)") + + def test_xyz_to_las(self, tmp_dir, cloud_xyz): + """Convert XYZ cloud to LAS format via CloudCompare.""" + from cli_anything.cloudcompare.utils.cc_backend import convert_format + + output_path = os.path.join(tmp_dir, "output.las") + result = convert_format(cloud_xyz, output_path) + + assert result["returncode"] == 0, ( + f"CloudCompare failed:\n{result['stderr'][:800]}" + ) + assert result.get("exists"), f"Output LAS not created: {output_path}" + assert result["file_size"] > 0 + + # Verify LAS magic bytes ("LASF") + with open(output_path, "rb") as f: + magic = f.read(4) + assert magic == b"LASF", f"Output is not a valid LAS file: {magic}" + + print(f"\n LAS output: {output_path} ({result['file_size']:,} bytes)") + + +class TestSubsampling: + def test_spatial_subsample(self, tmp_dir, cloud_xyz): + """Subsample using SPATIAL method.""" + from cli_anything.cloudcompare.utils.cc_backend import subsample + + output_path = os.path.join(tmp_dir, "subsampled_spatial.xyz") + result = subsample(cloud_xyz, output_path, method="SPATIAL", parameter=0.2) + + assert result["returncode"] == 0, ( + f"Subsample failed:\n{result['stderr'][:800]}" + ) + assert result.get("exists"), f"Subsampled cloud not created: {output_path}" + assert result["file_size"] > 0 + + # Spatial subsampling should reduce the cloud + input_size = os.path.getsize(cloud_xyz) + assert result["file_size"] <= input_size * 2 # not much larger than input + + print(f"\n Spatial subsample: {output_path} ({result['file_size']:,} bytes)") + + def test_random_subsample(self, tmp_dir, cloud_xyz): + """Subsample using RANDOM method (keep N points).""" + from cli_anything.cloudcompare.utils.cc_backend import subsample + + output_path = os.path.join(tmp_dir, "subsampled_random.xyz") + result = subsample(cloud_xyz, output_path, method="RANDOM", parameter=50) + + assert result["returncode"] == 0, ( + f"Random subsample failed:\n{result['stderr'][:800]}" + ) + assert result.get("exists"), f"Subsampled cloud not created: {output_path}" + assert result["file_size"] > 0 + + print(f"\n Random subsample: {output_path} ({result['file_size']:,} bytes)") + + +class TestSORFilter: + def test_sor_removes_outliers(self, tmp_dir, noisy_cloud_xyz): + """SOR filter removes outlier noise points.""" + from cli_anything.cloudcompare.utils.cc_backend import sor_filter + + output_path = os.path.join(tmp_dir, "filtered.xyz") + result = sor_filter(noisy_cloud_xyz, output_path, nb_points=6, std_ratio=1.0) + + assert result["returncode"] == 0, ( + f"SOR filter failed:\n{result['stderr'][:800]}" + ) + assert result.get("exists"), f"Filtered cloud not created: {output_path}" + assert result["file_size"] > 0 + + # Compare point counts (line counts in XYZ/ASC format). + # NOTE: file size comparison is unreliable — CloudCompare appends a + # scalar field (deviation) column to ASC output, making the filtered + # file larger per-point even though it has fewer points. + with open(noisy_cloud_xyz) as f: + input_count = sum(1 for line in f if line.strip()) + with open(output_path) as f: + output_count = sum(1 for line in f if line.strip()) + + # SOR should have removed the 10 outlier noise points + assert output_count < input_count, ( + f"SOR filter did not reduce point count: " + f"input={input_count} pts → output={output_count} pts" + ) + + print(f"\n SOR filtered: {output_path} ({result['file_size']:,} bytes)") + print(f" Points: {input_count} → {output_count} (removed {input_count - output_count})") + + +class TestCSFFilter: + """E2E tests for the Cloth Simulation Filter (CSF) ground extraction.""" + + @pytest.fixture + def scene_cloud(self, tmp_dir): + """Synthetic scene: 100 ground points (z≈0) + 25 elevated points (z=5).""" + path = os.path.join(tmp_dir, "scene.xyz") + with open(path, "w") as f: + for i in range(10): + for j in range(10): + f.write(f"{i * 0.5:.1f} {j * 0.5:.1f} 0.0\n") + for i in range(5): + for j in range(5): + f.write(f"{i * 0.5:.1f} {j * 0.5:.1f} 5.0\n") + return path + + def test_csf_extracts_ground(self, tmp_dir, scene_cloud): + """CSF filter produces a ground cloud.""" + from cli_anything.cloudcompare.utils.cc_backend import csf_filter + + ground_out = os.path.join(tmp_dir, "ground.las") + result = csf_filter(scene_cloud, ground_out, scene="FLAT", + cloth_resolution=0.5, class_threshold=0.3) + + assert result["returncode"] == 0, f"CSF failed:\n{result['stderr'][:400]}" + assert result["ground_exists"], "Ground cloud not created" + assert result["ground_size"] > 0 + print(f"\n CSF ground: {ground_out} ({result['ground_size']:,} bytes)") + + def test_csf_exports_both_layers(self, tmp_dir, scene_cloud): + """CSF filter exports both ground and off-ground clouds.""" + from cli_anything.cloudcompare.utils.cc_backend import csf_filter + + ground_out = os.path.join(tmp_dir, "ground.las") + offground_out = os.path.join(tmp_dir, "offground.las") + result = csf_filter(scene_cloud, ground_out, offground_out, + scene="FLAT", cloth_resolution=0.5, class_threshold=0.3) + + assert result["returncode"] == 0 + assert result["ground_exists"] + assert result["offground_exists"] + assert result["ground_size"] > 0 + assert result["offground_size"] > 0 + + # Ground + offground must account for all input points + def _las_point_count(p): + """Read POINTS count from a LAS/PCD/ASC file (best-effort).""" + import struct + with open(p, "rb") as f: + header = f.read(375) + # LAS 1.x: point count at offset 107 (uint32) + try: + return struct.unpack_from(" 0 + print(f"\n CLI CSF → {data['ground_size']:,} bytes") + + +class TestSFColorOps: + """Tests for scalar-field ↔ RGB colour conversion.""" + + @pytest.fixture + def cloud_with_sf(self, tmp_dir, cloud_xyz): + """Cloud with Z as active scalar field (PLY preserves SF).""" + from cli_anything.cloudcompare.utils.cc_backend import coord_to_sf + out = os.path.join(tmp_dir, "cloud_sf.ply") + result = coord_to_sf(cloud_xyz, out, dimension="Z") + assert result["returncode"] == 0, result["stderr"][:300] + return out + + def test_sf_to_rgb(self, tmp_dir, cloud_with_sf): + from cli_anything.cloudcompare.utils.cc_backend import sf_to_rgb + out = os.path.join(tmp_dir, "cloud_rgb.ply") + result = sf_to_rgb(cloud_with_sf, out) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "sf_to_rgb produced no output" + assert result["file_size"] > 0 + print(f"\n SF→RGB: {out} ({result['file_size']:,} bytes)") + + def test_rgb_to_sf(self, tmp_dir, cloud_with_sf): + """Round-trip: SF→RGB then RGB→SF.""" + from cli_anything.cloudcompare.utils.cc_backend import sf_to_rgb, rgb_to_sf + rgb_out = os.path.join(tmp_dir, "cloud_rgb2.ply") + sf_out = os.path.join(tmp_dir, "cloud_sf2.ply") + r1 = sf_to_rgb(cloud_with_sf, rgb_out) + assert r1["returncode"] == 0 + r2 = rgb_to_sf(rgb_out, sf_out) + assert r2["returncode"] == 0, r2["stderr"][:300] + assert r2.get("exists"), "rgb_to_sf produced no output" + print(f"\n RGB→SF: {sf_out} ({r2['file_size']:,} bytes)") + + +class TestNoisePCLFilter: + """Tests for the PCL noise filter (-NOISE KNN/RADIUS REL/ABS). + + Note: CloudCompare's CLI does not expose Gaussian/Bilateral spatial + smoothing. The -NOISE command (PCL wrapper plugin) is the closest + equivalent for noise removal available via the command line. + """ + + def test_noise_filter_knn(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import noise_filter + out = os.path.join(tmp_dir, "denoised_knn.xyz") + result = noise_filter(cloud_xyz, out, knn=6, noisiness=1.0) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "noise_filter (KNN) produced no output" + assert result["file_size"] > 0 + print(f"\n Noise(KNN): {out} ({result['file_size']:,} bytes)") + + def test_noise_filter_radius(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import noise_filter + out = os.path.join(tmp_dir, "denoised_radius.xyz") + result = noise_filter(cloud_xyz, out, use_radius=True, radius=0.2, noisiness=1.0) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "noise_filter (RADIUS) produced no output" + print(f"\n Noise(RADIUS): {out} ({result['file_size']:,} bytes)") + + def test_noise_filter_absolute(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import noise_filter + out = os.path.join(tmp_dir, "denoised_abs.xyz") + result = noise_filter(cloud_xyz, out, knn=6, noisiness=0.05, absolute=True) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "noise_filter (ABS) produced no output" + print(f"\n Noise(ABS): {out} ({result['file_size']:,} bytes)") + + +class TestNormalsOps: + """Tests for normal computation and inversion.""" + + def test_invert_normals(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import compute_normals, invert_normals + with_normals = os.path.join(tmp_dir, "normals.ply") + inverted = os.path.join(tmp_dir, "normals_inv.ply") + r1 = compute_normals(cloud_xyz, with_normals, octree_level=8) + assert r1["returncode"] == 0, r1["stderr"][:300] + r2 = invert_normals(with_normals, inverted) + assert r2["returncode"] == 0, r2["stderr"][:300] + assert r2.get("exists"), "invert_normals produced no output" + print(f"\n Inverted normals: {inverted} ({r2['file_size']:,} bytes)") + + +class TestDelaunayMesh: + """Tests for Delaunay 2.5-D mesh generation and mesh sampling.""" + + def test_delaunay_creates_mesh(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import delaunay_mesh + out = os.path.join(tmp_dir, "terrain.obj") + result = delaunay_mesh(cloud_xyz, out, axis_aligned=True) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "delaunay_mesh produced no output" + assert result["file_size"] > 0 + print(f"\n Delaunay mesh: {out} ({result['file_size']:,} bytes)") + + def test_delaunay_best_fit(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import delaunay_mesh + out = os.path.join(tmp_dir, "terrain_bf.obj") + result = delaunay_mesh(cloud_xyz, out, axis_aligned=False) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "delaunay best-fit produced no output" + + def test_sample_mesh(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import delaunay_mesh, sample_mesh + mesh_out = os.path.join(tmp_dir, "terrain.obj") + sample_out = os.path.join(tmp_dir, "sampled.xyz") + r1 = delaunay_mesh(cloud_xyz, mesh_out) + assert r1["returncode"] == 0, r1["stderr"][:300] + r2 = sample_mesh(mesh_out, sample_out, count=500) + assert r2["returncode"] == 0, r2["stderr"][:300] + assert r2.get("exists"), "sample_mesh produced no output" + print(f"\n Sampled {500} pts → {sample_out} ({r2['file_size']:,} bytes)") + + +class TestApplyTransform: + """Tests for -APPLY_TRANS (rigid-body transformation).""" + + def test_apply_identity_matrix(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import apply_transform + mat_file = os.path.join(tmp_dir, "identity.txt") + with open(mat_file, "w") as f: + f.write("1 0 0 0\n0 1 0 0\n0 0 1 0\n0 0 0 1\n") + out = os.path.join(tmp_dir, "transformed.xyz") + result = apply_transform(cloud_xyz, out, mat_file) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "apply_transform produced no output" + print(f"\n Transformed cloud: {out} ({result['file_size']:,} bytes)") + + def test_apply_translation_matrix(self, tmp_dir, cloud_xyz): + from cli_anything.cloudcompare.utils.cc_backend import apply_transform + mat_file = os.path.join(tmp_dir, "translate.txt") + with open(mat_file, "w") as f: + f.write("1 0 0 5\n0 1 0 0\n0 0 1 0\n0 0 0 1\n") # translate X by 5 + out = os.path.join(tmp_dir, "translated.xyz") + result = apply_transform(cloud_xyz, out, mat_file) + assert result["returncode"] == 0, result["stderr"][:300] + assert result.get("exists"), "Translation transform produced no output" + + +class TestSegmentCC: + """Tests for connected-component segmentation (EXTRACT_CC).""" + + @pytest.fixture + def two_cluster_cloud(self, tmp_dir): + """Two spatially separated clusters of 50 points each.""" + import random + random.seed(7) + path = os.path.join(tmp_dir, "two_clusters.xyz") + with open(path, "w") as f: + # Cluster A near origin + for _ in range(50): + x = random.uniform(0.0, 0.1) + y = random.uniform(0.0, 0.1) + z = random.uniform(0.0, 0.1) + f.write(f"{x:.6f} {y:.6f} {z:.6f}\n") + # Cluster B far away + for _ in range(50): + x = random.uniform(10.0, 10.1) + y = random.uniform(10.0, 10.1) + z = random.uniform(10.0, 10.1) + f.write(f"{x:.6f} {y:.6f} {z:.6f}\n") + return path + + def test_extract_two_components(self, tmp_dir, two_cluster_cloud): + from cli_anything.cloudcompare.utils.cc_backend import extract_connected_components + out_dir = os.path.join(tmp_dir, "components") + result = extract_connected_components( + two_cluster_cloud, out_dir, + octree_level=8, min_points=10, output_fmt="xyz", + ) + assert result["returncode"] == 0, result["stderr"][:300] + assert result["component_count"] >= 2, ( + f"Expected ≥2 components, got {result['component_count']}.\n" + f"Components: {result['components']}" + ) + print(f"\n CC segments: {result['component_count']} components in {out_dir}") + + +class TestExportPipeline: + def test_export_cloud_to_las(self, tmp_dir, cloud_xyz): + """Export a cloud to LAS using the export module.""" + from cli_anything.cloudcompare.core.export import export_cloud + + output_path = os.path.join(tmp_dir, "exported.las") + result = export_cloud(cloud_xyz, output_path, preset="las", overwrite=True) + + assert result["format"] == "LAS" + assert result["file_size"] > 0 + assert os.path.exists(result["output"]) + + with open(result["output"], "rb") as f: + assert f.read(4) == b"LASF" + + print(f"\n Export LAS: {result['output']} ({result['file_size']:,} bytes)") + + def test_export_cloud_to_ply(self, tmp_dir, cloud_xyz): + """Export a cloud to PLY using the export module.""" + from cli_anything.cloudcompare.core.export import export_cloud + + output_path = os.path.join(tmp_dir, "exported.ply") + result = export_cloud(cloud_xyz, output_path, preset="ply", overwrite=True) + + assert result["format"] == "PLY" + assert result["file_size"] > 0 + + with open(result["output"], "rb") as f: + assert f.read(3) == b"ply" + + print(f"\n Export PLY: {result['output']} ({result['file_size']:,} bytes)") + + def test_export_raises_on_no_overwrite(self, tmp_dir, cloud_xyz): + """Export raises FileExistsError when output exists and overwrite=False.""" + from cli_anything.cloudcompare.core.export import export_cloud + + output_path = os.path.join(tmp_dir, "exported_noover.las") + # First export + export_cloud(cloud_xyz, output_path, preset="las", overwrite=True) + # Second export without overwrite should raise + with pytest.raises(FileExistsError): + export_cloud(cloud_xyz, output_path, preset="las", overwrite=False) + + def test_list_presets(self): + """list_presets returns cloud and mesh format dicts.""" + from cli_anything.cloudcompare.core.export import list_presets + presets = list_presets() + assert "cloud" in presets + assert "mesh" in presets + assert "las" in presets["cloud"] + assert "obj" in presets["mesh"] + + +class TestProjectWorkflow: + def test_full_project_lifecycle(self, tmp_dir, cloud_xyz): + """Create project → add cloud → subsample → save → reload.""" + from cli_anything.cloudcompare.core.session import Session + from cli_anything.cloudcompare.utils.cc_backend import subsample + + proj_path = os.path.join(tmp_dir, "workflow.json") + output_cloud = os.path.join(tmp_dir, "subsampled.xyz") + + # 1. Create session and add cloud + s = Session(proj_path) + s.add_cloud(cloud_xyz, label="raw_scan") + s.save() + + # 2. Subsample + cloud_entry = s.get_cloud(0) + result = subsample(cloud_entry["path"], output_cloud, "SPATIAL", 0.2) + assert result["returncode"] == 0 + assert result.get("exists") + + # 3. Add output back to project + s.add_cloud(output_cloud, label="thinned") + s.record("subsample", [cloud_entry["path"]], [output_cloud], + {"method": "SPATIAL", "param": 0.2}) + s.save() + + # 4. Verify persisted state + s2 = Session(proj_path) + assert s2.cloud_count == 2 + assert s2.history(1)[0]["operation"] == "subsample" + print(f"\n Project workflow complete: {proj_path}") + print(f" Subsampled cloud: {output_cloud} ({result['file_size']:,} bytes)") + + +# ── TestCLISubprocess (installed command tests) ──────────────────────────────── + +class TestCLISubprocess: + CLI_BASE = _resolve_cli("cli-anything-cloudcompare") + + def _run(self, args, check=True, allow_fail=False): + result = subprocess.run( + self.CLI_BASE + args, + capture_output=True, + text=True, + ) + if check and not allow_fail and result.returncode != 0: + pytest.fail( + f"CLI failed (exit {result.returncode}):\n" + f" stdout: {result.stdout[:400]}\n" + f" stderr: {result.stderr[:400]}" + ) + return result + + def test_help(self): + """--help exits 0 and shows usage.""" + result = self._run(["--help"]) + assert result.returncode == 0 + assert "cloudcompare" in result.stdout.lower() or "Usage" in result.stdout + + def test_info_json(self): + """info --json returns valid JSON with expected keys.""" + result = self._run(["--json", "info"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "cloudcompare_available" in data + assert "supported_cloud_formats" in data + print(f"\n CloudCompare available: {data['cloudcompare_available']}") + print(f" Command: {data.get('command')}") + + def test_project_new_creates_file(self, tmp_dir): + """project new -o creates a valid JSON project file.""" + proj_path = os.path.join(tmp_dir, "cli_test.json") + result = self._run(["--json", "project", "new", "-o", proj_path]) + assert result.returncode == 0 + assert os.path.exists(proj_path) + data = json.loads(result.stdout) + assert "cloud_count" in data + assert data["cloud_count"] == 0 + print(f"\n Project created: {proj_path}") + + def test_project_info_json(self, tmp_dir): + """project info --json returns JSON with project details.""" + proj_path = os.path.join(tmp_dir, "info_test.json") + # Create project first + self._run(["project", "new", "-o", proj_path]) + # Query info + result = self._run(["--json", "--project", proj_path, "project", "info"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "cloud_count" in data + assert "mesh_count" in data + assert "settings" in data + + def test_cloud_add_and_list(self, tmp_dir, cloud_xyz): + """cloud add adds the cloud; cloud list returns it.""" + proj_path = os.path.join(tmp_dir, "cloud_test.json") + self._run(["project", "new", "-o", proj_path]) + self._run(["--project", proj_path, "cloud", "add", cloud_xyz, "--label", "scan_a"]) + result = self._run(["--json", "--project", proj_path, "cloud", "list"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["count"] == 1 + assert data["clouds"][0]["label"] == "scan_a" + + def test_export_formats_json(self): + """export formats --json returns cloud and mesh format lists.""" + result = self._run(["--json", "export", "formats"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "cloud" in data + assert "mesh" in data + assert "las" in data["cloud"] + assert "obj" in data["mesh"] + + def test_session_history_json(self, tmp_dir): + """session history --json returns JSON history list.""" + proj_path = os.path.join(tmp_dir, "hist_test.json") + self._run(["project", "new", "-o", proj_path]) + result = self._run(["--json", "--project", proj_path, "session", "history"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "history" in data + assert "count" in data + + def test_full_subsample_workflow(self, tmp_dir, cloud_xyz): + """Full workflow: create project → add cloud → subsample → verify output.""" + proj_path = os.path.join(tmp_dir, "subsample_wf.json") + out_cloud = os.path.join(tmp_dir, "subsampled.las") + + # Create project + self._run(["project", "new", "-o", proj_path]) + + # Add cloud + self._run(["--project", proj_path, "cloud", "add", cloud_xyz]) + + # Subsample via CloudCompare + result = self._run([ + "--json", "--project", proj_path, + "cloud", "subsample", "0", + "-o", out_cloud, + "--method", "SPATIAL", + "--param", "0.15", + "--add-to-project", + ]) + assert result.returncode == 0 + data = json.loads(result.stdout) + + assert data.get("exists") is True, ( + f"CloudCompare did not produce output.\n" + f"Result: {json.dumps(data, indent=2)}" + ) + assert os.path.exists(data["output"]), f"Output file missing: {data['output']}" + + # Verify LAS magic bytes + with open(data["output"], "rb") as f: + magic = f.read(4) + assert magic == b"LASF", f"Output is not valid LAS: {magic}" + + print(f"\n Subsampled cloud: {data['output']}") + print(f" File size: {data.get('file_size', '?'):,} bytes") + + # Verify project was updated (--add-to-project) + info_result = self._run(["--json", "--project", proj_path, "project", "info"]) + info = json.loads(info_result.stdout) + assert info["cloud_count"] == 2 + + def test_full_export_workflow(self, tmp_dir, cloud_xyz): + """Full workflow: create project → add cloud → export to PLY.""" + proj_path = os.path.join(tmp_dir, "export_wf.json") + out_cloud = os.path.join(tmp_dir, "exported.ply") + + self._run(["project", "new", "-o", proj_path]) + self._run(["--project", proj_path, "cloud", "add", cloud_xyz]) + result = self._run([ + "--json", "--project", proj_path, + "export", "cloud", "0", out_cloud, + "--overwrite", + ]) + assert result.returncode == 0 + data = json.loads(result.stdout) + + assert os.path.exists(data["output"]), f"Exported PLY missing: {data['output']}" + assert data["file_size"] > 0 + + with open(data["output"], "rb") as f: + assert f.read(3) == b"ply" + + print(f"\n Exported PLY: {data['output']} ({data['file_size']:,} bytes)") + + def test_project_status_json(self, tmp_dir): + """project status --json returns quick status.""" + proj_path = os.path.join(tmp_dir, "status_test.json") + self._run(["project", "new", "-o", proj_path]) + result = self._run(["--json", "--project", proj_path, "project", "status"]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "clouds" in data + assert "meshes" in data + assert "modified" in data + + def test_cloud_invert_normals_workflow(self, tmp_dir, cloud_xyz): + """Workflow: add cloud → normals → invert-normals.""" + proj_path = os.path.join(tmp_dir, "normals_wf.json") + normals_out = os.path.join(tmp_dir, "normals.ply") + inv_out = os.path.join(tmp_dir, "normals_inv.ply") + + self._run(["project", "new", "-o", proj_path]) + self._run(["--project", proj_path, "cloud", "add", cloud_xyz]) + + # Compute normals + r1 = self._run([ + "--json", "--project", proj_path, + "cloud", "normals", "0", "-o", normals_out, "--add-to-project", + ]) + assert r1.returncode == 0 + d1 = json.loads(r1.stdout) + assert d1.get("exists"), "normals command produced no output" + + # Add normals cloud and invert + r2 = self._run([ + "--json", "--project", proj_path, + "cloud", "invert-normals", "1", "-o", inv_out, + ]) + assert r2.returncode == 0 + d2 = json.loads(r2.stdout) + assert d2.get("exists"), "invert-normals produced no output" + print(f"\n Inverted normals: {inv_out} ({d2['file_size']:,} bytes)") + + def test_cloud_mesh_delaunay_workflow(self, tmp_dir, cloud_xyz): + """Workflow: add cloud → mesh-delaunay → verify mesh.""" + proj_path = os.path.join(tmp_dir, "delaunay_wf.json") + mesh_out = os.path.join(tmp_dir, "terrain.obj") + + self._run(["project", "new", "-o", proj_path]) + self._run(["--project", proj_path, "cloud", "add", cloud_xyz]) + + result = self._run([ + "--json", "--project", proj_path, + "cloud", "mesh-delaunay", "0", "-o", mesh_out, + ]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data.get("exists"), f"mesh-delaunay produced no output: {result.stderr[:300]}" + assert data["file_size"] > 0 + print(f"\n Delaunay mesh: {mesh_out} ({data['file_size']:,} bytes)") + + def test_transform_apply_workflow(self, tmp_dir, cloud_xyz): + """Workflow: add cloud → apply identity transform → verify output.""" + proj_path = os.path.join(tmp_dir, "trans_wf.json") + mat_file = os.path.join(tmp_dir, "identity.txt") + out_cloud = os.path.join(tmp_dir, "transformed.xyz") + + with open(mat_file, "w") as f: + f.write("1 0 0 0\n0 1 0 0\n0 0 1 0\n0 0 0 1\n") + + self._run(["project", "new", "-o", proj_path]) + self._run(["--project", proj_path, "cloud", "add", cloud_xyz]) + + result = self._run([ + "--json", "--project", proj_path, + "transform", "apply", "0", + "-o", out_cloud, "-m", mat_file, + ]) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data.get("exists"), f"transform apply produced no output: {result.stderr[:300]}" + print(f"\n Transformed: {out_cloud} ({data['file_size']:,} bytes)") diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/__init__.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py new file mode 100644 index 000000000..8f9fd5f11 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py @@ -0,0 +1,960 @@ +"""CloudCompare backend — invokes the real CloudCompare executable. + +CloudCompare ships with a full command-line mode via the -SILENT flag. +This module wraps that CLI and handles Flatpak/native detection. + +Usage pattern: + CloudCompare -SILENT -O input.las -SS SPATIAL 0.05 -SAVE_CLOUDS + +Reference: https://www.cloudcompare.org/doc/wiki/index.php/Command_line_mode +""" + +import glob +import os +import shutil +import subprocess +from pathlib import Path +from typing import Optional + + +# Supported input/output file extensions +CLOUD_FORMATS = { + "bin": "BIN", + "las": "LAS", + "laz": "LAS", + "ply": "PLY", + "pcd": "PCD", + "xyz": "ASC", + "txt": "ASC", + "asc": "ASC", + "csv": "ASC", + "e57": "E57", + "dp": "DP", +} + +MESH_FORMATS = { + "obj": "OBJ", + "stl": "STL", + "ply": "PLY", + "bin": "BIN", +} + + +def find_cloudcompare() -> list[str]: + """Locate the CloudCompare executable. + + Returns a command prefix list (e.g., ['CloudCompare'] or + ['flatpak', 'run', 'org.cloudcompare.CloudCompare']). + + Raises RuntimeError with install instructions if not found. + """ + # 1. Try native binary first + native = shutil.which("CloudCompare") or shutil.which("cloudcompare") + if native: + return [native] + + # 2. Try Flatpak + flatpak = shutil.which("flatpak") + if flatpak: + result = subprocess.run( + ["flatpak", "list", "--app", "--columns=application"], + capture_output=True, + text=True, + ) + if "org.cloudcompare.CloudCompare" in result.stdout: + return ["flatpak", "run", "org.cloudcompare.CloudCompare"] + + # 3. Try snap + snap_path = "/snap/bin/cloudcompare" + if os.path.exists(snap_path): + return [snap_path] + + raise RuntimeError( + "CloudCompare is not installed or not found.\n" + "Install it with one of:\n" + " flatpak install flathub org.cloudcompare.CloudCompare # Flatpak\n" + " sudo apt install cloudcompare # Debian/Ubuntu\n" + " brew install --cask cloudcompare # macOS\n" + " https://cloudcompare.org/release/index.html # Windows installer" + ) + + +def run_cloudcompare(args: list[str], cwd: Optional[str] = None) -> dict: + """Run CloudCompare with the given arguments in silent mode. + + Args: + args: List of CC arguments (without the executable itself). + The -SILENT flag is prepended automatically. + cwd: Working directory for the subprocess. + + Returns: + dict with keys: returncode, stdout, stderr, command + """ + cmd_prefix = find_cloudcompare() + full_cmd = cmd_prefix + ["-SILENT"] + args + + result = subprocess.run( + full_cmd, + capture_output=True, + text=True, + cwd=cwd, + ) + + return { + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "command": " ".join(full_cmd), + } + + +def open_and_save( + input_path: str, + output_path: str, + extra_args: Optional[list[str]] = None, +) -> dict: + """Load a point cloud, optionally process it, and save. + + Args: + input_path: Path to input file. + output_path: Path to output file. + extra_args: Additional CC command-line arguments between load and save. + + Returns: + dict with result info. + """ + input_path = os.path.abspath(input_path) + output_path = os.path.abspath(output_path) + out_dir = os.path.dirname(output_path) + out_name = os.path.splitext(os.path.basename(output_path))[0] + out_ext = os.path.splitext(output_path)[1].lstrip(".") + + fmt = CLOUD_FORMATS.get(out_ext.lower(), "ASC") + + args = ["-O", input_path] + if extra_args: + args.extend(extra_args) + + args += [ + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_CLOUDS", "FILE", output_path, + ] + + result = run_cloudcompare(args, cwd=out_dir) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def subsample( + input_path: str, + output_path: str, + method: str = "SPATIAL", + parameter: float = 0.05, +) -> dict: + """Subsample a point cloud. + + Args: + input_path: Input cloud file. + output_path: Output cloud file. + method: RANDOM (count), SPATIAL (min dist), or OCTREE (level). + parameter: For RANDOM: point count. For SPATIAL: min distance. + For OCTREE: octree level (1-10). + """ + method = method.upper() + if method not in ("RANDOM", "SPATIAL", "OCTREE"): + raise ValueError(f"method must be RANDOM, SPATIAL, or OCTREE, got {method!r}") + + if method == "OCTREE": + param_str = str(int(parameter)) + else: + param_str = str(parameter) + + return open_and_save(input_path, output_path, ["-SS", method, param_str]) + + +def compute_roughness( + input_path: str, + output_path: str, + radius: float = 0.1, +) -> dict: + """Compute roughness scalar field for a point cloud. + + Args: + input_path: Input cloud file. + output_path: Output cloud file (with roughness SF). + radius: Sphere radius for roughness computation. + """ + return open_and_save(input_path, output_path, ["-ROUGH", str(radius)]) + + +def compute_density( + input_path: str, + output_path: str, + sphere_radius: float = 0.1, + density_type: str = "KNN", +) -> dict: + """Compute point density scalar field. + + Args: + input_path: Input cloud file. + output_path: Output cloud file. + sphere_radius: Sphere radius for density computation. + density_type: KNN, SURFACE, or VOLUME. + """ + density_type = density_type.upper() + extra = ["-DENSITY", str(sphere_radius), "-TYPE", density_type] + return open_and_save(input_path, output_path, extra) + + +def compute_curvature( + input_path: str, + output_path: str, + curvature_type: str = "MEAN", + radius: float = 0.1, +) -> dict: + """Compute curvature scalar field. + + Args: + input_path: Input cloud file. + output_path: Output cloud file. + curvature_type: MEAN or GAUSS. + radius: Kernel radius. + """ + curvature_type = curvature_type.upper() + return open_and_save(input_path, output_path, ["-CURV", curvature_type, str(radius)]) + + +def sor_filter( + input_path: str, + output_path: str, + nb_points: int = 6, + std_ratio: float = 1.0, +) -> dict: + """Statistical Outlier Removal (SOR) filter. + + Args: + input_path: Input cloud file. + output_path: Output cloud file. + nb_points: Number of nearest neighbors. + std_ratio: Standard deviation multiplier threshold. + """ + return open_and_save(input_path, output_path, ["-SOR", str(nb_points), str(std_ratio)]) + + +def crop_cloud( + input_path: str, + output_path: str, + xmin: float, + ymin: float, + zmin: float, + xmax: float, + ymax: float, + zmax: float, + outside: bool = False, +) -> dict: + """Crop a point cloud to a bounding box. + + Args: + input_path: Input cloud file. + output_path: Output cloud file. + xmin/ymin/zmin: Minimum corner. + xmax/ymax/zmax: Maximum corner. + outside: If True, keep points outside the box. + """ + extra = ["-CROP", f"{xmin}:{ymin}:{zmin}:{xmax}:{ymax}:{zmax}"] + if outside: + extra.append("-OUTSIDE") + return open_and_save(input_path, output_path, extra) + + +def merge_clouds( + input_paths: list[str], + output_path: str, +) -> dict: + """Merge multiple point clouds into one. + + Args: + input_paths: List of input cloud files. + output_path: Output merged cloud file. + """ + if len(input_paths) < 2: + raise ValueError("Need at least 2 clouds to merge") + + output_path = os.path.abspath(output_path) + out_ext = os.path.splitext(output_path)[1].lstrip(".") + fmt = CLOUD_FORMATS.get(out_ext.lower(), "ASC") + + args = [] + for p in input_paths: + args += ["-O", os.path.abspath(p)] + + args += [ + "-MERGE_CLOUDS", + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_CLOUDS", "FILE", output_path, + ] + + result = run_cloudcompare(args) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def compute_c2c_distances( + compare_path: str, + reference_path: str, + output_path: str, + split_xyz: bool = False, + octree_level: int = 10, +) -> dict: + """Compute cloud-to-cloud distances. + + Args: + compare_path: 'Compared' cloud (gets the distance SF). + reference_path: 'Reference' cloud. + output_path: Output cloud file with distance SF. + split_xyz: If True, also compute X/Y/Z components separately. + octree_level: Octree level for the computation. + """ + args = [ + "-O", os.path.abspath(reference_path), + "-O", os.path.abspath(compare_path), + "-C2C_DIST", + "-OCTREE_LEVEL", str(octree_level), + ] + if split_xyz: + args.append("-SPLIT_XYZ") + + output_path = os.path.abspath(output_path) + out_ext = os.path.splitext(output_path)[1].lstrip(".") + fmt = CLOUD_FORMATS.get(out_ext.lower(), "ASC") + + args += [ + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_CLOUDS", "FILE", output_path, + ] + + result = run_cloudcompare(args) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def compute_c2m_distances( + cloud_path: str, + mesh_path: str, + output_path: str, + flip_normals: bool = False, + unsigned: bool = False, +) -> dict: + """Compute cloud-to-mesh distances. + + Args: + cloud_path: Input point cloud. + mesh_path: Reference mesh. + output_path: Output cloud with distance SF. + flip_normals: Flip mesh normals before computing. + unsigned: Compute unsigned distances. + """ + args = [ + "-O", os.path.abspath(mesh_path), + "-O", os.path.abspath(cloud_path), + "-C2M_DIST", + ] + if flip_normals: + args.append("-FLIP_NORMS") + if unsigned: + args.append("-UNSIGNED") + + output_path = os.path.abspath(output_path) + out_ext = os.path.splitext(output_path)[1].lstrip(".") + fmt = CLOUD_FORMATS.get(out_ext.lower(), "ASC") + + args += [ + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_CLOUDS", "FILE", output_path, + ] + + result = run_cloudcompare(args) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def run_icp( + aligned_path: str, + reference_path: str, + output_path: str, + max_iterations: int = 100, + min_error_diff: float = 1e-6, + overlap: float = 100.0, +) -> dict: + """Run ICP (Iterative Closest Point) registration. + + Args: + aligned_path: Cloud to align. + reference_path: Reference cloud. + output_path: Aligned output cloud. + max_iterations: Maximum ICP iterations. + min_error_diff: Stop when improvement is below this. + overlap: Percentage of overlap (0-100). + """ + args = [ + "-O", os.path.abspath(reference_path), + "-O", os.path.abspath(aligned_path), + "-ICP", + "-ITER", str(max_iterations), + "-MIN_ERROR_DIFF", str(min_error_diff), + "-OVERLAP", str(overlap), + ] + + output_path = os.path.abspath(output_path) + out_ext = os.path.splitext(output_path)[1].lstrip(".") + fmt = CLOUD_FORMATS.get(out_ext.lower(), "ASC") + + args += [ + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_CLOUDS", "FILE", output_path, + ] + + result = run_cloudcompare(args) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def coord_to_sf( + input_path: str, + output_path: str, + dimension: str = "Z", + sf_index: int = 0, +) -> dict: + """Convert a coordinate axis (X/Y/Z) to a scalar field. + + This is the standard way to create a height (elevation) scalar field + from the Z coordinate, which can then be used for filtering or analysis. + + Args: + input_path: Input cloud file. + output_path: Output cloud file (with the new scalar field). + dimension: Axis to convert: X, Y, or Z. Default Z (height). + sf_index: Index to set as active SF after creation. Default 0. + """ + dim = dimension.upper() + if dim not in ("X", "Y", "Z"): + raise ValueError(f"dimension must be X, Y, or Z, got {dim!r}") + extra = ["-COORD_TO_SF", dim, "-SET_ACTIVE_SF", str(sf_index)] + return open_and_save(input_path, output_path, extra) + + +def filter_sf_by_value( + input_path: str, + output_path: str, + min_val: float, + max_val: float, + sf_index: Optional[int] = None, +) -> dict: + """Filter a cloud to points whose active scalar field value falls in [min_val, max_val]. + + Typical usage: after coord_to_sf(dim="Z"), filter to a height range. + + Args: + input_path: Input cloud file (must have an active scalar field). + output_path: Output cloud file (filtered). + min_val: Minimum SF value to keep. + max_val: Maximum SF value to keep. + sf_index: If given, sets this SF index as active before filtering. + Use when the cloud has multiple SFs and you need to pick one. + """ + extra = [] + if sf_index is not None: + extra += ["-SET_ACTIVE_SF", str(sf_index)] + extra += ["-FILTER_SF", str(min_val), str(max_val)] + return open_and_save(input_path, output_path, extra) + + +def coord_to_sf_and_filter( + input_path: str, + output_path: str, + dimension: str = "Z", + min_val: Optional[float] = None, + max_val: Optional[float] = None, +) -> dict: + """Convert a coordinate to SF and optionally filter by value range in one pass. + + Combines -COORD_TO_SF and -FILTER_SF in a single CloudCompare invocation. + Useful for height-based slice extraction (e.g., keep points between z=10 and z=20). + + Args: + input_path: Input cloud file. + output_path: Output cloud file. + dimension: X, Y, or Z. Default Z. + min_val: Minimum value to keep. None = no lower bound (uses 'MIN'). + max_val: Maximum value to keep. None = no upper bound (uses 'MAX'). + """ + dim = dimension.upper() + if dim not in ("X", "Y", "Z"): + raise ValueError(f"dimension must be X, Y, or Z, got {dim!r}") + + extra = ["-COORD_TO_SF", dim, "-SET_ACTIVE_SF", "0"] + + if min_val is not None or max_val is not None: + lo = str(min_val) if min_val is not None else "MIN" + hi = str(max_val) if max_val is not None else "MAX" + extra += ["-FILTER_SF", lo, hi] + + return open_and_save(input_path, output_path, extra) + + +def convert_format( + input_path: str, + output_path: str, +) -> dict: + """Convert a point cloud from one format to another. + + Args: + input_path: Input cloud (any supported format). + output_path: Output cloud (format determined by extension). + """ + return open_and_save(input_path, output_path) + + +def compute_normals( + input_path: str, + output_path: str, + octree_level: int = 10, + orientation: str = "PLUS_Z", +) -> dict: + """Compute normals via octree. + + Args: + input_path: Input cloud. + output_path: Output cloud with normals. + octree_level: Octree level for normal computation. + orientation: Normal orientation hint: PLUS_X/Y/Z or MINUS_X/Y/Z. + """ + extra = ["-OCTREE_NORMALS", str(octree_level)] + if orientation: + extra += ["-ORIENT", orientation] + return open_and_save(input_path, output_path, extra) + + +def csf_filter( + input_path: str, + ground_output: str, + offground_output: Optional[str] = None, + scene: str = "RELIEF", + cloth_resolution: float = 2.0, + class_threshold: float = 0.5, + max_iteration: int = 500, + proc_slope: bool = False, +) -> dict: + """Ground filtering using the Cloth Simulation Filter (CSF) algorithm. + + CSF simulates a cloth draped over an inverted point cloud to separate + ground points from off-ground points (vegetation, buildings, etc.). + + Reference: Zhang et al. (2016) Remote Sensing 8(6):501. + + Args: + input_path: Input cloud file (LiDAR scan). + ground_output: Output path for the ground point cloud. + offground_output: Output path for the off-ground cloud (optional). + If None, off-ground points are not exported. + scene: Scene type affecting cloth rigidness: + - SLOPE (rigidness=1): steep terrain with slopes + - RELIEF (rigidness=2): general terrain (default) + - FLAT (rigidness=3): flat or urban terrain + cloth_resolution: Grid resolution of the cloth (metres). Smaller = + finer ground detail but slower. Default 2.0. + class_threshold: Max distance (metres) from cloth to classify a point + as ground. Default 0.5. + max_iteration: Maximum cloth simulation iterations. Default 500. + proc_slope: Enable post-processing to smooth slope artifacts. Default False. + + Returns: + dict with keys: returncode, ground, ground_exists, ground_size, + and optionally offground, offground_exists, offground_size. + """ + scene = scene.upper() + if scene not in ("SLOPE", "RELIEF", "FLAT"): + raise ValueError(f"scene must be SLOPE, RELIEF, or FLAT, got {scene!r}") + + input_path = os.path.abspath(input_path) + ground_output = os.path.abspath(ground_output) + in_dir = os.path.dirname(input_path) + in_stem = os.path.splitext(os.path.basename(input_path))[0] + + # Determine export format from requested output extension + out_ext = os.path.splitext(ground_output)[1].lstrip(".").lower() + fmt = CLOUD_FORMATS.get(out_ext, "LAS") + + # Build the CC command. + # IMPORTANT: -C_EXPORT_FMT must come BEFORE -CSF so the format is set + # before CSF calls exportEntity() internally for -EXPORT_GROUND/OFFGROUND. + args = [ + "-O", input_path, + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-CSF", + "-SCENES", scene, + "-CLOTH_RESOLUTION", str(cloth_resolution), + "-CLASS_THRESHOLD", str(class_threshold), + "-MAX_ITERATION", str(max_iteration), + ] + if proc_slope: + args.append("-PROC_SLOPE") + args.append("-EXPORT_GROUND") + if offground_output is not None: + args.append("-EXPORT_OFFGROUND") + + result = run_cloudcompare(args, cwd=in_dir) + + # CC auto-generates output filenames: {stem}_ground_points.{ext} + # Use glob to find them robustly (extension may vary by fmt) + def _find_and_move(pattern: str, dest: str) -> bool: + candidates = glob.glob(pattern) + if not candidates: + return False + src = candidates[0] + if src != dest: + os.makedirs(os.path.dirname(dest) or ".", exist_ok=True) + os.replace(src, dest) + return os.path.exists(dest) + + ground_exists = _find_and_move( + os.path.join(in_dir, f"{in_stem}_ground_points.*"), + ground_output, + ) + + result["ground"] = ground_output + result["ground_exists"] = ground_exists + result["ground_size"] = os.path.getsize(ground_output) if ground_exists else 0 + + if offground_output is not None: + offground_output = os.path.abspath(offground_output) + offground_exists = _find_and_move( + os.path.join(in_dir, f"{in_stem}_offground_points.*"), + offground_output, + ) + result["offground"] = offground_output + result["offground_exists"] = offground_exists + result["offground_size"] = os.path.getsize(offground_output) if offground_exists else 0 + + return result + + +def sf_to_rgb( + input_path: str, + output_path: str, +) -> dict: + """Convert the active scalar field to RGB colours. + + CloudCompare maps each SF value through the current colour ramp and stores + the result as per-point RGB. Useful before applying colour-based filters + or exporting a coloured cloud. + + Args: + input_path: Input cloud (must have an active scalar field). + output_path: Output cloud with RGB colours. + """ + return open_and_save(input_path, output_path, ["-SF_CONVERT_TO_RGB", "TRUE"]) + + +def rgb_to_sf( + input_path: str, + output_path: str, +) -> dict: + """Convert RGB colours to a scalar field (intensity/greyscale). + + The resulting SF stores the luminance of each point's RGB triplet. + + Args: + input_path: Input cloud with RGB colours. + output_path: Output cloud with the new scalar field. + """ + return open_and_save(input_path, output_path, ["-RGB_CONVERT_TO_SF"]) + + +def noise_filter( + input_path: str, + output_path: str, + knn: int = 6, + noisiness: float = 1.0, + use_radius: bool = False, + radius: float = 0.1, + absolute: bool = False, +) -> dict: + """Apply the PCL noise filter to remove noisy points. + + Uses CloudCompare's ``-NOISE`` command (backed by the PCL wrapper plugin). + Noisy points are identified by comparing each point's deviation from its + local neighbourhood to a noise threshold. + + Note: CC's CLI does not provide Gaussian/Bilateral spatial smoothing. + This PCL noise filter is the closest available spatial noise-removal + operation via the command line. + + Args: + input_path: Input cloud. + output_path: Filtered output cloud (noisy points removed). + knn: Number of nearest neighbours used when ``use_radius`` + is False (default 6). + noisiness: Noise threshold multiplier. Points whose deviation + exceeds this multiple of the local noise estimate are + removed (default 1.0). + use_radius: If True, use a fixed search radius instead of KNN. + radius: Search radius when ``use_radius`` is True. + absolute: If True, interpret ``noisiness`` as an absolute + distance threshold (ABS) rather than relative (REL). + + Returns: + dict with output, exists, file_size, returncode. + """ + mode = "RADIUS" if use_radius else "KNN" + mode_val = str(radius) if use_radius else str(knn) + threshold_mode = "ABS" if absolute else "REL" + + extra = ["-NOISE", mode, mode_val, threshold_mode, str(noisiness)] + return open_and_save(input_path, output_path, extra) + + +def invert_normals( + input_path: str, + output_path: str, +) -> dict: + """Invert (flip) all normals in a point cloud. + + Args: + input_path: Input cloud with normals. + output_path: Output cloud with flipped normals. + """ + return open_and_save(input_path, output_path, ["-INVERT_NORMALS"]) + + +def apply_transform( + input_path: str, + output_path: str, + matrix_file: str, + inverse: bool = False, +) -> dict: + """Apply a rigid-body (or affine) transformation to a point cloud. + + The transformation is read from a plain-text file containing a 4×4 matrix + (one row per line, values space-separated). + + Args: + input_path: Input cloud or mesh. + output_path: Transformed output cloud. + matrix_file: Path to the 4×4 transformation matrix text file. + inverse: If True, apply the inverse of the matrix. + + Example matrix file (identity):: + + 1 0 0 0 + 0 1 0 0 + 0 0 1 0 + 0 0 0 1 + """ + matrix_file = os.path.abspath(matrix_file) + extra = ["-APPLY_TRANS"] + if inverse: + extra.append("-INVERSE") + extra.append(matrix_file) + return open_and_save(input_path, output_path, extra) + + +def delaunay_mesh( + input_path: str, + output_path: str, + axis_aligned: bool = True, + max_edge_length: float = 0.0, +) -> dict: + """Create a 2.5-D Delaunay triangulation mesh from a point cloud. + + Projects the cloud onto a plane (axis-aligned XY or best-fit) and + triangulates the projected points. Suitable for terrain-like surfaces. + + Args: + input_path: Input cloud. + output_path: Output mesh file (extension determines format). + axis_aligned: If True, project onto the XY plane (-AA). + If False, use the best-fit plane (-BEST_FIT). + max_edge_length: Remove triangles whose longest edge exceeds this + value (0 = no limit). + + Returns: + dict with output, exists, file_size, returncode. + """ + input_path = os.path.abspath(input_path) + output_path = os.path.abspath(output_path) + out_dir = os.path.dirname(output_path) or "." + out_ext = Path(output_path).suffix.lstrip(".") + fmt = MESH_FORMATS.get(out_ext.lower(), "OBJ") + + args = ["-O", input_path, "-DELAUNAY"] + if axis_aligned: + args.append("-AA") + else: + args.append("-BEST_FIT") + if max_edge_length > 0: + args += ["-MAX_EDGE_LENGTH", str(max_edge_length)] + args += [ + "-M_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_MESHES", "FILE", output_path, + ] + + result = run_cloudcompare(args, cwd=out_dir) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def sample_mesh( + mesh_path: str, + output_path: str, + count: int = 10000, +) -> dict: + """Sample a point cloud from a mesh surface. + + Randomly places ``count`` points on the mesh triangles, proportional to + triangle area. + + Args: + mesh_path: Input mesh file. + output_path: Output sampled point cloud. + count: Number of points to sample. + """ + mesh_path = os.path.abspath(mesh_path) + output_path = os.path.abspath(output_path) + out_dir = os.path.dirname(output_path) or "." + out_ext = Path(output_path).suffix.lstrip(".") + fmt = CLOUD_FORMATS.get(out_ext.lower(), "ASC") + + args = [ + "-O", mesh_path, + "-SAMPLE_MESH", "DENSITY", str(count), + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-SAVE_CLOUDS", "FILE", output_path, + ] + + result = run_cloudcompare(args, cwd=out_dir) + result["output"] = output_path + result["exists"] = os.path.exists(output_path) + if result["exists"]: + result["file_size"] = os.path.getsize(output_path) + return result + + +def extract_connected_components( + input_path: str, + output_dir: str, + octree_level: int = 8, + min_points: int = 100, + output_fmt: str = "xyz", +) -> dict: + """Segment a cloud into connected components (clusters). + + Uses CloudCompare's ``-EXTRACT_CC`` command which labels connected regions + at a given octree resolution and exports each component as a separate file. + + Args: + input_path: Input cloud. + output_dir: Directory where component clouds will be saved. + octree_level: Octree level controlling neighbourhood radius + (1-10; higher = finer, more components). + min_points: Discard components with fewer than this many points. + output_fmt: Output file extension / format (default ``"xyz"``). + + Returns: + dict with output_dir, components (list of paths), component_count, + returncode. + """ + input_path = os.path.abspath(input_path) + in_dir = os.path.dirname(input_path) + fmt = CLOUD_FORMATS.get(output_fmt.lower(), "ASC") + + args = [ + "-O", input_path, + "-C_EXPORT_FMT", fmt, + "-NO_TIMESTAMP", + "-EXTRACT_CC", str(octree_level), str(min_points), + "-SAVE_CLOUDS", + ] + + # CC saves component files to the input file's directory, not cwd. + # Files are named: {input_stem}_COMPONENT_{n}.{actual_ext} + # The actual extension depends on the CC format name, not the input extension + # (e.g., output_fmt="xyz" → CC format "ASC" → files saved as ".asc"). + _fmt_to_ext = { + "ASC": "asc", "PLY": "ply", "LAS": "las", + "PCD": "pcd", "BIN": "bin", "E57": "e57", + } + actual_ext = _fmt_to_ext.get(fmt, output_fmt.lower()) + + result = run_cloudcompare(args, cwd=in_dir) + + components = sorted( + glob.glob(os.path.join(in_dir, f"*_COMPONENT_*.{actual_ext}")) + ) + result["output_dir"] = in_dir + result["components"] = components + result["component_count"] = len(components) + return result + + +def is_available() -> bool: + """Check if CloudCompare is available.""" + try: + find_cloudcompare() + return True + except RuntimeError: + return False + + +def get_version() -> Optional[str]: + """Try to retrieve CloudCompare version string. + + CloudCompare does not support a --version flag. For Flatpak installations, + version is read from `flatpak info`. For native installations, returns None. + """ + try: + cmd = find_cloudcompare() + # Flatpak: extract version from `flatpak info` + if "flatpak" in cmd: + app_id = next( + (c for c in cmd if c.startswith("org.cloudcompare")), + "org.cloudcompare.CloudCompare", + ) + result = subprocess.run( + ["flatpak", "info", app_id], + capture_output=True, + text=True, + timeout=10, + ) + for line in result.stdout.splitlines(): + if line.strip().startswith("Version:"): + return line.split(":", 1)[1].strip() + return None + except Exception: + return None diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py new file mode 100644 index 000000000..c7312348a --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/repl_skin.py @@ -0,0 +1,521 @@ +"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("shotcut", version="1.0.0") + skin.print_banner() # auto-detects skills/SKILL.md inside the package + prompt_text = skin.prompt(project_name="my_video.mlt", modified=True) + skin.success("Project saved") + skin.error("File not found") + skin.warning("Unsaved changes") + skin.info("Processing 24 clips...") + skin.status("Track 1", "3 clips, 00:02:30") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# ── ANSI color codes (no external deps for core styling) ────────────── + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors — each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# ── Brand icon ──────────────────────────────────────────────────────── + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}◆{_RESET}" +_ICON_SMALL = f"{_CYAN}▸{_RESET}" + +# ── Box drawing characters ──────────────────────────────────────────── + +_H_LINE = "─" +_V_LINE = "│" +_TL = "╭" +_TR = "╮" +_BL = "╰" +_BR = "╯" +_T_DOWN = "┬" +_T_UP = "┴" +_T_RIGHT = "├" +_T_LEFT = "┤" +_CROSS = "┼" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None, skill_path: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "blender"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + skill_path: Path to the SKILL.md file for agent discovery. + Auto-detected from the package's skills/ directory if not provided. + Displayed in banner for AI agents to know where to read skill info. + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + + # Auto-detect skill path from package layout: + # cli_anything//utils/repl_skin.py (this file) + # cli_anything//skills/SKILL.md (target) + if skill_path is None: + from pathlib import Path + _auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md" + if _auto.is_file(): + skill_path = str(_auto) + self.skill_path = skill_path + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # ── Banner ──────────────────────────────────────────────────────── + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: ◆ cli-anything · Shotcut + icon = self._c(_CYAN + _BOLD, "◆") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "·") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + # Skill path for agent discovery + skill_line = None + if self.skill_path: + skill_icon = self._c(_MAGENTA, "◇") + skill_label = self._c(_DARK_GRAY, " Skill:") + skill_path_display = self._c(_LIGHT_GRAY, self.skill_path) + skill_line = f" {skill_icon} {skill_label} {skill_path_display}" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + if skill_line: + print(_box_line(skill_line)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # ── Prompt ──────────────────────────────────────────────────────── + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}◆{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " ❯ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "◆ ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " ❯ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # ── Messages ────────────────────────────────────────────────────── + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "✓") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "✗") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "⚠") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "●") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # ── Status display ──────────────────────────────────────────────── + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "█" * filled + "░" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # ── Table display ───────────────────────────────────────────────── + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # ── Help display ────────────────────────────────────────────────── + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # ── Goodbye ─────────────────────────────────────────────────────── + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # ── Prompt toolkit session factory ──────────────────────────────── + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # ── Toolbar builder ─────────────────────────────────────────────── + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " │ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ───────── + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange +} diff --git a/cloudcompare/agent-harness/setup.py b/cloudcompare/agent-harness/setup.py new file mode 100644 index 000000000..4fd1cca5c --- /dev/null +++ b/cloudcompare/agent-harness/setup.py @@ -0,0 +1,30 @@ +from setuptools import setup, find_namespace_packages + +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_content_type="text/markdown", + author="cli-anything", + python_requires=">=3.10", + packages=find_namespace_packages(include=["cli_anything.*"]), + package_data={ + "cli_anything.cloudcompare": ["skills/*.md"], + }, + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + ], + entry_points={ + "console_scripts": [ + "cli-anything-cloudcompare=cli_anything.cloudcompare.cloudcompare_cli:main", + ], + }, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Topic :: Scientific/Engineering :: GIS", + "Topic :: Scientific/Engineering :: Visualization", + ], +) diff --git a/registry.json b/registry.json index ee886cdfd..df2ca645d 100644 --- a/registry.json +++ b/registry.json @@ -396,6 +396,20 @@ "category": "web", "contributor": "allthingssecurity", "contributor_url": "https://github.com/allthingssecurity" + }, + { + "name": "cloudcompare", + "display_name": "CloudCompare", + "version": "0.1.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", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=cloudcompare/agent-harness", + "entry_point": "cli-anything-cloudcompare", + "skill_md": "cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md", + "category": "graphics", + "contributor": "Taeyoung96", + "contributor_url": "https://github.com/Taeyoung96" } ] } From 17a1b42edf7797fafa335cef259a3d20b2afdc32 Mon Sep 17 00:00:00 2001 From: Taeyoung96 Date: Mon, 30 Mar 2026 20:29:41 +0900 Subject: [PATCH 2/3] fix: address Copilot review comments + update CloudCompare README icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code fixes (cc_backend.py): - restrict component glob to input stem to avoid picking up leftover files - move generated components to output_dir via shutil.move - coerce RANDOM subsample parameter to int Code fixes (export.py): - move overwrite check after preset path resolution in export_cloud/export_mesh Code fixes (project.py): - guard fcntl import with try/except for Windows compatibility Code fixes (cloudcompare_cli.py): - remove non-existent 'project open' from REPL help - fix session update after 'project new' and explicit -p/--project usage Docs (SKILL.md): - fix Click root option placement in example - remove non-existent redo reference Docs (README.md): - replace cloud emoji with 🅲🅲 icon - update test totals to 2,005 Config (registry.json, setup.py): - align registry version to 1.0.0 - use Path.read_text(encoding='utf-8') in setup.py 88/88 tests passing (49 unit + 39 E2E) --- README.md | 6 ++--- .../cloudcompare/cloudcompare_cli.py | 20 +++++++++++---- .../cli_anything/cloudcompare/core/export.py | 24 ++++++++++-------- .../cli_anything/cloudcompare/core/project.py | 18 ++++++++----- .../cli_anything/cloudcompare/skills/SKILL.md | 6 ++--- .../cloudcompare/utils/cc_backend.py | 25 +++++++++++++++++-- cloudcompare/agent-harness/setup.py | 6 ++++- registry.json | 2 +- 8 files changed, 75 insertions(+), 32 deletions(-) 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", From 9802da37aa848391b24dfa9f76acfe547e6098fc Mon Sep 17 00:00:00 2001 From: Taeyoung96 Date: Mon, 30 Mar 2026 21:23:43 +0900 Subject: [PATCH 3/3] docs(cloudcompare): expand SKILL.md to list all 41 commands Added comprehensive command reference covering all 9 command groups: project (3), session (4), cloud (21), mesh (3), distance (2), transform (2), export (4), info (1), repl (1). Each command includes exact option flags with types, usage notes, and bash examples. Added file format table, 4 typical workflow pipelines (LiDAR pre-processing, change detection, height slicing, surface reconstruction), error handling guide, and tested version info (CloudCompare 2.13.2, Flatpak, Linux). --- .../cli_anything/cloudcompare/skills/SKILL.md | 780 +++++++++++++++++- 1 file changed, 747 insertions(+), 33 deletions(-) diff --git a/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md index 5fd164cc7..afa3a2ffd 100644 --- a/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md @@ -1,15 +1,15 @@ --- name: "cli-anything-cloudcompare" -description: "Command-line interface for Cloudcompare - Agent-friendly command-line harness for [CloudCompare](https://cloudcompare.org) — the open-source 3..." +description: "Command-line interface for CloudCompare — Agent-friendly harness for CloudCompare, the open-source 3D point cloud and mesh processing software. Supports 41 commands across 9 groups: project management, session control, point cloud operations (subsample, filter, segment, analyze), mesh operations, distance computation (C2C, C2M), transformations (ICP, matrix), export (LAS/LAZ/PLY/PCD/OBJ/STL/E57), and interactive REPL." --- # cli-anything-cloudcompare Agent-friendly command-line harness for [CloudCompare](https://cloudcompare.org) — the open-source 3D point cloud and mesh processing software. -## Installation +**41 commands** across 9 groups. -This CLI is installed as part of the cli-anything-cloudcompare package: +## Installation ```bash pip install cli-anything-cloudcompare @@ -17,59 +17,773 @@ pip install cli-anything-cloudcompare **Prerequisites:** - Python 3.10+ -- Cloudcompare must be installed on your system +- CloudCompare installed on your system + - Linux (Flatpak): `flatpak install flathub org.cloudcompare.CloudCompare` + - macOS/Windows: download from https://cloudcompare.org -## Usage +**Tested with:** CloudCompare 2.13.2 (Flatpak, Linux) -### Basic Commands +## Global Options + +These options must be placed **before** the subcommand: ```bash -# Show help -cli-anything-cloudcompare --help - -# Start interactive REPL mode -cli-anything-cloudcompare - -# Create a new project -cli-anything-cloudcompare project new -o project.json - -# Run with JSON output (for agent consumption) -cli-anything-cloudcompare --project project.json --json project info +cli-anything-cloudcompare [--project FILE] [--json] COMMAND [ARGS]... ``` -## Examples +| Option | Description | +|---|---| +| `-p, --project TEXT` | Path to project JSON file | +| `--json` | Output results as JSON (for agent consumption) | -### Create a New Project +## Command Groups -Create a new cloudcompare project file. +### 1. project — Project Management (3 commands) + +#### project new +Create a new empty project file. ```bash +# Create a project with default name cli-anything-cloudcompare project new -o myproject.json -# Or with JSON output for programmatic use + +# Create a project with a custom name +cli-anything-cloudcompare project new -o myproject.json -n "Bridge Survey 2024" + +# JSON output for agents cli-anything-cloudcompare --json project new -o myproject.json ``` -### Interactive REPL Session +Options: `-o/--output TEXT` (required), `-n/--name TEXT` -Start an interactive session with undo support. +#### project info +Show project info and loaded entities. ```bash -cli-anything-cloudcompare -# Enter commands interactively -# Use 'help' to see available commands -# Use 'session undo' to revert the last operation +cli-anything-cloudcompare --project myproject.json project info + +# JSON output +cli-anything-cloudcompare --project myproject.json --json project info ``` +#### project status +Show quick project status (cloud count, mesh count, last operation). + +```bash +cli-anything-cloudcompare --project myproject.json project status +``` + +--- + +### 2. session — Session Management (4 commands) + +#### session save +Save the current project state to disk. + +```bash +cli-anything-cloudcompare --project myproject.json session save +``` + +#### session history +Show recent operation history. + +```bash +# Show last 10 operations (default) +cli-anything-cloudcompare --project myproject.json session history + +# Show last 5 operations +cli-anything-cloudcompare --project myproject.json session history -n 5 +``` + +Options: `-n/--last INTEGER` + +#### session set-format +Update the default export format for future operations. + +```bash +# Set default cloud export to LAS +cli-anything-cloudcompare --project myproject.json session set-format --cloud-fmt LAS --cloud-ext las + +# Set default mesh export to OBJ +cli-anything-cloudcompare --project myproject.json session set-format --mesh-fmt OBJ --mesh-ext obj + +# Set both cloud and mesh defaults +cli-anything-cloudcompare --project myproject.json session set-format \ + --cloud-fmt PLY --cloud-ext ply \ + --mesh-fmt STL --mesh-ext stl +``` + +Options: `--cloud-fmt TEXT`, `--cloud-ext TEXT`, `--mesh-fmt TEXT`, `--mesh-ext TEXT` + +#### session undo +Remove the last operation from history (soft undo — does not delete output files). + +```bash +cli-anything-cloudcompare --project myproject.json session undo +``` + +--- + +### 3. cloud — Point Cloud Operations (21 commands) + +All cloud commands take `CLOUD_INDEX` (0-based integer from `cloud list`) and most accept `--add-to-project` to register the output back into the project. + +#### cloud add +Add a point cloud file to the project. + +```bash +# Add a LAS file +cli-anything-cloudcompare --project myproject.json cloud add /data/scan.las + +# Add with a label +cli-anything-cloudcompare --project myproject.json cloud add /data/scan.las -l "roof scan" +``` + +Options: `-l/--label TEXT` + +#### cloud list +List all clouds currently in the project. + +```bash +cli-anything-cloudcompare --project myproject.json cloud list + +# JSON output for parsing indices +cli-anything-cloudcompare --project myproject.json --json cloud list +``` + +#### cloud convert +Convert a cloud from one format to another (format determined by file extension). + +```bash +# LAS → PLY +cli-anything-cloudcompare cloud convert /data/scan.las /data/scan.ply + +# PCD → LAS +cli-anything-cloudcompare cloud convert /data/cloud.pcd /data/cloud.las +``` + +#### cloud subsample +Reduce the number of points using RANDOM, SPATIAL, or OCTREE method. + +```bash +# Random: keep 100 000 points +cli-anything-cloudcompare --project myproject.json cloud subsample 0 \ + -o /data/sub_random.las -m random -n 100000 + +# Spatial: minimum distance 0.05 m between points +cli-anything-cloudcompare --project myproject.json cloud subsample 0 \ + -o /data/sub_spatial.las -m spatial -n 0.05 + +# Octree: level 8 +cli-anything-cloudcompare --project myproject.json cloud subsample 0 \ + -o /data/sub_octree.las -m octree -n 8 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `-m/--method [random|spatial|octree]`, `-n/--param FLOAT`, `--add-to-project` + +#### cloud crop +Crop a cloud to an axis-aligned bounding box. + +```bash +# Keep points inside the box +cli-anything-cloudcompare --project myproject.json cloud crop 0 \ + -o /data/cropped.las \ + --xmin 0.0 --ymin 0.0 --zmin 0.0 \ + --xmax 10.0 --ymax 10.0 --zmax 5.0 + +# Keep points OUTSIDE the box +cli-anything-cloudcompare --project myproject.json cloud crop 0 \ + -o /data/exterior.las \ + --xmin 0.0 --ymin 0.0 --zmin 0.0 \ + --xmax 10.0 --ymax 10.0 --zmax 5.0 --outside +``` + +Options: `-o/--output TEXT` (required), `--xmin/ymin/zmin/xmax/ymax/zmax FLOAT` (all required), `--outside`, `--add-to-project` + +#### cloud normals +Compute surface normals via the octree method. + +```bash +# Compute normals at octree level 6 +cli-anything-cloudcompare --project myproject.json cloud normals 0 \ + -o /data/with_normals.ply --level 6 + +# Compute normals oriented toward +Z +cli-anything-cloudcompare --project myproject.json cloud normals 0 \ + -o /data/with_normals.ply --level 6 --orientation plus_z --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--level INTEGER` (1–10), `--orientation [plus_x|plus_y|plus_z|minus_x|minus_y|minus_z]`, `--add-to-project` + +#### cloud invert-normals +Flip all normal vectors in the cloud. + +```bash +cli-anything-cloudcompare --project myproject.json cloud invert-normals 0 \ + -o /data/flipped_normals.ply --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--add-to-project` + +#### cloud filter-sor +Statistical Outlier Removal — removes isolated noise points. + +```bash +# Default parameters (k=6 neighbours, 1.0 std ratio) +cli-anything-cloudcompare --project myproject.json cloud filter-sor 0 \ + -o /data/denoised.las + +# Custom parameters +cli-anything-cloudcompare --project myproject.json cloud filter-sor 0 \ + -o /data/denoised.las --nb-points 12 --std-ratio 2.0 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--nb-points INTEGER`, `--std-ratio FLOAT`, `--add-to-project` + +#### cloud noise-filter +Remove noisy points using the PCL noise filter (KNN or radius mode). + +```bash +# KNN mode (default) +cli-anything-cloudcompare --project myproject.json cloud noise-filter 0 \ + -o /data/clean.las --knn 8 --noisiness 1.0 + +# Radius mode +cli-anything-cloudcompare --project myproject.json cloud noise-filter 0 \ + -o /data/clean.las --radius 0.1 --use-radius --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--knn INTEGER`, `--noisiness FLOAT`, `--radius FLOAT`, `--use-radius`, `--absolute`, `--add-to-project` + +#### cloud filter-csf +Ground filtering using the Cloth Simulation Filter (CSF) algorithm. Separates ground from off-ground points (buildings, vegetation). + +```bash +# Extract ground only — mixed terrain +cli-anything-cloudcompare --project myproject.json cloud filter-csf 0 \ + --ground /data/ground.las --scene relief + +# Split ground + off-ground — urban scene +cli-anything-cloudcompare --project myproject.json cloud filter-csf 0 \ + --ground /data/ground.las \ + --offground /data/buildings.las \ + --scene flat --cloth-resolution 0.5 --class-threshold 0.3 + +# Steep forested slope with slope post-processing +cli-anything-cloudcompare --project myproject.json cloud filter-csf 0 \ + --ground /data/terrain.las --scene slope --proc-slope --add-to-project +``` + +Options: `-g/--ground TEXT` (required), `-u/--offground TEXT`, `--scene [slope|relief|flat]`, `--cloth-resolution FLOAT`, `--class-threshold FLOAT`, `--max-iteration INTEGER`, `--proc-slope`, `--add-to-project` + +#### cloud filter-sf +Filter a cloud by scalar field value range (keep points where SF ∈ [min, max]). + +```bash +# Keep points with SF value between 10 and 50 +cli-anything-cloudcompare --project myproject.json cloud filter-sf 0 \ + -o /data/filtered.las --min 10.0 --max 50.0 + +# Filter using a specific SF index +cli-anything-cloudcompare --project myproject.json cloud filter-sf 0 \ + -o /data/filtered.las --min 0.0 --max 1.5 --sf-index 2 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--min FLOAT` (required), `--max FLOAT` (required), `--sf-index INTEGER`, `--add-to-project` + +#### cloud sf-from-coord +Convert a coordinate axis (X/Y/Z) to a scalar field. Commonly used to create a height (Z) scalar field. + +```bash +# Create Z scalar field (height) +cli-anything-cloudcompare --project myproject.json cloud sf-from-coord 0 \ + -o /data/with_z_sf.las --dim z --add-to-project + +# Create X scalar field with a specific active index +cli-anything-cloudcompare --project myproject.json cloud sf-from-coord 0 \ + -o /data/with_x_sf.las --dim x --sf-index 0 +``` + +Options: `-o/--output TEXT` (required), `--dim [x|y|z]` (default: z), `--sf-index INTEGER`, `--add-to-project` + +#### cloud sf-filter-z +Convenience command: convert Z → scalar field and filter by height range in one step. + +```bash +# Extract points between z=1.0 m and z=2.5 m +cli-anything-cloudcompare --project myproject.json cloud sf-filter-z 0 \ + -o /data/slice.las --min 1.0 --max 2.5 --add-to-project + +# Only apply upper bound +cli-anything-cloudcompare --project myproject.json cloud sf-filter-z 0 \ + -o /data/below_5m.las --max 5.0 +``` + +Options: `-o/--output TEXT` (required), `--min FLOAT`, `--max FLOAT`, `--add-to-project` + +#### cloud sf-to-rgb +Convert the active scalar field to RGB colours. + +```bash +cli-anything-cloudcompare --project myproject.json cloud sf-to-rgb 0 \ + -o /data/coloured.ply --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--add-to-project` + +#### cloud rgb-to-sf +Convert RGB colours to a scalar field (luminance value). + +```bash +cli-anything-cloudcompare --project myproject.json cloud rgb-to-sf 0 \ + -o /data/luminance.las --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--add-to-project` + +#### cloud curvature +Compute curvature scalar field (MEAN or GAUSS). + +```bash +# Mean curvature with radius 0.5 m +cli-anything-cloudcompare --project myproject.json cloud curvature 0 \ + -o /data/curvature.las --type mean --radius 0.5 + +# Gaussian curvature +cli-anything-cloudcompare --project myproject.json cloud curvature 0 \ + -o /data/curvature.las --type gauss --radius 0.5 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--type [mean|gauss]`, `-r/--radius FLOAT`, `--add-to-project` + +#### cloud roughness +Compute roughness scalar field (deviation from local best-fit plane). + +```bash +cli-anything-cloudcompare --project myproject.json cloud roughness 0 \ + -o /data/roughness.las --radius 0.2 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `-r/--radius FLOAT`, `--add-to-project` + +#### cloud density +Compute point density scalar field. + +```bash +# KNN density +cli-anything-cloudcompare --project myproject.json cloud density 0 \ + -o /data/density.las --type knn --radius 0.5 + +# Surface density +cli-anything-cloudcompare --project myproject.json cloud density 0 \ + -o /data/density.las --type surface --radius 1.0 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `-r/--radius FLOAT`, `--type [knn|surface|volume]`, `--add-to-project` + +#### cloud segment-cc +Segment cloud into connected components (clusters). Each component is saved as a separate file. + +```bash +# Segment with octree level 8, minimum 100 points per component +cli-anything-cloudcompare --project myproject.json cloud segment-cc 0 \ + -o /data/components/ --octree-level 8 --min-points 100 + +# Save components as PLY files +cli-anything-cloudcompare --project myproject.json cloud segment-cc 0 \ + -o /data/components/ --octree-level 6 --min-points 50 --fmt ply +``` + +Options: `-o/--output-dir TEXT` (required), `--octree-level INTEGER`, `--min-points INTEGER`, `--fmt TEXT` + +#### cloud merge +Merge all clouds in the project into a single cloud. + +```bash +cli-anything-cloudcompare --project myproject.json cloud merge \ + -o /data/merged.las --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--add-to-project` + +#### cloud mesh-delaunay +Build a 2.5-D Delaunay triangulation mesh from a cloud. + +```bash +# Basic Delaunay mesh +cli-anything-cloudcompare --project myproject.json cloud mesh-delaunay 0 \ + -o /data/surface.obj + +# Best-fit plane with max edge length limit +cli-anything-cloudcompare --project myproject.json cloud mesh-delaunay 0 \ + -o /data/surface.ply --best-fit --max-edge-length 2.0 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `--best-fit`, `--max-edge-length FLOAT`, `--add-to-project` + +--- + +### 4. mesh — Mesh Operations (3 commands) + +#### mesh add +Add a mesh file to the project. + +```bash +cli-anything-cloudcompare --project myproject.json mesh add /data/model.obj + +# Add with label +cli-anything-cloudcompare --project myproject.json mesh add /data/model.ply -l "building model" +``` + +Options: `-l/--label TEXT` + +#### mesh list +List all meshes in the project. + +```bash +cli-anything-cloudcompare --project myproject.json mesh list + +# JSON output +cli-anything-cloudcompare --project myproject.json --json mesh list +``` + +#### mesh sample +Sample a point cloud from a mesh surface. + +```bash +# Sample 50 000 points from mesh at index 0 +cli-anything-cloudcompare --project myproject.json mesh sample 0 \ + -o /data/sampled.las -n 50000 + +# Add sampled cloud back to project +cli-anything-cloudcompare --project myproject.json mesh sample 0 \ + -o /data/sampled.las -n 100000 --add-to-project +``` + +Options: `-o/--output TEXT` (required), `-n/--count INTEGER`, `--add-to-project` + +--- + +### 5. distance — Distance Computation (2 commands) + +#### distance c2c +Compute cloud-to-cloud distances. Adds a distance scalar field to the compared cloud. + +```bash +# Compare cloud 1 to reference cloud 0 +cli-anything-cloudcompare --project myproject.json distance c2c \ + --compare 1 --reference 0 -o /data/distances.las + +# Split into X/Y/Z components at octree level 8 +cli-anything-cloudcompare --project myproject.json distance c2c \ + --compare 1 --reference 0 -o /data/distances.las \ + --split-xyz --octree-level 8 --add-to-project +``` + +Options: `--compare TEXT` (required), `--reference TEXT` (required), `-o/--output TEXT` (required), `--split-xyz`, `--octree-level INTEGER`, `--add-to-project` + +#### distance c2m +Compute cloud-to-mesh distances. Adds a distance scalar field to the cloud. + +```bash +# Basic cloud-to-mesh distance +cli-anything-cloudcompare --project myproject.json distance c2m \ + --cloud 0 --mesh 0 -o /data/c2m_dist.las + +# With flipped normals and unsigned distances +cli-anything-cloudcompare --project myproject.json distance c2m \ + --cloud 0 --mesh 0 -o /data/c2m_dist.las \ + --flip-normals --unsigned --add-to-project +``` + +Options: `--cloud INTEGER` (required), `--mesh INTEGER` (required), `-o/--output TEXT` (required), `--flip-normals`, `--unsigned`, `--add-to-project` + +--- + +### 6. transform — Transformations and Registration (2 commands) + +#### transform apply +Apply a 4×4 rigid-body transformation matrix to a cloud. + +```bash +# Apply a transformation matrix from file +cli-anything-cloudcompare --project myproject.json transform apply 0 \ + -o /data/transformed.las -m /data/matrix.txt + +# Apply the inverse transformation +cli-anything-cloudcompare --project myproject.json transform apply 0 \ + -o /data/transformed.las -m /data/matrix.txt --inverse --add-to-project +``` + +The matrix file must contain 4 rows of 4 space-separated values: +``` +1 0 0 0 +0 1 0 0 +0 0 1 0 +0 0 0 1 +``` + +Options: `-o/--output TEXT` (required), `-m/--matrix TEXT` (required), `--inverse`, `--add-to-project` + +#### transform icp +Run ICP (Iterative Closest Point) registration to align one cloud to another. + +```bash +# Basic ICP alignment +cli-anything-cloudcompare --project myproject.json transform icp \ + --aligned 1 --reference 0 -o /data/aligned.las + +# ICP with overlap and iteration control +cli-anything-cloudcompare --project myproject.json transform icp \ + --aligned 1 --reference 0 -o /data/aligned.las \ + --max-iter 50 --overlap 80 --min-error-diff 1e-6 --add-to-project +``` + +Options: `--aligned INTEGER` (required), `--reference INTEGER` (required), `-o/--output TEXT` (required), `--max-iter INTEGER`, `--min-error-diff FLOAT`, `--overlap FLOAT`, `--add-to-project` + +--- + +### 7. export — Export Clouds and Meshes (4 commands) + +#### export formats +List all available export format presets. + +```bash +cli-anything-cloudcompare export formats + +# JSON output +cli-anything-cloudcompare --json export formats +``` + +#### export cloud +Export a cloud to a target format. + +```bash +# Export cloud at index 0 as LAS +cli-anything-cloudcompare --project myproject.json export cloud 0 /data/output.las + +# Export as PLY using preset +cli-anything-cloudcompare --project myproject.json export cloud 0 /data/output.ply -f ply + +# Overwrite if file exists +cli-anything-cloudcompare --project myproject.json export cloud 0 /data/output.las -f las --overwrite +``` + +Supported presets: `las`, `laz`, `ply`, `pcd`, `xyz`, `asc`, `csv`, `bin`, `e57` + +Options: `-f/--preset TEXT`, `--overwrite` + +#### export mesh +Export a mesh to a target format. + +```bash +# Export mesh at index 0 as OBJ +cli-anything-cloudcompare --project myproject.json export mesh 0 /data/model.obj + +# Export as STL +cli-anything-cloudcompare --project myproject.json export mesh 0 /data/model.stl -f stl --overwrite +``` + +Supported presets: `obj`, `stl`, `ply`, `bin` + +Options: `-f/--preset TEXT`, `--overwrite` + +#### export batch +Batch export all project clouds to a directory. + +```bash +# Export all clouds as LAS +cli-anything-cloudcompare --project myproject.json export batch \ + -d /data/exports/ -f las + +# Overwrite existing files +cli-anything-cloudcompare --project myproject.json export batch \ + -d /data/exports/ -f ply --overwrite +``` + +Options: `-d/--output-dir TEXT` (required), `-f/--preset TEXT`, `--overwrite` + +--- + +### 8. info — Installation Info (1 command) + +Show CloudCompare installation path and version. + +```bash +cli-anything-cloudcompare info + +# JSON output +cli-anything-cloudcompare --json info +``` + +--- + +### 9. repl — Interactive REPL (1 command) + +Start the interactive REPL session with history and undo support. + +```bash +# Start REPL without a project +cli-anything-cloudcompare repl + +# Start REPL with an existing project +cli-anything-cloudcompare repl -p myproject.json + +# Equivalent: run without subcommand +cli-anything-cloudcompare --project myproject.json +``` + +Options: `-p/--project TEXT` + +Inside the REPL, type `help` to list available commands or `session undo` to revert the last operation. + +--- + +## Supported File Formats + +| Format | Extension | Read | Write | Notes | +|---|---|---|---|---| +| LAS | `.las` | ✓ | ✓ | LiDAR standard, supports intensity/RGB | +| LAZ | `.laz` | ✓ | ✓ | Compressed LAS | +| PLY | `.ply` | ✓ | ✓ | ASCII or binary | +| PCD | `.pcd` | ✓ | ✓ | PCL format | +| XYZ | `.xyz` | ✓ | ✓ | Plain text XYZ | +| ASC | `.asc` | ✓ | ✓ | ASCII with header | +| CSV | `.csv` | ✓ | ✓ | Comma-separated | +| E57 | `.e57` | ✓ | ✓ | ASTM scanner exchange | +| BIN | `.bin` | ✓ | ✓ | CloudCompare native binary | +| OBJ | `.obj` | ✓ | ✓ | Mesh (Wavefront) | +| STL | `.stl` | ✓ | ✓ | Mesh (3D printing) | + +--- + +## Typical Workflows + +### Workflow 1: LiDAR Pre-processing Pipeline + +```bash +P=myproject.json + +# 1. Create project and load scan +cli-anything-cloudcompare project new -o $P +cli-anything-cloudcompare --project $P cloud add /data/scan.las +cli-anything-cloudcompare --project $P cloud list # note index → 0 + +# 2. Denoise +cli-anything-cloudcompare --project $P cloud filter-sor 0 \ + -o /data/denoised.las --nb-points 6 --std-ratio 1.0 --add-to-project + +# 3. Subsample to 5 cm grid +cli-anything-cloudcompare --project $P cloud subsample 1 \ + -o /data/subsampled.las -m spatial -n 0.05 --add-to-project + +# 4. Extract ground plane (CSF) +cli-anything-cloudcompare --project $P cloud filter-csf 2 \ + --ground /data/ground.las --offground /data/objects.las \ + --scene relief --add-to-project + +# 5. Export result +cli-anything-cloudcompare --project $P export cloud 3 /data/ground_final.las -f las --overwrite +``` + +### Workflow 2: Change Detection Between Two Scans + +```bash +P=compare.json +cli-anything-cloudcompare project new -o $P +cli-anything-cloudcompare --project $P cloud add /data/scan_2023.las # index 0 +cli-anything-cloudcompare --project $P cloud add /data/scan_2024.las # index 1 + +# ICP alignment (align 2024 to 2023) +cli-anything-cloudcompare --project $P transform icp \ + --aligned 1 --reference 0 -o /data/aligned_2024.las \ + --overlap 90 --add-to-project # index 2 + +# Cloud-to-cloud distance +cli-anything-cloudcompare --project $P distance c2c \ + --compare 2 --reference 0 -o /data/change_map.las --add-to-project + +# Export as LAS with distance scalar field +cli-anything-cloudcompare --project $P export cloud 3 /data/change_map_final.las --overwrite +``` + +### Workflow 3: Height Slice Extraction + +```bash +P=slice.json +cli-anything-cloudcompare project new -o $P +cli-anything-cloudcompare --project $P cloud add /data/building.las + +# Extract points at 2–3 m height (floor level) +cli-anything-cloudcompare --project $P cloud sf-filter-z 0 \ + -o /data/floor_slice.las --min 2.0 --max 3.0 --add-to-project + +# Export +cli-anything-cloudcompare --project $P export cloud 1 /data/floor_slice_out.las --overwrite +``` + +### Workflow 4: Surface Reconstruction + +```bash +P=mesh.json +cli-anything-cloudcompare project new -o $P +cli-anything-cloudcompare --project $P cloud add /data/terrain.las + +# Compute normals +cli-anything-cloudcompare --project $P cloud normals 0 \ + -o /data/with_normals.ply --level 6 --orientation plus_z --add-to-project + +# Delaunay mesh +cli-anything-cloudcompare --project $P cloud mesh-delaunay 1 \ + -o /data/terrain_mesh.obj --max-edge-length 1.0 --add-to-project + +# Export mesh +cli-anything-cloudcompare --project $P export mesh 0 /data/terrain_mesh_final.obj --overwrite +``` + +--- + +## Error Handling + +| Exit Code | Meaning | +|---|---| +| `0` | Success | +| `1` | General error (see stderr for details) | +| `2` | Invalid arguments | + +Common errors: + +```bash +# CloudCompare not found +# → Install CloudCompare; check `cli-anything-cloudcompare info` + +# Index out of range +# → Run `cloud list` or `mesh list` to confirm valid indices + +# File already exists (no --overwrite) +# → Add --overwrite flag to export commands + +# fcntl not available (Windows) +# → File locking is skipped automatically; project save still works +``` + +--- + ## For AI Agents -When using this CLI programmatically: - 1. **Always use `--json` flag** for parseable output -2. **Check return codes** - 0 for success, non-zero for errors +2. **Check return codes** — 0 for success, non-zero for errors 3. **Parse stderr** for error messages on failure -4. **Use absolute paths** for all file operations -5. **Verify outputs exist** after export operations +4. **Use absolute paths** for all file arguments +5. **Verify output files exist** after export operations +6. **Chain with `--add-to-project`** to build multi-step pipelines without re-loading files +7. **Use `cloud list --json`** to discover valid cloud indices before each operation +8. **Use `export formats --json`** to discover available format presets ## Version -1.0.0 \ No newline at end of file +| Component | Version | +|---|---| +| cli-anything-cloudcompare | 1.0.0 | +| CloudCompare (tested) | 2.13.2 | +| Python (minimum) | 3.10 |