Files
2026-07-22 13:48:46 +08:00

848 lines
31 KiB
Python

"""Example 13: compact 50 mm x 10 mm cycloidal reducer assembly.
Design plan
===========
Package envelope:
- Maximum outside diameter: 50 mm.
- Maximum stack height: 10 mm.
Reduction stage:
- Fixed pin ring: 11 pins.
- Twin cycloidal discs: 10 lobes each, stacked with 180 degree eccentric
carrier separation and a half-lobe tooth-index phase for load balance.
- Single-stage reduction: 11 - 1 = 10:1.
- The fixed pin ring contact is represented as a gear-like coupling between the
input eccentric carrier and each cycloidal disc's relative spin: each disc
spins -11/10 turn relative to its eccentric carrier for each input turn,
giving a global cycloidal/output phase of -1/10 input turn.
Structure:
- fixed_housing: outer sleeve, top/bottom retainers, and 11 fixed ring pins.
- input_disk: bottom three-hole threaded mounting disk plus a double eccentric
cam shaft. The lower cam is at 0 degrees; the upper cam is at 180 degrees.
- lower_cycloidal_disc and upper_cycloidal_disc: 10-lobed discs with eccentric
bearing bores and three oversize output-pin relief holes. The upper disc is
tooth-indexed by half a lobe, i.e. 180 degrees divided by 10 lobes = 18
geometric degrees. A full 180 degree rotation would be symmetry-equivalent
to the lower disc because the profile has 10 lobes.
- output_disk: top three-hole threaded mounting disk plus three output pins.
Assembly relationships:
- fixed_housing is grounded.
- input_disk is revolute about the housing axis.
- output_disk is revolute about the housing axis.
- lower_cycloidal_disc is revolute on the lower input eccentric cam axis.
- upper_cycloidal_disc is revolute on the upper input eccentric cam axis.
- input_disk to each cycloidal disc has a gear-like pin-ring rolling coupling.
The output pins are fixed to the output disk and pass through oversize circular
holes in both cycloidal discs. The two discs load those pins from opposite
eccentric directions, so the real mechanism keeps the output-pin side load more
balanced through a full rotation. This SDK does not yet have a native
pin-slot/contact primitive, so the example models that relation as clearance
geometry rather than a false coaxial ratio shortcut.
Each cycloidal outline is fit as ten cubic B-spline segments, one segment per
lobe. This keeps the exported topology small and stable while preserving the
analytic pin-wheel profile within a controlled fit tolerance.
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
import simplecadapi as scad
from simplecadapi import ql
sys.setrecursionlimit(20000)
PACKAGE_DIAMETER = 50.0
PACKAGE_RADIUS = PACKAGE_DIAMETER / 2.0
PACKAGE_HEIGHT = 10.0
OUTPUT_DIR = Path("examples/out/cycloidal_reducer_50mm_10x")
PIN_COUNT = 11
CYCLOID_LOBES = PIN_COUNT - 1
REDUCTION_RATIO = CYCLOID_LOBES
ECCENTRICITY = 0.8
LOWER_ECCENTRIC_CENTER = (ECCENTRICITY, 0.0)
UPPER_ECCENTRIC_CENTER = (-ECCENTRICITY, 0.0)
UPPER_CYCLOID_BODY_PHASE_DEGREES = 180.0 / CYCLOID_LOBES
RING_PIN_PITCH_RADIUS = 18.0
RING_PIN_RADIUS = 0.65
PROFILE_ROLLER_RADIUS = 1.6
HOUSING_INNER_RADIUS = 22.3
RETAINER_INNER_RADIUS = 13.5
BOTTOM_RETAINER_BOTTOM_Z = 1.25
RETAINER_THICKNESS = 0.75
TOP_RETAINER_BOTTOM_Z = 8.0
PIN_BOTTOM_Z = BOTTOM_RETAINER_BOTTOM_Z
PIN_TOP_Z = TOP_RETAINER_BOTTOM_Z + RETAINER_THICKNESS
INPUT_FLANGE_RADIUS = 11.8
OUTPUT_FLANGE_RADIUS = 11.8
FLANGE_THICKNESS = 1.1
INPUT_FLANGE_BOTTOM_Z = 0.0
OUTPUT_FLANGE_BOTTOM_Z = PACKAGE_HEIGHT - FLANGE_THICKNESS
MOUNT_HOLE_COUNT = 3
MOUNT_HOLE_RADIUS = 1.03
MOUNT_HOLE_ENTRY_RADIUS = 1.35
MOUNT_HOLE_ENTRY_DEPTH = 0.28
MOUNT_HOLE_PITCH_RADIUS = 8.8
ECCENTRIC_BOSS_RADIUS = 3.1
INPUT_SHAFT_RADIUS = 0.55
INPUT_CAM_DATUM_PAD_RADIUS = 0.22
INPUT_CAM_DATUM_PAD_HEIGHT = 0.06
INPUT_CAM_DATUM_PAD_OVERLAP = 0.02
CYCLOID_BORE_RADIUS = 3.45
LOWER_CYCLOID_BOTTOM_Z = 2.15
CYCLOID_DISC_HEIGHT = 2.65
CYCLOID_DISC_GAP = 0.20
CYCLOID_BEARING_RACE_HEIGHT = 0.10
LOWER_CYCLOID_TOP_Z = LOWER_CYCLOID_BOTTOM_Z + CYCLOID_DISC_HEIGHT
LOWER_CYCLOID_CONNECTOR_Z = LOWER_CYCLOID_TOP_Z + CYCLOID_BEARING_RACE_HEIGHT
UPPER_CYCLOID_BOTTOM_Z = LOWER_CYCLOID_CONNECTOR_Z + CYCLOID_DISC_GAP
UPPER_CYCLOID_TOP_Z = UPPER_CYCLOID_BOTTOM_Z + CYCLOID_DISC_HEIGHT
UPPER_CYCLOID_CONNECTOR_Z = UPPER_CYCLOID_TOP_Z + CYCLOID_BEARING_RACE_HEIGHT
CYCLOID_STACK_HEIGHT = UPPER_CYCLOID_CONNECTOR_Z - LOWER_CYCLOID_BOTTOM_Z
CYCLOID_LOBE_SAMPLE_COUNT = 33
CYCLOID_SPLINE_TOLERANCE = 0.005
CYCLOID_SPLINE_MAX_CONTROL_POINTS = 20
OUTPUT_PIN_COUNT = 3
OUTPUT_PIN_RADIUS = 1.0
OUTPUT_PIN_CLEARANCE_RADIUS = OUTPUT_PIN_RADIUS + ECCENTRICITY + 0.25
OUTPUT_PIN_PITCH_RADIUS = 6.4
OUTPUT_PIN_PHASE = 60.0
OUTPUT_PIN_BOTTOM_Z = LOWER_CYCLOID_BOTTOM_Z
OUTPUT_PIN_TOP_Z = OUTPUT_FLANGE_BOTTOM_Z + 0.20
def _polar(radius: float, angle_degrees: float) -> tuple[float, float]:
angle = math.radians(angle_degrees)
return radius * math.cos(angle), radius * math.sin(angle)
def _z_rotation_placement(origin: tuple[float, float, float], angle_degrees: float):
angle = math.radians(angle_degrees)
return scad.make_placement_rplacement(
origin=origin,
x_axis=(math.cos(angle), math.sin(angle), 0.0),
y_axis=(-math.sin(angle), math.cos(angle), 0.0),
)
def _ground_solid(label: str, solid: scad.Solid) -> None:
faces = ql.select(solid.get_faces()).all()
role_faces = ql.select(faces).where(ql.tag("role.*")).all()
print(
f"{label}: faces={len(faces)} role_faces={len(role_faces)} "
f"volume={solid.get_volume():.1f} tags={','.join(scad.list_tags(solid))}"
)
def _ground_compound(label: str, compound: scad.Compound) -> None:
solids = ql.select(compound.get_solids()).all()
face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids)
volume = sum(solid.get_volume() for solid in solids)
print(f"{label}: solids={len(solids)} faces={face_count} volume={volume:.1f}")
def _axis_face(
label: str,
solid: scad.Solid,
center_xy: tuple[float, float],
target_z: float,
normal_z: float,
) -> scad.Face:
candidates = []
for face in ql.select(solid.get_faces()).all():
normal = face.get_normal_at()
if normal_z > 0.0 and normal.z < 0.7:
continue
if normal_z < 0.0 and normal.z > -0.7:
continue
center = face.get_center()
xy_error = math.hypot(center.x - center_xy[0], center.y - center_xy[1])
z_error = abs(center.z - target_z)
candidates.append((z_error * 100.0 + xy_error, face, center, normal))
if not candidates:
raise ValueError(f"no connector face found for {label}")
_score, face, center, normal = min(candidates, key=lambda item: item[0])
print(
f"{label}_connector_face: center=({center.x:.3f},{center.y:.3f},{center.z:.3f}) "
f"normal=({normal.x:.3f},{normal.y:.3f},{normal.z:.3f}) area={face.get_area():.3f}"
)
return face
def _make_annular_cylinder(
*,
outer_radius: float,
inner_radius: float,
bottom_z: float,
height: float,
) -> scad.Solid:
outer = scad.make_cylinder_rsolid(
radius=outer_radius,
height=height,
bottom_face_center=(0.0, 0.0, bottom_z),
axis=(0.0, 0.0, 1.0),
)
inner = scad.make_cylinder_rsolid(
radius=inner_radius,
height=height + 2.0,
bottom_face_center=(0.0, 0.0, bottom_z - 1.0),
axis=(0.0, 0.0, 1.0),
)
return scad.cut_rsolid(outer, inner, skip_non_intersecting=False)
def _cut_three_threaded_hole_envelopes(
solid: scad.Solid,
*,
bottom_z: float,
thickness: float,
entry_face: str,
phase_degrees: float = 0.0,
) -> scad.Solid:
cutters: list[scad.Solid] = []
for index in range(MOUNT_HOLE_COUNT):
angle = phase_degrees + 360.0 * index / MOUNT_HOLE_COUNT
x, y = _polar(MOUNT_HOLE_PITCH_RADIUS, angle)
cutters.append(
scad.make_cylinder_rsolid(
radius=MOUNT_HOLE_RADIUS,
height=thickness + 0.4,
bottom_face_center=(x, y, bottom_z - 0.2),
axis=(0.0, 0.0, 1.0),
)
)
if entry_face == "bottom":
entry_bottom_z = bottom_z - 0.04
elif entry_face == "top":
entry_bottom_z = bottom_z + thickness - MOUNT_HOLE_ENTRY_DEPTH
else:
raise ValueError("entry_face must be 'bottom' or 'top'")
cutters.append(
scad.make_cylinder_rsolid(
radius=MOUNT_HOLE_ENTRY_RADIUS,
height=MOUNT_HOLE_ENTRY_DEPTH + 0.08,
bottom_face_center=(x, y, entry_bottom_z),
axis=(0.0, 0.0, 1.0),
)
)
return scad.cut_rsolid(solid, cutters, skip_non_intersecting=False)
def _build_fixed_housing() -> scad.Solid:
sleeve = _make_annular_cylinder(
outer_radius=PACKAGE_RADIUS,
inner_radius=HOUSING_INNER_RADIUS,
bottom_z=0.0,
height=PACKAGE_HEIGHT,
)
bottom_retainer = _make_annular_cylinder(
outer_radius=PACKAGE_RADIUS,
inner_radius=RETAINER_INNER_RADIUS,
bottom_z=BOTTOM_RETAINER_BOTTOM_Z,
height=RETAINER_THICKNESS,
)
top_retainer = _make_annular_cylinder(
outer_radius=PACKAGE_RADIUS,
inner_radius=RETAINER_INNER_RADIUS,
bottom_z=TOP_RETAINER_BOTTOM_Z,
height=RETAINER_THICKNESS,
)
pins: list[scad.Solid] = []
for index in range(PIN_COUNT):
angle = 360.0 * index / PIN_COUNT
x, y = _polar(RING_PIN_PITCH_RADIUS, angle)
pins.append(
scad.make_cylinder_rsolid(
radius=RING_PIN_RADIUS,
height=PIN_TOP_Z - PIN_BOTTOM_Z,
bottom_face_center=(x, y, PIN_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
housing = scad.union_rsolid(
sleeve,
bottom_retainer,
top_retainer,
pins,
glue=False,
)
housing = scad.apply_tag(housing, "role.fixed_pin_housing")
housing = scad.apply_tag(housing, "group.cycloidal_reducer")
_ground_solid("fixed_housing", housing)
return housing
def _build_input_disk() -> scad.Solid:
flange = scad.make_cylinder_rsolid(
radius=INPUT_FLANGE_RADIUS,
height=FLANGE_THICKNESS,
bottom_face_center=(0.0, 0.0, INPUT_FLANGE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
flange = _cut_three_threaded_hole_envelopes(
flange,
bottom_z=INPUT_FLANGE_BOTTOM_Z,
thickness=FLANGE_THICKNESS,
entry_face="bottom",
phase_degrees=0.0,
)
cam_bottom_z = FLANGE_THICKNESS - 0.20
input_shaft = scad.make_cylinder_rsolid(
radius=INPUT_SHAFT_RADIUS,
height=UPPER_CYCLOID_CONNECTOR_Z - cam_bottom_z,
bottom_face_center=(0.0, 0.0, cam_bottom_z),
axis=(0.0, 0.0, 1.0),
)
lower_pad_bottom_z = LOWER_CYCLOID_CONNECTOR_Z - INPUT_CAM_DATUM_PAD_HEIGHT
lower_eccentric_boss = scad.make_cylinder_rsolid(
radius=ECCENTRIC_BOSS_RADIUS,
height=lower_pad_bottom_z + INPUT_CAM_DATUM_PAD_OVERLAP - cam_bottom_z,
bottom_face_center=(*LOWER_ECCENTRIC_CENTER, cam_bottom_z),
axis=(0.0, 0.0, 1.0),
)
lower_datum_pad = scad.make_cylinder_rsolid(
radius=INPUT_CAM_DATUM_PAD_RADIUS,
height=INPUT_CAM_DATUM_PAD_HEIGHT,
bottom_face_center=(*LOWER_ECCENTRIC_CENTER, lower_pad_bottom_z),
axis=(0.0, 0.0, 1.0),
)
upper_boss_bottom_z = UPPER_CYCLOID_BOTTOM_Z - 0.10
upper_pad_bottom_z = UPPER_CYCLOID_CONNECTOR_Z - INPUT_CAM_DATUM_PAD_HEIGHT
upper_eccentric_boss = scad.make_cylinder_rsolid(
radius=ECCENTRIC_BOSS_RADIUS,
height=upper_pad_bottom_z + INPUT_CAM_DATUM_PAD_OVERLAP - upper_boss_bottom_z,
bottom_face_center=(*UPPER_ECCENTRIC_CENTER, upper_boss_bottom_z),
axis=(0.0, 0.0, 1.0),
)
upper_datum_pad = scad.make_cylinder_rsolid(
radius=INPUT_CAM_DATUM_PAD_RADIUS,
height=INPUT_CAM_DATUM_PAD_HEIGHT,
bottom_face_center=(*UPPER_ECCENTRIC_CENTER, upper_pad_bottom_z),
axis=(0.0, 0.0, 1.0),
)
input_disk = scad.union_rsolid(
flange,
input_shaft,
lower_eccentric_boss,
lower_datum_pad,
upper_eccentric_boss,
upper_datum_pad,
glue=False,
)
input_disk = scad.apply_tag(input_disk, "role.input_three_thread_disk")
input_disk = scad.apply_tag(input_disk, "role.double_eccentric_camshaft")
input_disk = scad.apply_tag(input_disk, "group.cycloidal_reducer")
_ground_solid("input_disk", input_disk)
return input_disk
def _build_cycloidal_disc(
*,
label: str,
bottom_z: float,
output_pin_phase: float,
body_phase_degrees: float,
role_tag: str,
) -> scad.Solid:
disc = scad.std.gear.make_cycloidal_disc_rsolid(
n_lobes=CYCLOID_LOBES,
ring_pin_pitch_radius=RING_PIN_PITCH_RADIUS,
roller_radius=PROFILE_ROLLER_RADIUS,
eccentricity=ECCENTRICITY,
gear_height=CYCLOID_DISC_HEIGHT,
bore_radius=CYCLOID_BORE_RADIUS,
output_pin_count=OUTPUT_PIN_COUNT,
output_pin_pitch_radius=OUTPUT_PIN_PITCH_RADIUS,
output_pin_clearance_radius=OUTPUT_PIN_CLEARANCE_RADIUS,
output_pin_phase=output_pin_phase,
sample_count_per_lobe=CYCLOID_LOBE_SAMPLE_COUNT,
spline_tolerance=CYCLOID_SPLINE_TOLERANCE,
max_control_points=CYCLOID_SPLINE_MAX_CONTROL_POINTS,
)
cycloid_meta = disc.get_metadata("std.gear.cycloidal_disc", {})
top_z = bottom_z + CYCLOID_DISC_HEIGHT
connector_z = top_z + CYCLOID_BEARING_RACE_HEIGHT
disc = scad.translate_shape(disc, (0.0, 0.0, bottom_z))
bearing_race = _make_annular_cylinder(
outer_radius=CYCLOID_BORE_RADIUS + 0.75,
inner_radius=CYCLOID_BORE_RADIUS,
bottom_z=top_z - 0.02,
height=CYCLOID_BEARING_RACE_HEIGHT + 0.02,
)
disc = scad.union_rsolid(disc, bearing_race, glue=False)
if body_phase_degrees:
disc = scad.rotate_shape(
disc,
body_phase_degrees,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, 0.0),
)
disc = scad.apply_tag(disc, role_tag)
disc = scad.apply_tag(disc, "role.ten_lobe_cycloidal_disc")
disc = scad.apply_tag(disc, "group.cycloidal_reducer")
print(
f"{label}_profile: "
f"pins={PIN_COUNT} lobes={CYCLOID_LOBES} "
f"bottom_z={bottom_z:.2f} connector_z={connector_z:.2f} "
f"body_phase={body_phase_degrees:.1f} "
f"raw_output_pin_phase={output_pin_phase:.1f} "
f"segments={cycloid_meta.get('segment_count', CYCLOID_LOBES)} "
f"samples_per_lobe={CYCLOID_LOBE_SAMPLE_COUNT} "
f"control_points={min(cycloid_meta.get('control_point_counts', [0]))}.."
f"{max(cycloid_meta.get('control_point_counts', [0]))} "
f"fit_error_max={max(cycloid_meta.get('max_errors', [0.0])):.5f} "
f"radius_min={cycloid_meta.get('radius_min', 0.0):.3f} "
f"radius_max={cycloid_meta.get('radius_max', 0.0):.3f}"
)
_ground_solid(label, disc)
return disc
def _build_output_disk() -> scad.Solid:
flange = scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_RADIUS,
height=FLANGE_THICKNESS,
bottom_face_center=(0.0, 0.0, OUTPUT_FLANGE_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
flange = _cut_three_threaded_hole_envelopes(
flange,
bottom_z=OUTPUT_FLANGE_BOTTOM_Z,
thickness=FLANGE_THICKNESS,
entry_face="top",
phase_degrees=0.0,
)
pins: list[scad.Solid] = []
for index in range(OUTPUT_PIN_COUNT):
angle = OUTPUT_PIN_PHASE + 360.0 * index / OUTPUT_PIN_COUNT
x, y = _polar(OUTPUT_PIN_PITCH_RADIUS, angle)
pins.append(
scad.make_cylinder_rsolid(
radius=OUTPUT_PIN_RADIUS,
height=OUTPUT_PIN_TOP_Z - OUTPUT_PIN_BOTTOM_Z,
bottom_face_center=(x, y, OUTPUT_PIN_BOTTOM_Z),
axis=(0.0, 0.0, 1.0),
)
)
output_disk = scad.union_rsolid(flange, pins, glue=False)
output_disk = scad.apply_tag(output_disk, "role.output_three_thread_disk")
output_disk = scad.apply_tag(output_disk, "group.cycloidal_reducer")
_ground_solid("output_disk", output_disk)
return output_disk
def build_cycloidal_reducer():
with scad.GraphSession() as session:
housing = _build_fixed_housing()
input_disk = _build_input_disk()
lower_cycloidal_disc = _build_cycloidal_disc(
label="lower_cycloidal_disc",
bottom_z=LOWER_CYCLOID_BOTTOM_Z,
output_pin_phase=OUTPUT_PIN_PHASE,
body_phase_degrees=0.0,
role_tag="role.lower_cycloidal_disc",
)
upper_cycloidal_disc = _build_cycloidal_disc(
label="upper_cycloidal_disc",
bottom_z=UPPER_CYCLOID_BOTTOM_Z,
output_pin_phase=OUTPUT_PIN_PHASE - UPPER_CYCLOID_BODY_PHASE_DEGREES,
body_phase_degrees=UPPER_CYCLOID_BODY_PHASE_DEGREES,
role_tag="role.upper_cycloidal_disc",
)
output_disk = _build_output_disk()
housing_material = scad.make_material_rmaterial(
material_id="black_anodized_aluminum",
name="Black anodized aluminum",
density=2.7e-6,
density_unit="kg/mm^3",
color=(0.08, 0.08, 0.09),
)
steel_material = scad.make_material_rmaterial(
material_id="bearing_steel",
name="Bearing steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.62, 0.64, 0.66),
)
bronze_material = scad.make_material_rmaterial(
material_id="phosphor_bronze",
name="Phosphor bronze",
density=8.8e-6,
density_unit="kg/mm^3",
color=(0.72, 0.48, 0.20),
)
print(
"materials: "
f"{housing_material.material_id},{steel_material.material_id},{bronze_material.material_id}"
)
housing_part = scad.make_part_rpart(
part_id="fixed_pin_housing",
body=housing,
name="Fixed housing with eleven pin ring",
)
housing_part = scad.assign_material_rpart(housing_part, housing_material)
housing_part = scad.add_connector_rpart(
housing_part,
scad.make_face_connector_rconnector(
"input_axis",
_axis_face(
"housing_input_axis",
housing,
(0.0, 0.0),
INPUT_FLANGE_BOTTOM_Z,
-1.0,
),
flip=True,
),
)
housing_part = scad.add_connector_rpart(
housing_part,
scad.make_face_connector_rconnector(
"output_axis",
_axis_face(
"housing_output_axis",
housing,
(0.0, 0.0),
PACKAGE_HEIGHT,
1.0,
),
),
)
input_part = scad.make_part_rpart(
part_id="input_three_thread_disk",
body=input_disk,
name="Input three threaded-hole disk with double eccentric camshaft",
)
input_part = scad.assign_material_rpart(input_part, steel_material)
input_part = scad.add_connector_rpart(
input_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face(
"input_axis",
input_disk,
(0.0, 0.0),
INPUT_FLANGE_BOTTOM_Z,
-1.0,
),
flip=True,
),
)
input_part = scad.add_connector_rpart(
input_part,
scad.make_face_connector_rconnector(
"lower_eccentric_axis",
_axis_face(
"input_lower_eccentric_axis",
input_disk,
LOWER_ECCENTRIC_CENTER,
LOWER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
input_part = scad.add_connector_rpart(
input_part,
scad.make_face_connector_rconnector(
"upper_eccentric_axis",
_axis_face(
"input_upper_eccentric_axis",
input_disk,
UPPER_ECCENTRIC_CENTER,
UPPER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
lower_cycloid_part = scad.make_part_rpart(
part_id="lower_ten_lobe_cycloidal_disc",
body=lower_cycloidal_disc,
name="Lower ten-lobe cycloidal disc",
)
lower_cycloid_part = scad.assign_material_rpart(
lower_cycloid_part, bronze_material
)
lower_cycloid_part = scad.add_connector_rpart(
lower_cycloid_part,
scad.make_face_connector_rconnector(
"eccentric_axis",
_axis_face(
"lower_cycloid_eccentric_axis",
lower_cycloidal_disc,
(0.0, 0.0),
LOWER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
upper_cycloid_part = scad.make_part_rpart(
part_id="upper_ten_lobe_cycloidal_disc",
body=upper_cycloidal_disc,
name="Upper ten-lobe cycloidal disc, 180 degree phased",
)
upper_cycloid_part = scad.assign_material_rpart(
upper_cycloid_part, bronze_material
)
upper_cycloid_part = scad.add_connector_rpart(
upper_cycloid_part,
scad.make_face_connector_rconnector(
"eccentric_axis",
_axis_face(
"upper_cycloid_eccentric_axis",
upper_cycloidal_disc,
(0.0, 0.0),
UPPER_CYCLOID_CONNECTOR_Z,
1.0,
),
),
)
output_part = scad.make_part_rpart(
part_id="output_three_thread_disk",
body=output_disk,
name="Output three threaded-hole disk with drive pins",
)
output_part = scad.assign_material_rpart(output_part, steel_material)
output_part = scad.add_connector_rpart(
output_part,
scad.make_face_connector_rconnector(
"axis",
_axis_face(
"output_axis",
output_disk,
(0.0, 0.0),
PACKAGE_HEIGHT,
1.0,
),
),
)
reducer = scad.make_assembly_rassembly(
assembly_id="cycloidal_reducer_50mm_10x",
name="50 mm diameter 10:1 cycloidal reducer",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=housing_part,
component_id="fixed_housing",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Grounded fixed pin-ring housing",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=input_part,
component_id="input_disk",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Input three-thread-hole disk",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=lower_cycloid_part,
component_id="lower_cycloidal_disc",
placement=_z_rotation_placement((ECCENTRICITY, 0.0, 0.0), 0.0),
name="Lower cycloidal disc riding on 0 degree eccentric cam",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=upper_cycloid_part,
component_id="upper_cycloidal_disc",
placement=_z_rotation_placement((-ECCENTRICITY, 0.0, 0.0), 0.0),
name="Upper cycloidal disc riding on 180 degree eccentric cam",
)
reducer = scad.add_component_rassembly(
assembly=reducer,
item=output_part,
component_id="output_disk",
placement=_z_rotation_placement((0.0, 0.0, 0.0), 0.0),
name="Output three-thread-hole disk",
)
reducer = scad.ground_component_rassembly(reducer, "fixed_housing")
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="input_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_housing", connector_id="input_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="axis"
),
name="Input disk rotates in the fixed housing",
)
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="output_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_housing", connector_id="output_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="output_disk", connector_id="axis"
),
name="Output disk rotates coaxially in the fixed housing",
)
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="lower_cycloid_on_eccentric_cam",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="lower_eccentric_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="lower_cycloidal_disc", connector_id="eccentric_axis"
),
name="Lower cycloidal disc rotates on the 0 degree input eccentric cam",
)
reducer = scad.add_revolute_constraint_rassembly(
assembly=reducer,
constraint_id="upper_cycloid_on_eccentric_cam",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="upper_eccentric_axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="upper_cycloidal_disc", connector_id="eccentric_axis"
),
name="Upper cycloidal disc rotates on the 180 degree input eccentric cam",
)
reducer = scad.add_gear_constraint_rassembly(
assembly=reducer,
constraint_id="fixed_pin_ring_to_lower_cycloid_spin",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="lower_cycloidal_disc", connector_id="eccentric_axis"
),
pitch_radius_a=float(PIN_COUNT),
pitch_radius_b=float(CYCLOID_LOBES),
name="Fixed pin ring drives the lower cycloidal disc relative spin",
)
reducer = scad.add_gear_constraint_rassembly(
assembly=reducer,
constraint_id="fixed_pin_ring_to_upper_cycloid_spin",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="input_disk", connector_id="axis"
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="upper_cycloidal_disc", connector_id="eccentric_axis"
),
pitch_radius_a=float(PIN_COUNT),
pitch_radius_b=float(CYCLOID_LOBES),
name="Fixed pin ring drives the upper cycloidal disc relative spin",
)
print(
"assembly_plan: "
f"diameter={PACKAGE_DIAMETER:.1f} height={PACKAGE_HEIGHT:.1f} "
f"pins={PIN_COUNT} lobes={CYCLOID_LOBES} reduction={REDUCTION_RATIO}:1 "
f"eccentricity={ECCENTRICITY:.2f} cycloid_discs=2 "
f"eccentric_phase_degrees=0,180 "
f"tooth_index_phase_degrees=0,{UPPER_CYCLOID_BODY_PHASE_DEGREES:.1f} "
f"stack_height={CYCLOID_STACK_HEIGHT:.2f}"
)
print(
"load_balance: "
"lower_eccentric=(+e,0) upper_eccentric=(-e,0) "
"output_pins_pass_through_both_discs contact_not_solved"
)
print(
"kinematic_relation: "
f"each_cycloid_relative=-{PIN_COUNT}/{CYCLOID_LOBES}*input "
f"each_cycloid_global=output=-1/{REDUCTION_RATIO}*input via output pin holes"
)
reducer = scad.solve_assembly_constraints_rassembly(reducer)
report = scad.inspect_assembly_constraints_rconstraintreport(reducer)
print(
"assembly: "
f"components={','.join(reducer.component_ids())} "
f"grounded={','.join(reducer.grounded_component_ids)} "
f"solved={report.solved} constraints={len(reducer.constraints)}"
)
for residual in report.residuals:
print(
f"constraint_{residual.constraint_id}: "
f"translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} "
f"ok={residual.within_tolerance}"
)
preview = scad.make_compound_from_assembly_rcompound(reducer)
_ground_compound("assembly_preview", preview)
model_json = scad.export_model_json(session)
return reducer, preview, model_json
def main() -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.model.json"
step_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.step"
fcstd_path = OUTPUT_DIR / "cycloidal_reducer_50mm_10x.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json = build_cycloidal_reducer()
model_path.write_text(model_json, encoding="utf-8")
scad.export_step(preview, str(step_path))
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(model_json, str(fcstd_path.resolve()))
fcstd_status = f"{fcstd_path} ({fcstd_path.stat().st_size} bytes)"
except Exception as exc: # pragma: no cover - depends on local FreeCAD install
fcstd_status = f"failed ({exc.__class__.__name__}: {exc})"
payload = json.loads(model_json)
replayed = scad.replay_model_json(model_json)
solids = ql.select(preview.get_solids()).all()
face_count = sum(len(ql.select(solid.get_faces()).all()) for solid in solids)
volumes = [solid.get_volume() for solid in solids]
print(
"package: "
f"diameter={PACKAGE_DIAMETER:.1f} height={PACKAGE_HEIGHT:.1f} "
f"outer_radius={PACKAGE_RADIUS:.1f}"
)
print(
"mounting: "
f"input=3xM2.5_envelope output=3xM2.5_envelope "
f"hole_pcd={2.0 * MOUNT_HOLE_PITCH_RADIUS:.1f}"
)
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print(f"preview_solids={len(solids)}")
print(f"preview_faces={face_count}")
print("volumes=" + ",".join(f"{volume:.1f}" for volume in volumes))
print(f"replay_outputs={len(replayed)}")
print("replay_types=" + ",".join(type(item).__name__ for item in replayed))
print(f"graph_nodes={len(payload['graph']['nodes'])}")
print(f"model={model_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()