Files
syfffffjj 07027e8fde feat: huoyi M20 smooth/bridge training and stability docs
Current code snapshot: smooth/bridge task configs, smooth random terrain,
roll/pitch stability rewards, M20 URDF updates, eval profile and stability
training documentation.
2026-11-02 16:41:07 +08:00

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()