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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ A library for the parametric creation of gear racks, spur-, ring-, bevel- and wo
Creates a gear rack.

This script adjusts the pressure angle in the transverse section to the helix angle: e.g. with a 20° helix angle, a pressure angle of 20° becomes a pressure angle of 21.17° in the transverse section.
The rack module matches the gear module directly, without additional helix-based scaling.

### Format

Expand Down
79 changes: 74 additions & 5 deletions gears.scad
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ module rack(modul, length, height, width, pressure_angle = 20, helix_angle = 0)
// Dimension Calculations
modul=modul*(1-clearance);
c = modul / 6; // Tip Clearance
mx = modul/cos(helix_angle); // Module Shift by Helix Angle in the X-Direction
a = 2*mx*tan(pressure_angle)+c*tan(pressure_angle); // Flank Width
b = pi*mx/2-2*mx*tan(pressure_angle); // Tip Width
mx = modul; // Keep module consistent with gears; no helix scaling
alpha_spur = atan(tan(pressure_angle)/cos(helix_angle)); // Helix Angle in Transverse Section
a = 2*mx*tan(alpha_spur)+c*tan(alpha_spur); // Flank Width
b = pi*mx/2-2*mx*tan(alpha_spur); // Tip Width
x = width*tan(helix_angle); // Topside Shift by Helix Angle in the X-Direction
nz = ceil((length+abs(2*x))/(pi*mx)); // Number of Teeth

Expand Down Expand Up @@ -148,6 +149,74 @@ module rack(modul, length, height, width, pressure_angle = 20, helix_angle = 0)
};
}

/* Involute rack; uses involute flank geometry to better match involute gears
modul = Height of the Tooth Tip above the Rolling LIne
length = Length of the Rack
height = Height of the Rack to the Pitch Line
width = Width of a Tooth
pressure_angle = Pressure Angle, Standard = 20° according to DIN 867. Should not exceed 45°.
helix_angle = Helix Angle of the Rack Transverse Axis; 0° = Spur Teeth */
module involute_rack(modul, length, height, width, pressure_angle = 20, helix_angle = 0) {

// Dimension Calculations
c = modul / 6; // Tip Clearance
mx = modul; // Keep module consistent with gears; no helix scaling
alpha_spur = atan(tan(pressure_angle)/cos(helix_angle)); // Helix Angle in Transverse Section
pitch = pi*mx; // Pitch length
x_shift = width*tan(helix_angle); // Topside Shift by Helix Angle in the X-Direction
x_slope = (width == 0) ? 0 : x_shift/width; // Avoid division by zero for degenerate widths
nz = ceil((length+abs(2*x_shift))/pitch); // Number of Teeth
virtual_teeth = (nz < 12) ? 12 : nz; // Stabilize involute curvature for short racks

// Virtual gear parameters for involute flank generation
d = mx * virtual_teeth; // Pitch Circle Diameter
r = d / 2; // Pitch Circle Radius
rb = r * cos(alpha_spur); // Base Circle Radius
da = (mx < 1) ? d + mx * 2.2 : d + mx * 2; // Tip Diameter
ra = da / 2; // Tip Circle Radius
rho_ra = acos(rb/ra); // Maximum Rolling Angle
rho_r = acos(rb/r); // Rolling Angle at Pitch Circle
phi_r = grad(tan(rho_r)-radian(rho_r)); // Angle to Point of Involute on Pitch Circle
step = rho_ra/16; // Involute is divided into 16 pieces
tooth_width = (180*(1-clearance))/virtual_teeth + 2*phi_r; // Tooth thickness in degrees
angle_shift = -phi_r - 90*(1-clearance)/virtual_teeth; // Center tooth around zero degrees

y_root = -(mx + c); // Root Line
y_base = rb - r; // Base Line (involute start)

// 2D involute tooth profile for a single pitch
points_2d = concat(
[[0, y_root], [pitch, y_root], [pitch, y_base]],
[for (rho = [0:step:rho_ra]) let (
angle = tooth_width - ev(rb, rho)[1] + angle_shift,
radius = ev(rb, rho)[0]
) [r*radian(angle) + pitch/2, radius - r]],
[for (rho = [rho_ra:-step:0]) let (
angle = ev(rb, rho)[1] + angle_shift,
radius = ev(rb, rho)[0]
) [r*radian(angle) + pitch/2, radius - r]],
[[0, y_base]]
);

translate([-pitch*(nz-1)/2,0,0]){
intersection(){
copier([1,0,0], nz, pitch, 0){
multmatrix(m = [
[1,0,x_slope,0],
[0,1,0,0],
[0,0,1,0],
[0,0,0,1]
])
linear_extrude(height = width, convexity = 10)
polygon(points_2d);
};
translate([abs(x_shift),-height-0.5,-0.5]){
cube([length,height+mx+1,width+1]); // Cuboid which includes the Volume of the Rack
}
};
};
}

