diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml new file mode 100644 index 0000000..00baa79 --- /dev/null +++ b/.github/workflows/auto-merge-release.yml @@ -0,0 +1,28 @@ +# Auto-merge release-please PRs +# Automatically merges release-please PRs once CI passes + +name: Auto-merge Release PRs + +on: + pull_request: + branches: [releases] + types: [opened, synchronize, reopened] + +jobs: + auto-merge: + name: Auto-merge release-please PR + runs-on: ubuntu-latest + # Only run for release-please PRs + if: startsWith(github.head_ref, 'release-please--') + + permissions: + contents: write + pull-requests: write + + steps: + - name: Enable auto-merge + uses: peter-evans/enable-pull-request-automerge@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + pull-request-number: ${{ github.event.pull_request.number }} + merge-method: squash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97534bb..cb53c6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # CI - runs on PRs to releases branch -# Full test matrix, linting, and build validation +# Separated jobs for faster feedback and clearer results name: CI @@ -23,13 +23,13 @@ jobs: run: pip install black flake8 - name: Check formatting with black - run: black --check --diff microfinity/ tests/ + run: black --check --diff microfinity/ meshcutter/ tests/ - name: Lint with flake8 - run: flake8 microfinity/ tests/ --max-line-length=120 --extend-ignore=E203,W503,F401,F403,F405,E402,F821,W293,W605,F841 + run: flake8 microfinity/ meshcutter/ tests/ --max-line-length=120 --extend-ignore=E203,W503,F401,F403,F405,E402,F821,W293,W605,F841 - test: - name: Test (Python ${{ matrix.python-version }}) + test-microfinity: + name: Test microfinity (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -48,18 +48,61 @@ jobs: run: | pip install --upgrade pip pip install cadquery-ocp cadquery cqkit - pip install pytest pytest-cov + pip install pytest pip install -e . - - name: Run full test suite - run: pytest -v --cov=microfinity --cov-report=xml + - name: Run microfinity tests + run: pytest tests/ --ignore=tests/test_meshcutter -v - - name: Upload coverage - uses: codecov/codecov-action@v4 - if: matrix.python-version == '3.11' + test-meshcutter-unit: + name: Test meshcutter unit (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install cadquery-ocp cadquery cqkit + pip install manifold3d + pip install pytest + pip install -e . + + - name: Run meshcutter unit tests + run: pytest tests/test_meshcutter -m "not integration" -v + + test-meshcutter-integration: + name: Test meshcutter integration + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 with: - files: ./coverage.xml - fail_ci_if_error: false + python-version: "3.11" + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install cadquery-ocp cadquery cqkit + pip install manifold3d + pip install pytest + pip install -e . + + - name: Run meshcutter integration tests + run: pytest tests/test_meshcutter -m "integration" -v build: name: Build Package diff --git a/.github/workflows/sync-to-dev.yml b/.github/workflows/sync-to-dev.yml new file mode 100644 index 0000000..3a39e92 --- /dev/null +++ b/.github/workflows/sync-to-dev.yml @@ -0,0 +1,54 @@ +# Sync releases back to dev after release-please creates a release +# This ensures dev stays up-to-date with version bumps and changelog updates + +name: Sync to Dev + +on: + push: + branches: [releases] + paths: + - 'pyproject.toml' + - 'microfinity/__init__.py' + - 'CHANGELOG.md' + +jobs: + sync: + name: Sync releases to dev + runs-on: ubuntu-latest + # Only run if this looks like a release-please commit + if: contains(github.event.head_commit.message, 'chore(releases)') || contains(github.event.head_commit.message, 'chore(main)') + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync version files to dev + run: | + # Get the version from releases branch + VERSION=$(grep -m1 'version = ' pyproject.toml | cut -d'"' -f2) + echo "Syncing version $VERSION to dev" + + # Checkout dev branch + git checkout dev + + # Cherry-pick the version bump changes (if they apply cleanly) + # Or directly update the version files + git checkout releases -- pyproject.toml microfinity/__init__.py CHANGELOG.md + + # Check if there are changes + if git diff --quiet; then + echo "No changes to sync" + exit 0 + fi + + # Commit and push + git add pyproject.toml microfinity/__init__.py CHANGELOG.md + git commit -m "chore: sync version $VERSION from releases" + git push origin dev diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4b6de2f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,105 @@ +# Claude Project Context: Microfinity + +This file provides context for Claude when working on this project. + +## Project Overview + +**Microfinity** is a Python library for creating Gridfinity-compatible objects using CadQuery. It includes: + +1. **microfinity/** - CadQuery-based generator for boxes, baseplates, and spacers +2. **meshcutter/** - Mesh-based tool to convert 1U Gridfinity boxes to micro-divided feet + +## Key Architecture Decisions + +### Meshcutter: Replace-Base Approach + +The meshcutter uses a **replace-base pipeline** (not boolean subtraction) to convert 1U feet to micro-feet: + +1. **Trim** the input mesh above z=5mm (keeps walls and interior) +2. **Generate** fresh micro-feet base using microfinity's construction path +3. **Union** the trimmed top with the new base + +This produces geometry **identical** to natively-generated micro boxes (<1mm³ difference). + +**Important**: The legacy boolean subtraction approach has been deprecated and removed. + +### Gridfinity Constants + +All Gridfinity constants should come from `meshcutter/core/constants.py`, which re-exports from `microfinity.core.constants`. + +Key values: +- `GRU = 42.0` - 1U pitch (mm) +- `GR_TOL = 0.5` - Clearance between feet +- `GR_BASE_HEIGHT = 4.75` - Foot height +- `GR_BASE_CLR = 0.25` - Clearance above foot +- `Z_SPLIT_HEIGHT = 5.0` - Where we cut between top and base + +## Important Files + +### Core Modules +- `meshcutter/core/replace_base.py` - Main replace-base pipeline +- `meshcutter/core/constants.py` - Centralized constants +- `meshcutter/core/mesh_utils.py` - Mesh conversion utilities +- `meshcutter/core/cq_utils.py` - CadQuery utilities +- `meshcutter/core/grid_utils.py` - Grid/offset calculations + +### CLI +- `meshcutter/cli/meshcut.py` - Command-line interface + +### Tests +- `tests/test_meshcutter/test_golden.py` - Golden comparison tests +- `tests/test_meshcutter/golden_utils.py` - Test utilities + +## Refactoring Tracking + +**IMPORTANT**: When completing refactoring tasks, update `REFACTOR_TODO.md` to mark items as complete. + +The refactoring TODO file tracks: +- DRY elimination (utility modules) +- Module updates +- Test creation +- CI setup + +## Testing + +### Golden Tests +Golden tests compare meshcutter output against microfinity-generated references: +- Reference meshes are generated on-demand (not stored) +- Acceptance threshold: <1mm³ total geometric difference +- Test configurations: 1x1x1, 2x3x2, 1x1x3, 3x3x1 + +### Running Tests +```bash +# All meshcutter tests +pytest tests/test_meshcutter/ -v + +# Just golden tests +pytest tests/test_meshcutter/test_golden.py -v + +# Quick unit tests (skip slow golden tests) +pytest tests/test_meshcutter/ -v -m "not golden" +``` + +## Code Style + +- **Line length**: 120 characters (configured in pyproject.toml) +- **Formatter**: Black +- **Type hints**: Use throughout, especially for public APIs +- **Docstrings**: Google style + +## Common Tasks + +### Adding a New Micro-Division Size +1. Update `meshcutter/core/grid_utils.py` if offset calculations change +2. Add test configuration to `tests/test_meshcutter/test_golden.py` +3. Update `docs/TODO_FUTURE_DIVISIONS.md` + +### Updating Constants +1. Check if constant exists in `microfinity.core.constants` +2. If yes, re-export in `meshcutter/core/constants.py` +3. If no, add to meshcutter-specific section + +### Debugging Mesh Issues +1. Use `mesh_utils.get_mesh_diagnostics()` for mesh info +2. Export intermediate meshes with `mesh.export('/tmp/debug.stl')` +3. View in external tool (MeshLab, Blender) diff --git a/REFACTOR_TODO.md b/REFACTOR_TODO.md new file mode 100644 index 0000000..77b3c12 --- /dev/null +++ b/REFACTOR_TODO.md @@ -0,0 +1,153 @@ +# Meshcutter Refactoring TODO + +This file tracks the production-ready refactoring of the meshcutter module. + +**Goal**: Eliminate DRY violations, improve separation of concerns, add golden tests, and drop legacy boolean subtraction approach. + +**Instructions for Claude**: Update this file as items are completed. Check off items with `[x]` and add completion notes where relevant. + +--- + +## Phase 1: Utility Modules (DRY Elimination) + +### 1.1 Constants Module +- [x] Create `meshcutter/core/constants.py` + - Re-export Gridfinity constants from microfinity + - Add meshcutter-specific constants (Z_SPLIT_HEIGHT, SLEEVE_HEIGHT, etc.) + +### 1.2 Mesh Utilities Module +- [x] Create `meshcutter/core/mesh_utils.py` + - Extract `trimesh_to_manifold()` from replace_base.py, cq_cutter.py + - Extract `manifold_to_trimesh()` from replace_base.py + - Extract `cq_to_trimesh()` from replace_base.py, cq_cutter.py + - Extract `repair_mesh_manifold()` from meshcut.py + - Extract `clean_mesh_components()` from meshcut.py + - Extract `is_degenerate_sliver()` from meshcut.py + - Extract `get_mesh_diagnostics()` from boolean.py, detection.py + +### 1.3 CadQuery Utilities Module +- [x] Create `meshcutter/core/cq_utils.py` + - Extract `ZLEN_FIX` detection (computed once at module load) + - Extract `extrude_profile()` from replace_base.py, cq_cutter.py + +### 1.4 Grid Utilities Module +- [x] Create `meshcutter/core/grid_utils.py` + - Consolidate `micro_foot_offsets()` variants into unified implementation + - Extract `detect_cell_centers()` from foot_cutter.py + - Extract `quantize()` helper from cq_cutter.py + +--- + +## Phase 2: Update Existing Modules + +### 2.1 Replace Base Module +- [x] Update `meshcutter/core/replace_base.py` + - Remove duplicated `ZLEN_FIX` and `extrude_profile` + - Remove duplicated mesh conversion functions + - Remove duplicated `micro_foot_offsets` + - Import from new utility modules + +### 2.2 CQ Cutter Module +- [x] Update `meshcutter/core/cq_cutter.py` + - Remove `generate_junction_correction()` entirely (~340 lines) + - Remove `detect_seam_network()` if unused + - Remove duplicated functions + - Import from new utility modules + +### 2.3 Foot Cutter Module +- [x] Update `meshcutter/core/foot_cutter.py` + - Remove legacy `generate_microgrid_cutter` support + - Simplify `convert_to_micro_feet` to only use replace-base + - Import from new utility modules + +### 2.4 Boolean Module +- [x] Update `meshcutter/core/boolean.py` + - Remove duplicated `get_mesh_diagnostics` + - Import from mesh_utils + +### 2.5 CLI Module +- [x] Update `meshcutter/cli/meshcut.py` + - Remove `--use-boolean` flag and legacy code path + - Remove inline utility functions (`_repair_mesh_manifold`, etc.) + - Import from new utility modules + - Simplify to only use replace-base approach + +--- + +## Phase 3: Tests + +### 3.1 Golden Test Infrastructure +- [x] Create `tests/test_meshcutter/golden_utils.py` + - `generate_1u_box()` - generate 1U box using microfinity + - `generate_micro_box()` - generate micro-divided box using microfinity + - `compare_meshes()` - compare meshes using manifold3d boolean difference + - `assert_meshes_match()` - assertion helper with <1mm³ tolerance + +### 3.2 Golden Tests +- [x] Create `tests/test_meshcutter/test_golden.py` + - Test configurations: 1x1x1, 2x3x2, 1x1x3, 3x3x1 + - `test_replace_base_matches_native()` - verify output matches reference + - `test_output_is_watertight()` - verify watertight output + - `test_output_bounds_match()` - verify bounds match + +### 3.3 Unit Tests +- [x] Create `tests/test_meshcutter/test_replace_base.py` + - Test `trim_mesh_above_z()` + - Test `generate_micro_base_with_sleeve()` + - Test `replace_base_pipeline()` + +- [x] Create `tests/test_meshcutter/test_mesh_utils.py` + - Test mesh conversion roundtrips + - Test component cleaning + - Test sliver detection + +--- + +## Phase 4: CI & Documentation + +### 4.1 CI Workflow +- [x] Create `.github/workflows/meshcutter-test.yml` + - Run on push to meshcutter/** + - Run unit tests and golden tests + - Test on Python 3.10 and 3.11 + +### 4.2 Configuration +- [x] Update `pyproject.toml` + - Add pytest markers: `golden`, `slow` + +### 4.3 Documentation +- [x] Create `CLAUDE.md` with project context +- [x] Create `docs/TODO_FUTURE_DIVISIONS.md` for 0.5U and 1/3U plans + +--- + +## Phase 5: Final Cleanup + +- [x] Run full test suite: `pytest tests/test_meshcutter/ -v` (93 tests pass) +- [x] Verify CLI works: `python -m meshcutter.cli.meshcut --help` +- [x] Test end-to-end: convert 1U box to 0.25U (volume match: <0.01mm³) +- [x] Commit and push all changes + +--- + +## Code Metrics + +### Before Refactoring +- `cq_cutter.py`: ~1970 lines +- Duplicated functions: 5+ across modules +- Deprecated code: ~340 lines + +### After Refactoring (Target) +- `cq_cutter.py`: ~1200 lines (-40%) +- New utility modules: 4 (constants, mesh_utils, cq_utils, grid_utils) +- Duplicated functions: 0 +- Deprecated code: 0 + +--- + +## Notes + +- **Replace-base only**: Legacy boolean subtraction approach has been dropped +- **Acceptance threshold**: <1mm³ total geometric difference +- **Test configs**: 1x1x1, 2x3x2, 1x1x3, 3x3x1 (all with micro_divisions=4) +- **Future divisions**: 0.5U and 1/3U planned but not implemented yet diff --git a/docs/TODO_FUTURE_DIVISIONS.md b/docs/TODO_FUTURE_DIVISIONS.md new file mode 100644 index 0000000..c1eb60f --- /dev/null +++ b/docs/TODO_FUTURE_DIVISIONS.md @@ -0,0 +1,77 @@ +# Future Division Support TODO + +This document tracks planned support for additional micro-division sizes. + +## Currently Supported + +### 4 divisions (0.25U / quarter-grid) +- **Pitch**: 10.5mm +- **Status**: Fully supported and tested +- **Acceptance threshold**: <1mm³ geometric difference + +## Planned Support + +### 2 divisions (0.5U / half-grid) + +- **Pitch**: 21mm +- **Status**: Not yet tested + +#### TODO +- [ ] Add test configurations for `micro_divisions=2` +- [ ] Verify `replace_base_pipeline` works correctly with 2 divisions +- [ ] Add golden tests comparing against microfinity-generated 0.5U boxes +- [ ] Update CLI help text with 0.5U examples +- [ ] Document any limitations or edge cases + +#### Implementation Notes +The replace-base approach should work for 2 divisions since: +1. `microfinity.GridfinityBox` already supports `micro_divisions=2` +2. `grid_utils.micro_foot_offsets_grid()` calculates correct offsets +3. No special handling needed for corner radii (larger feet = larger radii work fine) + +### 3 divisions (1/3U) + +- **Pitch**: 14mm +- **Status**: Not yet investigated + +#### TODO +- [ ] Investigate if `microfinity.GridfinityBox` supports `micro_divisions=3` +- [ ] If not supported, implement in microfinity first +- [ ] Update `grid_utils.py` offset calculations if needed +- [ ] Add validation for non-integer cell divisions +- [ ] Add golden tests +- [ ] Document limitations (corner radius constraints, etc.) + +#### Potential Issues +- **Corner radius**: 1/3U feet may have constrained corner radii +- **Fractional sizes**: Would require 1/3 unit increments (e.g., 0.33U, 0.67U) +- **Compatibility**: May not mate perfectly with 0.25U or 0.5U baseplates + +## Implementation Strategy + +### Phase 1: 0.5U Support +1. Add golden tests for 0.5U conversion +2. Verify existing code handles it correctly +3. Add CLI examples and documentation +4. Release as minor version bump + +### Phase 2: 1/3U Support (if feasible) +1. Prototype in microfinity library first +2. Validate geometric constraints (radii, clearances) +3. Add meshcutter support +4. Extensive testing for cross-compatibility + +## Testing Approach + +For each new division size: + +1. **Golden tests**: Compare meshcutter output against microfinity reference +2. **Geometric validation**: Verify watertight, correct bounds, correct volume +3. **Cross-compatibility**: Test that converted boxes fit on standard baseplates +4. **Edge cases**: Test 1x1, large grids, and tall boxes + +## Notes + +- The replace-base approach is division-agnostic; it generates fresh micro-feet using microfinity's construction path +- Acceptance threshold of <1mm³ should apply to all division sizes +- Reference meshes are generated on-demand using microfinity at the same commit diff --git a/meshcutter/__init__.py b/meshcutter/__init__.py index 31d24a2..b83267d 100644 --- a/meshcutter/__init__.py +++ b/meshcutter/__init__.py @@ -5,8 +5,10 @@ # Cut gridfinity micro-division profiles into existing STL/3MF models # using mesh boolean operations. # +# Part of the microfinity package - shares version with microfinity. +# -__version__ = "0.1.0" +from microfinity import __version__ from meshcutter.core.detection import detect_bottom_frame, extract_footprint from meshcutter.core.grid import generate_grid_mask, compute_grid_positions diff --git a/meshcutter/cli/meshcut.py b/meshcutter/cli/meshcut.py index d441679..5ecab0a 100644 --- a/meshcutter/cli/meshcut.py +++ b/meshcutter/cli/meshcut.py @@ -2,6 +2,13 @@ # # meshcutter.cli.meshcut - CLI entry point for microfinity-meshcut # +# Converts standard 1U Gridfinity feet into micro-divided feet. +# +# Two approaches are available: +# 1. Replace-base (default): Replaces entire foot region with fresh micro-feet. +# Produces EXACT geometric match with natively-generated micro boxes. +# 2. Boolean subtraction (legacy): Carves micro-feet pattern into existing feet. +# Preserves features below z=5mm but has small geometric residuals (~50mm³). from __future__ import annotations @@ -11,9 +18,16 @@ from pathlib import Path from meshcutter import __version__ -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, generate_profiled_cutter, validate_cutter, COPLANAR_EPSILON +from meshcutter.core.constants import ( + GR_BASE_HEIGHT, + COPLANAR_EPSILON, + MIN_COMPONENT_FACES, + MIN_SLIVER_SIZE, + MIN_SLIVER_VOLUME, + GRU, +) +from meshcutter.core.detection import detect_aligned_frame +from meshcutter.core.foot_cutter import generate_microgrid_cutter, convert_to_micro_feet from meshcutter.core.boolean import ( boolean_difference, validate_boolean_inputs, @@ -25,99 +39,113 @@ from meshcutter.io.exporter import export_stl, get_export_info, ExporterError -# Gridfinity constants -GR_BASE_HEIGHT = 4.75 # Default cut depth from microfinity +def _repair_mesh_manifold(mesh): + """Repair mesh by passing through manifold3d. - -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. + This eliminates floating-point artifacts and non-manifold geometry + that can occur during boolean operations or STL export/import. 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 + mesh: trimesh.Trimesh to repair + + Returns: + Repaired trimesh.Trimesh (or original if manifold3d unavailable) """ - 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'' + try: + import numpy as np + import manifold3d + + # Convert to manifold (auto-repairs) + m = manifold3d.Manifold( + manifold3d.Mesh( + vert_properties=np.array(mesh.vertices, dtype=np.float32), + tri_verts=np.array(mesh.faces, dtype=np.uint32), ) - 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'' - ) + # Convert back to trimesh + import trimesh + + mesh_data = m.to_mesh() + return trimesh.Trimesh(vertices=mesh_data.vert_properties[:, :3], faces=mesh_data.tri_verts) + except ImportError: + return mesh # manifold3d not available + + +def _is_degenerate_sliver(component, min_size=MIN_SLIVER_SIZE, min_volume=MIN_SLIVER_VOLUME): + """Check if a component is a degenerate sliver (boolean artifact). + + A sliver is identified by: + - All bounding box dimensions being very small (< min_size), OR + - Having an extremely small volume (< min_volume), OR + - Having fewer than 4 faces (can't form a valid solid) + + Args: + component: trimesh.Trimesh component to check + min_size: Minimum acceptable size in any dimension (mm) + min_volume: Minimum acceptable volume (mm³) + + Returns: + True if the component is a degenerate sliver that should be removed + """ + import numpy as np + + # Check face count - need at least 4 faces for a tetrahedron + if len(component.faces) < 4: + return True + + # Check if all dimensions are tiny (nanometer-scale artifact) + size = component.bounds[1] - component.bounds[0] + if (size < min_size).all(): + return True - svg_lines.append("") - svg_lines.append("") + # Check volume - if it's effectively zero, it's degenerate + # Use absolute value since non-watertight meshes can have negative volume + if abs(component.volume) < min_volume: + return True - output_path.write_text("\n".join(svg_lines)) + return False + + +def _clean_mesh_components(mesh): + """Remove floating/degenerate components from mesh. + + Keeps only the largest component(s) that have significant geometry. + Small floating triangles and nanometer-scale slivers (common boolean + artifacts) are removed. + + Args: + mesh: trimesh.Trimesh input mesh + + Returns: + Tuple of (cleaned mesh, number of components removed) + """ + import trimesh + + components = mesh.split(only_watertight=False) + + if len(components) <= 1: + return mesh, 0 + + # Find the main component (largest by face count) + main_component = max(components, key=lambda c: len(c.faces)) + main_face_count = len(main_component.faces) + + # Keep components that: + # 1. Have at least MIN_COMPONENT_FACES faces OR 1% of main component, AND + # 2. Are NOT degenerate slivers (size/volume check) + threshold = max(MIN_COMPONENT_FACES, main_face_count * 0.01) + kept = [c for c in components if len(c.faces) >= threshold and not _is_degenerate_sliver(c)] + removed_count = len(components) - len(kept) + + if len(kept) == 1: + return kept[0], removed_count + elif len(kept) > 1: + # Concatenate kept components + return trimesh.util.concatenate(kept), removed_count + else: + # Shouldn't happen, but return original if no components kept + return mesh, 0 def main(): @@ -125,33 +153,40 @@ def main(): parser = argparse.ArgumentParser( prog="microfinity-meshcut", description=( - "Cut gridfinity micro-division profile into existing STL/3MF models.\n\n" - "This tool adds gridfinity 'feet' at fractional grid positions to\n" - "existing models, enabling them to work with micro-divided baseplates." + "Convert standard 1U Gridfinity feet into micro-divided feet.\n\n" + "This tool enables existing Gridfinity models to work with\n" + "micro-divided baseplates (quarter-grid or half-grid).\n\n" + "By default, uses the 'replace-base' approach which produces\n" + "geometry identical to natively-generated micro boxes." ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - # Basic usage with quarter-grid divisions (10.5mm pitch) + # Basic usage with quarter-grid divisions (default, recommended) microfinity-meshcut input.stl -o output.stl # Half-grid divisions (21mm pitch) - microfinity-meshcut input.stl -o output.stl --divisions 2 + microfinity-meshcut input.stl -o output.stl --micro-divisions 2 - # Full grid (42mm pitch, standard gridfinity) - microfinity-meshcut input.stl -o output.stl --divisions 1 + # Legacy boolean subtraction (preserves magnet holes but has residuals) + microfinity-meshcut input.stl -o output.stl --use-boolean --add-channels - # With custom slot width and clearance - microfinity-meshcut input.stl -o output.stl --slot-width 2.0 --clearance 0.2 + # No-op mode (useful for testing pipeline) + microfinity-meshcut input.stl -o output.stl --micro-divisions 1 # 3MF input - microfinity-meshcut model.3mf -o output.stl --divisions 4 - - # With mesh repair - microfinity-meshcut messy.stl -o clean.stl --repair - - # Force Z-up assumption (faster, for aligned models) - microfinity-meshcut aligned.stl -o output.stl --force-z-up + microfinity-meshcut model.3mf -o output.stl + +Approaches: + Replace-base (default): + - Replaces entire foot region (z < 5mm) with fresh micro-feet + - Produces EXACT match with natively-generated micro boxes + - WARNING: Removes magnet holes, screw holes, and base text + + Boolean subtraction (--use-boolean): + - Carves micro-feet pattern into existing feet + - Preserves features below z=5mm (holes, text) + - Has small geometric residuals (~50mm³) at corners """, ) @@ -159,7 +194,7 @@ def main(): parser.add_argument( "input", type=Path, - help="Input STL or 3MF file", + help="Input STL or 3MF file (must have standard 1U Gridfinity feet)", ) # Required arguments @@ -171,69 +206,69 @@ def main(): help="Output STL file", ) - # Grid options + # Micro-division options parser.add_argument( "-d", - "--divisions", + "--micro-divisions", type=int, default=4, choices=[1, 2, 4], - help=("Micro-divisions per grid unit: " "4=quarter-grid (10.5mm), 2=half (21mm), 1=full (42mm). " "Default: 4"), + help=("Micro-divisions per grid unit: " "4=quarter-grid (10.5mm), 2=half (21mm), 1=no-op. " "Default: 4"), ) parser.add_argument( - "--slot-width", + "--depth", type=float, - default=DEFAULT_SLOT_WIDTH, - help=f"Width of cut slots in mm. Default: {DEFAULT_SLOT_WIDTH}", + default=GR_BASE_HEIGHT, + help=f"Cut depth in mm. Default: {GR_BASE_HEIGHT} (GR_BASE_HEIGHT)", ) parser.add_argument( - "--clearance", + "--epsilon", type=float, - default=0.15, - help="Clearance per side in mm (expands slots). Default: 0.15", + default=COPLANAR_EPSILON, + help=f"Coplanar avoidance offset in mm. Default: {COPLANAR_EPSILON}", ) parser.add_argument( - "--depth", + "--overshoot", type=float, - default=GR_BASE_HEIGHT, - help=f"Cut depth in mm. Default: {GR_BASE_HEIGHT} (GR_BASE_HEIGHT)", + default=0.0, + help="Extend cutter beyond foot boundary to cut outer walls (mm). " + "Set to 0.0 to keep cutter within foot boundary. Default: 0.0", ) 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" - ), + "--auto-overshoot", + action="store_true", + help="Automatically apply 0.35mm overshoot for multi-cell grids (2+ cells). " + "This helps cut outer shell walls that intersect the foot region. " + "Overrides --overshoot when applicable.", ) parser.add_argument( - "--profile-slices", - type=int, - default=10, - help="Number of Z slices for profile approximation (3-50). Default: 10", + "--wall-cut", + type=float, + default=0.0, + help="Shrink micro-feet in cutter to cut outer walls (mm). " + "Creates overlap between cutter and model edge. Default: 0.0", ) - # Grid phase options parser.add_argument( - "--phase-x", - type=float, - default=0.0, - help="Grid phase offset in X (mm). Default: 0.0", + "--add-channels", + action="store_true", + help="Add inter-cell channel cutters to cut material between adjacent cells. " + "Only needed for solid-walled 1U boxes. (Only used with --use-boolean)", ) + # Approach selection parser.add_argument( - "--phase-y", - type=float, - default=0.0, - help="Grid phase offset in Y (mm). Default: 0.0", + "--use-boolean", + action="store_true", + help="Use legacy boolean subtraction instead of replace-base approach. " + "Boolean subtraction preserves features below z=5mm (magnet holes, screw holes, text) " + "but has small geometric residuals (~50mm³) at corners. " + "Default: replace-base (exact geometry but removes base features).", ) # Detection options @@ -258,31 +293,24 @@ def main(): ) parser.add_argument( - "--epsilon", - type=float, - default=COPLANAR_EPSILON, - help=f"Coplanar avoidance offset in mm. Default: {COPLANAR_EPSILON}", - ) - - # Output options - parser.add_argument( - "-v", - "--verbose", + "--no-clean", action="store_true", - help="Verbose output with detailed progress", + help="Disable automatic cleanup of floating/degenerate triangles in input mesh", ) parser.add_argument( - "--dump-mask", - type=Path, - metavar="PATH", - help="Export 2D grid mask to SVG file for debugging", + "--no-validate", + action="store_true", + help="Skip cutter geometry validation (not recommended). " + "Validation detects internal faces that cause boolean artifacts.", ) + # Output options parser.add_argument( - "--require-intersect", + "-v", + "--verbose", action="store_true", - help="Fail (not just warn) if cutter doesn't intersect part", + help="Verbose output with detailed progress", ) parser.add_argument( @@ -312,14 +340,24 @@ def run_meshcut(args: argparse.Namespace) -> None: """Run the mesh cutting operation.""" start_time = time.time() + micro_pitch = GRU / args.micro_divisions + micro_foot_size = micro_pitch - 0.5 # GR_TOL = 0.5 + + # Determine which approach to use + use_replace_base = not args.use_boolean + if args.verbose: print(f"microfinity-meshcut v{__version__}") print(f"Input: {args.input}") print(f"Output: {args.output}") - 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(f"Micro-divisions: {args.micro_divisions}") + print(f" Micro-pitch: {micro_pitch:.1f}mm") + print(f" Micro-foot size: {micro_foot_size:.1f}mm") + print(f" Feet per 1U cell: {args.micro_divisions ** 2}") + if use_replace_base: + print(" Method: replace-base (exact geometry)") + else: + print(" Method: boolean subtraction (preserves base features)") print() # Check available engines @@ -335,6 +373,30 @@ def run_meshcut(args: argparse.Namespace) -> None: print(" pip install manifold3d") print() + # Handle no-op case (micro_divisions=1) + if args.micro_divisions == 1: + if args.verbose: + print("micro-divisions=1: no changes will be made (pass-through mode)") + print() + + # Load and immediately export (useful for testing pipeline) + try: + mesh = load_mesh(args.input) + except LoaderError as e: + raise RuntimeError(f"Failed to load mesh: {e}") + + try: + export_stl(mesh, args.output) + except ExporterError as e: + raise RuntimeError(f"Failed to export: {e}") + + elapsed = time.time() - start_time + if args.verbose: + print(f"Completed in {elapsed:.2f}s (no changes)") + else: + print(f"Wrote {args.output} (unchanged)") + return + # Step 1: Load mesh if args.verbose: print("Loading mesh...", end=" ", flush=True) @@ -344,6 +406,12 @@ def run_meshcut(args: argparse.Namespace) -> None: except LoaderError as e: raise RuntimeError(f"Failed to load mesh: {e}") + # Clean up floating/degenerate components if enabled (default) + if not args.no_clean: + mesh, cleaned_count = _clean_mesh_components(mesh) + if args.verbose and cleaned_count > 0: + print(f"(removed {cleaned_count} floating components) ", end="") + if args.verbose: info = validate_mesh(mesh) print("done") @@ -357,212 +425,235 @@ def run_meshcut(args: argparse.Namespace) -> None: print(" Warning: Mesh is not watertight. Consider using --repair") print() - # 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 and footprint ({mode}, edge-aligned)...", end=" ", flush=True) - - try: - 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(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" Footprint bounds: ({bounds[0]:.1f}, {bounds[1]:.1f}) to ({bounds[2]:.1f}, {bounds[3]:.1f})") - print() - - # Step 4: Generate grid mask - if args.verbose: - print("Generating grid mask...", end=" ", flush=True) + # ========================================================================= + # REPLACE-BASE APPROACH (default, recommended) + # ========================================================================= + if use_replace_base: + if args.verbose: + print("Converting to micro-feet using replace-base approach...") + print(" NOTE: This replaces everything below z=5mm (magnet holes, etc. will be lost)") + print() - pitch = compute_pitch(args.divisions) + try: + result_mesh = convert_to_micro_feet( + mesh, + micro_divisions=args.micro_divisions, + pitch=GRU, + use_replace_base=True, + ) + except Exception as e: + raise RuntimeError(f"Failed to convert to micro-feet: {e}") - try: - grid_mask = generate_grid_mask( - footprint=footprint, - pitch=pitch, - slot_width=args.slot_width, - clearance=args.clearance, - phase_x=args.phase_x, - phase_y=args.phase_y, - ) - except ValueError as e: - raise RuntimeError(f"Failed to generate grid mask: {e}") + if result_mesh is None: + raise RuntimeError("Conversion returned None") - if args.verbose: - grid_info = get_grid_info(footprint, pitch, args.phase_x, args.phase_y) - print("done") - print(f" Grid pitch: {pitch:.1f}mm") - 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" 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() + # Clean up floating/degenerate components from result + if not args.no_clean: + result_mesh, cleaned_count = _clean_mesh_components(result_mesh) + if args.verbose and cleaned_count > 0: + print(f"Cleaned up {cleaned_count} floating components from result") + print() - # Optional: dump mask to SVG for debugging - if args.dump_mask: + # ========================================================================= + # LEGACY BOOLEAN SUBTRACTION APPROACH + # ========================================================================= + else: + # Step 2: Detect bottom frame and extract footprint if args.verbose: - print(f"Dumping mask to {args.dump_mask}...", end=" ", flush=True) + mode = "force Z-up" if args.force_z_up else "auto-detect" + print(f"Detecting bottom plane and footprint ({mode})...", 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}") + 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(f" Frame yaw: {yaw_deg:.2f} degrees") + bounds = footprint.bounds + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + print(f" Footprint: {width:.1f} x {height:.1f} mm") + + # Infer cell count + from meshcutter.core.foot_cutter import detect_cell_centers + + centers = detect_cell_centers(footprint, GRU) + cells_x = int(round((width + 0.5) / GRU)) + cells_y = int(round((height + 0.5) / GRU)) + print(f" Detected grid: {cells_x} x {cells_y} = {len(centers)} cells") print() - # Step 5: Generate 3D cutter - if args.verbose: - profile_desc = f"profile={args.profile}" if args.profile != "rect" else "rectangular" - print(f"Generating 3D cutter mesh ({profile_desc})...", end=" ", flush=True) - - try: - # 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, + # Determine effective overshoot + # Auto-overshoot applies 0.35mm for multi-cell grids to cut outer shell walls + effective_overshoot = args.overshoot + if args.auto_overshoot: + # Compute cell count from mesh bounds + bounds = footprint.bounds + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + cells_x = int(round((width + 0.5) / GRU)) + cells_y = int(round((height + 0.5) / GRU)) + num_cells = cells_x * cells_y + + if num_cells >= 2: + effective_overshoot = 0.35 + if args.verbose: + print(f"Auto-overshoot enabled: using {effective_overshoot}mm for {num_cells}-cell grid") + print() + + # Step 3: Generate micro-grid cutter + if args.verbose: + print( + f"Generating micro-foot cutter ({args.micro_divisions}x{args.micro_divisions} per cell)...", + end=" ", + flush=True, ) - else: - # Use profiled cutter with chamfers - cutter = generate_profiled_cutter( - grid_mask=grid_mask, + + try: + cutter = generate_microgrid_cutter( + footprint=footprint, frame=frame, - profile=args.profile, - depth=args.depth, + micro_divisions=args.micro_divisions, + pitch=GRU, epsilon=args.epsilon, - n_slices=n_slices, + mesh_bounds=mesh.bounds, # Use mesh bounds for accurate cell detection + overshoot=effective_overshoot, + wall_cut=args.wall_cut, + add_channels=args.add_channels, ) - except ValueError as e: - raise RuntimeError(f"Failed to generate cutter: {e}") + 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() + if cutter is None: + raise RuntimeError("Cutter generation returned None") - # Step 6: Validate inputs and check bounds intersection - if args.verbose: - print("Validating boolean inputs...", end=" ", flush=True) + if args.verbose: + print("done") + print(f" Cutter triangles: {len(cutter.faces)}") + print(f" Cutter watertight: {cutter.is_watertight}") + cutter_bounds = cutter.bounds + print( + f" Cutter bounds: [{cutter_bounds[0, 0]:.1f}, {cutter_bounds[0, 1]:.1f}] " + f"to [{cutter_bounds[1, 0]:.1f}, {cutter_bounds[1, 1]:.1f}]" + ) + print() - # Print bounds for debugging - part_bounds = mesh.bounds - cutter_bounds = cutter.bounds + # Validate cutter geometry (detect internal faces / stacked sheets) + if not args.no_validate: + from meshcutter.core.validation import validate_cutter_geometry, CutterValidationError - 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}]" + if args.verbose: + print("Validating cutter geometry...", end=" ", flush=True) + + try: + validation = validate_cutter_geometry(cutter, name="cutter", epsilon=args.epsilon, raise_on_error=True) + if args.verbose: + print("OK") + if validation["warnings"]: + for w in validation["warnings"]: + print(f" Note: {w}") + print() + except CutterValidationError as e: + raise RuntimeError(str(e)) + + # Step 4: Validate inputs and check bounds intersection + if args.verbose: + print("Validating boolean inputs...", end=" ", flush=True) + + part_bounds = mesh.bounds + cutter_bounds = cutter.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] + 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] ) - # 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] - 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: + import math - if not bounds_intersect: - # 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" 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) + yaw_angle = math.degrees(math.atan2(frame.x_axis[1], frame.x_axis[0])) + 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" Frame yaw: {yaw_angle:.2f} degrees\n" + f" This may indicate a detection or alignment issue." + ) - warnings = validate_boolean_inputs(mesh, cutter) - if warnings and args.verbose: - for w in warnings: - print(f" Warning: {w}") - elif args.verbose: - print(" Bounds check: OK") - print() + warnings = validate_boolean_inputs(mesh, cutter) + if args.verbose: + if warnings: + print() + for w in warnings: + print(f" Warning: {w}") + else: + print("OK") + print() - # Step 7: Boolean difference - if args.verbose: - repair_msg = " (with repair)" if args.repair else "" - print(f"Performing boolean difference{repair_msg}...", end=" ", flush=True) + # Step 5: Boolean difference + if args.verbose: + repair_msg = " (with repair)" if args.repair else "" + print(f"Performing boolean difference{repair_msg}...", end=" ", flush=True) - try: - result = boolean_difference( - part=mesh, - cutter=cutter, - repair=args.repair, - ) - except BooleanError as e: - raise RuntimeError(str(e)) + try: + result = boolean_difference( + part=mesh, + cutter=cutter, + repair=args.repair, + ) + except BooleanError as e: + raise RuntimeError(str(e)) - if args.verbose: - print("done") - print(f" Engine used: {result.engine_used}") - print(f" Repair applied: {result.repair_applied}") - if result.warnings: - for w in result.warnings: - print(f" Note: {w}") - print() + if args.verbose: + print("done") + print(f" Engine used: {result.engine_used}") + print(f" Repair applied: {result.repair_applied}") + if result.warnings: + for w in result.warnings: + print(f" Note: {w}") + print() + + # Clean up floating/degenerate components from result + if not args.no_clean: + result_mesh, cleaned_count = _clean_mesh_components(result.mesh) + if args.verbose and cleaned_count > 0: + print(f"Cleaned up {cleaned_count} floating components from result") + + # Final manifold repair to eliminate any remaining artifacts + result_mesh = _repair_mesh_manifold(result_mesh) + if args.verbose: + print() + else: + result_mesh = result.mesh - # Step 8: Export result + # Step 6: Export result if args.verbose: print(f"Exporting to {args.output}...", end=" ", flush=True) try: - export_stl(result.mesh, args.output) + export_stl(result_mesh, args.output) except ExporterError as e: raise RuntimeError(f"Failed to export: {e}") if args.verbose: - export_info = get_export_info(result.mesh) + export_info = get_export_info(result_mesh) print("done") print(f" Output triangles: {export_info['face_count']}") print(f" Output watertight: {export_info['is_watertight']}") diff --git a/meshcutter/core/__init__.py b/meshcutter/core/__init__.py index 4afb758..aa8afda 100644 --- a/meshcutter/core/__init__.py +++ b/meshcutter/core/__init__.py @@ -4,27 +4,22 @@ # 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, +from meshcutter.core.foot_cutter import ( + generate_microgrid_cutter, + generate_cell_cutter, + detect_cell_centers, + GRU, ) __all__ = [ "detect_bottom_frame", "extract_footprint", "detect_aligned_frame", - "generate_grid_mask", - "compute_grid_positions", - "generate_cutter", + "generate_microgrid_cutter", + "generate_cell_cutter", + "detect_cell_centers", "boolean_difference", "repair_mesh", - "CutterProfile", - "get_profile", - "PROFILE_RECTANGULAR", - "PROFILE_GRIDFINITY", + "GRU", ] diff --git a/meshcutter/core/constants.py b/meshcutter/core/constants.py new file mode 100644 index 0000000..2a997f4 --- /dev/null +++ b/meshcutter/core/constants.py @@ -0,0 +1,44 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.constants - Centralized Gridfinity constants +# +# Re-exports constants from microfinity.core.constants for consistency, +# plus meshcutter-specific constants for the replace-base pipeline. + +from __future__ import annotations + +# Re-export Gridfinity constants from microfinity +from microfinity.core.constants import ( + GRU, # 42.0 - 1U pitch (mm) + GR_TOL, # 0.5 - Clearance between feet (mm) + GR_RAD, # 4.0 - Nominal exterior fillet radius (mm) + GR_BASE_HEIGHT, # 4.75 - Foot height (mm) + GR_BASE_CLR, # 0.25 - Clearance above foot (mm) + GR_BOX_PROFILE, # Profile segments for box foot + GR_BOX_CHAMF_H, # 0.8 - Bottom chamfer height + GR_STR_H, # 1.8 - Straight section height + SQRT2, # sqrt(2) for 45-degree calculations +) + +# ----------------------------------------------------------------------------- +# Meshcutter-specific constants +# ----------------------------------------------------------------------------- + +# Z_SPLIT_HEIGHT is the plane where we cut between top (kept) and base (replaced) +# z_split = z_min + GR_BASE_HEIGHT + GR_BASE_CLR = z_min + 5.0mm +Z_SPLIT_HEIGHT: float = GR_BASE_HEIGHT + GR_BASE_CLR # 4.75 + 0.25 = 5.0mm + +# SLEEVE_HEIGHT - how far the new base extends ABOVE z_split for overlap +# This is critical: overlap ensures robust union (no coplanar faces) +SLEEVE_HEIGHT: float = 0.5 # mm + +# COPLANAR_EPSILON - offset to avoid coplanar geometry issues in booleans +COPLANAR_EPSILON: float = 0.02 # mm + +# Mesh cleanup thresholds +MIN_COMPONENT_FACES: int = 100 # Minimum faces to keep a component +MIN_SLIVER_SIZE: float = 0.001 # 1 µm - any dimension smaller is suspicious +MIN_SLIVER_VOLUME: float = 1e-12 # mm³ - volumes below this are effectively zero + +# Golden test acceptance threshold +MAX_VOLUME_DIFF_MM3: float = 1.0 # Maximum acceptable difference in mm³ diff --git a/meshcutter/core/cq_cutter.py b/meshcutter/core/cq_cutter.py new file mode 100644 index 0000000..5a5fbd7 --- /dev/null +++ b/meshcutter/core/cq_cutter.py @@ -0,0 +1,1968 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.cq_cutter - CadQuery-based Gridfinity micro-foot cutter generation +# +# Generates cutter geometry using CadQuery for proper 45-degree chamfers, +# exactly mirroring microfinity's foot construction for geometric accuracy. +# + +from __future__ import annotations + +import math +import os +import tempfile +from functools import lru_cache +from typing import List, Optional, Tuple + +import cadquery as cq +import trimesh +from cqkit.cq_helpers import rounded_rect_sketch + +from microfinity.core.constants import ( + GR_BASE_CLR, + GR_BASE_HEIGHT, + GR_BOX_PROFILE, + GR_RAD, + GR_TOL, + GRU, + SQRT2, +) + +# ----------------------------------------------------------------------------- +# CadQuery version detection for ZLEN_FIX (copied from microfinity.core.base) +# CQ versions < 2.4.0 typically require zlen correction for tapered extrusions +# ----------------------------------------------------------------------------- +ZLEN_FIX = True +_r = cq.Workplane("XY").rect(2, 2).extrude(1, taper=45) +_bb = _r.vals()[0].BoundingBox() +if abs(_bb.zlen - 1.0) < 1e-3: + ZLEN_FIX = False + + +# ----------------------------------------------------------------------------- +# Extrude profile helper (mirrors microfinity.core.base.GridfinityObject.extrude_profile) +# ----------------------------------------------------------------------------- +def extrude_profile(sketch, profile, workplane="XY", angle=None) -> cq.Workplane: + """Extrude a sketch through a multi-segment profile with optional tapers. + + This is a standalone version of GridfinityObject.extrude_profile() to avoid + needing to instantiate a full GridfinityObject just for profile extrusion. + + Args: + sketch: CadQuery Sketch to extrude + profile: Tuple of profile segments. Each segment is either: + - A float (straight extrusion height) + - A tuple (height, taper_angle) for tapered extrusion + workplane: Workplane to start from (default "XY") + angle: If provided, use angle-based ZLEN correction instead of SQRT2 + + Returns: + CadQuery Workplane with extruded solid + """ + taper = profile[0][1] if isinstance(profile[0], (list, tuple)) else 0 + zlen = profile[0][0] if isinstance(profile[0], (list, tuple)) else profile[0] + + if abs(taper) > 0: + if angle is None: + zlen = zlen if ZLEN_FIX else zlen / SQRT2 + else: + zlen = zlen / math.cos(math.radians(taper)) if ZLEN_FIX else zlen + + r = cq.Workplane(workplane).placeSketch(sketch).extrude(zlen, taper=taper) + + for level in profile[1:]: + if isinstance(level, (tuple, list)): + if angle is None: + zlen = level[0] if ZLEN_FIX else level[0] / SQRT2 + else: + zlen = level[0] / math.cos(math.radians(level[1])) if ZLEN_FIX else level[0] + r = r.faces(">Z").wires().toPending().extrude(zlen, taper=level[1]) + else: + r = r.faces(">Z").wires().toPending().extrude(level) + + return r + + +# ----------------------------------------------------------------------------- +# Geometry constants (derived from microfinity conventions) +# ----------------------------------------------------------------------------- +def get_outer_rad() -> float: + """Get the outer corner radius used for foot solids. + + From microfinity: outer_rad = GR_RAD - GR_TOL / 2 + """ + return GR_RAD - GR_TOL / 2 # 4.0 - 0.25 = 3.75mm + + +def get_foot_rad() -> float: + """Get the corner radius for foot profile extrusion. + + From microfinity: rad = outer_rad + GR_BASE_CLR + """ + return get_outer_rad() + GR_BASE_CLR # 3.75 + 0.25 = 4.0mm + + +def _top_chamfer_run() -> float: + """Get the horizontal/vertical run of the top 45° chamfer (mm). + + The top chamfer is a 45° slope, so its horizontal run equals its vertical run. + This is used to calculate the width needed for inter-cell channels and + boundary fill to fully cover the chamfer seam between adjacent feet. + + Returns: + Chamfer run in mm (~2.4mm for standard Gridfinity) + """ + # Try to use GR_BOX_TOP_CHAMF if available (this is the vertical height) + try: + from microfinity.core.constants import GR_BOX_TOP_CHAMF + + return float(GR_BOX_TOP_CHAMF) + except ImportError: + pass + + # Fallback: compute from GR_BOX_PROFILE[0] = (diagonal_length, angle_deg) + # The profile stores (length_along_slope, angle) where length is diagonal + # For a 45° chamfer: vertical_run = diagonal_length * sin(45°) = diagonal / sqrt(2) + chamfer_segment = GR_BOX_PROFILE[0] + if isinstance(chamfer_segment, (tuple, list)) and len(chamfer_segment) >= 2: + diagonal_len, angle_deg = chamfer_segment[0], chamfer_segment[1] + return float(diagonal_len) * math.sin(math.radians(float(angle_deg))) + + # Ultimate fallback: use known value for standard Gridfinity + return 2.4 + + +# ----------------------------------------------------------------------------- +# Foot generation (mirrors microfinity/parts/box.py) +# ----------------------------------------------------------------------------- +def generate_1u_foot_cq(cropped: bool = True) -> cq.Workplane: + """Generate a 1U foot solid using CadQuery. + + Mirrors microfinity box.py render_shell() for macro feet. + + In box.py, the foot is generated at GRU (42mm) then intersected with + an outer envelope at (GRU - GR_TOL) = 41.5mm with radius (GR_RAD - GR_TOL/2). + We replicate this by generating the raw foot then intersecting with the + same cropping envelope. + + Args: + cropped: If True (default), apply envelope cropping to match actual model. + If False, return raw 42mm foot. + + Returns: + CadQuery Workplane with 1U foot solid + """ + rad = get_foot_rad() # 4.0mm + + # Generate raw foot at GRU (42mm) - same as box.py render_shell() + foot = extrude_profile(rounded_rect_sketch(GRU, GRU, rad), GR_BOX_PROFILE) + foot = foot.translate((0, 0, -GR_BASE_CLR)) + foot = foot.mirror(mirrorPlane="XY") + + if cropped: + # Apply the same cropping envelope as box.py + # rc = cq.Workplane("XY").placeSketch(rs).extrude(-GR_BASE_HEIGHT - 1).translate((*self.half_dim, 0.5)) + outer_size = GRU - GR_TOL # 41.5mm + outer_rad = get_outer_rad() # 3.75mm + crop_env = ( + cq.Workplane("XY") + .placeSketch(rounded_rect_sketch(outer_size, outer_size, outer_rad)) + .extrude(-GR_BASE_HEIGHT - 1) + .translate(cq.Vector(0, 0, 0.5)) + ) + foot = crop_env.intersect(foot) + + return foot + + +def generate_micro_foot_cq( + micro_divisions: int = 4, + size_reduction: float = 0.0, +) -> cq.Workplane: + """Generate a micro foot solid using CadQuery. + + Creates a micro-foot with the correct dimensions to match microfinity output. + The foot size is (micro_pitch - GR_TOL) to account for the 0.5mm clearance + between adjacent feet. For div=4, this gives 10.0mm feet with 0.5mm gaps. + + Args: + micro_divisions: Number of divisions (2 or 4) + size_reduction: Amount to shrink the foot size (mm). Used for cutter + generation where smaller micro-feet create a gap that + allows cutting the outer wall. + + Returns: + CadQuery Workplane with micro foot solid + """ + outer_rad = get_outer_rad() # 3.75mm + rad_1u = outer_rad + GR_BASE_CLR # 4.0mm + + micro_pitch = GRU / micro_divisions # 10.5mm for div=4 + # Foot size = micro_pitch - GR_TOL to match actual foot dimensions + # This gives 10.0mm feet for div=4 (10.5 - 0.5), matching microfinity output + foot_size = micro_pitch - GR_TOL - size_reduction # 10.0mm for div=4 + + # Ensure minimum viable size + foot_size = max(foot_size, 2.0) + + # Clamp radius to valid range (must fit in foot) + rad = min(rad_1u, foot_size / 2 - 0.05) + rad = max(rad, 0.2) # Minimum to avoid degenerate geometry + + foot = extrude_profile(rounded_rect_sketch(foot_size, foot_size, rad), GR_BOX_PROFILE) + foot = foot.translate((0, 0, -GR_BASE_CLR)) + foot = foot.mirror(mirrorPlane="XY") + + return foot + + +def micro_foot_offsets(micro_divisions: int, pitch: float = GRU) -> List[Tuple[float, float]]: + """Return micro-foot center offsets relative to 1U cell center. + + This matches the microfinity reference implementation exactly: + - Foot centers are symmetric about the origin + - Outermost feet extend GR_TOL/2 PAST the envelope edge (then get cropped) + - This is identical to how 1U feet (at 42mm) get cropped by the 41.5mm envelope + + For micro_divisions=4, pitch=42: + - micro_pitch = 10.5mm + - foot_size = 10.5mm (same as micro_pitch) + - Centers at: ±15.75, ±5.25 (matching reference micro_grid_centres) + - Outermost foot edge at ±21.0, cropped by envelope to ±20.75 + + The key insight: feet should extend PAST the envelope by GR_TOL/2 so that + the envelope intersection crops them, producing the correct chamfer profile + at the boundary (same mechanism as 1U feet). + + Args: + micro_divisions: Number of divisions (1, 2, or 4) + pitch: 1U pitch (default 42mm) + + Returns: + List of (x, y) offset tuples + """ + if micro_divisions <= 1: + return [(0.0, 0.0)] + + micro_pitch = pitch / micro_divisions # 10.5mm for div=4 + + # Simple symmetric formula matching microfinity's micro_grid_centres + # NO inward shift - feet extend past envelope and get cropped + # + # For div=4: centers at (micro_pitch/2) * [-3, -1, 1, 3] = [-15.75, -5.25, 5.25, 15.75] + # This puts outermost foot edges at ±21.0, which get cropped to ±20.75 by envelope + offsets = [] + for i in range(micro_divisions): + for j in range(micro_divisions): + x = (micro_pitch / 2) * (2 * i - (micro_divisions - 1)) + y = (micro_pitch / 2) * (2 * j - (micro_divisions - 1)) + offsets.append((x, y)) + return offsets + + +# ----------------------------------------------------------------------------- +# Cell cutter generation +# ----------------------------------------------------------------------------- +@lru_cache(maxsize=8) +def generate_extended_foot_cq(overshoot: float = 0.0, cropped: bool = True) -> cq.Workplane: + """Generate an extended 1U foot solid for cutter envelope. + + This creates a foot that extends beyond the normal 1U foot boundary + by the overshoot amount on all sides. Used for generating cutters + that extend beyond the foot boundary to cut outer walls. + + Args: + overshoot: Extension beyond normal foot size (mm) + cropped: If True (default), apply envelope cropping to match actual model. + + Returns: + CadQuery Workplane with extended foot solid + """ + rad = get_foot_rad() # 4.0mm + size = GRU + 2 * overshoot # Extend in both directions from raw 42mm + + foot = extrude_profile( + rounded_rect_sketch(size, size, rad), + GR_BOX_PROFILE, + ) + foot = foot.translate((0, 0, -GR_BASE_CLR)) + foot = foot.mirror(mirrorPlane="XY") + + if cropped: + # Apply cropping envelope extended by overshoot + outer_size = GRU - GR_TOL + 2 * overshoot # 41.5mm + overshoot + outer_rad = get_outer_rad() # 3.75mm + crop_env = ( + cq.Workplane("XY") + .placeSketch(rounded_rect_sketch(outer_size, outer_size, outer_rad)) + .extrude(-GR_BASE_HEIGHT - 1) + .translate(cq.Vector(0, 0, 0.5)) + ) + foot = crop_env.intersect(foot) + + return foot + + +@lru_cache(maxsize=32) +def generate_cell_cutter_cq( + micro_divisions: int = 4, + epsilon: float = 0.02, + overshoot: float = 0.0, + wall_cut: float = 0.0, +) -> cq.Workplane: + """Generate cutter for a single 1U cell using CadQuery. + + The cutter is computed as: C = Envelope - union(micro_feet) + where Envelope is the 1U foot (optionally extended) and micro_feet are smaller. + + The overshoot parameter extends the cutter beyond the normal F1 boundary, + allowing it to cut the outer walls of edge cells where the micro-foot gaps + should extend beyond the 1U foot profile. + + The wall_cut parameter shrinks the micro-feet used in the cutter calculation, + creating cutter material that extends INTO the model at the outer edge. This + allows the cutter to cut through the outer wall of the foot. + + This is done entirely in CadQuery (B-rep booleans) for stability, + including the epsilon extension below z=0. + + Args: + micro_divisions: Number of divisions (2 or 4) + epsilon: Extension below z=0 to avoid coplanar issues (mm) + overshoot: Extension beyond normal F1 size to cut outer walls (mm). + Default 0.0 = no extension (cutter stays within F1). + Typical value: 1.0-2.0mm for cutting outer walls. + wall_cut: Amount to shrink micro-feet in cutter, creating overlap with + model outer wall (mm). Set to 0.5-1.0 to cut outer walls. + Default 0.0 = no shrinkage. + + Returns: + CadQuery Workplane with cell cutter solid + """ + if micro_divisions <= 1: + raise ValueError("micro_divisions must be > 1 for cutter generation") + + # Generate the envelope (F1 foot, optionally extended) + # Use cropped=True to match the actual model's 41.5mm cropped foot. + # The cutter must match the model's F1 exactly so the chamfer profiles align. + # Using uncropped (42mm) would leave 0.25mm of F1 chamfer uncut at the edges. + if overshoot > 0: + envelope = generate_extended_foot_cq(overshoot, cropped=True) + else: + envelope = generate_1u_foot_cq(cropped=True) + envelope_solid = envelope.val() + + # Generate micro foot template (optionally shrunk for wall cutting) + micro_foot = generate_micro_foot_cq(micro_divisions, size_reduction=wall_cut) + micro_foot_solid = micro_foot.val() + + # Union all micro feet at their offset positions + # Using OCC Shape operations (.fuse/.cut) for consistency + offsets = micro_foot_offsets(micro_divisions) + micro_feet_union = None + + for ox, oy in offsets: + instance = micro_foot_solid.translate(cq.Vector(ox, oy, 0)) + if micro_feet_union is None: + micro_feet_union = instance + else: + micro_feet_union = micro_feet_union.fuse(instance) + + # Cutter = envelope - union of micro feet + cutter_solid = envelope_solid.cut(micro_feet_union) + + # NOTE: epsilon extension is now applied as a pure affine transform in + # generate_grid_cutter_mesh() via apply_bottom_epsilon_preserve_top(). + # The previous approach (fusing a box) created internal coincident faces + # that caused "stacked Z sheets" and boolean artifacts. + + return cq.Workplane("XY").newObject([cutter_solid]) + + +# ----------------------------------------------------------------------------- +# Corner plug generation for 4-cell meeting points +# ----------------------------------------------------------------------------- +def detect_four_cell_intersections( + cell_centers: List[Tuple[float, float]], + pitch: float = GRU, +) -> List[Tuple[float, float]]: + """Detect points where exactly 4 cells meet. + + A 4-cell intersection exists at (xb, yb) if and only if all four adjacent + cells exist: (x_left, y_down), (x_left, y_up), (x_right, y_down), (x_right, y_up) + + This is robust for arbitrary grids including ragged/non-rectangular layouts. + + Args: + cell_centers: List of (x, y) cell center coordinates + pitch: 1U pitch (default 42mm) + + Returns: + List of (x, y) coordinates where 4 cells meet + """ + if len(cell_centers) < 4: + return [] + + # Create a set for fast lookup (quantize to avoid float comparison issues) + def quantize(v: float, precision: float = 0.1) -> float: + return round(v / precision) * precision + + cell_set = {(quantize(cx), quantize(cy)) for cx, cy in cell_centers} + + # Find unique X and Y coordinates + xs = sorted(set(quantize(cx) for cx, cy in cell_centers)) + ys = sorted(set(quantize(cy) for cx, cy in cell_centers)) + + # Find all valid 4-cell intersections + intersections = [] + + for i in range(len(xs) - 1): + x_left = xs[i] + x_right = xs[i + 1] + x_boundary = (x_left + x_right) / 2.0 + + for j in range(len(ys) - 1): + y_down = ys[j] + y_up = ys[j + 1] + y_boundary = (y_down + y_up) / 2.0 + + # Check if all 4 adjacent cells exist + has_all_four = ( + (quantize(x_left), quantize(y_down)) in cell_set + and (quantize(x_left), quantize(y_up)) in cell_set + and (quantize(x_right), quantize(y_down)) in cell_set + and (quantize(x_right), quantize(y_up)) in cell_set + ) + + if has_all_four: + intersections.append((x_boundary, y_boundary)) + + return intersections + + +def detect_seam_network( + cell_centers: List[Tuple[float, float]], + pitch: float = GRU, + footprint_bounds: Optional[Tuple[float, float, float, float]] = None, +) -> List[Tuple[float, float, List[str]]]: + """Detect all seam nodes in the inter-cell channel network. + + A seam node is any point where channels meet or terminate. This includes: + - Degree 4: Internal 4-cell crossings (4 incident channels) + - Degree 3: T-junctions at boundaries or cutouts (3 incident channels) + - Degree 2: Edge terminations where channels meet boundary (2 incident channels) + + Each node is returned with its incident channel directions. + + Args: + cell_centers: List of (x, y) cell center coordinates + pitch: 1U pitch (default 42mm) + footprint_bounds: Optional (x_min, y_min, x_max, y_max) clipping bounds + + Returns: + List of (node_x, node_y, incident_directions) tuples where + incident_directions is a list of 'N', 'S', 'E', 'W' indicating + which channel directions are present at this node. + """ + if len(cell_centers) < 2: + return [] + + # Quantize for float comparison + def quantize(v: float, precision: float = 0.1) -> float: + return round(v / precision) * precision + + cell_set = {(quantize(cx), quantize(cy)) for cx, cy in cell_centers} + + # Find unique X and Y coordinates (grid lines) + xs = sorted(set(quantize(cx) for cx, cy in cell_centers)) + ys = sorted(set(quantize(cy) for cx, cy in cell_centers)) + + # Channel seam locations: + # - Vertical seams (between X columns): at X = (xs[i] + xs[i+1]) / 2 + # - Horizontal seams (between Y rows): at Y = (ys[j] + ys[j+1]) / 2 + x_seams = [(xs[i] + xs[i + 1]) / 2.0 for i in range(len(xs) - 1)] + y_seams = [(ys[j] + ys[j + 1]) / 2.0 for j in range(len(ys) - 1)] + + seam_nodes = [] + + # Internal nodes: where vertical and horizontal seams cross + for x_seam in x_seams: + for y_seam in y_seams: + # Check which cells exist around this crossing + # A channel exists in a direction if both cells along it exist + + # Find the two X columns this seam is between + x_left = None + x_right = None + for i in range(len(xs) - 1): + if abs((xs[i] + xs[i + 1]) / 2.0 - x_seam) < 0.01: + x_left = xs[i] + x_right = xs[i + 1] + break + + # Find the two Y rows this seam is between + y_down = None + y_up = None + for j in range(len(ys) - 1): + if abs((ys[j] + ys[j + 1]) / 2.0 - y_seam) < 0.01: + y_down = ys[j] + y_up = ys[j + 1] + break + + if x_left is None or y_down is None: + continue + + # Determine incident directions based on adjacent cell existence + incident = [] + + # North: vertical channel continues north if cells at (x_left, y_up) and (x_right, y_up) exist + if (quantize(x_left), quantize(y_up)) in cell_set and (quantize(x_right), quantize(y_up)) in cell_set: + incident.append("N") + + # South: vertical channel continues south if cells at (x_left, y_down) and (x_right, y_down) exist + if (quantize(x_left), quantize(y_down)) in cell_set and (quantize(x_right), quantize(y_down)) in cell_set: + incident.append("S") + + # East: horizontal channel continues east if cells at (x_right, y_up) and (x_right, y_down) exist + if (quantize(x_right), quantize(y_up)) in cell_set and (quantize(x_right), quantize(y_down)) in cell_set: + incident.append("E") + + # West: horizontal channel continues west if cells at (x_left, y_up) and (x_left, y_down) exist + if (quantize(x_left), quantize(y_up)) in cell_set and (quantize(x_left), quantize(y_down)) in cell_set: + incident.append("W") + + if len(incident) >= 2: # Only nodes with at least 2 incident channels + seam_nodes.append((x_seam, y_seam, incident)) + + # Boundary nodes: where channels meet the outer edge of the footprint + if footprint_bounds is not None: + fb_xmin, fb_ymin, fb_xmax, fb_ymax = footprint_bounds + + # Vertical channels at X seams meeting Y boundaries + for x_seam in x_seams: + # Find which Y rows have cells on both sides of this X seam + for j in range(len(ys)): + y_row = ys[j] + # Check if cells on both sides of x_seam at this y_row + x_left = None + x_right = None + for i in range(len(xs) - 1): + if abs((xs[i] + xs[i + 1]) / 2.0 - x_seam) < 0.01: + x_left = xs[i] + x_right = xs[i + 1] + break + if x_left is None: + continue + + has_left = (quantize(x_left), quantize(y_row)) in cell_set + has_right = (quantize(x_right), quantize(y_row)) in cell_set + + if has_left and has_right: + # Channel exists at this x_seam, y_row + # Check if this is at a Y boundary + if j == 0: + # Bottom boundary + y_node = y_row - pitch / 2.0 + if y_node >= fb_ymin - 1: + seam_nodes.append((x_seam, y_node, ["N"])) + if j == len(ys) - 1: + # Top boundary + y_node = y_row + pitch / 2.0 + if y_node <= fb_ymax + 1: + seam_nodes.append((x_seam, y_node, ["S"])) + + # Horizontal channels at Y seams meeting X boundaries + for y_seam in y_seams: + # Find which X columns have cells on both sides of this Y seam + for i in range(len(xs)): + x_col = xs[i] + y_down = None + y_up = None + for j in range(len(ys) - 1): + if abs((ys[j] + ys[j + 1]) / 2.0 - y_seam) < 0.01: + y_down = ys[j] + y_up = ys[j + 1] + break + if y_down is None: + continue + + has_down = (quantize(x_col), quantize(y_down)) in cell_set + has_up = (quantize(x_col), quantize(y_up)) in cell_set + + if has_down and has_up: + # Channel exists at this x_col, y_seam + # Check if this is at an X boundary + if i == 0: + # Left boundary + x_node = x_col - pitch / 2.0 + if x_node >= fb_xmin - 1: + seam_nodes.append((x_node, y_seam, ["E"])) + if i == len(xs) - 1: + # Right boundary + x_node = x_col + pitch / 2.0 + if x_node <= fb_xmax + 1: + seam_nodes.append((x_node, y_seam, ["W"])) + + return seam_nodes + + +def generate_junction_correction( + node_x: float, + node_y: float, + incident_directions: List[str], + z0: float, + z1: float, + z2: float, + z3: float, + top_chamf_vert: float, + bot_chamf_vert: float, + micro_pitch: float = None, + n_arc_points: int = 12, +) -> Optional[cq.Workplane]: + """DEPRECATED: Generate correction geometry at a seam junction node. + + WARNING: This function is DEPRECATED and NOT USED in production. + + This was an attempt to patch junction artifacts in the boolean subtraction + approach. It fundamentally doesn't work because adding geometry still goes + through the same mesh boolean pipeline that causes the artifacts. + + The correct solution is to use the replace_base_pipeline from + meshcutter.core.replace_base, which generates fresh micro-feet base + geometry instead of trying to carve it with booleans. + + See: meshcutter.core.replace_base.replace_base_pipeline() + + --- + Original docstring preserved below for reference: + --- + + At channel junctions, small circular-segment gaps exist between the micro-foot's + curved inner corner arc and the straight channel edge. These gaps are not cut by + either the cell cutter (which follows the foot arc) or the channel (which has + straight edges). + + The gap at each micro-foot inner corner is the thin circular segment between: + - The foot's corner arc (R=4mm at base, shrinking with Z) + - The tangent channel edge (straight line) + + This function generates the correction geometry by building the circular segment + at each adjacent micro-foot's inner corner. + + Args: + node_x: X position of the junction node + node_y: Y position of the junction node + incident_directions: List of incident channel directions ('N', 'S', 'E', 'W') + z0, z1, z2, z3: Z breakpoints (CQ space) + top_chamf_vert: Vertical height of top chamfer region + bot_chamf_vert: Vertical height of bottom chamfer region + micro_pitch: Micro-foot pitch (default GRU/4 = 10.5mm) + n_arc_points: Number of points for arc approximation + + Returns: + CadQuery Workplane with correction solid, or None if no correction needed + """ + import warnings + + warnings.warn( + "generate_junction_correction is deprecated and does not work correctly. " + "Use meshcutter.core.replace_base.replace_base_pipeline() instead.", + DeprecationWarning, + stacklevel=2, + ) + import numpy as np + + if micro_pitch is None: + micro_pitch = GRU / 4.0 # 10.5mm for div=4 + + micro_foot_half = (micro_pitch - GR_TOL) / 2.0 # 5.0mm for div=4 + + def channel_half_width_at_z(z: float) -> float: + """Compute channel half-width at given Z level (CQ space).""" + if z >= z3: + return GR_TOL / 2.0 + elif z >= z2: + return GR_TOL / 2.0 + (z3 - z) + elif z >= z1: + return GR_TOL / 2.0 + (z3 - z2) # = 0.25 + bot_chamf_vert + else: + return GR_TOL / 2.0 + (z3 - z2) + (z1 - z) + + def foot_corner_radius_at_z(z: float) -> float: + """Compute micro-foot corner radius at given Z level (CQ space).""" + if z >= z3: + return GR_RAD + else: + return max(0.0, GR_RAD - (z3 - z)) + + # At a junction node, find the adjacent micro-feet and their inner corner positions. + # For a 4-cell intersection at (node_x, node_y), the 4 adjacent micro-feet are at: + # - Foot centers: (node_x ± micro_pitch/2, node_y ± micro_pitch/2) + # - Each foot's inner corner points toward the junction + + # The gap exists between each foot's inner corner arc and the adjacent channel edge. + # For the foot at (node_x - micro_pitch/2, node_y - micro_pitch/2) (SW quadrant): + # - Inner corner at (node_x - GR_TOL/2, node_y - GR_TOL/2) + # - Arc center at (node_x - GR_TOL/2 - GR_RAD + micro_foot_half + GR_TOL/2, ...) + # Wait, this is getting complicated. Let me work it out properly. + + # Foot at center (fx, fy) has: + # - Outer edges at fx ± micro_foot_half, fy ± micro_foot_half + # - Corner radius GR_RAD at base + # - Inner corner (toward +X, +Y) at (fx + micro_foot_half, fy + micro_foot_half) + # - Arc center for inner corner at (fx + micro_foot_half - GR_RAD, fy + micro_foot_half - GR_RAD) + + # For the SW foot (in the -X, -Y quadrant from junction): + # - Foot center: (node_x - micro_pitch/2, node_y - micro_pitch/2) + # = (node_x - 5.25, node_y - 5.25) + # - Inner corner (toward +X, +Y from foot center): + # (fx + micro_foot_half, fy + micro_foot_half) + # = (node_x - 5.25 + 5, node_y - 5.25 + 5) + # = (node_x - 0.25, node_y - 0.25) + # - Arc center: (node_x - 0.25 - 4, node_y - 0.25 - 4) = (node_x - 4.25, node_y - 4.25) + + # The gap for this foot is the circular segment between: + # - Arc (R=4 at base) centered at (node_x - 4.25, node_y - 4.25) + # - Tangent lines: X = node_x - 0.25 (vertical channel edge) and Y = node_y - 0.25 (horizontal channel edge) + + # Generate circular segment polygons for each adjacent foot + def segment_polygon_at_z( + arc_center_x: float, + arc_center_y: float, + foot_corner_x: float, + foot_corner_y: float, + dx: int, + dy: int, + z: float, + ) -> Optional[List[Tuple[float, float]]]: + """Generate circular segment polygon for one micro-foot corner at given Z. + + The segment is bounded by: + - The vertical channel edge at x = foot_corner_x + - The horizontal channel edge at y = foot_corner_y + - The foot's inner corner arc + + Args: + arc_center_x, arc_center_y: Arc center (fixed, doesn't move with Z) + foot_corner_x, foot_corner_y: Corner position at foot base (Z=z3) + dx, dy: Direction of the quadrant (-1 for -X, +1 for +X, etc.) + z: Current Z level + + Returns: + Polygon points, or None if no gap at this Z + """ + R = foot_corner_radius_at_z(z) + if R <= 0.1: + return None + + w = channel_half_width_at_z(z) + + # At this Z level: + # - Foot corner has moved inward by (z3 - z) due to chamfer + # - Channel edge has moved outward by (z3 - z) due to widening + shrink = z3 - z + corner_x = foot_corner_x - dx * shrink # Corner moves inward + corner_y = foot_corner_y - dy * shrink + # Arc center stays FIXED (it's the center of the original arc) + + # Channel edges at this Z (relative to node): + # For dx=-1: vertical channel edge at x = -w (moving left as channel widens) + # For dx=+1: vertical channel edge at x = +w (moving right as channel widens) + channel_edge_x = dx * w + channel_edge_y = dy * w + + # The gap is bounded by: + # - Vertical line x = channel_edge_x (from corner to where arc meets it) + # - Arc from vertical intersection to horizontal intersection + # - Horizontal line y = channel_edge_y (from arc to corner) + + # But the gap only exists where the channel edge is between the corner and the arc tangent. + # At base (z=z3): channel edge is at ±0.25, foot corner is at ±0.25, so they coincide! + # As Z decreases: channel edge moves outward, corner moves inward, creating a gap. + + # Actually wait - let me reconsider. + # At z=z3 (foot base): + # - Foot corner at (node_x - 0.25, node_y - 0.25) for SW foot + # - Channel edge at X = -0.25 and Y = -0.25 relative to node + # - These coincide! No gap at z=z3. + + # As Z decreases: + # - Foot corner moves toward (-node_x, -node_y) direction (inward) + # - Channel edge moves in (-dx, -dy) direction (outward) + # - Now there's a gap between channel edge and foot corner + + # Check if there's a gap + # Gap exists if channel edge is "outside" the corner (further from node center) + # For dx=-1: gap if channel_edge_x < corner_x (channel moved further left) + # For dx=+1: gap if channel_edge_x > corner_x (channel moved further right) + gap_width_x = dx * (channel_edge_x - corner_x) # Positive if gap exists + gap_width_y = dy * (channel_edge_y - corner_y) # Positive if gap exists + + if gap_width_x <= 0 and gap_width_y <= 0: + return None # No gap at this Z + + # Build the segment polygon + # The segment is the region bounded by: + # - x = corner_x to x = channel_edge_x (or the arc, whichever is closer) + # - y = corner_y to y = channel_edge_y (or the arc, whichever is closer) + # - The arc itself + + # Arc intersections with channel edges: + # Vertical edge x = channel_edge_x: + # (channel_edge_x - arc_center_x)^2 + (y - arc_center_y)^2 = R^2 + # y = arc_center_y ± sqrt(R^2 - (channel_edge_x - arc_center_x)^2) + dx_edge = channel_edge_x - (arc_center_x - node_x) + disc_v = R * R - dx_edge * dx_edge + if disc_v < 0: + # Arc doesn't reach vertical channel edge + return None + arc_y_at_vedge = (arc_center_y - node_y) + dy * np.sqrt(disc_v) + + # Horizontal edge y = channel_edge_y: + dy_edge = channel_edge_y - (arc_center_y - node_y) + disc_h = R * R - dy_edge * dy_edge + if disc_h < 0: + # Arc doesn't reach horizontal channel edge + return None + arc_x_at_hedge = (arc_center_x - node_x) + dx * np.sqrt(disc_h) + + # Build polygon (in local coords relative to node): + # Start at the L-corner where channel edges meet + pts = [(channel_edge_x, channel_edge_y)] + + # Go along horizontal edge to where arc meets it + pts.append((arc_x_at_hedge, channel_edge_y)) + + # Follow arc from horizontal intersection to vertical intersection + # Arc center in local coords: + local_arc_cx = arc_center_x - node_x + local_arc_cy = arc_center_y - node_y + + # Calculate start and end angles for the arc + start_angle = np.arctan2(channel_edge_y - local_arc_cy, arc_x_at_hedge - local_arc_cx) + end_angle = np.arctan2(arc_y_at_vedge - local_arc_cy, channel_edge_x - local_arc_cx) + + # Ensure we go the right way around the arc + if dx * dy > 0: + # Same sign: arc goes counterclockwise (in standard math coords) + if end_angle < start_angle: + end_angle += 2 * np.pi + else: + # Opposite sign: arc goes clockwise + if end_angle > start_angle: + end_angle -= 2 * np.pi + + for i in range(1, n_arc_points - 1): + t = i / (n_arc_points - 1) + angle = start_angle + t * (end_angle - start_angle) + px = local_arc_cx + R * np.cos(angle) + py = local_arc_cy + R * np.sin(angle) + pts.append((px, py)) + + # End at vertical edge where arc meets it + pts.append((channel_edge_x, arc_y_at_vedge)) + + # Close back to start (CadQuery will handle this) + + return pts + + # Determine which quadrants have gaps based on incident directions + # At a degree-4 node, all 4 adjacent feet have gaps + # At a degree-2 node (boundary), only 2 adjacent feet have gaps + + has_n = "N" in incident_directions + has_s = "S" in incident_directions + has_e = "E" in incident_directions + has_w = "W" in incident_directions + + # For each quadrant, check if both adjacent channel directions exist + quadrants = [] + if has_e and has_n: # NE quadrant + quadrants.append((1, 1)) + if has_w and has_n: # NW quadrant + quadrants.append((-1, 1)) + if has_e and has_s: # SE quadrant + quadrants.append((1, -1)) + if has_w and has_s: # SW quadrant + quadrants.append((-1, -1)) + + if not quadrants: + return None + + # For each quadrant, compute the foot corner position and arc center + foot_data = [] + for dx, dy in quadrants: + # Foot corner at base (relative to node) + foot_corner_x = dx * GR_TOL / 2.0 + foot_corner_y = dy * GR_TOL / 2.0 + # Arc center (relative to node) + arc_center_x = foot_corner_x + dx * GR_RAD + arc_center_y = foot_corner_y + dy * GR_RAD + foot_data.append((arc_center_x, arc_center_y, foot_corner_x, foot_corner_y, dx, dy)) + + # Build correction geometry at multiple Z levels + z_levels = [z0, z1, z2, z3] + all_segments = None + + for arc_cx, arc_cy, corner_x, corner_y, dx, dy in foot_data: + segment_slices = [] + + for z in z_levels: + pts = segment_polygon_at_z( + arc_center_x=arc_cx + node_x, + arc_center_y=arc_cy + node_y, + foot_corner_x=corner_x + node_x, + foot_corner_y=corner_y + node_y, + dx=dx, + dy=dy, + z=z, + ) + if pts is not None and len(pts) >= 3: + segment_slices.append((z, pts)) + + if len(segment_slices) < 2: + continue + + # Loft through the slices + z_first, pts_first = segment_slices[0] + wp = cq.Workplane("XY", origin=(node_x, node_y, z_first)) + wp = wp.polyline(pts_first).close() + + for z_level, pts in segment_slices[1:]: + wp = wp.workplane(offset=(z_level - z_first)) + wp = wp.polyline(pts).close() + z_first = z_level + + try: + segment_solid = wp.loft() + if all_segments is None: + all_segments = segment_solid.val() + else: + all_segments = all_segments.fuse(segment_solid.val()) + except Exception: + continue + + if all_segments is None: + return None + + return cq.Workplane("XY").newObject([all_segments]) + + +def generate_corner_plug( + x_pos: float, + y_pos: float, + z0: float, + z1: float, + z2: float, + z3: float, + top_chamf_vert: float, + bot_chamf_vert: float, + straight_vert: float, + epsilon: float = 0.02, +) -> cq.Workplane: + """Generate a diamond-shaped corner plug at a 4-cell intersection. + + The plug fills the diagonal gap between perpendicular channels at the + intersection point. It uses a diamond (45° rotated square) cross-section + that follows the same taper profile as the channels. + + The diamond inradius at each Z level equals the channel half-width: + - At z0 (tip): inradius = (GR_TOL + 2*(top_chamf + bot_chamf)) / 2 + - At z1 (after bot chamfer): inradius = (GR_TOL + 2*top_chamf) / 2 + - At z2 (same as z1, straight section) + - At z3 (base): inradius = GR_TOL / 2 + + The plug is built as three segments using loft/extrude to match the + channel taper profile exactly. + + Args: + x_pos: X position of the intersection + y_pos: Y position of the intersection + z0, z1, z2, z3: Z breakpoints (same as channel profile) + top_chamf_vert: Vertical height of top chamfer + bot_chamf_vert: Vertical height of bottom chamfer + straight_vert: Vertical height of straight section + epsilon: Small margin to add to inradius for tolerance + + Returns: + CadQuery Workplane with diamond-shaped plug + """ + # Calculate inradius at each Z level (with epsilon margin) + r0 = (GR_TOL + 2.0 * (top_chamf_vert + bot_chamf_vert)) / 2.0 + epsilon # At z0 (tip) + r1 = (GR_TOL + 2.0 * top_chamf_vert) / 2.0 + epsilon # At z1 (after bot chamfer) + r2 = r1 # At z2 (same, straight section) + r3 = GR_TOL / 2.0 + epsilon # At z3 (base) + + def diamond_points(r: float) -> List[Tuple[float, float]]: + """Create diamond (45° square) vertices with given inradius.""" + return [(r, 0), (0, r), (-r, 0), (0, -r)] + + # Build plug as three segments using loft/extrude + # Segment 1: z0 to z1 (bottom chamfer - tapered) + segment1 = ( + cq.Workplane("XY", origin=(x_pos, y_pos, z0)) + .polyline(diamond_points(r0)) + .close() + .workplane(offset=(z1 - z0)) + .polyline(diamond_points(r1)) + .close() + .loft() + ) + + # Segment 2: z1 to z2 (straight section - constant radius) + segment2 = cq.Workplane("XY", origin=(x_pos, y_pos, z1)).polyline(diamond_points(r1)).close().extrude(z2 - z1) + + # Segment 3: z2 to z3 (top chamfer - tapered) + segment3 = ( + cq.Workplane("XY", origin=(x_pos, y_pos, z2)) + .polyline(diamond_points(r2)) + .close() + .workplane(offset=(z3 - z2)) + .polyline(diamond_points(r3)) + .close() + .loft() + ) + + # Union all segments + plug = segment1.union(segment2).union(segment3) + + return plug + + +def generate_inner_corner_fillet( + corner_x: float, + corner_y: float, + arc_center_x: float, + arc_center_y: float, + z0: float, + z1: float, + z2: float, + z3: float, + base_radius: float = 4.0, + n_arc_points: int = 12, +) -> cq.Workplane: + """Generate a fillet plug to fill the gap at inner micro-foot corners. + + At 4-cell meeting points, the cell cutter has a curved boundary where the + micro-foot's inner corner arc is. The channel has straight edges. This + creates a "circular segment" gap between the curved cell cutter edge and + the straight channel edge. + + This fillet fills that gap. It's shaped like a circular segment that tapers + along Z to match the foot profile. + + The gap exists because: + - Cell cutter boundary follows the micro-foot's 4mm corner arc + - Channel boundary is straight (X=corner_x or Y=corner_y) + - The arc curves AWAY from the straight lines, leaving a gap + + Args: + corner_x: X coordinate of the corner (where channel edges meet) + corner_y: Y coordinate of the corner + arc_center_x: X coordinate of the micro-foot arc center + arc_center_y: Y coordinate of the micro-foot arc center + z0: Bottom Z (foot tip, CQ space) + z1: After bottom chamfer + z2: After straight section + z3: Top Z (foot base, CQ space) + base_radius: Arc radius at foot base (Z=z3), typically 4mm + n_arc_points: Number of points to approximate the arc + + Returns: + CadQuery Workplane with fillet solid + """ + import numpy as np + + def fillet_polygon_at_z(z_level: float, z_ref: float = z3) -> List[Tuple[float, float]]: + """Generate fillet polygon at given Z level. + + The fillet shrinks as Z decreases (toward foot tip) because: + - The micro-foot corner radius decreases with the 45° chamfer + - The corner position moves inward + + Args: + z_level: Z level in CQ space + z_ref: Reference Z (foot base, where radius = base_radius) + + Returns: + List of (x, y) polygon vertices + """ + # How much has the foot shrunk at this Z? + shrink = z_ref - z_level # Positive when z_level < z_ref + + # Current arc radius + R = base_radius - shrink + if R <= 0.1: # Minimum radius to avoid degenerate geometry + return None + + # Current corner position (moves inward with shrink) + cx = corner_x - shrink + cy = corner_y - shrink + + # Arc center stays fixed (property of uniform 45° taper) + # Current arc endpoints: + # - Arc start: (arc_center_x + R, arc_center_y) - on the X=cx line + # - Arc end: (arc_center_x, arc_center_y + R) - on the Y=cy line + + arc_start = (arc_center_x + R, arc_center_y) + arc_end = (arc_center_x, arc_center_y + R) + + # Build polygon: corner -> arc_start -> along arc -> arc_end -> back to corner + pts = [(cx, cy)] + pts.append(arc_start) + + # Arc points (from θ=0 to θ=π/2) + for t in np.linspace(0, np.pi / 2, n_arc_points)[1:-1]: + x = arc_center_x + R * np.cos(t) + y = arc_center_y + R * np.sin(t) + pts.append((x, y)) + + pts.append(arc_end) + + return pts + + def make_polygon_wire(pts: List[Tuple[float, float]], z: float) -> cq.Workplane: + """Create a CadQuery wire from polygon points at given Z.""" + wp = cq.Workplane("XY", origin=(0, 0, z)) + return wp.polyline(pts).close() + + # Generate polygons at key Z levels + # We use the same Z breakpoints as the channel profile + z_levels = [z0, z1, z2, z3] + + # Build the fillet as a lofted solid through multiple cross-sections + polygons = [] + for z in z_levels: + pts = fillet_polygon_at_z(z, z_ref=z3) + if pts is None: + continue + polygons.append((z, pts)) + + if len(polygons) < 2: + # Not enough valid cross-sections + return None + + # Create loft from bottom to top + # Start with the bottom polygon + z_bottom, pts_bottom = polygons[0] + fillet = cq.Workplane("XY", origin=(0, 0, z_bottom)).polyline(pts_bottom).close() + + for z_level, pts in polygons[1:]: + fillet = fillet.workplane(offset=(z_level - z_bottom)).polyline(pts).close() + z_bottom = z_level + + fillet = fillet.loft() + + return fillet + + +def detect_inner_corner_positions( + cell_centers: List[Tuple[float, float]], + pitch: float = GRU, + micro_pitch: float = None, +) -> List[Tuple[float, float, float, float]]: + """Detect positions where inner corner fillets are needed. + + At each 4-cell meeting point, there are 4 inner micro-foot corners that + need fillets. This function returns the corner and arc center positions + for each fillet. + + Args: + cell_centers: List of (x, y) cell center coordinates + pitch: 1U pitch (default 42mm) + micro_pitch: Micro-foot pitch (default GRU/4 = 10.5mm) + + Returns: + List of (corner_x, corner_y, arc_center_x, arc_center_y) tuples + """ + if micro_pitch is None: + micro_pitch = pitch / 4 # Default to quarter-grid + + # First find 4-cell intersection points + intersections = detect_four_cell_intersections(cell_centers, pitch) + + fillets = [] + micro_foot_half = (micro_pitch - GR_TOL) / 2 # 5.0mm for div=4 + corner_radius = GR_RAD # 4.0mm + + for ix, iy in intersections: + # At each intersection, there are 4 adjacent micro-feet + # Each has its inner corner pointing toward the intersection + + # The 4 micro-feet are at offsets (±micro_pitch/2, ±micro_pitch/2) from intersection + # Their inner corners are at (ix + dx*0.25, iy + dy*0.25) at Z=5 + # The arc center is always (corner - 4, corner - 4) because the inner corner + # arc curves away from the intersection + + # For dx=-1, dy=-1: corner at (ix - 0.25, iy - 0.25), arc center at (ix - 4.25, iy - 4.25) + # For dx=+1, dy=+1: corner at (ix + 0.25, iy + 0.25), arc center at (ix - 3.75, iy - 3.75) + # Wait, that's wrong. Each foot's arc center is at (foot_center + 5 - 4, ...) = (foot_center + 1) + # which is (corner - 4, corner - 4) from the corner. + + # Actually: arc center is (corner_x + dx*corner_radius, corner_y + dy*corner_radius) + # because for dx=-1: corner_x - 4 is the correct direction (toward -X) + # for dx=+1: corner_x + 4 is the correct direction (toward +X) + for dx, dy in [(-1, -1), (-1, 1), (1, -1), (1, 1)]: + corner_x = ix + dx * GR_TOL / 2 + corner_y = iy + dy * GR_TOL / 2 + # Arc center is in the direction AWAY from the intersection (same sign as dx, dy) + arc_cx = corner_x + dx * corner_radius + arc_cy = corner_y + dy * corner_radius + fillets.append((corner_x, corner_y, arc_cx, arc_cy)) + + return fillets + + +def generate_intercell_channels( + cell_centers: List[Tuple[float, float]], + pitch: float = GRU, + epsilon: float = 0.02, + footprint_bounds: Optional[Tuple[float, float, float, float]] = None, +) -> Optional[cq.Workplane]: + """Generate tapered channel cutters between adjacent 1U cells. + + When cutting micro-feet into a model with existing 1U feet, the cell + cutters (F1 - micro_feet) don't reach the inter-cell gaps. This function + creates TAPERED channels that match the foot profile exactly: + - Narrow (GR_TOL) at foot base (Z=5 in world, Z=-4.75 in CQ space) + - Wide at foot tip (Z=0 in world, Z=+0.25 in CQ space) + + The channel profile mirrors GR_BOX_PROFILE to match the foot chamfers, + ensuring the channel cuts exactly the gap between micro-feet without + cutting into the micro-feet themselves. + + Args: + cell_centers: List of (x, y) cell center coordinates + pitch: 1U pitch (default 42mm) + epsilon: Extension below z=0 (mm) + footprint_bounds: Optional (x_min, y_min, x_max, y_max) to clip channels + to model footprint. If None, channels extend to full + grid extent (may clip corners of outer model). + + Returns: + CadQuery Workplane with channel cutters, or None if no channels needed + """ + if len(cell_centers) < 2: + return None + + # Find unique X and Y coordinates + xs = sorted(set(cx for cx, cy in cell_centers)) + ys = sorted(set(cy for cx, cy in cell_centers)) + + # Channel profile segments (from GR_BOX_PROFILE) + # GR_BOX_PROFILE = ((top_chamf_diag, 45), straight, (bot_chamf_diag, 45)) + # Extract vertical heights from diagonal lengths + top_chamf_diag = GR_BOX_PROFILE[0][0] + top_chamf_vert = top_chamf_diag / SQRT2 # ~2.4mm + straight_vert = GR_BOX_PROFILE[1] # 1.8mm + bot_chamf_diag = GR_BOX_PROFILE[2][0] + bot_chamf_vert = bot_chamf_diag / SQRT2 # ~0.8mm + + # Z levels in CQ space (before flip_z transform) + # z0 = foot base (narrow channel), z3 = foot tip (wide channel) + z0 = -GR_BASE_HEIGHT # -4.75mm + z1 = z0 + top_chamf_vert # ~-2.35mm + z2 = z1 + straight_vert # ~-0.55mm + z3 = z2 + bot_chamf_vert # +0.25mm = GR_BASE_CLR (exact foot base, no epsilon) + + # Base channel width at foot base (narrowest point) + # This is just the GR_TOL gap between adjacent micro-feet + base_width = GR_TOL # 0.5mm + + # Small XY epsilon for channel length overshoot + eps_xy = 0.05 + + channels = None + + def _create_tapered_channel( + width: float, length: float, x_pos: float, y_pos: float, rotated: bool = False + ) -> cq.Workplane: + """Create a single tapered channel matching foot profile. + + The channel must be: + - WIDE at CQ Z=+0.25 (foot BASE in CQ, but becomes foot TIP at world Z=0) + - NARROW at CQ Z=-4.75 (foot TIP in CQ, but becomes foot BASE at world Z=5) + + In CQ space, the foot is WIDER at higher Z (base at top, tip at bottom). + After flip_z, this gets reversed: tip at world Z=0, base at world Z=5. + + The GAP between feet is inverse of foot width: + - At foot TIP (CQ Z=-4.75 → world Z=0): feet are narrow, gap is WIDE + - At foot BASE (CQ Z=+0.25 → world Z=5): feet are wide, gap is NARROW + + So we build the channel starting WIDE at the bottom (CQ Z=-4.75) and + SHRINKING as we go up (using +45 taper), matching the foot profile. + + Args: + width: Narrow width at Z=z3 (top, foot base) + length: Channel length (in the long direction) + x_pos: X position of channel center + y_pos: Y position of channel center + rotated: If True, rotate 90° (for horizontal channels) + + Returns: + CadQuery Workplane with tapered channel + """ + # Calculate wide width at bottom (foot tip in CQ, becomes world Z=0) + # Gap at foot tip = GR_TOL + 2*(top_chamf + bot_chamf) = 0.5 + 2*3.2 = 6.9mm + wide_width = GR_TOL + 2.0 * (top_chamf_vert + bot_chamf_vert) + + # Build channel from Z=z0 (bottom, wide) upward with POSITIVE taper (shrinking) + # The foot profile is: bot_chamf (45°) → straight → top_chamf (45°) + # We reverse this for the channel: start wide, shrink with chamfers + if rotated: + # Horizontal channel: wide in Y at bottom, narrow at top + channel = ( + cq.Workplane("XY", origin=(0, 0, z0)) + .rect(length, wide_width) + .extrude(bot_chamf_vert, taper=45) # Shrink during bottom chamfer + .faces(">Z") + .wires() + .toPending() + .extrude(straight_vert) # Straight section (no taper) + .faces(">Z") + .wires() + .toPending() + .extrude(top_chamf_vert, taper=45) # Shrink during top chamfer + ) + else: + # Vertical channel: wide in X at bottom, narrow at top + channel = ( + cq.Workplane("XY", origin=(0, 0, z0)) + .rect(wide_width, length) + .extrude(bot_chamf_vert, taper=45) # Shrink during bottom chamfer + .faces(">Z") + .wires() + .toPending() + .extrude(straight_vert) # Straight section (no taper) + .faces(">Z") + .wires() + .toPending() + .extrude(top_chamf_vert, taper=45) # Shrink during top chamfer + ) + + # Translate to final position + channel = channel.translate(cq.Vector(x_pos, y_pos, 0)) + return channel + + # Calculate wide channel width at foot tip (for corner inset calculation) + # Gap at foot tip = GR_TOL + 2*(top_chamf + bot_chamf) = 0.5 + 2*3.2 = 6.9mm + wide_width = GR_TOL + 2.0 * (top_chamf_vert + bot_chamf_vert) + + # Corner radius of the model (used to inset clipping bounds) + # The model has rounded corners with radius approximately GR_RAD - GR_TOL/2 + # We need to inset the clipping bounds by the channel's half-width at the bottom + # PLUS the corner radius to avoid extending into the rounded corner region. + corner_radius = GR_RAD - GR_TOL / 2.0 # 3.75mm + channel_half_width_at_tip = wide_width / 2.0 # 3.45mm at Z=0 (foot tip) + # Total inset needed: corner region where channel would extend outside model + corner_inset = corner_radius + channel_half_width_at_tip # ~7.2mm + + # Create vertical channels (between X columns) + for i in range(len(xs) - 1): + x_mid = (xs[i] + xs[i + 1]) / 2.0 + # Find Y extent for this column pair + ys_at_cols = [cy for cx, cy in cell_centers if cx in (xs[i], xs[i + 1])] + if not ys_at_cols: + continue + y_min = min(ys_at_cols) - pitch / 2 - 1 + y_max = max(ys_at_cols) + pitch / 2 + 1 + + # Clip to footprint bounds if provided, with corner inset + if footprint_bounds is not None: + # Standard clipping to footprint bounds + y_min = max(y_min, footprint_bounds[1]) + y_max = min(y_max, footprint_bounds[3]) + + # Additional corner inset: only apply if channel is near X boundary + # This prevents the wide channel from extending into rounded corners + dist_to_x_min = x_mid - footprint_bounds[0] + dist_to_x_max = footprint_bounds[2] - x_mid + if dist_to_x_min < corner_inset or dist_to_x_max < corner_inset: + # Channel is near X boundary - inset Y bounds to avoid corners + y_min = max(y_min, footprint_bounds[1] + corner_inset) + y_max = min(y_max, footprint_bounds[3] - corner_inset) + + if y_max <= y_min: + continue # Channel completely outside footprint + + y_len = y_max - y_min + 2.0 * eps_xy + y_center = (y_min + y_max) / 2.0 + + channel = _create_tapered_channel(base_width, y_len, x_mid, y_center, rotated=False) + + if channels is None: + channels = channel.val() + else: + channels = channels.fuse(channel.val()) + + # Create horizontal channels (between Y rows) + for j in range(len(ys) - 1): + y_mid = (ys[j] + ys[j + 1]) / 2.0 + # Find X extent for this row pair + xs_at_rows = [cx for cx, cy in cell_centers if cy in (ys[j], ys[j + 1])] + if not xs_at_rows: + continue + x_min = min(xs_at_rows) - pitch / 2 - 1 + x_max = max(xs_at_rows) + pitch / 2 + 1 + + # Clip to footprint bounds if provided, with corner inset + if footprint_bounds is not None: + # Standard clipping to footprint bounds + x_min = max(x_min, footprint_bounds[0]) + x_max = min(x_max, footprint_bounds[2]) + + # Additional corner inset: only apply if channel is near Y boundary + # This prevents the wide channel from extending into rounded corners + dist_to_y_min = y_mid - footprint_bounds[1] + dist_to_y_max = footprint_bounds[3] - y_mid + if dist_to_y_min < corner_inset or dist_to_y_max < corner_inset: + # Channel is near Y boundary - inset X bounds to avoid corners + x_min = max(x_min, footprint_bounds[0] + corner_inset) + x_max = min(x_max, footprint_bounds[2] - corner_inset) + + if x_max <= x_min: + continue # Channel completely outside footprint + + x_len = x_max - x_min + 2.0 * eps_xy + x_center = (x_min + x_max) / 2.0 + + channel = _create_tapered_channel(base_width, x_len, x_center, y_mid, rotated=True) + + if channels is None: + channels = channel.val() + else: + channels = channels.fuse(channel.val()) + + # Add corner plugs at 4-cell intersections + # These fill the diamond-shaped gaps where perpendicular channels don't fully overlap + four_cell_intersections = detect_four_cell_intersections(cell_centers, pitch) + + for ix, iy in four_cell_intersections: + plug = generate_corner_plug( + x_pos=ix, + y_pos=iy, + z0=z0, + z1=z1, + z2=z2, + z3=z3, + top_chamf_vert=top_chamf_vert, + bot_chamf_vert=bot_chamf_vert, + straight_vert=straight_vert, + epsilon=eps_xy, # Use same epsilon as channels + ) + + if channels is None: + channels = plug.val() + else: + channels = channels.fuse(plug.val()) + + # NOTE: Inner corner fillets are disabled for now. + # The fillet geometry needs more work to match the actual gap shape. + # The current implementation creates fillets that are too large. + # + # TODO: Investigate the actual gap geometry more carefully. + # The residual is only ~0.12mm³ per corner, but the fillet is ~4mm³. + # + # inner_corner_positions = detect_inner_corner_positions(cell_centers, pitch) + # corner_radius = GR_RAD # 4.0mm at foot base + # + # for corner_x, corner_y, arc_cx, arc_cy in inner_corner_positions: + # fillet = generate_inner_corner_fillet( + # corner_x=corner_x, + # corner_y=corner_y, + # arc_center_x=arc_cx, + # arc_center_y=arc_cy, + # z0=z0, + # z1=z1, + # z2=z2, + # z3=z3, + # base_radius=corner_radius, + # n_arc_points=12, + # ) + # + # if fillet is not None: + # if channels is None: + # channels = fillet.val() + # else: + # channels = channels.fuse(fillet.val()) + + if channels is None: + return None + + return cq.Workplane("XY").newObject([channels]) + + +def generate_boundary_fill( + cell_centers: List[Tuple[float, float]], + pitch: float = GRU, + epsilon: float = 0.02, + footprint_bounds: Optional[Tuple[float, float, float, float]] = None, +) -> Optional[cq.Workplane]: + """Generate boundary fill to cover outer chamfer regions of edge cells. + + The cell cutter is based on the cropped F1 envelope (41.5mm), but the input + mesh's 1U feet have chamfers that extend slightly beyond this boundary at + the outer perimeter. This creates thin residual strips at the outer edges. + + IMPORTANT: Unlike inter-cell channels, the boundary fill should NOT extend + inward into the micro-feet region. At the outer perimeter: + - The outermost micro-foot edge is at the grid boundary (cell_center ± 20.75) + - We only need to cut the tiny sliver of 1U foot material OUTSIDE this boundary + - This is just the GR_TOL/2 = 0.25mm that extends beyond the cropped envelope + + The boundary fill is a thin ring that extends OUTWARD from the grid boundary, + not inward. This prevents cutting into the outer micro-feet. + + Args: + cell_centers: List of (x, y) cell center coordinates + pitch: 1U pitch (default 42mm) + epsilon: Extension below z=0 (mm) + footprint_bounds: Optional (x_min, y_min, x_max, y_max) to constrain the + outer extent of the boundary fill to the model footprint. + If None, extends slightly beyond grid boundary. + + Returns: + CadQuery Workplane with boundary fill, or None if not needed + """ + if not cell_centers: + return None + + # Find grid extents + xs = sorted(set(cx for cx, cy in cell_centers)) + ys = sorted(set(cy for cx, cy in cell_centers)) + + x_min_cell = min(xs) + x_max_cell = max(xs) + y_min_cell = min(ys) + y_max_cell = max(ys) + + # Grid outer boundary (center of outer cells ± half of cropped F1 envelope) + # This is where the outermost micro-feet edges are + # Cropped F1 envelope = pitch - GR_TOL = 41.5mm, so half = 20.75mm + grid_x_min = x_min_cell - (pitch - GR_TOL) / 2 + grid_x_max = x_max_cell + (pitch - GR_TOL) / 2 + grid_y_min = y_min_cell - (pitch - GR_TOL) / 2 + grid_y_max = y_max_cell + (pitch - GR_TOL) / 2 + + # The boundary fill should only extend OUTWARD from the grid boundary + # to cut the small amount of 1U material beyond the micro-feet. + # This is approximately GR_TOL/2 = 0.25mm plus a small margin. + eps_xy = 0.05 + outward_extend = GR_TOL / 2.0 + eps_xy # ~0.3mm outward only + + # Z range matches the cell cutter + z_min_foot = -GR_BASE_HEIGHT # -4.75mm + z_max_foot = GR_BASE_CLR # 0.25mm exactly (no epsilon on top) + fill_height = z_max_foot - z_min_foot + z_center = (z_min_foot + z_max_foot) / 2.0 + + # Outer boundary: extend outward from grid boundary, but clip to footprint + outer_x_min = grid_x_min - outward_extend + outer_x_max = grid_x_max + outward_extend + outer_y_min = grid_y_min - outward_extend + outer_y_max = grid_y_max + outward_extend + + # Clip outer boundary to footprint bounds if provided + if footprint_bounds is not None: + outer_x_min = max(outer_x_min, footprint_bounds[0]) + outer_x_max = min(outer_x_max, footprint_bounds[2]) + outer_y_min = max(outer_y_min, footprint_bounds[1]) + outer_y_max = min(outer_y_max, footprint_bounds[3]) + + # Inner boundary: exactly at the grid boundary (micro-feet edge) + # This means the ring only covers the area OUTSIDE the micro-feet + inner_x_min = grid_x_min + inner_x_max = grid_x_max + inner_y_min = grid_y_min + inner_y_max = grid_y_max + + # Grid center for positioning + grid_center_x = (x_min_cell + x_max_cell) / 2.0 + grid_center_y = (y_min_cell + y_max_cell) / 2.0 + + # Create outer box + outer_width = outer_x_max - outer_x_min + outer_height = outer_y_max - outer_y_min + outer_box = ( + cq.Workplane("XY") + .box(outer_width, outer_height, fill_height) + .translate(cq.Vector(grid_center_x, grid_center_y, z_center)) + ) + + # Create inner cutout at the grid boundary + inner_width = inner_x_max - inner_x_min + inner_height = inner_y_max - inner_y_min + if inner_width > 0 and inner_height > 0: + inner_box = ( + cq.Workplane("XY") + .box(inner_width, inner_height, fill_height + 1) # slightly taller for clean cut + .translate(cq.Vector(grid_center_x, grid_center_y, z_center)) + ) + frame = outer_box.cut(inner_box) + else: + # Grid too small for ring, use solid rect + frame = outer_box + + return frame + + +def generate_grid_cutter_cq( + cell_centers: List[Tuple[float, float]], + micro_divisions: int = 4, + epsilon: float = 0.02, + overshoot: float = 0.0, + wall_cut: float = 0.0, + add_channels: bool = False, + footprint_bounds: Optional[Tuple[float, float, float, float]] = None, +) -> cq.Workplane: + """Generate full grid cutter in CadQuery (one export, one subtract). + + Instances the cell cutter at each detected cell center and unions them + all in CadQuery before export. This minimizes triangle boolean operations. + + Args: + cell_centers: List of (x, y) cell center coordinates + micro_divisions: Number of divisions (2 or 4) + epsilon: Extension below z=0 (mm) + overshoot: Extension beyond F1 boundary to cut outer walls (mm) + wall_cut: Shrink micro-feet to cut outer walls (mm) + add_channels: If True, add inter-cell channel cutters + footprint_bounds: Optional (x_min, y_min, x_max, y_max) to clip channels + and boundary fill to model footprint. Prevents corner + clipping artifacts when cutter extends beyond model. + + Returns: + CadQuery Workplane with complete grid cutter solid + """ + if not cell_centers: + raise ValueError("No cell centers provided") + + # Get the cached cell cutter + cell_cutter = generate_cell_cutter_cq(micro_divisions, epsilon, overshoot, wall_cut) + cell_solid = cell_cutter.val() + + # Instance at each cell center + grid_cutter = None + for cx, cy in cell_centers: + instance = cell_solid.translate(cq.Vector(cx, cy, 0)) + if grid_cutter is None: + grid_cutter = instance + else: + grid_cutter = grid_cutter.fuse(instance) + + # Add inter-cell channels if requested + if add_channels and len(cell_centers) > 1: + channels_cq = generate_intercell_channels(cell_centers, GRU, epsilon, footprint_bounds) + if channels_cq is not None: + grid_cutter = grid_cutter.fuse(channels_cq.val()) + + # Add boundary fill to cover outer chamfer regions + if add_channels: + boundary_fill = generate_boundary_fill(cell_centers, GRU, epsilon, footprint_bounds) + if boundary_fill is not None: + grid_cutter = grid_cutter.fuse(boundary_fill.val()) + + # Z-clipping safeguard: Intersect with half-space to enforce Z <= GR_BASE_CLR + # This catches any epsilon/rounding drift from channels or boundary fill. + # Creates a large bounding box that ends exactly at z=GR_BASE_CLR (foot base plane). + bb = grid_cutter.BoundingBox() + clip_margin = 10.0 # Extra margin in XY to ensure full coverage + clip_box = ( + cq.Workplane("XY") + .box( + bb.xmax - bb.xmin + 2 * clip_margin, + bb.ymax - bb.ymin + 2 * clip_margin, + GR_BASE_CLR - bb.zmin + clip_margin, # From below cutter to exactly GR_BASE_CLR + ) + .translate( + cq.Vector( + (bb.xmin + bb.xmax) / 2, + (bb.ymin + bb.ymax) / 2, + (bb.zmin - clip_margin + GR_BASE_CLR) / 2, # Center the box vertically + ) + ) + ) + grid_cutter = grid_cutter.intersect(clip_box.val()) + + # Assert that Z max is within tolerance of GR_BASE_CLR + final_bb = grid_cutter.BoundingBox() + assert ( + final_bb.zmax <= GR_BASE_CLR + 1e-6 + ), f"Cutter Z max ({final_bb.zmax:.6f}) exceeds GR_BASE_CLR ({GR_BASE_CLR})" + + return cq.Workplane("XY").newObject([grid_cutter]) + + +def generate_grid_cutter_meshes( + cell_centers: List[Tuple[float, float]], + micro_divisions: int = 4, + epsilon: float = 0.02, + tol: float = 0.01, + ang_tol: float = 0.1, + flip_z: bool = True, + overshoot: float = 0.0, + wall_cut: float = 0.0, + add_channels: bool = False, +) -> List[trimesh.Trimesh]: + """Generate grid cutter as list of individual cell cutter meshes. + + This approach keeps each cell cutter as a separate watertight mesh, + which works better with manifold boolean operations that can handle + multiple tool meshes. + + Args: + cell_centers: List of (x, y) cell center coordinates + micro_divisions: Number of divisions (2 or 4) + epsilon: Extension below z=0 (mm) + tol: STL export tolerance + ang_tol: STL export angular tolerance + flip_z: If True, flip Z coordinates so foot points upward (z=0 to z~5) + matching typical STL exports. Default True. + overshoot: Extension beyond F1 boundary to cut outer walls (mm) + wall_cut: Shrink micro-feet to cut outer walls (mm) + add_channels: If True, add inter-cell channel cutters to cut material + between adjacent cells. Only needed for solid-walled 1U + boxes. Default False. + + Returns: + List of trimesh.Trimesh meshes, one per cell + """ + if not cell_centers: + raise ValueError("No cell centers provided") + + # Generate the template cell cutter mesh (watertight) + cell_cutter_cq = generate_cell_cutter_cq(micro_divisions, epsilon, overshoot, wall_cut) + cell_mesh = cq_to_trimesh(cell_cutter_cq, tol=tol, ang_tol=ang_tol) + + # Align Z if requested (default) to match typical STL orientation + # The CQ cutter has foot tip at z≈-4.75, base at z≈0.25 (after mirror in CQ) + # STL exports typically have foot tip at z=0, base at z≈5 + # We just need to TRANSLATE upward, NOT negate/reflect Z + # This preserves the correct taper direction (narrow at bottom, wide at top) + cell_z_min = None + if flip_z: + # Shift so the minimum Z (foot tip) is at z=-epsilon + # This ensures the cutter extends BELOW the model's z=0 bottom plane, + # avoiding coplanar faces that cause non-manifold edges + cell_z_min = cell_mesh.vertices[:, 2].min() + cell_mesh.vertices[:, 2] -= cell_z_min # Now tip is at z=0 + cell_mesh.vertices[:, 2] -= epsilon # Now tip is at z=-epsilon + # No invert() needed since we didn't negate/reflect + + # Instance at each cell center + meshes = [] + for cx, cy in cell_centers: + instance = cell_mesh.copy() + instance.apply_translation([cx, cy, 0]) + meshes.append(instance) + + # Add inter-cell channel cutters if requested and there are multiple cells + # These cut away the remaining 1U foot wall material between adjacent cells + # Only needed for solid-walled 1U boxes, not for natively-generated microfinity boxes + if add_channels and len(cell_centers) > 1: + channels_cq = generate_intercell_channels(cell_centers, GRU, epsilon) + if channels_cq is not None: + channels_mesh = cq_to_trimesh(channels_cq, tol=tol, ang_tol=ang_tol) + if flip_z: + # Use the SAME z_min as cell cutters for consistent Z alignment + # This ensures channels end at the same Z level as the cell cutters + # (i.e., at Z=5.0 in world coordinates, matching the foot base) + if cell_z_min is not None: + channels_mesh.vertices[:, 2] -= cell_z_min + channels_mesh.vertices[:, 2] -= epsilon + meshes.append(channels_mesh) + + return meshes + + +def apply_bottom_epsilon_preserve_top(mesh: trimesh.Trimesh, epsilon: float) -> None: + """Apply coplanar avoidance by pushing bottom down while preserving top. + + Maps Z range [zmin, zmax] → [zmin - epsilon, zmax] using affine transform. + This cannot create internal faces because it only moves vertices. + + The transform is: + z_new = -epsilon + (z - zmin) * (h + epsilon) / h + + Where h = zmax - zmin. This keeps: + - z=zmin → -epsilon (bottom gets pushed down) + - z=zmax → h (top stays exactly where it was after normalization) + + Args: + mesh: Trimesh to modify in-place + epsilon: Amount to push bottom below Z=0 + """ + if epsilon <= 0: + return + + zmin = float(mesh.vertices[:, 2].min()) + zmax = float(mesh.vertices[:, 2].max()) + h = zmax - zmin + + if h <= 1e-9: + raise ValueError(f"Degenerate cutter height: h={h}") + + # Normalize so bottom is at 0, top at h + mesh.vertices[:, 2] -= zmin + + # Affine map [0, h] -> [-epsilon, h] + # z_new = -epsilon + z * (h + epsilon) / h + scale = (h + epsilon) / h + mesh.vertices[:, 2] = -epsilon + mesh.vertices[:, 2] * scale + + +def generate_grid_cutter_mesh( + cell_centers: List[Tuple[float, float]], + micro_divisions: int = 4, + epsilon: float = 0.02, + tol: float = 0.01, + ang_tol: float = 0.1, + flip_z: bool = True, + overshoot: float = 0.0, + wall_cut: float = 0.0, + add_channels: bool = False, + fast_mode: bool = False, + footprint_bounds: Optional[Tuple[float, float, float, float]] = None, +) -> trimesh.Trimesh: + """Generate grid cutter as a single mesh. + + ALWAYS uses CadQuery fusion for robustness (eliminates boolean slivers). + The fast_mode option exists for debugging but is not recommended for + production use as it may leave slivers or asymmetric artifacts. + + The top of the cutter is extended by epsilon before the Z-shift, ensuring + the final cutter top is at exactly Z=5.0 (not truncated by epsilon). + + Args: + cell_centers: List of (x, y) cell center coordinates + micro_divisions: Number of divisions (2 or 4) + epsilon: Extension below z=0 (mm) + tol: STL export tolerance + ang_tol: STL export angular tolerance + flip_z: If True, flip Z so foot points upward (z=0 to z~5). Default True. + overshoot: Extension beyond F1 boundary to cut outer walls (mm) + wall_cut: Shrink micro-feet to cut outer walls (mm) + add_channels: If True, add inter-cell channel cutters. Default False. + fast_mode: If True, use mesh concatenation instead of CQ fusion (debug only). + WARNING: May leave slivers/asymmetry due to non-fused shells. + footprint_bounds: Optional (x_min, y_min, x_max, y_max) to clip channels + and boundary fill to model footprint. + + Returns: + trimesh.Trimesh with all cell cutters + """ + # Fast mode: use mesh concatenation (debug only, may have slivers) + if fast_mode: + import warnings + + warnings.warn( + "fast_mode=True may leave slivers/asymmetry due to non-fused shells. " "Use only for debugging.", + UserWarning, + ) + meshes = generate_grid_cutter_meshes( + cell_centers, micro_divisions, epsilon, tol, ang_tol, flip_z, overshoot, wall_cut, add_channels=add_channels + ) + if len(meshes) == 1: + return meshes[0] + return trimesh.util.concatenate(meshes) + + # Default: Always use CadQuery fusion for robustness + # This eliminates boolean slivers from overlapping mesh shells + cq_cutter = generate_grid_cutter_cq( + cell_centers, + micro_divisions, + epsilon, + overshoot, + wall_cut, + add_channels=add_channels, + footprint_bounds=footprint_bounds, + ) + + # Convert to trimesh (no box-fusing operations that create internal faces) + mesh = cq_to_trimesh(cq_cutter, tol=tol, ang_tol=ang_tol) + + if flip_z: + # Apply coplanar avoidance as a pure affine transform. + # This pushes the bottom down by epsilon while preserving the top, + # WITHOUT creating internal coincident faces (which caused "stacked + # Z sheets" artifacts like 4.98/5.00/5.02). + apply_bottom_epsilon_preserve_top(mesh, epsilon) + + return mesh + + +# ----------------------------------------------------------------------------- +# Conversion to trimesh +# ----------------------------------------------------------------------------- +def cq_to_trimesh( + cq_obj: cq.Workplane, + tol: float = 0.01, + ang_tol: float = 0.1, +) -> trimesh.Trimesh: + """Convert CadQuery Workplane to trimesh with proper cleanup. + + Exports to STL via temp file, then loads with trimesh. + Temp file is automatically cleaned up. + + Args: + cq_obj: CadQuery Workplane to convert + tol: Linear mesh tolerance (mm) + ang_tol: Angular mesh tolerance (radians) + + Returns: + trimesh.Trimesh mesh + """ + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = os.path.join(tmpdir, "cutter.stl") + cq.exporters.export( + cq_obj, + stl_path, + exportType="STL", + tolerance=tol, + angularTolerance=ang_tol, + ) + mesh = trimesh.load(stl_path) + + return mesh + + +# ----------------------------------------------------------------------------- +# Validation helpers +# ----------------------------------------------------------------------------- +def get_cell_cutter_volume(micro_divisions: int = 4) -> float: + """Get the volume of a single cell cutter from CadQuery solid. + + This is the authoritative volume (not dependent on tessellation). + + Args: + micro_divisions: Number of divisions (2 or 4) + + Returns: + Volume in mm³ + """ + cutter = generate_cell_cutter_cq(micro_divisions, epsilon=0) + return cutter.val().Volume() + + +def get_1u_foot_volume() -> float: + """Get the volume of a 1U foot from CadQuery solid. + + Returns: + Volume in mm³ + """ + foot = generate_1u_foot_cq() + return foot.val().Volume() + + +def get_micro_foot_volume(micro_divisions: int = 4) -> float: + """Get the volume of a micro foot from CadQuery solid. + + Args: + micro_divisions: Number of divisions (2 or 4) + + Returns: + Volume in mm³ + """ + foot = generate_micro_foot_cq(micro_divisions) + return foot.val().Volume() diff --git a/meshcutter/core/cq_utils.py b/meshcutter/core/cq_utils.py new file mode 100644 index 0000000..17d2ab7 --- /dev/null +++ b/meshcutter/core/cq_utils.py @@ -0,0 +1,82 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.cq_utils - CadQuery utilities and version compatibility +# +# This module provides CadQuery-specific utilities including version detection +# and the extrude_profile function used for generating foot geometry. + +from __future__ import annotations + +import math +from typing import List, Tuple, Union + +import cadquery as cq + +from meshcutter.core.constants import SQRT2 + + +# ----------------------------------------------------------------------------- +# CadQuery version detection +# ----------------------------------------------------------------------------- +# CQ versions < 2.4.0 typically require zlen correction for tapered extrusions, +# i.e., scaling the vertical extrusion extent by 1/cos(taper). +# This is computed once at module load time. + +ZLEN_FIX: bool = True +_test_result = cq.Workplane("XY").rect(2, 2).extrude(1, taper=45) +_test_bb = _test_result.vals()[0].BoundingBox() +if abs(_test_bb.zlen - 1.0) < 1e-3: + ZLEN_FIX = False + + +# ----------------------------------------------------------------------------- +# Profile extrusion +# ----------------------------------------------------------------------------- + + +def extrude_profile( + sketch, + profile: List[Union[float, Tuple[float, float]]], + workplane: str = "XY", + angle: float = None, +) -> cq.Workplane: + """Extrude a sketch through a multi-segment profile with optional tapers. + + This mirrors microfinity.core.base.GridfinityObject.extrude_profile() exactly + to ensure geometric consistency. + + The profile is a list of segments, where each segment is either: + - A float: straight extrusion of that height + - A tuple (height, taper_angle): tapered extrusion + + Args: + sketch: CadQuery sketch to extrude + profile: List of profile segments + workplane: Workplane orientation (default "XY") + angle: Override angle for taper calculations + + Returns: + CadQuery Workplane with the extruded geometry + """ + taper = profile[0][1] if isinstance(profile[0], (list, tuple)) else 0 + zlen = profile[0][0] if isinstance(profile[0], (list, tuple)) else profile[0] + + if abs(taper) > 0: + if angle is None: + zlen = zlen if ZLEN_FIX else zlen / SQRT2 + else: + zlen = zlen / math.cos(math.radians(taper)) if ZLEN_FIX else zlen + + r = cq.Workplane(workplane).placeSketch(sketch).extrude(zlen, taper=taper) + + for level in profile[1:]: + if isinstance(level, (tuple, list)): + if angle is None: + zlen = level[0] if ZLEN_FIX else level[0] / SQRT2 + else: + zlen = level[0] / math.cos(math.radians(level[1])) if ZLEN_FIX else level[0] + r = r.faces(">Z").wires().toPending().extrude(zlen, taper=level[1]) + else: + r = r.faces(">Z").wires().toPending().extrude(level) + + return r diff --git a/meshcutter/core/detection.py b/meshcutter/core/detection.py index 257dc50..e2b20e4 100644 --- a/meshcutter/core/detection.py +++ b/meshcutter/core/detection.py @@ -15,6 +15,113 @@ from shapely.validation import make_valid +def normalize(v: np.ndarray) -> np.ndarray: + """Normalize a vector, handling near-zero magnitude.""" + n = np.linalg.norm(v) + return v / (n + 1e-12) + + +def gram_schmidt_xy(x: np.ndarray, y: np.ndarray) -> tuple: + """Orthonormalize X and Y vectors, computing Z = X cross Y. + + Ensures right-handed coordinate system. + + Args: + x: Desired X axis direction + y: Desired Y axis direction + + Returns: + Tuple of (x, y, z) orthonormal vectors forming right-handed basis + """ + x = normalize(x) + y = y - np.dot(y, x) * x # Remove component along x + y = normalize(y) + z = np.cross(x, y) + z = normalize(z) + + # Enforce right-handed system + if np.linalg.det(np.stack([x, y, z], axis=1)) < 0: + y = -y + z = np.cross(x, y) + z = normalize(z) + + return x, y, z + + +def snap_to_cardinal(v: np.ndarray, deg: float = 5.0) -> np.ndarray: + """Snap a vector to the nearest cardinal axis if within tolerance. + + Only snaps if the vector is within `deg` degrees of a cardinal axis. + Otherwise returns the input vector unchanged. + + Args: + v: Input unit vector + deg: Tolerance in degrees (default 5.0) + + Returns: + Snapped vector (unit cardinal) or original vector + """ + v = normalize(v) + axes = np.eye(3) + dots = axes @ v # Dot product with each cardinal axis + + # Find the cardinal axis with largest alignment + i = int(np.argmax(np.abs(dots))) + + # Check if close enough to snap + if np.abs(dots[i]) >= np.cos(np.deg2rad(deg)): + out = np.zeros(3) + out[i] = 1.0 if dots[i] >= 0 else -1.0 + return out + + return v # Not close enough; don't snap + + +def stabilize_frame( + x: np.ndarray, + y: np.ndarray, + snap_deg: float = 5.0, +) -> tuple: + """Stabilize a frame by orthonormalizing and optionally snapping to cardinal axes. + + This eliminates tiny rotation drift from mesh noise that causes asymmetric + cutting artifacts (e.g., "Y- clips on X+ row, Y+ clips on X- row"). + + Process: + 1. Gram-Schmidt orthonormalize X and Y + 2. Snap X to nearest cardinal if within tolerance + 3. Snap Y to nearest cardinal if within tolerance + 4. Re-orthonormalize to ensure orthogonality after snapping + + Args: + x: X axis vector + y: Y axis vector + snap_deg: Snap tolerance in degrees (default 5.0) + + Returns: + Tuple of (x, y, z) stabilized orthonormal vectors + """ + # First, make orthonormal + x, y, z = gram_schmidt_xy(x, y) + + # Snap to cardinals if close + x2 = snap_to_cardinal(x, deg=snap_deg) + y2 = snap_to_cardinal(y, deg=snap_deg) + + # Re-orthonormalize after snapping (snapping may break orthogonality) + x = normalize(x2) + y = y2 - np.dot(y2, x) * x + y = normalize(y) + z = normalize(np.cross(x, y)) + + # Ensure right-handed + if np.linalg.det(np.stack([x, y, z], axis=1)) < 0: + y = -y + z = normalize(np.cross(x, y)) + + return x, y, z + + def compute_dominant_edge_angle( footprint: Union[Polygon, MultiPolygon], bin_size_deg: float = 0.5, @@ -137,6 +244,9 @@ 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. + After rotation, the frame is stabilized by snapping near-cardinal axes + to eliminate any remaining drift. + Args: frame: The original BottomFrame yaw_angle: Angle in radians to rotate the X-Y plane @@ -155,12 +265,11 @@ def apply_yaw_to_frame(frame: "BottomFrame", yaw_angle: float) -> "BottomFrame": 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) + # Stabilize by snapping near-cardinal axes (eliminates remaining drift) + x_new, y_new, z_new = stabilize_frame(x_new, y_new, snap_deg=5.0) # Build new rotation matrix - new_rotation = np.column_stack([x_new, y_new, frame.up_normal]) + new_rotation = np.column_stack([x_new, y_new, z_new]) return BottomFrame( origin=frame.origin.copy(), @@ -289,7 +398,8 @@ def detect_bottom_frame( origin = centroids[bottom_mask].mean(axis=0) origin[2] = z_min # Snap to actual z_min - # Identity rotation for Z-aligned mesh + # Identity rotation for Z-aligned mesh (already axis-aligned) + # Stabilization is trivially satisfied for identity rotation = np.eye(3) else: @@ -376,6 +486,11 @@ def detect_bottom_frame( if np.dot(z_check, up_normal) < 0: y_axis = -y_axis + # Stabilize the frame by snapping near-cardinal axes + # This eliminates tiny rotation drift from mesh noise that causes + # asymmetric cutting artifacts (e.g., "Y- clips on X+ row") + x_axis, y_axis, up_normal = stabilize_frame(x_axis, y_axis, snap_deg=5.0) + # Build rotation matrix rotation = np.column_stack([x_axis, y_axis, up_normal]) diff --git a/meshcutter/core/foot_cutter.py b/meshcutter/core/foot_cutter.py new file mode 100644 index 0000000..189de26 --- /dev/null +++ b/meshcutter/core/foot_cutter.py @@ -0,0 +1,520 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.foot_cutter - Gridfinity micro-foot complement cutter generation +# +# Generates cutters using the complement approach: Cutter = F1 - Fm +# where F1 is the 1U foot volume and Fm is the union of micro-feet. +# +# Instead of computing this via 3D booleans, we construct the geometry +# analytically as a lofted polygon-with-holes, which is faster and more stable. + +from __future__ import annotations + +from typing import List, Tuple, Optional, Union + +import numpy as np +import trimesh +from shapely.geometry import Polygon, MultiPolygon + +from meshcutter.core.detection import BottomFrame +from meshcutter.core.geometry import sample_rounded_rect +from meshcutter.core.profile import ( + GR_BASE_HEIGHT, + GR_BOX_CHAMF_H, + GR_STR_H, + GR_BASE_CLR, +) + +# Gridfinity constants (from microfinity.core.constants) +GRU = 42.0 # 1U pitch (mm) +GR_TOL = 0.5 # Clearance between feet (mm) +GR_RAD = 4.0 # Nominal exterior fillet radius (mm) + + +def compute_foot_size(pitch: float) -> float: + """Compute foot size from pitch: size = pitch - GR_TOL.""" + return pitch - GR_TOL + + +def compute_corner_radius(foot_size: float) -> float: + """Compute corner radius for a foot, clamped to valid range. + + Uses: r = min(GR_RAD + GR_BASE_CLR, foot_size/2 - 0.05) + """ + r = min(GR_RAD + GR_BASE_CLR, foot_size / 2.0 - 0.05) + return max(r, 0.2) # Minimum radius to avoid degenerate geometry + + +def get_profile_z_levels(epsilon: float = 0.02) -> List[float]: + """Get the Z levels for the Gridfinity box profile. + + Only 4-5 levels needed since profile is piecewise-linear: + - z0: -epsilon (coplanar avoidance) + - z1: 0.0 (floor contact) + - z2: bottom chamfer end + - z3: straight section end + - z4: top (full height) + """ + z0 = -epsilon + z1 = 0.0 + z2 = GR_BOX_CHAMF_H + z3 = GR_BOX_CHAMF_H + GR_STR_H + z4 = GR_BASE_HEIGHT + + return [z0, z1, z2, z3, z4] + + +def compute_inset_at_z(z: float) -> float: + """Compute the profile inset at height z. + + The Gridfinity foot profile in world coordinates (z=0 at foot tip): + - z=0: foot tip (narrow), maximum inset + - z=GR_BASE_HEIGHT: foot base (wide), zero inset + + Profile segments (from base to tip): + - [GR_BASE_HEIGHT - top_chamf, GR_BASE_HEIGHT]: top 45° chamfer (inset grows toward tip) + - [GR_BOX_CHAMF_H, GR_BASE_HEIGHT - top_chamf]: straight section + - [0, GR_BOX_CHAMF_H]: bottom 45° chamfer (inset continues to tip) + + The max inset at z=0 is: GR_BOX_CHAMF_H + (top_chamf_height) + where top_chamf_height = GR_BASE_HEIGHT - GR_BOX_CHAMF_H - GR_STR_H + """ + if z <= 0: + # Below foot: use max inset + z = 0.0 + if z >= GR_BASE_HEIGHT: + # At or above foot base: zero inset + return 0.0 + + # Profile heights + z_bot_chamf_end = GR_BOX_CHAMF_H # 0.8mm + z_str_end = GR_BOX_CHAMF_H + GR_STR_H # 2.6mm + z_top = GR_BASE_HEIGHT # 4.75mm + + # Top chamfer height + top_chamf_h = z_top - z_str_end # 2.15mm + + if z >= z_str_end: + # Top chamfer region: inset grows linearly from 0 at z_top to GR_BOX_CHAMF_H at z_str_end + # But wait - this doesn't match! Let me reconsider. + # At z=z_top (4.75): inset = 0 (foot base, full size) + # At z=z_str_end (2.6): inset = top_chamf_h = 2.15 + return z_top - z # Linear from 0 at top to 2.15 at z_str_end + elif z >= z_bot_chamf_end: + # Straight section: constant inset = top_chamf_h + return top_chamf_h + else: + # Bottom chamfer: inset continues growing + # At z=z_bot_chamf_end (0.8): inset = top_chamf_h = 2.15 + # At z=0: inset = top_chamf_h + z_bot_chamf_end = 2.15 + 0.8 = 2.95 + return top_chamf_h + (z_bot_chamf_end - z) + + +def micro_foot_offsets(micro_divisions: int, pitch: float = GRU) -> List[Tuple[float, float]]: + """Return micro-foot center offsets relative to 1U cell center. + + For micro_divisions=4, pitch=42: + - micro_pitch = 10.5 + - offsets at: [-15.75, -5.25, 5.25, 15.75] in each axis + - 16 micro-feet total + """ + if micro_divisions <= 1: + return [(0.0, 0.0)] # Single foot at center + + micro_pitch = pitch / micro_divisions + offsets = [] + for i in range(micro_divisions): + for j in range(micro_divisions): + x = (i - (micro_divisions - 1) / 2.0) * micro_pitch + y = (j - (micro_divisions - 1) / 2.0) * micro_pitch + offsets.append((x, y)) + return offsets + + +def cell_cutter_cross_section_shapely( + z: float, + pitch: float, + micro_divisions: int, + points_per_corner: int = 8, +) -> Union[Polygon, MultiPolygon]: + """Compute the cutter cross-section at height z using Shapely boolean. + + The cross-section is F1 - Fm (1U foot minus micro-feet union). + Uses Shapely for robust boolean operations that handle edge cases + where micro-feet touch the outer boundary. + + Args: + z: Height from bottom (mm) + pitch: 1U pitch (default 42mm) + micro_divisions: Number of divisions (2 or 4) + points_per_corner: Vertices per corner arc + + Returns: + Shapely Polygon representing the cutter cross-section + """ + from shapely.geometry import Polygon as ShapelyPolygon + from shapely.ops import unary_union + + inset = compute_inset_at_z(z) + + # 1U foot dimensions at this Z + foot_1u_size = compute_foot_size(pitch) + foot_1u_size_at_z = foot_1u_size - 2 * inset + radius_1u = compute_corner_radius(foot_1u_size) + radius_1u_at_z = max(radius_1u - inset, 0.0) + + # Build 1U foot polygon + outer_verts = sample_rounded_rect( + width=foot_1u_size_at_z, + height=foot_1u_size_at_z, + radius=radius_1u_at_z, + points_per_corner=points_per_corner, + center=(0.0, 0.0), + ) + outer_ring = list(map(tuple, outer_verts)) + if outer_ring[0] != outer_ring[-1]: + outer_ring.append(outer_ring[0]) + f1 = ShapelyPolygon(outer_ring) + + # Micro-foot dimensions at this Z + # Note: Micro-feet use different sizing than 1U feet: + # - Size: pitch - GR_TOL - 2*GR_BASE_CLR (additional clearance between micro-feet) + # - Radius: GR_RAD - GR_BASE_CLR (smaller radius than 1U feet) + micro_pitch = pitch / micro_divisions + micro_foot_size = compute_foot_size(micro_pitch) - 2 * GR_BASE_CLR + micro_foot_size_at_z = micro_foot_size - 2 * inset + # Micro-foot radius uses GR_RAD - GR_BASE_CLR instead of GR_RAD + GR_BASE_CLR + radius_micro_base = GR_RAD - GR_BASE_CLR # 3.75mm + radius_micro = min(radius_micro_base, micro_foot_size / 2.0 - 0.05) + radius_micro = max(radius_micro, 0.2) # Minimum radius + radius_micro_at_z = max(radius_micro - inset, 0.0) + + # Build micro-feet polygons + micro_feet = [] + for ox, oy in micro_foot_offsets(micro_divisions, pitch): + if micro_foot_size_at_z > 0.1: # Skip if foot collapsed + verts = sample_rounded_rect( + width=micro_foot_size_at_z, + height=micro_foot_size_at_z, + radius=radius_micro_at_z, + points_per_corner=points_per_corner, + center=(ox, oy), + ) + ring = list(map(tuple, verts)) + if ring[0] != ring[-1]: + ring.append(ring[0]) + micro_feet.append(ShapelyPolygon(ring)) + + # Compute F1 - Fm + if micro_feet: + fm = unary_union(micro_feet) + cutter_shape = f1.difference(fm) + else: + cutter_shape = f1 + + return cutter_shape # type: ignore[return-value] + + +def generate_cell_cutter( + pitch: float = GRU, + micro_divisions: int = 4, + epsilon: float = 0.02, + points_per_corner: int = 8, +) -> Optional[trimesh.Trimesh]: + """Generate cutter for a single 1U cell using CadQuery. + + Uses CadQuery-based generation for proper 45-degree chamfers that + exactly match microfinity's foot geometry. + + Args: + pitch: 1U pitch (default 42mm) - currently only 42mm is supported + micro_divisions: Number of divisions (1, 2, or 4) + epsilon: Coplanar avoidance offset (mm) + points_per_corner: Ignored (kept for API compatibility) + + Returns: + trimesh.Trimesh for the cell cutter, or None if micro_divisions=1 + """ + if micro_divisions <= 1: + return None # No cutting needed + + if pitch != GRU: + raise ValueError(f"Only pitch={GRU} is supported, got {pitch}") + + from meshcutter.core.cq_cutter import generate_cell_cutter_cq, cq_to_trimesh + + cq_cutter = generate_cell_cutter_cq(micro_divisions, epsilon) + return cq_to_trimesh(cq_cutter) + + +def detect_cell_centers_from_footprint( + footprint: Union[Polygon, MultiPolygon], + pitch: float = GRU, +) -> List[Tuple[float, float]]: + """Detect 1U cell centers from footprint polygon. + + Uses Gridfinity convention: overall_dim ≈ N * pitch - GR_TOL + So: N = round((dim + GR_TOL) / pitch) + + Assumes axis-aligned rectangular Gridfinity footprint. + + Args: + footprint: Shapely polygon of bottom footprint + pitch: 1U pitch (default 42mm) + + Returns: + List of (x, y) cell center coordinates + """ + bounds = footprint.bounds # (minx, miny, maxx, maxy) + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + + # Gridfinity convention: overall_dim ≈ N * pitch - GR_TOL + cells_x = int(round((width + GR_TOL) / pitch)) + cells_y = int(round((height + GR_TOL) / pitch)) + + # Ensure at least 1 cell in each dimension + cells_x = max(1, cells_x) + cells_y = max(1, cells_y) + + # Centers arranged symmetrically within footprint + cx = (bounds[0] + bounds[2]) / 2.0 + cy = (bounds[1] + bounds[3]) / 2.0 + + centers = [] + for i in range(cells_x): + for j in range(cells_y): + x = cx + (i - (cells_x - 1) / 2.0) * pitch + y = cy + (j - (cells_y - 1) / 2.0) * pitch + centers.append((x, y)) + + return centers + + +def detect_cell_centers( + footprint: Union[Polygon, MultiPolygon], + pitch: float = GRU, + mesh_bounds: Optional[np.ndarray] = None, +) -> List[Tuple[float, float]]: + """Detect 1U cell centers from footprint, optionally using mesh bounds for sizing. + + For Gridfinity models, the footprint detected at Z=0 may be smaller than + the actual foot base due to chamfers. If mesh_bounds is provided, we use + the XY extent of the mesh bounds for calculating the number of cells. + + IMPORTANT: The returned centers are in LOCAL FRAME coordinates (same as footprint). + The center position comes from the footprint (which is in local coords), while + the dimensions can come from mesh_bounds (for accurate cell count). + + Args: + footprint: Shapely polygon of bottom footprint (LOCAL frame coordinates) + pitch: 1U pitch (default 42mm) + mesh_bounds: Optional mesh bounds array [[minx,miny,minz], [maxx,maxy,maxz]] + in WORLD coordinates. Used only for determining cell count, + not for center positioning. + + Returns: + List of (x, y) cell center coordinates in LOCAL frame + """ + # Always use footprint center (local frame coordinates) + # This ensures cell centers are in the correct coordinate system for + # transformation to world coordinates via frame.to_transform_matrix() + fp_bounds = footprint.bounds + cx = (fp_bounds[0] + fp_bounds[2]) / 2.0 + cy = (fp_bounds[1] + fp_bounds[3]) / 2.0 + + if mesh_bounds is not None: + # Use mesh bounds for dimensions (captures full foot size) + # This gives accurate cell count even if footprint is smaller due to chamfers + width = mesh_bounds[1, 0] - mesh_bounds[0, 0] + height = mesh_bounds[1, 1] - mesh_bounds[0, 1] + else: + # Use footprint bounds for dimensions + width = fp_bounds[2] - fp_bounds[0] + height = fp_bounds[3] - fp_bounds[1] + + # Gridfinity convention: overall_dim ≈ N * pitch - GR_TOL + cells_x = int(round((width + GR_TOL) / pitch)) + cells_y = int(round((height + GR_TOL) / pitch)) + + # Ensure at least 1 cell in each dimension + cells_x = max(1, cells_x) + cells_y = max(1, cells_y) + + centers = [] + for i in range(cells_x): + for j in range(cells_y): + x = cx + (i - (cells_x - 1) / 2.0) * pitch + y = cy + (j - (cells_y - 1) / 2.0) * pitch + centers.append((x, y)) + + return centers + + +def convert_to_micro_feet( + input_mesh: trimesh.Trimesh, + micro_divisions: int = 4, + pitch: float = GRU, + use_replace_base: bool = True, +) -> Optional[trimesh.Trimesh]: + """Convert a 1U Gridfinity box to micro-divided feet. + + This is the main high-level API for converting standard Gridfinity boxes + with 1U (42mm) feet into micro-divided versions with smaller feet. + + Two approaches are available: + + 1. Replace Base (default, recommended): + - Trims the input mesh above the foot region (z >= 5mm) + - Generates fresh micro-feet base using microfinity's construction path + - Unions the trimmed top with the new base + - Produces EXACT geometric match with natively-generated micro boxes + - Limitation: Removes everything below z=5mm (magnet holes, screw holes, etc.) + + 2. Boolean Subtraction (legacy): + - Generates a cutter shape (1U foot - micro feet union) + - Subtracts the cutter from the input mesh + - Preserves features below z=5mm (holes, text, etc.) + - May have small geometric residuals (~50mm³) due to mesh boolean artifacts + + Args: + input_mesh: Input mesh with standard 1U feet + micro_divisions: Number of divisions per 1U (2 or 4) + pitch: 1U pitch (42mm) + use_replace_base: If True (default), use the replace-base approach. + If False, use legacy boolean subtraction. + + Returns: + Output mesh with micro-feet, or None if conversion fails + """ + from meshcutter.core.detection import detect_aligned_frame + from meshcutter.core.boolean import boolean_difference + + if micro_divisions <= 1: + return input_mesh.copy() # No conversion needed + + if use_replace_base: + # Use the new replace-base pipeline for exact geometric match + from meshcutter.core.replace_base import replace_base_pipeline + + frame, footprint = detect_aligned_frame(input_mesh, force_z_up=True) + + return replace_base_pipeline( + input_mesh=input_mesh, + footprint=footprint, + frame=frame, + micro_divisions=micro_divisions, + pitch=pitch, + mesh_bounds=input_mesh.bounds, + ) + else: + # Use legacy boolean subtraction (preserves holes but has residuals) + frame, footprint = detect_aligned_frame(input_mesh, force_z_up=True) + + cutter = generate_microgrid_cutter( + footprint=footprint, + frame=frame, + micro_divisions=micro_divisions, + pitch=pitch, + mesh_bounds=input_mesh.bounds, + add_channels=True, + ) + + if cutter is None: + return input_mesh.copy() + + result = boolean_difference(part=input_mesh, cutter=cutter) + return result.mesh + + +def generate_microgrid_cutter( + footprint: Union[Polygon, MultiPolygon], + frame: BottomFrame, + micro_divisions: int = 4, + pitch: float = GRU, + epsilon: float = 0.02, + points_per_corner: int = 8, + mesh_bounds: Optional[np.ndarray] = None, + overshoot: float = 0.0, + wall_cut: float = 0.0, + add_channels: bool = False, +) -> Optional[trimesh.Trimesh]: + """Generate complete cutter for all 1U cells using CadQuery. + + Pipeline: + 1. Detect 1U cell centers from footprint/mesh bounds + 2. Build full grid cutter in CadQuery (one solid) + 3. Export to trimesh once + 4. Transform to world coordinates + + This approach minimizes triangle boolean operations by doing all + unions in CadQuery (B-rep) before tessellation. + + Args: + footprint: Shapely polygon of bottom footprint (local coords) + frame: BottomFrame for world transform + micro_divisions: Number of divisions (1, 2, or 4) + pitch: 1U pitch (default 42mm) + epsilon: Coplanar avoidance offset (mm) + points_per_corner: Ignored (kept for API compatibility) + mesh_bounds: Optional original mesh bounds for accurate cell detection + overshoot: Extension beyond F1 boundary to cut outer walls (mm). + Set to 1.0-2.0mm to cut through outer foot walls. + wall_cut: Shrink micro-feet to cut outer walls (mm). + Set to 0.5-1.0 to create cutter overlap with model edge. + add_channels: If True, add inter-cell channel cutters to cut material + between adjacent cells. Only needed for solid-walled 1U + boxes. Default False. + + Returns: + trimesh.Trimesh cutter in world coordinates, or None if micro_divisions=1 + """ + if micro_divisions <= 1: + return None # No cutting needed + + if pitch != GRU: + raise ValueError(f"Only pitch={GRU} is supported, got {pitch}") + + # Detect cell centers (use mesh bounds if available for full foot coverage) + centers = detect_cell_centers(footprint, pitch, mesh_bounds) + + if not centers: + raise ValueError("No cells detected in footprint") + + from meshcutter.core.cq_cutter import generate_grid_cutter_mesh + + # Compute footprint bounds for clipping channels/boundary fill to model footprint. + # IMPORTANT: Use mesh_bounds (full model extent at foot base) NOT footprint bounds + # (which is at Z=0 foot tip where feet are narrower due to chamfer). + # This prevents the cutter from extending beyond the model and clipping corners. + if mesh_bounds is not None: + # mesh_bounds is in world coords; footprint/centers are in local coords + # For a Z-aligned model, the XY extent is the same in both coord systems + footprint_bounds = ( + float(mesh_bounds[0, 0]), # x_min + float(mesh_bounds[0, 1]), # y_min + float(mesh_bounds[1, 0]), # x_max + float(mesh_bounds[1, 1]), # y_max + ) + else: + # Fallback to footprint bounds (may cause corner clipping) + fp_bounds = footprint.bounds # (minx, miny, maxx, maxy) + footprint_bounds = (fp_bounds[0], fp_bounds[1], fp_bounds[2], fp_bounds[3]) + + # Build grid cutter from individual cell meshes (watertight cells) + cutter_local = generate_grid_cutter_mesh( + centers, + micro_divisions, + epsilon, + overshoot=overshoot, + wall_cut=wall_cut, + add_channels=add_channels, + footprint_bounds=footprint_bounds, + ) + + # Transform to world coordinates + T = frame.to_transform_matrix() + cutter_world = cutter_local.copy() + cutter_world.apply_transform(T) + + return cutter_world diff --git a/meshcutter/core/geometry.py b/meshcutter/core/geometry.py new file mode 100644 index 0000000..8b292bc --- /dev/null +++ b/meshcutter/core/geometry.py @@ -0,0 +1,327 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.geometry - Geometry helpers for foot cutter generation +# +# Provides topology-stable rounded rectangle generation and polygon-with-holes +# lofting for constructing Gridfinity foot complement cutters. + +from __future__ import annotations + +from typing import List, Tuple, Optional + +import numpy as np +import trimesh +from shapely.geometry import Polygon +from shapely.ops import triangulate + + +def sample_rounded_rect( + width: float, + height: float, + radius: float, + points_per_corner: int = 8, + center: Tuple[float, float] = (0.0, 0.0), +) -> np.ndarray: + """ + Generate CCW vertices for a rounded rectangle. + + Topology-stable: + - Always returns same vertex count regardless of radius + - When radius approaches 0, corner arcs collapse but vertex count unchanged + - No duplicate closing point (open ring) + - Guaranteed CCW ordering + + Args: + width: Total width of rectangle (mm) + height: Total height of rectangle (mm) + radius: Corner radius (mm), clamped to valid range + points_per_corner: Number of points per corner arc (default: 8) + center: Center point (x, y) + + Returns: + np.ndarray of shape (N, 2) with CCW-ordered vertices (open ring) + N = 4 * points_per_corner (corners) + 4 (straight midpoints) + """ + cx, cy = center + + # Clamp radius to valid range + max_radius = min(width, height) / 2.0 - 1e-6 + r = max(0.0, min(radius, max_radius)) + + # Half-dimensions minus radius gives corner centers + hw = width / 2.0 + hh = height / 2.0 + + # Corner centers (CCW from bottom-right) + corners = [ + (cx + hw - r, cy - hh + r), # bottom-right + (cx + hw - r, cy + hh - r), # top-right + (cx - hw + r, cy + hh - r), # top-left + (cx - hw + r, cy - hh + r), # bottom-left + ] + + # Start angles for each corner (CCW) + start_angles = [ + -np.pi / 2, # bottom-right: -90° to 0° + 0, # top-right: 0° to 90° + np.pi / 2, # top-left: 90° to 180° + np.pi, # bottom-left: 180° to 270° + ] + + vertices = [] + + for i, (corner_cx, corner_cy) in enumerate(corners): + start_angle = start_angles[i] + # Generate arc points for this corner + for j in range(points_per_corner): + t = j / points_per_corner + angle = start_angle + t * (np.pi / 2) + if r > 1e-9: + x = corner_cx + r * np.cos(angle) + y = corner_cy + r * np.sin(angle) + else: + # Radius is ~0, all points collapse to corner + # Still generate same count for topology stability + x = corner_cx + y = corner_cy + vertices.append((x, y)) + + # Add straight edge midpoint after each corner arc + # This helps with triangulation and edge correspondence + if i == 0: + # Right edge midpoint + vertices.append((cx + hw, cy)) + elif i == 1: + # Top edge midpoint + vertices.append((cx, cy + hh)) + elif i == 2: + # Left edge midpoint + vertices.append((cx - hw, cy)) + elif i == 3: + # Bottom edge midpoint + vertices.append((cx, cy - hh)) + + return np.array(vertices, dtype=np.float64) + + +def reverse_winding(vertices: np.ndarray) -> np.ndarray: + """Reverse vertex winding order (CCW <-> CW).""" + return vertices[::-1].copy() + + +def triangulate_polygon_with_holes( + outer: np.ndarray, + holes: List[np.ndarray], +) -> Tuple[np.ndarray, np.ndarray]: + """ + Triangulate a polygon with holes using earcut algorithm. + + Args: + outer: CCW vertices of outer boundary (N, 2) + holes: List of CW vertices for each hole (each is (M, 2)) + + Returns: + (vertices, faces) tuple for a 2D triangulated mesh + """ + try: + import mapbox_earcut as earcut + except ImportError: + # Fallback to trimesh's built-in earcut + earcut = None + + # Build vertex array: outer ring followed by all holes + all_vertices = list(outer) + ring_ends = [len(outer)] + + for hole in holes: + all_vertices.extend(hole) + ring_ends.append(len(all_vertices)) + + vertices = np.array(all_vertices, dtype=np.float64) + + if earcut is not None: + # Use mapbox_earcut directly + # ring_ends defines where each ring ends (exclusive) + # earcut expects a 2D array of vertices and array of ring end indices + # The last value must equal the total number of vertices + ring_array = np.array(ring_ends, dtype=np.uint32) + + # earcut returns flat array of triangle indices + indices = earcut.triangulate_float64(vertices, ring_array) + + faces = np.array(indices, dtype=np.int64).reshape(-1, 3) + return vertices, faces + else: + # Fallback: build Shapely polygon and use trimesh + outer_ring = list(map(tuple, outer)) + if outer_ring[0] != outer_ring[-1]: + outer_ring = outer_ring + [outer_ring[0]] + + hole_rings = [] + for hole in holes: + hole_ring = list(map(tuple, hole)) + if hole_ring[0] != hole_ring[-1]: + hole_ring = hole_ring + [hole_ring[0]] + hole_rings.append(hole_ring) + + poly = Polygon(outer_ring, hole_rings) + + if not poly.is_valid: + # Try to fix with buffer(0) + poly = poly.buffer(0) + + if poly.is_empty or not hasattr(poly, "exterior"): + raise ValueError("Invalid polygon for triangulation") + + # Use trimesh's triangulation + try: + verts, faces = trimesh.creation.triangulate_polygon(poly, engine="earcut") + return np.array(verts), np.array(faces) + except Exception as e: + raise ValueError(f"Failed to triangulate polygon: {e}") + + +def loft_polygon_with_holes( + rings: List[Tuple[float, np.ndarray, List[np.ndarray]]], +) -> trimesh.Trimesh: + """ + Loft a polygon-with-holes through multiple Z levels. + + Creates a watertight solid by: + 1. Connecting corresponding vertices between consecutive Z rings (side faces) + 2. Adding triangulated caps at top and bottom (using ring vertices) + + Args: + rings: List of (z, outer_verts, [hole_verts, ...]) tuples + - z: Z coordinate for this ring + - outer_verts: CCW vertices (N, 2) for outer boundary + - hole_verts: List of CW vertices for holes + + Returns: + Watertight trimesh.Trimesh solid + + Raises: + ValueError: If rings have inconsistent topology + """ + if len(rings) < 2: + raise ValueError("Need at least 2 rings to loft") + + # Validate topology consistency + n_outer = len(rings[0][1]) + n_holes = len(rings[0][2]) + hole_sizes = [len(h) for h in rings[0][2]] + + for i, (z, outer, holes) in enumerate(rings): + if len(outer) != n_outer: + raise ValueError(f"Ring {i} has {len(outer)} outer vertices, expected {n_outer}") + if len(holes) != n_holes: + raise ValueError(f"Ring {i} has {len(holes)} holes, expected {n_holes}") + for j, h in enumerate(holes): + if len(h) != hole_sizes[j]: + raise ValueError(f"Ring {i} hole {j} has {len(h)} vertices, expected {hole_sizes[j]}") + + all_vertices = [] + all_faces = [] + + # Build 3D vertices for all rings + ring_vertex_offsets = [] + for z, outer, holes in rings: + offset = len(all_vertices) + ring_vertex_offsets.append(offset) + + # Add outer boundary vertices + for x, y in outer: + all_vertices.append([x, y, z]) + + # Add hole vertices + for hole in holes: + for x, y in hole: + all_vertices.append([x, y, z]) + + # Create side faces between consecutive rings + for ring_idx in range(len(rings) - 1): + offset_a = ring_vertex_offsets[ring_idx] + offset_b = ring_vertex_offsets[ring_idx + 1] + + # Outer boundary side faces (CCW outer = outward normal) + for i in range(n_outer): + i_next = (i + 1) % n_outer + v0 = offset_a + i + v1 = offset_a + i_next + v2 = offset_b + i_next + v3 = offset_b + i + # Outer faces: normal points outward (away from center) + # For CCW outer ring, going up (a->b), we want CCW when viewed from outside + all_faces.append([v0, v1, v2]) + all_faces.append([v0, v2, v3]) + + # Hole boundary side faces (CW holes = inward normal toward hole center) + hole_offset_in_ring = n_outer + for hole_idx, hole_size in enumerate(hole_sizes): + for i in range(hole_size): + i_next = (i + 1) % hole_size + v0 = offset_a + hole_offset_in_ring + i + v1 = offset_a + hole_offset_in_ring + i_next + v2 = offset_b + hole_offset_in_ring + i_next + v3 = offset_b + hole_offset_in_ring + i + # Hole faces: normal points inward (toward hole center = outward from solid) + # For CW hole ring, going up, we want faces pointing inward + all_faces.append([v0, v2, v1]) + all_faces.append([v0, v3, v2]) + hole_offset_in_ring += hole_size + + # Add caps using triangulation, but reuse the ring vertices + # Bottom cap (first ring, facing -Z) + z_bottom, outer_bottom, holes_bottom = rings[0] + offset_bottom = ring_vertex_offsets[0] + + # Triangulate to get face indices (relative to the combined vertex list) + cap_verts, cap_faces = triangulate_polygon_with_holes(outer_bottom, holes_bottom) + + # Map triangulation vertices to ring vertices + # The triangulation returns vertices in same order: outer, then holes + # We need to map these back to the ring vertex indices + total_ring_verts = n_outer + sum(hole_sizes) + + # Bottom cap faces (facing -Z, so reverse winding) + for face in cap_faces: + # Indices are into the triangulation vertex array, which matches ring vertex layout + all_faces.append( + [ + offset_bottom + face[0], + offset_bottom + face[2], + offset_bottom + face[1], + ] + ) + + # Top cap (last ring, facing +Z) + z_top, outer_top, holes_top = rings[-1] + offset_top = ring_vertex_offsets[-1] + + cap_verts, cap_faces = triangulate_polygon_with_holes(outer_top, holes_top) + + # Top cap faces (facing +Z, normal winding) + for face in cap_faces: + all_faces.append( + [ + offset_top + face[0], + offset_top + face[1], + offset_top + face[2], + ] + ) + + vertices = np.array(all_vertices, dtype=np.float64) + faces = np.array(all_faces, dtype=np.int64) + + mesh = trimesh.Trimesh(vertices=vertices, faces=faces) + + # Clean up the mesh + mesh.merge_vertices() + mesh.update_faces(mesh.nondegenerate_faces()) + mesh.update_faces(mesh.unique_faces()) + + # Fix winding if volume is negative + if mesh.volume < 0: + mesh.invert() + + return mesh diff --git a/meshcutter/core/grid.py b/meshcutter/core/grid.py index b3286da..0e29140 100644 --- a/meshcutter/core/grid.py +++ b/meshcutter/core/grid.py @@ -86,19 +86,21 @@ def generate_grid_mask( phase_x: float = 0.0, phase_y: float = 0.0, overshoot: float = 1.0, + clip_to_footprint: bool = False, ) -> Union[Polygon, MultiPolygon]: """ Generate 2D grid mask polygon for cutting. This function creates a 2D mask representing the grid of slots to be cut. - The mask is the intersection of (union of all strips) with the footprint. + By default, strips extend beyond the footprint bounding box by `overshoot` + to ensure cuts go completely through each foot cell. Strategy: 1. Compute grid positions covering footprint bbox 2. Create X-strips (vertical) and Y-strips (horizontal) as rectangles 3. Strips use: slot_width + 2*clearance as the effective width 4. Union all strips (fast 2D operation via Shapely) - 5. Intersect with footprint (clips to part boundary) + 5. Optionally clip to footprint (disabled by default for through-cuts) Args: footprint: Shapely Polygon/MultiPolygon of the bottom footprint @@ -108,6 +110,8 @@ def generate_grid_mask( phase_x: Grid phase offset in X (mm) phase_y: Grid phase offset in Y (mm) overshoot: How far strips extend beyond bbox (mm) + clip_to_footprint: If True, clip mask to footprint boundary (legacy). + If False (default), strips extend full length for through-cuts. Returns: Shapely Polygon or MultiPolygon representing the cut region @@ -177,21 +181,26 @@ def generate_grid_mask( if not grid_union.is_valid: grid_union = make_valid(grid_union) - # Intersect with footprint to clip to part boundary - clipped = footprint.intersection(grid_union) + if clip_to_footprint: + # Legacy behavior: clip to footprint polygon boundary + result = footprint.intersection(grid_union) + else: + # Default: no clipping - strips extend full length for through-cuts + # This ensures cuts go completely through each foot cell and past the model edge + result = grid_union # Validate result - if clipped.is_empty: + if result.is_empty: raise ValueError( - "Grid mask is empty after intersection with footprint. " + "Grid mask is empty after processing. " "Check that grid pitch and phase produce cuts that overlap the footprint." ) # Ensure validity of result - if not clipped.is_valid: - clipped = make_valid(clipped) + if not result.is_valid: + result = make_valid(result) - return clipped + return result def compute_pitch(divisions: int) -> float: diff --git a/meshcutter/core/grid_utils.py b/meshcutter/core/grid_utils.py new file mode 100644 index 0000000..3f72231 --- /dev/null +++ b/meshcutter/core/grid_utils.py @@ -0,0 +1,198 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.grid_utils - Grid and micro-foot offset calculations +# +# This module provides utilities for calculating micro-foot positions +# and detecting cell centers from footprints. + +from __future__ import annotations + +from typing import List, Optional, Tuple, Union + +import numpy as np +from shapely.geometry import Polygon, MultiPolygon + +from meshcutter.core.constants import GRU, GR_TOL + + +# ----------------------------------------------------------------------------- +# Micro-foot offset calculations +# ----------------------------------------------------------------------------- + + +def micro_foot_offsets_single_cell( + micro_divisions: int = 4, + pitch: float = GRU, +) -> List[Tuple[float, float]]: + """Return micro-foot center offsets relative to 1U cell center. + + For micro_divisions=4, pitch=42: + - micro_pitch = 10.5 + - offsets at: [-15.75, -5.25, 5.25, 15.75] in each axis + - 16 micro-feet total per cell + + Args: + micro_divisions: Number of divisions (2 or 4) + pitch: 1U pitch (default 42mm) + + Returns: + List of (x, y) offsets relative to cell center + """ + if micro_divisions <= 1: + return [(0.0, 0.0)] # Single foot at center + + micro_pitch = pitch / micro_divisions + offsets = [] + for i in range(micro_divisions): + for j in range(micro_divisions): + x = (i - (micro_divisions - 1) / 2.0) * micro_pitch + y = (j - (micro_divisions - 1) / 2.0) * micro_pitch + offsets.append((x, y)) + return offsets + + +def micro_foot_offsets_grid( + cells_x: int, + cells_y: int, + micro_divisions: int = 4, + pitch: float = GRU, +) -> List[Tuple[float, float]]: + """Return micro-foot center offsets for a grid of cells. + + Matches microfinity's micro_grid_centres calculation exactly. + The returned offsets are centered at (0, 0) for the grid center. + + Args: + cells_x: Number of 1U cells in X direction + cells_y: Number of 1U cells in Y direction + micro_divisions: Number of divisions per 1U (2 or 4) + pitch: 1U pitch (42mm) + + Returns: + List of (x, y) offsets for micro-foot centers, centered at origin + """ + if micro_divisions <= 1: + # Standard 1U grid + half_l = (cells_x - 1) * pitch / 2 + half_w = (cells_y - 1) * pitch / 2 + return [(x * pitch - half_l, y * pitch - half_w) for x in range(cells_x) for y in range(cells_y)] + + micro_pitch = pitch / micro_divisions + micro_count_x = cells_x * micro_divisions + micro_count_y = cells_y * micro_divisions + + # Half extents (distance from center to edge foot centers) + micro_half_l = (micro_count_x - 1) * micro_pitch / 2 + micro_half_w = (micro_count_y - 1) * micro_pitch / 2 + + offsets = [ + (x * micro_pitch - micro_half_l, y * micro_pitch - micro_half_w) + for x in range(micro_count_x) + for y in range(micro_count_y) + ] + return offsets + + +# ----------------------------------------------------------------------------- +# Cell center detection +# ----------------------------------------------------------------------------- + + +def detect_cell_centers( + footprint: Union[Polygon, MultiPolygon], + pitch: float = GRU, + mesh_bounds: Optional[np.ndarray] = None, +) -> List[Tuple[float, float]]: + """Detect 1U cell centers from footprint, optionally using mesh bounds for sizing. + + For Gridfinity models, the footprint detected at Z=0 may be smaller than + the actual foot base due to chamfers. If mesh_bounds is provided, we use + the XY extent of the mesh bounds for calculating the number of cells. + + The returned centers are in LOCAL FRAME coordinates (same as footprint). + The center position comes from the footprint (which is in local coords), while + the dimensions can come from mesh_bounds (for accurate cell count). + + Args: + footprint: Shapely polygon of bottom footprint (LOCAL frame coordinates) + pitch: 1U pitch (default 42mm) + mesh_bounds: Optional mesh bounds array [[minx,miny,minz], [maxx,maxy,maxz]] + in WORLD coordinates. Used only for determining cell count, + not for center positioning. + + Returns: + List of (x, y) cell center coordinates in LOCAL frame + """ + # Always use footprint center (local frame coordinates) + fp_bounds = footprint.bounds + cx = (fp_bounds[0] + fp_bounds[2]) / 2.0 + cy = (fp_bounds[1] + fp_bounds[3]) / 2.0 + + if mesh_bounds is not None: + # Use mesh bounds for dimensions (captures full foot size) + width = mesh_bounds[1, 0] - mesh_bounds[0, 0] + height = mesh_bounds[1, 1] - mesh_bounds[0, 1] + else: + # Use footprint bounds for dimensions + width = fp_bounds[2] - fp_bounds[0] + height = fp_bounds[3] - fp_bounds[1] + + # Gridfinity convention: overall_dim ≈ N * pitch - GR_TOL + cells_x = int(round((width + GR_TOL) / pitch)) + cells_y = int(round((height + GR_TOL) / pitch)) + + # Ensure at least 1 cell in each dimension + cells_x = max(1, cells_x) + cells_y = max(1, cells_y) + + centers = [] + for i in range(cells_x): + for j in range(cells_y): + x = cx + (i - (cells_x - 1) / 2.0) * pitch + y = cy + (j - (cells_y - 1) / 2.0) * pitch + centers.append((x, y)) + + return centers + + +def detect_grid_size( + mesh_bounds: np.ndarray, + pitch: float = GRU, +) -> Tuple[int, int]: + """Detect the grid size (cells_x, cells_y) from mesh bounds. + + Args: + mesh_bounds: Mesh bounds array [[minx,miny,minz], [maxx,maxy,maxz]] + pitch: 1U pitch (default 42mm) + + Returns: + Tuple of (cells_x, cells_y) + """ + width = mesh_bounds[1, 0] - mesh_bounds[0, 0] + height = mesh_bounds[1, 1] - mesh_bounds[0, 1] + + # Gridfinity convention: overall_dim ≈ N * pitch - GR_TOL + cells_x = int(round((width + GR_TOL) / pitch)) + cells_y = int(round((height + GR_TOL) / pitch)) + + return max(1, cells_x), max(1, cells_y) + + +# ----------------------------------------------------------------------------- +# Utility functions +# ----------------------------------------------------------------------------- + + +def quantize(v: float, precision: float = 0.1) -> float: + """Quantize a value to a given precision. + + Useful for snapping coordinates to grid positions. + + Args: + v: Value to quantize + precision: Precision to round to + + Returns: + Quantized value + """ + return round(v / precision) * precision diff --git a/meshcutter/core/mesh_utils.py b/meshcutter/core/mesh_utils.py new file mode 100644 index 0000000..d593f80 --- /dev/null +++ b/meshcutter/core/mesh_utils.py @@ -0,0 +1,284 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.mesh_utils - Mesh conversion and utility functions +# +# This module provides utilities for converting between mesh formats, +# cleaning meshes, and computing mesh diagnostics. + +from __future__ import annotations + +import os +import tempfile +from typing import Dict, List, Optional, Tuple + +import numpy as np +import trimesh + +from meshcutter.core.constants import ( + MIN_COMPONENT_FACES, + MIN_SLIVER_SIZE, + MIN_SLIVER_VOLUME, +) + + +# ----------------------------------------------------------------------------- +# Mesh format conversion +# ----------------------------------------------------------------------------- + + +def trimesh_to_manifold(mesh: trimesh.Trimesh): + """Convert trimesh to manifold3d Manifold. + + Args: + mesh: Input trimesh mesh + + Returns: + manifold3d.Manifold object + """ + import manifold3d + + return manifold3d.Manifold( + manifold3d.Mesh( + vert_properties=np.array(mesh.vertices, dtype=np.float32), + tri_verts=np.array(mesh.faces, dtype=np.uint32), + ) + ) + + +def manifold_to_trimesh(manifold) -> trimesh.Trimesh: + """Convert manifold3d Manifold to trimesh. + + Args: + manifold: manifold3d.Manifold object + + Returns: + trimesh.Trimesh object + """ + mesh_data = manifold.to_mesh() + return trimesh.Trimesh( + vertices=np.array(mesh_data.vert_properties, dtype=np.float64), + faces=np.array(mesh_data.tri_verts, dtype=np.int64), + ) + + +def cq_to_trimesh(cq_obj, tol: float = 0.01, ang_tol: float = 0.1) -> trimesh.Trimesh: + """Convert CadQuery Workplane to trimesh. + + Uses CadQuery's STL export with specified tolerances, matching + microfinity's default export settings. + + Args: + cq_obj: CadQuery Workplane object + tol: Linear mesh tolerance (mm) + ang_tol: Angular mesh tolerance (radians) + + Returns: + trimesh.Trimesh object + """ + import cadquery as cq + + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = os.path.join(tmpdir, "temp.stl") + cq.exporters.export( + cq_obj, + stl_path, + exportType="STL", + tolerance=tol, + angularTolerance=ang_tol, + ) + mesh = trimesh.load(stl_path) + + # Ensure we return a Trimesh, not a Scene + if isinstance(mesh, trimesh.Scene): + # Combine all geometries in the scene + meshes = [g for g in mesh.geometry.values() if isinstance(g, trimesh.Trimesh)] + if len(meshes) == 1: + return meshes[0] + elif len(meshes) > 1: + return trimesh.util.concatenate(meshes) + else: + raise ValueError("No valid mesh geometry in CadQuery export") + + return mesh + + +# ----------------------------------------------------------------------------- +# Mesh repair and cleaning +# ----------------------------------------------------------------------------- + + +def repair_mesh_manifold(mesh: trimesh.Trimesh) -> trimesh.Trimesh: + """Repair mesh by passing through manifold3d. + + This eliminates floating-point artifacts and non-manifold geometry + that can occur during boolean operations or STL export/import. + + Args: + mesh: trimesh.Trimesh to repair + + Returns: + Repaired trimesh.Trimesh (or original if manifold3d unavailable) + """ + try: + import manifold3d + + # Convert to manifold (auto-repairs) + m = manifold3d.Manifold( + manifold3d.Mesh( + vert_properties=np.array(mesh.vertices, dtype=np.float32), + tri_verts=np.array(mesh.faces, dtype=np.uint32), + ) + ) + + # Convert back to trimesh + mesh_data = m.to_mesh() + return trimesh.Trimesh(vertices=mesh_data.vert_properties[:, :3], faces=mesh_data.tri_verts) + except ImportError: + return mesh # manifold3d not available + + +def is_degenerate_sliver( + component: trimesh.Trimesh, + min_size: float = MIN_SLIVER_SIZE, + min_volume: float = MIN_SLIVER_VOLUME, +) -> bool: + """Check if a component is a degenerate sliver (boolean artifact). + + A sliver is identified by: + - All bounding box dimensions being very small (< min_size), OR + - Having an extremely small volume (< min_volume), OR + - Having fewer than 4 faces (can't form a valid solid) + + Args: + component: trimesh.Trimesh component to check + min_size: Minimum acceptable size in any dimension (mm) + min_volume: Minimum acceptable volume (mm³) + + Returns: + True if the component is a degenerate sliver that should be removed + """ + # Check face count - need at least 4 faces for a tetrahedron + if len(component.faces) < 4: + return True + + # Check if all dimensions are tiny (nanometer-scale artifact) + size = component.bounds[1] - component.bounds[0] + if (size < min_size).all(): + return True + + # Check volume - if it's effectively zero, it's degenerate + # Use absolute value since non-watertight meshes can have negative volume + if abs(component.volume) < min_volume: + return True + + return False + + +def clean_mesh_components( + mesh: trimesh.Trimesh, + min_faces: int = MIN_COMPONENT_FACES, +) -> Tuple[trimesh.Trimesh, int]: + """Remove floating/degenerate components from mesh. + + Keeps only the largest component(s) that have significant geometry. + Small floating triangles and nanometer-scale slivers (common boolean + artifacts) are removed. + + Args: + mesh: trimesh.Trimesh input mesh + min_faces: Minimum faces to keep a component + + Returns: + Tuple of (cleaned mesh, number of components removed) + """ + components = mesh.split(only_watertight=False) + + if len(components) <= 1: + return mesh, 0 + + # Find the main component (largest by face count) + main_component = max(components, key=lambda c: len(c.faces)) + main_face_count = len(main_component.faces) + + # Keep components that: + # 1. Have at least min_faces faces OR 1% of main component, AND + # 2. Are NOT degenerate slivers (size/volume check) + threshold = max(min_faces, main_face_count * 0.01) + kept = [c for c in components if len(c.faces) >= threshold and not is_degenerate_sliver(c)] + removed_count = len(components) - len(kept) + + if len(kept) == 1: + return kept[0], removed_count + elif len(kept) > 1: + # Concatenate kept components + return trimesh.util.concatenate(kept), removed_count + else: + # Shouldn't happen, but return original if no components kept + return mesh, 0 + + +# ----------------------------------------------------------------------------- +# Mesh diagnostics +# ----------------------------------------------------------------------------- + + +def get_mesh_diagnostics(mesh: trimesh.Trimesh) -> Dict: + """Get diagnostic information about a mesh. + + Useful for debugging boolean failures and mesh issues. + + Args: + mesh: Input mesh + + Returns: + Dictionary with diagnostic info + """ + diagnostics = { + "is_watertight": mesh.is_watertight, + "is_winding_consistent": mesh.is_winding_consistent, + "euler_number": mesh.euler_number, + "face_count": len(mesh.faces), + "vertex_count": len(mesh.vertices), + "bounds_min": mesh.bounds[0].tolist(), + "bounds_max": mesh.bounds[1].tolist(), + "area": mesh.area, + } + + # Volume only valid for watertight meshes + if mesh.is_watertight: + diagnostics["volume"] = mesh.volume + else: + diagnostics["volume"] = None + + # Check for non-manifold edges + try: + edges = mesh.edges_unique + diagnostics["edge_count"] = len(edges) + except Exception: + diagnostics["edge_count"] = None + + return diagnostics + + +def validate_mesh_for_boolean(mesh: trimesh.Trimesh, name: str = "mesh") -> List[str]: + """Validate a mesh is suitable for boolean operations. + + Args: + mesh: Input mesh + name: Name for error messages + + Returns: + List of warning messages (empty if all OK) + """ + warnings = [] + + if not mesh.is_watertight: + warnings.append(f"{name} is not watertight - boolean may fail") + + if not mesh.is_winding_consistent: + warnings.append(f"{name} has inconsistent face winding") + + if len(mesh.faces) == 0: + warnings.append(f"{name} has no faces") + + return warnings diff --git a/meshcutter/core/replace_base.py b/meshcutter/core/replace_base.py new file mode 100644 index 0000000..5aac194 --- /dev/null +++ b/meshcutter/core/replace_base.py @@ -0,0 +1,502 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.replace_base - Replace Base Pipeline for micro-foot conversion +# +# Instead of carving into existing geometry with booleans (which creates artifacts), +# this approach replaces the entire foot region: +# 1. Trim input mesh above z_split to keep only the top portion +# 2. Generate fresh micro-feet base directly (using microfinity construction path) +# 3. Union with overlapping sleeve interface (NOT coplanar join) +# +# This avoids the mesh boolean retessellation artifacts that caused ~50mm^3 residuals. + +from __future__ import annotations + +import tempfile +import os +from typing import List, Optional, Tuple, Union + +import numpy as np +import trimesh +import manifold3d +import cadquery as cq +from cqkit.cq_helpers import rounded_rect_sketch, composite_from_pts +from shapely.geometry import Polygon, MultiPolygon + +from microfinity.core.constants import ( + GR_BASE_CLR, + GR_BASE_HEIGHT, + GR_BOX_PROFILE, + GR_RAD, + GR_TOL, + GRU, + SQRT2, +) + +from meshcutter.core.detection import BottomFrame + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +# Z_split is the plane where we cut between top (kept) and bottom (replaced) +# z_split = z_min + GR_BASE_HEIGHT + GR_BASE_CLR = z_min + 5.0mm +Z_SPLIT_HEIGHT = GR_BASE_HEIGHT + GR_BASE_CLR # 4.75 + 0.25 = 5.0mm + +# Sleeve height - how far the new base extends ABOVE z_split for overlap +# This is critical: overlap ensures robust union (no coplanar faces) +SLEEVE_HEIGHT = 0.5 # mm + + +# ----------------------------------------------------------------------------- +# CadQuery version detection (from cq_cutter.py) +# ----------------------------------------------------------------------------- +ZLEN_FIX = True +_r = cq.Workplane("XY").rect(2, 2).extrude(1, taper=45) +_bb = _r.vals()[0].BoundingBox() +if abs(_bb.zlen - 1.0) < 1e-3: + ZLEN_FIX = False + + +def extrude_profile(sketch, profile, workplane="XY", angle=None) -> cq.Workplane: + """Extrude a sketch through a multi-segment profile with optional tapers. + + Mirrors microfinity.core.base.GridfinityObject.extrude_profile(). + """ + import math + + taper = profile[0][1] if isinstance(profile[0], (list, tuple)) else 0 + zlen = profile[0][0] if isinstance(profile[0], (list, tuple)) else profile[0] + + if abs(taper) > 0: + if angle is None: + zlen = zlen if ZLEN_FIX else zlen / SQRT2 + else: + zlen = zlen / math.cos(math.radians(taper)) if ZLEN_FIX else zlen + + r = cq.Workplane(workplane).placeSketch(sketch).extrude(zlen, taper=taper) + + for level in profile[1:]: + if isinstance(level, (tuple, list)): + if angle is None: + zlen = level[0] if ZLEN_FIX else level[0] / SQRT2 + else: + zlen = level[0] / math.cos(math.radians(level[1])) if ZLEN_FIX else level[0] + r = r.faces(">Z").wires().toPending().extrude(zlen, taper=level[1]) + else: + r = r.faces(">Z").wires().toPending().extrude(level) + + return r + + +# ----------------------------------------------------------------------------- +# Trimesh <-> Manifold conversion helpers +# ----------------------------------------------------------------------------- +def trimesh_to_manifold(mesh: trimesh.Trimesh) -> manifold3d.Manifold: + """Convert trimesh to manifold3d Manifold.""" + return manifold3d.Manifold( + manifold3d.Mesh( + vert_properties=np.array(mesh.vertices, dtype=np.float32), tri_verts=np.array(mesh.faces, dtype=np.uint32) + ) + ) + + +def manifold_to_trimesh(manifold: manifold3d.Manifold) -> trimesh.Trimesh: + """Convert manifold3d Manifold to trimesh.""" + mesh_data = manifold.to_mesh() + return trimesh.Trimesh( + vertices=np.array(mesh_data.vert_properties, dtype=np.float64), + faces=np.array(mesh_data.tri_verts, dtype=np.int64), + ) + + +def cq_to_trimesh(cq_obj: cq.Workplane, tol: float = 0.01, ang_tol: float = 0.1) -> trimesh.Trimesh: + """Convert CadQuery Workplane to trimesh.""" + with tempfile.TemporaryDirectory() as tmpdir: + stl_path = os.path.join(tmpdir, "temp.stl") + cq.exporters.export( + cq_obj, + stl_path, + exportType="STL", + tolerance=tol, + angularTolerance=ang_tol, + ) + mesh = trimesh.load(stl_path) + return mesh + + +# ----------------------------------------------------------------------------- +# Step 1: Trim mesh above z_split +# ----------------------------------------------------------------------------- +def trim_mesh_above_z(mesh: trimesh.Trimesh, z_split: float) -> trimesh.Trimesh: + """Trim mesh to keep only the portion above z_split. + + Uses Manifold's trimByPlane to extract the top portion of the mesh. + The plane normal points downward (-Z) so material above z_split is kept. + + Args: + mesh: Input mesh + z_split: Z coordinate of the split plane + + Returns: + Trimesh containing only geometry above z_split + """ + manifold = trimesh_to_manifold(mesh) + + # trimByPlane keeps material in the positive half-space of the plane + # Plane defined by normal and distance from origin along normal + # To keep material ABOVE z_split, use normal=(0,0,-1), distance=-z_split + # This keeps material where dot(vertex, normal) >= distance + # i.e., -vertex.z >= -z_split => vertex.z <= z_split + # Wait, that's wrong. Let me reconsider. + # + # trimByPlane(normal, originOffset): keeps vertices where dot(v, normal) >= originOffset + # To keep v.z >= z_split: use normal=(0,0,1), originOffset=z_split + trimmed = manifold.trim_by_plane( + normal=(0.0, 0.0, 1.0), origin_offset=z_split # Plane normal pointing up # Keep material where z >= z_split + ) + + return manifold_to_trimesh(trimmed) + + +# ----------------------------------------------------------------------------- +# Step 2: Generate micro-base with sleeve overlap +# ----------------------------------------------------------------------------- +def generate_micro_foot_cq(micro_divisions: int = 4) -> cq.Workplane: + """Generate a single micro-foot solid using CadQuery. + + Uses the same construction path as microfinity for geometric accuracy. + """ + micro_pitch = GRU / micro_divisions # 10.5mm for div=4 + foot_size = micro_pitch - GR_TOL # 10.0mm for div=4 + + # Corner radius - same as microfinity's micro_foot() + outer_rad = GR_RAD - GR_TOL / 2 # 3.75mm + rad = min(outer_rad + GR_BASE_CLR, foot_size / 2 - 0.05) + rad = max(rad, 0.2) + + foot = extrude_profile(rounded_rect_sketch(foot_size, foot_size, rad), GR_BOX_PROFILE) + foot = foot.translate((0, 0, -GR_BASE_CLR)) + foot = foot.mirror(mirrorPlane="XY") + + return foot + + +def micro_foot_offsets( + cells_x: int, cells_y: int, micro_divisions: int = 4, pitch: float = GRU +) -> List[Tuple[float, float]]: + """Return micro-foot center offsets for a grid of cells. + + Matches microfinity's micro_grid_centres calculation exactly. + + Args: + cells_x: Number of 1U cells in X direction + cells_y: Number of 1U cells in Y direction + micro_divisions: Number of divisions per 1U (2 or 4) + pitch: 1U pitch (42mm) + + Returns: + List of (x, y) offsets for micro-foot centers + """ + if micro_divisions <= 1: + # Standard 1U grid + return [(x * pitch, y * pitch) for x in range(cells_x) for y in range(cells_y)] + + micro_pitch = pitch / micro_divisions + micro_count_x = cells_x * micro_divisions + micro_count_y = cells_y * micro_divisions + + # Half extents (distance from center to edge foot centers) + half_l = (cells_x - 1) * pitch / 2 + half_w = (cells_y - 1) * pitch / 2 + micro_half_l = (micro_count_x - 1) * micro_pitch / 2 + micro_half_w = (micro_count_y - 1) * micro_pitch / 2 + + # Center the micro lattice on the cell grid center + offsets = [ + (x * micro_pitch - micro_half_l + half_l, y * micro_pitch - micro_half_w + half_w) + for x in range(micro_count_x) + for y in range(micro_count_y) + ] + return offsets + + +def generate_micro_base_with_sleeve( + cells_x: int, + cells_y: int, + micro_divisions: int = 4, + z_base: float = 0.0, + sleeve_height: float = SLEEVE_HEIGHT, + pitch: float = GRU, +) -> trimesh.Trimesh: + """Generate micro-feet base that extends above z_split by sleeve_height. + + This creates: + 1. Micro-feet at the correct positions (matching microfinity exactly) + 2. Outer envelope that crops the feet (41.5mm per cell) + 3. Wall sleeve that extends above z_split for overlap with trimmed top + + The geometry is generated in CadQuery for accuracy, then converted to trimesh. + The final mesh is centered at origin in XY (same as microfinity output). + + Args: + cells_x: Number of 1U cells in X direction + cells_y: Number of 1U cells in Y direction + micro_divisions: Number of divisions per 1U (2 or 4) + z_base: Z coordinate of the base bottom (typically mesh z_min) + sleeve_height: How far to extend above z_split (mm) + pitch: 1U pitch (42mm) + + Returns: + Trimesh of the micro-base with sleeve, centered at origin in XY + """ + # Outer envelope dimensions (same as microfinity) + outer_l = cells_x * pitch - GR_TOL # 83.5mm for 2 cells + outer_w = cells_y * pitch - GR_TOL # 125.5mm for 3 cells + outer_rad = GR_RAD - GR_TOL / 2 # 3.75mm + + # Half dimensions for internal grid centering (matches microfinity) + # This is the offset from grid origin to the center of the grid + half_l = (cells_x - 1) * pitch / 2 # 21mm for 2 cells + half_w = (cells_y - 1) * pitch / 2 # 42mm for 3 cells + + # Generate micro-foot template + micro_foot = generate_micro_foot_cq(micro_divisions) + micro_foot_solid = micro_foot.val() + + # Replicate micro-feet at all positions + # These offsets are in "internal" coordinates where grid starts at (0,0) + offsets = micro_foot_offsets(cells_x, cells_y, micro_divisions, pitch) + + feet_union = None + for ox, oy in offsets: + instance = micro_foot_solid.translate(cq.Vector(ox, oy, 0)) + if feet_union is None: + feet_union = instance + else: + feet_union = feet_union.fuse(instance) + + # Create outer envelope to crop feet + # Envelope is centered at (half_l, half_w) in internal coords + # This matches box.py: rc.translate((*self.half_dim, 0.5)) + crop_env = ( + cq.Workplane("XY") + .placeSketch(rounded_rect_sketch(outer_l, outer_w, outer_rad)) + .extrude(-GR_BASE_HEIGHT - 1) + .translate(cq.Vector(half_l, half_w, 0.5)) + ) + + # Intersect feet with envelope + cropped_feet = crop_env.val().intersect(feet_union) + + # Create wall sleeve that extends above the feet (if sleeve_height > 0) + # Sleeve extends from z=GR_BASE_CLR (top of feet) to z=GR_BASE_CLR+sleeve_height + if sleeve_height > 0.01: # Use small threshold to avoid degenerate geometry + sleeve = ( + cq.Workplane("XY") + .placeSketch(rounded_rect_sketch(outer_l, outer_w, outer_rad)) + .extrude(sleeve_height) + .translate(cq.Vector(half_l, half_w, GR_BASE_CLR)) + ) + # Union feet with sleeve + base_solid = cropped_feet.fuse(sleeve.val()) + else: + base_solid = cropped_feet + + # Translate to final position - center at origin in XY + # In microfinity, render() does: translate((-self.half_l, -self.half_w, GR_BASE_HEIGHT)) + # We apply the XY centering here, and handle Z separately + base_solid = base_solid.translate(cq.Vector(-half_l, -half_w, 0)) + + base_cq = cq.Workplane("XY").newObject([base_solid]) + + # Convert to trimesh + mesh = cq_to_trimesh(base_cq, tol=0.01, ang_tol=0.1) + + # Adjust Z position + # After the CQ operations, the mesh has: + # - foot tip (narrow end) at z ≈ -4.75 + # - foot base + sleeve at z ≈ 0.25 + sleeve_height + # We want foot tip at z_base + z_min_mesh = mesh.vertices[:, 2].min() + z_shift = z_base - z_min_mesh + mesh.vertices[:, 2] += z_shift + + return mesh + + +# ----------------------------------------------------------------------------- +# Step 3: Replace base pipeline +# ----------------------------------------------------------------------------- +def replace_base_pipeline( + input_mesh: trimesh.Trimesh, + footprint: Union[Polygon, MultiPolygon], + frame: BottomFrame, + micro_divisions: int = 4, + pitch: float = GRU, + sleeve_height: float = SLEEVE_HEIGHT, + mesh_bounds: Optional[np.ndarray] = None, +) -> trimesh.Trimesh: + """Replace the foot region of input mesh with fresh micro-feet. + + This is the main entry point for the replace-base approach. + + Pipeline: + 1. Detect footprint and cell count from input mesh + 2. Compute z_split = z_min + 5.0mm + 3. Trim input to get top portion (above z_split) + 4. Generate micro-base with sleeve (extends above z_split) + 5. Union trimmed top + micro-base (overlap makes this robust) + 6. Validate result + + IMPORTANT LIMITATION: This replaces EVERYTHING below z_split. + Magnet holes, screw holes, and embossed text on the base will be lost. + + Args: + input_mesh: Input mesh with 1U feet + footprint: Shapely polygon of detected footprint (local coords) + frame: BottomFrame for coordinate transform + micro_divisions: Number of divisions per 1U (2 or 4) + pitch: 1U pitch (42mm) + sleeve_height: Overlap height above z_split (mm) + mesh_bounds: Optional mesh bounds for accurate cell detection + + Returns: + Output mesh with micro-feet + + Raises: + ValueError: If input is invalid or result fails validation + """ + # Get mesh bounds + if mesh_bounds is None: + mesh_bounds = input_mesh.bounds + + z_min = float(mesh_bounds[0, 2]) + z_split = z_min + Z_SPLIT_HEIGHT + + # Detect cell count from footprint/bounds + fp_bounds = footprint.bounds # (minx, miny, maxx, maxy) + + # Use mesh bounds for dimensions (full foot coverage at base) + width = mesh_bounds[1, 0] - mesh_bounds[0, 0] + height = mesh_bounds[1, 1] - mesh_bounds[0, 1] + + # Gridfinity convention: overall_dim = N * pitch - GR_TOL + cells_x = int(round((width + GR_TOL) / pitch)) + cells_y = int(round((height + GR_TOL) / pitch)) + cells_x = max(1, cells_x) + cells_y = max(1, cells_y) + + print("Replace base pipeline:") + print(f" Input z_min: {z_min:.3f}mm, z_split: {z_split:.3f}mm") + print(f" Detected grid: {cells_x}x{cells_y} cells") + print(f" Micro divisions: {micro_divisions}") + + # Step 1: Trim input to keep top portion + print(f" Trimming input above z={z_split:.3f}...") + trimmed_top = trim_mesh_above_z(input_mesh, z_split) + print(f" Trimmed top: {len(trimmed_top.vertices)} vertices, {len(trimmed_top.faces)} faces") + + # Verify trimmed top has reasonable geometry + if len(trimmed_top.faces) < 10: + raise ValueError( + f"Trimmed top has too few faces ({len(trimmed_top.faces)}). " + f"Check that z_split={z_split:.3f} is correct." + ) + + # Step 2: Generate micro-base with sleeve + print(f" Generating micro-base with {sleeve_height:.2f}mm sleeve...") + + # The micro-base needs to be positioned correctly: + # - X/Y centered on the mesh (not necessarily at origin) + # - Z starts at z_min (foot tip) and extends to z_split + sleeve_height + + # Get mesh center in XY + mesh_center_x = (mesh_bounds[0, 0] + mesh_bounds[1, 0]) / 2 + mesh_center_y = (mesh_bounds[0, 1] + mesh_bounds[1, 1]) / 2 + + micro_base = generate_micro_base_with_sleeve( + cells_x=cells_x, + cells_y=cells_y, + micro_divisions=micro_divisions, + z_base=z_min, + sleeve_height=sleeve_height, + pitch=pitch, + ) + + # The micro_base is already centered at origin by generate_micro_base_with_sleeve. + # We only need to shift if the input mesh is NOT centered at origin. + # For standard microfinity meshes, both are centered at origin. + if abs(mesh_center_x) > 0.01 or abs(mesh_center_y) > 0.01: + micro_base.vertices[:, 0] += mesh_center_x + micro_base.vertices[:, 1] += mesh_center_y + + print(f" Micro-base: {len(micro_base.vertices)} vertices, {len(micro_base.faces)} faces") + print(f" Base z range: [{micro_base.vertices[:, 2].min():.3f}, {micro_base.vertices[:, 2].max():.3f}]") + + # Step 3: Union trimmed top + micro-base + print(" Performing union...") + + manifold_top = trimesh_to_manifold(trimmed_top) + manifold_base = trimesh_to_manifold(micro_base) + + result_manifold = manifold_top + manifold_base + result = manifold_to_trimesh(result_manifold) + + print(f" Result: {len(result.vertices)} vertices, {len(result.faces)} faces") + + # Step 4: Validate result + print(" Validating result...") + + if not result.is_watertight: + print(" WARNING: Result is not watertight!") + + if result.volume <= 0: + raise ValueError("Result has non-positive volume") + + # Check for tiny disconnected components + components = result.split(only_watertight=False) + if len(components) > 1: + print(f" WARNING: Result has {len(components)} disconnected components") + # Keep only the largest component + largest = max(components, key=lambda m: m.volume if m.is_watertight else 0) + if largest.volume > result.volume * 0.9: + print(f" Keeping largest component (volume={largest.volume:.2f}mm^3)") + result = largest + else: + print(f" WARNING: Largest component is only {largest.volume/result.volume*100:.1f}% of total") + + print(f" Done. Output volume: {result.volume:.2f}mm^3") + + return result + + +# ----------------------------------------------------------------------------- +# High-level API +# ----------------------------------------------------------------------------- +def convert_to_micro( + input_mesh: trimesh.Trimesh, + micro_divisions: int = 4, +) -> trimesh.Trimesh: + """Convert a 1U Gridfinity box to micro-divided feet. + + This is a convenience wrapper that handles footprint detection + and calls the replace_base_pipeline. + + Args: + input_mesh: Input mesh with standard 1U feet + micro_divisions: Number of divisions (2 or 4) + + Returns: + Output mesh with micro-feet + """ + from meshcutter.core.detection import detect_aligned_frame + + frame, footprint = detect_aligned_frame(input_mesh, force_z_up=True) + + return replace_base_pipeline( + input_mesh=input_mesh, + footprint=footprint, + frame=frame, + micro_divisions=micro_divisions, + mesh_bounds=input_mesh.bounds, + ) diff --git a/meshcutter/core/validation.py b/meshcutter/core/validation.py new file mode 100644 index 0000000..c7dd382 --- /dev/null +++ b/meshcutter/core/validation.py @@ -0,0 +1,431 @@ +#! /usr/bin/env python3 +# +# meshcutter.core.validation - Placement validation and sanity checks +# +# Provides utilities to verify cutter placement against input mesh, +# catching common issues like misaligned grids or insufficient coverage. +# + +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +import numpy as np +import trimesh + +from microfinity.core.constants import GR_BASE_CLR, GR_BASE_HEIGHT, GR_TOL, GRU + + +def validate_cutter_placement( + input_mesh: trimesh.Trimesh, + cutter_mesh: trimesh.Trimesh, + cell_centers: List[Tuple[float, float]], + expected_cells: Optional[int] = None, + margin: float = 5.0, +) -> Dict: + """Validate cutter placement against input mesh. + + Checks: + 1. Cell count matches expected (if provided) + 2. Cutter XY bounds overlap input mesh with sufficient margin + 3. Cutter Z range covers the foot region + + Args: + input_mesh: Original input mesh + cutter_mesh: Generated cutter mesh + cell_centers: List of detected (x, y) cell centers + expected_cells: Expected number of cells (optional) + margin: Minimum XY overlap margin in mm (default 5.0) + + Returns: + Dict with validation results: + - cell_count: Number of detected cells + - expected_cells: Expected count (if provided) + - cells_match: Whether counts match + - xy_overlap: (overlap_x, overlap_y) in mm + - xy_margin_ok: Whether margin is sufficient + - z_covers_foot: Whether Z range covers foot region + - input_bounds: Input mesh bounds + - cutter_bounds: Cutter mesh bounds + - issues: List of issue descriptions + """ + input_bounds = input_mesh.bounds # [[minx,miny,minz], [maxx,maxy,maxz]] + cutter_bounds = cutter_mesh.bounds + + # XY overlap calculation + xy_overlap_x = min(input_bounds[1, 0], cutter_bounds[1, 0]) - max(input_bounds[0, 0], cutter_bounds[0, 0]) + xy_overlap_y = min(input_bounds[1, 1], cutter_bounds[1, 1]) - max(input_bounds[0, 1], cutter_bounds[0, 1]) + + # Z coverage check (foot region is z=0 to ~5mm) + foot_z_min = 0.0 + foot_z_max = GR_BASE_HEIGHT + GR_BASE_CLR # ~5.0mm + z_covers_foot = (cutter_bounds[0, 2] <= foot_z_min) and (cutter_bounds[1, 2] >= foot_z_max) + + # Cell count check + cells_match = True + if expected_cells is not None: + cells_match = len(cell_centers) == expected_cells + + # XY margin check + xy_margin_ok = xy_overlap_x >= margin and xy_overlap_y >= margin + + # Collect issues + issues = [] + if not cells_match: + issues.append(f"Cell count mismatch: detected {len(cell_centers)}, expected {expected_cells}") + if not xy_margin_ok: + issues.append(f"Insufficient XY overlap: {xy_overlap_x:.2f}mm x {xy_overlap_y:.2f}mm (need >= {margin}mm)") + if not z_covers_foot: + issues.append( + f"Cutter Z range [{cutter_bounds[0, 2]:.2f}, {cutter_bounds[1, 2]:.2f}] " + f"doesn't cover foot region [0, {foot_z_max:.2f}]" + ) + + result = { + "cell_count": len(cell_centers), + "expected_cells": expected_cells, + "cells_match": cells_match, + "xy_overlap": (xy_overlap_x, xy_overlap_y), + "xy_margin_ok": xy_margin_ok, + "z_covers_foot": z_covers_foot, + "input_bounds": input_bounds.tolist(), + "cutter_bounds": cutter_bounds.tolist(), + "issues": issues, + "valid": len(issues) == 0, + } + + return result + + +def print_placement_report(validation: Dict, verbose: bool = True) -> None: + """Print human-readable placement validation report. + + Args: + validation: Dict from validate_cutter_placement() + verbose: If True, print full details; if False, only issues + """ + print("=== Cutter Placement Validation ===") + + if verbose: + print(f"Cells detected: {validation['cell_count']}", end="") + if validation["expected_cells"] is not None: + print(f" (expected: {validation['expected_cells']})") + else: + print() + + print(f"XY overlap: {validation['xy_overlap'][0]:.2f}mm x {validation['xy_overlap'][1]:.2f}mm") + print(f"Z covers foot region: {validation['z_covers_foot']}") + + print( + f"Input bounds: X[{validation['input_bounds'][0][0]:.2f}, {validation['input_bounds'][1][0]:.2f}] " + f"Y[{validation['input_bounds'][0][1]:.2f}, {validation['input_bounds'][1][1]:.2f}] " + f"Z[{validation['input_bounds'][0][2]:.2f}, {validation['input_bounds'][1][2]:.2f}]" + ) + print( + f"Cutter bounds: X[{validation['cutter_bounds'][0][0]:.2f}, {validation['cutter_bounds'][1][0]:.2f}] " + f"Y[{validation['cutter_bounds'][0][1]:.2f}, {validation['cutter_bounds'][1][1]:.2f}] " + f"Z[{validation['cutter_bounds'][0][2]:.2f}, {validation['cutter_bounds'][1][2]:.2f}]" + ) + + if validation["valid"]: + print("Status: VALID") + else: + print("Status: ISSUES DETECTED") + for issue in validation["issues"]: + print(f" WARNING: {issue}") + + +def estimate_expected_cells(mesh_bounds: np.ndarray, pitch: float = GRU) -> int: + """Estimate expected number of 1U cells from mesh bounds. + + Uses Gridfinity convention: overall_dim ~ N * pitch - GR_TOL + So: N = round((dim + GR_TOL) / pitch) + + Args: + mesh_bounds: Mesh bounds [[minx,miny,minz], [maxx,maxy,maxz]] + pitch: 1U pitch (default 42mm) + + Returns: + Expected number of cells (cells_x * cells_y) + """ + width = mesh_bounds[1, 0] - mesh_bounds[0, 0] + height = mesh_bounds[1, 1] - mesh_bounds[0, 1] + + cells_x = max(1, int(round((width + GR_TOL) / pitch))) + cells_y = max(1, int(round((height + GR_TOL) / pitch))) + + return cells_x * cells_y + + +def validate_mesh_quality(mesh: trimesh.Trimesh) -> Dict: + """Validate mesh quality for boolean operations. + + Checks: + 1. Watertightness + 2. Consistent winding + 3. No degenerate faces + 4. Positive volume + + Args: + mesh: Trimesh to validate + + Returns: + Dict with quality metrics and issues + """ + issues = [] + + is_watertight = mesh.is_watertight + is_winding_consistent = mesh.is_winding_consistent + + # Check for degenerate faces + degen_mask = mesh.nondegenerate_faces(height=1e-8) + degen_count = len(degen_mask) - np.sum(degen_mask) + + volume = mesh.volume + has_positive_volume = volume > 0 + + if not is_watertight: + issues.append("Mesh is not watertight") + if not is_winding_consistent: + issues.append("Mesh winding is inconsistent") + if degen_count > 0: + issues.append(f"Mesh has {degen_count} degenerate faces") + if not has_positive_volume: + issues.append(f"Mesh has non-positive volume: {volume:.2f}") + + result = { + "is_watertight": is_watertight, + "is_winding_consistent": is_winding_consistent, + "degenerate_faces": int(degen_count), + "volume": volume, + "face_count": len(mesh.faces), + "vertex_count": len(mesh.vertices), + "issues": issues, + "valid": len(issues) == 0, + } + + return result + + +class CutterValidationError(Exception): + """Raised when cutter validation fails with critical errors.""" + + pass + + +def has_stacked_sheets_near_top( + mesh: trimesh.Trimesh, + n_samples: int = 100, + epsilon: float = 0.02, + band_multiplier: float = 3.0, +) -> Tuple[bool, str]: + """Detect stacked sheets near the top plane (symptom of internal faces). + + Casts rays downward from above the mesh and checks for multiple hits + clustered near the top plane. A valid solid should have clean enter/exit + pairs, not multiple surfaces at nearly the same Z level. + + The specific failure this detects: internal coincident faces created by + box-fuse operations, which triangulate into "stacked Z sheets" like + Z hits at [4.98, 5.00, 5.02] instead of just [5.00]. + + Args: + mesh: Trimesh to check + n_samples: Number of random XY sample points + epsilon: Epsilon value used in cutter generation + band_multiplier: Multiplier for epsilon to define "near top" band + + Returns: + Tuple of (has_stacked_sheets, details_string) + """ + bounds = mesh.bounds + top_z = bounds[1, 2] + band = epsilon * band_multiplier # Detection band around top plane + + # Sample random XY points within bounds (with margin to avoid edges) + margin = 1.0 + x_min, x_max = bounds[0, 0] + margin, bounds[1, 0] - margin + y_min, y_max = bounds[0, 1] + margin, bounds[1, 1] - margin + + if x_max <= x_min or y_max <= y_min: + return False, "mesh too small to sample" + + rng = np.random.default_rng(42) # Deterministic for reproducibility + xs = rng.uniform(x_min, x_max, n_samples) + ys = rng.uniform(y_min, y_max, n_samples) + + stacked_count = 0 + example_hits = None + + for x, y in zip(xs, ys): + # Cast ray downward from above + origins = np.array([[x, y, top_z + 10]]) + directions = np.array([[0, 0, -1]]) + locs, _, _ = mesh.ray.intersects_location(origins, directions) + + if len(locs) < 2: + continue + + z_hits = locs[:, 2] + + # Count hits near top_z (within band) + near_top_mask = np.abs(z_hits - top_z) < band + near_top_hits = z_hits[near_top_mask] + + # Check for 3+ total hits with at least 2 distinct near top + # (normal solid: 2 hits for enter/exit; stacked: 3+ with clustering) + if len(z_hits) >= 3 and len(near_top_hits) >= 2: + unique_near = np.unique(np.round(near_top_hits, 3)) + if len(unique_near) >= 2: + stacked_count += 1 + if example_hits is None: + example_hits = sorted([round(z, 3) for z in z_hits]) + + if stacked_count > 0: + details = f"{stacked_count}/{n_samples} rays found stacked sheets" + if example_hits: + details += f"; example Z hits: {example_hits}" + return True, details + + return False, "" + + +def validate_cutter_geometry( + mesh: trimesh.Trimesh, + name: str = "cutter", + epsilon: float = 0.02, + raise_on_error: bool = True, +) -> Dict: + """Validate cutter mesh geometry for boolean operations. + + Performs comprehensive validation including: + 1. Watertightness (critical) + 2. Stacked sheet detection (critical - catches internal faces) + 3. Winding consistency + 4. Single component check + + Args: + mesh: Trimesh to validate + name: Name for error messages + epsilon: Epsilon value used for coplanar avoidance + raise_on_error: If True, raise CutterValidationError on critical failure + + Returns: + Dict with validation results + + Raises: + CutterValidationError: If raise_on_error=True and critical validation fails + """ + errors = [] + warnings = [] + + # Check watertight (critical) + is_watertight = mesh.is_watertight + if not is_watertight: + errors.append(f"{name} is not watertight") + + # Check winding consistency (warning) + if not mesh.is_winding_consistent: + warnings.append(f"{name} has inconsistent winding") + + # Check for stacked sheets near top (critical) + stacked, stacked_details = has_stacked_sheets_near_top(mesh, epsilon=epsilon) + if stacked: + errors.append(f"{name} has stacked sheets (internal faces): {stacked_details}") + + # Check single component (warning if multiple) + try: + components = mesh.split(only_watertight=False) + if len(components) > 1: + warnings.append(f"{name} has {len(components)} components (expected 1)") + except Exception: + pass + + result = { + "is_watertight": is_watertight, + "has_stacked_sheets": stacked, + "stacked_details": stacked_details, + "errors": errors, + "warnings": warnings, + "valid": len(errors) == 0, + } + + if raise_on_error and errors: + raise CutterValidationError("Cutter validation failed:\n" + "\n".join(f" - {e}" for e in errors)) + + return result + + +def quick_z_hit_check( + mesh: trimesh.Trimesh, + sample_points: Optional[List[Tuple[float, float]]] = None, +) -> Dict: + """Quick diagnostic check of Z-hit patterns at specific points. + + This is a targeted check that can be used to verify the specific + failure mode of stacked sheets. Returns a dict with hit information + at each sample point. + + Args: + mesh: Trimesh to check + sample_points: List of (x, y) points to sample. If None, uses + default grid based on mesh bounds. + + Returns: + Dict mapping (x, y) tuple to list of Z hits + """ + bounds = mesh.bounds + top_z = bounds[1, 2] + + if sample_points is None: + # Default: sample at center and midpoints + margin = 2.0 + cx = (bounds[0, 0] + bounds[1, 0]) / 2 + cy = (bounds[0, 1] + bounds[1, 1]) / 2 + x_lo = bounds[0, 0] + margin + x_hi = bounds[1, 0] - margin + y_lo = bounds[0, 1] + margin + y_hi = bounds[1, 1] - margin + + sample_points = [ + (cx, cy), # Center + (x_lo, cy), # Left + (x_hi, cy), # Right + (cx, y_lo), # Bottom + (cx, y_hi), # Top + ] + + results = {} + for x, y in sample_points: + origins = np.array([[x, y, top_z + 10]]) + directions = np.array([[0, 0, -1]]) + locs, _, _ = mesh.ray.intersects_location(origins, directions) + + z_hits = sorted([round(z, 4) for z in locs[:, 2]]) if len(locs) > 0 else [] + results[(round(x, 2), round(y, 2))] = z_hits + + return results + + +def print_mesh_quality_report(quality: Dict, name: str = "Mesh") -> None: + """Print mesh quality report. + + Args: + quality: Dict from validate_mesh_quality() + name: Name to use in report header + """ + print(f"=== {name} Quality ===") + print(f"Vertices: {quality['vertex_count']}, Faces: {quality['face_count']}") + print(f"Volume: {quality['volume']:.2f} mm³") + print(f"Watertight: {quality['is_watertight']}") + print(f"Consistent winding: {quality['is_winding_consistent']}") + + if quality["degenerate_faces"] > 0: + print(f"Degenerate faces: {quality['degenerate_faces']}") + + if quality["valid"]: + print("Status: VALID") + else: + print("Status: ISSUES DETECTED") + for issue in quality["issues"]: + print(f" WARNING: {issue}") diff --git a/meshcutter/tests/test_cq_cutter.py b/meshcutter/tests/test_cq_cutter.py new file mode 100644 index 0000000..5b0644a --- /dev/null +++ b/meshcutter/tests/test_cq_cutter.py @@ -0,0 +1,285 @@ +"""Tests for meshcutter.core.cq_cutter module. + +Tests the CadQuery-based cutter generation for Gridfinity micro-feet. +""" + +import pytest +import trimesh + +from meshcutter.core.cq_cutter import ( + generate_1u_foot_cq, + generate_micro_foot_cq, + generate_cell_cutter_cq, + generate_grid_cutter_mesh, + generate_grid_cutter_meshes, + cq_to_trimesh, + micro_foot_offsets, + get_cell_cutter_volume, + get_1u_foot_volume, + get_micro_foot_volume, +) + + +class TestFootGeneration: + """Tests for foot solid generation.""" + + def test_1u_foot_dimensions(self): + """1U foot should have correct dimensions.""" + foot = generate_1u_foot_cq(cropped=False) + bb = foot.val().BoundingBox() + + # Uncropped foot is 42mm + assert abs(bb.xlen - 42.0) < 0.1 + assert abs(bb.ylen - 42.0) < 0.1 + + def test_1u_foot_cropped_dimensions(self): + """Cropped 1U foot should be 41.5mm (42 - 0.5 tolerance).""" + foot = generate_1u_foot_cq(cropped=True) + bb = foot.val().BoundingBox() + + assert abs(bb.xlen - 41.5) < 0.1 + assert abs(bb.ylen - 41.5) < 0.1 + + def test_micro_foot_dimensions(self): + """Micro foot for div=4 should be 10mm.""" + foot = generate_micro_foot_cq(micro_divisions=4) + bb = foot.val().BoundingBox() + + # micro_pitch = 42/4 = 10.5, foot_size = 10.5 - 0.5 = 10.0 + assert abs(bb.xlen - 10.0) < 0.1 + assert abs(bb.ylen - 10.0) < 0.1 + + def test_micro_foot_with_reduction(self): + """Micro foot with size_reduction should be smaller.""" + foot_normal = generate_micro_foot_cq(micro_divisions=4, size_reduction=0.0) + foot_reduced = generate_micro_foot_cq(micro_divisions=4, size_reduction=0.5) + + bb_normal = foot_normal.val().BoundingBox() + bb_reduced = foot_reduced.val().BoundingBox() + + assert bb_normal.xlen - bb_reduced.xlen > 0.4 # Should be ~0.5 smaller + + +class TestMicroFootOffsets: + """Tests for micro foot offset calculations.""" + + def test_div1_offsets(self): + """div=1 should return single offset at origin.""" + offsets = micro_foot_offsets(1) + assert offsets == [(0.0, 0.0)] + + def test_div2_offsets(self): + """div=2 should return 4 offsets.""" + offsets = micro_foot_offsets(2) + assert len(offsets) == 4 + + # Offsets should be at +/- 10.5mm + xs = sorted(set(o[0] for o in offsets)) + assert len(xs) == 2 + assert abs(xs[0] - (-10.5)) < 0.01 + assert abs(xs[1] - 10.5) < 0.01 + + def test_div4_offsets(self): + """div=4 should return 16 offsets.""" + offsets = micro_foot_offsets(4) + assert len(offsets) == 16 + + # Offsets should be at -15.75, -5.25, 5.25, 15.75 + xs = sorted(set(o[0] for o in offsets)) + assert len(xs) == 4 + expected_xs = [-15.75, -5.25, 5.25, 15.75] + for x, expected in zip(xs, expected_xs): + assert abs(x - expected) < 0.01 + + +class TestCellCutter: + """Tests for cell cutter generation.""" + + def test_cell_cutter_volume(self): + """Cell cutter volume should be F1 - 16*Fm approximately.""" + # Clear cache to ensure fresh generation + generate_cell_cutter_cq.cache_clear() + + f1_vol = get_1u_foot_volume() + fm_vol = get_micro_foot_volume(4) + cutter_vol = get_cell_cutter_volume(4) + + # Cutter = F1 - 16*Fm (approximately, feet overlap at tips) + expected_approx = f1_vol - 16 * fm_vol + + # The actual cutter should be close but not exact due to overlap + # Expect within 10% of the naive calculation + assert cutter_vol > expected_approx * 0.8 + assert cutter_vol < f1_vol # Must be less than full foot + + def test_cell_cutter_watertight(self): + """Cell cutter mesh should be watertight.""" + generate_cell_cutter_cq.cache_clear() + + cutter = generate_cell_cutter_cq(micro_divisions=4, epsilon=0.02) + mesh = cq_to_trimesh(cutter) + + # CadQuery-generated meshes should be watertight + assert mesh.is_watertight + + def test_cell_cutter_div2(self): + """Cell cutter for div=2 should work.""" + generate_cell_cutter_cq.cache_clear() + + cutter = generate_cell_cutter_cq(micro_divisions=2, epsilon=0.02) + mesh = cq_to_trimesh(cutter) + + assert mesh.is_watertight + # div=2 cutter should be smaller (fewer, larger micro-feet) + vol_div2 = get_cell_cutter_volume(2) + vol_div4 = get_cell_cutter_volume(4) + assert vol_div2 < vol_div4 + + +class TestGridCutter: + """Tests for grid cutter generation.""" + + def test_single_cell_grid(self): + """Single cell grid should produce one mesh.""" + centers = [(0.0, 0.0)] + meshes = generate_grid_cutter_meshes(centers, micro_divisions=4, add_channels=False) + + assert len(meshes) == 1 + assert meshes[0].is_watertight + + def test_multi_cell_grid_no_channels(self): + """Multi-cell grid without channels should produce N meshes.""" + centers = [(-21.0, 0.0), (21.0, 0.0)] # 2x1 grid + meshes = generate_grid_cutter_meshes(centers, micro_divisions=4, add_channels=False) + + assert len(meshes) == 2 # One per cell, no channel + + def test_multi_cell_grid_with_channels(self): + """Multi-cell grid with channels should include channel mesh.""" + centers = [(-21.0, 0.0), (21.0, 0.0)] # 2x1 grid + meshes = generate_grid_cutter_meshes(centers, micro_divisions=4, add_channels=True) + + assert len(meshes) == 3 # Two cells + one channel mesh + + def test_grid_cutter_mesh_concatenation(self): + """generate_grid_cutter_mesh should concatenate all cells.""" + centers = [(-21.0, 0.0), (21.0, 0.0)] + mesh = generate_grid_cutter_mesh(centers, micro_divisions=4, add_channels=False) + + # Should be a single mesh + assert isinstance(mesh, trimesh.Trimesh) + + # Volume should be approximately 2x single cell + single_vol = get_cell_cutter_volume(4) + assert mesh.volume > single_vol * 1.8 + assert mesh.volume < single_vol * 2.2 + + +class TestCutterPlacement: + """Tests for cutter placement and Z-orientation.""" + + def test_flip_z_orientation(self): + """With flip_z=True, cutter should extend from z=-epsilon upward.""" + centers = [(0.0, 0.0)] + meshes = generate_grid_cutter_meshes(centers, micro_divisions=4, epsilon=0.02, flip_z=True) + + mesh = meshes[0] + z_min = mesh.vertices[:, 2].min() + z_max = mesh.vertices[:, 2].max() + + # Should start at -epsilon + assert abs(z_min - (-0.02)) < 0.01 + # Should extend upward ~5mm + assert z_max > 4.5 + assert z_max < 5.5 + + def test_no_flip_z(self): + """With flip_z=False, cutter is in CadQuery's native orientation.""" + centers = [(0.0, 0.0)] + meshes = generate_grid_cutter_meshes(centers, micro_divisions=4, epsilon=0.02, flip_z=False) + + mesh = meshes[0] + z_min = mesh.vertices[:, 2].min() + z_max = mesh.vertices[:, 2].max() + + # In CQ orientation, foot tip is at positive Z + # (after mirror in generate_*_foot_cq) + # The exact values depend on CQ internals + assert z_max > z_min + + +class TestOvershotAndWallCut: + """Tests for overshoot and wall_cut parameters.""" + + def test_overshoot_increases_volume(self): + """Overshoot should increase cutter volume.""" + generate_cell_cutter_cq.cache_clear() + + cutter_plain = generate_cell_cutter_cq(4, 0.02, overshoot=0.0, wall_cut=0.0) + cutter_overshoot = generate_cell_cutter_cq(4, 0.02, overshoot=1.0, wall_cut=0.0) + + vol_plain = cutter_plain.val().Volume() + vol_overshoot = cutter_overshoot.val().Volume() + + assert vol_overshoot > vol_plain + + def test_wall_cut_increases_volume(self): + """Wall cut should increase cutter volume (smaller micro-feet).""" + generate_cell_cutter_cq.cache_clear() + + cutter_plain = generate_cell_cutter_cq(4, 0.02, overshoot=0.0, wall_cut=0.0) + cutter_wall = generate_cell_cutter_cq(4, 0.02, overshoot=0.0, wall_cut=0.5) + + vol_plain = cutter_plain.val().Volume() + vol_wall = cutter_wall.val().Volume() + + assert vol_wall > vol_plain + + +class TestMicroFootMatchesMicrofinity: + """Tests that meshcutter micro-feet match microfinity's output exactly.""" + + def test_micro_foot_matches_microfinity(self): + """Meshcutter micro_foot should match microfinity box.micro_foot().""" + from microfinity.parts.box import GridfinityBox + + # Generate feet from both sources + box = GridfinityBox(1, 1, 1, micro_divisions=4) + mf_foot = box.micro_foot() + mc_foot = generate_micro_foot_cq(micro_divisions=4) + + # Convert to trimesh for comparison + mf_mesh = cq_to_trimesh(mf_foot) + mc_mesh = cq_to_trimesh(mc_foot) + + # Bounds should match + assert abs(mf_mesh.bounds[0][0] - mc_mesh.bounds[0][0]) < 0.01 + assert abs(mf_mesh.bounds[1][0] - mc_mesh.bounds[1][0]) < 0.01 + + # Volume should match + assert abs(mf_mesh.volume - mc_mesh.volume) < 0.1 + + def test_micro_foot_width_at_straight_section(self): + """Micro-foot should be 5.2mm wide at straight section (Z=-4.0 in CQ).""" + foot = generate_micro_foot_cq(micro_divisions=4) + mesh = cq_to_trimesh(foot) + + # Find vertices at Z≈-4.0 (straight section in CQ coordinates) + z_target = -4.0 + tol = 0.15 + verts = mesh.vertices[abs(mesh.vertices[:, 2] - z_target) < tol] + + assert len(verts) > 0, "No vertices found at straight section" + width = verts[:, 0].max() - verts[:, 0].min() + + # Should be 5.2mm (10.0mm foot at straight section with chamfer profile) + assert abs(width - 5.2) < 0.1, f"Width {width} != 5.2mm" + + def test_micro_foot_top_width(self): + """Micro-foot should be 10.0mm wide at top (foot_size = micro_pitch - GR_TOL).""" + foot = generate_micro_foot_cq(micro_divisions=4) + bb = foot.val().BoundingBox() + + # Top width should be 10.0mm + assert abs(bb.xlen - 10.0) < 0.1 + assert abs(bb.ylen - 10.0) < 0.1 diff --git a/microfinity/__init__.py b/microfinity/__init__.py index 71bf05e..fe408bc 100644 --- a/microfinity/__init__.py +++ b/microfinity/__init__.py @@ -4,7 +4,7 @@ # fmt: off __project__ = 'microfinity' -__version__ = '0.6.0' +__version__ = '1.2.0' # fmt: on VERSION = __project__ + "-" + __version__ diff --git a/microfinity/core/base.py b/microfinity/core/base.py index 633dcdd..243f07d 100644 --- a/microfinity/core/base.py +++ b/microfinity/core/base.py @@ -230,13 +230,19 @@ def grid_centres(self): def micro_grid_centres(self): """Returns center points for micro-grid cells. - Micro feet are centered on the envelope (aligned with half_dim) to ensure - proper intersection regardless of fractional sizes. - - The total span of feet exactly matches the outer envelope dimensions: - - Feet span: (micro_count - 1) * micro_pitch + foot_size - - Where foot_size = micro_pitch - GR_TOL - - Total: micro_count * micro_pitch - GR_TOL = length_u * GRU - GR_TOL = outer_l + Positions micro feet at micro_pitch intervals, with outermost feet + extending GR_TOL/2 (0.25mm) past the envelope boundary. This matches + how 1U feet work: they're created at 42mm but cropped by the 41.5mm + envelope, putting the profile 0.25mm "into" the chamfer at the edge. + + The envelope intersection (in render_shell) crops the outermost feet, + producing identical chamfer profiles at the boundary for both 1U and + micro feet. + + Key insight: micro feet are created at micro_pitch size (10.5mm), not + micro_pitch - GR_TOL (10.0mm). The 0.5mm gaps between adjacent feet + come from their chamfered profiles overlapping/merging, not from the + foot size being smaller than the pitch. """ if self.micro_divisions <= 1: return self.grid_centres @@ -247,13 +253,24 @@ def micro_grid_centres(self): micro_count_l = int(round(v_l)) micro_count_w = int(round(v_w)) - # Micro lattice half-extents (distance from center to edge foot centers) - micro_half_l = (micro_count_l - 1) * self.micro_pitch / 2 - micro_half_w = (micro_count_w - 1) * self.micro_pitch / 2 + # Micro foot size matches micro_pitch (like 1U feet match GRU) + # The envelope intersection will crop the outermost feet + foot_size = self.micro_pitch # 10.5mm for divisions=4 + + # Position feet so outermost foot edges extend GR_TOL/2 past envelope + # This matches 1U behavior: feet at 42mm, envelope at 41.5mm + # After cropping, the chamfer profile is 0.25mm "in" at the edge + env_min_l = -self.outer_l / 2 + env_min_w = -self.outer_w / 2 + + # First foot center: half foot size from envelope edge + # (foot edge will be at env_min - GR_TOL/2, gets cropped to env_min) + first_l = env_min_l + foot_size / 2 - GR_TOL / 2 + first_w = env_min_w + foot_size / 2 - GR_TOL / 2 - # Center the micro lattice on the envelope (which is at half_l, half_w) + # Generate centres, offset by half_l/half_w to match bin coordinate system return [ - (x * self.micro_pitch - micro_half_l + self.half_l, y * self.micro_pitch - micro_half_w + self.half_w) + (first_l + x * self.micro_pitch + self.half_l, first_w + y * self.micro_pitch + self.half_w) for x in range(micro_count_l) for y in range(micro_count_w) ] diff --git a/pyproject.toml b/pyproject.toml index 77d4372..5eb2c8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "microfinity" -version = "0.6.0" +version = "1.2.0" description = "A python library to make Gridfinity compatible objects with CadQuery." readme = "README.md" license = {text = "MIT"} @@ -74,7 +74,7 @@ include = ["microfinity*", "meshcutter*"] [tool.commitizen] name = "cz_conventional_commits" -version = "0.6.0" +version = "1.2.0" tag_format = "v$version" version_files = [ "microfinity/__init__.py:__version__", @@ -100,6 +100,10 @@ testpaths = ["tests"] filterwarnings = [ "ignore::DeprecationWarning:nptyping.typing_", ] +markers = [ + "integration: end-to-end integration tests", + "golden: golden comparison tests", +] [tool.coverage.run] source = ["microfinity", "meshcutter"] diff --git a/tests/test_meshcutter/test_grid.py b/tests/test_meshcutter/test_grid.py index 97652b2..68ea22a 100644 --- a/tests/test_meshcutter/test_grid.py +++ b/tests/test_meshcutter/test_grid.py @@ -136,7 +136,7 @@ def test_clearance_expands_slots(self): assert mask_with_clearance.area > mask_no_clearance.area def test_clips_to_footprint(self): - """Mask should not extend beyond footprint.""" + """Mask clips to footprint when clip_to_footprint=True.""" footprint = box(5, 5, 37, 37) # Smaller than grid pitch = 10.5 @@ -144,9 +144,10 @@ def test_clips_to_footprint(self): footprint=footprint, pitch=pitch, slot_width=2.0, + clip_to_footprint=True, # Enable clipping to test clipping behavior ) - # Mask bounds should not exceed footprint bounds + # Mask bounds should not exceed footprint bounds when clipped mask_bounds = mask.bounds foot_bounds = footprint.bounds diff --git a/tests/test_meshcutter/test_integration.py b/tests/test_meshcutter/test_integration.py index ffd955e..ba08bfb 100644 --- a/tests/test_meshcutter/test_integration.py +++ b/tests/test_meshcutter/test_integration.py @@ -6,6 +6,9 @@ # import pytest + +# Mark all tests in this module as integration tests +pytestmark = pytest.mark.integration import numpy as np import trimesh import tempfile diff --git a/tests/test_microgrid.py b/tests/test_microgrid.py index b762260..26218c3 100644 --- a/tests/test_microgrid.py +++ b/tests/test_microgrid.py @@ -135,12 +135,12 @@ def test_micro_grid_centres_centered_on_envelope_fractional(self): assert _almost_same(sum(xs) / len(xs), box.half_l, tol=0.01) assert _almost_same(sum(ys) / len(ys), box.half_w, tol=0.01) - def test_micro_feet_span_matches_envelope_x(self): - """Verify micro feet X span matches envelope X dimension.""" + def test_micro_feet_span_exceeds_envelope_x(self): + """Verify micro feet X span exceeds envelope by GR_TOL (before cropping).""" box = GridfinityBox(1.25, 0.5, 3, micro_divisions=4) centres = box.micro_grid_centres - foot_size = box.micro_pitch - GR_TOL # 10.0 + foot_size = box.micro_pitch # 10.5mm (full pitch, like 1U feet) foot_half = foot_size / 2 # Calculate actual feet span in X @@ -149,15 +149,16 @@ def test_micro_feet_span_matches_envelope_x(self): feet_x_max = max(xs) + foot_half feet_span_x = feet_x_max - feet_x_min - # Should match outer_l = 52.0 - assert _almost_same(feet_span_x, 52.0, tol=0.01) + # Should exceed outer_l by GR_TOL (overhang on both sides) + # outer_l = 52.0, span = 52.0 + 0.5 = 52.5 + assert _almost_same(feet_span_x, box.outer_l + GR_TOL, tol=0.01) - def test_micro_feet_span_matches_envelope_y(self): - """Verify micro feet Y span matches envelope Y dimension.""" + def test_micro_feet_span_exceeds_envelope_y(self): + """Verify micro feet Y span exceeds envelope by GR_TOL (before cropping).""" box = GridfinityBox(1.25, 0.5, 3, micro_divisions=4) centres = box.micro_grid_centres - foot_size = box.micro_pitch - GR_TOL # 10.0 + foot_size = box.micro_pitch # 10.5mm (full pitch, like 1U feet) foot_half = foot_size / 2 # Calculate actual feet span in Y @@ -166,8 +167,9 @@ def test_micro_feet_span_matches_envelope_y(self): feet_y_max = max(ys) + foot_half feet_span_y = feet_y_max - feet_y_min - # Should match outer_w = 20.5 - assert _almost_same(feet_span_y, 20.5, tol=0.01) + # Should exceed outer_w by GR_TOL (overhang on both sides) + # outer_w = 20.5, span = 20.5 + 0.5 = 21.0 + assert _almost_same(feet_span_y, box.outer_w + GR_TOL, tol=0.01) def test_fractional_renders_without_error(self): """Verify fractional micro-grid box renders successfully.""" @@ -181,6 +183,115 @@ def test_fractional_renders_without_error(self): box.save_step_file(path=EXPORT_STEP_FILE_PATH) +@pytest.mark.skipif(SKIP_TEST_MICROGRID, reason="Skipped by SKIP_TEST_MICROGRID env var") +class TestMicroFootEnvelopeAlignment: + """Test that micro foot outer edges extend past bin envelope by GR_TOL/2. + + This matches 1U foot behavior: feet are created at full pitch size (42mm + for 1U, micro_pitch for micro), then cropped by envelope intersection. + The cropping puts the chamfer profile 0.25mm "in" at the boundary, + producing identical profiles for both foot types. + """ + + def test_envelope_overhang_1x1(self): + """Verify micro foot edges extend GR_TOL/2 past envelope for 1x1 box.""" + box = GridfinityBox(1, 1, 3, micro_divisions=4) + centres = box.micro_grid_centres + foot_size = box.micro_pitch # 10.5mm (NOT micro_pitch - GR_TOL) + + xs = [c[0] for c in centres] + ys = [c[1] for c in centres] + + # Envelope bounds (centered on half_l=0, half_w=0 for 1x1) + env_max_x = box.half_l + box.outer_l / 2 # 0 + 20.75 = 20.75 + env_min_x = box.half_l - box.outer_l / 2 # 0 - 20.75 = -20.75 + env_max_y = box.half_w + box.outer_w / 2 + env_min_y = box.half_w - box.outer_w / 2 + + eps = 1e-6 + overhang = GR_TOL / 2 # 0.25mm + + # Outermost foot outer edge should extend GR_TOL/2 past envelope + assert ( + abs((max(xs) + foot_size / 2) - (env_max_x + overhang)) < eps + ), f"Max X foot edge {max(xs) + foot_size/2} != {env_max_x + overhang}" + assert ( + abs((min(xs) - foot_size / 2) - (env_min_x - overhang)) < eps + ), f"Min X foot edge {min(xs) - foot_size/2} != {env_min_x - overhang}" + assert ( + abs((max(ys) + foot_size / 2) - (env_max_y + overhang)) < eps + ), f"Max Y foot edge {max(ys) + foot_size/2} != {env_max_y + overhang}" + assert ( + abs((min(ys) - foot_size / 2) - (env_min_y - overhang)) < eps + ), f"Min Y foot edge {min(ys) - foot_size/2} != {env_min_y - overhang}" + + def test_envelope_overhang_2x3(self): + """Verify micro foot edges extend GR_TOL/2 past envelope for 2x3 box.""" + box = GridfinityBox(2, 3, 3, micro_divisions=4) + centres = box.micro_grid_centres + foot_size = box.micro_pitch + + xs = [c[0] for c in centres] + ys = [c[1] for c in centres] + + env_max_x = box.half_l + box.outer_l / 2 + env_min_x = box.half_l - box.outer_l / 2 + env_max_y = box.half_w + box.outer_w / 2 + env_min_y = box.half_w - box.outer_w / 2 + + eps = 1e-6 + overhang = GR_TOL / 2 + + assert abs((max(xs) + foot_size / 2) - (env_max_x + overhang)) < eps + assert abs((min(xs) - foot_size / 2) - (env_min_x - overhang)) < eps + assert abs((max(ys) + foot_size / 2) - (env_max_y + overhang)) < eps + assert abs((min(ys) - foot_size / 2) - (env_min_y - overhang)) < eps + + def test_envelope_overhang_fractional(self): + """Verify micro foot edges extend GR_TOL/2 past envelope for fractional box.""" + box = GridfinityBox(1.25, 0.5, 3, micro_divisions=4) + centres = box.micro_grid_centres + foot_size = box.micro_pitch + + xs = [c[0] for c in centres] + ys = [c[1] for c in centres] + + env_max_x = box.half_l + box.outer_l / 2 + env_min_x = box.half_l - box.outer_l / 2 + env_max_y = box.half_w + box.outer_w / 2 + env_min_y = box.half_w - box.outer_w / 2 + + eps = 1e-6 + overhang = GR_TOL / 2 + + assert abs((max(xs) + foot_size / 2) - (env_max_x + overhang)) < eps + assert abs((min(xs) - foot_size / 2) - (env_min_x - overhang)) < eps + assert abs((max(ys) + foot_size / 2) - (env_max_y + overhang)) < eps + assert abs((min(ys) - foot_size / 2) - (env_min_y - overhang)) < eps + + def test_envelope_overhang_half_divisions(self): + """Verify envelope overhang with micro_divisions=2 (half-grid).""" + box = GridfinityBox(1.5, 1, 3, micro_divisions=2) + centres = box.micro_grid_centres + foot_size = box.micro_pitch # 21mm + + xs = [c[0] for c in centres] + ys = [c[1] for c in centres] + + env_max_x = box.half_l + box.outer_l / 2 + env_min_x = box.half_l - box.outer_l / 2 + env_max_y = box.half_w + box.outer_w / 2 + env_min_y = box.half_w - box.outer_w / 2 + + eps = 1e-6 + overhang = GR_TOL / 2 + + assert abs((max(xs) + foot_size / 2) - (env_max_x + overhang)) < eps + assert abs((min(xs) - foot_size / 2) - (env_min_x - overhang)) < eps + assert abs((max(ys) + foot_size / 2) - (env_max_y + overhang)) < eps + assert abs((min(ys) - foot_size / 2) - (env_min_y - overhang)) < eps + + @pytest.mark.skipif(SKIP_TEST_MICROGRID, reason="Skipped by SKIP_TEST_MICROGRID env var") class TestMicroGridValidation: """Test input validation for micro-grid parameters."""