From 62095c9d9fe116c452023a474c4ae863c7c07838 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 6 Jan 2026 06:53:52 -0500 Subject: [PATCH] fix: correct cutter extrusion direction to cut into part instead of below it - Change cutter Z range from [z_min - depth, z_min] to [z_min - epsilon, z_min + depth] - Add bounds intersection check in CLI to fail fast if cutter doesn't overlap part - Snap BottomFrame origin Z to z_min in auto-detect mode - Add verbose bounds output for debugging - Update test expectations for new extrusion direction --- meshcutter/cli/meshcut.py | 36 +++++++++++- meshcutter/core/cutter.py | 88 +++++++++++++++------------- meshcutter/core/detection.py | 1 + tests/test_meshcutter/test_cutter.py | 59 ++++++++++++++----- 4 files changed, 125 insertions(+), 59 deletions(-) diff --git a/meshcutter/cli/meshcut.py b/meshcutter/cli/meshcut.py index 2946326..13cc2b2 100644 --- a/meshcutter/cli/meshcut.py +++ b/meshcutter/cli/meshcut.py @@ -318,17 +318,47 @@ def run_meshcut(args: argparse.Namespace) -> None: print(f" Cutter watertight: {cutter_info['is_watertight']}") print() - # Step 6: Validate inputs + # Step 6: Validate inputs and check bounds intersection if args.verbose: print("Validating boolean inputs...", end=" ", flush=True) + # Print bounds for debugging + part_bounds = mesh.bounds + cutter_bounds = cutter.bounds + + if args.verbose: + print() + print(f" Part bounds: {part_bounds[0].tolist()} to {part_bounds[1].tolist()}") + print(f" Cutter bounds: {cutter_bounds[0].tolist()} to {cutter_bounds[1].tolist()}") + print( + f" Z overlap: part[{part_bounds[0, 2]:.3f}, {part_bounds[1, 2]:.3f}] " + f"cutter[{cutter_bounds[0, 2]:.3f}, {cutter_bounds[1, 2]:.3f}]" + ) + + # Hard check: cutter must intersect part bounds + bounds_intersect = not ( + part_bounds[1, 0] < cutter_bounds[0, 0] + or part_bounds[0, 0] > cutter_bounds[1, 0] + or part_bounds[1, 1] < cutter_bounds[0, 1] + or part_bounds[0, 1] > cutter_bounds[1, 1] + or part_bounds[1, 2] < cutter_bounds[0, 2] + or part_bounds[0, 2] > cutter_bounds[1, 2] + ) + + if not bounds_intersect: + raise RuntimeError( + 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." + ) + warnings = validate_boolean_inputs(mesh, cutter) if warnings and args.verbose: - print() for w in warnings: print(f" Warning: {w}") elif args.verbose: - print("ok") + print(" Bounds check: OK") print() # Step 7: Boolean difference diff --git a/meshcutter/core/cutter.py b/meshcutter/core/cutter.py index d095873..30786a5 100644 --- a/meshcutter/core/cutter.py +++ b/meshcutter/core/cutter.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Union +from typing import Optional, Union import numpy as np import trimesh @@ -14,7 +14,8 @@ from meshcutter.core.detection import BottomFrame -# Epsilon for pushing cutter slightly into the part to avoid coplanar issues +# Penetration below the bottom plane to avoid coplanar boolean degeneracy. +# The cutter will extend from z = -epsilon up to z = +depth in local coordinates. COPLANAR_EPSILON = 0.02 # mm @@ -27,19 +28,22 @@ def generate_cutter( """ Generate a 3D cutter mesh from a 2D grid mask polygon. - This function extrudes the 2D grid mask downward (into the part) to create + This function extrudes the 2D grid mask upward into the part to create a solid mesh suitable for boolean difference operations. Strategy: - 1. Extrude the 2D polygon in local -Z direction by depth - 2. Transform from local frame to world coordinates - 3. Push cutter slightly into part (by epsilon) to avoid coplanar issues + 1. Extrude the 2D polygon in local +Z direction into the part by `depth` + 2. Extend cutter slightly below the bottom plane by `epsilon` (coplanar avoidance) + 3. Transform from local frame to world coordinates + + The resulting cutter in world coordinates spans approximately: + z_range = [z_min - epsilon, z_min + depth] Args: grid_mask: Shapely Polygon/MultiPolygon in local 2D coordinates frame: BottomFrame defining the local coordinate system - depth: Extrusion depth in mm (positive value, extruded downward) - epsilon: Small offset to push cutter into part (avoids coplanar faces) + depth: Extrusion depth in mm (positive value, extruded upward into part) + epsilon: Penetration below bottom plane in mm (avoids coplanar faces) Returns: trimesh.Trimesh in world coordinates @@ -49,13 +53,15 @@ def generate_cutter( """ if depth <= 0: raise ValueError(f"depth must be positive, got {depth}") + if epsilon < 0: + raise ValueError(f"epsilon must be non-negative, got {epsilon}") # Handle MultiPolygon by creating meshes for each and combining if isinstance(grid_mask, MultiPolygon): meshes = [] for poly in grid_mask.geoms: if poly.is_valid and poly.area > 1e-6: - mesh = _extrude_polygon(poly, depth) + mesh = _extrude_polygon(poly, depth, epsilon) if mesh is not None: meshes.append(mesh) @@ -65,24 +71,14 @@ def generate_cutter( # Concatenate all meshes cutter_local = trimesh.util.concatenate(meshes) else: - cutter_local = _extrude_polygon(grid_mask, depth) + cutter_local = _extrude_polygon(grid_mask, depth, epsilon) if cutter_local is None: raise ValueError("Failed to extrude polygon") - # The extrusion is created with top at Z=0, extending to Z=-depth - # We need to transform to world coordinates - - # Build transformation matrix - # Local frame: origin at frame.origin, axes defined by frame.rotation + # Transform local cutter into world coordinates + # Local z=0 corresponds to the bottom plane (frame.origin) T = frame.to_transform_matrix() - # Apply epsilon offset: push cutter into part by moving along -up_normal - # This means translating in local -Z by epsilon - offset_local = np.array([0, 0, -epsilon]) - offset_world = frame.rotation @ offset_local - T[:3, 3] += offset_world - - # Apply transformation cutter_world = cutter_local.copy() cutter_world.apply_transform(T) @@ -93,32 +89,37 @@ def generate_cutter( return cutter_world -def _extrude_polygon(poly: Polygon, depth: float) -> trimesh.Trimesh: +def _extrude_polygon(poly: Polygon, depth: float, epsilon: float) -> Optional[trimesh.Trimesh]: """ Extrude a single 2D polygon to a 3D mesh. - The polygon is extruded from Z=0 to Z=-depth (downward in local frame). + The polygon is extruded from Z=-epsilon to Z=+depth in local frame. + This creates a cutter that: + - Penetrates slightly below the bottom plane (by epsilon) + - Extends upward into the part (by depth) Args: poly: Shapely Polygon (can have holes) - depth: Extrusion depth (positive value) + depth: Extrusion depth upward into part (positive value) + epsilon: Penetration below bottom plane (>= 0) Returns: trimesh.Trimesh or None if extrusion fails """ try: - # trimesh.creation.extrude_polygon extrudes in +Z direction - # We want -Z, so we'll extrude in +Z then flip + # Total height includes both the upward depth and downward penetration + height = depth + epsilon - # Create the extrusion - mesh = trimesh.creation.extrude_polygon(poly, height=depth) + # extrude_polygon extrudes in +Z direction: creates mesh from Z=0 to Z=height + mesh = trimesh.creation.extrude_polygon(poly, height=height) if mesh is None or len(mesh.faces) == 0: return None - # Flip to extrude downward: translate down and invert - # Move so top is at Z=0, bottom at Z=-depth - mesh.apply_translation([0, 0, -depth]) + # Shift so cutter spans [-epsilon, +depth] in local Z + # Currently spans [0, height], need to shift down by epsilon + if epsilon != 0: + mesh.apply_translation([0, 0, -epsilon]) return mesh @@ -151,6 +152,7 @@ def estimate_cutter_bounds( grid_mask: Union[Polygon, MultiPolygon], frame: BottomFrame, depth: float, + epsilon: float = COPLANAR_EPSILON, ) -> dict: """ Estimate the bounds of the cutter without actually creating it. @@ -161,6 +163,7 @@ def estimate_cutter_bounds( grid_mask: Shapely Polygon/MultiPolygon in local 2D coordinates frame: BottomFrame defining the local coordinate system depth: Extrusion depth in mm + epsilon: Penetration below bottom plane in mm Returns: Dictionary with estimated bounds info @@ -169,16 +172,17 @@ def estimate_cutter_bounds( bounds_2d = grid_mask.bounds # (minx, miny, maxx, maxy) # Convert corners to 3D local coordinates + # Cutter spans from z=-epsilon to z=+depth corners_local = np.array( [ - [bounds_2d[0], bounds_2d[1], 0], - [bounds_2d[2], bounds_2d[1], 0], - [bounds_2d[2], bounds_2d[3], 0], - [bounds_2d[0], bounds_2d[3], 0], - [bounds_2d[0], bounds_2d[1], -depth], - [bounds_2d[2], bounds_2d[1], -depth], - [bounds_2d[2], bounds_2d[3], -depth], - [bounds_2d[0], bounds_2d[3], -depth], + [bounds_2d[0], bounds_2d[1], -epsilon], + [bounds_2d[2], bounds_2d[1], -epsilon], + [bounds_2d[2], bounds_2d[3], -epsilon], + [bounds_2d[0], bounds_2d[3], -epsilon], + [bounds_2d[0], bounds_2d[1], depth], + [bounds_2d[2], bounds_2d[1], depth], + [bounds_2d[2], bounds_2d[3], depth], + [bounds_2d[0], bounds_2d[3], depth], ] ) @@ -187,9 +191,9 @@ def estimate_cutter_bounds( return { "local_bounds_2d": bounds_2d, - "local_z_range": (0, -depth), + "local_z_range": (-epsilon, depth), "world_min": corners_world.min(axis=0).tolist(), "world_max": corners_world.max(axis=0).tolist(), "grid_mask_area": grid_mask.area, - "estimated_volume": grid_mask.area * depth, + "estimated_volume": grid_mask.area * (depth + epsilon), } diff --git a/meshcutter/core/detection.py b/meshcutter/core/detection.py index c7750c1..e887615 100644 --- a/meshcutter/core/detection.py +++ b/meshcutter/core/detection.py @@ -180,6 +180,7 @@ def detect_bottom_frame( # Project vertices onto the bottom plane and compute PCA for X/Y axes # Project to 2D by removing the up_normal component origin = centroids[bottom_mask].mean(axis=0) + origin[2] = z_min # Snap to actual z_min for consistent local Z=0 plane # Create a temporary basis to project vertices # Find a vector not parallel to up_normal for cross product diff --git a/tests/test_meshcutter/test_cutter.py b/tests/test_meshcutter/test_cutter.py index 5665f9c..8f9c4a9 100644 --- a/tests/test_meshcutter/test_cutter.py +++ b/tests/test_meshcutter/test_cutter.py @@ -41,8 +41,8 @@ def test_simple_rectangle(self): assert len(cutter.faces) > 0 assert len(cutter.vertices) > 0 - def test_cutter_depth(self): - """Cutter should extend to correct depth.""" + def test_cutter_z_extent(self): + """Cutter should extend from z_min to z_min + depth (with epsilon=0).""" grid_mask = box(-10, -10, 10, 10) frame = create_identity_frame(z_min=0.0) depth = 4.75 @@ -54,9 +54,13 @@ def test_cutter_depth(self): # Z extent should be approximately equal to depth assert z_extent == pytest.approx(depth, rel=0.01) + # Bottom should be at z_min (0.0) + assert bounds[0, 2] == pytest.approx(0.0, abs=0.01) + # Top should be at z_min + depth + assert bounds[1, 2] == pytest.approx(depth, abs=0.01) - def test_cutter_top_at_frame_origin(self): - """Cutter top should be at or slightly below frame origin Z.""" + def test_cutter_bottom_at_frame_origin(self): + """Cutter bottom should be at frame origin Z (with epsilon=0).""" grid_mask = box(-10, -10, 10, 10) frame = create_identity_frame(z_min=5.0) depth = 4.75 @@ -64,11 +68,13 @@ def test_cutter_top_at_frame_origin(self): cutter = generate_cutter(grid_mask, frame, depth, epsilon=0) bounds = cutter.bounds - # Top of cutter should be at z=5.0 (frame origin z) - assert bounds[1, 2] == pytest.approx(5.0, abs=0.1) + # Bottom of cutter should be at z=5.0 (frame origin z / z_min) + assert bounds[0, 2] == pytest.approx(5.0, abs=0.1) + # Top should be at z_min + depth = 9.75 + assert bounds[1, 2] == pytest.approx(5.0 + depth, abs=0.1) - def test_epsilon_pushes_cutter_into_part(self): - """Epsilon should push cutter slightly into part.""" + def test_epsilon_extends_below_bottom(self): + """Epsilon should extend cutter below the bottom plane.""" grid_mask = box(-10, -10, 10, 10) frame = create_identity_frame(z_min=0.0) depth = 4.75 @@ -77,8 +83,13 @@ def test_epsilon_pushes_cutter_into_part(self): cutter = generate_cutter(grid_mask, frame, depth, epsilon=epsilon) bounds = cutter.bounds - # Top should be below z=0 by epsilon amount - assert bounds[1, 2] < 0 + # Bottom should be at z_min - epsilon = -0.5 + assert bounds[0, 2] == pytest.approx(-epsilon, abs=0.01) + # Top should be at z_min + depth = 4.75 + assert bounds[1, 2] == pytest.approx(depth, abs=0.01) + # Total extent should be depth + epsilon + z_extent = bounds[1, 2] - bounds[0, 2] + assert z_extent == pytest.approx(depth + epsilon, rel=0.01) def test_invalid_depth(self): """Zero or negative depth should raise ValueError.""" @@ -91,6 +102,14 @@ def test_invalid_depth(self): with pytest.raises(ValueError): generate_cutter(grid_mask, frame, depth=-5) + def test_invalid_epsilon(self): + """Negative epsilon should raise ValueError.""" + grid_mask = box(-10, -10, 10, 10) + frame = create_identity_frame() + + with pytest.raises(ValueError): + generate_cutter(grid_mask, frame, depth=5.0, epsilon=-0.1) + def test_multipolygon_input(self): """Should handle MultiPolygon inputs.""" poly1 = box(-20, -20, -5, 20) @@ -159,12 +178,24 @@ def test_estimates_bounds(self): assert "estimated_volume" in estimate def test_estimated_volume(self): - """Estimated volume should be area * depth.""" + """Estimated volume should be area * (depth + epsilon).""" grid_mask = box(-10, -10, 10, 10) # Area = 400 frame = create_identity_frame() depth = 5.0 + epsilon = COPLANAR_EPSILON - estimate = estimate_cutter_bounds(grid_mask, frame, depth) + estimate = estimate_cutter_bounds(grid_mask, frame, depth, epsilon=epsilon) + + expected_volume = 400.0 * (depth + epsilon) + assert estimate["estimated_volume"] == pytest.approx(expected_volume, rel=0.01) + + def test_local_z_range(self): + """Local Z range should be (-epsilon, depth).""" + grid_mask = box(-10, -10, 10, 10) + frame = create_identity_frame() + depth = 5.0 + epsilon = 0.1 + + estimate = estimate_cutter_bounds(grid_mask, frame, depth, epsilon=epsilon) - expected_volume = 400.0 * 5.0 - assert estimate["estimated_volume"] == pytest.approx(expected_volume) + assert estimate["local_z_range"] == (-epsilon, depth)