Skip to content
Merged
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
36 changes: 33 additions & 3 deletions meshcutter/cli/meshcut.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 46 additions & 42 deletions meshcutter/core/cutter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from typing import Union
from typing import Optional, Union

import numpy as np
import trimesh
Expand All @@ -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


Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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],
]
)

Expand All @@ -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),
}
1 change: 1 addition & 0 deletions meshcutter/core/detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 45 additions & 14 deletions tests/test_meshcutter/test_cutter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,21 +54,27 @@ 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

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
Expand All @@ -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."""
Expand All @@ -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)
Expand Down Expand Up @@ -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)