Skip to content

Commit 0cfb337

Browse files
author
Your Name
committed
feat: add debug subcommand with analyze, slice, compare, footprint
- microfinity debug analyze: mesh diagnostics (vertices, faces, volume, issues) - microfinity debug slice: SVG cross-section at specified Z height - microfinity debug compare: compare two meshes (dimensions, volume diff) - microfinity debug footprint: extract 2D footprint as SVG
1 parent c56f769 commit 0cfb337

File tree

3 files changed

+382
-5
lines changed

3 files changed

+382
-5
lines changed

TODO.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ microfinity debug [subcommand] # Debug/visualization tools
5252
- [x] Implement `microfinity meshcut` subcommand
5353
- [x] Implement `microfinity calibrate` subcommand (stub, delegates to legacy)
5454
- [x] Implement `microfinity info` subcommand
55-
- [ ] Implement `microfinity debug` subcommand (see Debug Tooling section)
55+
- [x] Implement `microfinity debug` subcommand (see Debug Tooling section)
5656
- [ ] Remove old entry points (`microfinity-box`, `microfinity-meshcut`, etc.)
5757
- [x] Update pyproject.toml scripts section
5858

@@ -155,10 +155,10 @@ microfinity debug footprint <file.stl> --output footprint.svg
155155
microfinity debug explode <file.stl> --output exploded.stl
156156
```
157157

158-
- [ ] Implement `microfinity debug slice`
159-
- [ ] Implement `microfinity debug compare`
160-
- [ ] Implement `microfinity debug analyze`
161-
- [ ] Implement `microfinity debug footprint`
158+
- [x] Implement `microfinity debug slice`
159+
- [x] Implement `microfinity debug compare`
160+
- [x] Implement `microfinity debug analyze`
161+
- [x] Implement `microfinity debug footprint`
162162
- [ ] Implement `microfinity debug explode`
163163
- [ ] Implement `microfinity debug measure`
164164

microfinity/cli/debug.py

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
#! /usr/bin/env python3
2+
"""
3+
microfinity.cli.debug - Debug tooling for mesh analysis and visualization.
4+
5+
Provides commands for analyzing, comparing, and debugging 3D meshes.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import json
12+
import sys
13+
from pathlib import Path
14+
from typing import Any, Dict
15+
16+
17+
def cmd_analyze(args: argparse.Namespace) -> int:
18+
"""Analyze a mesh file and report diagnostics."""
19+
import trimesh
20+
21+
input_path = Path(args.input)
22+
if not input_path.exists():
23+
print(f"Error: File not found: {input_path}", file=sys.stderr)
24+
return 1
25+
26+
print(f"Analyzing: {input_path}")
27+
print()
28+
29+
try:
30+
mesh = trimesh.load(str(input_path))
31+
except Exception as e:
32+
print(f"Error loading mesh: {e}", file=sys.stderr)
33+
return 1
34+
35+
# Handle scene vs mesh
36+
if isinstance(mesh, trimesh.Scene):
37+
if len(mesh.geometry) == 0:
38+
print("Error: Scene contains no geometry", file=sys.stderr)
39+
return 1
40+
# Combine all geometries
41+
mesh = trimesh.util.concatenate(list(mesh.geometry.values()))
42+
43+
# Collect diagnostics
44+
report: Dict[str, Any] = {
45+
"file": str(input_path),
46+
"format": input_path.suffix.lower(),
47+
"geometry": {
48+
"vertices": len(mesh.vertices),
49+
"faces": len(mesh.faces),
50+
"edges": len(mesh.edges_unique),
51+
},
52+
"bounds": {
53+
"min": mesh.bounds[0].tolist(),
54+
"max": mesh.bounds[1].tolist(),
55+
"dimensions": (mesh.bounds[1] - mesh.bounds[0]).tolist(),
56+
},
57+
"properties": {
58+
"watertight": mesh.is_watertight,
59+
"volume": float(mesh.volume) if mesh.is_watertight else None,
60+
"area": float(mesh.area),
61+
"center_mass": mesh.center_mass.tolist() if mesh.is_watertight else None,
62+
},
63+
"quality": {
64+
"is_convex": mesh.is_convex,
65+
"euler_number": mesh.euler_number,
66+
},
67+
}
68+
69+
# Check for issues
70+
issues = []
71+
if not mesh.is_watertight:
72+
issues.append("Mesh is not watertight (has holes)")
73+
if mesh.euler_number != 2:
74+
issues.append(f"Non-manifold geometry (Euler number: {mesh.euler_number}, expected 2)")
75+
76+
# Check for degenerate faces
77+
face_areas = mesh.area_faces
78+
degenerate_count = (face_areas < 1e-10).sum()
79+
if degenerate_count > 0:
80+
issues.append(f"Found {degenerate_count} degenerate (zero-area) faces")
81+
82+
report["issues"] = issues
83+
84+
# Output
85+
if args.output:
86+
output_path = Path(args.output)
87+
with open(output_path, "w") as f:
88+
json.dump(report, f, indent=2)
89+
print(f"Report written to: {output_path}")
90+
else:
91+
# Pretty print to console
92+
dims = report["bounds"]["dimensions"]
93+
print("Geometry:")
94+
print(f" Vertices: {report['geometry']['vertices']:,}")
95+
print(f" Faces: {report['geometry']['faces']:,}")
96+
print(f" Edges: {report['geometry']['edges']:,}")
97+
print()
98+
print("Dimensions:")
99+
print(f" X: {dims[0]:.2f} mm")
100+
print(f" Y: {dims[1]:.2f} mm")
101+
print(f" Z: {dims[2]:.2f} mm")
102+
print()
103+
print("Properties:")
104+
print(f" Watertight: {report['properties']['watertight']}")
105+
if report["properties"]["volume"]:
106+
print(f" Volume: {report['properties']['volume']:.2f} mm^3")
107+
print(f" Surface area: {report['properties']['area']:.2f} mm^2")
108+
print()
109+
110+
if issues:
111+
print("Issues:")
112+
for issue in issues:
113+
print(f" - {issue}")
114+
else:
115+
print("No issues detected.")
116+
117+
return 0
118+
119+
120+
def cmd_slice(args: argparse.Namespace) -> int:
121+
"""Generate SVG cross-section at specified Z height."""
122+
import trimesh
123+
import numpy as np
124+
125+
input_path = Path(args.input)
126+
if not input_path.exists():
127+
print(f"Error: File not found: {input_path}", file=sys.stderr)
128+
return 1
129+
130+
try:
131+
mesh = trimesh.load(str(input_path))
132+
except Exception as e:
133+
print(f"Error loading mesh: {e}", file=sys.stderr)
134+
return 1
135+
136+
if isinstance(mesh, trimesh.Scene):
137+
mesh = trimesh.util.concatenate(list(mesh.geometry.values()))
138+
139+
z_height = args.z
140+
if z_height is None:
141+
# Default to middle of mesh
142+
z_height = (mesh.bounds[0][2] + mesh.bounds[1][2]) / 2
143+
print(f"Using default Z height: {z_height:.2f} mm")
144+
145+
# Create slice
146+
try:
147+
slice_2d = mesh.section(plane_origin=[0, 0, z_height], plane_normal=[0, 0, 1])
148+
except Exception as e:
149+
print(f"Error creating slice: {e}", file=sys.stderr)
150+
return 1
151+
152+
if slice_2d is None:
153+
print(f"No intersection at Z={z_height:.2f} mm", file=sys.stderr)
154+
return 1
155+
156+
# Convert to 2D path
157+
try:
158+
path_2d, _ = slice_2d.to_planar()
159+
except Exception as e:
160+
print(f"Error converting to 2D: {e}", file=sys.stderr)
161+
return 1
162+
163+
# Export to SVG
164+
output_path = args.output or Path(input_path.stem + f"_z{z_height:.1f}.svg")
165+
166+
try:
167+
# Get SVG string
168+
svg_data = path_2d.to_svg()
169+
with open(output_path, "w") as f:
170+
f.write(svg_data)
171+
print(f"Slice exported to: {output_path}")
172+
except Exception as e:
173+
print(f"Error exporting SVG: {e}", file=sys.stderr)
174+
return 1
175+
176+
return 0
177+
178+
179+
def cmd_compare(args: argparse.Namespace) -> int:
180+
"""Compare two meshes and report differences."""
181+
import trimesh
182+
import numpy as np
183+
184+
path_a = Path(args.file_a)
185+
path_b = Path(args.file_b)
186+
187+
for p in [path_a, path_b]:
188+
if not p.exists():
189+
print(f"Error: File not found: {p}", file=sys.stderr)
190+
return 1
191+
192+
try:
193+
mesh_a = trimesh.load(str(path_a))
194+
mesh_b = trimesh.load(str(path_b))
195+
except Exception as e:
196+
print(f"Error loading meshes: {e}", file=sys.stderr)
197+
return 1
198+
199+
# Handle scenes
200+
if isinstance(mesh_a, trimesh.Scene):
201+
mesh_a = trimesh.util.concatenate(list(mesh_a.geometry.values()))
202+
if isinstance(mesh_b, trimesh.Scene):
203+
mesh_b = trimesh.util.concatenate(list(mesh_b.geometry.values()))
204+
205+
print(f"Comparing:")
206+
print(f" A: {path_a}")
207+
print(f" B: {path_b}")
208+
print()
209+
210+
# Basic comparison
211+
print("Geometry comparison:")
212+
print(f" Vertices: {len(mesh_a.vertices):,} vs {len(mesh_b.vertices):,}")
213+
print(f" Faces: {len(mesh_a.faces):,} vs {len(mesh_b.faces):,}")
214+
print()
215+
216+
# Bounds comparison
217+
dims_a = mesh_a.bounds[1] - mesh_a.bounds[0]
218+
dims_b = mesh_b.bounds[1] - mesh_b.bounds[0]
219+
print("Dimensions (mm):")
220+
print(f" X: {dims_a[0]:.2f} vs {dims_b[0]:.2f} (diff: {abs(dims_a[0] - dims_b[0]):.3f})")
221+
print(f" Y: {dims_a[1]:.2f} vs {dims_b[1]:.2f} (diff: {abs(dims_a[1] - dims_b[1]):.3f})")
222+
print(f" Z: {dims_a[2]:.2f} vs {dims_b[2]:.2f} (diff: {abs(dims_a[2] - dims_b[2]):.3f})")
223+
print()
224+
225+
# Volume comparison (if both watertight)
226+
if mesh_a.is_watertight and mesh_b.is_watertight:
227+
vol_a = mesh_a.volume
228+
vol_b = mesh_b.volume
229+
vol_diff = abs(vol_a - vol_b)
230+
vol_pct = (vol_diff / max(vol_a, vol_b)) * 100 if max(vol_a, vol_b) > 0 else 0
231+
232+
print("Volume comparison:")
233+
print(f" A: {vol_a:.2f} mm^3")
234+
print(f" B: {vol_b:.2f} mm^3")
235+
print(f" Difference: {vol_diff:.2f} mm^3 ({vol_pct:.2f}%)")
236+
237+
# Boolean difference if requested
238+
if args.output:
239+
try:
240+
# Try to compute symmetric difference
241+
diff_mesh = mesh_a.difference(mesh_b)
242+
if diff_mesh and len(diff_mesh.faces) > 0:
243+
output_path = Path(args.output)
244+
diff_mesh.export(str(output_path))
245+
print(f"\nDifference mesh exported to: {output_path}")
246+
except Exception as e:
247+
print(f"\nCould not compute boolean difference: {e}")
248+
else:
249+
print("Volume comparison: Skipped (one or both meshes not watertight)")
250+
251+
return 0
252+
253+
254+
def cmd_footprint(args: argparse.Namespace) -> int:
255+
"""Extract and export 2D footprint of mesh."""
256+
import trimesh
257+
import numpy as np
258+
259+
input_path = Path(args.input)
260+
if not input_path.exists():
261+
print(f"Error: File not found: {input_path}", file=sys.stderr)
262+
return 1
263+
264+
try:
265+
mesh = trimesh.load(str(input_path))
266+
except Exception as e:
267+
print(f"Error loading mesh: {e}", file=sys.stderr)
268+
return 1
269+
270+
if isinstance(mesh, trimesh.Scene):
271+
mesh = trimesh.util.concatenate(list(mesh.geometry.values()))
272+
273+
# Get bottom Z
274+
z_min = mesh.bounds[0][2]
275+
z_slice = z_min + 0.1 # Slice just above bottom
276+
277+
print(f"Extracting footprint at Z={z_slice:.2f} mm")
278+
279+
try:
280+
slice_2d = mesh.section(plane_origin=[0, 0, z_slice], plane_normal=[0, 0, 1])
281+
if slice_2d is None:
282+
# Try projection instead
283+
print("No slice found, using convex hull projection")
284+
from shapely.geometry import MultiPoint
285+
286+
bottom_verts = mesh.vertices[mesh.vertices[:, 2] < z_min + 1.0]
287+
if len(bottom_verts) == 0:
288+
print("Error: No bottom vertices found", file=sys.stderr)
289+
return 1
290+
points = MultiPoint(bottom_verts[:, :2])
291+
footprint = points.convex_hull
292+
293+
# Export as SVG manually
294+
output_path = args.output or Path(input_path.stem + "_footprint.svg")
295+
bounds = footprint.bounds
296+
width = bounds[2] - bounds[0]
297+
height = bounds[3] - bounds[1]
298+
svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{bounds[0]} {bounds[1]} {width} {height}">\n'
299+
svg += f' <path d="M {" L ".join(f"{x},{y}" for x, y in footprint.exterior.coords)}" fill="none" stroke="black" stroke-width="0.5"/>\n'
300+
svg += "</svg>"
301+
with open(output_path, "w") as f:
302+
f.write(svg)
303+
print(f"Footprint exported to: {output_path}")
304+
return 0
305+
306+
path_2d, _ = slice_2d.to_planar()
307+
output_path = args.output or Path(input_path.stem + "_footprint.svg")
308+
svg_data = path_2d.to_svg()
309+
with open(output_path, "w") as f:
310+
f.write(svg_data)
311+
print(f"Footprint exported to: {output_path}")
312+
313+
except Exception as e:
314+
print(f"Error extracting footprint: {e}", file=sys.stderr)
315+
return 1
316+
317+
return 0
318+
319+
320+
def add_debug_subparsers(subparsers: argparse._SubParsersAction) -> None:
321+
"""Add debug subcommand parsers."""
322+
323+
# analyze
324+
analyze_parser = subparsers.add_parser("analyze", help="Analyze mesh and report diagnostics")
325+
analyze_parser.add_argument("input", type=Path, help="Input mesh file (STL, 3MF, OBJ)")
326+
analyze_parser.add_argument("-o", "--output", type=Path, help="Output JSON report file")
327+
analyze_parser.set_defaults(func=cmd_analyze)
328+
329+
# slice
330+
slice_parser = subparsers.add_parser("slice", help="Generate SVG cross-section")
331+
slice_parser.add_argument("input", type=Path, help="Input mesh file")
332+
slice_parser.add_argument("-z", type=float, help="Z height for slice (default: middle)")
333+
slice_parser.add_argument("-o", "--output", type=Path, help="Output SVG file")
334+
slice_parser.set_defaults(func=cmd_slice)
335+
336+
# compare
337+
compare_parser = subparsers.add_parser("compare", help="Compare two meshes")
338+
compare_parser.add_argument("file_a", type=Path, help="First mesh file")
339+
compare_parser.add_argument("file_b", type=Path, help="Second mesh file")
340+
compare_parser.add_argument("-o", "--output", type=Path, help="Output difference mesh")
341+
compare_parser.set_defaults(func=cmd_compare)
342+
343+
# footprint
344+
footprint_parser = subparsers.add_parser("footprint", help="Extract 2D footprint")
345+
footprint_parser.add_argument("input", type=Path, help="Input mesh file")
346+
footprint_parser.add_argument("-o", "--output", type=Path, help="Output SVG file")
347+
footprint_parser.set_defaults(func=cmd_footprint)
348+
349+
350+
def cmd_debug(args: argparse.Namespace) -> int:
351+
"""Debug subcommand dispatcher."""
352+
if hasattr(args, "func"):
353+
return args.func(args)
354+
else:
355+
print("Usage: microfinity debug <command> [options]")
356+
print()
357+
print("Commands:")
358+
print(" analyze - Analyze mesh and report diagnostics")
359+
print(" slice - Generate SVG cross-section at Z height")
360+
print(" compare - Compare two meshes")
361+
print(" footprint - Extract 2D footprint")
362+
return 0

0 commit comments

Comments
 (0)