Files
cadSet/SimpleCADAPI/test/test_stdlib_gear.py
2026-07-22 19:38:36 +08:00

666 lines
25 KiB
Python

"""Tests for the standard-parts library: gears, ring gears, and racks."""
import json
import math
import unittest
import simplecadapi as scad
def _loft_nodes_for(factory):
with scad.GraphSession() as session:
factory()
payload = json.loads(scad.export_model_json(session=session))
return [node for node in payload["graph"]["nodes"] if node["op"] == "make_loft_rsolid"]
class TestStdGearSurface(unittest.TestCase):
def test_preferred_nested_std_export_surface(self):
self.assertFalse(hasattr(scad, "std" + "_gear"))
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=8, module=1.0, gear_height=2.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_public_gear_factories_follow_make_rtype_naming(self):
factory_names = [
name for name in scad.std.gear.__all__
if callable(getattr(scad.std.gear, name, None))
]
self.assertGreater(len(factory_names), 0)
for name in factory_names:
self.assertTrue(name.startswith("make_"), name)
self.assertIn("_r", name, name)
class TestSpurGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_spur_gear(self):
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=10, module=2.0, pressure_angle=20.0, gear_height=5.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_default_pressure_angle(self):
solid = scad.std.gear.make_spur_gear_rsolid(
n_teeth=20, module=1.5, gear_height=4.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_more_teeth_larger_volume(self):
small = scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, gear_height=5.0)
large = scad.std.gear.make_spur_gear_rsolid(n_teeth=24, module=2.0, gear_height=5.0)
self.assertGreater(large.get_volume(), small.get_volume())
def test_height_scales_volume(self):
short = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=4.0)
tall = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0)
self.assertAlmostEqual(tall.get_volume(), 2.0 * short.get_volume(), places=0)
def test_invalid_params(self):
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=2, module=2.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=-1.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, gear_height=0.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1)
with self.assertRaises(Exception):
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, addendum_factor=0.0)
def test_tip_radius_bounds(self):
n_teeth = 20
module = 2.0
solid = scad.std.gear.make_spur_gear_rsolid(n_teeth=n_teeth, module=module, gear_height=4.0)
expected_tip = module * n_teeth / 2.0 + module
max_r = 0.0
for face in solid.get_faces():
for wire in face.get_wires():
for edge in wire.get_edges():
for vertex in edge.get_vertices():
x, y, _ = vertex.get_coordinates()
max_r = max(max_r, math.sqrt(x * x + y * y))
self.assertLess(max_r, expected_tip * 1.05)
self.assertGreater(max_r, expected_tip * 0.95)
def test_external_gear_root_transition_is_not_radial_line_patch(self):
_face, sketch = scad.std.gear._build_gear_profile_face(
n_teeth=18,
module=1.5,
pressure_angle=math.radians(20.0),
return_sketch=True,
)
self.assertNotIn("line_up_0", sketch.entities)
self.assertNotIn("line_down_0", sketch.entities)
self.assertEqual(sketch.entities["fillet_left_0"].kind, "bspline")
self.assertEqual(sketch.entities["fillet_right_0"].kind, "bspline")
def test_external_gear_involute_bspline_uses_analytic_endpoints(self):
_face, sketch = scad.std.gear._build_gear_profile_face(
n_teeth=18,
module=1.5,
pressure_angle=math.radians(20.0),
return_sketch=True,
)
left = sketch.entities["bspline_left_0"]
first_cp = left.data["control_points"][0]
last_cp = left.data["control_points"][-1]
start = sketch.entities["t0_bs"].data
tip = sketch.entities["t0_ts"].data
self.assertAlmostEqual(first_cp[0], start["x"], places=8)
self.assertAlmostEqual(first_cp[1], start["y"], places=8)
self.assertAlmostEqual(last_cp[0], tip["x"], places=8)
self.assertAlmostEqual(last_cp[1], tip["y"], places=8)
def test_external_gear_profile_only_fixes_center_point(self):
_face, sketch = scad.std.gear._build_gear_profile_face(
n_teeth=18,
module=1.5,
pressure_angle=math.radians(20.0),
return_sketch=True,
)
fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"]
self.assertEqual(len(fix_constraints), 1)
self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center")
def test_external_gear_backlash_reduces_pitch_tooth_thickness(self):
n_teeth = 18
module = 1.5
pressure_angle = math.radians(20.0)
backlash = 0.12
no_backlash = scad.std.gear._compute_tooth_geometry(
n_teeth, module, pressure_angle, backlash=0.0,
)
with_backlash = scad.std.gear._compute_tooth_geometry(
n_teeth, module, pressure_angle, backlash=backlash,
)
no_backlash_width = no_backlash["right_start"] - no_backlash["left_start"]
with_backlash_width = with_backlash["right_start"] - with_backlash["left_start"]
self.assertLess(with_backlash_width, no_backlash_width)
self.assertAlmostEqual(
no_backlash_width - with_backlash_width,
backlash / no_backlash["pitch_radius"],
places=12,
)
def test_external_gear_addendum_and_clearance_factors_control_radii(self):
n_teeth = 18
module = 1.5
pressure_angle = math.radians(20.0)
geo = scad.std.gear._compute_tooth_geometry(
n_teeth,
module,
pressure_angle,
addendum_factor=0.8,
clearance_factor=0.1,
)
pitch_radius = module * n_teeth / 2.0
self.assertAlmostEqual(geo["tip_radius"], pitch_radius + 0.8 * module)
self.assertAlmostEqual(geo["root_radius"], pitch_radius - 0.9 * module)
def test_involute_bspline_uses_shared_fit_helper(self):
original = scad.std.gear.fit_cubic_bspline_control_points
calls = []
def wrapped(*args, **kwargs):
calls.append((args, kwargs))
return original(*args, **kwargs)
scad.std.gear.fit_cubic_bspline_control_points = wrapped
try:
scad.std.gear._build_gear_profile_face(
n_teeth=12,
module=1.5,
pressure_angle=math.radians(20.0),
)
finally:
scad.std.gear.fit_cubic_bspline_control_points = original
self.assertGreater(len(calls), 0)
self.assertTrue(all(call[1]["tolerance"] == 1e-4 for call in calls))
class TestHelicalGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_helical_gear(self):
solid = scad.std.gear.make_helical_gear_rsolid(
n_teeth=12, module=2.0, helix_angle=25.0, gear_height=8.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_zero_helix_falls_back_to_spur(self):
spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=6.0)
helical = scad.std.gear.make_helical_gear_rsolid(
n_teeth=12, module=2.0, gear_height=6.0, helix_angle=0.0,
)
self.assertAlmostEqual(spur.get_volume(), helical.get_volume(), places=0)
def test_invalid_params(self):
with self.assertRaises(Exception):
scad.std.gear.make_helical_gear_rsolid(n_teeth=2, module=2.0)
def test_helical_gear_uses_small_step_ruled_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_helical_gear_rsolid(
n_teeth=12,
module=2.0,
helix_angle=25.0,
gear_height=8.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 7)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
class TestHerringboneGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_herringbone_gear(self):
solid = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=12, module=2.0, helix_angle=25.0, gear_height=10.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_zero_helix_falls_back_to_spur(self):
spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0)
herringbone = scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=12, module=2.0, gear_height=8.0, helix_angle=0.0,
)
self.assertAlmostEqual(spur.get_volume(), herringbone.get_volume(), places=0)
def test_herringbone_gear_uses_small_step_ruled_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_herringbone_gear_rsolid(
n_teeth=12,
module=2.0,
helix_angle=25.0,
gear_height=10.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
class TestSpurRingGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_ring_gear(self):
solid = scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_ring_volume_less_than_disc(self):
n_teeth = 20
module = 2.0
rim_thickness = 4.0
ring = scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=n_teeth, module=module, gear_height=5.0, rim_thickness=rim_thickness,
)
pitch_radius = module * n_teeth / 2.0
outer_r = pitch_radius + 1.25 * module + rim_thickness
disc = scad.make_cylinder_rsolid(radius=outer_r, height=5.0)
self.assertLess(ring.get_volume(), disc.get_volume())
def test_ring_profile_uses_internal_tooth_radii(self):
n_teeth = 66
module = 1.5
pressure_angle = math.radians(20.0)
face = scad.std.gear._build_ring_gear_face(
n_teeth=n_teeth,
module=module,
pressure_angle=pressure_angle,
rim_thickness=4.0,
)
inner_wire = face.get_inner_wires()[0]
vertex_radii = [
math.hypot(x, y)
for edge in inner_wire.get_edges()
for vertex in edge.get_vertices()
for x, y, _z in [vertex.get_coordinates()]
]
pitch_radius = module * n_teeth / 2.0
base_radius = pitch_radius * math.cos(pressure_angle)
self.assertAlmostEqual(min(vertex_radii), pitch_radius - module, places=5)
self.assertAlmostEqual(max(vertex_radii), pitch_radius + 1.25 * module, places=5)
self.assertGreater(min(vertex_radii), base_radius + 0.5 * module)
def test_spur_ring_gear_uses_direct_multi_loop_face_not_2d_cut(self):
with scad.GraphSession() as session:
scad.std.gear.make_spur_ring_gear_rsolid(
n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0,
)
payload = json.loads(scad.export_model_json(session))
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertIn("make_face_from_wires_rface", ops)
self.assertNotIn("make_2d_cut_rface", ops)
def test_internal_profile_wire_uses_internal_bspline_flanks(self):
_wire, sketch = scad.std.gear._build_internal_gear_profile_wire(
n_teeth=66,
module=1.5,
pressure_angle=math.radians(20.0),
return_sketch=True,
)
left = sketch.entities["bspline_internal_left_0"]
right = sketch.entities["bspline_internal_right_0"]
self.assertEqual(left.kind, "bspline")
self.assertEqual(right.kind, "bspline")
self.assertNotIn("bspline_left_0", sketch.entities)
self.assertNotIn("bspline_right_0", sketch.entities)
def test_internal_profile_only_fixes_center_point(self):
_wire, sketch = scad.std.gear._build_internal_gear_profile_wire(
n_teeth=20,
module=1.5,
pressure_angle=math.radians(20.0),
return_sketch=True,
)
fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"]
self.assertEqual(len(fix_constraints), 1)
self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center")
def test_ring_backlash_increases_internal_tooth_space(self):
n_teeth = 66
module = 1.5
pressure_angle = math.radians(20.0)
backlash = 0.12
no_backlash = scad.std.gear._compute_internal_tooth_geometry(
n_teeth, module, pressure_angle, backlash=0.0,
)
with_backlash = scad.std.gear._compute_internal_tooth_geometry(
n_teeth, module, pressure_angle, backlash=backlash,
)
no_backlash_space = no_backlash["tooth_angle"] - (
no_backlash["right_root_angle"] - no_backlash["left_root_angle"]
)
with_backlash_space = with_backlash["tooth_angle"] - (
with_backlash["right_root_angle"] - with_backlash["left_root_angle"]
)
self.assertGreater(with_backlash_space, no_backlash_space)
self.assertAlmostEqual(
with_backlash_space - no_backlash_space,
backlash / no_backlash["pitch_radius"],
places=12,
)
def test_ring_addendum_and_clearance_factors_control_internal_radii(self):
n_teeth = 66
module = 1.5
pitch, tip, root, outer = scad.std.gear._internal_ring_radii(
n_teeth,
module,
rim_thickness=4.0,
addendum_factor=0.8,
clearance_factor=0.1,
)
self.assertAlmostEqual(tip, pitch - 0.8 * module)
self.assertAlmostEqual(root, pitch + 0.9 * module)
self.assertAlmostEqual(outer, root + 4.0)
def test_invalid_params(self):
with self.assertRaises(Exception):
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=2, module=2.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, rim_thickness=0.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1)
class TestHelicalRingGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_helical_ring(self):
solid = scad.std.gear.make_helical_ring_gear_rsolid(
n_teeth=20, module=2.0, helix_angle=20.0, gear_height=8.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_helical_ring_uses_small_step_ruled_inner_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_helical_ring_gear_rsolid(
n_teeth=20,
module=2.0,
helix_angle=20.0,
gear_height=8.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 7)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
class TestHerringboneRingGear(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_herringbone_ring(self):
solid = scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=20, module=2.0, helix_angle=20.0, gear_height=10.0,
)
self.assertGreater(solid.get_volume(), 0.0)
def test_herringbone_ring_uses_small_step_ruled_inner_loft(self):
loft_nodes = _loft_nodes_for(
lambda: scad.std.gear.make_herringbone_ring_gear_rsolid(
n_teeth=20,
module=2.0,
helix_angle=20.0,
gear_height=10.0,
)
)
self.assertEqual(len(loft_nodes), 1)
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9)
self.assertTrue(loft_nodes[0]["params"]["ruled"])
class TestCycloidalDisc(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_cycloidal_disc(self):
solid = scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=10,
ring_pin_pitch_radius=18.0,
roller_radius=1.6,
eccentricity=0.8,
gear_height=5.7,
bore_radius=3.45,
output_pin_count=3,
output_pin_pitch_radius=6.4,
output_pin_clearance_radius=2.05,
output_pin_phase=60.0,
)
self.assertGreater(solid.get_volume(), 0.0)
meta = solid.get_metadata("std.gear.cycloidal_disc")
self.assertEqual(meta["pin_count"], 11)
self.assertEqual(meta["n_lobes"], 10)
self.assertEqual(meta["segment_count"], 10)
self.assertLess(meta["radius_min"], meta["radius_max"])
def test_cycloidal_disc_uses_one_bspline_per_lobe(self):
with scad.GraphSession() as session:
scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=8,
ring_pin_pitch_radius=15.0,
roller_radius=1.2,
eccentricity=0.7,
gear_height=4.0,
)
payload = json.loads(scad.export_model_json(session))
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertEqual(ops.count("make_spline_redge"), 8)
self.assertEqual(ops.count("make_line_redge"), 0)
def test_cycloidal_disc_holes_reduce_volume(self):
plain = scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=10,
ring_pin_pitch_radius=18.0,
roller_radius=1.6,
eccentricity=0.8,
gear_height=5.7,
)
bored = scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=10,
ring_pin_pitch_radius=18.0,
roller_radius=1.6,
eccentricity=0.8,
gear_height=5.7,
bore_radius=3.45,
output_pin_count=3,
output_pin_pitch_radius=6.4,
output_pin_clearance_radius=2.05,
)
self.assertLess(bored.get_volume(), plain.get_volume())
def test_twin_disc_workflow_uses_half_lobe_phase(self):
n_lobes = 4
output_pin_phase = 60.0
half_lobe_phase = 180.0 / n_lobes
with scad.GraphSession() as session:
scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=n_lobes,
ring_pin_pitch_radius=10.0,
roller_radius=0.9,
eccentricity=0.45,
gear_height=2.0,
bore_radius=1.5,
output_pin_count=3,
output_pin_pitch_radius=3.0,
output_pin_clearance_radius=0.9,
output_pin_phase=output_pin_phase,
)
upper = scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=n_lobes,
ring_pin_pitch_radius=10.0,
roller_radius=0.9,
eccentricity=0.45,
gear_height=2.0,
bore_radius=1.5,
output_pin_count=3,
output_pin_pitch_radius=3.0,
output_pin_clearance_radius=0.9,
output_pin_phase=output_pin_phase - half_lobe_phase,
)
scad.rotate_shape(
upper,
half_lobe_phase,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
upper_meta = upper.get_metadata("std.gear.cycloidal_disc")
self.assertEqual(
upper_meta["output_pin_phase"],
output_pin_phase - half_lobe_phase,
)
payload = json.loads(scad.export_model_json(session))
ops = [node["op"] for node in payload["graph"]["nodes"]]
rotate_angles = [
node["params"]["angle"]
for node in payload["graph"]["nodes"]
if node["op"] == "make_rotate_rshape"
]
self.assertEqual(ops.count("make_spline_redge"), n_lobes * 2)
self.assertEqual(rotate_angles, [half_lobe_phase])
def test_invalid_params(self):
with self.assertRaises(Exception):
scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=1,
ring_pin_pitch_radius=18.0,
roller_radius=1.6,
eccentricity=0.8,
)
with self.assertRaises(Exception):
scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=10,
ring_pin_pitch_radius=18.0,
roller_radius=1.6,
eccentricity=0.8,
output_pin_count=3,
)
class TestSpurRack(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_rack(self):
solid = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=8, rack_height=5.0)
self.assertGreater(solid.get_volume(), 0.0)
def test_more_teeth_larger_volume(self):
short = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=5, rack_height=5.0)
long = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=10, rack_height=5.0)
self.assertGreater(long.get_volume(), short.get_volume())
def test_invalid_params(self):
with self.assertRaises(Exception):
scad.std.gear.make_spur_rack_rsolid(module=-1.0)
with self.assertRaises(Exception):
scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=0)
def test_rack_profile_has_no_fix_constraints(self):
with scad.GraphSession() as session:
scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=5, rack_height=5.0)
payload = json.loads(scad.export_model_json(session))
ops = [node["op"] for node in payload["graph"]["nodes"]]
self.assertNotIn("make_constrain_fix_rsketch", ops)
class TestHelicalRack(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_helical_rack(self):
solid = scad.std.gear.make_helical_rack_rsolid(
module=2.0, n_teeth=8, helix_angle=25.0, rack_height=8.0,
)
self.assertGreater(solid.get_volume(), 0.0)
class TestHerringboneRack(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_basic_herringbone_rack(self):
solid = scad.std.gear.make_herringbone_rack_rsolid(
module=2.0, n_teeth=8, helix_angle=30.0, rack_height=10.0,
)
self.assertGreater(solid.get_volume(), 0.0)
class Test2DFaceBoolean(unittest.TestCase):
def setUp(self):
scad.GraphSession()
def test_make_2d_cut_rface_creates_hole(self):
outer = scad.make_circle_rface(center=(0, 0, 0), radius=10.0)
inner = scad.make_circle_rface(center=(0, 0, 0), radius=4.0)
ring = scad.make_2d_cut_rface(outer, inner)
self.assertAlmostEqual(
ring.get_area(), math.pi * (100 - 16), places=1,
)
self.assertEqual(len(ring.get_inner_wires()), 1)
def test_make_face_from_wires_rface_creates_hole(self):
outer = scad.make_circle_rwire(center=(0, 0, 0), radius=10.0)
inner = scad.make_circle_rwire(center=(0, 0, 0), radius=4.0)
ring = scad.make_face_from_wires_rface(outer, [inner])
self.assertAlmostEqual(
ring.get_area(), math.pi * (100 - 16), places=1,
)
self.assertEqual(len(ring.get_inner_wires()), 1)
def test_make_2d_union_rface(self):
a = scad.make_circle_rface(center=(0, 0, 0), radius=5.0)
b = scad.make_circle_rface(center=(3, 0, 0), radius=5.0)
merged = scad.make_2d_union_rface(a, b)
self.assertGreater(merged.get_area(), math.pi * 25)
def test_make_2d_intersect_rface(self):
a = scad.make_circle_rface(center=(0, 0, 0), radius=5.0)
b = scad.make_circle_rface(center=(3, 0, 0), radius=5.0)
overlap = scad.make_2d_intersect_rface(a, b)
self.assertGreater(overlap.get_area(), 0.0)
self.assertLess(overlap.get_area(), math.pi * 25)
if __name__ == "__main__":
unittest.main()