diff --git a/README.md b/README.md index a4c81d8..6c7c146 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gears.scad b/gears.scad index bd2cb95..4ae0d85 100644 --- a/gears.scad +++ b/gears.scad @@ -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 @@ -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 @@ -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 } } } diff --git a/requirements-dev.txt b/requirements-dev.txt index 037103e..c7e63dd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1 @@ -pillow \ No newline at end of file +Pillow>=10.0.0 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..5dda061 --- /dev/null +++ b/setup.sh @@ -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 diff --git a/tests/test_herringbone_rack.py b/tests/test_herringbone_rack.py new file mode 100644 index 0000000..97ae4d1 --- /dev/null +++ b/tests/test_herringbone_rack.py @@ -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 +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()