diff --git a/meshcutter/cli/meshcut.py b/meshcutter/cli/meshcut.py index 13cc2b2..d441679 100644 --- a/meshcutter/cli/meshcut.py +++ b/meshcutter/cli/meshcut.py @@ -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, @@ -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'', + f'', # 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'' + ) + else: + path = polygon_to_path(footprint) + svg_lines.append( + f'' + ) + + # Draw mask (red fill with transparency) + if isinstance(mask, MultiPolygon): + for poly in mask.geoms: + path = polygon_to_path(poly) + svg_lines.append( + f'' + ) + else: + path = polygon_to_path(mask) + svg_lines.append( + f'' + ) + + svg_lines.append("") + svg_lines.append("") + + output_path.write_text("\n".join(svg_lines)) + + def main(): """Main entry point for microfinity-meshcut CLI.""" parser = argparse.ArgumentParser( @@ -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", @@ -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", @@ -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 @@ -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 @@ -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() @@ -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] @@ -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: diff --git a/meshcutter/core/__init__.py b/meshcutter/core/__init__.py index 265cfa7..4afb758 100644 --- a/meshcutter/core/__init__.py +++ b/meshcutter/core/__init__.py @@ -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", ] diff --git a/meshcutter/core/cutter.py b/meshcutter/core/cutter.py index 30786a5..cd9daf9 100644 --- a/meshcutter/core/cutter.py +++ b/meshcutter/core/cutter.py @@ -5,13 +5,15 @@ from __future__ import annotations -from typing import Optional, Union +from typing import List, Optional, Union import numpy as np import trimesh from shapely.geometry import Polygon, MultiPolygon +from shapely.ops import unary_union from meshcutter.core.detection import BottomFrame +from meshcutter.core.profile import CutterProfile, get_profile, PROFILE_RECTANGULAR # Penetration below the bottom plane to avoid coplanar boolean degeneracy. @@ -197,3 +199,212 @@ def estimate_cutter_bounds( "grid_mask_area": grid_mask.area, "estimated_volume": grid_mask.area * (depth + epsilon), } + + +def generate_profiled_cutter( + grid_mask: Union[Polygon, MultiPolygon], + frame: BottomFrame, + profile: Union[str, CutterProfile] = PROFILE_RECTANGULAR, + depth: Optional[float] = None, + epsilon: float = COPLANAR_EPSILON, + n_slices: int = 10, + mitre_limit: float = 5.0, + simplify_tolerance: float = 0.01, +) -> trimesh.Trimesh: + """ + Generate a 3D cutter mesh with a chamfered/profiled sidewall. + + This function creates a cutter that matches the Gridfinity base profile + by stacking multiple extruded slabs with progressive insets. + + Strategy: + 1. Sample Z levels from the profile + 2. For each Z level, compute the inset and buffer the polygon inward + 3. Extrude each slab between consecutive Z levels + 4. Concatenate all slabs (union if manifold3d available) + 5. Transform to world coordinates + + Args: + grid_mask: Shapely Polygon/MultiPolygon in local 2D coordinates + frame: BottomFrame defining the local coordinate system + profile: Profile name ("rect", "gridfinity") or CutterProfile instance + depth: Override depth (uses profile's depth if None) + epsilon: Penetration below bottom plane in mm + n_slices: Number of Z slices for profile approximation + mitre_limit: Mitre limit for buffer operation (prevents spikes) + simplify_tolerance: Tolerance for polygon simplification (reduces noise) + + Returns: + trimesh.Trimesh in world coordinates + + Raises: + ValueError: If extrusion fails or produces invalid mesh + """ + # Get profile + if isinstance(profile, str): + profile_obj = get_profile(profile, depth if depth is not None else 4.75) + else: + profile_obj = profile + + # Use profile's total height if depth not specified + if depth is None: + depth = profile_obj.total_height + + 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}") + + # Simplify the mask slightly to reduce micro-zigzags that create bad miters + if simplify_tolerance > 0: + simplified_mask = grid_mask.simplify(simplify_tolerance, preserve_topology=True) + if simplified_mask.is_valid and not simplified_mask.is_empty: + grid_mask = simplified_mask + + # Get Z levels + z_levels = profile_obj.sample_z_levels(n_slices=n_slices, epsilon=epsilon) + + # Ensure we have the full depth + if z_levels[-1] < depth: + z_levels.append(depth) + + # Generate slabs + slabs: List[trimesh.Trimesh] = [] + + for i in range(len(z_levels) - 1): + z_bottom = z_levels[i] + z_top = z_levels[i + 1] + slab_height = z_top - z_bottom + + if slab_height <= 0: + continue + + # Compute inset at bottom of this slab + # (conservative: use the larger inset to avoid boolean issues) + inset = profile_obj.inset_at(max(0, z_bottom)) + + # Buffer the polygon inward + if inset > 0: + buffered = grid_mask.buffer( + -inset, + join_style="mitre", + mitre_limit=mitre_limit, + ) + else: + buffered = grid_mask + + # Skip if polygon collapsed + if buffered.is_empty: + continue + + # Handle invalid geometry + if not buffered.is_valid: + from shapely.validation import make_valid + + buffered = make_valid(buffered) + if buffered.is_empty: + continue + + # Extrude this slab + slab_meshes = _extrude_geometry(buffered, slab_height) + + if not slab_meshes: + continue + + # Translate to correct Z position + for mesh in slab_meshes: + mesh.apply_translation([0, 0, z_bottom]) + + slabs.extend(slab_meshes) + + if not slabs: + raise ValueError("No valid slabs created for profiled cutter") + + # Concatenate all slabs + cutter_local = trimesh.util.concatenate(slabs) + + # Try to union/merge if manifold3d is available (cleaner result) + cutter_local = _try_union_slabs(cutter_local) + + # Transform to world coordinates + T = frame.to_transform_matrix() + cutter_world = cutter_local.copy() + cutter_world.apply_transform(T) + + if len(cutter_world.faces) == 0: + raise ValueError("Profiled cutter mesh has no faces") + + return cutter_world + + +def _extrude_geometry(geom, height: float) -> List[trimesh.Trimesh]: + """ + Extrude a Shapely geometry (Polygon or MultiPolygon) to 3D meshes. + + Args: + geom: Shapely geometry + height: Extrusion height + + Returns: + List of trimesh.Trimesh objects + """ + meshes = [] + + if isinstance(geom, MultiPolygon): + for poly in geom.geoms: + if isinstance(poly, Polygon) and poly.is_valid and poly.area > 1e-6: + mesh = _extrude_single_polygon(poly, height) + if mesh is not None: + meshes.append(mesh) + elif isinstance(geom, Polygon): + if geom.is_valid and geom.area > 1e-6: + mesh = _extrude_single_polygon(geom, height) + if mesh is not None: + meshes.append(mesh) + # Handle GeometryCollection or other types + elif hasattr(geom, "geoms"): + for g in geom.geoms: + meshes.extend(_extrude_geometry(g, height)) + + return meshes + + +def _extrude_single_polygon(poly: Polygon, height: float) -> Optional[trimesh.Trimesh]: + """Extrude a single polygon to a mesh.""" + try: + mesh = trimesh.creation.extrude_polygon(poly, height=height) + if mesh is not None and len(mesh.faces) > 0: + return mesh + except Exception: + pass + return None + + +def _try_union_slabs(cutter: trimesh.Trimesh) -> trimesh.Trimesh: + """ + Try to union/merge the slab meshes for a cleaner result. + + Uses manifold3d if available, otherwise just merges vertices. + """ + try: + # Try manifold3d union + import manifold3d + + m = manifold3d.Manifold.from_mesh(cutter.vertices, cutter.faces) + # Self-union to clean up + mesh_out = m.to_mesh() + return trimesh.Trimesh(vertices=mesh_out.vert_properties, faces=mesh_out.tri_verts) + except ImportError: + pass + except Exception: + pass + + # Fallback: just merge vertices and remove degenerate faces + try: + cutter.merge_vertices() + cutter.remove_degenerate_faces() + cutter.remove_duplicate_faces() + except Exception: + pass + + return cutter diff --git a/meshcutter/core/detection.py b/meshcutter/core/detection.py index e887615..257dc50 100644 --- a/meshcutter/core/detection.py +++ b/meshcutter/core/detection.py @@ -10,11 +10,165 @@ import numpy as np import trimesh -from shapely.geometry import Polygon, MultiPolygon +from shapely.geometry import Polygon, MultiPolygon, LineString from shapely.ops import unary_union from shapely.validation import make_valid +def compute_dominant_edge_angle( + footprint: Union[Polygon, MultiPolygon], + bin_size_deg: float = 0.5, + min_dominance: float = 0.25, +) -> Optional[float]: + """ + Compute the dominant edge direction from a footprint polygon using length-weighted voting. + + This function analyzes the exterior boundary edges of the footprint and finds + the most common edge direction (within a tolerance). This is more robust than + PCA or minimum rotated rectangle for parts with straight edges. + + Algorithm: + 1. Extract all exterior boundary segments + 2. For each segment, compute its angle (reduced to [0, pi) for undirected) + 3. Weight each angle by segment length + 4. Histogram the angles and find the dominant bin + 5. Compute weighted mean within winning bin neighborhood + + Args: + footprint: Shapely Polygon or MultiPolygon + bin_size_deg: Histogram bin size in degrees (default: 0.5) + min_dominance: Minimum fraction of total weight in best bin to be considered + "dominant" (default: 0.25). If not met, returns None. + + Returns: + Dominant angle in radians [0, pi), or None if no dominant direction found. + """ + # Collect all edges from exterior rings + edges: list[tuple[float, float]] = [] # List of (angle, length) + + def add_polygon_edges(poly: Polygon): + if poly.is_empty: + return + coords = np.array(poly.exterior.coords) + for i in range(len(coords) - 1): + p1, p2 = coords[i], coords[i + 1] + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + length = np.sqrt(dx * dx + dy * dy) + if length < 1e-9: + continue + # Compute angle, reduce to [0, pi) for undirected edges + angle = np.arctan2(dy, dx) + if angle < 0: + angle += np.pi + if angle >= np.pi: + angle -= np.pi + edges.append((angle, length)) + + if isinstance(footprint, MultiPolygon): + # Use largest polygon for edge voting + largest = max(footprint.geoms, key=lambda p: p.area) + add_polygon_edges(largest) + else: + add_polygon_edges(footprint) + + if not edges: + return None + + # Build length-weighted histogram + edges_arr = np.array(edges) # shape (N, 2): [angle, length] + angles = edges_arr[:, 0] + lengths = edges_arr[:, 1] + total_length = lengths.sum() + + if total_length < 1e-9: + return None + + # Create histogram bins + bin_size_rad = np.deg2rad(bin_size_deg) + n_bins = int(np.ceil(np.pi / bin_size_rad)) + bins = np.zeros(n_bins) + + for angle, length in zip(angles, lengths): + bin_idx = int(angle / bin_size_rad) % n_bins + bins[bin_idx] += length + + # Find best bin + best_bin = np.argmax(bins) + best_weight = bins[best_bin] + + # Check dominance threshold + if best_weight / total_length < min_dominance: + return None + + # Compute weighted mean angle within winning bin +/- 1 bin (to reduce quantization) + # Handle wraparound at pi + neighbor_bins = [(best_bin - 1) % n_bins, best_bin, (best_bin + 1) % n_bins] + + weighted_sum = 0.0 + weight_sum = 0.0 + + for angle, length in zip(angles, lengths): + bin_idx = int(angle / bin_size_rad) % n_bins + if bin_idx in neighbor_bins: + # Handle wraparound: if angle near 0 and best_bin near pi, adjust + adjusted_angle = angle + best_angle_approx = best_bin * bin_size_rad + if best_angle_approx > np.pi * 0.75 and angle < np.pi * 0.25: + adjusted_angle = angle + np.pi + elif best_angle_approx < np.pi * 0.25 and angle > np.pi * 0.75: + adjusted_angle = angle - np.pi + weighted_sum += adjusted_angle * length + weight_sum += length + + if weight_sum < 1e-9: + return best_bin * bin_size_rad + bin_size_rad / 2 + + dominant_angle = weighted_sum / weight_sum + # Normalize back to [0, pi) + while dominant_angle < 0: + dominant_angle += np.pi + while dominant_angle >= np.pi: + dominant_angle -= np.pi + + return float(dominant_angle) + + +def apply_yaw_to_frame(frame: "BottomFrame", yaw_angle: float) -> "BottomFrame": + """ + Apply a yaw (rotation about Z/up_normal) to align the frame's X axis. + + Args: + frame: The original BottomFrame + yaw_angle: Angle in radians to rotate the X-Y plane + + Returns: + New BottomFrame with rotated X/Y axes (same origin and up_normal) + """ + cos_yaw = np.cos(yaw_angle) + sin_yaw = np.sin(yaw_angle) + + # Current axes + x_old = frame.x_axis.copy() + y_old = frame.y_axis.copy() + + # Rotate in the X-Y plane (about up_normal) + x_new = cos_yaw * x_old + sin_yaw * y_old + y_new = -sin_yaw * x_old + cos_yaw * y_old + + # Normalize (should already be unit, but ensure) + x_new = x_new / np.linalg.norm(x_new) + y_new = y_new / np.linalg.norm(y_new) + + # Build new rotation matrix + new_rotation = np.column_stack([x_new, y_new, frame.up_normal]) + + return BottomFrame( + origin=frame.origin.copy(), + rotation=new_rotation, + z_min=frame.z_min, + ) + + @dataclass class BottomFrame: """ @@ -252,10 +406,12 @@ def extract_footprint( # Compute slice_delta adaptively if not provided if slice_delta is None: bbox_diagonal = np.linalg.norm(mesh.bounds[1] - mesh.bounds[0]) - slice_delta = max(0.05, min(0.2, 0.01 * bbox_diagonal)) + delta = float(max(0.05, min(0.2, 0.01 * bbox_diagonal))) + else: + delta = slice_delta # Compute slice plane in world coordinates - slice_origin = frame.origin + slice_delta * frame.up_normal + slice_origin = frame.origin + delta * frame.up_normal slice_normal = frame.up_normal # Slice the mesh @@ -267,7 +423,7 @@ def extract_footprint( if section is None: # Try with different delta values for delta_mult in [0.5, 2.0, 0.1, 5.0]: - alt_delta = slice_delta * delta_mult + alt_delta = delta * delta_mult alt_origin = frame.origin + alt_delta * frame.up_normal try: section = mesh.section(plane_origin=alt_origin, plane_normal=slice_normal) @@ -278,7 +434,7 @@ def extract_footprint( if section is None: raise ValueError( - f"No cross-section found at slice_delta={slice_delta:.3f}mm. " + f"No cross-section found at slice_delta={delta:.3f}mm. " "The mesh may not intersect the bottom plane or may be non-watertight." ) @@ -308,13 +464,13 @@ def extract_footprint( # Union all polygons if len(polygons) == 1: - footprint = polygons[0] + footprint_geom = polygons[0] else: - footprint = unary_union(polygons) + footprint_geom = unary_union(polygons) # Ensure validity - if not footprint.is_valid: - footprint = make_valid(footprint) + if not footprint_geom.is_valid: + footprint_geom = make_valid(footprint_geom) # Transform footprint to local frame coordinates # The planar path has its own transform; we need to account for that @@ -325,10 +481,12 @@ def extract_footprint( # We need to convert to our local frame # Extract coordinates from footprint - if isinstance(footprint, MultiPolygon): + if isinstance(footprint_geom, MultiPolygon): result_polys = [] - for poly in footprint.geoms: - local_poly = _transform_polygon_to_local_frame(poly, transform, frame) + for geom in footprint_geom.geoms: + if not isinstance(geom, Polygon): + continue + local_poly = _transform_polygon_to_local_frame(geom, transform, frame) if local_poly is not None and local_poly.is_valid and local_poly.area > 1e-6: result_polys.append(local_poly) if len(result_polys) == 1: @@ -337,11 +495,13 @@ def extract_footprint( return MultiPolygon(result_polys) else: raise ValueError("No valid polygons after transformation") - else: - local_poly = _transform_polygon_to_local_frame(footprint, transform, frame) + elif isinstance(footprint_geom, Polygon): + local_poly = _transform_polygon_to_local_frame(footprint_geom, transform, frame) if local_poly is None or not local_poly.is_valid: raise ValueError("Failed to transform footprint to local frame") return local_poly + else: + raise ValueError(f"Unexpected geometry type: {type(footprint_geom)}") def _transform_polygon_to_local_frame( @@ -392,6 +552,99 @@ def _transform_polygon_to_local_frame( return None +def detect_aligned_frame( + mesh: trimesh.Trimesh, + force_z_up: bool = False, + z_tolerance: float = 0.1, + normal_threshold: float = 0.9, + edge_voting: bool = True, +) -> Tuple[BottomFrame, Union[Polygon, MultiPolygon]]: + """ + Detect bottom frame with edge-aligned axes and extract footprint. + + This function combines frame detection, footprint extraction, and + edge-direction voting into a single pipeline that ensures the + frame axes are aligned with the dominant edge directions. + + Process: + 1. Detect bottom plane with provisional axes + 2. Extract footprint in provisional frame + 3. Compute dominant edge angle (if edge_voting=True) + 4. Apply yaw rotation to align X axis with dominant edge + 5. Re-extract footprint in corrected frame + + Args: + mesh: Input triangle mesh + force_z_up: If True, assume mesh is already Z-aligned + z_tolerance: Tolerance for bottom face detection (mm) + normal_threshold: Threshold for face normal alignment + edge_voting: If True, use edge voting to align axes (recommended) + + Returns: + Tuple of (aligned_frame, footprint_in_aligned_frame) + + Raises: + ValueError: If detection or footprint extraction fails + """ + # Step 1: Detect bottom plane with provisional axes + frame = detect_bottom_frame( + mesh, + force_z_up=force_z_up, + normal_threshold=normal_threshold, + z_tolerance=z_tolerance, + ) + + # Step 2: Extract footprint in provisional frame + footprint = extract_footprint(mesh, frame) + + if not edge_voting: + return frame, footprint + + # Step 3: Compute dominant edge angle + dominant_angle = compute_dominant_edge_angle(footprint) + + if dominant_angle is None: + # No dominant edge found, use frame as-is + return frame, footprint + + # Step 4: Compute yaw rotation needed + # The current X axis is at angle 0 in local frame + # We want it aligned with the dominant edge angle + # So we rotate by -dominant_angle to bring dominant edge to X axis + # + # However, we also want to pick the closest 90-degree alignment + # (since rectangles have edges at 0 and 90 degrees) + # Snap to nearest multiple of 90 degrees if close + angle_deg = np.degrees(dominant_angle) + + # Find the nearest 90-degree snap point + snap_angles = [0, 90, 180] + closest_snap = min(snap_angles, key=lambda s: min(abs(angle_deg - s), abs(angle_deg - s + 180))) + + # Use the snap if within tolerance + if abs(angle_deg - closest_snap) < 5.0 or abs(angle_deg - closest_snap + 180) < 5.0: + yaw = np.radians(closest_snap) + else: + yaw = dominant_angle + + # We want to rotate so the dominant direction aligns with X + # Current X is at 0, dominant is at `yaw`, so rotate by -yaw + # But since we want X to align TO the dominant edge, we rotate by -yaw + yaw_correction = -yaw + + # Only apply rotation if it's significant + if abs(yaw_correction) < np.radians(0.1): + return frame, footprint + + # Step 5: Apply yaw rotation + aligned_frame = apply_yaw_to_frame(frame, yaw_correction) + + # Step 6: Re-extract footprint in aligned frame + aligned_footprint = extract_footprint(mesh, aligned_frame) + + return aligned_frame, aligned_footprint + + def get_mesh_diagnostics(mesh: trimesh.Trimesh) -> dict: """ Get diagnostic information about a mesh for debugging. diff --git a/meshcutter/core/profile.py b/meshcutter/core/profile.py new file mode 100644 index 0000000..dcd043c --- /dev/null +++ b/meshcutter/core/profile.py @@ -0,0 +1,271 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.profile - Gridfinity profile definitions for chamfered cutters +# + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Tuple +from math import sqrt + +import numpy as np + + +# Gridfinity profile constants (from microfinity/core/constants.py) +SQRT2 = sqrt(2) + +GR_BASE_HEIGHT = 4.75 # Total base height (mm) +GR_STR_H = 1.8 # Straight vertical section height (mm) +GR_BASE_CLR = 0.25 # Clearance above nominal base height + +# Baseplate profile chamfer dimensions +GR_BASE_CHAMF_H = 0.98994949 / SQRT2 # ~0.7mm bottom chamfer vertical height +GR_BASE_TOP_CHAMF = GR_BASE_HEIGHT - GR_BASE_CHAMF_H - GR_STR_H # ~2.25mm top chamfer + +# Box/bin base profile (slightly different dimensions) +GR_BOX_CHAMF_H = 1.1313708 / SQRT2 # ~0.8mm bottom chamfer vertical height +GR_BOX_TOP_CHAMF = GR_BASE_HEIGHT - GR_BOX_CHAMF_H - GR_STR_H + GR_BASE_CLR # ~2.4mm + + +@dataclass +class ProfileSegment: + """ + A single segment of the profile (from bottom to top). + + Attributes: + z_start: Starting Z coordinate (from bottom = 0) + z_end: Ending Z coordinate + inset_start: Inset from polygon at z_start (mm) + inset_end: Inset from polygon at z_end (mm) + """ + + z_start: float + z_end: float + inset_start: float + inset_end: float + + @property + def height(self) -> float: + return self.z_end - self.z_start + + def inset_at(self, z: float) -> float: + """Compute inset at a given Z within this segment.""" + if self.z_end == self.z_start: + return self.inset_start + t = (z - self.z_start) / (self.z_end - self.z_start) + t = max(0.0, min(1.0, t)) + return self.inset_start + t * (self.inset_end - self.inset_start) + + +@dataclass +class CutterProfile: + """ + A complete cutter profile definition. + + The profile defines how the cutter polygon is inset at different Z heights. + This allows creating chamfered/stepped cutter geometry that matches + Gridfinity base profiles. + + The profile is defined from Z=0 (bottom plane) upward into the part. + At each Z level, the polygon is inset (buffered inward) by the specified amount. + A 45-degree chamfer is achieved by linearly increasing inset with height. + """ + + name: str + segments: List[ProfileSegment] + + @property + def total_height(self) -> float: + """Total height of the profile.""" + if not self.segments: + return 0.0 + return self.segments[-1].z_end + + @property + def max_inset(self) -> float: + """Maximum inset in the profile.""" + if not self.segments: + return 0.0 + return max(max(s.inset_start, s.inset_end) for s in self.segments) + + def inset_at(self, z: float) -> float: + """ + Compute the inset at a given Z height. + + Args: + z: Height from bottom plane (0 = bottom) + + Returns: + Inset distance in mm (how much to buffer inward) + """ + if z <= 0: + return self.segments[0].inset_start if self.segments else 0.0 + + for seg in self.segments: + if seg.z_start <= z <= seg.z_end: + return seg.inset_at(z) + + # Beyond profile, use max inset + return self.max_inset + + def sample_z_levels(self, n_slices: int = 10, epsilon: float = 0.02) -> List[float]: + """ + Generate Z levels for slab extrusion. + + Args: + n_slices: Approximate number of slices + epsilon: Penetration below Z=0 for coplanar avoidance + + Returns: + List of Z values from -epsilon to total_height + """ + if not self.segments: + return [-epsilon, 0.0] + + # Include segment boundaries for accurate profile representation + z_levels = set([-epsilon]) + for seg in self.segments: + z_levels.add(seg.z_start) + z_levels.add(seg.z_end) + + # Add intermediate points within each segment + for seg in self.segments: + if seg.height > 0: + n_intermediate = max(1, int(n_slices * seg.height / self.total_height)) + for i in range(1, n_intermediate + 1): + z = seg.z_start + (seg.height * i / (n_intermediate + 1)) + z_levels.add(z) + + return sorted(z_levels) + + +def create_rectangular_profile(depth: float) -> CutterProfile: + """ + Create a simple rectangular (no chamfer) profile. + + Args: + depth: Total depth of cut (mm) + + Returns: + CutterProfile with no chamfers (constant zero inset) + """ + return CutterProfile( + name="rectangular", + segments=[ + ProfileSegment(z_start=0.0, z_end=depth, inset_start=0.0, inset_end=0.0), + ], + ) + + +def create_gridfinity_baseplate_profile(depth: float = GR_BASE_HEIGHT) -> CutterProfile: + """ + Create a Gridfinity baseplate pocket profile. + + This profile matches the cavities in a Gridfinity baseplate that receive + the bin feet. It has 45-degree chamfers at top and bottom with a straight + section in between. + + Profile (from bottom, Z=0, upward into baseplate): + - Bottom chamfer: 45° inward, ~0.7mm height + - Straight section: constant inset, 1.8mm height + - Top chamfer: 45° inward, ~2.25mm height + + Args: + depth: Total depth (default: GR_BASE_HEIGHT = 4.75mm) + + Returns: + CutterProfile for baseplate pockets + """ + # Adjust heights if depth is different from standard + scale = depth / GR_BASE_HEIGHT if depth != GR_BASE_HEIGHT else 1.0 + + bottom_chamf_h = GR_BASE_CHAMF_H * scale + straight_h = GR_STR_H * scale + top_chamf_h = GR_BASE_TOP_CHAMF * scale + + # For 45-degree chamfers: inset = height (1:1 ratio) + bottom_inset = bottom_chamf_h + top_inset = bottom_inset + top_chamf_h + + z1 = bottom_chamf_h + z2 = z1 + straight_h + z3 = z2 + top_chamf_h + + return CutterProfile( + name="gridfinity_baseplate", + segments=[ + # Bottom chamfer: inset grows from 0 to bottom_inset + ProfileSegment(z_start=0.0, z_end=z1, inset_start=0.0, inset_end=bottom_inset), + # Straight section: constant inset + ProfileSegment(z_start=z1, z_end=z2, inset_start=bottom_inset, inset_end=bottom_inset), + # Top chamfer: inset grows from bottom_inset to top_inset + ProfileSegment(z_start=z2, z_end=z3, inset_start=bottom_inset, inset_end=top_inset), + ], + ) + + +def create_gridfinity_box_profile(depth: float = GR_BASE_HEIGHT) -> CutterProfile: + """ + Create a Gridfinity box/bin foot profile. + + This profile matches the feet on Gridfinity bins that insert into + baseplate pockets. Slightly different chamfer dimensions than baseplate. + + Args: + depth: Total depth (default: GR_BASE_HEIGHT = 4.75mm) + + Returns: + CutterProfile for bin feet + """ + scale = depth / GR_BASE_HEIGHT if depth != GR_BASE_HEIGHT else 1.0 + + bottom_chamf_h = GR_BOX_CHAMF_H * scale + straight_h = GR_STR_H * scale + top_chamf_h = GR_BOX_TOP_CHAMF * scale + + bottom_inset = bottom_chamf_h + top_inset = bottom_inset + top_chamf_h + + z1 = bottom_chamf_h + z2 = z1 + straight_h + z3 = z2 + top_chamf_h + + return CutterProfile( + name="gridfinity_box", + segments=[ + ProfileSegment(z_start=0.0, z_end=z1, inset_start=0.0, inset_end=bottom_inset), + ProfileSegment(z_start=z1, z_end=z2, inset_start=bottom_inset, inset_end=bottom_inset), + ProfileSegment(z_start=z2, z_end=z3, inset_start=bottom_inset, inset_end=top_inset), + ], + ) + + +# Pre-defined profiles +PROFILE_RECTANGULAR = "rect" +PROFILE_GRIDFINITY = "gridfinity" +PROFILE_GRIDFINITY_BOX = "gridfinity_box" + + +def get_profile(name: str, depth: float = GR_BASE_HEIGHT) -> CutterProfile: + """ + Get a cutter profile by name. + + Args: + name: Profile name ("rect", "gridfinity", "gridfinity_box") + depth: Cut depth in mm + + Returns: + CutterProfile instance + + Raises: + ValueError: If profile name is unknown + """ + if name == PROFILE_RECTANGULAR: + return create_rectangular_profile(depth) + elif name == PROFILE_GRIDFINITY: + return create_gridfinity_baseplate_profile(depth) + elif name == PROFILE_GRIDFINITY_BOX: + return create_gridfinity_box_profile(depth) + else: + raise ValueError(f"Unknown profile: {name}. Valid options: rect, gridfinity, gridfinity_box") diff --git a/pyproject.toml b/pyproject.toml index 77d4372..67e76c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] diff --git a/tests/test_meshcutter/test_edge_voting.py b/tests/test_meshcutter/test_edge_voting.py new file mode 100644 index 0000000..a636b77 --- /dev/null +++ b/tests/test_meshcutter/test_edge_voting.py @@ -0,0 +1,163 @@ +#! /usr/bin/env python3 +"""Tests for edge-direction voting alignment.""" + +import numpy as np +import pytest +from shapely.geometry import Polygon, box +from shapely.affinity import rotate + +from meshcutter.core.detection import ( + compute_dominant_edge_angle, + apply_yaw_to_frame, + BottomFrame, +) + + +class TestComputeDominantEdgeAngle: + """Tests for compute_dominant_edge_angle().""" + + def test_axis_aligned_rectangle(self): + """Axis-aligned rectangle should have dominant angle near 0 or 90 degrees.""" + poly = box(0, 0, 42, 21) # 42x21 rectangle, long edge along X + angle = compute_dominant_edge_angle(poly) + assert angle is not None + # Dominant angle should be near 0 (horizontal) or pi/2 (vertical) + # Since long edge is along X, expect near 0 + assert angle < np.radians(5) or angle > np.radians(85) + + def test_rotated_rectangle(self): + """Rotated rectangle should detect the rotation angle.""" + poly = box(0, 0, 42, 21) + rotated = rotate(poly, 30, origin="centroid") # 30 degree rotation + angle = compute_dominant_edge_angle(rotated) + assert angle is not None + # Should be near 30 degrees or 120 degrees (perpendicular edge) + angle_deg = np.degrees(angle) + assert (25 < angle_deg < 35) or (115 < angle_deg < 125) + + def test_square_has_dominant_angle(self): + """Square should still find a dominant angle (from one pair of edges).""" + poly = box(0, 0, 42, 42) + angle = compute_dominant_edge_angle(poly) + assert angle is not None + # Should be near 0 or 90 degrees + angle_deg = np.degrees(angle) + assert angle_deg < 5 or (85 < angle_deg < 95) + + def test_rectangle_with_small_fillets(self): + """Rectangle with small fillets should still detect correct angle.""" + # Create a rectangle with chamfered corners (simulating fillets) + # Original rectangle: 42x21 + coords = [ + (2, 0), + (40, 0), # bottom edge + (42, 2), + (42, 19), # right edge + (40, 21), + (2, 21), # top edge + (0, 19), + (0, 2), # left edge + (2, 0), # close + ] + poly = Polygon(coords) + angle = compute_dominant_edge_angle(poly) + assert angle is not None + # Long edges dominate, should be near 0 + angle_deg = np.degrees(angle) + assert angle_deg < 10 or angle_deg > 80 + + def test_no_dominant_angle_for_circle(self): + """Circle-like polygon has no dominant edge direction.""" + # Create a circle approximation with many edges + n_points = 64 + angles = np.linspace(0, 2 * np.pi, n_points, endpoint=False) + coords = [(20 * np.cos(a), 20 * np.sin(a)) for a in angles] + poly = Polygon(coords) + + # Should return None (no dominant direction) + # or the dominance threshold may not be met + angle = compute_dominant_edge_angle(poly, min_dominance=0.35) + # For a circle, no bin should have > 35% of the weight + # This may or may not return None depending on binning + # Just verify it doesn't crash + + def test_recovers_angle_within_tolerance(self): + """Slightly filleted rectangle should recover angle within 0.5 degrees.""" + # Create a clean rectangle with very small corner fillets + coords = [ + (1, 0), + (41, 0), # bottom (40mm) + (42, 1), + (42, 20), # right (19mm) + (41, 21), + (1, 21), # top (40mm) + (0, 20), + (0, 1), # left (19mm) + (1, 0), + ] + poly = Polygon(coords) + angle = compute_dominant_edge_angle(poly) + assert angle is not None + # Should be very close to 0 (horizontal dominates) + assert abs(angle) < np.radians(0.5) or abs(angle - np.pi / 2) < np.radians(0.5) + + +class TestApplyYawToFrame: + """Tests for apply_yaw_to_frame().""" + + def test_identity_rotation(self): + """Zero yaw should not change frame.""" + frame = BottomFrame( + origin=np.array([0.0, 0.0, 0.0]), + rotation=np.eye(3), + z_min=0.0, + ) + rotated = apply_yaw_to_frame(frame, 0.0) + np.testing.assert_array_almost_equal(rotated.x_axis, frame.x_axis) + np.testing.assert_array_almost_equal(rotated.y_axis, frame.y_axis) + + def test_90_degree_rotation(self): + """90 degree yaw should swap X and Y axes.""" + frame = BottomFrame( + origin=np.array([0.0, 0.0, 0.0]), + rotation=np.eye(3), + z_min=0.0, + ) + rotated = apply_yaw_to_frame(frame, np.pi / 2) + # After 90 deg rotation: new_x = old_y, new_y = -old_x + np.testing.assert_array_almost_equal(rotated.x_axis, [0, 1, 0]) + np.testing.assert_array_almost_equal(rotated.y_axis, [-1, 0, 0]) + + def test_preserves_up_normal(self): + """Yaw rotation should not change the up normal.""" + frame = BottomFrame( + origin=np.array([10.0, 20.0, 5.0]), + rotation=np.eye(3), + z_min=5.0, + ) + rotated = apply_yaw_to_frame(frame, np.radians(45)) + np.testing.assert_array_almost_equal(rotated.up_normal, frame.up_normal) + + def test_preserves_origin(self): + """Yaw rotation should not change the origin.""" + origin = np.array([10.0, 20.0, 5.0]) + frame = BottomFrame( + origin=origin.copy(), + rotation=np.eye(3), + z_min=5.0, + ) + rotated = apply_yaw_to_frame(frame, np.radians(30)) + np.testing.assert_array_almost_equal(rotated.origin, origin) + + def test_right_handed_after_rotation(self): + """Frame should remain right-handed after rotation.""" + frame = BottomFrame( + origin=np.array([0.0, 0.0, 0.0]), + rotation=np.eye(3), + z_min=0.0, + ) + for angle in [30, 45, 60, 90, 120, 180, 270]: + rotated = apply_yaw_to_frame(frame, np.radians(angle)) + # Check right-handedness: x cross y should equal z + cross = np.cross(rotated.x_axis, rotated.y_axis) + np.testing.assert_array_almost_equal(cross, rotated.up_normal) diff --git a/tests/test_meshcutter/test_profile.py b/tests/test_meshcutter/test_profile.py new file mode 100644 index 0000000..35c3072 --- /dev/null +++ b/tests/test_meshcutter/test_profile.py @@ -0,0 +1,254 @@ +#! /usr/bin/env python3 +"""Tests for cutter profiles and profiled cutter generation.""" + +import numpy as np +import pytest +from shapely.geometry import box + +from meshcutter.core.profile import ( + CutterProfile, + ProfileSegment, + create_rectangular_profile, + create_gridfinity_baseplate_profile, + create_gridfinity_box_profile, + get_profile, + GR_BASE_HEIGHT, + PROFILE_RECTANGULAR, + PROFILE_GRIDFINITY, +) +from meshcutter.core.cutter import generate_profiled_cutter +from meshcutter.core.detection import BottomFrame + + +class TestProfileSegment: + """Tests for ProfileSegment.""" + + def test_height(self): + """Height should be z_end - z_start.""" + seg = ProfileSegment(z_start=1.0, z_end=3.0, inset_start=0.0, inset_end=1.0) + assert seg.height == 2.0 + + def test_inset_at_start(self): + """Inset at z_start should equal inset_start.""" + seg = ProfileSegment(z_start=0.0, z_end=1.0, inset_start=0.5, inset_end=1.5) + assert seg.inset_at(0.0) == pytest.approx(0.5) + + def test_inset_at_end(self): + """Inset at z_end should equal inset_end.""" + seg = ProfileSegment(z_start=0.0, z_end=1.0, inset_start=0.5, inset_end=1.5) + assert seg.inset_at(1.0) == pytest.approx(1.5) + + def test_inset_at_midpoint(self): + """Inset at midpoint should be average of start and end.""" + seg = ProfileSegment(z_start=0.0, z_end=2.0, inset_start=0.0, inset_end=2.0) + assert seg.inset_at(1.0) == pytest.approx(1.0) + + def test_inset_at_clamped(self): + """Inset should be clamped to segment bounds.""" + seg = ProfileSegment(z_start=1.0, z_end=2.0, inset_start=0.0, inset_end=1.0) + # Below segment + assert seg.inset_at(0.5) == pytest.approx(0.0) + # Above segment + assert seg.inset_at(2.5) == pytest.approx(1.0) + + +class TestCutterProfile: + """Tests for CutterProfile.""" + + def test_total_height(self): + """Total height should be z_end of last segment.""" + profile = CutterProfile( + name="test", + segments=[ + ProfileSegment(z_start=0.0, z_end=1.0, inset_start=0.0, inset_end=0.5), + ProfileSegment(z_start=1.0, z_end=3.0, inset_start=0.5, inset_end=0.5), + ], + ) + assert profile.total_height == 3.0 + + def test_max_inset(self): + """Max inset should be maximum across all segments.""" + profile = CutterProfile( + name="test", + segments=[ + ProfileSegment(z_start=0.0, z_end=1.0, inset_start=0.0, inset_end=1.0), + ProfileSegment(z_start=1.0, z_end=2.0, inset_start=1.0, inset_end=0.5), + ], + ) + assert profile.max_inset == 1.0 + + def test_inset_at_finds_correct_segment(self): + """inset_at should find the correct segment for a given Z.""" + profile = CutterProfile( + name="test", + segments=[ + ProfileSegment(z_start=0.0, z_end=1.0, inset_start=0.0, inset_end=1.0), + ProfileSegment(z_start=1.0, z_end=2.0, inset_start=1.0, inset_end=1.0), + ProfileSegment(z_start=2.0, z_end=3.0, inset_start=1.0, inset_end=2.0), + ], + ) + # In first segment + assert profile.inset_at(0.5) == pytest.approx(0.5) + # In second segment + assert profile.inset_at(1.5) == pytest.approx(1.0) + # In third segment + assert profile.inset_at(2.5) == pytest.approx(1.5) + + def test_sample_z_levels_includes_boundaries(self): + """Z levels should include all segment boundaries.""" + profile = create_gridfinity_baseplate_profile() + z_levels = profile.sample_z_levels(n_slices=5) + # Should include -epsilon, 0, and all segment boundaries + assert z_levels[0] < 0 # epsilon + assert 0.0 in z_levels or any(abs(z) < 0.001 for z in z_levels) + + +class TestRectangularProfile: + """Tests for rectangular profile.""" + + def test_constant_zero_inset(self): + """Rectangular profile should have zero inset throughout.""" + profile = create_rectangular_profile(depth=5.0) + for z in [0.0, 1.0, 2.5, 5.0]: + assert profile.inset_at(z) == 0.0 + + def test_total_height_matches_depth(self): + """Total height should match specified depth.""" + profile = create_rectangular_profile(depth=3.5) + assert profile.total_height == 3.5 + + +class TestGridfinityBaseplateProfile: + """Tests for Gridfinity baseplate profile.""" + + def test_standard_height(self): + """Standard profile should have height of 4.75mm.""" + profile = create_gridfinity_baseplate_profile() + assert profile.total_height == pytest.approx(GR_BASE_HEIGHT, rel=0.01) + + def test_three_segments(self): + """Profile should have 3 segments: bottom chamfer, straight, top chamfer.""" + profile = create_gridfinity_baseplate_profile() + assert len(profile.segments) == 3 + + def test_bottom_chamfer_starts_at_zero(self): + """Bottom chamfer should start with zero inset.""" + profile = create_gridfinity_baseplate_profile() + assert profile.inset_at(0.0) == pytest.approx(0.0) + + def test_inset_increases_with_height(self): + """Inset should generally increase with height (chamfers).""" + profile = create_gridfinity_baseplate_profile() + insets = [profile.inset_at(z) for z in [0.0, 1.0, 2.0, 3.0, 4.0]] + # Should be non-decreasing + for i in range(1, len(insets)): + assert insets[i] >= insets[i - 1] - 0.01 # Allow small tolerance + + +class TestGetProfile: + """Tests for get_profile().""" + + def test_get_rectangular(self): + """Should return rectangular profile.""" + profile = get_profile(PROFILE_RECTANGULAR, depth=5.0) + assert profile.name == "rectangular" + assert profile.max_inset == 0.0 + + def test_get_gridfinity(self): + """Should return gridfinity baseplate profile.""" + profile = get_profile(PROFILE_GRIDFINITY) + assert profile.name == "gridfinity_baseplate" + assert profile.max_inset > 0 + + def test_unknown_profile_raises(self): + """Unknown profile name should raise ValueError.""" + with pytest.raises(ValueError, match="Unknown profile"): + get_profile("unknown_profile") + + +class TestGenerateProfiledCutter: + """Tests for generate_profiled_cutter().""" + + @pytest.fixture + def simple_setup(self): + """Create a simple test setup.""" + poly = box(0, 0, 42, 42) + frame = BottomFrame( + origin=np.array([21.0, 21.0, 0.0]), + rotation=np.eye(3), + z_min=0.0, + ) + return poly, frame + + def test_rectangular_profile_creates_mesh(self, simple_setup): + """Rectangular profile should create a valid mesh.""" + poly, frame = simple_setup + cutter = generate_profiled_cutter(poly, frame, profile="rect", depth=4.0) + assert len(cutter.faces) > 0 + assert len(cutter.vertices) > 0 + + def test_gridfinity_profile_creates_mesh(self, simple_setup): + """Gridfinity profile should create a valid mesh.""" + poly, frame = simple_setup + cutter = generate_profiled_cutter(poly, frame, profile="gridfinity") + assert len(cutter.faces) > 0 + assert len(cutter.vertices) > 0 + + def test_gridfinity_has_different_geometry(self, simple_setup): + """Gridfinity profile should have different vertex count than rectangular.""" + poly, frame = simple_setup + rect_cutter = generate_profiled_cutter(poly, frame, profile="rect", depth=4.0) + gf_cutter = generate_profiled_cutter(poly, frame, profile="gridfinity") + # Gridfinity has multiple slabs with different insets, so different vertices + # Even if face count is same (due to merging), vertex count differs + assert len(gf_cutter.vertices) != len(rect_cutter.vertices) + + def test_cutter_z_extent(self, simple_setup): + """Cutter should extend from -epsilon to +depth.""" + poly, frame = simple_setup + depth = 4.75 + epsilon = 0.02 + cutter = generate_profiled_cutter(poly, frame, profile="gridfinity", depth=depth, epsilon=epsilon) + bounds = cutter.bounds + # Z should start near -epsilon (with frame at z=0) + assert bounds[0, 2] <= epsilon + # Z should end near depth + assert bounds[1, 2] >= depth - 0.1 + + def test_more_slices_more_faces(self, simple_setup): + """More slices should produce more faces.""" + poly, frame = simple_setup + cutter_5 = generate_profiled_cutter(poly, frame, profile="gridfinity", n_slices=5) + cutter_20 = generate_profiled_cutter(poly, frame, profile="gridfinity", n_slices=20) + assert len(cutter_20.faces) > len(cutter_5.faces) + + def test_transforms_to_world_coords(self, simple_setup): + """Cutter should be transformed to world coordinates.""" + poly, frame = simple_setup + # Move frame origin + frame_offset = BottomFrame( + origin=np.array([100.0, 200.0, 50.0]), + rotation=np.eye(3), + z_min=50.0, + ) + cutter = generate_profiled_cutter(poly, frame_offset, profile="rect", depth=4.0) + # Bounds should be near the offset origin + bounds = cutter.bounds + assert bounds[0, 0] > 50 # X offset + assert bounds[0, 1] > 150 # Y offset + assert bounds[0, 2] > 45 # Z offset (50 - epsilon) + + def test_handles_multipolygon(self): + """Should handle MultiPolygon input.""" + from shapely.geometry import MultiPolygon + + poly1 = box(0, 0, 10, 10) + poly2 = box(20, 20, 30, 30) + multi = MultiPolygon([poly1, poly2]) + frame = BottomFrame( + origin=np.array([15.0, 15.0, 0.0]), + rotation=np.eye(3), + z_min=0.0, + ) + cutter = generate_profiled_cutter(multi, frame, profile="rect", depth=2.0) + assert len(cutter.faces) > 0