Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -330,6 +336,10 @@ paket-files/
__pycache__/
*.pyc

# Python virtual environment
venv/
path/

# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
Expand Down Expand Up @@ -401,4 +411,6 @@ FodyWeavers.xsd
*.sln.iml

# output
**/output/**
**/output/**
# Pytest
.pytest_cache/
117 changes: 80 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -47,10 +49,23 @@ The tray generator is designed to be run from the command line, making it easy f
#### Basic Syntax

```bash
python Trays/tray_generator.py <diameter1> <diameter2> ... [options]
python Trays/tray_generator.py <size1> <size2> ... [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).

> **⚠️ 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

Expand All @@ -64,21 +79,53 @@ 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
```

#### 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 | Cutout shape: `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:
Expand All @@ -96,26 +143,21 @@ 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:
```bash
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:
Expand All @@ -125,42 +167,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
Expand Down
Empty file added Trays/functions/__init__.py
Empty file.
77 changes: 41 additions & 36 deletions Trays/functions/base_tray_generator.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -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),
)

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

Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -204,15 +209,15 @@ 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)

with BuildPart() as hinge_lock_negative:
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,
Expand All @@ -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 +
Expand Down Expand Up @@ -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)
# %%
Empty file.
Loading