Skip to content

Commit ff1e2a2

Browse files
authored
Merge pull request #16 from nullStack65/dev
fix: correct cutter extrusion direction to cut into part instead of b…
2 parents 01013f5 + 62095c9 commit ff1e2a2

File tree

4 files changed

+125
-59
lines changed

4 files changed

+125
-59
lines changed

meshcutter/cli/meshcut.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,17 +318,47 @@ def run_meshcut(args: argparse.Namespace) -> None:
318318
print(f" Cutter watertight: {cutter_info['is_watertight']}")
319319
print()
320320

321-
# Step 6: Validate inputs
321+
# Step 6: Validate inputs and check bounds intersection
322322
if args.verbose:
323323
print("Validating boolean inputs...", end=" ", flush=True)
324324

325+
# Print bounds for debugging
326+
part_bounds = mesh.bounds
327+
cutter_bounds = cutter.bounds
328+
329+
if args.verbose:
330+
print()
331+
print(f" Part bounds: {part_bounds[0].tolist()} to {part_bounds[1].tolist()}")
332+
print(f" Cutter bounds: {cutter_bounds[0].tolist()} to {cutter_bounds[1].tolist()}")
333+
print(
334+
f" Z overlap: part[{part_bounds[0, 2]:.3f}, {part_bounds[1, 2]:.3f}] "
335+
f"cutter[{cutter_bounds[0, 2]:.3f}, {cutter_bounds[1, 2]:.3f}]"
336+
)
337+
338+
# Hard check: cutter must intersect part bounds
339+
bounds_intersect = not (
340+
part_bounds[1, 0] < cutter_bounds[0, 0]
341+
or part_bounds[0, 0] > cutter_bounds[1, 0]
342+
or part_bounds[1, 1] < cutter_bounds[0, 1]
343+
or part_bounds[0, 1] > cutter_bounds[1, 1]
344+
or part_bounds[1, 2] < cutter_bounds[0, 2]
345+
or part_bounds[0, 2] > cutter_bounds[1, 2]
346+
)
347+
348+
if not bounds_intersect:
349+
raise RuntimeError(
350+
f"Cutter does not intersect part bounds.\n"
351+
f"Part bounds: {part_bounds.tolist()}\n"
352+
f"Cutter bounds: {cutter_bounds.tolist()}\n"
353+
f"This is likely a transform/extrusion-direction bug."
354+
)
355+
325356
warnings = validate_boolean_inputs(mesh, cutter)
326357
if warnings and args.verbose:
327-
print()
328358
for w in warnings:
329359
print(f" Warning: {w}")
330360
elif args.verbose:
331-
print("ok")
361+
print(" Bounds check: OK")
332362
print()
333363

334364
# Step 7: Boolean difference

meshcutter/core/cutter.py

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from __future__ import annotations
77

8-
from typing import Union
8+
from typing import Optional, Union
99

1010
import numpy as np
1111
import trimesh
@@ -14,7 +14,8 @@
1414
from 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.
1819
COPLANAR_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
}

