chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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` (1–10), `--orientation [plus_x|plus_y|plus_z|minus_x|minus_y|minus_z]`, `--add-to-project`
|
||||
|
||||
#### cloud invert-normals
|
||||
Flip all normal vectors in the cloud.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json cloud invert-normals 0 \
|
||||
-o /data/flipped_normals.ply --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--add-to-project`
|
||||
|
||||
#### cloud filter-sor
|
||||
Statistical Outlier Removal — removes isolated noise points.
|
||||
|
||||
```bash
|
||||
# Default parameters (k=6 neighbours, 1.0 std ratio)
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-sor 0 \
|
||||
-o /data/denoised.las
|
||||
|
||||
# Custom parameters
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-sor 0 \
|
||||
-o /data/denoised.las --nb-points 12 --std-ratio 2.0 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--nb-points INTEGER`, `--std-ratio FLOAT`, `--add-to-project`
|
||||
|
||||
#### cloud noise-filter
|
||||
Remove noisy points using the PCL noise filter (KNN or radius mode).
|
||||
|
||||
```bash
|
||||
# KNN mode (default)
|
||||
cli-anything-cloudcompare --project myproject.json cloud noise-filter 0 \
|
||||
-o /data/clean.las --knn 8 --noisiness 1.0
|
||||
|
||||
# Radius mode
|
||||
cli-anything-cloudcompare --project myproject.json cloud noise-filter 0 \
|
||||
-o /data/clean.las --radius 0.1 --use-radius --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--knn INTEGER`, `--noisiness FLOAT`, `--radius FLOAT`, `--use-radius`, `--absolute`, `--add-to-project`
|
||||
|
||||
#### cloud filter-csf
|
||||
Ground filtering using the Cloth Simulation Filter (CSF) algorithm. Separates ground from off-ground points (buildings, vegetation).
|
||||
|
||||
```bash
|
||||
# Extract ground only — mixed terrain
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-csf 0 \
|
||||
--ground /data/ground.las --scene relief
|
||||
|
||||
# Split ground + off-ground — urban scene
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-csf 0 \
|
||||
--ground /data/ground.las \
|
||||
--offground /data/buildings.las \
|
||||
--scene flat --cloth-resolution 0.5 --class-threshold 0.3
|
||||
|
||||
# Steep forested slope with slope post-processing
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-csf 0 \
|
||||
--ground /data/terrain.las --scene slope --proc-slope --add-to-project
|
||||
```
|
||||
|
||||
Options: `-g/--ground TEXT` (required), `-u/--offground TEXT`, `--scene [slope|relief|flat]`, `--cloth-resolution FLOAT`, `--class-threshold FLOAT`, `--max-iteration INTEGER`, `--proc-slope`, `--add-to-project`
|
||||
|
||||
#### cloud filter-sf
|
||||
Filter a cloud by scalar field value range (keep points where SF ∈ [min, max]).
|
||||
|
||||
```bash
|
||||
# Keep points with SF value between 10 and 50
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-sf 0 \
|
||||
-o /data/filtered.las --min 10.0 --max 50.0
|
||||
|
||||
# Filter using a specific SF index
|
||||
cli-anything-cloudcompare --project myproject.json cloud filter-sf 0 \
|
||||
-o /data/filtered.las --min 0.0 --max 1.5 --sf-index 2 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--min FLOAT` (required), `--max FLOAT` (required), `--sf-index INTEGER`, `--add-to-project`
|
||||
|
||||
#### cloud sf-from-coord
|
||||
Convert a coordinate axis (X/Y/Z) to a scalar field. Commonly used to create a height (Z) scalar field.
|
||||
|
||||
```bash
|
||||
# Create Z scalar field (height)
|
||||
cli-anything-cloudcompare --project myproject.json cloud sf-from-coord 0 \
|
||||
-o /data/with_z_sf.las --dim z --add-to-project
|
||||
|
||||
# Create X scalar field with a specific active index
|
||||
cli-anything-cloudcompare --project myproject.json cloud sf-from-coord 0 \
|
||||
-o /data/with_x_sf.las --dim x --sf-index 0
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--dim [x|y|z]` (default: z), `--sf-index INTEGER`, `--add-to-project`
|
||||
|
||||
#### cloud sf-filter-z
|
||||
Convenience command: convert Z → scalar field and filter by height range in one step.
|
||||
|
||||
```bash
|
||||
# Extract points between z=1.0 m and z=2.5 m
|
||||
cli-anything-cloudcompare --project myproject.json cloud sf-filter-z 0 \
|
||||
-o /data/slice.las --min 1.0 --max 2.5 --add-to-project
|
||||
|
||||
# Only apply upper bound
|
||||
cli-anything-cloudcompare --project myproject.json cloud sf-filter-z 0 \
|
||||
-o /data/below_5m.las --max 5.0
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--min FLOAT`, `--max FLOAT`, `--add-to-project`
|
||||
|
||||
#### cloud sf-to-rgb
|
||||
Convert the active scalar field to RGB colours.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json cloud sf-to-rgb 0 \
|
||||
-o /data/coloured.ply --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--add-to-project`
|
||||
|
||||
#### cloud rgb-to-sf
|
||||
Convert RGB colours to a scalar field (luminance value).
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json cloud rgb-to-sf 0 \
|
||||
-o /data/luminance.las --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--add-to-project`
|
||||
|
||||
#### cloud curvature
|
||||
Compute curvature scalar field (MEAN or GAUSS).
|
||||
|
||||
```bash
|
||||
# Mean curvature with radius 0.5 m
|
||||
cli-anything-cloudcompare --project myproject.json cloud curvature 0 \
|
||||
-o /data/curvature.las --type mean --radius 0.5
|
||||
|
||||
# Gaussian curvature
|
||||
cli-anything-cloudcompare --project myproject.json cloud curvature 0 \
|
||||
-o /data/curvature.las --type gauss --radius 0.5 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--type [mean|gauss]`, `-r/--radius FLOAT`, `--add-to-project`
|
||||
|
||||
#### cloud roughness
|
||||
Compute roughness scalar field (deviation from local best-fit plane).
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json cloud roughness 0 \
|
||||
-o /data/roughness.las --radius 0.2 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `-r/--radius FLOAT`, `--add-to-project`
|
||||
|
||||
#### cloud density
|
||||
Compute point density scalar field.
|
||||
|
||||
```bash
|
||||
# KNN density
|
||||
cli-anything-cloudcompare --project myproject.json cloud density 0 \
|
||||
-o /data/density.las --type knn --radius 0.5
|
||||
|
||||
# Surface density
|
||||
cli-anything-cloudcompare --project myproject.json cloud density 0 \
|
||||
-o /data/density.las --type surface --radius 1.0 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `-r/--radius FLOAT`, `--type [knn|surface|volume]`, `--add-to-project`
|
||||
|
||||
#### cloud segment-cc
|
||||
Segment cloud into connected components (clusters). Each component is saved as a separate file.
|
||||
|
||||
```bash
|
||||
# Segment with octree level 8, minimum 100 points per component
|
||||
cli-anything-cloudcompare --project myproject.json cloud segment-cc 0 \
|
||||
-o /data/components/ --octree-level 8 --min-points 100
|
||||
|
||||
# Save components as PLY files
|
||||
cli-anything-cloudcompare --project myproject.json cloud segment-cc 0 \
|
||||
-o /data/components/ --octree-level 6 --min-points 50 --fmt ply
|
||||
```
|
||||
|
||||
Options: `-o/--output-dir TEXT` (required), `--octree-level INTEGER`, `--min-points INTEGER`, `--fmt TEXT`
|
||||
|
||||
#### cloud merge
|
||||
Merge all clouds in the project into a single cloud.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json cloud merge \
|
||||
-o /data/merged.las --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--add-to-project`
|
||||
|
||||
#### cloud mesh-delaunay
|
||||
Build a 2.5-D Delaunay triangulation mesh from a cloud.
|
||||
|
||||
```bash
|
||||
# Basic Delaunay mesh
|
||||
cli-anything-cloudcompare --project myproject.json cloud mesh-delaunay 0 \
|
||||
-o /data/surface.obj
|
||||
|
||||
# Best-fit plane with max edge length limit
|
||||
cli-anything-cloudcompare --project myproject.json cloud mesh-delaunay 0 \
|
||||
-o /data/surface.ply --best-fit --max-edge-length 2.0 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `--best-fit`, `--max-edge-length FLOAT`, `--add-to-project`
|
||||
|
||||
---
|
||||
|
||||
### 4. mesh — Mesh Operations (3 commands)
|
||||
|
||||
#### mesh add
|
||||
Add a mesh file to the project.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json mesh add /data/model.obj
|
||||
|
||||
# Add with label
|
||||
cli-anything-cloudcompare --project myproject.json mesh add /data/model.ply -l "building model"
|
||||
```
|
||||
|
||||
Options: `-l/--label TEXT`
|
||||
|
||||
#### mesh list
|
||||
List all meshes in the project.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare --project myproject.json mesh list
|
||||
|
||||
# JSON output
|
||||
cli-anything-cloudcompare --project myproject.json --json mesh list
|
||||
```
|
||||
|
||||
#### mesh sample
|
||||
Sample a point cloud from a mesh surface.
|
||||
|
||||
```bash
|
||||
# Sample 50 000 points from mesh at index 0
|
||||
cli-anything-cloudcompare --project myproject.json mesh sample 0 \
|
||||
-o /data/sampled.las -n 50000
|
||||
|
||||
# Add sampled cloud back to project
|
||||
cli-anything-cloudcompare --project myproject.json mesh sample 0 \
|
||||
-o /data/sampled.las -n 100000 --add-to-project
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `-n/--count INTEGER`, `--add-to-project`
|
||||
|
||||
---
|
||||
|
||||
### 5. distance — Distance Computation (2 commands)
|
||||
|
||||
#### distance c2c
|
||||
Compute cloud-to-cloud distances. Adds a distance scalar field to the compared cloud.
|
||||
|
||||
```bash
|
||||
# Compare cloud 1 to reference cloud 0
|
||||
cli-anything-cloudcompare --project myproject.json distance c2c \
|
||||
--compare 1 --reference 0 -o /data/distances.las
|
||||
|
||||
# Split into X/Y/Z components at octree level 8
|
||||
cli-anything-cloudcompare --project myproject.json distance c2c \
|
||||
--compare 1 --reference 0 -o /data/distances.las \
|
||||
--split-xyz --octree-level 8 --add-to-project
|
||||
```
|
||||
|
||||
Options: `--compare TEXT` (required), `--reference TEXT` (required), `-o/--output TEXT` (required), `--split-xyz`, `--octree-level INTEGER`, `--add-to-project`
|
||||
|
||||
#### distance c2m
|
||||
Compute cloud-to-mesh distances. Adds a distance scalar field to the cloud.
|
||||
|
||||
```bash
|
||||
# Basic cloud-to-mesh distance
|
||||
cli-anything-cloudcompare --project myproject.json distance c2m \
|
||||
--cloud 0 --mesh 0 -o /data/c2m_dist.las
|
||||
|
||||
# With flipped normals and unsigned distances
|
||||
cli-anything-cloudcompare --project myproject.json distance c2m \
|
||||
--cloud 0 --mesh 0 -o /data/c2m_dist.las \
|
||||
--flip-normals --unsigned --add-to-project
|
||||
```
|
||||
|
||||
Options: `--cloud INTEGER` (required), `--mesh INTEGER` (required), `-o/--output TEXT` (required), `--flip-normals`, `--unsigned`, `--add-to-project`
|
||||
|
||||
---
|
||||
|
||||
### 6. transform — Transformations and Registration (2 commands)
|
||||
|
||||
#### transform apply
|
||||
Apply a 4×4 rigid-body transformation matrix to a cloud.
|
||||
|
||||
```bash
|
||||
# Apply a transformation matrix from file
|
||||
cli-anything-cloudcompare --project myproject.json transform apply 0 \
|
||||
-o /data/transformed.las -m /data/matrix.txt
|
||||
|
||||
# Apply the inverse transformation
|
||||
cli-anything-cloudcompare --project myproject.json transform apply 0 \
|
||||
-o /data/transformed.las -m /data/matrix.txt --inverse --add-to-project
|
||||
```
|
||||
|
||||
The matrix file must contain 4 rows of 4 space-separated values:
|
||||
```
|
||||
1 0 0 0
|
||||
0 1 0 0
|
||||
0 0 1 0
|
||||
0 0 0 1
|
||||
```
|
||||
|
||||
Options: `-o/--output TEXT` (required), `-m/--matrix TEXT` (required), `--inverse`, `--add-to-project`
|
||||
|
||||
#### transform icp
|
||||
Run ICP (Iterative Closest Point) registration to align one cloud to another.
|
||||
|
||||
```bash
|
||||
# Basic ICP alignment
|
||||
cli-anything-cloudcompare --project myproject.json transform icp \
|
||||
--aligned 1 --reference 0 -o /data/aligned.las
|
||||
|
||||
# ICP with overlap and iteration control
|
||||
cli-anything-cloudcompare --project myproject.json transform icp \
|
||||
--aligned 1 --reference 0 -o /data/aligned.las \
|
||||
--max-iter 50 --overlap 80 --min-error-diff 1e-6 --add-to-project
|
||||
```
|
||||
|
||||
Options: `--aligned INTEGER` (required), `--reference INTEGER` (required), `-o/--output TEXT` (required), `--max-iter INTEGER`, `--min-error-diff FLOAT`, `--overlap FLOAT`, `--add-to-project`
|
||||
|
||||
---
|
||||
|
||||
### 7. export — Export Clouds and Meshes (4 commands)
|
||||
|
||||
#### export formats
|
||||
List all available export format presets.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare export formats
|
||||
|
||||
# JSON output
|
||||
cli-anything-cloudcompare --json export formats
|
||||
```
|
||||
|
||||
#### export cloud
|
||||
Export a cloud to a target format.
|
||||
|
||||
```bash
|
||||
# Export cloud at index 0 as LAS
|
||||
cli-anything-cloudcompare --project myproject.json export cloud 0 /data/output.las
|
||||
|
||||
# Export as PLY using preset
|
||||
cli-anything-cloudcompare --project myproject.json export cloud 0 /data/output.ply -f ply
|
||||
|
||||
# Overwrite if file exists
|
||||
cli-anything-cloudcompare --project myproject.json export cloud 0 /data/output.las -f las --overwrite
|
||||
```
|
||||
|
||||
Supported presets: `las`, `laz`, `ply`, `pcd`, `xyz`, `asc`, `csv`, `bin`, `e57`
|
||||
|
||||
Options: `-f/--preset TEXT`, `--overwrite`
|
||||
|
||||
#### export mesh
|
||||
Export a mesh to a target format.
|
||||
|
||||
```bash
|
||||
# Export mesh at index 0 as OBJ
|
||||
cli-anything-cloudcompare --project myproject.json export mesh 0 /data/model.obj
|
||||
|
||||
# Export as STL
|
||||
cli-anything-cloudcompare --project myproject.json export mesh 0 /data/model.stl -f stl --overwrite
|
||||
```
|
||||
|
||||
Supported presets: `obj`, `stl`, `ply`, `bin`
|
||||
|
||||
Options: `-f/--preset TEXT`, `--overwrite`
|
||||
|
||||
#### export batch
|
||||
Batch export all project clouds to a directory.
|
||||
|
||||
```bash
|
||||
# Export all clouds as LAS
|
||||
cli-anything-cloudcompare --project myproject.json export batch \
|
||||
-d /data/exports/ -f las
|
||||
|
||||
# Overwrite existing files
|
||||
cli-anything-cloudcompare --project myproject.json export batch \
|
||||
-d /data/exports/ -f ply --overwrite
|
||||
```
|
||||
|
||||
Options: `-d/--output-dir TEXT` (required), `-f/--preset TEXT`, `--overwrite`
|
||||
|
||||
---
|
||||
|
||||
### 8. info — Installation Info (1 command)
|
||||
|
||||
Show CloudCompare installation path and version.
|
||||
|
||||
```bash
|
||||
cli-anything-cloudcompare info
|
||||
|
||||
# JSON output
|
||||
cli-anything-cloudcompare --json info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. repl — Interactive REPL (1 command)
|
||||
|
||||
Start the interactive REPL session with history and undo support.
|
||||
|
||||
```bash
|
||||
# Start REPL without a project
|
||||
cli-anything-cloudcompare repl
|
||||
|
||||
# Start REPL with an existing project
|
||||
cli-anything-cloudcompare repl -p myproject.json
|
||||
|
||||
# Equivalent: run without subcommand
|
||||
cli-anything-cloudcompare --project myproject.json
|
||||
```
|
||||
|
||||
Options: `-p/--project TEXT`
|
||||
|
||||
Inside the REPL, type `help` to list available commands or `session undo` to revert the last operation.
|
||||
|
||||
---
|
||||
|
||||
## Supported File Formats
|
||||
|
||||
| Format | Extension | Read | Write | Notes |
|
||||
|---|---|---|---|---|
|
||||
| LAS | `.las` | ✓ | ✓ | LiDAR standard, supports intensity/RGB |
|
||||
| LAZ | `.laz` | ✓ | ✓ | Compressed LAS |
|
||||
| PLY | `.ply` | ✓ | ✓ | ASCII or binary |
|
||||
| PCD | `.pcd` | ✓ | ✓ | PCL format |
|
||||
| XYZ | `.xyz` | ✓ | ✓ | Plain text XYZ |
|
||||
| ASC | `.asc` | ✓ | ✓ | ASCII with header |
|
||||
| CSV | `.csv` | ✓ | ✓ | Comma-separated |
|
||||
| E57 | `.e57` | ✓ | ✓ | ASTM scanner exchange |
|
||||
| BIN | `.bin` | ✓ | ✓ | CloudCompare native binary |
|
||||
| OBJ | `.obj` | ✓ | ✓ | Mesh (Wavefront) |
|
||||
| STL | `.stl` | ✓ | ✓ | Mesh (3D printing) |
|
||||
|
||||
---
|
||||
|
||||
## Typical Workflows
|
||||
|
||||
### Workflow 1: LiDAR Pre-processing Pipeline
|
||||
|
||||
```bash
|
||||
P=myproject.json
|
||||
|
||||
# 1. Create project and load scan
|
||||
cli-anything-cloudcompare project new -o $P
|
||||
cli-anything-cloudcompare --project $P cloud add /data/scan.las
|
||||
cli-anything-cloudcompare --project $P cloud list # note index → 0
|
||||
|
||||
# 2. Denoise
|
||||
cli-anything-cloudcompare --project $P cloud filter-sor 0 \
|
||||
-o /data/denoised.las --nb-points 6 --std-ratio 1.0 --add-to-project
|
||||
|
||||
# 3. Subsample to 5 cm grid
|
||||
cli-anything-cloudcompare --project $P cloud subsample 1 \
|
||||
-o /data/subsampled.las -m spatial -n 0.05 --add-to-project
|
||||
|
||||
# 4. Extract ground plane (CSF)
|
||||
cli-anything-cloudcompare --project $P cloud filter-csf 2 \
|
||||
--ground /data/ground.las --offground /data/objects.las \
|
||||
--scene relief --add-to-project
|
||||
|
||||
# 5. Export result
|
||||
cli-anything-cloudcompare --project $P export cloud 3 /data/ground_final.las -f las --overwrite
|
||||
```
|
||||
|
||||
### Workflow 2: Change Detection Between Two Scans
|
||||
|
||||
```bash
|
||||
P=compare.json
|
||||
cli-anything-cloudcompare project new -o $P
|
||||
cli-anything-cloudcompare --project $P cloud add /data/scan_2023.las # index 0
|
||||
cli-anything-cloudcompare --project $P cloud add /data/scan_2024.las # index 1
|
||||
|
||||
# ICP alignment (align 2024 to 2023)
|
||||
cli-anything-cloudcompare --project $P transform icp \
|
||||
--aligned 1 --reference 0 -o /data/aligned_2024.las \
|
||||
--overlap 90 --add-to-project # index 2
|
||||
|
||||
# Cloud-to-cloud distance
|
||||
cli-anything-cloudcompare --project $P distance c2c \
|
||||
--compare 2 --reference 0 -o /data/change_map.las --add-to-project
|
||||
|
||||
# Export as LAS with distance scalar field
|
||||
cli-anything-cloudcompare --project $P export cloud 3 /data/change_map_final.las --overwrite
|
||||
```
|
||||
|
||||
### Workflow 3: Height Slice Extraction
|
||||
|
||||
```bash
|
||||
P=slice.json
|
||||
cli-anything-cloudcompare project new -o $P
|
||||
cli-anything-cloudcompare --project $P cloud add /data/building.las
|
||||
|
||||
# Extract points at 2–3 m height (floor level)
|
||||
cli-anything-cloudcompare --project $P cloud sf-filter-z 0 \
|
||||
-o /data/floor_slice.las --min 2.0 --max 3.0 --add-to-project
|
||||
|
||||
# Export
|
||||
cli-anything-cloudcompare --project $P export cloud 1 /data/floor_slice_out.las --overwrite
|
||||
```
|
||||
|
||||
### Workflow 4: Surface Reconstruction
|
||||
|
||||
```bash
|
||||
P=mesh.json
|
||||
cli-anything-cloudcompare project new -o $P
|
||||
cli-anything-cloudcompare --project $P cloud add /data/terrain.las
|
||||
|
||||
# Compute normals
|
||||
cli-anything-cloudcompare --project $P cloud normals 0 \
|
||||
-o /data/with_normals.ply --level 6 --orientation plus_z --add-to-project
|
||||
|
||||
# Delaunay mesh
|
||||
cli-anything-cloudcompare --project $P cloud mesh-delaunay 1 \
|
||||
-o /data/terrain_mesh.obj --max-edge-length 1.0 --add-to-project
|
||||
|
||||
# Export mesh
|
||||
cli-anything-cloudcompare --project $P export mesh 0 /data/terrain_mesh_final.obj --overwrite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Exit Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Success |
|
||||
| `1` | General error (see stderr for details) |
|
||||
| `2` | Invalid arguments |
|
||||
|
||||
Common errors:
|
||||
|
||||
```bash
|
||||
# CloudCompare not found
|
||||
# → Install CloudCompare; check `cli-anything-cloudcompare info`
|
||||
|
||||
# Index out of range
|
||||
# → Run `cloud list` or `mesh list` to confirm valid indices
|
||||
|
||||
# File already exists (no --overwrite)
|
||||
# → Add --overwrite flag to export commands
|
||||
|
||||
# fcntl not available (Windows)
|
||||
# → File locking is skipped automatically; project save still works
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For AI Agents
|
||||
|
||||
1. **Always use `--json` flag** for parseable output
|
||||
2. **Check return codes** — 0 for success, non-zero for errors
|
||||
3. **Parse stderr** for error messages on failure
|
||||
4. **Use absolute paths** for all file arguments
|
||||
5. **Verify output files exist** after export operations
|
||||
6. **Chain with `--add-to-project`** to build multi-step pipelines without re-loading files
|
||||
7. **Use `cloud list --json`** to discover valid cloud indices before each operation
|
||||
8. **Use `export formats --json`** to discover available format presets
|
||||
|
||||
## Version
|
||||
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| cli-anything-cloudcompare | 1.0.0 |
|
||||
| CloudCompare (tested) | 2.13.2 |
|
||||
| Python (minimum) | 3.10 |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user