Skip to content
Merged

Dev #18

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 200 additions & 32 deletions meshcutter/cli/meshcut.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
from pathlib import Path

from meshcutter import __version__
from meshcutter.core.detection import detect_bottom_frame, extract_footprint, get_mesh_diagnostics
from meshcutter.core.detection import detect_bottom_frame, extract_footprint, get_mesh_diagnostics, detect_aligned_frame
from meshcutter.core.grid import generate_grid_mask, compute_pitch, get_grid_info, DEFAULT_SLOT_WIDTH
from meshcutter.core.cutter import generate_cutter, validate_cutter, COPLANAR_EPSILON
from meshcutter.core.cutter import generate_cutter, generate_profiled_cutter, validate_cutter, COPLANAR_EPSILON
from meshcutter.core.boolean import (
boolean_difference,
validate_boolean_inputs,
Expand All @@ -29,6 +29,97 @@
GR_BASE_HEIGHT = 4.75 # Default cut depth from microfinity


def dump_mask_svg(
mask,
footprint,
output_path: Path,
stroke_width: float = 0.5,
) -> None:
"""
Export 2D mask and footprint to SVG file for debugging.

Args:
mask: Shapely geometry (grid mask)
footprint: Shapely geometry (footprint outline)
output_path: Path to write SVG file
stroke_width: Stroke width for SVG elements
"""
from shapely.geometry import MultiPolygon

# Get combined bounds
all_bounds = list(mask.bounds) + list(footprint.bounds)
min_x = min(all_bounds[0], all_bounds[4])
min_y = min(all_bounds[1], all_bounds[5])
max_x = max(all_bounds[2], all_bounds[6])
max_y = max(all_bounds[3], all_bounds[7])

# Add padding
padding = max(max_x - min_x, max_y - min_y) * 0.05
view_min_x = min_x - padding
view_min_y = min_y - padding
view_width = (max_x - min_x) + 2 * padding
view_height = (max_y - min_y) + 2 * padding

# Build SVG
svg_lines = [
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'viewBox="{view_min_x} {-view_min_y - view_height} {view_width} {view_height}">',
f'<g transform="scale(1, -1)">', # Flip Y for standard coordinate system
]

# Helper to convert polygon to SVG path
def polygon_to_path(poly):
if poly.is_empty:
return ""
coords = list(poly.exterior.coords)
d = f"M {coords[0][0]} {coords[0][1]}"
for x, y in coords[1:]:
d += f" L {x} {y}"
d += " Z"
# Add holes
for interior in poly.interiors:
hole_coords = list(interior.coords)
d += f" M {hole_coords[0][0]} {hole_coords[0][1]}"
for x, y in hole_coords[1:]:
d += f" L {x} {y}"
d += " Z"
return d

# Draw footprint outline (blue, no fill)
if isinstance(footprint, MultiPolygon):
for poly in footprint.geoms:
path = polygon_to_path(poly)
svg_lines.append(
f'<path d="{path}" fill="none" stroke="#0066cc" '
f'stroke-width="{stroke_width}" stroke-dasharray="2,2"/>'
)
else:
path = polygon_to_path(footprint)
svg_lines.append(
f'<path d="{path}" fill="none" stroke="#0066cc" ' f'stroke-width="{stroke_width}" stroke-dasharray="2,2"/>'
)

# Draw mask (red fill with transparency)
if isinstance(mask, MultiPolygon):
for poly in mask.geoms:
path = polygon_to_path(poly)
svg_lines.append(
f'<path d="{path}" fill="#cc3333" fill-opacity="0.4" '
f'stroke="#cc0000" stroke-width="{stroke_width * 0.5}"/>'
)
else:
path = polygon_to_path(mask)
svg_lines.append(
f'<path d="{path}" fill="#cc3333" fill-opacity="0.4" '
f'stroke="#cc0000" stroke-width="{stroke_width * 0.5}"/>'
)

svg_lines.append("</g>")
svg_lines.append("</svg>")

output_path.write_text("\n".join(svg_lines))


def main():
"""Main entry point for microfinity-meshcut CLI."""
parser = argparse.ArgumentParser(
Expand Down Expand Up @@ -111,6 +202,25 @@ def main():
help=f"Cut depth in mm. Default: {GR_BASE_HEIGHT} (GR_BASE_HEIGHT)",
)

parser.add_argument(
"--profile",
type=str,
default="rect",
choices=["rect", "gridfinity", "gridfinity_box"],
help=(
"Cutter profile: 'rect' (simple rectangle), "
"'gridfinity' (baseplate chamfer profile), "
"'gridfinity_box' (bin foot profile). Default: rect"
),
)

parser.add_argument(
"--profile-slices",
type=int,
default=10,
help="Number of Z slices for profile approximation (3-50). Default: 10",
)

# Grid phase options
parser.add_argument(
"--phase-x",
Expand Down Expand Up @@ -162,6 +272,19 @@ def main():
help="Verbose output with detailed progress",
)

parser.add_argument(
"--dump-mask",
type=Path,
metavar="PATH",
help="Export 2D grid mask to SVG file for debugging",
)

parser.add_argument(
"--require-intersect",
action="store_true",
help="Fail (not just warn) if cutter doesn't intersect part",
)

parser.add_argument(
"--version",
action="version",
Expand Down Expand Up @@ -196,6 +319,7 @@ def run_meshcut(args: argparse.Namespace) -> None:
print(f"Divisions: {args.divisions} (pitch: {compute_pitch(args.divisions):.1f}mm)")
print(f"Slot width: {args.slot_width}mm + 2x{args.clearance}mm clearance")
print(f"Cut depth: {args.depth}mm")
print(f"Profile: {args.profile}")
print()

# Check available engines
Expand Down Expand Up @@ -233,40 +357,33 @@ def run_meshcut(args: argparse.Namespace) -> None:
print(" Warning: Mesh is not watertight. Consider using --repair")
print()

# Step 2: Detect bottom frame
# Step 2+3: Detect bottom frame and extract footprint with edge alignment
if args.verbose:
mode = "force Z-up" if args.force_z_up else "auto-detect"
print(f"Detecting bottom plane ({mode})...", end=" ", flush=True)
print(f"Detecting bottom plane and footprint ({mode}, edge-aligned)...", end=" ", flush=True)

try:
frame = detect_bottom_frame(
frame, footprint = detect_aligned_frame(
mesh,
force_z_up=args.force_z_up,
z_tolerance=args.z_tolerance,
edge_voting=True,
)
except ValueError as e:
raise RuntimeError(f"Failed to detect bottom plane: {e}")

if args.verbose:
import math

yaw_deg = math.degrees(math.atan2(frame.x_axis[1], frame.x_axis[0]))
print("done")
print(f" Z-min: {frame.z_min:.3f}mm")
print(f" Origin: ({frame.origin[0]:.2f}, {frame.origin[1]:.2f}, {frame.origin[2]:.2f})")
print()

# Step 3: Extract footprint
if args.verbose:
print("Extracting bottom footprint...", end=" ", flush=True)

try:
footprint = extract_footprint(mesh, frame)
except ValueError as e:
raise RuntimeError(f"Failed to extract footprint: {e}")

if args.verbose:
print("done")
print(f" X-axis: ({frame.x_axis[0]:.3f}, {frame.x_axis[1]:.3f}, {frame.x_axis[2]:.3f})")
print(f" Frame yaw: {yaw_deg:.2f} degrees")
print(f" Footprint area: {footprint.area:.1f} mm^2")
bounds = footprint.bounds
print(f" Bounds: ({bounds[0]:.1f}, {bounds[1]:.1f}) to ({bounds[2]:.1f}, {bounds[3]:.1f})")
print(f" Footprint bounds: ({bounds[0]:.1f}, {bounds[1]:.1f}) to ({bounds[2]:.1f}, {bounds[3]:.1f})")
print()

# Step 4: Generate grid mask
Expand Down Expand Up @@ -294,26 +411,65 @@ def run_meshcut(args: argparse.Namespace) -> None:
print(f" X cuts: {grid_info['num_x_cuts']}")
print(f" Y cuts: {grid_info['num_y_cuts']}")
print(f" Total cuts: {grid_info['total_cuts']}")
print(f" Cut area: {grid_mask.area:.1f} mm^2")
print(f" Mask geometry type: {grid_mask.geom_type}")
from shapely.geometry import MultiPolygon as _MP

if isinstance(grid_mask, _MP):
print(f" Mask components: {len(list(grid_mask.geoms))}")
print(f" Mask area: {grid_mask.area:.1f} mm^2")
print(f" Footprint area: {footprint.area:.1f} mm^2")
print(f" Coverage ratio: {grid_mask.area / footprint.area * 100:.1f}%")
print()

# Optional: dump mask to SVG for debugging
if args.dump_mask:
if args.verbose:
print(f"Dumping mask to {args.dump_mask}...", end=" ", flush=True)
try:
dump_mask_svg(grid_mask, footprint, args.dump_mask)
if args.verbose:
print("done")
except Exception as e:
print(f"\n Warning: Failed to dump mask: {e}")
if args.verbose:
print()

# Step 5: Generate 3D cutter
if args.verbose:
print("Generating 3D cutter mesh...", end=" ", flush=True)
profile_desc = f"profile={args.profile}" if args.profile != "rect" else "rectangular"
print(f"Generating 3D cutter mesh ({profile_desc})...", end=" ", flush=True)

try:
cutter = generate_cutter(
grid_mask=grid_mask,
frame=frame,
depth=args.depth,
epsilon=args.epsilon,
)
# Validate profile slices
n_slices = max(3, min(50, args.profile_slices))

if args.profile == "rect":
# Use simple rectangular extrusion (original behavior)
cutter = generate_cutter(
grid_mask=grid_mask,
frame=frame,
depth=args.depth,
epsilon=args.epsilon,
)
else:
# Use profiled cutter with chamfers
cutter = generate_profiled_cutter(
grid_mask=grid_mask,
frame=frame,
profile=args.profile,
depth=args.depth,
epsilon=args.epsilon,
n_slices=n_slices,
)
except ValueError as e:
raise RuntimeError(f"Failed to generate cutter: {e}")

if args.verbose:
cutter_info = validate_cutter(cutter)
print("done")
print(f" Profile: {args.profile}")
if args.profile != "rect":
print(f" Profile slices: {n_slices}")
print(f" Cutter triangles: {cutter_info['face_count']}")
print(f" Cutter watertight: {cutter_info['is_watertight']}")
print()
Expand All @@ -335,7 +491,7 @@ def run_meshcut(args: argparse.Namespace) -> None:
f"cutter[{cutter_bounds[0, 2]:.3f}, {cutter_bounds[1, 2]:.3f}]"
)

# Hard check: cutter must intersect part bounds
# Check if cutter intersects part bounds
bounds_intersect = not (
part_bounds[1, 0] < cutter_bounds[0, 0]
or part_bounds[0, 0] > cutter_bounds[1, 0]
Expand All @@ -346,12 +502,24 @@ def run_meshcut(args: argparse.Namespace) -> None:
)

if not bounds_intersect:
raise RuntimeError(
# Print diagnostic info about frame
import math

yaw_angle = math.degrees(math.atan2(frame.x_axis[1], frame.x_axis[0]))
msg = (
f"Cutter does not intersect part bounds.\n"
f"Part bounds: {part_bounds.tolist()}\n"
f"Cutter bounds: {cutter_bounds.tolist()}\n"
f"This is likely a transform/extrusion-direction bug."
f" Part bounds: {part_bounds.tolist()}\n"
f" Cutter bounds: {cutter_bounds.tolist()}\n"
f" Frame origin: {frame.origin.tolist()}\n"
f" Frame X-axis: {frame.x_axis.tolist()}\n"
f" Frame Y-axis: {frame.y_axis.tolist()}\n"
f" Frame yaw: {yaw_angle:.2f} degrees\n"
f" This may indicate a transform/alignment bug."
)
if args.require_intersect:
raise RuntimeError(msg)
else:
print(f"Warning: {msg}", file=sys.stderr)

warnings = validate_boolean_inputs(mesh, cutter)
if warnings and args.verbose:
Expand Down
13 changes: 12 additions & 1 deletion meshcutter/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,28 @@
# meshcutter.core - Core geometry operations
#

from meshcutter.core.detection import detect_bottom_frame, extract_footprint
from meshcutter.core.detection import detect_bottom_frame, extract_footprint, detect_aligned_frame
from meshcutter.core.grid import generate_grid_mask, compute_grid_positions
from meshcutter.core.cutter import generate_cutter
from meshcutter.core.boolean import boolean_difference, repair_mesh
from meshcutter.core.profile import (
CutterProfile,
get_profile,
PROFILE_RECTANGULAR,
PROFILE_GRIDFINITY,
)

__all__ = [
"detect_bottom_frame",
"extract_footprint",
"detect_aligned_frame",
"generate_grid_mask",
"compute_grid_positions",
"generate_cutter",
"boolean_difference",
"repair_mesh",
"CutterProfile",
"get_profile",
"PROFILE_RECTANGULAR",
"PROFILE_GRIDFINITY",
]
Loading