urdf-mesh-optimizer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
optimizer/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """urdf-mesh-optimizer: lightweight visual and collision meshes for ROS 2 / MoveIt."""
2
+
3
+ __version__ = "0.2.0"
optimizer/cli.py ADDED
@@ -0,0 +1,404 @@
1
+ """
2
+ cli.py - Command-line interface for urdf-mesh-optimizer.
3
+
4
+ Single file:
5
+ urdf-mesh-optimizer --input model.stl --output model_small.obj --triangles 5000
6
+ urdf-mesh-optimizer --input model.stl --output model_small.obj --reduction 80
7
+
8
+ URDF pair (visual + collision):
9
+ urdf-mesh-optimizer --input cell.stl --urdf-output ./output \
10
+ --visual 20000 --collision 3000
11
+
12
+ Batch (folder):
13
+ urdf-mesh-optimizer --folder ./cell_assets --output ./output --collision 5000
14
+
15
+ Whole robot (every mesh in a URDF, URIs rewritten):
16
+ urdf-mesh-optimizer --urdf my_robot.urdf --output ./optimized --collision 3000
17
+
18
+ Exit codes:
19
+ 0 success
20
+ 1 a known, reported error (bad input, bad mesh, bad arguments combination
21
+ caught at runtime rather than by argparse)
22
+ 2 argument parsing error (raised by argparse itself, e.g. missing a
23
+ required flag)
24
+ """
25
+
26
+ import argparse
27
+ import logging
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ from optimizer import __version__
32
+ from optimizer.core import (
33
+ SUPPORTED_FORMATS,
34
+ clean_mesh,
35
+ load_mesh,
36
+ resolve_output_path,
37
+ resolve_target_triangles,
38
+ save_mesh,
39
+ simplify_mesh,
40
+ )
41
+ from optimizer.metrics import build_report, print_report, save_report
42
+ from optimizer.urdf_export import export_urdf_pair
43
+ from optimizer.urdf_robot import UrdfError, optimize_urdf, print_urdf_summary
44
+
45
+ log = logging.getLogger("optimizer")
46
+
47
+
48
+ class CliError(Exception):
49
+ """A known, user-facing error. Caught in main() and reported with exit code 1."""
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _process_single(args: argparse.Namespace) -> None:
58
+ log.info("Loading '%s' …", args.input)
59
+ mesh = load_mesh(args.input)
60
+ mesh = clean_mesh(mesh)
61
+
62
+ target = resolve_target_triangles(mesh, args.triangles, args.reduction)
63
+ output_path = resolve_output_path(args.output, args.format)
64
+
65
+ if args.dry_run:
66
+ print(
67
+ f"[dry-run] Would simplify '{args.input}' ({len(mesh.triangles):,} triangles) "
68
+ f"→ {target:,} triangles"
69
+ )
70
+ print(f"[dry-run] Would write → '{output_path}'")
71
+ if args.save_report:
72
+ report_path = str(Path(output_path).with_suffix(".report.json"))
73
+ print(f"[dry-run] Would save report → '{report_path}'")
74
+ return
75
+
76
+ log.info("Simplifying to %s triangles …", f"{target:,}")
77
+ result, elapsed = simplify_mesh(mesh, target)
78
+
79
+ save_mesh(result, output_path)
80
+ log.info("Saved → '%s'", output_path)
81
+
82
+ report = build_report(
83
+ args.input,
84
+ output_path,
85
+ mesh,
86
+ result,
87
+ elapsed,
88
+ compute_hausdorff=not args.skip_hausdorff,
89
+ )
90
+ print_report(report)
91
+
92
+ if args.save_report:
93
+ report_path = str(Path(output_path).with_suffix(".report.json"))
94
+ save_report(report, report_path)
95
+
96
+
97
+ def _process_urdf(args: argparse.Namespace) -> None:
98
+ if args.visual is None or args.collision is None:
99
+ raise CliError("--urdf-output requires both --visual and --collision triangle counts.")
100
+
101
+ if args.dry_run:
102
+ mesh = load_mesh(args.input)
103
+ stem = Path(args.input).stem
104
+ out = Path(args.urdf_output)
105
+ visual_path = out / f"{stem}_visual.stl"
106
+ collision_path = out / f"{stem}_collision.stl"
107
+ snippet_path = out / f"{stem}_urdf_snippet.xml"
108
+ print(f"[dry-run] Would simplify '{args.input}' ({len(mesh.triangles):,} triangles)")
109
+ print(f"[dry-run] Would write → '{visual_path}' ({args.visual:,} triangles)")
110
+ print(f"[dry-run] Would write → '{collision_path}' ({args.collision:,} triangles)")
111
+ print(f"[dry-run] Would write → '{snippet_path}'")
112
+ return
113
+
114
+ export_urdf_pair(
115
+ input_path=args.input,
116
+ output_dir=args.urdf_output,
117
+ visual_triangles=args.visual,
118
+ collision_triangles=args.collision,
119
+ compute_hausdorff=not args.skip_hausdorff,
120
+ )
121
+
122
+
123
+ def _process_batch(args: argparse.Namespace) -> None:
124
+ folder = Path(args.folder)
125
+ if not folder.is_dir():
126
+ raise CliError(f"'{folder}' is not a directory.")
127
+
128
+ out_dir = Path(args.output) if args.output else folder / "optimized"
129
+ files = sorted(f for f in folder.iterdir() if f.suffix.lower() in SUPPORTED_FORMATS)
130
+
131
+ if not files:
132
+ raise CliError(f"No supported mesh files found in '{folder}'.")
133
+
134
+ if args.dry_run:
135
+ print(f"[dry-run] Would process {len(files)} mesh(es) in '{folder}' → '{out_dir}'")
136
+ for f in files:
137
+ print(f"[dry-run] {f.name}")
138
+ return
139
+
140
+ out_dir.mkdir(parents=True, exist_ok=True)
141
+ log.info("Found %d mesh(es) in '%s'. Processing …", len(files), folder)
142
+ all_reports = []
143
+ failures = 0
144
+
145
+ for f in files:
146
+ try:
147
+ mesh = load_mesh(str(f))
148
+ mesh = clean_mesh(mesh)
149
+ target = resolve_target_triangles(mesh, args.triangles, args.reduction)
150
+ result, elapsed = simplify_mesh(mesh, target)
151
+ out_path = resolve_output_path(str(out_dir / f.name), args.format)
152
+ save_mesh(result, out_path)
153
+ report = build_report(
154
+ str(f),
155
+ out_path,
156
+ mesh,
157
+ result,
158
+ elapsed,
159
+ compute_hausdorff=not args.skip_hausdorff,
160
+ )
161
+ print_report(report)
162
+ all_reports.append(report)
163
+ except Exception as exc:
164
+ log.warning("Skipping '%s': %s", f.name, exc)
165
+ failures += 1
166
+
167
+ if args.save_report:
168
+ import json
169
+
170
+ report_path = out_dir / "batch_report.json"
171
+ with open(report_path, "w") as fh:
172
+ json.dump(all_reports, fh, indent=2)
173
+ log.info("Batch report saved → %s", report_path)
174
+
175
+ if failures and not all_reports:
176
+ raise CliError(f"All {failures} file(s) in the batch failed to process.")
177
+
178
+
179
+ def _parse_package_paths(entries: list[str] | None) -> dict[str, str]:
180
+ paths = {}
181
+ for entry in entries or []:
182
+ name, sep, path = entry.partition("=")
183
+ if not sep or not name or not path:
184
+ raise CliError(f"--package-path expects NAME=DIR, got '{entry}'.")
185
+ paths[name] = path
186
+ return paths
187
+
188
+
189
+ def _process_robot_urdf(args: argparse.Namespace) -> None:
190
+ if args.collision is None:
191
+ raise CliError("--urdf requires --collision (per-mesh collision triangle budget).")
192
+ if args.output is None:
193
+ raise CliError("--urdf requires --output (directory for the rewritten URDF + meshes).")
194
+ try:
195
+ summary = optimize_urdf(
196
+ args.urdf,
197
+ args.output,
198
+ collision_triangles=args.collision,
199
+ visual_triangles=args.visual,
200
+ package_paths=_parse_package_paths(args.package_path),
201
+ mesh_uri_prefix=args.mesh_uri_prefix,
202
+ compute_hausdorff=not args.skip_hausdorff,
203
+ dry_run=args.dry_run,
204
+ )
205
+ except UrdfError as exc:
206
+ raise CliError(str(exc)) from exc
207
+ print_urdf_summary(summary, dry_run=args.dry_run)
208
+ if not args.dry_run and not args.mesh_uri_prefix:
209
+ log.info(
210
+ "Mesh URIs are relative to the output URDF. For RViz / MoveIt, install the output "
211
+ "into a package and pass --mesh-uri-prefix package://<pkg>/<dir>."
212
+ )
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Parser
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ def build_parser() -> argparse.ArgumentParser:
221
+ p = argparse.ArgumentParser(
222
+ prog="urdf-mesh-optimizer",
223
+ description=(
224
+ "Mesh optimization toolkit for ROS2, MoveIt, and digital twins: "
225
+ "decimation, URDF export, and geometric validation."
226
+ ),
227
+ formatter_class=argparse.RawDescriptionHelpFormatter,
228
+ epilog="""
229
+ Examples:
230
+ # Reduce a single file to 5 000 triangles
231
+ urdf-mesh-optimizer --input gear.stl --output gear_small.obj --triangles 5000
232
+
233
+ # Reduce by percentage
234
+ urdf-mesh-optimizer --input env.ply --output env_small.ply --reduction 80
235
+
236
+ # Preview what a run would do without writing anything
237
+ urdf-mesh-optimizer --input gear.stl --output gear_small.obj --triangles 5000 --dry-run
238
+
239
+ # Generate URDF-ready visual + collision pair
240
+ urdf-mesh-optimizer --input robot_cell.stl --urdf-output ./output \\
241
+ --visual 20000 --collision 3000
242
+
243
+ # Batch-process a whole folder, forcing OBJ output regardless of source format
244
+ urdf-mesh-optimizer --folder ./cell_assets --output ./optimized \\
245
+ --collision 5000 --format obj
246
+
247
+ # Optimize every mesh in a robot description, rewriting the URDF to match
248
+ urdf-mesh-optimizer --urdf ur5e.urdf --output ./ur5e_optimized --collision 2000 \\
249
+ --mesh-uri-prefix package://ur5e_description/meshes_optimized
250
+
251
+ # Verbose logging for debugging a large batch job
252
+ urdf-mesh-optimizer --folder ./cell_assets --collision 5000 -v
253
+ """,
254
+ )
255
+
256
+ p.add_argument("--version", action="version", version=f"urdf-mesh-optimizer {__version__}")
257
+
258
+ # --- Input modes ---
259
+ input_group = p.add_mutually_exclusive_group(required=True)
260
+ input_group.add_argument("--input", metavar="FILE", help="Single input mesh file.")
261
+ input_group.add_argument("--folder", metavar="DIR", help="Folder of mesh files (batch mode).")
262
+ input_group.add_argument(
263
+ "--urdf",
264
+ metavar="FILE",
265
+ help="Robot URDF: optimize every mesh it references and write a rewritten copy "
266
+ "(robot mode). Requires --output DIR and --collision N; --visual N is optional.",
267
+ )
268
+
269
+ # --- Output ---
270
+ p.add_argument(
271
+ "--output",
272
+ metavar="PATH",
273
+ help="Output file (single mode) or output directory (batch mode).",
274
+ )
275
+ p.add_argument(
276
+ "--urdf-output",
277
+ metavar="DIR",
278
+ help="Output directory for URDF pair (activates URDF mode).",
279
+ )
280
+ p.add_argument(
281
+ "--format",
282
+ metavar="EXT",
283
+ help="Force output format (e.g. 'obj', 'stl'), overriding the output "
284
+ "path's extension. Applies to single and batch modes.",
285
+ )
286
+
287
+ # --- Target size ---
288
+ size_group = p.add_mutually_exclusive_group()
289
+ size_group.add_argument(
290
+ "--triangles", type=int, metavar="N", help="Target triangle count (absolute)."
291
+ )
292
+ size_group.add_argument(
293
+ "--reduction", type=float, metavar="PCT", help="Reduction percentage, e.g. 80 → keep 20%%."
294
+ )
295
+
296
+ # --- URDF pair sizes ---
297
+ p.add_argument(
298
+ "--visual",
299
+ type=int,
300
+ metavar="N",
301
+ help="Triangle count for visual mesh (URDF pair mode; optional per-mesh budget in "
302
+ "robot mode).",
303
+ )
304
+ p.add_argument(
305
+ "--collision",
306
+ type=int,
307
+ metavar="N",
308
+ help="Triangle count for collision mesh (URDF pair / batch mode; per-mesh budget "
309
+ "in robot mode).",
310
+ )
311
+
312
+ # --- Robot (URDF) mode ---
313
+ p.add_argument(
314
+ "--package-path",
315
+ action="append",
316
+ metavar="NAME=DIR",
317
+ help="Where to find a ROS package referenced as package://NAME/... (repeatable). "
318
+ "Otherwise found next to the URDF or via AMENT_PREFIX_PATH.",
319
+ )
320
+ p.add_argument(
321
+ "--mesh-uri-prefix",
322
+ metavar="URI",
323
+ help="Prefix for rewritten mesh URIs in robot mode, e.g. "
324
+ "package://my_robot_description/meshes_optimized (default: relative paths).",
325
+ )
326
+
327
+ # --- Extras ---
328
+ p.add_argument(
329
+ "--save-report",
330
+ action="store_true",
331
+ help="Persist a JSON performance/quality report alongside output.",
332
+ )
333
+ p.add_argument(
334
+ "--skip-hausdorff",
335
+ action="store_true",
336
+ help="Skip Hausdorff distance computation (faster for large meshes).",
337
+ )
338
+ p.add_argument(
339
+ "--dry-run",
340
+ action="store_true",
341
+ help="Print what would happen without reading targets or writing any files.",
342
+ )
343
+ p.add_argument(
344
+ "-v",
345
+ "--verbose",
346
+ action="count",
347
+ default=0,
348
+ help="Increase logging verbosity. Repeat for more detail (-v, -vv).",
349
+ )
350
+
351
+ return p
352
+
353
+
354
+ def _configure_logging(verbosity: int) -> None:
355
+ """Default shows progress (INFO); -v adds debug detail from dependencies too."""
356
+ level = logging.DEBUG if verbosity >= 1 else logging.INFO
357
+ logging.basicConfig(level=level, format="[%(name)s] %(message)s")
358
+ if verbosity == 0:
359
+ logging.getLogger("open3d").setLevel(logging.WARNING)
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Entry point
364
+ # ---------------------------------------------------------------------------
365
+
366
+
367
+ def main() -> None:
368
+ parser = build_parser()
369
+ args = parser.parse_args()
370
+ _configure_logging(args.verbose)
371
+
372
+ try:
373
+ # ---- Routing ----
374
+ if args.folder:
375
+ if args.triangles is None and args.reduction is None and args.collision is None:
376
+ parser.error("Batch mode requires --triangles, --reduction, or --collision.")
377
+ # In batch mode --collision is a shorthand for --triangles
378
+ if args.collision and args.triangles is None and args.reduction is None:
379
+ args.triangles = args.collision
380
+ _process_batch(args)
381
+
382
+ elif args.urdf:
383
+ _process_robot_urdf(args)
384
+
385
+ elif args.urdf_output:
386
+ _process_urdf(args)
387
+
388
+ else:
389
+ if args.output is None:
390
+ parser.error("--output is required in single-file mode.")
391
+ if args.triangles is None and args.reduction is None:
392
+ parser.error("Single-file mode requires --triangles or --reduction.")
393
+ _process_single(args)
394
+
395
+ except CliError as exc:
396
+ log.error("%s", exc)
397
+ sys.exit(1)
398
+ except (FileNotFoundError, ValueError, RuntimeError) as exc:
399
+ log.error("%s", exc)
400
+ sys.exit(1)
401
+
402
+
403
+ if __name__ == "__main__":
404
+ main()
optimizer/core.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ core.py - Mesh loading, simplification, and cleaning logic.
3
+ Supports: STL, OBJ, PLY, OFF, GLB/GLTF
4
+ """
5
+
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import open3d as o3d
10
+
11
+ SUPPORTED_FORMATS = {".stl", ".obj", ".ply", ".off", ".glb", ".gltf"}
12
+
13
+
14
+ def load_mesh(path: str) -> o3d.geometry.TriangleMesh:
15
+ """Load a mesh from any supported format."""
16
+ p = Path(path)
17
+ if not p.exists():
18
+ raise FileNotFoundError(f"Input file not found: '{path}'.")
19
+ if p.suffix.lower() not in SUPPORTED_FORMATS:
20
+ raise ValueError(
21
+ f"Unsupported format: '{p.suffix}'. " f"Supported: {', '.join(SUPPORTED_FORMATS)}"
22
+ )
23
+ mesh = o3d.io.read_triangle_mesh(str(p))
24
+ if len(mesh.triangles) == 0:
25
+ raise RuntimeError(f"No triangles found in '{path}'. File may be empty or corrupt.")
26
+ return mesh
27
+
28
+
29
+ def clean_mesh(mesh: o3d.geometry.TriangleMesh) -> o3d.geometry.TriangleMesh:
30
+ """Remove duplicated vertices, degenerate triangles, and unreferenced geometry."""
31
+ mesh.remove_duplicated_vertices()
32
+ mesh.remove_duplicated_triangles()
33
+ mesh.remove_degenerate_triangles()
34
+ mesh.remove_unreferenced_vertices()
35
+ return mesh
36
+
37
+
38
+ def simplify_mesh(
39
+ mesh: o3d.geometry.TriangleMesh,
40
+ target_triangles: int,
41
+ ) -> tuple[o3d.geometry.TriangleMesh, float]:
42
+ """
43
+ Simplify a mesh using Quadric Error Metrics (QEM).
44
+
45
+ Returns:
46
+ (simplified_mesh, processing_time_sec)
47
+ """
48
+ t0 = time.perf_counter()
49
+ # open3d renamed this method to simplify_quadric_decimation in 0.19;
50
+ # support both so the tool works across the >=0.18 range we advertise.
51
+ if hasattr(mesh, "simplify_quadric_decimation"):
52
+ simplified = mesh.simplify_quadric_decimation(target_number_of_triangles=target_triangles)
53
+ else:
54
+ simplified = mesh.simplify_quadric_error_metrics(
55
+ target_number_of_triangles=target_triangles
56
+ )
57
+ elapsed = time.perf_counter() - t0
58
+ simplified = clean_mesh(simplified)
59
+ return simplified, elapsed
60
+
61
+
62
+ def save_mesh(mesh: o3d.geometry.TriangleMesh, path: str) -> None:
63
+ """Save a mesh; output format is inferred from the file extension."""
64
+ p = Path(path)
65
+ p.parent.mkdir(parents=True, exist_ok=True)
66
+ # STL export silently fails (writes a 0-byte file) without triangle
67
+ # normals. simplify_quadric_decimation doesn't carry normals through,
68
+ # so recompute them unconditionally before every write.
69
+ mesh.compute_triangle_normals()
70
+ success = o3d.io.write_triangle_mesh(str(p), mesh)
71
+ if not success:
72
+ raise RuntimeError(f"Failed to write mesh to '{path}'.")
73
+
74
+
75
+ def resolve_output_path(path: str, format_override: str | None) -> str:
76
+ """
77
+ Apply an explicit --format override to an output path, replacing its
78
+ extension. Validates the requested format is supported.
79
+ """
80
+ if format_override is None:
81
+ return path
82
+ fmt = format_override.lower().lstrip(".")
83
+ suffix = f".{fmt}"
84
+ if suffix not in SUPPORTED_FORMATS:
85
+ raise ValueError(
86
+ f"Unsupported --format '{format_override}'. "
87
+ f"Supported: {', '.join(sorted(s.lstrip('.') for s in SUPPORTED_FORMATS))}"
88
+ )
89
+ return str(Path(path).with_suffix(suffix))
90
+
91
+
92
+ def resolve_target_triangles(
93
+ mesh: o3d.geometry.TriangleMesh,
94
+ target_triangles: int | None,
95
+ reduction_percent: float | None,
96
+ ) -> int:
97
+ """
98
+ Resolve target triangle count from either an absolute value or a percentage.
99
+ Exactly one of the two arguments must be provided.
100
+ """
101
+ original = len(mesh.triangles)
102
+ if target_triangles is not None and reduction_percent is not None:
103
+ raise ValueError("Provide --triangles OR --reduction, not both.")
104
+ if target_triangles is not None:
105
+ return int(target_triangles)
106
+ if reduction_percent is not None:
107
+ if not (0 < reduction_percent < 100):
108
+ raise ValueError("--reduction must be between 0 and 100 (exclusive).")
109
+ keep = 1.0 - (reduction_percent / 100.0)
110
+ return max(1, int(original * keep))
111
+ raise ValueError("Provide --triangles or --reduction.")
optimizer/metrics.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ metrics.py - Geometry validation and performance reporting.
3
+
4
+ Computes:
5
+ - Triangle counts (before / after)
6
+ - File size delta
7
+ - Processing time
8
+ - Hausdorff distance (geometric deviation estimate)
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import open3d as o3d
17
+
18
+ log = logging.getLogger("optimizer")
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Hausdorff distance
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def hausdorff_distance(
27
+ original: o3d.geometry.TriangleMesh,
28
+ simplified: o3d.geometry.TriangleMesh,
29
+ n_samples: int = 10_000,
30
+ ) -> dict[str, float]:
31
+ """
32
+ Estimate one-sided and symmetric Hausdorff distances by sampling
33
+ points on both surfaces and querying nearest-neighbour distances.
34
+
35
+ Returns a dict with keys: max_mm, mean_mm, rms_mm
36
+ (assumes mesh units are millimetres; adjust label if metres).
37
+ """
38
+ pcd_orig = original.sample_points_uniformly(number_of_points=n_samples)
39
+ pcd_simp = simplified.sample_points_uniformly(number_of_points=n_samples)
40
+
41
+ dists_orig_to_simp = np.asarray(pcd_orig.compute_point_cloud_distance(pcd_simp))
42
+ dists_simp_to_orig = np.asarray(pcd_simp.compute_point_cloud_distance(pcd_orig))
43
+
44
+ all_dists = np.concatenate([dists_orig_to_simp, dists_simp_to_orig])
45
+
46
+ return {
47
+ "hausdorff_max": float(np.max(all_dists)),
48
+ "hausdorff_mean": float(np.mean(all_dists)),
49
+ "hausdorff_rms": float(np.sqrt(np.mean(all_dists**2))),
50
+ }
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Performance report
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def build_report(
59
+ input_path: str,
60
+ output_path: str,
61
+ original_mesh: o3d.geometry.TriangleMesh,
62
+ result_mesh: o3d.geometry.TriangleMesh,
63
+ processing_time_sec: float,
64
+ compute_hausdorff: bool = True,
65
+ hausdorff_samples: int = 10_000,
66
+ ) -> dict:
67
+ """Build a structured performance/quality report dict."""
68
+ orig_tri = len(original_mesh.triangles)
69
+ red_tri = len(result_mesh.triangles)
70
+ reduction_pct = round((1 - red_tri / orig_tri) * 100, 2) if orig_tri > 0 else 0.0
71
+
72
+ in_size = Path(input_path).stat().st_size / (1024 * 1024)
73
+ out_size = (
74
+ Path(output_path).stat().st_size / (1024 * 1024) if Path(output_path).exists() else 0.0
75
+ )
76
+
77
+ report = {
78
+ "input_file": str(input_path),
79
+ "output_file": str(output_path),
80
+ "original_triangles": orig_tri,
81
+ "reduced_triangles": red_tri,
82
+ "reduction_percent": reduction_pct,
83
+ "file_size_before_mb": round(in_size, 3),
84
+ "file_size_after_mb": round(out_size, 3),
85
+ "processing_time_sec": round(processing_time_sec, 4),
86
+ }
87
+
88
+ if compute_hausdorff:
89
+ h = hausdorff_distance(original_mesh, result_mesh, n_samples=hausdorff_samples)
90
+ report.update(h)
91
+
92
+ return report
93
+
94
+
95
+ def print_report(report: dict) -> None:
96
+ """Pretty-print the report to stdout."""
97
+ sep = "─" * 52
98
+ print(f"\n{sep}")
99
+ print(" Mesh Preprocessing Report")
100
+ print(sep)
101
+ print(f" Input : {report['input_file']}")
102
+ print(f" Output : {report['output_file']}")
103
+ print(
104
+ f" Triangles : {report['original_triangles']:,} → {report['reduced_triangles']:,}"
105
+ )
106
+ print(f" Reduction : {report['reduction_percent']}%")
107
+ before, after = report["file_size_before_mb"], report["file_size_after_mb"]
108
+ print(f" File size : {before} MB → {after} MB")
109
+ print(f" Time : {report['processing_time_sec']} s")
110
+ if "hausdorff_max" in report:
111
+ print(f" Hausdorff max : {report['hausdorff_max']:.6f} (mesh units)")
112
+ print(f" Hausdorff RMS : {report['hausdorff_rms']:.6f} (mesh units)")
113
+ print(sep + "\n")
114
+
115
+
116
+ def save_report(report: dict, path: str) -> None:
117
+ """Persist the report as JSON."""
118
+ p = Path(path)
119
+ p.parent.mkdir(parents=True, exist_ok=True)
120
+ with open(p, "w") as fh:
121
+ json.dump(report, fh, indent=2)
122
+ log.info("Report saved → %s", p)