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"
}
]
}