meshcutter/core/detection.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def detect_bottom_frame(
180180
# Project vertices onto the bottom plane and compute PCA for X/Y axes
181181
# Project to 2D by removing the up_normal component
182182
origin = centroids[bottom_mask].mean(axis=0)
183+
origin[2] = z_min # Snap to actual z_min for consistent local Z=0 plane
183184

184185
# Create a temporary basis to project vertices
185186
# Find a vector not parallel to up_normal for cross product

tests/test_meshcutter/test_cutter.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ def test_simple_rectangle(self):
4141
assert len(cutter.faces) > 0
4242
assert len(cutter.vertices) > 0
4343

44-
def test_cutter_depth(self):
45-
"""Cutter should extend to correct depth."""
44+
def test_cutter_z_extent(self):
45+
"""Cutter should extend from z_min to z_min + depth (with epsilon=0)."""
4646
grid_mask = box(-10, -10, 10, 10)
4747
frame = create_identity_frame(z_min=0.0)
4848
depth = 4.75
@@ -54,21 +54,27 @@ def test_cutter_depth(self):
5454

5555
# Z extent should be approximately equal to depth
5656
assert z_extent == pytest.approx(depth, rel=0.01)
57+
# Bottom should be at z_min (0.0)
58+
assert bounds[0, 2] == pytest.approx(0.0, abs=0.01)
59+
# Top should be at z_min + depth
60+
assert bounds[1, 2] == pytest.approx(depth, abs=0.01)
5761

58-
def test_cutter_top_at_frame_origin(self):
59-
"""Cutter top should be at or slightly below frame origin Z."""
62+
def test_cutter_bottom_at_frame_origin(self):
63+
"""Cutter bottom should be at frame origin Z (with epsilon=0)."""
6064
grid_mask = box(-10, -10, 10, 10)
6165
frame = create_identity_frame(z_min=5.0)
6266
depth = 4.75
6367

6468
cutter = generate_cutter(grid_mask, frame, depth, epsilon=0)
6569

6670
bounds = cutter.bounds
67-
# Top of cutter should be at z=5.0 (frame origin z)
68-
assert bounds[1, 2] == pytest.approx(5.0, abs=0.1)
71+
# Bottom of cutter should be at z=5.0 (frame origin z / z_min)
72+
assert bounds[0, 2] == pytest.approx(5.0, abs=0.1)
73+
# Top should be at z_min + depth = 9.75
74+
assert bounds[1, 2] == pytest.approx(5.0 + depth, abs=0.1)
6975

70-
def test_epsilon_pushes_cutter_into_part(self):
71-
"""Epsilon should push cutter slightly into part."""
76+
def test_epsilon_extends_below_bottom(self):
77+
"""Epsilon should extend cutter below the bottom plane."""
7278
grid_mask = box(-10, -10, 10, 10)
7379
frame = create_identity_frame(z_min=0.0)
7480
depth = 4.75
@@ -77,8 +83,13 @@ def test_epsilon_pushes_cutter_into_part(self):
7783
cutter = generate_cutter(grid_mask, frame, depth, epsilon=epsilon)
7884

7985
bounds = cutter.bounds
80-
# Top should be below z=0 by epsilon amount
81-
assert bounds[1, 2] < 0
86+
# Bottom should be at z_min - epsilon = -0.5
87+
assert bounds[0, 2] == pytest.approx(-epsilon, abs=0.01)
88+
# Top should be at z_min + depth = 4.75
89+
assert bounds[1, 2] == pytest.approx(depth, abs=0.01)
90+
# Total extent should be depth + epsilon
91+
z_extent = bounds[1, 2] - bounds[0, 2]
92+
assert z_extent == pytest.approx(depth + epsilon, rel=0.01)
8293

8394
def test_invalid_depth(self):
8495
"""Zero or negative depth should raise ValueError."""
@@ -91,6 +102,14 @@ def test_invalid_depth(self):
91102
with pytest.raises(ValueError):
92103
generate_cutter(grid_mask, frame, depth=-5)
93104

105+
def test_invalid_epsilon(self):
106+
"""Negative epsilon should raise ValueError."""
107+
grid_mask = box(-10, -10, 10, 10)
108+
frame = create_identity_frame()
109+
110+
with pytest.raises(ValueError):
111+
generate_cutter(grid_mask, frame, depth=5.0, epsilon=-0.1)
112+
94113
def test_multipolygon_input(self):
95114
"""Should handle MultiPolygon inputs."""
96115
poly1 = box(-20, -20, -5, 20)
@@ -159,12 +178,24 @@ def test_estimates_bounds(self):
159178
assert "estimated_volume" in estimate
160179

161180
def test_estimated_volume(self):
162-
"""Estimated volume should be area * depth."""
181+
"""Estimated volume should be area * (depth + epsilon)."""
163182
grid_mask = box(-10, -10, 10, 10) # Area = 400
164183
frame = create_identity_frame()
165184
depth = 5.0
185+
epsilon = COPLANAR_EPSILON
166186

167-
estimate = estimate_cutter_bounds(grid_mask, frame, depth)
187+
estimate = estimate_cutter_bounds(grid_mask, frame, depth, epsilon=epsilon)
188+
189+
expected_volume = 400.0 * (depth + epsilon)
190+
assert estimate["estimated_volume"] == pytest.approx(expected_volume, rel=0.01)
191+
192+
def test_local_z_range(self):
193+
"""Local Z range should be (-epsilon, depth)."""
194+
grid_mask = box(-10, -10, 10, 10)
195+
frame = create_identity_frame()
196+
depth = 5.0
197+
epsilon = 0.1
198+
199+
estimate = estimate_cutter_bounds(grid_mask, frame, depth, epsilon=epsilon)
168200

169-
expected_volume = 400.0 * 5.0
170-
assert estimate["estimated_volume"] == pytest.approx(expected_volume)
201+
assert estimate["local_z_range"] == (-epsilon, depth)

0 commit comments

Comments
 (0)