diff --git a/.gitignore b/.gitignore index a3a9455..b388eac 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,12 @@ bld/ [Ll]og/ [Ll]ogs/ +# Mac file system stuff +.DS_Store + +# Project Output Files +output/* + # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot @@ -330,6 +336,10 @@ paket-files/ __pycache__/ *.pyc +# Python virtual environment +venv/ +path/ + # Cake - Uncomment if you are using it # tools/** # !tools/packages.config @@ -401,4 +411,6 @@ FodyWeavers.xsd *.sln.iml # output -**/output/** \ No newline at end of file +**/output/** +# Pytest +.pytest_cache/ diff --git a/README.md b/README.md index a46fd45..9b5fc57 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ LayerSafe generates parametric 3D tray designs featuring: - Adjustable dimensions (width and depth) - Hinged flap mechanisms for opening/closing - Customizable rails and base structure -- Built-in cutouts for organization +- Cutouts for **circular, square, hexagonal, and oval** bases, mixed sizes per tray +- Adjustable cutout wall angle (taper) to match sloped base edges +- Flap clearance so closed flaps rotate past seated bases - Support for single or double tray configurations - Export capabilities in STEP and STL formats @@ -47,10 +49,25 @@ The tray generator is designed to be run from the command line, making it easy f #### Basic Syntax ```bash -python Trays/tray_generator.py ... [options] +python Trays/tray_generator.py ... [options] ``` -> **⚠️ Important:** Base diameters should be measured as accurately as possible. Precision down to **0.1mm** is recommended for proper fit. Use quality calipers with good accuracy (±0.1mm or better) to measure your bases before generating the tray. +Each size is one base. What "size" means depends on the cutout shape: + +| Shape | `--cutout-shape` | Size measurement | +|-------|------------------|------------------| +| Circle (default) | `circle` | Base diameter | +| Square | `square` | Side length | +| Hexagon | `hex` | Across the flats | +| Oval | `oval` | `WIDTHxDEPTH` pair, e.g. `60x35` | + +Hex cutouts are oriented with their flats facing the tray edges (corners pointing sideways), so measure your hex bases across the flats—the natural caliper measurement. + +Oval sizes are given in tray orientation: width runs along the tray, depth front-to-back. If an oval is too deep for a row, swap the numbers (e.g. `35x60`) to stand it upright. Ovals too deep to sit in two straight rows are automatically nested against alternating edges when they fit (e.g. two 75x42 ovals on the standard tray). + +Shapes can be mixed in one tray by prefixing individual sizes with a shape name, e.g. `oval:60x35` or `hex:25.4`. Sizes without a prefix use `--cutout-shape` (circle by default). + +> **⚠️ Important:** Base sizes should be measured as accurately as possible. Precision down to **0.1mm** is recommended for proper fit. Use quality calipers with good accuracy (±0.1mm or better) to measure your bases before generating the tray. #### Simple Examples @@ -64,21 +81,58 @@ Generate a tray with mixed diameters (2× 25.4mm and 1× 31.6mm): python Trays/tray_generator.py 25.4 25.4 31.6 ``` +Generate a tray for five 29.8mm (across flats) hex bases: +```bash +python Trays/tray_generator.py 29.8 29.8 29.8 29.8 29.8 --cutout-shape hex +``` + +Generate a tray for oval bases (60mm wide, 30mm deep): +```bash +python Trays/tray_generator.py 60x30 60x30 45x25 --cutout-shape oval +``` + +Mix shapes in one tray — one oval among circles (deep ovals among small circles usually need `--force-linear-positions`): +```bash +python Trays/tray_generator.py oval:60x35 24.7 24.7 24.7 24.7 --force-linear-positions +``` + #### Available Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `--width` | float | 189.5 | Total tray width in mm | | `--depth` | float | 66.0 | Total tray depth in mm | +| `--cutout-shape` | choice | circle | Default cutout shape for unprefixed sizes: `circle`, `square`, `hex`, or `oval` | +| `--taper-angle` | float | shape default | Wall angle of the cutouts in degrees from vertical (default: 12.5 for circle, 5 for square/hex/oval). See [Matching sloped bases](#matching-sloped-bases) | +| `--flap-clearance` | float | 1.0 | Extra sideways clearance (mm per side) in the flap's part of square/hex/oval cutouts so the flap rotates closed past seated bases. Circle cutouts have their own rotation relief and ignore this | +| `--tolerance` | float | 0.55 | Fit tolerance added around each base (mm) | | `--safety-margin-x` | float | 6.5 | Horizontal margin from edges (mm) | | `--safety-margin-y` | float | 0.8 | Vertical margin from edges (mm) | -| `--tolerance` | float | 0.55 | Tolerance for circle fit (mm) | +| `--min-cutout-spacing` | float | 2.0 | Minimum gap (mm) between adjacent cutout edges | | `--edge-offsets` | space-separated floats | None | Edge offsets for each base (e.g., `0.5 0.5 0.5`) will reduce the depth of the base with the given amount without affecting the width. Useful for fine-tuning fit, especially on larger bases (mm) | | `--edge-adjusts` | space-separated floats | None | Edge adjustments for each base (e.g., `0.2 0.2 0.2`), independent of edge-offsets for additional fine-tuning, a larger value will give a larger flat-spot below the curved section (mm) | | `--single-sided` | flag | False | Generate a single-sided tray (default: double-sided) | -| `--force-linear-positions` | flag | False | Forces linear positioning (default: automatically selects linear or alternating positioning) | +| `--force-linear-positions` | flag | False | Forces straight-row positioning (default: bases too deep to stack are automatically nested against alternating edges — exact tangency for circles, conservative bounding-box spacing for square/hex/oval) | | `--output` | string | auto | Output filename (without extension) | +#### Matching sloped bases + +Many bases are narrower at the top than the bottom. Give the generator the **bottom** size and set the wall angle to match the slope: + +``` +taper_angle = atan((bottom_size - top_size) / (2 * base_height)) +``` + +A positive angle narrows the cutout toward the top (the usual case); a negative angle widens it. + +Example: hex bases measuring 29.8mm across flats at the bottom, 27.2mm at the top, 4mm tall → `atan(2.6 / 8)` ≈ 18°: + +```bash +python Trays/tray_generator.py 29.8 29.8 29.8 29.8 29.8 --cutout-shape hex --taper-angle 18.0 +``` + +The walls then keep the same clearance along the full height of the base. + #### Advanced Examples Adjust safety margins for a tight fit: @@ -96,14 +150,14 @@ Generate a single-sided tray (not double-sided): python Trays/tray_generator.py 31.6 31.6 31.6 --single-sided ``` -Apply edge offsets to customize base positioning: +Square bases with a reduced flap clearance: ```bash -python Trays/tray_generator.py 25.4 25.4 25.4 --edge-offsets 0.5 0.5 0.5 +python Trays/tray_generator.py 25.0 25.0 25.0 --cutout-shape square --flap-clearance 0.6 ``` -Apply edge adjustments for additional fine-tuning: +Apply edge offsets to customize base positioning: ```bash -python Trays/tray_generator.py 25.4 25.4 25.4 --edge-adjusts 0.2 0.2 0.2 +python Trays/tray_generator.py 25.4 25.4 25.4 --edge-offsets 0.5 0.5 0.5 ``` Specify a custom output filename: @@ -111,11 +165,6 @@ Specify a custom output filename: python Trays/tray_generator.py 31.6 31.6 31.6 --output my_custom_tray ``` -Combine multiple options: -```bash -python Trays/tray_generator.py 31.6 31.6 31.6 31.6 31.6 31.6 --safety-margin-y 0.4 --tolerance 0.6 --width 190 --single-sided --edge-offsets 0.2 0.2 0.2 0.2 0.2 0.2 --edge-adjusts 0.1 0.1 0.1 0.1 0.1 0.1 --output special_tray -``` - #### Getting Help View all available options: @@ -125,42 +174,43 @@ python Trays/tray_generator.py --help #### Output -Generated files are automatically saved to `Trays/output/`: +Generated files are saved to `Trays/output/` (regardless of the directory you run the command from): - **STL format** (`.stl`) — Suitable for 3D printing - **STEP format** (`.step`) — Suitable for CAD software and CNC machines -Filenames are auto-generated based on your diameter input (e.g., `tray_6x31.6mm.stl`), or you can specify a custom name with `--output`. - -### Jupyter Notebook (Optional) - -If you prefer an interactive notebook environment: -1. Open `Trays/tray_generator.ipynb` in Jupyter -2. Modify the default parameters in the notebook and run cells -3. View the 3D model preview in the notebook output +Filenames are auto-generated from your size input (e.g., `tray_6x31.6mm.stl`), or you can specify a custom name with `--output`. ### Python IDE Usage (Optional) To use in VS Code or another IDE: -1. Open `Trays/tray_generator.py` -2. Modify the default parameters in the "User-Adjustable Parameters" section +1. Open `Trays/tray_generator.py` +2. Adjust the parameters in the "User-Adjustable Parameters" section (see below) 3. Run the script (VS Code: F5 or Run button) ### Customizing Tray Parameters -For more detailed customization, you can edit the **User-Adjustable Parameters** section in `tray_generator.py`: +All tray geometry and generation defaults live in a single `TrayConfig` dataclass in `Trays/functions/tray_config.py` — dimensions, rail/flap/hinge geometry, cutout shape, taper, tolerances, and more. For deeper customization than the CLI exposes, either edit the defaults there or override individual fields in `tray_generator.py`: ```python -total_width = 189.5 # Overall tray width (mm) -total_depth = 66.0 # Overall tray depth (mm) -floor_thickness = 0.4 # Bottom thickness (mm) -base_height = 4.2 # Height of base section (mm) -rail_height = 8.4 # Height of side rails (mm) -rail_width = 4.8 # Width of side rails (mm) -flap_depth = 11.8 # Depth of hinged flap (mm) -flap_center_gap = 0.2 # Gap between flap and base (mm) -hinge_width = 2.8 # Width of hinge mechanism (mm) -hinge_height = 3.6 # Height of hinge mechanism (mm) -is_double_tray = True # Set to True for stacked tray configuration +config = TrayConfig( + total_width=200, # Overall tray width (mm) + rail_height=10.0, # Height of side rails (mm) + cutout_shape='hex', # 'circle', 'square', or 'hex' + is_double_tray=False, # Single-sided tray +) +``` + +### Adding a new cutout shape + +Shapes are pluggable: subclass `CutoutShape` in `Trays/functions/shapes.py`, implement `build()` (the 3D negative) and `circumradius()`, override `footprint()`/`layout_sizes()` if the shape's bounding box is not size × size, and register an instance in `SHAPES`. The CLI choices, layout, and orchestrator pick it up automatically. + +## Development + +Run the test suite (pure-math layout tests, no CAD required for most): + +```bash +pip install pytest +python -m pytest ``` ## License diff --git a/Trays/functions/__init__.py b/Trays/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Trays/functions/base_tray_generator.py b/Trays/functions/base_tray_generator.py index 8e32b80..09d71e1 100644 --- a/Trays/functions/base_tray_generator.py +++ b/Trays/functions/base_tray_generator.py @@ -1,29 +1,34 @@ from build123d import * from ocp_vscode import * +if __name__ == "__main__": + from tray_config import TrayConfig +else: + from .tray_config import TrayConfig + + +def generate_base_tray(config: TrayConfig): + """Generate the bare tray geometry (no cutouts) from a TrayConfig.""" + total_width = config.total_width + total_depth = config.total_depth + floor_thickness = config.floor_thickness + base_height = config.base_height + rail_height = config.rail_height + rail_width = config.rail_width + flap_center_gap = config.flap_center_gap + flap_depth = config.flap_depth + hinge_width = config.hinge_width + hinge_height = config.hinge_height + hinge_depth = config.hinge_depth + hinge_pin_radius = config.hinge_pin_radius + hinge_pin_length = config.hinge_pin_length + bottom_chamfer = config.bottom_chamfer + hinge_lock_radius = config.hinge_lock_radius + hinge_lock_offset = config.hinge_lock_offset + hinge_lock_depth = config.hinge_lock_depth + is_double_tray = config.is_double_tray + epsilon = config.epsilon -def generate_base_tray( - total_width=189.5, - total_depth=66.0, - floor_thickness=0.8, - base_heigth=4.2, - rail_height=8.4, - rail_width=4.8, - flap_center_gap=0.2, - flap_depth=11.8, - hinge_width=2.8, - hinge_height=3.6, - hinge_depth=17.5, - hinge_pin_diameter=1.4, - hinge_pin_length=3, - bottom_chamfer=0.4, - hinge_lock_radius=2, - hinge_lock_offset=0.5, - hinge_lock_depth=8.3, - is_double_tray=False, - epsilon=0.001, -): - """Generate tray geometry with all components.""" # Calculated Parameters center_width = total_width - 2 * rail_width center_depth = total_depth - 2 * (flap_depth + flap_center_gap) @@ -37,9 +42,9 @@ def generate_base_tray( hinge_negative_depth = hinge_depth + hinge_negative_space hinge_negative_height = hinge_height + hinge_negative_space hinge_pin_offset = ( - hinge_height - 2 * hinge_pin_diameter + hinge_top_offset) / 2 + hinge_height - 2 * hinge_pin_radius + hinge_top_offset) / 2 hinge_negative_fillet_radius = ( - hinge_pin_diameter + hinge_pin_offset + hinge_negative_space + hinge_pin_radius + hinge_pin_offset + hinge_negative_space ) hinge_depth += hinge_negative_space @@ -51,7 +56,7 @@ def generate_base_tray( Box( center_width, center_depth / 2, - base_heigth + hinge_top_offset, + base_height + hinge_top_offset, align=(Align.CENTER, Align.MAX, Align.MIN), ) @@ -91,12 +96,12 @@ def generate_base_tray( with Locations(hinge_negative_offset): with Locations(( -hinge_pin_length, - hinge_depth - 2 * hinge_pin_diameter - + hinge_depth - 2 * hinge_pin_radius - hinge_pin_offset * 2 + hinge_top_offset/2 + epsilon, hinge_pin_offset - hinge_negative_space + epsilon, )): Cylinder( - hinge_pin_diameter + hinge_negative_space, + hinge_pin_radius + hinge_negative_space, ( hinge_pin_length * 2 + hinge_width + 2 * hinge_negative_space @@ -157,7 +162,7 @@ def generate_base_tray( Box( flap_width, flap_depth, - base_heigth + hinge_top_offset, + base_height + hinge_top_offset, align=(Align.CENTER, Align.MIN, Align.MIN), ) @@ -173,11 +178,11 @@ def generate_base_tray( ) with Locations(( -hinge_pin_length, - hinge_depth - hinge_pin_diameter * 2 - hinge_pin_offset, + hinge_depth - hinge_pin_radius * 2 - hinge_pin_offset, hinge_pin_offset, )): Cylinder( - hinge_pin_diameter, + hinge_pin_radius, hinge_pin_length * 2 + hinge_width, rotation=(0, 90, 0), align=(Align.MAX, Align.MIN, Align.MIN), @@ -194,7 +199,7 @@ def generate_base_tray( with Locations(( -(flap_width / 2 - hinge_lock_radius + hinge_lock_offset), -total_depth / 2, - (base_heigth + hinge_top_offset) / 2, + (base_height + hinge_top_offset) / 2, )): Cylinder( hinge_lock_radius, @@ -204,7 +209,7 @@ def generate_base_tray( ) hinge_lock.part = split( hinge_lock.part, - flap.part.faces().filter_by(Plane.YZ).sort_by(Axis.X)[1], + Plane(flap.part.faces().filter_by(Plane.YZ).sort_by(Axis.X)[1]), ) hinge_lock.part = hinge_lock.part + mirror(hinge_lock.part, Plane.YZ) @@ -212,7 +217,7 @@ def generate_base_tray( with Locations(( -(flap_width / 2 - hinge_lock_radius + hinge_lock_offset), -total_depth / 2, - (base_heigth + hinge_top_offset) / 2, + (base_height + hinge_top_offset) / 2, )): Cylinder( hinge_lock_radius + hinge_negative_space, @@ -222,7 +227,7 @@ def generate_base_tray( ) hinge_lock_negative.part = split( hinge_lock_negative.part, - flap.part.faces().filter_by(Plane.YZ).sort_by(Axis.X)[1], + Plane(flap.part.faces().filter_by(Plane.YZ).sort_by(Axis.X)[1]), ) hinge_lock_negative.part = ( hinge_lock_negative.part + @@ -270,6 +275,6 @@ def generate_base_tray( if __name__ == "__main__": - center, flap = generate_base_tray(is_double_tray=True) - show(center, flap) + tray = generate_base_tray(TrayConfig(is_double_tray=True)) + show(tray) # %% diff --git a/Trays/functions/calculate_cutout_positions/__init__.py b/Trays/functions/calculate_cutout_positions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Trays/functions/calculate_cutout_positions/calculate_alternating_cutout_positions.py b/Trays/functions/calculate_cutout_positions/calculate_alternating_cutout_positions.py index 9a9454c..5b78384 100644 --- a/Trays/functions/calculate_cutout_positions/calculate_alternating_cutout_positions.py +++ b/Trays/functions/calculate_cutout_positions/calculate_alternating_cutout_positions.py @@ -1,116 +1,203 @@ # %% import math +# Validation constants +EDGE_TOLERANCE = 0.1 # Allowed boundary overshoot for floating-point precision +MIN_EDGE_GAP = 0.4 # Minimum gap (mm) between the edges of adjacent cutouts + def calculate_alternating_cutout_positions( usable_area, - diameters, + sizes, edge_offsets, - tolerance + tolerance, + layout_sizes=None, + nesting='circle', ): - full_diameters = [] - for diameter in diameters: - full_diameters.append(diameter + tolerance) - - if len(diameters) == 1: - return [{ + """Nest bases alternately against the front and back edges. + + `layout_sizes` is an optional per-base (x_size, y_size) list (see the + linear layout); defaults to (size, size). `nesting` selects the spacing + math, either one string for every base or a per-base list: 'circle' is + exact tangency for circular footprints; 'box' is conservative + bounding-box separation, safe for any shape (squares, hexes, ovals) -- + a pair resting on opposite edges is spaced apart in x unless the tray + is deep enough for them to clear vertically. A mixed pair uses circle + tangency only when both bases are 'circle'. + """ + if len(sizes) == 0: + return [] + + if layout_sizes is None: + layout_sizes = [(size, size) for size in sizes] + + if isinstance(nesting, str): + nesting = [nesting] * len(sizes) + + if len(sizes) == 1: + positions = [{ 'x': 0, - 'diameter': diameters[0], - 'y': usable_area['min']['y'] + diameter/2 - edge_offsets[0], + 'y': usable_area['min']['y'] + layout_sizes[0][1] / 2 + + _offset(edge_offsets, 0), + 'size': sizes[0], + 'index': 0, 'flipped': False, }] + else: + positions = _calculate_initial_positions( + usable_area, sizes, edge_offsets, tolerance, layout_sizes, nesting) - positions = _calculate_initial_positions(usable_area, diameters, edge_offsets, tolerance) + _validate_positions(usable_area, positions, tolerance, layout_sizes, + nesting) return positions +def _offset(edge_offsets, i): + return edge_offsets[i] if i < len(edge_offsets) else 0 + + +def _format_size(size): + if isinstance(size, (tuple, list)): + return f"{size[0]}x{size[1]}" + return str(size) + + +def _box_min_dx(fx_a, fy_a, fx_b, fy_b, dy, tolerance, gap): + """Minimum x distance between two bounding-box bases whose centers are + `dy` apart in y, keeping `gap` between the physical (toleranced) holes. + Zero when the tray is deep enough for the pair to clear vertically.""" + y_clearance = dy - (fy_a + tolerance) / 2 - (fy_b + tolerance) / 2 + if y_clearance >= gap: + return 0.0 + return (fx_a + tolerance) / 2 + (fx_b + tolerance) / 2 + gap + + +def _pair_nests_as_circles(nesting, i, j): + """Exact circle tangency only applies between two circular footprints; + any pair involving a box-nested shape falls back to bounding boxes.""" + return nesting[i] == 'circle' and nesting[j] == 'circle' + + def _calculate_initial_positions( usable_area, - diameters, + sizes, edge_offsets, - tolerance + tolerance, + layout_sizes, + nesting, ): positions = [] usable_area_total = { 'x': -usable_area['min']['x'] + usable_area['max']['x'], 'y': -usable_area['min']['y'] + usable_area['max']['y']} - for i, diameter in enumerate(diameters): + for i, size in enumerate(sizes): + fx, fy = layout_sizes[i] if i == 0: positions.append({ - 'x': usable_area['min']['x'] + diameter/2, - 'y': usable_area['min']['y'] + diameter/2 - edge_offsets[i], - 'diameter': diameters[0], + 'x': usable_area['min']['x'] + fx/2, + 'y': usable_area['min']['y'] + fy/2 + _offset(edge_offsets, i), + 'size': size, + 'index': i, 'flipped': False, }) else: last_pos = positions[-1] - offset = _side_from_hyp( - last_pos['diameter'] / 2 + diameter / 2, usable_area_total['y'] - - last_pos['diameter'] / 2 - diameter / 2) + last_fx, last_fy = layout_sizes[i - 1] + # Distance in y between the two resting centers. + dy = usable_area_total['y'] - last_fy / 2 - fy / 2 + if _pair_nests_as_circles(nesting, i - 1, i): + # Nest against the previous cutout using the full (toleranced) + # hole sizes so the physical holes keep their clearance. + hyp = (last_fx + tolerance) / 2 + (fx + tolerance) / 2 + offset = _side_from_hyp(hyp, dy) + else: + offset = _box_min_dx(last_fx, last_fy, fx, fy, dy, tolerance, 0.0) is_flipped = not last_pos['flipped'] + # Edge offsets move the cutout inward, away from its resting edge + # (same convention as the linear layout). + if is_flipped: + y = usable_area['max']['y'] - fy/2 - _offset(edge_offsets, i) + else: + y = usable_area['min']['y'] + fy/2 + _offset(edge_offsets, i) positions.append({ 'x': last_pos['x'] + offset, - 'y': (usable_area['max']['y'] - diameter/2 + edge_offsets[i]) if is_flipped else (usable_area['min']['y'] + diameter/2 + edge_offsets[i]), - 'diameter': diameter, + 'y': y, + 'size': size, + 'index': i, 'flipped': is_flipped, }) for i in range(100): - positions, error = _redistribution_pass(usable_area, positions) + positions, error = _redistribution_pass( + usable_area, positions, tolerance, layout_sizes, nesting) if error < 0.01: break -# Validate: check for overlaps and boundary violations - edge_tolerance = 0.1 # Allow 0.1mm tolerance for floating-point precision - gap_tolerance = 0.4 # Minimum 0.4mm gap between circles - has_error = False - - for i, pos in enumerate(positions): - # Check boundaries (allow small tolerance for floating-point precision) - left_edge = pos['x'] - pos['diameter'] / 2 - right_edge = pos['x'] + pos['diameter'] / 2 - top_edge = pos['y'] + pos['diameter'] / 2 - bottom_edge = pos['y'] - pos['diameter'] / 2 - - if (left_edge < usable_area['min']['x'] - edge_tolerance or - right_edge > usable_area['max']['x'] + edge_tolerance or - top_edge > usable_area['max']['y'] + edge_tolerance or - bottom_edge < usable_area['min']['y'] - edge_tolerance): - break + return positions - # Check for overlaps (minimum 0.4mm gap required) - distances = [] - for i in range(len(positions) - 1): - dx = positions[i+1]['x'] - positions[i]['x'] - dy = positions[i+1]['y'] - positions[i]['y'] - center_distance = math.sqrt(dx*dx + dy*dy) - # Edge-to-edge distance = center distance - radius1 - radius2 - edge_distance = center_distance - \ - positions[i]['diameter']/2 - positions[i+1]['diameter']/2 - distances.append(edge_distance) - - if edge_distance < gap_tolerance: - has_error = True - - if has_error: - raise ValueError( - "Total width of bases is too wide to fit on the tray.\n" - + "Remove a diameter from the list and try again." - ) - return positions +def _validate_positions(usable_area, positions, tolerance, layout_sizes, + nesting): + # Boundary check. Raw sizes are used on purpose: the usable area + # already reserves tolerance/2 along y, and the x safety margin absorbs + # the tolerance overhang, matching the linear layout's convention. + for pos, (fx, fy) in zip(positions, layout_sizes): + left_edge = pos['x'] - fx / 2 + right_edge = pos['x'] + fx / 2 + top_edge = pos['y'] + fy / 2 + bottom_edge = pos['y'] - fy / 2 + + if (left_edge < usable_area['min']['x'] - EDGE_TOLERANCE or + right_edge > usable_area['max']['x'] + EDGE_TOLERANCE or + top_edge > usable_area['max']['y'] + EDGE_TOLERANCE or + bottom_edge < usable_area['min']['y'] - EDGE_TOLERANCE): + raise ValueError( + f"A base of size {_format_size(pos['size'])}mm does not fit " + "within the tray's usable area.\n" + "Use a larger tray, remove a base from the list, or reduce " + "the safety margins." + ) + + # Overlap check between every pair of cutouts (not just consecutive ones), + # using the full (toleranced) hole sizes. + for i in range(len(positions)): + for j in range(i + 1, len(positions)): + dx = positions[j]['x'] - positions[i]['x'] + dy = positions[j]['y'] - positions[i]['y'] + if _pair_nests_as_circles(nesting, i, j): + center_distance = math.sqrt(dx*dx + dy*dy) + edge_distance = ( + center_distance - + (layout_sizes[i][0] + tolerance) / 2 - + (layout_sizes[j][0] + tolerance) / 2 + ) + else: + # Bounding boxes are separated if they clear on EITHER axis. + x_gap = (abs(dx) - (layout_sizes[i][0] + tolerance) / 2 + - (layout_sizes[j][0] + tolerance) / 2) + y_gap = (abs(dy) - (layout_sizes[i][1] + tolerance) / 2 + - (layout_sizes[j][1] + tolerance) / 2) + edge_distance = max(x_gap, y_gap) + if edge_distance < MIN_EDGE_GAP: + raise ValueError( + "Total width of bases is too wide to fit on the tray.\n" + + "Remove a base from the list and try again." + ) def _redistribution_pass( usable_area, - positions): + positions, + tolerance, + layout_sizes, + nesting): if len(positions) <= 1: return positions, 0 # Fixed boundaries - target_first_x = usable_area['min']['x'] + positions[0]['diameter'] / 2 - target_last_x = usable_area['max']['x'] - positions[-1]['diameter'] / 2 + target_first_x = usable_area['min']['x'] + layout_sizes[0][0] / 2 + target_last_x = usable_area['max']['x'] - layout_sizes[-1][0] / 2 target_x_span = target_last_x - target_first_x # Vertical distances (fixed by alternating pattern) @@ -122,21 +209,33 @@ def _redistribution_pass( if len(dy_list) == 0: return positions, 0 - # Find uniform edge-to-edge gap such that all gaps are equal - # For each segment i: edge_gap = h_i - radius_i - radius_{i+1} - # Therefore: h_i = edge_gap + radius_i + radius_{i+1} + # Find uniform edge-to-edge gap such that all gaps are equal. + # Circle nesting: for each segment i, + # h_i = edge_gap + full_radius_i + full_radius_{i+1}; dx = sqrt(h^2-dy^2) + # Box nesting: dx is zero when the pair clears vertically by the gap, + # otherwise the full half-widths plus the gap. + # Full sizes include the fit tolerance, since that is the size of the + # physical hole cut into the tray. + + def calculate_dx(i, dy, gap): + fx_a, fy_a = layout_sizes[i] + fx_b, fy_b = layout_sizes[i + 1] + if _pair_nests_as_circles(nesting, i, i + 1): + radius_a = (fx_a + tolerance) / 2 + radius_b = (fx_b + tolerance) / 2 + h = gap + radius_a + radius_b # Hypotenuse needed for this gap + if h * h < dy * dy: + return None # Invalid: hypotenuse must be >= vertical distance + return math.sqrt(h * h - dy * dy) + return _box_min_dx(fx_a, fy_a, fx_b, fy_b, dy, tolerance, gap) def calculate_x_span(gap): """Calculate total x span for a given edge-to-edge gap""" total_dx = 0 for i, dy in enumerate(dy_list): - radius_i = positions[i]['diameter'] / 2 - radius_next = positions[i+1]['diameter'] / 2 - h = gap + radius_i + radius_next # Hypotenuse needed for this gap - - if h * h < dy * dy: - return None # Invalid: hypotenuse must be >= vertical distance - dx = math.sqrt(h * h - dy * dy) + dx = calculate_dx(i, dy, gap) + if dx is None: + return None total_dx += dx return total_dx @@ -158,10 +257,11 @@ def calculate_x_span(gap): # Calculate dx values with best_gap dx_list = [] for i, dy in enumerate(dy_list): - radius_i = positions[i]['diameter'] / 2 - radius_next = positions[i+1]['diameter'] / 2 - h = best_gap + radius_i + radius_next - dx = math.sqrt(h * h - dy * dy) + dx = calculate_dx(i, dy, best_gap) + if dx is None: + # best_gap sits marginally inside the infeasible region; fall back + # to the known-feasible upper bound of the search. + dx = calculate_dx(i, dy, high_gap) dx_list.append(dx) # Position elements based on calculated dx values @@ -200,6 +300,6 @@ def _side_from_hyp( if __name__ == "__main__": print(calculate_alternating_cutout_positions( {'min': {'x': -82.9, 'y': -31.65}, 'max': {'x': 82.9, 'y': 31.65}}, - [40, 40])) + [40, 40], [0, 0], 0.55)) # %% diff --git a/Trays/functions/calculate_cutout_positions/calculate_linear_cutout_positions.py b/Trays/functions/calculate_cutout_positions/calculate_linear_cutout_positions.py index 3cdb7f4..c8ce797 100644 --- a/Trays/functions/calculate_cutout_positions/calculate_linear_cutout_positions.py +++ b/Trays/functions/calculate_cutout_positions/calculate_linear_cutout_positions.py @@ -1,23 +1,39 @@ # %% -import math -# %% +# Minimum clearance (mm) between the physical edges of front- and +# back-row cutouts (matches the alternating layout's MIN_EDGE_GAP). +MIN_ROW_GAP = 0.4 + + +def _line_fits(line_total, n_in_line, new_x_size, max_width, tolerance, min_spacing): + """Check if adding new_x_size to a line still fits with minimum spacing.""" + n = n_in_line + 1 + return line_total + new_x_size + (n - 1) * tolerance + (n + 1) * min_spacing <= max_width def calculate_linear_cutout_positions( usable_area, - diameters, + sizes, edge_offsets, tolerance, is_double_tray=False, + min_spacing=2.0, + layout_sizes=None, ): - full_diameters = [] - for diameter in diameters: - full_diameters.append(diameter + tolerance) + """Place bases on one or two straight rows. + + `layout_sizes` is an optional per-base (x_size, y_size) list for shapes + whose bounding box is not size x size (e.g. a hex is wider across its + corners than its measured across-flats size). Defaults to (size, size). + The physical hole measures layout_size + tolerance across each axis; + positions keep the original scalar under the 'size' key and, because + the two rows reorder the bases, their position in `sizes` under the + 'index' key. + """ + if layout_sizes is None: + layout_sizes = [(size, size) for size in sizes] - line_one = [] line_one_indices = [] - line_two = [] line_two_indices = [] max_width = usable_area['max']['x'] * 2 @@ -25,41 +41,37 @@ def calculate_linear_cutout_positions( line_two_total = 0 left_idx = 0 - right_idx = len(diameters) - 1 + right_idx = len(sizes) - 1 take_from_left = True # Alternate taking from start and end while left_idx <= right_idx: if take_from_left: - diameter = diameters[left_idx] - if line_one_total + diameter <= max_width: - line_one.append(diameter) + x_size = layout_sizes[left_idx][0] + if _line_fits(line_one_total, len(line_one_indices), x_size, max_width, tolerance, min_spacing): line_one_indices.append(left_idx) - line_one_total += diameter - elif is_double_tray and line_two_total + diameter <= max_width: - line_two.append(diameter) + line_one_total += x_size + elif is_double_tray and _line_fits(line_two_total, len(line_two_indices), x_size, max_width, tolerance, min_spacing): line_two_indices.append(left_idx) - line_two_total += diameter + line_two_total += x_size else: raise ValueError( - "Total width of bases is too wide to fit on the tray.\n" - + "Remove a diameter from the list and try again." + "Total width of bases is too wide to fit on the tray with the required spacing.\n" + + "Remove a base from the list, reduce min_cutout_spacing, or use a wider tray." ) left_idx += 1 else: - diameter = diameters[right_idx] - if line_two_total + diameter <= max_width: - line_two.insert(0, diameter) + x_size = layout_sizes[right_idx][0] + if _line_fits(line_two_total, len(line_two_indices), x_size, max_width, tolerance, min_spacing): line_two_indices.insert(0, right_idx) - line_two_total += diameter - elif line_one_total + diameter <= max_width: - line_one.append(diameter) + line_two_total += x_size + elif _line_fits(line_one_total, len(line_one_indices), x_size, max_width, tolerance, min_spacing): line_one_indices.append(right_idx) - line_one_total += diameter + line_one_total += x_size else: raise ValueError( - "Total width of bases is too wide to fit on the tray.\n" - + "Remove a diameter from the list and try again." + "Total width of bases is too wide to fit on the tray with the required spacing.\n" + + "Remove a base from the list, reduce min_cutout_spacing, or use a wider tray." ) right_idx -= 1 @@ -68,69 +80,124 @@ def calculate_linear_cutout_positions( x_positions = calculate_line_positions( usable_area, - line_one + [sizes[i] for i in line_one_indices], + tolerance, + min_spacing, + x_sizes=[layout_sizes[i][0] for i in line_one_indices], ) for i, pos in enumerate(x_positions): - pos['y'] = usable_area['min']['y'] + (pos['diameter']) / 2 - if edge_offsets and i < len(edge_offsets): - pos['y'] += edge_offsets[line_one_indices[i]] + idx = line_one_indices[i] + pos['index'] = idx + pos['y'] = usable_area['min']['y'] + layout_sizes[idx][1] / 2 + if idx < len(edge_offsets): + pos['y'] += edge_offsets[idx] pos['flipped'] = False if is_double_tray: y_positions = calculate_line_positions( usable_area, - line_two, + [sizes[i] for i in line_two_indices], + tolerance, + min_spacing, + x_sizes=[layout_sizes[i][0] for i in line_two_indices], ) for i, pos in enumerate(y_positions): - pos['y'] = usable_area['max']['y'] - (pos['diameter']) / 2 - if edge_offsets and i < len(edge_offsets): - pos['y'] -= edge_offsets[line_two_indices[i]] + idx = line_two_indices[i] + pos['index'] = idx + pos['y'] = usable_area['max']['y'] - layout_sizes[idx][1] / 2 + if idx < len(edge_offsets): + pos['y'] -= edge_offsets[idx] pos['flipped'] = True positions = x_positions + y_positions + + _validate_row_overlap(usable_area, x_positions, y_positions, + layout_sizes, line_one_indices, line_two_indices, + tolerance) else: positions = x_positions + # A base deeper than the usable area cannot fit at all. + usable_depth = usable_area['max']['y'] - usable_area['min']['y'] + for x_size, y_size in layout_sizes: + if y_size > usable_depth + 0.001: + raise ValueError( + f"A base of depth {y_size}mm is too deep for the tray " + f"(usable depth: {usable_depth:.1f}mm).\n" + "Use a deeper tray, or for oval bases swap the size to " + "DEPTHxWIDTH so the long axis runs along the tray." + ) + return positions +def _validate_row_overlap(usable_area, front_positions, back_positions, + layout_sizes, front_indices, back_indices, + tolerance): + """The front and back rows share the tray's middle: two cutouts resting + on opposite edges collide when their combined depth exceeds the usable + depth AND they overlap along x. Deep bases are fine as long as the two + rows interleave in x.""" + usable_depth = usable_area['max']['y'] - usable_area['min']['y'] + + for front, fi in zip(front_positions, front_indices): + for back, bi in zip(back_positions, back_indices): + # Vertical clearance between the physical holes (tolerance eats + # tolerance/2 into the gap from each side). The standard tray runs + # opposing rows as close as ~0.1mm by design, so only an actual + # overlap counts. + y_gap = (usable_depth - layout_sizes[fi][1] - layout_sizes[bi][1] + - tolerance) + if y_gap >= -0.001: + continue + # Rows are too deep to stack: only allowed if they don't share x. + front_half = (layout_sizes[fi][0] + tolerance) / 2 + back_half = (layout_sizes[bi][0] + tolerance) / 2 + x_gap = abs(back['x'] - front['x']) - front_half - back_half + if x_gap < MIN_ROW_GAP: + raise ValueError( + "Bases on the front and back rows overlap in the middle of " + "the tray.\n" + "The tray is too shallow to stack these bases: use a deeper " + "tray, remove a base, or use --single-sided." + ) + + def calculate_line_positions( usable_area, - diameters, + sizes, + tolerance, + min_spacing=2.0, + x_sizes=None, ): + """Spread one row of bases evenly along x. + + `x_sizes` optionally overrides the horizontal extent used for spacing + (defaults to `sizes`); the returned 'size' key always carries the + original scalar. + """ + if x_sizes is None: + x_sizes = list(sizes) + positions = [] - diameter_total = sum(diameters) + x_size_total = sum(x_sizes) + n = len(sizes) - if diameter_total > usable_area['max']['x']*2: - raise ValueError( - "Total width of bases is too wide to fit on the tray.\n" - + "Remove a diameter from the list and try again." - ) + if n == 0: + return positions - if (len(diameters) == 0): - pass - elif (len(diameters) == 1): - positions.append({ - 'x': 0, - 'diameter': diameters[0], - }) - else: - remaining_space = usable_area['max']['x'] * 2 - diameter_total - padding = remaining_space / len(diameters) - current_x = 0 + max_width = usable_area['max']['x'] * 2 - for i, diameter in enumerate(diameters): - pos = {'diameter': diameter} + # Reserve min_spacing for all n+1 gaps, distribute any extra space equally. + available = max_width - x_size_total - (n - 1) * tolerance - (n + 1) * min_spacing + gap = min_spacing + max(available, 0) / (n + 1) - if i == 0: - pos['x'] = -usable_area['max']['x'] + \ - (diameters[0]) / 2 + padding/2 - else: - pos['x'] = current_x + diameters[i-1] / \ - 2 + diameter/2 + padding - current_x = pos['x'] - positions.append(pos) + x = -usable_area['max']['x'] + for size, x_size in zip(sizes, x_sizes): + x += gap + x_size / 2 + positions.append({'x': x, 'size': size}) + x += x_size / 2 + tolerance return positions diff --git a/Trays/functions/cutout_generator.py b/Trays/functions/cutout_generator.py index 96d34e3..b25e23a 100644 --- a/Trays/functions/cutout_generator.py +++ b/Trays/functions/cutout_generator.py @@ -2,113 +2,336 @@ from build123d import * from ocp_vscode import * import math -import copy + +# Wall draft for the cutout sides. NOTE: circle and prismatic cutouts +# currently use different draft angles and heights (12.5deg/6mm vs +# 5deg/5mm); unify these once it is confirmed the difference is not a +# deliberate fit choice. +CIRCLE_EXTRUDE_HEIGHT = 6 +CIRCLE_TAPER = 12.5 +PRISM_EXTRUDE_HEIGHT = 5 +PRISM_TAPER = 5 # %% def generate_cutout( - base_diameter, + size, tolerance=0.55, flap_depth=11.8, - hinge_diameter=27.5, + hinge_diameter=27.7, flap_center_gap=0.2, - cutout_edge_spacing=.8, - floor_thickness = .8, - lip_offset = 0, + edge_margin=0.8, + edge_adjust=0, + edge_offset=0, + taper_angle=None, + flap_clearance=0, epsilon=0.001 ): + """Build the negative shape for one circular base cutout. + + `size` is the base diameter. edge_margin is the y safety margin between + the cutout and the tray edge; edge_adjust (--edge-adjusts) grows the + flat spot below the curved lip; edge_offset (--edge-offsets) shifts the + lip to reduce the base depth. taper_angle is the wall angle in degrees + from vertical (None uses CIRCLE_TAPER). flap_clearance is accepted for + signature parity: the circular cutout's revolved lip relief already + provides the flap rotation clearance, so it is not used here. + """ + taper = CIRCLE_TAPER if taper_angle is None else taper_angle + with BuildPart() as normal_base: with BuildSketch(): - c = Circle(base_diameter/2 + tolerance/2, + c = Circle(size/2 + tolerance/2, align=(Align.CENTER, Align.CENTER)) - extrude(amount=6, taper=12.5) - extrude(c, amount=-6, taper=-12.5) - # # Add the slide path for the base - if(base_diameter/2 > flap_depth-cutout_edge_spacing): + extrude(amount=CIRCLE_EXTRUDE_HEIGHT, taper=taper) + extrude(c, amount=-CIRCLE_EXTRUDE_HEIGHT, taper=-taper) + # Add the slide path for the base + if(size/2 > flap_depth-edge_margin): cross_section_result = section(normal_base.part, Plane.XZ) - e = extrude(cross_section_result, (base_diameter/2 + tolerance/2) - - (flap_depth - cutout_edge_spacing) - flap_center_gap + epsilon) - - normal = copy.deepcopy(normal_base.part) - + extrude(cross_section_result, (size/2 + tolerance/2) - + (flap_depth - edge_margin) - flap_center_gap + epsilon) + normal_base.part = normal_base.part.translate((0,0,-epsilon)) - - if not lip_offset == 0: - lip_offseter = normal_base.part.translate((0,lip_offset,0)) - normal_base.part = normal_base.part.intersect(lip_offseter) - - if isinstance(normal_base.part, ShapeList): - normal_base.part = normal_base.part[0] + + if not edge_offset == 0: + edge_offseter = normal_base.part.translate((0,edge_offset,0)) + normal_base.part = normal_base.part.intersect(edge_offseter) + + # For some sizes (e.g. 24.7) the edge_offset self-intersection comes + # back as a dimension-less Compound, which would break the lip adjustor + # union below. + normal_base.part = _unwrap_boolean_result(normal_base.part) + if normal_base.part is None: + raise ValueError( + f"edge_offset={edge_offset} produced an empty cutout for size={size}") with BuildPart() as lip_adjustor_base: - with Locations((0, -base_diameter*0.75, -epsilon*2)): - Cylinder(base_diameter, 6, align=(Align.CENTER, Align.CENTER)) + with Locations((0, -size*0.75, -epsilon*2)): + Cylinder(size, 6, align=(Align.CENTER, Align.CENTER, Align.CENTER)) # Get the radius cut out of the adjustor - hinge_radius = hinge_diameter/2 - cutout_edge_spacing + hinge_radius = hinge_diameter/2 - edge_margin delta_x = hinge_radius - \ - math.sqrt(math.pow(hinge_radius, 2) - math.pow(2 - floor_thickness + epsilon, 2)) - + math.sqrt(math.pow(hinge_radius, 2) - math.pow(2 - edge_adjust + epsilon, 2)) + with BuildPart() as lip_adjustor_edge: with BuildSketch(Plane.YZ): - with Locations(( -base_diameter/2 - tolerance/2 + delta_x - hinge_radius, 2 - floor_thickness)): + with Locations(( -size/2 - tolerance/2 + delta_x - hinge_radius, 2 - edge_adjust)): Circle(hinge_radius, align=(Align.CENTER, Align.CENTER)) revolve_axis = Axis( origin=(0, -tolerance/2 - epsilon, 0), direction=(0, 0, 1)) revolve(axis=revolve_axis) - - lip_adjustor_edge.part = lip_adjustor_edge.part.translate((0,lip_offset,0)) + + lip_adjustor_edge.part = lip_adjustor_edge.part.translate((0,edge_offset,0)) lip_adjustor_base.part -= lip_adjustor_edge.part # Keep only the part of the lip adjustor that intersects with the flap with BuildPart() as lip_box: - with Locations((0, lip_offset - base_diameter/2 - tolerance/2, -1)): + with Locations((0, edge_offset - size/2 - tolerance/2, -1)): b = Box( - (base_diameter + 5), - (flap_depth - cutout_edge_spacing + epsilon)*2, + (size + 5), + (flap_depth - edge_margin + epsilon)*2, 8, align=(Align.CENTER, Align.CENTER, Align.MIN), ) - lip_adjustor_base.part = lip_adjustor_base.part.intersect(lip_box.part) - - # Convert ShapeList to Compound if needed - if isinstance(lip_adjustor_base.part, ShapeList): - lip_adjustor_base.part = lip_adjustor_base.part[0] + lip_adjustor_base.part = _unwrap_boolean_result( + lip_adjustor_base.part.intersect(lip_box.part)) - normal_base.part += lip_adjustor_base.part + # The intersection can be empty (small sizes with a nonzero + # edge_offset); in that case there is simply no lip to adjust for. + if lip_adjustor_base.part is not None: + normal_base.part += lip_adjustor_base.part # Create flattener with BuildPart() as flattener: - Box(base_diameter + 5, base_diameter + 5, 5, + Box(size + 5, size + 5, 5, align=(Align.CENTER, Align.CENTER, Align.MIN)) # Subtract flattener using boolean operation - normal_base.part = normal_base.part.intersect(flattener.part) - - # Convert normal_base from ShapeList to Shape if necessary - if isinstance(normal_base.part, ShapeList): - normal_base.part = normal_base.part[0] + normal_base.part = _unwrap_boolean_result( + normal_base.part.intersect(flattener.part)) + if normal_base.part is None: + raise ValueError(f"Flattening produced an empty cutout for size={size}") + + return Compound([normal_base.part]) + + +# %% + + +def _unwrap_boolean_result(shape): + """Boolean ops occasionally return a ShapeList or a dimension-less + Compound instead of a plain solid, which then breaks follow-up unions + (`Shape.__add__` compares class-level `_dim`, and plain Compound's is + None). Unwrap to the underlying solid(s), rewrapping as a Part if there + are several; returns None for an empty result.""" + if shape is None: + return None + if isinstance(shape, ShapeList): + solids = [solid for item in shape for solid in item.solids()] + elif isinstance(shape, Compound) and shape._dim is None: + solids = list(shape.solids()) + else: + return shape + if not solids: + return None + if len(solids) == 1: + return solids[0] + return Part(Compound(solids).wrapped) + + +def _prismatic_cutout( + profile_fn, + slide_path_length, + flatten_width, + flatten_depth, + taper_angle=None, + flap_clearance=0, + epsilon=0.001, +): + """Shared pipeline for straight-walled (prismatic) cutouts such as + squares and hexes: extrude the 2D profile with a wall draft, add the + slide path toward the flap, then remove everything below the floor + plane. The caller supplies the profile sketch via `profile_fn`; + taper_angle is the wall angle in degrees from vertical (None uses + PRISM_TAPER). + + flap_clearance widens the cutout sideways (per side) in the region in + front of the slide path -- the part cut into the rotating flap -- so + the flap's cutout edge sweeps past the seated base when closing + instead of clipping it. The pocket that grips the base is unaffected. + (Circle cutouts get this clearance from their revolved lip relief + instead.) + """ + taper = PRISM_TAPER if taper_angle is None else taper_angle + + with BuildPart() as normal_base: + with BuildSketch(): + profile_fn() + extrude(amount=PRISM_EXTRUDE_HEIGHT, taper=taper) + # Add the slide path for the base (same approach as circular cutout). + # Skipped for shallow bases whose front already sits under the flap + # (a negative length would extrude backwards into the pocket). + if slide_path_length > 0: + cross_section_result = section(normal_base.part, Plane.XZ) + extrude(cross_section_result, slide_path_length) + normal_base.part = normal_base.part.translate((0, 0, -epsilon)) + + if flap_clearance > 0: + # Union of the cutout shifted +-flap_clearance along x: for an + # x-convex profile this is exactly the cutout widened by + # flap_clearance per side, walls and taper preserved. + widened = (normal_base.part + + normal_base.part.translate((-flap_clearance, 0, 0)) + + normal_base.part.translate((flap_clearance, 0, 0))) + # Keep the widening only from the end of the slide path forward + # (the flap-center gap and the flap itself). + with BuildPart() as flap_region: + with Locations((0, -slide_path_length, 0)): + Box( + flatten_width + flap_clearance * 2 + 2, + flatten_depth * 2, + (PRISM_EXTRUDE_HEIGHT + 1) * 2, + align=(Align.CENTER, Align.MAX, Align.CENTER), + ) + relief = _unwrap_boolean_result(widened.intersect(flap_region.part)) + if relief is not None: + normal_base.part += relief + + with BuildPart() as flattener: + Box(flatten_width + flap_clearance * 2, flatten_depth, 1, + align=(Align.CENTER, Align.CENTER, Align.MAX)) + normal_base.part -= flattener.part - # # Handle case where subtraction returns a ShapeList - return_compound = Compound([normal_base.part]) + return Compound([normal_base.part]) - # show( - # return_compound, flattener, normal, lip_adjustor_base, - # lip_adjustor_edge, lip_box, c, lip_box.faces().sort_by(Axis.Z)[0]. - # center(), - # alphas=[1, 0.5, 1, 0.5, 0.5], - # names=['return', 'flat', 'normal', 'lip_base', 'lip_edge', 'lip_box', - # 'circle', 'lip_box_center']) - return return_compound +def generate_square_cutout( + size, + tolerance=0.55, + flap_depth=11.8, + hinge_diameter=27.7, + flap_center_gap=0.2, + edge_margin=0.8, + edge_adjust=0, + edge_offset=0, + taper_angle=None, + flap_clearance=0, + epsilon=0.001 +): + """Build the negative shape for one square base cutout. + + `size` is the side length. taper_angle is the wall angle in degrees from + vertical (None uses PRISM_TAPER); flap_clearance widens the flap's part + of the cutout so it can rotate closed past a seated base. edge_adjust + (--edge-adjusts) and edge_offset (--edge-offsets) are accepted for + signature parity with generate_cutout; the square cutout geometry does + not use them. + """ + half_side = size / 2 + tolerance / 2 + + return _prismatic_cutout( + profile_fn=lambda: Rectangle(size + tolerance, size + tolerance, + align=(Align.CENTER, Align.CENTER)), + slide_path_length=(half_side - flap_depth - flap_center_gap + + edge_margin), + flatten_width=size + tolerance * 2, + flatten_depth=size + tolerance * 2, + taper_angle=taper_angle, + flap_clearance=flap_clearance, + epsilon=epsilon, + ) + + +def generate_hex_cutout( + size, + tolerance=0.55, + flap_depth=11.8, + hinge_diameter=27.7, + flap_center_gap=0.2, + edge_margin=0.8, + edge_adjust=0, + edge_offset=0, + taper_angle=None, + flap_clearance=0, + epsilon=0.001 +): + """Build the negative shape for one hexagonal base cutout. + + `size` is measured across the flats. The hex is oriented with flats + facing the tray edges (corners pointing along x), so the front flat + rests against the edge lip like a square's side does. taper_angle is + the wall angle in degrees from vertical (None uses PRISM_TAPER); + flap_clearance widens the flap's part of the cutout so it can rotate + closed past a seated base. edge_adjust (--edge-adjusts) and edge_offset + (--edge-offsets) are accepted for signature parity with + generate_cutout; the hex cutout geometry does not use them. + """ + half_flats = size / 2 + tolerance / 2 + across_corners = (size + tolerance) * 2 / math.sqrt(3) + + return _prismatic_cutout( + # apothem-mode RegularPolygon: flats face +-y, corners along x + profile_fn=lambda: RegularPolygon((size + tolerance) / 2, 6, + major_radius=False, + align=(Align.CENTER, Align.CENTER)), + slide_path_length=(half_flats - flap_depth - flap_center_gap + + edge_margin), + flatten_width=across_corners + tolerance * 2, + flatten_depth=size + tolerance * 2, + taper_angle=taper_angle, + flap_clearance=flap_clearance, + epsilon=epsilon, + ) + + +def generate_oval_cutout( + size, + tolerance=0.55, + flap_depth=11.8, + hinge_diameter=27.7, + flap_center_gap=0.2, + edge_margin=0.8, + edge_adjust=0, + edge_offset=0, + taper_angle=None, + flap_clearance=0, + epsilon=0.001 +): + """Build the negative shape for one oval (elliptical) base cutout. + + `size` is a (width, depth) pair: width runs along the tray (x), depth + front-to-back (y). taper_angle is the wall angle in degrees from + vertical (None uses PRISM_TAPER); flap_clearance widens the flap's part + of the cutout so it can rotate closed past a seated base. edge_adjust + (--edge-adjusts) and edge_offset (--edge-offsets) are accepted for + signature parity with generate_cutout; the oval cutout geometry does + not use them. + """ + size_x, size_y = size + half_depth = size_y / 2 + tolerance / 2 + + return _prismatic_cutout( + profile_fn=lambda: Ellipse((size_x + tolerance) / 2, + (size_y + tolerance) / 2, + align=(Align.CENTER, Align.CENTER)), + slide_path_length=(half_depth - flap_depth - flap_center_gap + + edge_margin), + flatten_width=size_x + tolerance * 2, + flatten_depth=size_y + tolerance * 2, + taper_angle=taper_angle, + flap_clearance=flap_clearance, + epsilon=epsilon, + ) # %% + if __name__ == "__main__": - cutout = generate_cutout(49.6, tolerance=0.55, lip_offset=0.1, floor_thickness=-1) + cutout = generate_cutout(49.6, tolerance=0.55, edge_offset=0.1, edge_adjust=-1) show(cutout) # %% diff --git a/Trays/functions/full_tray_generator.py b/Trays/functions/full_tray_generator.py index 1ca7d96..9c92fbb 100644 --- a/Trays/functions/full_tray_generator.py +++ b/Trays/functions/full_tray_generator.py @@ -5,15 +5,17 @@ from ocp_vscode import * if __name__ == "__main__": + from tray_config import TrayConfig + from shapes import get_shape from base_tray_generator import generate_base_tray - from cutout_generator import generate_cutout - from calculate_cutout_positions.calculate_linear_cutout_positions import * - from calculate_cutout_positions.calculate_alternating_cutout_positions import * + from calculate_cutout_positions.calculate_linear_cutout_positions import calculate_linear_cutout_positions + from calculate_cutout_positions.calculate_alternating_cutout_positions import calculate_alternating_cutout_positions else: + from .tray_config import TrayConfig + from .shapes import get_shape from .base_tray_generator import generate_base_tray - from .cutout_generator import generate_cutout - from .calculate_cutout_positions.calculate_linear_cutout_positions import * - from .calculate_cutout_positions.calculate_alternating_cutout_positions import * + from .calculate_cutout_positions.calculate_linear_cutout_positions import calculate_linear_cutout_positions + from .calculate_cutout_positions.calculate_alternating_cutout_positions import calculate_alternating_cutout_positions base_tray_storage = {} @@ -47,22 +49,28 @@ def calculate_usable_area( def calculate_cutout_positions( usable_area, - diameters, + sizes, edge_offsets, tolerance, is_double_tray=False, - force_linear_positions = False + force_linear_positions=False, + min_cutout_spacing=2.0, + layout_sizes=None, + nesting='circle', ): - if len(diameters) == 0: + if len(sizes) == 0: return [] positions = [] - max_diameter = max(diameters) - if max_diameter <= -usable_area['min']['y'] or not is_double_tray or force_linear_positions: + max_y_size = (max(y for _, y in layout_sizes) if layout_sizes + else max(sizes)) + if max_y_size <= -usable_area['min']['y'] or not is_double_tray or force_linear_positions: positions = calculate_linear_cutout_positions( - usable_area, diameters, edge_offsets, tolerance, is_double_tray) + usable_area, sizes, edge_offsets, tolerance, is_double_tray, + min_spacing=min_cutout_spacing, layout_sizes=layout_sizes) else: positions = calculate_alternating_cutout_positions( - usable_area, diameters, edge_offsets, tolerance) + usable_area, sizes, edge_offsets, tolerance, + layout_sizes=layout_sizes, nesting=nesting) return positions @@ -70,115 +78,106 @@ def calculate_cutout_positions( def generate_full_tray( - diameters=[], - safety_margin=(6.5, 0.8), - total_width=189.5, - total_depth=66.0, - floor_thickness=0.8, - base_heigth=4.2, - rail_height=8.4, - rail_width=4.8, - flap_center_gap=0.2, - flap_depth=11.8, - hinge_width=2.8, - hinge_height=3.6, - hinge_depth=17.5, - hinge_pin_radius=1.4, - hinge_pin_length=3, - bottom_chamfer=0.4, - hinge_lock_radius=2, - hinge_lock_offset=0.5, - hinge_lock_depth=8.3, - edge_offsets = [], - edge_adjusts = [], - is_double_tray=False, - force_linear_positions = False, - epsilon=0.001, - tolerance=0.55, - hinge_diameter=27.7, + sizes, + config=None, + edge_offsets=None, + edge_adjusts=None, + shapes=None, ): - storage_key = ((total_width, total_depth), is_double_tray) - - # Pad edge_offsets with zeros to match diameters length + """Generate a tray with cutouts for the given base sizes. + + Each entry in `sizes` is one base: the diameter for circular cutouts, + the side length for square ones (see functions/shapes.py). `shapes` + optionally gives each base its own shape name; entries of None (and + bases beyond the end of the list) use config.cutout_shape, so shapes + can be mixed in one tray (e.g. one oval among circles). Geometry and + layout settings come from `config` (a TrayConfig); per-base fine-tuning + comes from edge_offsets / edge_adjusts, which are padded with zeros to + match the length of `sizes`. + """ + if config is None: + config = TrayConfig() + + shape_names = list(shapes) if shapes else [] + while len(shape_names) < len(sizes): + shape_names.append(None) + base_shapes = [get_shape(name if name is not None else config.cutout_shape) + for name in shape_names] + for i, (shape, size) in enumerate(zip(base_shapes, sizes)): + try: + shape.validate_size(size) + except ValueError as e: + raise ValueError(f"Base {i + 1}: {e}") from None + + storage_key = ((config.total_width, config.total_depth), + config.is_double_tray) + + # Pad edge_offsets with zeros to match sizes length edge_offsets = list(edge_offsets) if edge_offsets else [] - while len(edge_offsets) < len(diameters): + while len(edge_offsets) < len(sizes): edge_offsets.append(0) - # Pad edge_adjusts with zeros to match diameters length + # Pad edge_adjusts with zeros to match sizes length edge_adjusts = list(edge_adjusts) if edge_adjusts else [] - while len(edge_adjusts) < len(diameters): + while len(edge_adjusts) < len(sizes): edge_adjusts.append(0) # Create a base tray if one of the given dimmension doesn't exist # Grab a deep copy of the tray from storage if not storage_key in base_tray_storage: - temp_tray = generate_base_tray( - total_width, - total_depth, - floor_thickness, - base_heigth, - rail_height, - rail_width, - flap_center_gap, - flap_depth, - hinge_width, - hinge_height, - hinge_depth, - hinge_pin_radius, - hinge_pin_length, - bottom_chamfer, - hinge_lock_radius, - hinge_lock_offset, - hinge_lock_depth, - is_double_tray, - epsilon - ) - base_tray_storage[storage_key] = temp_tray + base_tray_storage[storage_key] = generate_base_tray(config) tray_compound = copy.deepcopy(base_tray_storage[storage_key]) usable_area = calculate_usable_area( - total_width, - total_depth, - rail_width, - safety_margin, - tolerance, - is_double_tray, + config.total_width, + config.total_depth, + config.rail_width, + config.safety_margin, + config.tolerance, + config.is_double_tray, ) - print("usable_area", usable_area) - print("diameters", diameters) - print("edge_offsets", edge_offsets) - print("tolerance", tolerance) - print("is_double_tray", is_double_tray) positions = calculate_cutout_positions( - usable_area, diameters, edge_offsets, tolerance, is_double_tray, force_linear_positions) + usable_area, sizes, edge_offsets, config.tolerance, + config.is_double_tray, + force_linear_positions=config.force_linear_positions, + min_cutout_spacing=config.min_cutout_spacing, + layout_sizes=[shape.layout_sizes(size, config.tolerance) + for shape, size in zip(base_shapes, sizes)], + nesting=[shape.nesting for shape in base_shapes]) cutouts_list = [] - for i, position in enumerate(positions): - cutout = (generate_cutout( - position['diameter'], - tolerance, - flap_depth, - hinge_diameter, - flap_center_gap, - safety_margin[1], - edge_adjusts[i], - edge_offsets[i], - epsilon - )) + for position in positions: + # Layouts may reorder the bases (the linear layout splits them into + # two rows), so per-base inputs are looked up by the original index. + idx = position['index'] + cutout = base_shapes[idx].build( + position['size'], + tolerance=config.tolerance, + flap_depth=config.flap_depth, + hinge_diameter=config.hinge_diameter, + flap_center_gap=config.flap_center_gap, + edge_margin=config.safety_margin[1], + edge_adjust=edge_adjusts[idx], + edge_offset=edge_offsets[idx], + taper_angle=config.taper_angle, + flap_clearance=config.flap_clearance, + epsilon=config.epsilon, + ) # Rotate 180 degrees if flipped (for top edge circles) if position['flipped']: cutout = cutout.rotate(Axis.Z, 180) - cutout = cutout.translate((position['x'], position['y'], floor_thickness)) + cutout = cutout.translate( + (position['x'], position['y'], config.floor_thickness)) cutouts_list.append(cutout) if cutouts_list: cutouts = Compound(cutouts_list) - tray_compound -= cutouts + tray_compound = tray_compound.cut(cutouts) return tray_compound, cutouts_list @@ -186,18 +185,9 @@ def generate_full_tray( if __name__ == "__main__": - # tray_compound, cutout_list = generate_full_tray( - # [49.6], - # is_double_tray=False, - # total_depth=110, - # total_width=80, - # edge_offsets=[.1] - # ) - tray_compound, cutout_list = generate_full_tray( [25, 40, 40, 25, 25, 25, 25], - is_double_tray=True, - force_linear_positions=True, + TrayConfig(is_double_tray=True, force_linear_positions=True), ) show(tray_compound, cutout_list) diff --git a/Trays/functions/shapes.py b/Trays/functions/shapes.py new file mode 100644 index 0000000..7293656 --- /dev/null +++ b/Trays/functions/shapes.py @@ -0,0 +1,256 @@ +"""Registry of cutout shapes the tray generator can cut for a base. + +To add a new shape: + 1. Subclass CutoutShape and implement build() (the 3D negative) and + circumradius(); override footprint() if the bounding box is not + size x size. + 2. Set `nesting` to 'box' (conservative bounding-box spacing, the + default) or 'circle' (exact tangency, only for circular footprints); + it controls how the alternating layout spaces this shape. + 3. Add an instance to SHAPES below. The CLI --cutout-shape choices and + the orchestrator pick it up from there. + +This module must stay importable without the CAD libraries (build123d), +so shape classes import their geometry builders lazily inside build(). +All lengths are in millimeters. +""" +import math +from collections import Counter + + +def parse_size(token): + """Parse one base-size token from the CLI. + + '31.6' -> 31.6 (scalar shapes: circle diameter, square side, hex + across-flats); '60x35' -> (60.0, 35.0) (pair shapes like oval: + width x depth in tray orientation). + """ + text = str(token).strip().lower() + if 'x' in text: + parts = text.split('x') + if len(parts) != 2: + raise ValueError( + f"Invalid size '{token}': pair sizes are WIDTHxDEPTH, e.g. 60x35") + try: + return (float(parts[0]), float(parts[1])) + except ValueError: + raise ValueError( + f"Invalid size '{token}': pair sizes are WIDTHxDEPTH, " + "e.g. 60x35") from None + try: + return float(text) + except ValueError: + raise ValueError(f"Invalid size '{token}': expected a number like 31.6 " + "or WIDTHxDEPTH like 60x35") from None + + +def format_size(size): + """Inverse of parse_size, used for filenames and messages.""" + if isinstance(size, (tuple, list)): + return f"{size[0]}x{size[1]}" + return str(size) + + +def parse_base(token): + """Parse one '[shape:]size' base token from the CLI. + + '24.7' -> (None, 24.7): no prefix, the tray's default shape decides. + 'oval:60x35' -> ('oval', (60.0, 35.0)): prefixed bases are validated + against the named shape's size format right here, so a bad token is + reported by name. + """ + text = str(token).strip() + if ':' not in text: + return None, parse_size(text) + shape_name, size_text = text.split(':', 1) + shape = get_shape(shape_name.strip().lower()) + size = parse_size(size_text) + shape.validate_size(size) + return shape.name, size + + +def format_base_summary(shape_names, sizes, default_shape): + """Filename fragment summarizing the bases, e.g. '1xoval60x35mm_4x24.7mm'. + + Bases are counted by (shape, size); `shape_names` entries of None (and + bases beyond the end of the list) use `default_shape`, and only shapes + differing from it are spelled out, so single-shape trays keep their + plain '10x31.6mm' style names. + """ + padded = list(shape_names) + [None] * (len(sizes) - len(shape_names)) + counts = Counter((name if name is not None else default_shape, size) + for name, size in zip(padded, sizes)) + # Sort by shape, then size; size keys are padded to tuples so scalar + # and (width, depth) pair sizes order together. + summary = sorted( + counts.items(), + key=lambda item: (item[0][0],) + ( + item[0][1] if isinstance(item[0][1], tuple) else (item[0][1],))) + parts = [] + for (shape_name, size), count in summary: + prefix = '' if shape_name == default_shape else shape_name + parts.append(f"{count}x{prefix}{format_size(size)}mm") + return '_'.join(parts) + + +class CutoutShape: + """One cutout shape, described by a `size` per base. + + For a circle, `size` is the base diameter; for a square, the side + length; for an oval, a (width, depth) pair. `tolerance` is the fit + tolerance added around the base, so the physical hole measures + size + tolerance across. + """ + + #: Registry key, also the CLI value for --cutout-shape. + name = None + #: How the alternating (nested) layout spaces this shape: 'circle' + #: uses exact circle tangency; 'box' uses conservative bounding-box + #: separation (safe for any shape). + nesting = 'box' + #: 'scalar' (one number per base) or 'pair' (WIDTHxDEPTH per base). + size_format = 'scalar' + + def validate_size(self, size): + is_pair = isinstance(size, (tuple, list)) + if self.size_format == 'pair' and not is_pair: + raise ValueError( + f"'{self.name}' bases need WIDTHxDEPTH sizes (e.g. 60x35); " + f"got {format_size(size)}") + if self.size_format == 'scalar' and is_pair: + raise ValueError( + f"'{self.name}' bases take a single size number per base; " + f"got {format_size(size)} (WIDTHxDEPTH sizes are only for " + "pair shapes like oval)") + + def footprint(self, size, tolerance): + """(x, y) bounding extent of the physical hole cut into the tray.""" + return (size + tolerance, size + tolerance) + + def layout_sizes(self, size, tolerance): + """Per-axis sizes the layout algorithms should use for this base, + defined so that layout_size + tolerance equals the physical hole + footprint per axis. + + Default: (size, size), exact for shapes whose bounding box is + size x size (do NOT compute it as footprint - tolerance here: the + float round-trip would shift layout positions by ~1e-14 and make + otherwise identical exports differ). Shapes with a wider bounding + box (e.g. a hex across its corners) must override this. + """ + return (size, size) + + def circumradius(self, size, tolerance): + """Radius of the smallest circle containing the physical hole.""" + raise NotImplementedError + + def min_center_distance(self, size, other, other_size, tolerance, gap=0.0): + """Minimum center-to-center distance to another cutout that keeps at + least `gap` clearance between their edges. + + Conservative default: the shapes' circumscribed circles touch. Shape + pairs with tighter exact math can override this. + """ + return (self.circumradius(size, tolerance) + + other.circumradius(other_size, tolerance) + + gap) + + def build(self, size, **params): + """Build the 3D negative (a build123d Compound) for one cutout.""" + raise NotImplementedError + + +class CircleShape(CutoutShape): + name = 'circle' + nesting = 'circle' + + def circumradius(self, size, tolerance): + return (size + tolerance) / 2 + + def build(self, size, **params): + try: + from .cutout_generator import generate_cutout + except ImportError: + from cutout_generator import generate_cutout + return generate_cutout(size, **params) + + +class SquareShape(CutoutShape): + name = 'square' + + def circumradius(self, size, tolerance): + return (size + tolerance) * math.sqrt(2) / 2 + + def build(self, size, **params): + try: + from .cutout_generator import generate_square_cutout + except ImportError: + from cutout_generator import generate_square_cutout + return generate_square_cutout(size, **params) + + +class HexShape(CutoutShape): + """Regular hexagon, measured across the flats (the natural caliper + measurement). Oriented with flats facing the tray edges, so the corners + point along x: the footprint is 2/sqrt(3) (~1.155x) wider across x than + the measured size. + """ + name = 'hex' + + def footprint(self, size, tolerance): + across_corners = (size + tolerance) * 2 / math.sqrt(3) + return (across_corners, size + tolerance) + + def circumradius(self, size, tolerance): + return (size + tolerance) / math.sqrt(3) + + def layout_sizes(self, size, tolerance): + across_corners = (size + tolerance) * 2 / math.sqrt(3) + return (across_corners - tolerance, size) + + def build(self, size, **params): + try: + from .cutout_generator import generate_hex_cutout + except ImportError: + from cutout_generator import generate_hex_cutout + return generate_hex_cutout(size, **params) + + +class OvalShape(CutoutShape): + """Ellipse, sized as a WIDTHxDEPTH pair in tray orientation: width runs + along the tray (x), depth front-to-back (y). Swap the two numbers to + stand the oval upright if it is too deep for a row. + """ + name = 'oval' + size_format = 'pair' + + def footprint(self, size, tolerance): + return (size[0] + tolerance, size[1] + tolerance) + + def circumradius(self, size, tolerance): + return (max(size) + tolerance) / 2 + + def layout_sizes(self, size, tolerance): + return (size[0], size[1]) + + def build(self, size, **params): + try: + from .cutout_generator import generate_oval_cutout + except ImportError: + from cutout_generator import generate_oval_cutout + return generate_oval_cutout(size, **params) + + +SHAPES = {shape.name: shape + for shape in (CircleShape(), SquareShape(), HexShape(), + OvalShape())} + + +def get_shape(name): + try: + return SHAPES[name] + except KeyError: + raise ValueError( + f"Unknown cutout shape '{name}'. " + f"Available shapes: {', '.join(sorted(SHAPES))}" + ) from None diff --git a/Trays/functions/tray_config.py b/Trays/functions/tray_config.py new file mode 100644 index 0000000..0ec6e1d --- /dev/null +++ b/Trays/functions/tray_config.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass + + +@dataclass +class TrayConfig: + """All tray geometry and generation settings, defaulted in one place. + + Per-base inputs (sizes, edge offsets, edge adjusts) are passed to + generate_full_tray separately. All lengths are in millimeters. + """ + + # Overall dimensions + total_width: float = 189.5 + total_depth: float = 66.0 + # (x, y) margin between the tray walls and the usable cutout area + safety_margin: tuple = (6.5, 0.8) + + # Base structure + floor_thickness: float = 0.8 + base_height: float = 4.2 + rail_height: float = 8.4 + rail_width: float = 4.8 + + # Flap + flap_center_gap: float = 0.2 + flap_depth: float = 11.8 + + # Hinge (hinge_depth is measured from the edge of the center, not the flap) + hinge_width: float = 2.8 + hinge_height: float = 3.6 + hinge_depth: float = 17.5 + hinge_pin_radius: float = 1.4 + hinge_pin_length: float = 3.0 + bottom_chamfer: float = 0.4 + hinge_lock_radius: float = 3.5 + hinge_lock_offset: float = 0.4 + hinge_lock_depth: float = 8.3 + hinge_diameter: float = 27.7 + + # Cutout and layout options + cutout_shape: str = 'circle' # any key of shapes.SHAPES ('circle', 'square') + # Wall angle of the cutout in degrees from vertical; positive narrows + # the cutout toward the top. None uses the shape's default (see + # cutout_generator: 12.5 for circles, 5 for squares/hexes). For a + # measured base: + # taper_angle = atan((bottom_size - top_size) / (2 * base_height)) + taper_angle: float = None + # Extra sideways clearance (mm per side) in the flap's portion of + # square/hex cutouts, so the closing flap's cutout edge sweeps past the + # base instead of clipping it. Circle cutouts have their own revolved + # rotation relief and ignore this. + flap_clearance: float = 1.0 + min_cutout_spacing: float = 2.0 # Minimum gap (mm) between cutout edges + is_double_tray: bool = True + force_linear_positions: bool = False + tolerance: float = 0.55 # Base fit tolerance (mm) + epsilon: float = 0.001 diff --git a/Trays/tray_generator.py b/Trays/tray_generator.py index dc3f6a5..07bc8be 100644 --- a/Trays/tray_generator.py +++ b/Trays/tray_generator.py @@ -1,51 +1,27 @@ # %% Libraries from build123d import * from ocp_vscode import * -import math -import copy import argparse import os -from collections import Counter from functions.full_tray_generator import generate_full_tray +from functions.shapes import SHAPES, parse_base, format_base_summary +from functions.tray_config import TrayConfig # %% User-Adjustable Parameters (Defaults) -diameters = [24.7, 49.6, 39.2, 49.6, 24.7, ] +# All tray geometry and layout defaults live in functions/tray_config.py +# (TrayConfig). Override individual fields here for IDE/Jupyter runs, e.g.: +# config = TrayConfig(total_width=200, cutout_shape='circle') +config = TrayConfig() + +sizes = [24.7, 49.6, 39.2, 49.6, 24.7, ] +# Optional per-base shape names, parallel to sizes; None (or a missing +# entry) uses config.cutout_shape. Example: ['oval', None, None]. +shapes = [] edge_offsets = [] edge_adjusts = [] -total_width = 189.5 # Default: 189.5 -total_depth = 66 # Default: 66.0 -safety_margin = (6.5, 0.8) - -floor_thickness = 0.8 -base_heigth = 4.2 -rail_height = 8.4 -rail_width = 4.8 - -flap_center_gap = 0.2 -flap_depth = 11.8 - -hinge_width = 2.8 -hinge_height = 3.6 -# Calculated from the edge of the center, not the flap -hinge_depth = 17.5 -hinge_pin_radius = 1.4 -hinge_pin_length = 3 -bottom_chamfer = 0.4 - -hinge_lock_radius = 3.5 -hinge_lock_offset = 0.4 -hinge_lock_depth = 8.3 -hinge_diameter = 27.7 - -is_double_tray = True - -base_tolerance = .55 -epsilon = 0.001 -custom_filename = "output/test_tray" - # %% Main execution @@ -65,57 +41,67 @@ # Detect if running in Jupyter/IPython is_jupyter = 'ipykernel' in sys.argv[0] or 'jupyter' in sys.argv[0].lower() - # Check if running from command line (has diameters argument) and NOT in Jupyter + # Check if running from command line (has sizes argument) and NOT in Jupyter if len(sys.argv) > 1 and not is_jupyter: parser = argparse.ArgumentParser( description="Generate a tray with custom base cutouts\n" - + "Usage: \"python tray_generator.py [diameters] [options]\"\n" + + "Usage: \"python tray_generator.py [sizes] [options]\"\n" + "Example: \"python tray_generator.py 24.7 24.7 24.7 24.7 24.7 24.7\"\n" + "Example: \"python tray_generator.py 31.6 31.6 31.6 31.6 31.6 31.6 --safety-margin-y 0.4\"\n" + "Example: \"python tray_generator.py 31.6 31.6 31.6 31.6 31.6 31.6 --safety-margin-y 0.4 --tolerance 0.6\"\n", formatter_class=argparse.RawDescriptionHelpFormatter ) + def base_argument(token): + try: + return parse_base(token) + except ValueError as e: + raise argparse.ArgumentTypeError(f"'{token}': {e}") + parser.add_argument( - "diameters", - type=float, + "sizes", + type=base_argument, nargs="+", - help="Space-separated list of base diameters (e.g., 31.6 31.6 25.4)" + help="Space-separated list of base sizes in mm: circle diameter, " + "square side length, or hex across-flats (e.g., 31.6 31.6 25.4). " + "Oval bases take WIDTHxDEPTH pairs (e.g., 60x35). Prefix a size " + "with a shape name to mix shapes in one tray (e.g., oval:60x35 " + "24.7 24.7); unprefixed sizes use --cutout-shape." ) parser.add_argument( "--width", type=float, - default=total_width, - help=f"Total tray width (default: {total_width})" + default=config.total_width, + help=f"Total tray width (default: {config.total_width})" ) parser.add_argument( "--depth", type=float, - default=total_depth, - help=f"Total tray depth (default: {total_depth})" + default=config.total_depth, + help=f"Total tray depth (default: {config.total_depth})" ) parser.add_argument( "--output", type=str, default=None, - help="Output file path without extension (default: auto-generated from diameter summary)" + help="Output file path without extension (default: auto-generated from size summary)" ) parser.add_argument( "--safety-margin-x", type=float, default=None, - help=f"Horizontal safety margin from edges (default: {safety_margin[0]})" + help=f"Horizontal safety margin from edges (default: {config.safety_margin[0]})" ) parser.add_argument( "--safety-margin-y", type=float, default=None, - help=f"Vertical safety margin from edges (default: {safety_margin[1]}). If generating a tray of bases around 32mm, you may have to lower this to 0.4." + help=f"Vertical safety margin from edges (default: {config.safety_margin[1]}). If generating a tray of bases around 32mm, you may have to lower this to 0.4." ) parser.add_argument( "--tolerance", type=float, default=None, - help=f"Tolerance for base fit (default: {base_tolerance})" + help=f"Tolerance for base fit (default: {config.tolerance})" ) parser.add_argument( "--edge-offsets", @@ -137,29 +123,71 @@ help="Generate a single-sided tray (default: double-sided)" ) parser.add_argument( - "--force-linear-positions", - action="store_true", - help="Forces the use of linear positioning as opposed to alternating" + "--cutout-shape", + type=str, + default=None, + choices=sorted(SHAPES), + help=f"Cutout shape (default: {config.cutout_shape})" + ) + parser.add_argument( + "--taper-angle", + type=float, + default=None, + help="Wall angle of the cutouts in degrees from vertical " + "(default: 12.5 for circle, 5 for square/hex). Positive narrows " + "the cutout toward the top. To match a measured base: " + "atan((bottom_size - top_size) / (2 * base_height)), e.g. a base " + "29.8mm at the bottom, 27.2mm at the top and 4mm tall needs " + "atan(2.6/8) = 18 degrees." + ) + parser.add_argument( + "--flap-clearance", + type=float, + default=None, + help="Extra sideways clearance (mm per side) in the flap's part " + "of square/hex cutouts so the flap can rotate closed past a " + f"seated base (default: {config.flap_clearance}). Circle " + "cutouts have their own rotation relief and ignore this." + ) + parser.add_argument( + "--min-cutout-spacing", + type=float, + default=None, + help=f"Minimum gap (mm) between adjacent cutout edges (default: {config.min_cutout_spacing})" + ) + parser.add_argument( + "--force-linear-positions", + action="store_true", + help="Forces the use of linear positioning as opposed to alternating" ) args = parser.parse_args() - # Override defaults with command line arguments - diameters = args.diameters - total_width = args.width - total_depth = args.depth + # Override config defaults with command line arguments + shapes = [name for name, _ in args.sizes] + sizes = [size for _, size in args.sizes] + config.total_width = args.width + config.total_depth = args.depth custom_output = args.output - is_double_tray = not args.single_sided - force_linear_positions = args.force_linear_positions + config.is_double_tray = not args.single_sided + if args.cutout_shape is not None: + config.cutout_shape = args.cutout_shape + if args.min_cutout_spacing is not None: + config.min_cutout_spacing = args.min_cutout_spacing + if args.taper_angle is not None: + config.taper_angle = args.taper_angle + if args.flap_clearance is not None: + config.flap_clearance = args.flap_clearance + config.force_linear_positions = args.force_linear_positions # Handle safety margins - use provided values or keep defaults - margin_x = args.safety_margin_x if args.safety_margin_x is not None else safety_margin[0] - margin_y = args.safety_margin_y if args.safety_margin_y is not None else safety_margin[1] - safety_margin = (margin_x, margin_y) + margin_x = args.safety_margin_x if args.safety_margin_x is not None else config.safety_margin[0] + margin_y = args.safety_margin_y if args.safety_margin_y is not None else config.safety_margin[1] + config.safety_margin = (margin_x, margin_y) # Handle tolerance - use provided value or keep default if args.tolerance is not None: - base_tolerance = args.tolerance + config.tolerance = args.tolerance # Handle edge offsets - use provided values or keep default if args.edge_offsets is not None: @@ -172,66 +200,44 @@ # No arguments - use defaults custom_output = None - # Create a summary of diameters (count how many of each size) - diameter_count = Counter(diameters) - diameter_summary = sorted(diameter_count.items()) - - # Generate filename from diameter summary if not provided + # Generate filename from a summary of the bases if not provided, + # like "tray_1xoval60x35mm_4x24.7mm". if custom_output: output_filename = custom_output else: - # Create filename like "tray_31.6x10_25.4x5" from the diameter summary - filename_parts = [ - f"{count}x{diameter}mm" for diameter, count in diameter_summary] - output_filename = f"tray_{'_'.join(filename_parts)}" + output_filename = ( + f"tray_{format_base_summary(shapes, sizes, config.cutout_shape)}") print("Generating", output_filename, flush=True) sys.stdout.flush() tray_compound, _ = generate_full_tray( - diameters, - safety_margin, - total_width, - total_depth, - floor_thickness, - base_heigth, - rail_height, - rail_width, - flap_center_gap, - flap_depth, - hinge_width, - hinge_height, - hinge_depth, - hinge_pin_radius, - hinge_pin_length, - bottom_chamfer, - hinge_lock_radius, - hinge_lock_offset, - hinge_lock_depth, - edge_offsets, - edge_adjusts, - is_double_tray, - force_linear_positions, - epsilon, - base_tolerance, - hinge_diameter + sizes, + config, + edge_offsets=edge_offsets, + edge_adjusts=edge_adjusts, + shapes=shapes, ) print("Tray generated successfully", flush=True) sys.stdout.flush() try: show(tray_compound) - except: + except Exception: pass - # Ensure output directory exists - os.makedirs("output", exist_ok=True) + # Export next to this script, regardless of the current working directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(script_dir, "output") + stl_path = os.path.join(output_dir, f"{output_filename}.stl") + step_path = os.path.join(output_dir, f"{output_filename}.step") + os.makedirs(os.path.dirname(stl_path), exist_ok=True) - export_stl(tray_compound, f"output/{output_filename}.stl") - print(f"Exported: output/{output_filename}.stl", flush=True) + export_stl(tray_compound, stl_path) + print(f"Exported: {stl_path}", flush=True) - export_step(tray_compound, f"output/{output_filename}.step") - print(f"Exported: output/{output_filename}.step", flush=True) + export_step(tray_compound, step_path) + print(f"Exported: {step_path}", flush=True) print(f"{output_filename} complete", flush=True) @@ -244,36 +250,26 @@ RED = "\033[91m" RESET = "\033[0m" - # Check for math domain error - usually caused by mixing large and small diameter bases + # Check for math domain error - usually caused by mixing large and small size bases if "math domain error" in error_message.lower(): - print(f"{RED}Error: Cannot fit base configuration.\n" \ - "Mixing large base diameters (32mm+) with small base diameters (<32mm) requires\n" \ - "alternating them in the layout. Multiple small bases in a row causes geometric conflicts.\n" \ - "Try: Distribute smaller diameters throughout with larger ones in between.\n" \ - "Example: Instead of [25, 25, 40, 40], try [25, 40, 25, 40]\n" \ - "Try: Setting the flag \"--force-linear-positions\".{RESET}", flush=True) - raise ValueError("Error: Cannot fit base configuration.\n" \ - "Mixing large base diameters (32mm+) with small base diameters (<32mm) requires\n" \ - "alternating them in the layout. Multiple small bases in a row causes geometric conflicts.\n" \ - "Try: Distribute smaller diameters throughout with larger ones in between.\n" \ - "Example: Instead of [25, 25, 40, 40], try [25, 40, 25, 40]\n" \ + message = ("Cannot fit base configuration.\n" + "Mixing large base sizes (32mm+) with small base sizes (<32mm) requires\n" + "alternating them in the layout. Multiple small bases in a row causes geometric conflicts.\n" + "Try: Distribute smaller sizes throughout with larger ones in between.\n" + "Example: Instead of [25, 25, 40, 40], try [25, 40, 25, 40]\n" "Try: Setting the flag \"--force-linear-positions\".") - if "keyerror: 'flipped'" in error_message.lower(): - print( - f"{RED}Error: System mirror the geometry of the base cutout for double sided trays.\n" \ - "This usually occurs when only one diameter is provided.\n" \ - "Try: Setting the flag \"--single-sided\".\n" \ - "(Note: You may then need to set --depth manually. Try --depth 132 for standard size.){RESET}", - flush=True) - raise ValueError("Error: System mirror the geometry of the base cutout for double sided trays.\n" \ - "This usually occurs when only one diameter is provided.\n" \ - "Try: Setting the flag \"--single-sided\".\n" \ - "(Note: You may then need to set --depth manually. Try --depth 132 for standard size.)\n") - + elif isinstance(e, KeyError) and "flipped" in error_message.lower(): + message = ("System mirrors the geometry of the base cutout for double sided trays.\n" + "This usually occurs when only one size is provided.\n" + "Try: Setting the flag \"--single-sided\".\n" + "(Note: You may then need to set --depth manually. Try --depth 132 for standard size.)") + elif isinstance(e, ValueError): + # Layout errors already carry a user-friendly message + message = error_message else: - print(f"{RED}Error: {type(e).__name__}: {e}{RESET}", flush=True) + message = f"{type(e).__name__}: {error_message}" - sys.stdout.flush() - exit(1) + print(f"{RED}Error: {message}{RESET}", flush=True) + sys.exit(1) # %% diff --git a/docs/feature-mixed-base-shapes.md b/docs/feature-mixed-base-shapes.md new file mode 100644 index 0000000..b9f0d59 --- /dev/null +++ b/docs/feature-mixed-base-shapes.md @@ -0,0 +1,247 @@ +# Feature: Mixed base shapes in one tray + +## Goal + +Allow each base in a tray to have its own cutout shape, instead of one +global `--cutout-shape` for the whole tray. Motivating use case: a tray +with one oval base and the rest circles, e.g. + +```bash +python tray_generator.py oval:60x35 24.7 24.7 24.7 24.7 +``` + +Full flexibility is wanted: any mix of `circle`, `square`, `hex`, and +`oval` (and any future shape registered in `Trays/functions/shapes.py`) +in a single tray. + +## Current state (read these files first) + +- `Trays/functions/shapes.py` — shape registry. Each `CutoutShape` + already exposes everything the layouts need per base: + `layout_sizes(size, tolerance)` (per-axis bounding sizes), `nesting` + (`'circle'` for exact tangency, `'box'` for conservative bounding-box + spacing), `size_format` (`'scalar'` vs `'pair'`), `validate_size`, + and `build(size, **params)`. All four `build()` implementations take + the identical kwarg set, so mixing shapes in the build loop is + already safe. +- `Trays/functions/full_tray_generator.py` — the orchestrator. This is + the main coupling point: `generate_full_tray` calls + `get_shape(config.cutout_shape)` **once** and uses that single shape + object for validation, `layout_sizes`, `nesting`, and every + `build()` call. +- `Trays/functions/calculate_cutout_positions/calculate_linear_cutout_positions.py` + — already fully per-base via the `layout_sizes` list. Needs no + spacing-math changes, only the `index` addition described below. +- `Trays/functions/calculate_cutout_positions/calculate_alternating_cutout_positions.py` + — per-base via `layout_sizes`, **except** that `nesting` is a single + string applied to every neighboring pair. This is the one real + geometry change. +- `Trays/tray_generator.py` — CLI. Sizes are positional tokens parsed + by `shapes.parse_size` (`31.6` → scalar, `60x35` → pair); the shape + comes from `--cutout-shape`. +- Tests live in `tests/` (`test_shapes.py`, `test_linear_positions.py`, + `test_alternating_positions.py`, `test_full_tray_helpers.py`, + `test_cutout_generator.py`). They run without the CAD libraries; + keep `shapes.py` importable without build123d (lazy imports inside + `build()`), and note `full_tray_generator` imports build123d at + module level, so pure-layout tests should target the + `calculate_cutout_positions` modules and `shapes.py` directly, as + the existing tests do. + +## CLI design + +Extend each positional size token with an optional shape prefix: + +``` +[shape:]size +``` + +- `24.7` — no prefix, uses the default shape (`--cutout-shape`, which + keeps its current default of `circle`). Fully backward compatible. +- `oval:60x35` — an oval base 60 mm wide, 35 mm deep. +- `square:31.6`, `hex:25.4`, `circle:24.7` — explicit prefixes. + +Examples: + +```bash +# One oval, four circles (the motivating case) +python tray_generator.py oval:60x35 24.7 24.7 24.7 24.7 + +# Default shape square, with one hex thrown in +python tray_generator.py --cutout-shape square 31.6 hex:25.4 31.6 +``` + +Rules: + +- Unknown prefix → error listing valid shapes (reuse the message from + `shapes.get_shape`). +- Size-format mismatch (e.g. `oval:31.6` or `circle:60x35`) → the + existing `validate_size` error, raised per token at parse time so + the user sees which token is wrong (wrap it as + `argparse.ArgumentTypeError` like the current `size_argument` does). +- A bare `60x35` with a scalar default shape is a mismatch error (this + matches current behavior, where pair sizes require + `--cutout-shape oval`). + +Implementation: add `parse_base(token, )` to `shapes.py` (alongside +`parse_size`) returning `(shape_name_or_None, size)` — `None` meaning +"use the tray default". Splitting rule: if the token contains a `:`, +the part before the first `:` is the shape name; the remainder goes +through the existing `parse_size`. Keep `parse_size` unchanged for +callers that want just a size. + +## Data model + +Thread a per-base shape list through the stack, parallel to `sizes` +(same pattern as `edge_offsets` / `edge_adjusts`): + +```python +def generate_full_tray(sizes, config=None, edge_offsets=None, + edge_adjusts=None, shapes=None): +``` + +- `shapes` is an optional list of shape-name strings (or `None` + entries), padded with `config.cutout_shape` to match `len(sizes)`. + Omitting it entirely reproduces today's behavior exactly. +- Resolve once at the top: `base_shapes = [get_shape(name) for name in + padded_names]`, then `base_shapes[i].validate_size(sizes[i])` per + base. +- `config.cutout_shape` stays and becomes "the default shape for bases + without an explicit one". No `TrayConfig` changes are required. + +## Orchestrator changes (`full_tray_generator.py`) + +1. `layout_sizes` — compute with each base's own shape: + `[base_shapes[i].layout_sizes(sizes[i], config.tolerance) for i in + range(len(sizes))]`. The linear/alternating selection in + `calculate_cutout_positions` (the `max_y_size` check) already works + off `layout_sizes` and needs no change. +2. `nesting` — pass a per-base list `[s.nesting for s in base_shapes]` + instead of the single `shape.nesting` (see next section). +3. Build loop — call `base_shapes[position['index']].build(...)` per + position (see the `index` section below for why the position, not + the loop counter, must supply the base index). + +## Alternating layout: per-pair nesting + +In `calculate_alternating_cutout_positions.py`, `nesting` becomes a +per-base list (keep accepting a single string for convenience by +broadcasting it, so existing tests/callers still work). + +The rule for any pair of bases (i, j): **use circle tangency math only +when both bases have `nesting == 'circle'`; otherwise use the +bounding-box math (`_box_min_dx` / the per-axis `x_gap`/`y_gap` +check).** Box math is conservative and correct for any footprint, so a +circle paired with an oval is safely spaced by bounding boxes. + +Three places branch on `nesting` today; each becomes a per-pair check: + +- `_calculate_initial_positions` — the consecutive-pair offset + (`if nesting == 'circle'` at ~line 96) checks + `nesting[i-1] == nesting[i] == 'circle'`. +- `_redistribution_pass.calculate_dx(i, ...)` — same check for the + pair (i, i+1). +- `_validate_positions` — the all-pairs overlap check uses circle math + only when both `nesting[i]` and `nesting[j]` are `'circle'`. + +Note: an all-circle tray must produce **bit-identical positions** to +today (the broadcast list is all-`'circle'`, so every pair takes the +circle branch). Likewise all-box trays. Add a regression test for +this. + +Optional refinement, explicitly out of scope for the first cut: exact +circle–ellipse and ellipse–ellipse tangency spacing (packs a mixed +oval/circle tray tighter than bounding boxes). The +`CutoutShape.min_center_distance` hook in `shapes.py` was designed as +the seam for this — a follow-up could route the pair spacing through +it — but bounding-box spacing is correct and sufficient now. + +## Positions must carry the original base index + +Add `'index': ` to every position +dict produced by **both** layout modules (`calculate_linear_cutout_positions.py` +already tracks it internally as `line_one_indices`/`line_two_indices`; +the alternating layout's position order equals input order, so it's +just `i`). + +Then in the `generate_full_tray` build loop, use +`idx = position['index']` for `base_shapes[idx]`, `edge_adjusts[idx]`, +and `edge_offsets[idx]` instead of the enumerate counter. + +This is required for mixed shapes (the linear layout reorders bases +into two rows, so position order ≠ input order) **and it fixes a +latent bug**: today `full_tray_generator.py:148-149` indexes +`edge_adjusts`/`edge_offsets` by position order, so on a reordered +double-row linear layout, per-base edge adjustments can be applied to +the wrong base. (The layout modules themselves apply `edge_offsets` by +original index, correctly — the mismatch is only in the build loop.) +Add a regression test: a double-tray linear layout with distinct +edge_offsets, asserting each position's offset matches its original +base. + +## Output filename + +`tray_generator.py` builds the filename from a `Counter` of sizes. +Count `(shape_name, size)` pairs instead, and prefix the shape name +when it differs from the tray's default shape, e.g. + +``` +tray_1xoval60x35mm_4x24.7mm +``` + +(An all-default tray keeps producing exactly today's filenames.) Sort +key must handle mixed tuple/scalar sizes as the current code does — +sort by `(shape_name, size_key)`. + +## Error handling + +- The "math domain error" hint and the layout `ValueError`s in + `tray_generator.py` stay as-is; the per-pair box fallback means + mixed pairs can't hit the circle-only `_side_from_hyp` failure + unless both are circles (message already suggests + `--force-linear-positions`). +- Per-token parse/validation errors should name the offending token. + +## Tests (all runnable without build123d) + +1. **Parsing**: `parse_base` round-trips `24.7`, `oval:60x35`, + `square:31.6`; rejects `bogus:31.6`, `oval:31.6`, `circle:60x35`, + `oval:60x35x2`. +2. **Backward compat**: all-circle alternating layout with the + broadcast nesting list produces positions identical to passing + `nesting='circle'` today (and same for all-box). +3. **Mixed alternating**: one oval among circles — every adjacent pair + involving the oval is spaced by box math, circle–circle pairs by + tangency; `_validate_positions` passes; all positions inside the + usable area. +4. **Mixed linear**: force-linear with an oval and circles; assert + `index` keys map each position back to its input base. +5. **Edge-offset regression**: double-tray linear layout with distinct + per-base edge_offsets — build-loop inputs (via `index`) match the + original bases. +6. **Validation**: per-base `validate_size` fires for a pair size on a + scalar shape at position N with a message naming that base. +7. **Filename**: mixed-shape summary produces the documented pattern; + all-default trays produce unchanged filenames. + +If a full end-to-end check is wanted and build123d is available, +generate the motivating tray (`oval:60x35 24.7 24.7 24.7 24.7`) and +assert it exports without error — but keep it optional/marked so the +suite still runs without CAD libs. + +## Non-goals + +- Per-base `taper_angle`, `tolerance`, or `flap_clearance` (stay + global in `TrayConfig`). +- Rotated shapes (ovals rotate by swapping WIDTHxDEPTH, as today). +- Exact ellipse tangency packing (see optional refinement above). +- Changes to the cutout builders in `cutout_generator.py` — none are + needed; they already share one kwarg interface. + +## Acceptance criteria + +- `python tray_generator.py oval:60x35 24.7 24.7 24.7 24.7` generates + and exports a tray with one oval and four circle cutouts. +- Existing invocations (no prefixes, with or without + `--cutout-shape`) produce byte-identical layouts to before. +- `pytest` passes, including the new tests above. diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..30ecf1f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# The layout modules live under Trays/functions and are not an installed +# package yet, so make them importable both as top-level modules +# (calculate_cutout_positions.*) and as the package path used by the CLI +# (functions.*). +sys.path.insert(0, str(ROOT / "Trays")) +sys.path.insert(0, str(ROOT / "Trays" / "functions")) diff --git a/tests/test_alternating_positions.py b/tests/test_alternating_positions.py new file mode 100644 index 0000000..3459f36 --- /dev/null +++ b/tests/test_alternating_positions.py @@ -0,0 +1,227 @@ +"""Tests for the alternating (nested) cutout layout. + +Pinned coordinates were captured from real runs. Several tests document +fixes for review findings B1-B4 and B9 (see comments); they originally +pinned the buggy behavior and were inverted when the bugs were fixed. +""" +import pytest + +from calculate_cutout_positions.calculate_alternating_cutout_positions import ( + calculate_alternating_cutout_positions, + _side_from_hyp, +) + +# Usable area for the default double tray (width 189.5, depth 66, +# rail_width 4.8, safety_margin (6.5, 0.8), tolerance 0.55). +UA_DOUBLE = {'min': {'x': -83.45, 'y': -31.925}, + 'max': {'x': 83.45, 'y': 31.925}} + +TOL = 0.55 + + +def test_two_large_circles_nest_at_opposite_corners(): + positions = calculate_alternating_cutout_positions( + {'min': {'x': -82.9, 'y': -31.65}, 'max': {'x': 82.9, 'y': 31.65}}, + [40, 40], [0, 0], TOL) + + assert positions[0]['x'] == pytest.approx(-62.9) # min_x + d/2 + assert positions[0]['y'] == pytest.approx(-11.65) # min_y + d/2 + assert positions[0]['flipped'] is False + assert positions[1]['x'] == pytest.approx(62.9) # max_x - d/2 + assert positions[1]['y'] == pytest.approx(11.65) # max_y - d/2 + assert positions[1]['flipped'] is True + + +def test_five_mixed_sizes_pinned_layout(): + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [24.7, 49.6, 39.2, 49.6, 24.7], [0] * 5, TOL) + + expected = [ + (-71.1, -19.575, 24.7, False), + (-42.278744, 7.125, 49.6, True), + (0.0, -12.325, 39.2, False), + (42.278744, 7.125, 49.6, True), + (71.1, -19.575, 24.7, False), + ] + assert len(positions) == len(expected) + for got, (x, y, d, flipped) in zip(positions, expected): + assert got['x'] == pytest.approx(x, abs=1e-5) + assert got['y'] == pytest.approx(y, abs=1e-5) + assert got['size'] == d + assert got['flipped'] is flipped + + +def test_flipped_flag_alternates(): + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [40.0, 40.0, 40.0], [0] * 3, TOL) + assert [p['flipped'] for p in positions] == [False, True, False] + + +def test_redistribution_equalizes_edge_gaps(): + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [24.7, 49.6, 39.2, 49.6, 24.7], [0] * 5, TOL) + + gaps = [] + for a, b in zip(positions, positions[1:]): + center_dist = ((b['x'] - a['x']) ** 2 + (b['y'] - a['y']) ** 2) ** 0.5 + # Physical clearance between the holes: subtract the toleranced radii. + gaps.append(center_dist + - (a['size'] + TOL) / 2 - (b['size'] + TOL) / 2) + + # All consecutive edge-to-edge gaps are equal and respect the validator's + # 0.4mm minimum. + for gap in gaps: + assert gap == pytest.approx(gaps[0], abs=0.02) + assert gap >= 0.4 + + +def test_single_size_is_centered(): + # Review B4: this path used to rely on a leftover loop variable and + # subtracted the edge offset; it now adds it (inward), matching the + # multi-size and linear-layout convention. + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [40.0], [0.5], TOL) + + assert len(positions) == 1 + assert positions[0]['x'] == 0 + assert positions[0]['y'] == pytest.approx(-11.425) # min_y + 20 + 0.5 + assert positions[0]['flipped'] is False + + +def test_single_size_without_edge_offsets(): + # Review B4: an empty edge_offsets list used to raise IndexError here. + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [40.0], [], TOL) + assert positions[0]['y'] == pytest.approx(-11.925) # min_y + 20 + + +def test_edge_offsets_move_cutouts_inward_consistently(): + """Review B3 (fixed): every position moves inward, away from its resting + edge — +y on the front row, -y on the back row, matching the linear + layout convention.""" + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [40.0, 40.0, 40.0], [1.0, 1.0, 1.0], TOL) + + front_y = UA_DOUBLE['min']['y'] + 20.0 + 1.0 + back_y = UA_DOUBLE['max']['y'] - 20.0 - 1.0 + assert positions[0]['y'] == pytest.approx(front_y) + assert positions[1]['y'] == pytest.approx(back_y) + assert positions[2]['y'] == pytest.approx(front_y) + + +def test_y_boundary_violation_raises(): + """Review B1 (fixed): cutouts overflowing the usable area in y used to be + silently accepted (the old boundary check `break`ed without flagging an + error). 35mm bases in a 30mm-deep area must be rejected.""" + area = {'min': {'x': -40.0, 'y': -15.0}, 'max': {'x': 40.0, 'y': 15.0}} + with pytest.raises(ValueError, match="does not fit"): + calculate_alternating_cutout_positions( + area, [35.0, 35.0], [0, 0], TOL) + + +def test_tolerance_affects_layout(): + """Review B9 (fixed): tolerance used to be ignored entirely by the + alternating layout. It now sizes the physical holes, so a (much) larger + tolerance produces a different layout when space is tight.""" + a = calculate_alternating_cutout_positions( + UA_DOUBLE, [24.7, 49.6, 39.2], [0] * 3, 0.55) + b = calculate_alternating_cutout_positions( + UA_DOUBLE, [24.7, 49.6, 39.2], [0] * 3, 5.0) + assert a != b + + +def test_overcrowded_layout_raises(): + # Too many large circles: they cannot all be placed inside the usable + # area with the required clearance. + with pytest.raises(ValueError, match="does not fit|too wide"): + calculate_alternating_cutout_positions( + UA_DOUBLE, [49.6] * 5, [0] * 5, TOL) + + +def test_non_consecutive_overlap_raises(): + """Review B2 (fixed): the overlap check only compared consecutive pairs, + so two same-side circles (i and i+2) could overlap undetected. Here the + tray is deep and narrow: consecutive gaps are fine (~0.6mm) but the two + 30mm circles both land on the front row almost on top of each other.""" + area = {'min': {'x': -22.0, 'y': -32.2}, 'max': {'x': 22.0, 'y': 32.2}} + with pytest.raises(ValueError, match="too wide"): + calculate_alternating_cutout_positions( + area, [30.0, 34.0, 30.0], [0, 0, 0], TOL) + + +def test_circles_too_small_to_nest_raise_with_hint(): + # Two 10mm circles on opposite sides cannot touch across the tray depth; + # the error should point the user at --force-linear-positions. + with pytest.raises(ValueError, match="force-linear-positions"): + calculate_alternating_cutout_positions( + UA_DOUBLE, [10.0, 10.0], [0, 0], TOL) + + +def test_side_from_hyp_math(): + assert _side_from_hyp(5, 3) == pytest.approx(4.0) + with pytest.raises(ValueError): + _side_from_hyp(3, 5) + + +def test_two_75x42_ovals_nest_diagonally(): + """The user scenario that motivated box nesting: two 75x42mm ovals on + the standard 189.5x66 tray. Too deep to stack (42+42 > 63.85 usable), + but they fit with one on each edge, offset in x.""" + ovals = [(75.0, 42.0), (75.0, 42.0)] + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, ovals, [0, 0], TOL, + layout_sizes=[(75.0, 42.0), (75.0, 42.0)], nesting='box') + + assert len(positions) == 2 + front, back = positions + assert front['flipped'] is False and back['flipped'] is True + assert front['size'] == (75.0, 42.0) + + # Resting on their edges: y = edge -+ depth/2. + assert front['y'] == pytest.approx(-31.925 + 21.0) + assert back['y'] == pytest.approx(31.925 - 21.0) + + # Pushed into opposite corners, physically clear of each other in x + # (holes are 75.55 wide; centers must be > 75.55 + 0.4 apart). + assert front['x'] == pytest.approx(-83.45 + 37.5) + assert back['x'] == pytest.approx(83.45 - 37.5) + assert (back['x'] - front['x']) - 75.55 >= 0.4 + + # And each stays inside the usable area. + assert front['x'] - 37.5 >= UA_DOUBLE['min']['x'] - 0.1 + assert back['x'] + 37.5 <= UA_DOUBLE['max']['x'] + 0.1 + + +def test_box_nesting_two_large_squares(): + """40mm squares were previously barred from the nesting layout; with + box spacing they nest into opposite corners like circles do -- but + spaced by their full widths, since circle tangency would let their + corners collide.""" + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [40.0, 40.0], [0, 0], TOL, + layout_sizes=[(40.0, 40.0), (40.0, 40.0)], nesting='box') + + assert [p['flipped'] for p in positions] == [False, True] + dx = positions[1]['x'] - positions[0]['x'] + # Box spacing: full half-widths + tolerance + gap, NOT the smaller + # circle-tangency distance for this dy. + assert dx - 40.55 >= 0.4 + + +def test_box_nesting_rejects_overcrowding(): + # Three 75-wide ovals cannot fit across 166.9mm of usable width. + with pytest.raises(ValueError, match="does not fit|too wide"): + calculate_alternating_cutout_positions( + UA_DOUBLE, [(75.0, 42.0)] * 3, [0] * 3, TOL, + layout_sizes=[(75.0, 42.0)] * 3, nesting='box') + + +def test_box_nesting_allows_vertical_clearance(): + # Two shallow bases that fit stacked need no x separation: box nesting + # may place them at the same x when the tray is deep enough. + positions = calculate_alternating_cutout_positions( + UA_DOUBLE, [(60.0, 25.0), (60.0, 25.0)], [0, 0], TOL, + layout_sizes=[(60.0, 25.0), (60.0, 25.0)], nesting='box') + # 25 + 25 + tolerance clears 63.85 usable depth: vertical gap is fine + # regardless of x, so no overlap error and both fit in bounds. + assert len(positions) == 2 diff --git a/tests/test_cutout_generator.py b/tests/test_cutout_generator.py new file mode 100644 index 0000000..f010c61 --- /dev/null +++ b/tests/test_cutout_generator.py @@ -0,0 +1,57 @@ +"""Tests for cutout boolean-result handling (functions/cutout_generator.py). + +Unlike the layout tests these need the CAD stack (build123d), but they only +build one small cutout so they stay fast. +""" +from build123d import Compound, ShapeList, Solid + +from cutout_generator import _unwrap_boolean_result, generate_cutout + + +def test_unwrap_dimensionless_compound(): + # Boolean ops can return a plain Compound whose class-level _dim is + # None, which cannot be fused with `+`. + box = Solid.make_box(1, 1, 1) + result = _unwrap_boolean_result(Compound([box])) + assert result is not None + assert type(result)._dim == 3 + + +def test_unwrap_shapelist(): + box = Solid.make_box(1, 1, 1) + result = _unwrap_boolean_result(ShapeList([box])) + assert result is not None + assert type(result)._dim == 3 + + +def test_unwrap_empty_returns_none(): + assert _unwrap_boolean_result(None) is None + assert _unwrap_boolean_result(ShapeList()) is None + + +def test_unwrap_keeps_all_solids(): + a = Solid.make_box(1, 1, 1) + b = Solid.make_box(1, 1, 1).translate((2, 0, 0)) + result = _unwrap_boolean_result(Compound([a, b])) + assert result is not None + assert type(result)._dim == 3 + assert abs(result.volume - 2) < 1e-6 + + +def test_unwrap_passes_through_regular_shapes(): + box = Solid.make_box(1, 1, 1) + assert _unwrap_boolean_result(box) is box + + +def test_generate_cutout_small_size_with_edge_offset(): + # Regression: these parameters (--edge-offsets 0.1 --edge-adjusts 0.2 on + # a 24.7mm base) made the edge_offset self-intersection return a + # dimension-less Compound, and the later lip adjustor union crashed with + # "ValueError: Only shapes with the same dimension can be added". + cutout = generate_cutout( + 24.7, + tolerance=0.55, + edge_adjust=0.2, + edge_offset=0.1, + ) + assert cutout.volume > 0 diff --git a/tests/test_full_tray_helpers.py b/tests/test_full_tray_helpers.py new file mode 100644 index 0000000..48df38e --- /dev/null +++ b/tests/test_full_tray_helpers.py @@ -0,0 +1,118 @@ +"""Tests for the pure-math helpers in full_tray_generator: usable-area +computation and the linear/alternating layout dispatch. + +full_tray_generator imports build123d and ocp_vscode at module level, so +this file is skipped when the CAD dependencies are not installed. The tests +themselves never build geometry. +""" +import pytest + +pytest.importorskip("build123d") +pytest.importorskip("ocp_vscode") + +from functions.full_tray_generator import ( # noqa: E402 + calculate_usable_area, + calculate_cutout_positions, + generate_full_tray, +) +from functions.tray_config import TrayConfig # noqa: E402 + +# Default tray parameters. +WIDTH, DEPTH = 189.5, 66.0 +RAIL_WIDTH = 4.8 +MARGIN = (6.5, 0.8) +TOL = 0.55 + + +@pytest.fixture(scope="module") +def ua_double(): + return calculate_usable_area(WIDTH, DEPTH, RAIL_WIDTH, MARGIN, TOL, True) + + +def test_usable_area_double(): + ua = calculate_usable_area(WIDTH, DEPTH, RAIL_WIDTH, MARGIN, TOL, True) + # x: half width minus rail minus x-margin; y: half depth minus y-margin + # minus half tolerance, symmetric for a double tray. + assert ua['min']['x'] == pytest.approx(-83.45) + assert ua['max']['x'] == pytest.approx(83.45) + assert ua['min']['y'] == pytest.approx(-31.925) + assert ua['max']['y'] == pytest.approx(31.925) + + +def test_usable_area_single_sided_caps_y_at_zero(): + ua = calculate_usable_area(WIDTH, DEPTH, RAIL_WIDTH, MARGIN, TOL, False) + assert ua['min']['y'] == pytest.approx(-31.925) + assert ua['max']['y'] == 0 + + +def test_dispatch_empty_input(ua_double): + assert calculate_cutout_positions(ua_double, [], [], TOL) == [] + + +def test_dispatch_small_sizes_use_linear(ua_double): + # max size <= -min_y (31.925): linear layout, rows stack at same x. + positions = calculate_cutout_positions( + ua_double, [25.0, 25.0], [0, 0], TOL, is_double_tray=True) + assert [p['x'] for p in positions] == pytest.approx([0.0, 0.0]) + assert [p['flipped'] for p in positions] == [False, True] + + +def test_dispatch_large_sizes_use_alternating(ua_double): + # max size > -min_y: alternating layout, nested at opposite corners. + positions = calculate_cutout_positions( + ua_double, [40.0, 40.0], [0, 0], TOL, is_double_tray=True) + assert positions[0]['x'] == pytest.approx(-63.45) + assert positions[1]['x'] == pytest.approx(63.45) + assert [p['flipped'] for p in positions] == [False, True] + + +def test_dispatch_force_linear_overrides_alternating(ua_double): + # 33mm > 31.925 would normally pick the alternating layout; the force + # flag stacks the bases on straight rows instead (33 + 25 still fits + # the tray depth). + positions = calculate_cutout_positions( + ua_double, [33.0, 25.0], [0, 0], TOL, is_double_tray=True, + force_linear_positions=True) + assert [p['x'] for p in positions] == pytest.approx([0.0, 0.0]) + assert [p['flipped'] for p in positions] == [False, True] + + +def test_dispatch_force_linear_rejects_overlapping_rows(ua_double): + # Forcing two 40mm bases onto stacked rows of a 66mm tray would merge + # their holes in the middle; the layout must refuse. + with pytest.raises(ValueError, match="front and back rows overlap"): + calculate_cutout_positions( + ua_double, [40.0, 40.0], [0, 0], TOL, is_double_tray=True, + force_linear_positions=True) + + +def test_dispatch_mixed_shapes_uses_per_pair_nesting(ua_double): + # A circle next to an oval: alternating layout (40 > 31.925), and the + # mixed pair must be spaced by bounding boxes, not circle tangency. + positions = calculate_cutout_positions( + ua_double, [40.0, (60.0, 35.0)], [0, 0], TOL, is_double_tray=True, + layout_sizes=[(40.0, 40.0), (60.0, 35.0)], + nesting=['circle', 'box']) + min_dx = (40.0 + TOL) / 2 + (60.0 + TOL) / 2 + assert abs(positions[1]['x'] - positions[0]['x']) >= min_dx - 0.01 + assert [p['index'] for p in positions] == [0, 1] + + +def test_generate_full_tray_validation_names_the_base(): + # Per-base size validation fires before any geometry is built and + # names the offending base. + with pytest.raises(ValueError, match="Base 2:.*WIDTHxDEPTH"): + generate_full_tray([24.7, 31.6], TrayConfig(), + shapes=[None, 'oval']) + with pytest.raises(ValueError, match="Base 1:.*single size number"): + generate_full_tray([(60.0, 35.0), 24.7], TrayConfig()) + + +def test_dispatch_single_sided_always_linear(): + # Single-sided trays have no back row, so the linear layout is used + # even for large bases (given enough depth, e.g. --depth 132). + ua = calculate_usable_area(WIDTH, 132.0, RAIL_WIDTH, MARGIN, TOL, False) + positions = calculate_cutout_positions( + ua, [40.0, 40.0], [0, 0], TOL, is_double_tray=False) + assert all(p['flipped'] is False for p in positions) + assert all(p['y'] == pytest.approx(-44.925) for p in positions) diff --git a/tests/test_linear_positions.py b/tests/test_linear_positions.py new file mode 100644 index 0000000..e0aa7bb --- /dev/null +++ b/tests/test_linear_positions.py @@ -0,0 +1,229 @@ +"""Characterization tests for the linear cutout layout. + +These pin the CURRENT behavior of calculate_linear_cutout_positions so the +upcoming shape-abstraction refactor can be done safely. Tests whose names +start with test_bug_ document known-buggy behavior on purpose; when the bug +is fixed, invert the assertion rather than deleting the test. +""" +import pytest + +from calculate_cutout_positions.calculate_linear_cutout_positions import ( + calculate_linear_cutout_positions, + calculate_line_positions, + _line_fits, +) + +# Usable area for the default tray: width 189.5, depth 66, rail_width 4.8, +# safety_margin (6.5, 0.8), tolerance 0.55 (matches calculate_usable_area). +UA_DOUBLE = {'min': {'x': -83.45, 'y': -31.925}, + 'max': {'x': 83.45, 'y': 31.925}} +UA_SINGLE = {'min': {'x': -83.45, 'y': -31.925}, + 'max': {'x': 83.45, 'y': 0}} + +TOL = 0.55 +MIN_SPACING = 2.0 + + +def test_single_sided_row_is_centered_and_evenly_spaced(): + positions = calculate_linear_cutout_positions( + UA_SINGLE, [31.6, 31.6, 31.6], [0, 0, 0], TOL, is_double_tray=False) + + assert [p['x'] for p in positions] == pytest.approx([-49.9, 0.0, 49.9]) + # y is always resting on the front edge: min_y + size/2 + assert all(p['y'] == pytest.approx(-16.125) for p in positions) + assert all(p['flipped'] is False for p in positions) + assert all(p['size'] == 31.6 for p in positions) + + +def test_single_item_is_centered(): + positions = calculate_linear_cutout_positions( + UA_SINGLE, [30.0], [0], TOL, is_double_tray=False) + + assert len(positions) == 1 + assert positions[0]['x'] == pytest.approx(0.0) + assert positions[0]['y'] == pytest.approx(-16.925) # min_y + 15 + + +def test_single_sided_base_deeper_than_tray_raises(): + # A 40mm base on a default-depth single-sided tray would overhang the + # tray's open back edge (usable depth 31.925mm); needs --depth. + with pytest.raises(ValueError, match="too deep"): + calculate_linear_cutout_positions( + UA_SINGLE, [40.0], [0], TOL, is_double_tray=False) + + +def test_empty_input_returns_empty_list(): + assert calculate_linear_cutout_positions( + UA_SINGLE, [], [], TOL, is_double_tray=False) == [] + + +def test_double_tray_splits_into_front_and_back_rows(): + positions = calculate_linear_cutout_positions( + UA_DOUBLE, [31.6] * 6, [0] * 6, TOL, is_double_tray=True) + + front = [p for p in positions if not p['flipped']] + back = [p for p in positions if p['flipped']] + assert len(front) == 3 and len(back) == 3 + + # Front row rests on the front edge, back row on the back edge (mirrored y), + # and back-row cutouts are marked flipped so the lip is rotated 180. + assert all(p['y'] == pytest.approx(-16.125) for p in front) + assert all(p['y'] == pytest.approx(16.125) for p in back) + # Both rows use the same x pattern. + assert [p['x'] for p in front] == pytest.approx([-49.9, 0.0, 49.9]) + assert [p['x'] for p in back] == pytest.approx([-49.9, 0.0, 49.9]) + + +def test_double_tray_row_assignment_and_edge_offsets(): + """Pins row assignment order (alternately taken from list start/end) and + that edge offsets are indexed by ORIGINAL input position, pushing cutouts + inward (+y on front row, -y on back row).""" + positions = calculate_linear_cutout_positions( + UA_DOUBLE, + [25.4, 25.4, 31.6, 30.0], + [0.5, 0.6, 0.7, 0.8], + TOL, + is_double_tray=True) + + expected = [ + # front row: input indices 0 and 1, y = -31.925 + d/2 + offset + {'x': -32.233333, 'y': -18.725, 'size': 25.4, 'flipped': False}, + {'x': 32.233333, 'y': -18.625, 'size': 25.4, 'flipped': False}, + # back row: input indices 2 and 3, y = 31.925 - d/2 - offset + {'x': -32.733333, 'y': 15.425, 'size': 31.6, 'flipped': True}, + {'x': 33.533333, 'y': 16.125, 'size': 30.0, 'flipped': True}, + ] + assert len(positions) == len(expected) + for got, want in zip(positions, expected): + assert got['x'] == pytest.approx(want['x'], abs=1e-5) + assert got['y'] == pytest.approx(want['y'], abs=1e-5) + assert got['size'] == want['size'] + assert got['flipped'] is want['flipped'] + + +def test_short_edge_offsets_list_is_safe(): + """Review B8 (fixed): an edge_offsets list shorter than the sizes list + used to IndexError on the back row (guard checked the wrong length). + Missing offsets are now treated as 0.""" + positions = calculate_linear_cutout_positions( + UA_DOUBLE, [30.0, 30.0], [0.5], TOL, is_double_tray=True) + + assert positions[0]['y'] == pytest.approx(-31.925 + 15.0 + 0.5) + assert positions[1]['y'] == pytest.approx(31.925 - 15.0) # no offset + + +def test_line_positions_symmetric_margins_and_tolerance_gap(): + positions = calculate_line_positions(UA_DOUBLE, [40.0, 40.0], TOL, + MIN_SPACING) + + xs = [p['x'] for p in positions] + assert xs == pytest.approx([-34.666667, 34.666667], abs=1e-5) + + # Outer margins are equal (leftover space distributed evenly)... + left_margin = (xs[0] - 20.0) - (-83.45) + right_margin = 83.45 - (xs[1] + 20.0) + assert left_margin == pytest.approx(right_margin) + # ...and the interior edge-to-edge gap gets the margin plus one tolerance. + interior_gap = (xs[1] - 20.0) - (xs[0] + 20.0) + assert interior_gap == pytest.approx(left_margin + TOL) + + +@pytest.mark.parametrize("sizes", [ + [31.6, 31.6, 31.6, 31.6], + [25.4, 40.0, 25.4], + [49.6, 24.7, 39.2], +]) +def test_row_stays_in_bounds_with_min_spacing(sizes): + positions = calculate_line_positions(UA_DOUBLE, sizes, TOL, MIN_SPACING) + + edges = [] + for p in positions: + left = p['x'] - p['size'] / 2 + right = p['x'] + p['size'] / 2 + assert left >= UA_DOUBLE['min']['x'] - 1e-9 + assert right <= UA_DOUBLE['max']['x'] + 1e-9 + edges.append((left, right)) + for (_, right_a), (left_b, _) in zip(edges, edges[1:]): + assert left_b - right_a >= MIN_SPACING - 1e-9 + + +def test_overflow_raises_single_sided(): + with pytest.raises(ValueError, match="too wide"): + calculate_linear_cutout_positions( + UA_SINGLE, [40.0] * 5, [0] * 5, TOL, is_double_tray=False) + + +def test_overflow_raises_double_when_both_rows_full(): + with pytest.raises(ValueError, match="too wide"): + calculate_linear_cutout_positions( + UA_DOUBLE, [40.0] * 9, [0] * 9, TOL, is_double_tray=True) + + +def test_layout_sizes_widen_spacing_and_set_y(): + """Shapes wider than their measured size (hexes across corners) pass + per-axis layout_sizes: x spacing must use the wide extent while the + 'size' key and the y resting extent keep their own values.""" + wide = 30.0 * 2 / (3 ** 0.5) # hex-like: ~34.64 across corners + positions = calculate_linear_cutout_positions( + UA_SINGLE, [30.0, 30.0], [0, 0], TOL, is_double_tray=False, + layout_sizes=[(wide, 30.0), (wide, 30.0)]) + + # 'size' stays the measured scalar; y rests on the y extent. + assert all(p['size'] == 30.0 for p in positions) + assert all(p['y'] == pytest.approx(-31.925 + 15.0) for p in positions) + + # The physical edge-to-edge gap along x (using the wide extent) must + # still respect min_spacing. + gap = (positions[1]['x'] - positions[0]['x']) - wide + assert gap >= MIN_SPACING - 1e-9 + + # And the wide extent must actually spread the centers further apart + # than the same bases laid out with their scalar size. + narrow = calculate_linear_cutout_positions( + UA_SINGLE, [30.0, 30.0], [0, 0], TOL, is_double_tray=False) + assert (positions[1]['x'] - positions[0]['x'] + > narrow[1]['x'] - narrow[0]['x']) + + +def test_stacked_deep_bases_raise(): + """Two 40mm bases resting on opposite edges of a 66mm tray overlap by + ~16mm in the middle at the same x. This used to be silently accepted + (producing merged, unusable holes).""" + with pytest.raises(ValueError, match="front and back rows overlap"): + calculate_linear_cutout_positions( + UA_DOUBLE, [40.0, 40.0], [0, 0], TOL, is_double_tray=True) + + +def test_deep_bases_allowed_when_rows_interleave_in_x(): + # On a wide-enough tray, deep bases may exceed half the depth as long + # as front and back rows do not share x. + wide = {'min': {'x': -120.0, 'y': -31.925}, 'max': {'x': 120.0, 'y': 31.925}} + positions = calculate_linear_cutout_positions( + wide, [40.0, 40.0, 40.0], [0] * 3, TOL, is_double_tray=True) + + front_xs = sorted(p['x'] for p in positions if not p['flipped']) + back_xs = [p['x'] for p in positions if p['flipped']] + assert len(front_xs) == 2 and len(back_xs) == 1 + # back base sits between the two front bases with real clearance + assert front_xs[0] + 20.275 < back_xs[0] - 20.275 + assert back_xs[0] + 20.275 < front_xs[1] - 20.275 + + +def test_oval_pair_sizes_flow_through_layout(): + # Oval bases carry (width, depth) pairs in 'size'; layout_sizes drive + # both packing and y resting. + positions = calculate_linear_cutout_positions( + UA_SINGLE, [(60.0, 30.0), (60.0, 30.0)], [0, 0], TOL, + is_double_tray=False, layout_sizes=[(60.0, 30.0), (60.0, 30.0)]) + + assert all(p['size'] == (60.0, 30.0) for p in positions) + assert all(p['y'] == pytest.approx(-31.925 + 15.0) for p in positions) + gap = (positions[1]['x'] - positions[0]['x']) - 60.0 + assert gap >= MIN_SPACING - 1e-9 + + +def test_line_fits_boundary_is_inclusive(): + # One item: total + 2 * min_spacing must be <= max_width. + # 162.9 + 2 * 2.0 == 166.9 -> exactly fits; 163.0 does not. + assert _line_fits(0, 0, 162.9, 166.9, TOL, MIN_SPACING) is True + assert _line_fits(0, 0, 163.0, 166.9, TOL, MIN_SPACING) is False diff --git a/tests/test_mixed_shapes.py b/tests/test_mixed_shapes.py new file mode 100644 index 0000000..add4514 --- /dev/null +++ b/tests/test_mixed_shapes.py @@ -0,0 +1,151 @@ +"""Tests for mixing base shapes in one tray (per-base nesting in the +alternating layout, and the 'index' key both layouts attach so the +orchestrator can map positions back to per-base inputs). + +Pure layout math: no CAD libraries required. +""" +import pytest + +from shapes import get_shape +from calculate_cutout_positions.calculate_alternating_cutout_positions import ( + calculate_alternating_cutout_positions, +) +from calculate_cutout_positions.calculate_linear_cutout_positions import ( + calculate_linear_cutout_positions, +) + +# Default double-tray usable area (see test_full_tray_helpers). +UA = {'min': {'x': -83.45, 'y': -31.925}, + 'max': {'x': 83.45, 'y': 31.925}} +TOL = 0.55 + + +def layout_sizes_for(specs): + """specs: list of (shape_name, size) -> per-base (x, y) layout sizes.""" + return [get_shape(name).layout_sizes(size, TOL) for name, size in specs] + + +def test_alternating_broadcast_matches_single_string_nesting(): + # A per-base all-circle list must place bases exactly where the old + # single-string nesting did (backward compatibility). + sizes = [40.0, 40.0, 40.0] + old = calculate_alternating_cutout_positions( + UA, sizes, [], TOL, nesting='circle') + new = calculate_alternating_cutout_positions( + UA, sizes, [], TOL, nesting=['circle'] * 3) + for a, b in zip(old, new): + assert a['x'] == b['x'] + assert a['y'] == b['y'] + # Same for all-box. + old = calculate_alternating_cutout_positions( + UA, sizes, [], TOL, nesting='box') + new = calculate_alternating_cutout_positions( + UA, sizes, [], TOL, nesting=['box'] * 3) + for a, b in zip(old, new): + assert a['x'] == b['x'] + assert a['y'] == b['y'] + + +def test_alternating_positions_carry_index_in_input_order(): + positions = calculate_alternating_cutout_positions( + UA, [40.0, 40.0, 40.0], [], TOL, nesting='circle') + assert [p['index'] for p in positions] == [0, 1, 2] + + +def test_alternating_mixed_oval_among_circles(): + # The motivating use case: one oval among circles. Pairs that involve + # the box-nested oval must be spaced by bounding boxes; circle-circle + # pairs may still nest by tangency. + specs = [('circle', 40.0), ('oval', (60.0, 35.0)), ('circle', 40.0)] + sizes = [size for _, size in specs] + ls = layout_sizes_for(specs) + nesting = [get_shape(name).nesting for name, _ in specs] + + positions = calculate_alternating_cutout_positions( + UA, sizes, [], TOL, layout_sizes=ls, nesting=nesting) + + assert [p['index'] for p in positions] == [0, 1, 2] + assert [p['flipped'] for p in positions] == [False, True, False] + + # The circle-oval pairs rest on opposite edges but are too deep to + # clear vertically, so box nesting demands full horizontal separation + # of the toleranced holes. + for a, b in ((0, 1), (1, 2)): + min_dx = (ls[a][0] + TOL) / 2 + (ls[b][0] + TOL) / 2 + assert abs(positions[b]['x'] - positions[a]['x']) >= min_dx - 0.01 + + # Everything stays inside the usable area. + for pos, (fx, fy) in zip(positions, ls): + assert pos['x'] - fx / 2 >= UA['min']['x'] - 0.1 + assert pos['x'] + fx / 2 <= UA['max']['x'] + 0.1 + assert pos['y'] - fy / 2 >= UA['min']['y'] - 0.1 + assert pos['y'] + fy / 2 <= UA['max']['y'] + 0.1 + + +def test_alternating_mixed_pair_rejects_overcrowding(): + # Two ovals and a circle that would fit under (incorrect) circle + # tangency but not under box spacing must be rejected. + specs = [('oval', (60.0, 35.0)), ('circle', 40.0), ('oval', (60.0, 35.0)), + ('circle', 40.0)] + sizes = [size for _, size in specs] + ls = layout_sizes_for(specs) + nesting = [get_shape(name).nesting for name, _ in specs] + with pytest.raises(ValueError): + calculate_alternating_cutout_positions( + UA, sizes, [], TOL, layout_sizes=ls, nesting=nesting) + + +# Two wide ovals and one circle: the second oval no longer fits the +# front row and overflows to the end of the back row, so the position +# order genuinely differs from the input order. +REORDERING_SPECS = [('oval', (100.0, 30.0)), ('oval', (100.0, 30.0)), + ('circle', 33.0)] + + +def test_linear_positions_carry_original_index(): + # The double-row linear layout can reorder bases; each position must + # say which input base it belongs to. + sizes = [size for _, size in REORDERING_SPECS] + ls = layout_sizes_for(REORDERING_SPECS) + positions = calculate_linear_cutout_positions( + UA, sizes, [], TOL, is_double_tray=True, layout_sizes=ls) + for pos in positions: + assert pos['size'] == sizes[pos['index']] + # The index key is doing real work here: position order != input order. + assert [p['index'] for p in positions] == [0, 2, 1] + + +def test_linear_mixed_oval_among_circles_maps_indices(): + specs = [('circle', 24.7), ('oval', (60.0, 35.0)), ('circle', 24.7), + ('circle', 24.7)] + sizes = [size for _, size in specs] + ls = layout_sizes_for(specs) + positions = calculate_linear_cutout_positions( + UA, sizes, [], TOL, is_double_tray=True, layout_sizes=ls) + assert sorted(p['index'] for p in positions) == [0, 1, 2, 3] + for pos in positions: + assert pos['size'] == sizes[pos['index']] + # Each base rests on its row's edge using its own depth. + fy = ls[pos['index']][1] + if pos['flipped']: + assert pos['y'] == pytest.approx(UA['max']['y'] - fy / 2) + else: + assert pos['y'] == pytest.approx(UA['min']['y'] + fy / 2) + + +def test_linear_edge_offsets_follow_their_base_when_rows_reorder(): + # Regression: per-base edge offsets must stay with their base even + # when the two rows change the position order. + sizes = [size for _, size in REORDERING_SPECS] + ls = layout_sizes_for(REORDERING_SPECS) + edge_offsets = [0.1, 0.2, 0.3] + positions = calculate_linear_cutout_positions( + UA, sizes, edge_offsets, TOL, is_double_tray=True, layout_sizes=ls) + assert [p['index'] for p in positions] == [0, 2, 1] + for pos in positions: + offset = edge_offsets[pos['index']] + fy = ls[pos['index']][1] + if pos['flipped']: + assert pos['y'] == pytest.approx(UA['max']['y'] - fy / 2 - offset) + else: + assert pos['y'] == pytest.approx(UA['min']['y'] + fy / 2 + offset) diff --git a/tests/test_shapes.py b/tests/test_shapes.py new file mode 100644 index 0000000..52a03d7 --- /dev/null +++ b/tests/test_shapes.py @@ -0,0 +1,168 @@ +"""Tests for the cutout shape registry (functions/shapes.py). + +The registry itself is pure Python: importing it (and calling everything +except build()) must not require the CAD libraries. +""" +import math + +import pytest + +from shapes import (SHAPES, get_shape, parse_size, format_size, parse_base, + format_base_summary, CircleShape, SquareShape, HexShape, + OvalShape) + + +def test_registry_contains_all_shapes(): + assert set(SHAPES) >= {'circle', 'square', 'hex', 'oval'} + assert isinstance(SHAPES['circle'], CircleShape) + assert isinstance(SHAPES['square'], SquareShape) + assert isinstance(SHAPES['hex'], HexShape) + assert isinstance(SHAPES['oval'], OvalShape) + # Registry keys match the shapes' own names (used for --cutout-shape). + for name, shape in SHAPES.items(): + assert shape.name == name + + +def test_parse_size(): + assert parse_size('31.6') == 31.6 + assert parse_size('60x35') == (60.0, 35.0) + assert parse_size('60X35') == (60.0, 35.0) + with pytest.raises(ValueError, match="WIDTHxDEPTH"): + parse_size('60x35x2') + with pytest.raises(ValueError, match="WIDTHxDEPTH"): + parse_size('60x') + with pytest.raises(ValueError): + parse_size('big') + + +def test_format_size_roundtrip(): + assert format_size(31.6) == '31.6' + assert format_size((60.0, 35.0)) == '60.0x35.0' + + +def test_parse_base_unprefixed_defers_shape(): + # No prefix: the shape is decided later by the tray default, so no + # size-format validation happens here. + assert parse_base('24.7') == (None, 24.7) + assert parse_base('60x35') == (None, (60.0, 35.0)) + + +def test_parse_base_prefixed(): + assert parse_base('oval:60x35') == ('oval', (60.0, 35.0)) + assert parse_base('square:31.6') == ('square', 31.6) + assert parse_base('circle:24.7') == ('circle', 24.7) + assert parse_base('OVAL:60X35') == ('oval', (60.0, 35.0)) + + +def test_parse_base_rejects_bad_tokens(): + with pytest.raises(ValueError, match="Unknown cutout shape"): + parse_base('bogus:31.6') + with pytest.raises(ValueError, match="WIDTHxDEPTH"): + parse_base('oval:31.6') + with pytest.raises(ValueError, match="single size number"): + parse_base('circle:60x35') + with pytest.raises(ValueError, match="WIDTHxDEPTH"): + parse_base('oval:60x35x2') + + +def test_format_base_summary_single_shape_keeps_plain_names(): + # All-default trays keep their historical filename style. + assert format_base_summary( + [], [24.7, 24.7, 31.6], 'circle') == '2x24.7mm_1x31.6mm' + assert format_base_summary( + [None, None], [31.6, 31.6], 'square') == '2x31.6mm' + + +def test_format_base_summary_mixed_shapes_are_prefixed(): + assert format_base_summary( + ['oval', None, None, None, None], + [(60.0, 35.0), 24.7, 24.7, 24.7, 24.7], + 'circle') == '4x24.7mm_1xoval60.0x35.0mm' + # An explicit prefix matching the default is not spelled out. + assert format_base_summary( + ['circle', 'hex'], [24.7, 25.4], 'circle') == '1x24.7mm_1xhex25.4mm' + + +def test_validate_size(): + get_shape('circle').validate_size(31.6) # no raise + get_shape('oval').validate_size((60.0, 35.0)) # no raise + with pytest.raises(ValueError, match="single size number"): + get_shape('circle').validate_size((60.0, 35.0)) + with pytest.raises(ValueError, match="WIDTHxDEPTH"): + get_shape('oval').validate_size(31.6) + + +def test_oval_footprint_and_layout_sizes(): + oval = get_shape('oval') + assert oval.footprint((60.0, 35.0), 0.55) == pytest.approx((60.55, 35.55)) + assert oval.layout_sizes((60.0, 35.0), 0.55) == (60.0, 35.0) + # Circumradius covers the long axis. + assert oval.circumradius((60.0, 35.0), 0.55) == pytest.approx(30.275) + + +def test_get_shape_unknown_name_lists_available(): + with pytest.raises(ValueError, match="circle.*square|square.*circle"): + get_shape('hexagon') + + +def test_circle_circumradius_and_footprint(): + circle = get_shape('circle') + assert circle.circumradius(40.0, 0.55) == pytest.approx(20.275) + assert circle.footprint(40.0, 0.55) == pytest.approx((40.55, 40.55)) + + +def test_square_circumradius_is_half_diagonal(): + square = get_shape('square') + assert square.circumradius(40.0, 0.55) == pytest.approx( + 40.55 * math.sqrt(2) / 2) + assert square.footprint(40.0, 0.55) == pytest.approx((40.55, 40.55)) + + +def test_min_center_distance_circle_circle_matches_layout_math(): + # Must equal the alternating layout's nesting distance: the toleranced + # radii sum plus the clearance gap. + circle = get_shape('circle') + d = circle.min_center_distance(24.7, circle, 49.6, 0.55, gap=0.4) + assert d == pytest.approx((24.7 + 0.55) / 2 + (49.6 + 0.55) / 2 + 0.4) + + +def test_min_center_distance_squares_is_conservative(): + # Two 40mm squares corner-to-corner need more room than two 40mm + # circles: the default circumscribed-circle math must reflect that. + circle = get_shape('circle') + square = get_shape('square') + assert (square.min_center_distance(40.0, square, 40.0, 0.55) + > circle.min_center_distance(40.0, circle, 40.0, 0.55)) + + +def test_nesting_styles(): + # Circles use exact tangency; every other shape uses conservative + # bounding-box spacing in the alternating layout. + assert get_shape('circle').nesting == 'circle' + assert get_shape('square').nesting == 'box' + assert get_shape('hex').nesting == 'box' + assert get_shape('oval').nesting == 'box' + + +def test_hex_footprint_is_wider_across_corners(): + # size is measured across the flats (y); the corners point along x and + # stick out by a factor of 2/sqrt(3). + hex_shape = get_shape('hex') + fx, fy = hex_shape.footprint(25.0, 0.55) + assert fy == pytest.approx(25.55) + assert fx == pytest.approx(25.55 * 2 / math.sqrt(3)) + assert hex_shape.circumradius(25.0, 0.55) == pytest.approx( + 25.55 / math.sqrt(3)) + + +def test_layout_sizes_default_and_hex(): + # Circle and square lay out as size x size... + assert get_shape('circle').layout_sizes(40.0, 0.55) == pytest.approx( + (40.0, 40.0)) + assert get_shape('square').layout_sizes(40.0, 0.55) == pytest.approx( + (40.0, 40.0)) + # ...but a hex must reserve its across-corners width along x, so that + # layout_size + tolerance equals the physical footprint per axis. + hx, hy = get_shape('hex').layout_sizes(25.0, 0.55) + assert hy == pytest.approx(25.0) + assert hx == pytest.approx(25.55 * 2 / math.sqrt(3) - 0.55)