55
66from __future__ import annotations
77
8- from typing import Union
8+ from typing import Optional , Union
99
1010import numpy as np
1111import trimesh
1414from meshcutter .core .detection import BottomFrame
1515
1616
17- # Epsilon for pushing cutter slightly into the part to avoid coplanar issues
17+ # Penetration below the bottom plane to avoid coplanar boolean degeneracy.
18+ # The cutter will extend from z = -epsilon up to z = +depth in local coordinates.
1819COPLANAR_EPSILON = 0.02 # mm
1920
2021
@@ -27,19 +28,22 @@ def generate_cutter(
2728 """
2829 Generate a 3D cutter mesh from a 2D grid mask polygon.
2930
30- This function extrudes the 2D grid mask downward ( into the part) to create
31+ This function extrudes the 2D grid mask upward into the part to create
3132 a solid mesh suitable for boolean difference operations.
3233
3334 Strategy:
34- 1. Extrude the 2D polygon in local -Z direction by depth
35- 2. Transform from local frame to world coordinates
36- 3. Push cutter slightly into part (by epsilon) to avoid coplanar issues
35+ 1. Extrude the 2D polygon in local +Z direction into the part by `depth`
36+ 2. Extend cutter slightly below the bottom plane by `epsilon` (coplanar avoidance)
37+ 3. Transform from local frame to world coordinates
38+
39+ The resulting cutter in world coordinates spans approximately:
40+ z_range = [z_min - epsilon, z_min + depth]
3741
3842 Args:
3943 grid_mask: Shapely Polygon/MultiPolygon in local 2D coordinates
4044 frame: BottomFrame defining the local coordinate system
41- depth: Extrusion depth in mm (positive value, extruded downward )
42- epsilon: Small offset to push cutter into part (avoids coplanar faces)
45+ depth: Extrusion depth in mm (positive value, extruded upward into part )
46+ epsilon: Penetration below bottom plane in mm (avoids coplanar faces)
4347
4448 Returns:
4549 trimesh.Trimesh in world coordinates
@@ -49,13 +53,15 @@ def generate_cutter(
4953 """
5054 if depth <= 0 :
5155 raise ValueError (f"depth must be positive, got { depth } " )
56+ if epsilon < 0 :
57+ raise ValueError (f"epsilon must be non-negative, got { epsilon } " )
5258
5359 # Handle MultiPolygon by creating meshes for each and combining
5460 if isinstance (grid_mask , MultiPolygon ):
5561 meshes = []
5662 for poly in grid_mask .geoms :
5763 if poly .is_valid and poly .area > 1e-6 :
58- mesh = _extrude_polygon (poly , depth )
64+ mesh = _extrude_polygon (poly , depth , epsilon )
5965 if mesh is not None :
6066 meshes .append (mesh )
6167
@@ -65,24 +71,14 @@ def generate_cutter(
6571 # Concatenate all meshes
6672 cutter_local = trimesh .util .concatenate (meshes )
6773 else :
68- cutter_local = _extrude_polygon (grid_mask , depth )
74+ cutter_local = _extrude_polygon (grid_mask , depth , epsilon )
6975 if cutter_local is None :
7076 raise ValueError ("Failed to extrude polygon" )
7177
72- # The extrusion is created with top at Z=0, extending to Z=-depth
73- # We need to transform to world coordinates
74-
75- # Build transformation matrix
76- # Local frame: origin at frame.origin, axes defined by frame.rotation
78+ # Transform local cutter into world coordinates
79+ # Local z=0 corresponds to the bottom plane (frame.origin)
7780 T = frame .to_transform_matrix ()
7881
79- # Apply epsilon offset: push cutter into part by moving along -up_normal
80- # This means translating in local -Z by epsilon
81- offset_local = np .array ([0 , 0 , - epsilon ])
82- offset_world = frame .rotation @ offset_local
83- T [:3 , 3 ] += offset_world
84-
85- # Apply transformation
8682 cutter_world = cutter_local .copy ()
8783 cutter_world .apply_transform (T )
8884
@@ -93,32 +89,37 @@ def generate_cutter(
9389 return cutter_world
9490
9591
96- def _extrude_polygon (poly : Polygon , depth : float ) -> trimesh .Trimesh :
92+ def _extrude_polygon (poly : Polygon , depth : float , epsilon : float ) -> Optional [ trimesh .Trimesh ] :
9793 """
9894 Extrude a single 2D polygon to a 3D mesh.
9995
100- The polygon is extruded from Z=0 to Z=-depth (downward in local frame).
96+ The polygon is extruded from Z=-epsilon to Z=+depth in local frame.
97+ This creates a cutter that:
98+ - Penetrates slightly below the bottom plane (by epsilon)
99+ - Extends upward into the part (by depth)
101100
102101 Args:
103102 poly: Shapely Polygon (can have holes)
104- depth: Extrusion depth (positive value)
103+ depth: Extrusion depth upward into part (positive value)
104+ epsilon: Penetration below bottom plane (>= 0)
105105
106106 Returns:
107107 trimesh.Trimesh or None if extrusion fails
108108 """
109109 try :
110- # trimesh.creation.extrude_polygon extrudes in +Z direction
111- # We want -Z, so we'll extrude in +Z then flip
110+ # Total height includes both the upward depth and downward penetration
111+ height = depth + epsilon
112112
113- # Create the extrusion
114- mesh = trimesh .creation .extrude_polygon (poly , height = depth )
113+ # extrude_polygon extrudes in +Z direction: creates mesh from Z=0 to Z=height
114+ mesh = trimesh .creation .extrude_polygon (poly , height = height )
115115
116116 if mesh is None or len (mesh .faces ) == 0 :
117117 return None
118118
119- # Flip to extrude downward: translate down and invert
120- # Move so top is at Z=0, bottom at Z=-depth
121- mesh .apply_translation ([0 , 0 , - depth ])
119+ # Shift so cutter spans [-epsilon, +depth] in local Z
120+ # Currently spans [0, height], need to shift down by epsilon
121+ if epsilon != 0 :
122+ mesh .apply_translation ([0 , 0 , - epsilon ])
122123
123124 return mesh
124125
@@ -151,6 +152,7 @@ def estimate_cutter_bounds(
151152 grid_mask : Union [Polygon , MultiPolygon ],
152153 frame : BottomFrame ,
153154 depth : float ,
155+ epsilon : float = COPLANAR_EPSILON ,
154156) -> dict :
155157 """
156158 Estimate the bounds of the cutter without actually creating it.
@@ -161,6 +163,7 @@ def estimate_cutter_bounds(
161163 grid_mask: Shapely Polygon/MultiPolygon in local 2D coordinates
162164 frame: BottomFrame defining the local coordinate system
163165 depth: Extrusion depth in mm
166+ epsilon: Penetration below bottom plane in mm
164167
165168 Returns:
166169 Dictionary with estimated bounds info
@@ -169,16 +172,17 @@ def estimate_cutter_bounds(
169172 bounds_2d = grid_mask .bounds # (minx, miny, maxx, maxy)
170173
171174 # Convert corners to 3D local coordinates
175+ # Cutter spans from z=-epsilon to z=+depth
172176 corners_local = np .array (
173177 [
174- [bounds_2d [0 ], bounds_2d [1 ], 0 ],
175- [bounds_2d [2 ], bounds_2d [1 ], 0 ],
176- [bounds_2d [2 ], bounds_2d [3 ], 0 ],
177- [bounds_2d [0 ], bounds_2d [3 ], 0 ],
178- [bounds_2d [0 ], bounds_2d [1 ], - depth ],
179- [bounds_2d [2 ], bounds_2d [1 ], - depth ],
180- [bounds_2d [2 ], bounds_2d [3 ], - depth ],
181- [bounds_2d [0 ], bounds_2d [3 ], - depth ],
178+ [bounds_2d [0 ], bounds_2d [1 ], - epsilon ],
179+ [bounds_2d [2 ], bounds_2d [1 ], - epsilon ],
180+ [bounds_2d [2 ], bounds_2d [3 ], - epsilon ],
181+ [bounds_2d [0 ], bounds_2d [3 ], - epsilon ],
182+ [bounds_2d [0 ], bounds_2d [1 ], depth ],
183+ [bounds_2d [2 ], bounds_2d [1 ], depth ],
184+ [bounds_2d [2 ], bounds_2d [3 ], depth ],
185+ [bounds_2d [0 ], bounds_2d [3 ], depth ],
182186 ]
183187 )
184188
@@ -187,9 +191,9 @@ def estimate_cutter_bounds(
187191
188192 return {
189193 "local_bounds_2d" : bounds_2d ,
190- "local_z_range" : (0 , - depth ),
194+ "local_z_range" : (- epsilon , depth ),
191195 "world_min" : corners_world .min (axis = 0 ).tolist (),
192196 "world_max" : corners_world .max (axis = 0 ).tolist (),
193197 "grid_mask_area" : grid_mask .area ,
194- "estimated_volume" : grid_mask .area * depth ,
198+ "estimated_volume" : grid_mask .area * ( depth + epsilon ) ,
195199 }
0 commit comments