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..9dd05b7cf 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 +โœ… 2,005 -> **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 2,005 tests โ€” 1,453 unit tests + 533 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..448370ff5 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/cloudcompare_cli.py @@ -0,0 +1,1648 @@ +"""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 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 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: + 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..3fb3d20fe --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/export.py @@ -0,0 +1,227 @@ +"""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}") + + # 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: + 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 + + 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: + 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}") + + # Resolve preset before overwrite check โ€” preset can change the output extension. + 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 + + 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, + "-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..f3126a4a7 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/core/project.py @@ -0,0 +1,285 @@ +"""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 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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +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: + 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() + 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..afa3a2ffd --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/skills/SKILL.md @@ -0,0 +1,789 @@ +--- +name: "cli-anything-cloudcompare" +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. + +**41 commands** across 9 groups. + +## Installation + +```bash +pip install cli-anything-cloudcompare +``` + +**Prerequisites:** +- Python 3.10+ +- CloudCompare installed on your system + - Linux (Flatpak): `flatpak install flathub org.cloudcompare.CloudCompare` + - macOS/Windows: download from https://cloudcompare.org + +**Tested with:** CloudCompare 2.13.2 (Flatpak, Linux) + +## Global Options + +These options must be placed **before** the subcommand: + +```bash +cli-anything-cloudcompare [--project FILE] [--json] COMMAND [ARGS]... +``` + +| Option | Description | +|---|---| +| `-p, --project TEXT` | Path to project JSON file | +| `--json` | Output results as JSON (for agent consumption) | + +## Command Groups + +### 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 + +# 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 +``` + +Options: `-o/--output TEXT` (required), `-n/--name TEXT` + +#### project info +Show project info and loaded entities. + +```bash +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 + +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 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 + +| Component | Version | +|---|---| +| cli-anything-cloudcompare | 1.0.0 | +| CloudCompare (tested) | 2.13.2 | +| Python (minimum) | 3.10 | 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..0767a3d42 --- /dev/null +++ b/cloudcompare/agent-harness/cli_anything/cloudcompare/utils/cc_backend.py @@ -0,0 +1,981 @@ +"""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)) + 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) + + 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) + 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 = [ + "-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) + + # Restrict matching to the current input stem to avoid picking up leftovers. + components = sorted( + glob.glob(os.path.join(in_dir, f"{input_stem}_COMPONENT_*.{actual_ext}")) + ) + + # 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 + + +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..a5a55ed83 --- /dev/null +++ b/cloudcompare/agent-harness/setup.py @@ -0,0 +1,34 @@ +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=_long_description, + 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..a8c949e07 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": "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", + "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" } ] }