/* Mountable-rack; uses module "rack"
modul = Height of the Tooth Tip above the Rolling LIne
length = Length of the Rack
Expand Down Expand Up @@ -334,9 +403,9 @@ module herringbone_rack(modul, length, height, width, pressure_angle = 20, helix
width = width/2;
translate([0,0,width]){
union(){
rack(modul, length, height, width, pressure_angle, helix_angle); // bottom Half
involute_rack(modul, length, height, width, pressure_angle, helix_angle); // bottom Half
mirror([0,0,1]){
rack(modul, length, height, width, pressure_angle, helix_angle); // top Half
involute_rack(modul, length, height, width, pressure_angle, helix_angle); // top Half
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pillow
Pillow>=10.0.0
20 changes: 20 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail

VENV_DIR="${1:-.env}"

if [ ! -f "requirements-dev.txt" ]; then
echo "requirements-dev.txt not found in $(pwd)" >&2
exit 1
fi

if [ ! -d "$VENV_DIR" ]; then
mkdir -p "$(dirname "$VENV_DIR")"
python3 -m venv "$VENV_DIR"
fi

# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"

python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt
86 changes: 86 additions & 0 deletions tests/test_herringbone_rack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path

from PIL import Image


HERRINGBONE_RACK_SCAD_SNIPPET = """\
include <gears.scad>
modul = 1;
gear_teeth = 30;
width = 20;
rack_length = 60;
rack_height = 5;
helix_angle = 30;
pressure_angle = 20;

intersection(){
rotate([0,180,0])
translate([.5, -20, -10])
herringbone_rack(modul=modul, length=rack_length, height=rack_height, width=width, pressure_angle=pressure_angle, helix_angle=helix_angle);
translate([0, -4.75, -20/2])
rotate([0,0,4])
herringbone_gear (modul=modul, tooth_number=gear_teeth, width=width, bore=4, pressure_angle=pressure_angle, helix_angle=helix_angle, optimized=true);
}
"""


class HerringboneRackMeshTest(unittest.TestCase):
"""Regression test for https://github.com/chrisspen/gears/issues/16."""

def setUp(self) -> None:
if shutil.which("openscad-nightly") is None:
self.skipTest("openscad-nightly is required for this test")

def test_herringbone_rack_mesh_does_not_intersect(self) -> None:
image = self._render_intersection()
pixels = list(image.getdata())
dark_pixels = sum(1 for pixel in pixels if pixel < 220)

self.assertLess(
dark_pixels,
100,
"Expected minimal intersection between herringbone rack and gear",
)

@staticmethod
def _render_intersection() -> Image.Image:
tests_dir = Path(__file__).resolve().parent
repo_root = tests_dir.parent
scad_source = repo_root / "gears.scad"

with tempfile.TemporaryDirectory(dir=repo_root) as tmp_dir:
tmp_path = Path(tmp_dir)
scad_path = tmp_path / "herringbone_mesh.scad"
png_path = tmp_path / ".." / ".cache" / "herringbone_mesh.png"

scad_path.write_text(HERRINGBONE_RACK_SCAD_SNIPPET)
shutil.copy(scad_source, tmp_path / "gears.scad")

cmd = [
"openscad-nightly",
str(scad_path),
"--viewall",
"--projection",
"ortho",
"-o",
str(png_path),
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as exc: # pragma: no cover - diagnostic aid
raise AssertionError(
f"openscad-nightly failed with code {exc.returncode}\n"
f"stdout: {exc.stdout.decode()}\n"
f"stderr: {exc.stderr.decode()}"
) from exc

image = Image.open(png_path).convert("L")
return image.copy()


if __name__ == "__main__":
unittest.main()