chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:55:23 +08:00
commit bc7eac8151
1701 changed files with 376767 additions and 0 deletions
+142
View File
@@ -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 <file>` | Open/load a file |
| `-SILENT` | Suppress GUI, run headlessly |
| `-SS METHOD PARAM` | Subsample (RANDOM/SPATIAL/OCTREE) |
| `-ROUGH <radius>` | Compute roughness SF |
| `-DENSITY <radius>` | Compute density SF |
| `-CURV TYPE <radius>` | Compute curvature SF |
| `-SOR <k> <std>` | 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 <level>` | Compute normals |
| `-C_EXPORT_FMT <fmt>` | Set cloud output format |
| `-M_EXPORT_FMT <fmt>` | Set mesh output format |
| `-NO_TIMESTAMP` | No timestamp suffix on output files |
| `-SAVE_CLOUDS FILE <path>` | Save to specific file |
| `-SAVE_MESHES FILE <path>` | 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
@@ -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'])"
```
@@ -0,0 +1,2 @@
"""cli-anything CloudCompare — CLI harness for CloudCompare 3D point cloud software."""
__version__ = "1.0.0"
@@ -0,0 +1,5 @@
"""Allow running as: python3 -m cli_anything.cloudcompare"""
from cli_anything.cloudcompare.cloudcompare_cli import main
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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)
@@ -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})"
@@ -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` (110), `--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 23 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 |
@@ -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`.
@@ -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")
@@ -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("<I", header, 107)[0]
except Exception:
return None
g = _las_point_count(ground_out)
u = _las_point_count(offground_out)
if g is not None and u is not None:
assert g + u == 125, f"Point count mismatch: {g} + {u} != 125"
print(f"\n Ground: {result['ground_size']:,} B Off-ground: {result['offground_size']:,} B")
def test_csf_cli_command(self, tmp_dir, project_json, scene_cloud):
"""cloud filter-csf via installed CLI subprocess."""
cli = _resolve_cli("cli-anything-cloudcompare")
ground_out = os.path.join(tmp_dir, "ground.las")
subprocess.run(cli + ["project", "new", "-o", project_json],
capture_output=True, check=True)
subprocess.run(cli + ["--project", project_json, "cloud", "add", scene_cloud],
capture_output=True, check=True)
r = subprocess.run(
cli + [
"--json", "--project", project_json,
"cloud", "filter-csf", "0",
"--ground", ground_out,
"--scene", "FLAT",
"--cloth-resolution", "0.5",
"--class-threshold", "0.3",
],
capture_output=True, text=True,
)
assert r.returncode == 0, f"CLI CSF failed:\nstdout={r.stdout}\nstderr={r.stderr}"
data = json.loads(r.stdout)
assert data["ground_exists"]
assert data["ground_size"] > 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)")
@@ -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
@@ -0,0 +1,567 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects repo-root or packaged SKILL.md
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
from pathlib import Path
# ── 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"
_SKILL_SOURCE_REPO = os.environ.get("CLI_ANYTHING_SKILL_REPO", "HKUDS/CLI-Anything")
# ── 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))
def _display_home_path(path: str) -> str:
"""Display a path relative to the home directory when possible."""
expanded = Path(path).expanduser().resolve()
home = Path.home().resolve()
try:
relative = expanded.relative_to(home)
return f"~/{relative.as_posix()}"
except ValueError:
return str(expanded)
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-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the repo-root skills/ tree when present,
otherwise from the package's skills/ directory.
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
software_aliases = {"iterm2_ctl": "iterm2"}
self.skill_slug = software_aliases.get(self.software, self.software).replace("_", "-")
self.skill_id = f"cli-anything-{self.skill_slug}"
self.skill_install_cmd = (
f"npx skills add {_SKILL_SOURCE_REPO} --skill {self.skill_id} -g -y"
)
global_skill_root = Path(
os.environ.get("CLI_ANYTHING_GLOBAL_SKILLS_DIR", str(Path.home() / ".agents" / "skills"))
).expanduser()
self.global_skill_path = str(global_skill_root / self.skill_id / "SKILL.md")
# Prefer repo-root canonical skills/<skill-id>/SKILL.md when running
# inside the CLI-Anything monorepo. Fall back to the packaged
# cli_anything/<software>/skills/SKILL.md for installed harnesses.
if skill_path is None:
package_skill = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
repo_skill = None
for parent in Path(__file__).resolve().parents:
candidate = parent / "skills" / self.skill_id / "SKILL.md"
if candidate.is_file():
repo_skill = candidate
break
if repo_skill and repo_skill.is_file():
skill_path = str(repo_skill)
elif package_skill.is_file():
skill_path = str(package_skill)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
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."""
import textwrap
inner = 72
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}"
def _meta_lines(label: str, value: str) -> list[str]:
"""Wrap a metadata line for the banner box."""
icon = self._c(_MAGENTA, "")
label_text = self._c(_DARK_GRAY, label)
prefix = f" {icon} {label_text} "
available = max(12, inner - _visible_len(prefix))
wrapped = textwrap.wrap(
value,
width=available,
break_long_words=True,
break_on_hyphens=False,
) or [""]
lines = [f"{prefix}{self._c(_LIGHT_GRAY, wrapped[0])}"]
continuation_prefix = " " * _visible_len(prefix)
for chunk in wrapped[1:]:
lines.append(f"{continuation_prefix}{self._c(_LIGHT_GRAY, chunk)}")
return lines
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 = ""
meta_lines: list[str] = []
meta_lines.extend(_meta_lines("Install:", self.skill_install_cmd))
meta_lines.extend(_meta_lines("Global skill:", _display_home_path(self.global_skill_path)))
print(top)
print(_box_line(title))
print(_box_line(ver))
for line in meta_lines:
print(_box_line(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
}
+34
View File
@@ -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",
],
)