101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Create lightweight visual STL meshes by deterministic vertex clustering.
|
|
|
|
The source meshes are left untouched. This optimization is intended only for
|
|
URDF ``visual`` geometry; collision geometry and all physical properties stay
|
|
in the URDF. Vertices are snapped to a regular grid, so the maximum movement
|
|
of any source vertex is bounded by ``sqrt(3) * grid_mm / 2``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import trimesh
|
|
|
|
|
|
def optimize_mesh(source: Path, destination: Path, grid_mm: float) -> dict[str, object]:
|
|
mesh = trimesh.load_mesh(source, process=True)
|
|
source_vertices = len(mesh.vertices)
|
|
source_faces = len(mesh.faces)
|
|
|
|
# Round each vertex to its nearest grid point. The rounding makes the
|
|
# geometric error bounded and gives deterministic output across runs.
|
|
clustered_vertices = np.rint(mesh.vertices / grid_mm).astype(np.int64)
|
|
unique_grid_vertices, inverse = np.unique(clustered_vertices, axis=0, return_inverse=True)
|
|
faces = inverse[mesh.faces]
|
|
|
|
# Remove triangles collapsed by clustering.
|
|
valid = (
|
|
(faces[:, 0] != faces[:, 1])
|
|
& (faces[:, 1] != faces[:, 2])
|
|
& (faces[:, 2] != faces[:, 0])
|
|
)
|
|
faces = faces[valid]
|
|
|
|
# Remove duplicate triangles while preserving the winding of the first
|
|
# occurrence. Sorting is used only to detect duplicates.
|
|
canonical_faces = np.sort(faces, axis=1)
|
|
_, first_indices = np.unique(canonical_faces, axis=0, return_index=True)
|
|
faces = faces[np.sort(first_indices)]
|
|
|
|
vertices = unique_grid_vertices.astype(np.float64) * grid_mm
|
|
result = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
|
|
result.remove_unreferenced_vertices()
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
result.export(destination, file_type="stl")
|
|
|
|
return {
|
|
"file": source.name,
|
|
"source_vertices": source_vertices,
|
|
"source_faces": source_faces,
|
|
"optimized_vertices": len(result.vertices),
|
|
"optimized_faces": len(result.faces),
|
|
"source_bytes": source.stat().st_size,
|
|
"optimized_bytes": destination.stat().st_size,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("source_dir", type=Path)
|
|
parser.add_argument("output_dir", type=Path)
|
|
parser.add_argument("--grid-mm", type=float, default=1.0)
|
|
args = parser.parse_args()
|
|
|
|
if args.grid_mm <= 0.0:
|
|
raise ValueError("--grid-mm must be positive")
|
|
|
|
source_dir = args.source_dir.resolve()
|
|
output_dir = args.output_dir.resolve()
|
|
records = [
|
|
optimize_mesh(source, output_dir / source.name, args.grid_mm)
|
|
for source in sorted(source_dir.glob("*.STL"))
|
|
]
|
|
if not records:
|
|
raise RuntimeError(f"No STL files found in {source_dir}")
|
|
|
|
manifest = {
|
|
"source_dir": str(source_dir),
|
|
"grid_mm": args.grid_mm,
|
|
"maximum_vertex_displacement_mm": math.sqrt(3.0) * args.grid_mm / 2.0,
|
|
"source_faces": sum(int(record["source_faces"]) for record in records),
|
|
"optimized_faces": sum(int(record["optimized_faces"]) for record in records),
|
|
"source_bytes": sum(int(record["source_bytes"]) for record in records),
|
|
"optimized_bytes": sum(int(record["optimized_bytes"]) for record in records),
|
|
"meshes": records,
|
|
}
|
|
(output_dir / "optimization_manifest.json").write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|