848 lines
31 KiB
Python
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()
|