first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,142 @@
"""Safe reuse boundary for the integrated Example 20 joint actuator."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
import simplecadapi as scad
EXAMPLES_DIR = Path(__file__).resolve().parents[1]
if str(EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(EXAMPLES_DIR))
_assembly = importlib.import_module("20_integrated_bldc_joint_actuator.assembly")
_dimensions = importlib.import_module("20_integrated_bldc_joint_actuator.dimensions")
_materials = importlib.import_module("20_integrated_bldc_joint_actuator.materials")
ACTUATOR_CASE_CLAMP_Z = _dimensions.OUTPUT_CASE_CLAMP_CENTER_Z
ACTUATOR_OUTPUT_FACE_Z = _dimensions.OUTPUT_FLANGE_TOP_Z
ACTUATOR_PACKAGE_RADIUS = _dimensions.PACKAGE_RADIUS
ACTUATOR_PACKAGE_BOTTOM_Z = _dimensions.PACKAGE_STRUCTURAL_BOTTOM_Z
ACTUATOR_PACKAGE_TOP_Z = _dimensions.PACKAGE_TOP_Z
OUTPUT_BOLT_ANGLES_DEGREES = _dimensions.OUTPUT_LINK_BOLT_ANGLES_DEGREES
OUTPUT_BOLT_CIRCLE_RADIUS = _dimensions.OUTPUT_LINK_HOLE_PCD / 2.0
OUTPUT_BOLT_COUNT = _dimensions.OUTPUT_LINK_BOLT_COUNT
OUTPUT_TAP_RADIUS = _dimensions.OUTPUT_LINK_TAP_RADIUS
OUTPUT_REGISTER_HEIGHT = _dimensions.OUTPUT_REGISTER_HEIGHT
OUTPUT_REGISTER_RADIUS = _dimensions.OUTPUT_REGISTER_RADIUS
def make_actuator_materials_rdict() -> dict[str, scad.Material]:
"""Create the externally supplied material set used by Example 20."""
return _materials.make_actuator_materials_rdict()
def make_joint_actuator_rassembly(
*, materials: dict[str, scad.Material]
) -> scad.Assembly:
"""Build the complete actuator as a two-body kinematic subassembly."""
_dimensions.validate_design_dimensions()
component_specs = _assembly.make_integrated_bldc_joint_actuator_components_rtuple(
materials=materials
)
fixed_body = scad.make_assembly_rassembly(
assembly_id="integrated_50mm_bldc_joint_actuator_fixed_body",
name="Rigid actuator housing, motor, electronics, and reducer internals",
)
output_carrier = next(
component for component in component_specs if component[0] == "output_carrier"
)
for component_id, item, source_placement, name in component_specs:
if component_id == "output_carrier":
continue
fixed_body = scad.add_component_rassembly(
assembly=fixed_body,
item=item,
component_id=component_id,
placement=source_placement,
name=name,
)
for connector_id, source_component_id, source_connector_id, name in (
("case_clamp_axis", "reducer_housing", "case_clamp_axis", "External split-clamp datum"),
("case_mount_axis", "output_bearing_cap", "case_mount_axis", "Fixed actuator case datum"),
("output_support_axis", "reducer_housing", "stage2_carrier_axis", "Output carrier bearing axis"),
("phase_terminal_access", "controller", "phase_access", "Rear phase-terminal service datum"),
("power_can_terminal_access", "controller", "power_can_access", "Rear power/CAN service datum"),
):
fixed_body = scad.forward_connector_rassembly(
assembly=fixed_body,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
)
actuator = scad.make_assembly_rassembly(
assembly_id="leg_joint_actuator",
name="50 mm integrated BLDC actuator with one external output degree of freedom",
)
actuator = scad.add_component_rassembly(
assembly=actuator,
item=fixed_body,
component_id="fixed_body",
placement=scad.identity_placement_rplacement(),
name="Rigid actuator body",
)
actuator = scad.add_component_rassembly(
assembly=actuator,
item=output_carrier[1],
component_id="output_carrier",
placement=output_carrier[2],
name=output_carrier[3],
)
actuator = scad.ground_component_rassembly(
assembly=actuator,
component_id="fixed_body",
)
actuator = scad.add_revolute_constraint_rassembly(
assembly=actuator,
constraint_id="output_revolute",
connector_a=scad.make_connector_ref_rconnectorref(
component_id="fixed_body",
connector_id="output_support_axis",
),
connector_b=scad.make_connector_ref_rconnectorref(
component_id="output_carrier",
connector_id="carrier_axis",
),
name="Actuator output carrier rotation",
)
actuator = scad.solve_assembly_constraints_rassembly(
assembly=actuator,
strict=True,
)
for connector_id, source_component_id, source_connector_id, name in (
("case_clamp_axis", "fixed_body", "case_clamp_axis", "External split-clamp datum"),
("case_mount_axis", "fixed_body", "case_mount_axis", "Fixed actuator case datum"),
("output_link_axis", "output_carrier", "output_link_axis", "Rotating six-hole output flange"),
("phase_terminal_access", "fixed_body", "phase_terminal_access", "Rear phase-terminal service datum"),
("power_can_terminal_access", "fixed_body", "power_can_terminal_access", "Rear power/CAN service datum"),
):
actuator = scad.forward_connector_rassembly(
assembly=actuator,
connector_id=connector_id,
source_component_id=source_component_id,
source_connector_id=source_connector_id,
name=name,
)
print(
"leg_joint_actuator: "
f"diameter={ACTUATOR_PACKAGE_RADIUS * 2.0:.1f} "
f"length={ACTUATOR_PACKAGE_TOP_Z - ACTUATOR_PACKAGE_BOTTOM_Z:.1f} "
f"output_pcd={OUTPUT_BOLT_CIRCLE_RADIUS * 2.0:.1f} "
f"components={len(actuator.component_ids())} revolutes=1 "
f"connectors={','.join(actuator.connector_ids())}"
)
return actuator
@@ -0,0 +1,194 @@
"""Split-clamp actuator mounts for the leg-wheel example."""
from __future__ import annotations
import simplecadapi as scad
from leg_common import make_part_with_connectors_rpart
from leg_dimensions import (
BODY_STANDOFF_THICKNESS,
BODY_STANDOFF_Z,
CASE_CLAMP_INNER_RADIUS,
CASE_CLAMP_OUTER_RADIUS,
CASE_CLAMP_PINCH_AXIS_RADIUS,
CASE_CLAMP_PINCH_HALF_SPAN,
CASE_CLAMP_PINCH_HOLE_RADIUS,
CASE_CLAMP_SLIT_WIDTH,
CASE_CLAMP_WIDTH,
KNEE_CASE_CLAMP_Z,
ROOT_AXIS,
THIGH_CASE_CLAMP_Z,
)
def make_split_case_clamp_rsolid(
*, center: tuple[float, float, float], z_center: float, tag: str
) -> scad.Solid:
"""Create one machinable C-clamp with coaxial pinch-bolt ears."""
z_min = z_center - CASE_CLAMP_WIDTH / 2.0
outer = scad.make_cylinder_rsolid(
radius=CASE_CLAMP_OUTER_RADIUS,
height=CASE_CLAMP_WIDTH,
bottom_face_center=(center[0], center[1], z_min),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=CASE_CLAMP_INNER_RADIUS,
height=CASE_CLAMP_WIDTH + 2.0,
bottom_face_center=(center[0], center[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
clamp = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
ear_center_x = center[0] + CASE_CLAMP_PINCH_AXIS_RADIUS - 2.5
ear_center_y = CASE_CLAMP_SLIT_WIDTH / 2.0 + 2.5
ears = [
scad.make_box_rsolid(
width=8.0,
height=5.0,
depth=CASE_CLAMP_WIDTH,
bottom_face_center=(ear_center_x, center[1] + sign * ear_center_y, z_min),
)
for sign in (-1.0, 1.0)
]
clamp = scad.union_rsolid(clamp, ears, glue=False)
slit = scad.make_box_rsolid(
width=15.0,
height=CASE_CLAMP_SLIT_WIDTH,
depth=CASE_CLAMP_WIDTH + 2.0,
bottom_face_center=(center[0] + 31.0, center[1], z_min - 1.0),
)
pinch_hole = scad.make_cylinder_rsolid(
radius=CASE_CLAMP_PINCH_HOLE_RADIUS,
height=CASE_CLAMP_PINCH_HALF_SPAN * 2.0,
bottom_face_center=(
center[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
center[1] - CASE_CLAMP_PINCH_HALF_SPAN,
z_center,
),
axis=(0.0, 1.0, 0.0),
)
clamp = scad.cut_rsolid(
clamp,
slit,
pinch_hole,
skip_non_intersecting=False,
)
clamp = scad.apply_tag(shape=clamp, tag=tag)
print(
f"{tag}: bore_d={CASE_CLAMP_INNER_RADIUS * 2.0:.2f} "
f"pinch_hole_d={CASE_CLAMP_PINCH_HOLE_RADIUS * 2.0:.2f} "
f"faces={len(clamp.get_faces())} volume={clamp.get_volume():.3f}"
)
return clamp
def make_body_mount_plate_rpart(*, material: scad.Material) -> scad.Part:
"""Build the two-collar body mount for coaxial tandem root actuators."""
lower = make_split_case_clamp_rsolid(
center=ROOT_AXIS,
z_center=THIGH_CASE_CLAMP_Z,
tag="role.thigh_actuator_split_clamp",
)
upper = make_split_case_clamp_rsolid(
center=ROOT_AXIS,
z_center=KNEE_CASE_CLAMP_Z,
tag="role.knee_drive_split_clamp",
)
lower_z = THIGH_CASE_CLAMP_Z - CASE_CLAMP_WIDTH / 2.0
upper_z = KNEE_CASE_CLAMP_Z + CASE_CLAMP_WIDTH / 2.0
post_height = upper_z - lower_z
post_offset_x = 35.0
posts = [
scad.make_cylinder_rsolid(
radius=3.8,
height=post_height,
bottom_face_center=(ROOT_AXIS[0] + sign * post_offset_x, ROOT_AXIS[1], lower_z),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
collar_bridges = [
scad.make_box_rsolid(
width=8.0,
height=7.6,
depth=CASE_CLAMP_WIDTH,
bottom_face_center=(
ROOT_AXIS[0] + sign * 33.0,
ROOT_AXIS[1],
clamp_z - CASE_CLAMP_WIDTH / 2.0,
),
)
for sign in (-1.0, 1.0)
for clamp_z in (THIGH_CASE_CLAMP_Z, KNEE_CASE_CLAMP_Z)
]
lug_z_min = BODY_STANDOFF_Z - BODY_STANDOFF_THICKNESS / 2.0
lugs = [
scad.make_box_rsolid(
width=24.0,
height=18.0,
depth=BODY_STANDOFF_THICKNESS,
bottom_face_center=(ROOT_AXIS[0] + sign * 41.0, ROOT_AXIS[1], lug_z_min),
)
for sign in (-1.0, 1.0)
]
mount = scad.union_rsolid(lower, upper, posts, collar_bridges, lugs, glue=False)
torso_holes = [
scad.make_cylinder_rsolid(
radius=2.3,
height=BODY_STANDOFF_THICKNESS + 2.0,
bottom_face_center=(ROOT_AXIS[0] + sign * 45.0, ROOT_AXIS[1], lug_z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
mount = scad.cut_rsolid(mount, torso_holes, skip_non_intersecting=False)
mount = scad.apply_tag(shape=mount, tag="role.body_mount_plate")
print(
"body_mount_plate: root_actuators=2 tandem=true "
f"clamp_z=({THIGH_CASE_CLAMP_Z:.1f},{KNEE_CASE_CLAMP_Z:.1f}) "
f"torso_holes=2 faces={len(mount.get_faces())} volume={mount.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="body_mount_plate",
body=mount,
name="Coaxial tandem root actuator split-clamp and body lug bracket",
material=material,
connectors=(
("case_axis", (ROOT_AXIS[0], ROOT_AXIS[1], THIGH_CASE_CLAMP_Z), "z", "Thigh actuator clamp datum"),
(
"knee_drive_case_axis",
(ROOT_AXIS[0], ROOT_AXIS[1], KNEE_CASE_CLAMP_Z),
"z",
"Knee-drive actuator clamp datum opposite the crank",
),
(
"thigh_clamp_bolt_seat",
(
ROOT_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
ROOT_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9,
THIGH_CASE_CLAMP_Z,
),
"y",
"Thigh collar M4 bolt head seat",
),
(
"knee_clamp_bolt_seat",
(
ROOT_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
ROOT_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9,
KNEE_CASE_CLAMP_Z,
),
"y",
"Knee-drive collar M4 bolt head seat",
),
(
"body_frame_axis",
(ROOT_AXIS[0], ROOT_AXIS[1], BODY_STANDOFF_Z),
"z",
"Body frame datum",
),
),
)
@@ -0,0 +1,151 @@
"""Static external-envelope collision probe for the rebuilt Example 18."""
from __future__ import annotations
import contextlib
import io
import sys
import time
import simplecadapi as scad
from actuator import make_actuator_materials_rdict
from leg_assembly import make_leg_wheel_robot_dog_leg_rassembly
from leg_materials import make_leg_materials_rdict
ACTUATOR_IDS = (
"thigh_actuator",
"knee_drive_actuator",
"wheel_hub_actuator",
)
EXTERNAL_ACTUATOR_LEAVES = (
("reducer_housing",),
("motor_shell",),
("rear_electronics_cover",),
("output_bearing_cap",),
("output_carrier",),
("controller", "three_phase_terminal"),
("controller", "power_can_terminal"),
)
TOP_LEVEL_EXTERNALS = (
"body_mount_plate",
"upper_link_plate",
"proximal_output_crank",
"knee_pushrod",
"shank_link",
"wheel_hub",
"wheel_tire",
"knee_bushing",
"knee_axle",
"thigh_clamp_bolt",
"knee_drive_clamp_bolt",
"wheel_clamp_bolt",
"proximal_linkage_pin",
"distal_linkage_pin",
)
def _leg_level_component_paths() -> tuple[tuple[str, ...], ...]:
paths: list[tuple[str, ...]] = []
for actuator_id in ACTUATOR_IDS:
for leaf in EXTERNAL_ACTUATOR_LEAVES:
if leaf == ("output_carrier",):
paths.append((actuator_id, *leaf))
else:
paths.append((actuator_id, "fixed_body", *leaf))
paths.extend((component_id,) for component_id in TOP_LEVEL_EXTERNALS)
for interface in ("thigh", "knee_drive", "wheel"):
paths.extend((f"{interface}_output_screw_{index}",) for index in range(1, 7))
return tuple(paths)
def _intentional_mating_pairs() -> tuple[scad.verifier.ComponentPair, ...]:
pairs = [
scad.verifier.ComponentPair("wheel_hub", "wheel_tire"),
scad.verifier.ComponentPair("body_mount_plate", "thigh_clamp_bolt"),
scad.verifier.ComponentPair("body_mount_plate", "knee_drive_clamp_bolt"),
scad.verifier.ComponentPair("shank_link", "wheel_clamp_bolt"),
]
for actuator_id in ACTUATOR_IDS:
pairs.append(
scad.verifier.ComponentPair(
(actuator_id, "fixed_body", "reducer_housing"),
(actuator_id, "fixed_body", "output_bearing_cap"),
)
)
for interface, actuator_id, driven_component_id in (
("thigh", "thigh_actuator", "upper_link_plate"),
("knee_drive", "knee_drive_actuator", "proximal_output_crank"),
("wheel", "wheel_hub_actuator", "wheel_hub"),
):
pairs.append(
scad.verifier.ComponentPair(
(actuator_id, "output_carrier"),
driven_component_id,
)
)
for index in range(1, 7):
pairs.append(
scad.verifier.ComponentPair(
f"{interface}_output_screw_{index}",
(actuator_id, "output_carrier"),
)
)
return tuple(pairs)
def main() -> None:
sys.setrecursionlimit(40000)
build_log = io.StringIO()
start = time.perf_counter()
with contextlib.redirect_stdout(build_log):
assembly = make_leg_wheel_robot_dog_leg_rassembly(
actuator_materials=make_actuator_materials_rdict(),
leg_materials=make_leg_materials_rdict(),
)
build_seconds = time.perf_counter() - start
config = scad.verifier.CollisionCheckConfig(
max_allowed_penetration=0.08,
max_contacts_per_pair=32,
scope=scad.verifier.CollisionScope(
component_paths=_leg_level_component_paths(),
exclude_pairs=_intentional_mating_pairs(),
),
)
start = time.perf_counter()
report = scad.verifier.check_collision_rcollisionreport(
assembly=assembly,
config=config,
)
check_seconds = time.perf_counter() - start
print(f"assembly {assembly.assembly_id}")
print(f"build_log_lines {len(build_log.getvalue().splitlines())}")
print(f"build_seconds {build_seconds:.3f}")
print(f"check_seconds {check_seconds:.3f}")
print(f"completed {report.completed}")
print(f"passed {report.passed}")
print(f"checked_pair_count {report.checked_pair_count}")
print(f"failed_pair_count {report.failed_pair_count}")
print(f"warning_count {len(report.warnings)}")
for warning in report.warnings:
path = "/".join(warning.component_path or ())
print(f"warning {path} {warning.code} {warning.message}")
for failure in sorted(
report.failures,
key=lambda item: item.penetration_depth,
reverse=True,
)[:25]:
print(
"failure",
"/".join(failure.component_a),
"/".join(failure.component_b),
f"depth={failure.penetration_depth:.3f}",
f"allowed={failure.allowed_penetration:.3f}",
)
if __name__ == "__main__":
main()
@@ -0,0 +1,164 @@
"""Modeled bolts, threaded fasteners, and joint bushings for Example 18."""
from __future__ import annotations
import simplecadapi as scad
from leg_common import make_part_with_connectors_rpart
from leg_dimensions import KNEE_AXIS
def make_socket_head_screw_rpart(
*,
part_id: str,
shank_radius: float,
shank_length: float,
head_radius: float,
head_height: float,
material: scad.Material,
) -> scad.Part:
"""Create a socket-head screw with its seat plane at local Z=0."""
shank = scad.make_cylinder_rsolid(
radius=shank_radius,
height=shank_length,
bottom_face_center=(0.0, 0.0, -head_height - shank_length),
axis=(0.0, 0.0, 1.0),
)
head = scad.make_cylinder_rsolid(
radius=head_radius,
height=head_height,
bottom_face_center=(0.0, 0.0, -head_height),
axis=(0.0, 0.0, 1.0),
)
screw = scad.union_rsolid(shank, head, glue=False)
screw = scad.apply_tag(shape=screw, tag="role.socket_head_screw")
return make_part_with_connectors_rpart(
part_id=part_id,
body=screw,
name=f"Socket-head screw {shank_radius * 2.0:.1f} x {shank_length:.1f} mm",
material=material,
connectors=(("head_top_axis", (0.0, 0.0, 0.0), "z", "Flush screw head top plane"),),
)
def make_clamp_bolt_stack_rpart(*, material: scad.Material) -> scad.Part:
"""Create an M4 bolt plus flange-nut stack for a split collar."""
shank_length = 12.2
shank = scad.make_cylinder_rsolid(
radius=2.0,
height=shank_length,
bottom_face_center=(0.0, 0.0, -shank_length),
axis=(0.0, 0.0, 1.0),
)
head = scad.make_cylinder_rsolid(
radius=3.6,
height=3.2,
bottom_face_center=(0.0, 0.0, -0.05),
axis=(0.0, 0.0, 1.0),
)
nut = scad.make_cylinder_rsolid(
radius=3.8,
height=3.2,
bottom_face_center=(0.0, 0.0, -shank_length - 3.15),
axis=(0.0, 0.0, 1.0),
)
stack = scad.union_rsolid(shank, head, nut, glue=False)
stack = scad.apply_tag(shape=stack, tag="role.clamp_bolt_and_nut")
return make_part_with_connectors_rpart(
part_id="m4_split_clamp_bolt_stack",
body=stack,
name="M4 split-clamp socket bolt and flange nut",
material=material,
connectors=(("seat_axis", (0.0, 0.0, 0.0), "z", "Clamp bolt head seat"),),
)
def make_knee_bushing_rpart(*, material: scad.Material) -> scad.Part:
"""Create a continuous bronze knee sleeve and axial spacer."""
outer = scad.make_cylinder_rsolid(
radius=5.95,
height=13.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 0.0),
axis=(0.0, 0.0, 1.0),
)
bore = scad.make_cylinder_rsolid(
radius=3.2,
height=15.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], -1.0),
axis=(0.0, 0.0, 1.0),
)
sleeve = scad.cut_rsolid(outer, bore, skip_non_intersecting=False)
sleeve = scad.apply_tag(shape=sleeve, tag="role.knee_bearing_bushing")
return make_part_with_connectors_rpart(
part_id="knee_bronze_bushing",
body=sleeve,
name="12 mm OD bronze knee bushing and spacer",
material=material,
connectors=(
("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 6.5), "z", "Knee revolute axis"),
("bolt_head_top_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 16.0), "z", "Shoulder bolt head top plane"),
),
)
def make_knee_shoulder_bolt_stack_rpart(*, material: scad.Material) -> scad.Part:
"""Create the knee shoulder axle, socket head, and retained nut."""
shaft = scad.make_cylinder_rsolid(
radius=3.0,
height=13.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 0.0),
axis=(0.0, 0.0, 1.0),
)
head = scad.make_cylinder_rsolid(
radius=5.5,
height=3.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], 13.0),
axis=(0.0, 0.0, 1.0),
)
nut = scad.make_cylinder_rsolid(
radius=5.5,
height=4.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], -4.0),
axis=(0.0, 0.0, 1.0),
)
axle = scad.union_rsolid(shaft, head, nut, glue=False)
axle = scad.apply_tag(shape=axle, tag="role.knee_shoulder_axle")
return make_part_with_connectors_rpart(
part_id="knee_shoulder_bolt_stack",
body=axle,
name="M6 knee shoulder axle with socket head and retained nut",
material=material,
connectors=(("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], 6.5), "z", "Knee axle axis"),),
)
def make_linkage_pin_stack_rpart(*, material: scad.Material) -> scad.Part:
"""Create a retained M4 shoulder pin spanning crank, gap, and pushrod."""
span = 9.3
shaft = scad.make_cylinder_rsolid(
radius=2.0,
height=span,
bottom_face_center=(0.0, 0.0, -span / 2.0),
axis=(0.0, 0.0, 1.0),
)
retainers = [
scad.make_cylinder_rsolid(
radius=3.5,
height=1.2,
bottom_face_center=(0.0, 0.0, sign * span / 2.0 - (1.2 if sign < 0.0 else 0.0)),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
pin = scad.union_rsolid(shaft, retainers, glue=False)
pin = scad.apply_tag(shape=pin, tag="role.retained_linkage_pin")
return make_part_with_connectors_rpart(
part_id="m4_linkage_shoulder_pin_stack",
body=pin,
name="M4 retained linkage shoulder pin",
material=material,
connectors=(("pin_axis", (0.0, 0.0, 0.0), "z", "Linkage pin axis"),),
)
@@ -0,0 +1,254 @@
"""Top-level bolt-aligned leg-wheel robot dog leg assembly."""
from __future__ import annotations
import simplecadapi as scad
from actuator import make_joint_actuator_rassembly
from brackets import make_body_mount_plate_rpart
from hardware import (
make_clamp_bolt_stack_rpart,
make_knee_bushing_rpart,
make_knee_shoulder_bolt_stack_rpart,
make_linkage_pin_stack_rpart,
make_socket_head_screw_rpart,
)
from leg_common import connector_ref, make_actuator_target_rplacement
from leg_dimensions import (
ACTUATOR_OUTPUT_CONNECTOR_Z,
DISTAL_PUSHROD_PIN,
KNEE_DRIVE_AXIS,
PROXIMAL_PUSHROD_PIN,
OUTPUT_FLANGE_SCREW_SHANK_RADIUS,
ROD_PIN_AXIS_Z,
ROOT_AXIS,
WHEEL_AXIS,
)
from links import (
make_proximal_crank_rpart,
make_pushrod_rpart,
make_shank_link_rpart,
make_upper_link_plate_rpart,
make_wheel_hub_rpart,
make_wheel_tire_rpart,
)
def make_leg_wheel_robot_dog_leg_rassembly(
*,
actuator_materials: dict[str, scad.Material],
leg_materials: dict[str, scad.Material],
) -> scad.Assembly:
"""Build the posed planar leg-wheel assembly with explicit bolt interfaces."""
actuator = make_joint_actuator_rassembly(materials=actuator_materials)
body_mount = make_body_mount_plate_rpart(material=leg_materials["bracket"])
upper_link = make_upper_link_plate_rpart(material=leg_materials["link"])
proximal_crank = make_proximal_crank_rpart(material=leg_materials["linkage"])
pushrod = make_pushrod_rpart(material=leg_materials["linkage"])
shank_link = make_shank_link_rpart(material=leg_materials["link"])
wheel_hub = make_wheel_hub_rpart(material=leg_materials["wheel_hub"])
wheel_tire = make_wheel_tire_rpart(material=leg_materials["tire"])
output_screw = make_socket_head_screw_rpart(
part_id="m3x5_output_socket_head_screw",
shank_radius=OUTPUT_FLANGE_SCREW_SHANK_RADIUS,
shank_length=5.0,
head_radius=2.85,
head_height=3.0,
material=leg_materials["fastener"],
)
clamp_bolt = make_clamp_bolt_stack_rpart(material=leg_materials["fastener"])
linkage_pin = make_linkage_pin_stack_rpart(material=leg_materials["fastener"])
knee_bushing = make_knee_bushing_rpart(material=leg_materials["bushing"])
knee_axle = make_knee_shoulder_bolt_stack_rpart(material=leg_materials["fastener"])
leg = scad.make_assembly_rassembly(
assembly_id="leg_wheel_robot_dog_leg",
name="Planar leg-wheel module with bolt-aligned actuator, knee, and wheel interfaces",
)
for component_id, target, axis, name in (
("thigh_actuator", ROOT_AXIS, "z", "Body-fixed thigh reducer actuator"),
("knee_drive_actuator", KNEE_DRIVE_AXIS, "z", "Body-fixed knee-drive actuator opposite the crank"),
("wheel_hub_actuator", WHEEL_AXIS, "z", "Distal wheel hub reducer actuator"),
):
leg = scad.add_component_rassembly(
assembly=leg,
item=actuator,
component_id=component_id,
placement=make_actuator_target_rplacement(
output_axis_origin=target,
output_axis_local_z=ACTUATOR_OUTPUT_CONNECTOR_Z,
axis=axis,
),
name=name,
)
for component_id, item, name in (
("body_mount_plate", body_mount, "Body-fixed hip stack bracket for thigh and knee-drive cases"),
("upper_link_plate", upper_link, "Output-bolted upper link plate"),
("proximal_output_crank", proximal_crank, "Output-bolted knee-drive crank"),
("knee_pushrod", pushrod, "Pinned pushrod between crank and shank"),
("shank_link", shank_link, "Lower shank with integral pushrod ear and wheel hub case mount"),
("wheel_hub", wheel_hub, "Rigid wheel hub bolted to actuator output"),
("wheel_tire", wheel_tire, "Replaceable rubber tire fitted to rigid wheel hub"),
("knee_bushing", knee_bushing, "Bronze knee pivot sleeve"),
("knee_axle", knee_axle, "Retained knee shoulder axle"),
):
leg = scad.add_component_rassembly(
assembly=leg,
item=item,
component_id=component_id,
placement=scad.identity_placement_rplacement(),
name=name,
)
for component_id, item, placement, name in (
(
"thigh_clamp_bolt",
clamp_bolt,
scad.identity_placement_rplacement(),
"M4 thigh actuator split-clamp bolt",
),
(
"knee_drive_clamp_bolt",
clamp_bolt,
scad.identity_placement_rplacement(),
"M4 knee-drive actuator split-clamp bolt",
),
(
"wheel_clamp_bolt",
clamp_bolt,
scad.identity_placement_rplacement(),
"M4 wheel actuator split-clamp bolt",
),
(
"proximal_linkage_pin",
linkage_pin,
scad.make_placement_rplacement(
origin=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z)
),
"Retained proximal linkage shoulder pin",
),
(
"distal_linkage_pin",
linkage_pin,
scad.make_placement_rplacement(
origin=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z)
),
"Retained distal linkage shoulder pin",
),
):
leg = scad.add_component_rassembly(
assembly=leg,
item=item,
component_id=component_id,
placement=placement,
name=name,
)
for interface, plate_component in (
("thigh", "upper_link_plate"),
("knee_drive", "proximal_output_crank"),
("wheel", "wheel_hub"),
):
for index in range(1, 7):
leg = scad.add_component_rassembly(
assembly=leg,
item=output_screw,
component_id=f"{interface}_output_screw_{index}",
placement=scad.identity_placement_rplacement(),
name=f"{interface.replace('_', ' ')} output M3 screw {index}",
)
leg = _add_leg_constraints_rassembly(assembly=leg)
leg = scad.solve_assembly_constraints_rassembly(assembly=leg, strict=True)
_ground_constraint_report(assembly=leg)
print(
"leg_components: actuators=3 structural=7 hardware=25 components="
f"{len(leg.component_ids())} constraints={len(leg.constraint_ids())}"
)
return leg
def _add_leg_constraints_rassembly(*, assembly: scad.Assembly) -> scad.Assembly:
assembly = scad.ground_component_rassembly(assembly=assembly, component_id="body_mount_plate")
fixed_pairs = (
("hip_stack_to_thigh_case", "body_mount_plate", "case_axis", "thigh_actuator", "case_clamp_axis"),
("hip_stack_to_knee_drive_case", "body_mount_plate", "knee_drive_case_axis", "knee_drive_actuator", "case_clamp_axis"),
("shank_clamped_to_wheel_case", "shank_link", "wheel_case_axis", "wheel_hub_actuator", "case_clamp_axis"),
("thigh_output_bolted_to_link", "thigh_actuator", "output_link_axis", "upper_link_plate", "output_axis"),
("knee_output_bolted_to_crank", "knee_drive_actuator", "output_link_axis", "proximal_output_crank", "output_axis"),
("wheel_output_bolted_to_hub", "wheel_hub_actuator", "output_link_axis", "wheel_hub", "wheel_axis"),
("wheel_tire_bonded_to_hub", "wheel_hub", "tire_axis", "wheel_tire", "hub_axis"),
("knee_bushing_pressed_in_upper_link", "upper_link_plate", "knee_axis", "knee_bushing", "knee_axis"),
("knee_axle_locked_to_bushing", "knee_bushing", "knee_axis", "knee_axle", "knee_axis"),
("thigh_clamp_bolt_seated", "body_mount_plate", "thigh_clamp_bolt_seat", "thigh_clamp_bolt", "seat_axis"),
("knee_clamp_bolt_seated", "body_mount_plate", "knee_clamp_bolt_seat", "knee_drive_clamp_bolt", "seat_axis"),
("wheel_clamp_bolt_seated", "shank_link", "wheel_clamp_bolt_seat", "wheel_clamp_bolt", "seat_axis"),
("proximal_pin_locked_to_crank", "proximal_output_crank", "rod_pin", "proximal_linkage_pin", "pin_axis"),
("distal_pin_locked_to_shank", "shank_link", "rod_pin", "distal_linkage_pin", "pin_axis"),
)
for constraint_id, a_component, a_connector, b_component, b_connector in fixed_pairs:
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
name=constraint_id.replace("_", " "),
)
revolutes = (
("proximal_pushrod_on_shoulder_pin", "proximal_linkage_pin", "pin_axis", "knee_pushrod", "proximal_pin", None),
("distal_pushrod_on_shoulder_pin", "distal_linkage_pin", "pin_axis", "knee_pushrod", "distal_pin", None),
("shank_rotates_on_knee_bushing", "knee_bushing", "knee_axis", "shank_link", "knee_axis", None),
)
for constraint_id, a_component, a_connector, b_component, b_connector, drive_angle in revolutes:
assembly = scad.add_revolute_constraint_rassembly(
assembly=assembly,
constraint_id=constraint_id,
connector_a=connector_ref(component_id=a_component, connector_id=a_connector),
connector_b=connector_ref(component_id=b_component, connector_id=b_connector),
drive_angle_degrees=drive_angle,
angle_limit=None,
name=constraint_id.replace("_", " "),
)
for interface, plate_component in (
("thigh", "upper_link_plate"),
("knee_drive", "proximal_output_crank"),
("wheel", "wheel_hub"),
):
for index in range(1, 7):
assembly = scad.add_fixed_constraint_rassembly(
assembly=assembly,
constraint_id=f"{interface}_output_screw_{index}_seated",
connector_a=connector_ref(
component_id=plate_component,
connector_id=f"output_bolt_{index}_head_top",
),
connector_b=connector_ref(
component_id=f"{interface}_output_screw_{index}",
connector_id="head_top_axis",
),
name=f"{interface.replace('_', ' ')} output screw {index} coaxial",
)
print(
f"leg_constraints_added: fixed={len(fixed_pairs) + 18} "
f"revolute={len(revolutes)} actuator_output_mounts=fixed bolt_aligned=21"
)
return assembly
def _ground_constraint_report(*, assembly: scad.Assembly) -> None:
report = scad.inspect_assembly_constraints_rconstraintreport(assembly=assembly)
print(
f"leg_constraints: solved={report.solved} grounded={len(report.grounded_component_ids)} "
f"solved_components={len(report.solved_component_ids)} unsolved={len(report.unsolved_component_ids)}"
)
for residual in report.residuals:
print(
f"leg_constraint_{residual.constraint_id}: translation={residual.translation_error:.6g} "
f"angle={residual.angular_error_degrees:.6g} ok={residual.within_tolerance}"
)
@@ -0,0 +1,343 @@
"""Shared geometry helpers for Example 18."""
from __future__ import annotations
import math
from collections.abc import Iterable
import simplecadapi as scad
from simplecadapi import ql
Point3 = tuple[float, float, float]
def bolt_circle_points(
*,
center: Point3,
radius: float,
angles_degrees: Iterable[float],
) -> tuple[Point3, ...]:
"""Return XY bolt-center points on a named bolt-circle datum."""
points = []
for angle_degrees in angles_degrees:
angle = math.radians(angle_degrees)
points.append(
(
center[0] + radius * math.cos(angle),
center[1] + radius * math.sin(angle),
center[2],
)
)
return tuple(points)
def make_bolt_circle_cutters_rsolidlist(
*,
center: Point3,
bolt_circle_radius: float,
angles_degrees: Iterable[float],
hole_radius: float,
z_min: float,
height: float,
counterbore_radius: float | None = None,
counterbore_depth: float = 0.0,
counterbore_from_top: bool = True,
counterbore_face_z: float | None = None,
) -> list[scad.Solid]:
"""Build through-hole and optional counterbore cutters for a bolt circle."""
cutters: list[scad.Solid] = []
for point in bolt_circle_points(
center=center,
radius=bolt_circle_radius,
angles_degrees=angles_degrees,
):
cutters.append(
scad.make_cylinder_rsolid(
radius=hole_radius,
height=height,
bottom_face_center=(point[0], point[1], z_min),
axis=(0.0, 0.0, 1.0),
)
)
if counterbore_radius is not None and counterbore_depth > 0.0:
face_z = counterbore_face_z
if face_z is None:
face_z = z_min + height if counterbore_from_top else z_min
counterbore_z = face_z - counterbore_depth if counterbore_from_top else face_z - 0.2
cutters.append(
scad.make_cylinder_rsolid(
radius=counterbore_radius,
height=counterbore_depth + 0.2,
bottom_face_center=(point[0], point[1], counterbore_z),
axis=(0.0, 0.0, 1.0),
)
)
return cutters
def make_rounded_slot_cutter_rsolid(
*,
center: Point3,
length: float,
width: float,
height: float,
angle_degrees: float,
tag: str,
) -> scad.Solid:
"""Build a capsule-shaped cutter for a lightening pocket."""
if length <= width:
raise ValueError("rounded slot length must exceed width")
radius = width / 2.0
straight = length - width
z_min = center[2] - height / 2.0
bridge = scad.make_box_rsolid(
width=straight,
height=width,
depth=height,
bottom_face_center=(0.0, 0.0, z_min),
)
left = scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=(-straight / 2.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
right = scad.make_cylinder_rsolid(
radius=radius,
height=height,
bottom_face_center=(straight / 2.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
cutter = scad.union_rsolid([bridge, left, right], glue=False)
cutter = scad.rotate_shape(
shape=cutter,
angle=angle_degrees,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, center[2]),
)
cutter = scad.translate_shape(shape=cutter, vector=(center[0], center[1], 0.0))
return scad.apply_tag(shape=cutter, tag=tag)
def make_axis_placement_rplacement(
*,
origin: Point3,
axis: str = "z",
) -> scad.Placement:
"""Create a placement whose local Z axis is the requested world axis."""
if axis == "z":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 1.0, 0.0),
)
if axis == "y":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, 0.0, -1.0),
)
if axis == "-z":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(1.0, 0.0, 0.0),
y_axis=(0.0, -1.0, 0.0),
)
if axis == "x":
return scad.make_placement_rplacement(
origin=origin,
x_axis=(0.0, 1.0, 0.0),
y_axis=(0.0, 0.0, 1.0),
)
raise ValueError(f"unsupported axis {axis!r}")
def make_actuator_target_rplacement(
*,
output_axis_origin: Point3,
output_axis_local_z: float,
axis: str,
) -> scad.Placement:
"""Place an actuator so its forwarded output/case axis lands on a world point."""
if axis == "z":
origin = (
output_axis_origin[0],
output_axis_origin[1],
output_axis_origin[2] - output_axis_local_z,
)
elif axis == "y":
origin = (
output_axis_origin[0],
output_axis_origin[1] - output_axis_local_z,
output_axis_origin[2],
)
elif axis == "-z":
origin = (
output_axis_origin[0],
output_axis_origin[1],
output_axis_origin[2] + output_axis_local_z,
)
else:
raise ValueError(f"unsupported axis {axis!r}")
return make_axis_placement_rplacement(origin=origin, axis=axis)
def add_datum_connector_rpart(
*,
part: scad.Part,
connector_id: str,
origin: Point3,
axis: str = "z",
name: str | None = None,
) -> scad.Part:
"""Attach a topology-free connector datum to a part."""
connector = scad.make_placement_connector_rconnector(
connector_id=connector_id,
placement=make_axis_placement_rplacement(origin=origin, axis=axis),
name=name,
)
return scad.add_connector_rpart(part=part, connector=connector)
def make_part_with_connectors_rpart(
*,
part_id: str,
body: scad.Solid,
name: str,
material: scad.Material,
connectors: Iterable[tuple[str, Point3, str, str | None]],
) -> scad.Part:
"""Wrap a solid as a materialized Part and add placement connectors."""
part = scad.make_part_rpart(part_id=part_id, body=body, name=name)
part = scad.assign_material_rpart(part=part, material=material)
for connector_id, origin, axis, connector_name in connectors:
part = add_datum_connector_rpart(
part=part,
connector_id=connector_id,
origin=origin,
axis=axis,
name=connector_name,
)
print(f"part_{part_id}: connectors={len(part.connectors)} volume={body.get_volume():.3f}")
return part
def make_rounded_bar_rsolid(
*,
start: Point3,
end: Point3,
width: float,
thickness: float,
end_hole_radius: float,
lightening_hole_radius: float | None = None,
lightening_count: int = 0,
tag: str,
) -> scad.Solid:
"""Build a planar rounded-end plate between two XY points at constant Z."""
dx = end[0] - start[0]
dy = end[1] - start[1]
length = math.hypot(dx, dy)
if length <= width:
raise ValueError("rounded bar length must exceed width")
z_center = start[2]
z_min = z_center - thickness / 2.0
radius = width / 2.0
bridge = scad.make_box_rsolid(
width=length,
height=width,
depth=thickness,
bottom_face_center=(length / 2.0, 0.0, z_min),
)
left = scad.make_cylinder_rsolid(
radius=radius,
height=thickness,
bottom_face_center=(0.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
right = scad.make_cylinder_rsolid(
radius=radius,
height=thickness,
bottom_face_center=(length, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
body = scad.union_rsolid([bridge, left, right], glue=False)
cutters = [
scad.make_cylinder_rsolid(
radius=end_hole_radius,
height=thickness + 2.0,
bottom_face_center=(0.0, 0.0, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=end_hole_radius,
height=thickness + 2.0,
bottom_face_center=(length, 0.0, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
if lightening_hole_radius is not None and lightening_count > 0:
for index in range(lightening_count):
fraction = (index + 1.0) / (lightening_count + 1.0)
cutters.append(
scad.make_cylinder_rsolid(
radius=lightening_hole_radius,
height=thickness + 2.0,
bottom_face_center=(length * fraction, 0.0, z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
)
body = scad.cut_rsolid(body, cutters, skip_non_intersecting=False)
body = scad.rotate_shape(
shape=body,
angle=math.degrees(math.atan2(dy, dx)),
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, z_center),
)
body = scad.translate_shape(shape=body, vector=(start[0], start[1], 0.0))
body = scad.apply_tag(shape=body, tag=tag)
_ground_solid(label=tag, solid=body)
return body
def _ground_solid(*, label: str, solid: scad.Solid) -> None:
faces = ql.select(items=solid.get_faces()).all()
print(f"{label}: faces={len(faces)} volume={solid.get_volume():.3f}")
def _axis_vector(*, axis: str) -> Point3:
if axis == "z":
return (0.0, 0.0, 1.0)
if axis == "y":
return (0.0, 1.0, 0.0)
if axis == "x":
return (1.0, 0.0, 0.0)
if axis == "-z":
return (0.0, 0.0, -1.0)
raise ValueError(f"unsupported axis {axis!r}")
def ground_compound(*, label: str, compound: scad.Compound) -> None:
"""Print compact grounding facts for an assembly preview."""
solids = ql.select(items=compound.get_solids()).all()
face_count = sum(len(ql.select(items=solid.get_faces()).all()) for solid in solids)
print(
f"{label}: solids={len(solids)} faces={face_count} "
f"volume={compound.get_volume():.3f}"
)
def connector_ref(*, component_id: str, connector_id: str) -> scad.ConnectorRef:
return scad.make_connector_ref_rconnectorref(
component_id=component_id,
connector_id=connector_id,
)
@@ -0,0 +1,173 @@
"""Design constants for the bolt-aligned planar leg-wheel example."""
from __future__ import annotations
import math
from actuator import (
ACTUATOR_CASE_CLAMP_Z,
ACTUATOR_OUTPUT_FACE_Z,
ACTUATOR_PACKAGE_RADIUS,
OUTPUT_BOLT_ANGLES_DEGREES,
OUTPUT_BOLT_CIRCLE_RADIUS,
OUTPUT_BOLT_COUNT,
OUTPUT_TAP_RADIUS,
OUTPUT_REGISTER_HEIGHT,
OUTPUT_REGISTER_RADIUS,
)
# This example intentionally drops the hip-abduction package for now. The model
# is one planar leg module like the reference sketch: one body-fixed thigh
# actuator drives the upper link, a second body-fixed coaxial knee-drive actuator
# drives a crank/pushrod, and a third hub actuator drives the wheel.
ROOT_AXIS = (0.0, 0.0, 0.0)
KNEE_AXIS = (42.0, -126.0, 0.0)
WHEEL_AXIS = (-78.0, -220.0, 0.0)
KNEE_PIVOT_Z = 6.5
# The knee-drive actuator is mirrored about its unchanged split-clamp plane, so
# its output flange and red crank sit on the side opposite the actuator body.
KNEE_DRIVE_OUTPUT_Z = 85.6
ROD_PIN_AXIS_Z = 85.95
THIGH_VECTOR_X = KNEE_AXIS[0] - ROOT_AXIS[0]
THIGH_VECTOR_Y = KNEE_AXIS[1] - ROOT_AXIS[1]
THIGH_LENGTH = math.hypot(THIGH_VECTOR_X, THIGH_VECTOR_Y)
THIGH_UNIT_X = THIGH_VECTOR_X / THIGH_LENGTH
THIGH_UNIT_Y = THIGH_VECTOR_Y / THIGH_LENGTH
THIGH_NORMAL_X = -THIGH_UNIT_Y
THIGH_NORMAL_Y = THIGH_UNIT_X
SHANK_VECTOR_X = WHEEL_AXIS[0] - KNEE_AXIS[0]
SHANK_VECTOR_Y = WHEEL_AXIS[1] - KNEE_AXIS[1]
SHANK_LENGTH = math.hypot(SHANK_VECTOR_X, SHANK_VECTOR_Y)
SHANK_UNIT_X = SHANK_VECTOR_X / SHANK_LENGTH
SHANK_UNIT_Y = SHANK_VECTOR_Y / SHANK_LENGTH
SHANK_NORMAL_X = -SHANK_UNIT_Y
SHANK_NORMAL_Y = SHANK_UNIT_X
REMOTE_CRANK_LENGTH = 46.0
DISTAL_CRANK_LENGTH = REMOTE_CRANK_LENGTH
KNEE_DRIVE_AXIS = (
ROOT_AXIS[0],
ROOT_AXIS[1],
KNEE_DRIVE_OUTPUT_Z,
)
PROXIMAL_PUSHROD_PIN = (
ROOT_AXIS[0] + THIGH_NORMAL_X * REMOTE_CRANK_LENGTH,
ROOT_AXIS[1] + THIGH_NORMAL_Y * REMOTE_CRANK_LENGTH,
0.0,
)
DISTAL_PUSHROD_PIN = (
KNEE_AXIS[0] + THIGH_NORMAL_X * DISTAL_CRANK_LENGTH,
KNEE_AXIS[1] + THIGH_NORMAL_Y * DISTAL_CRANK_LENGTH,
0.0,
)
# Shared Example 20 external interfaces. The actuator uses M3 tapped output
# holes; the leg-side plates use ISO-style clearance holes and socket-head
# counterbores at exactly the same centers.
ACTUATOR_OUTPUT_CONNECTOR_Z = ACTUATOR_OUTPUT_FACE_Z
ACTUATOR_CASE_OUTER_RADIUS = ACTUATOR_PACKAGE_RADIUS
OUTPUT_FLANGE_OUTER_RADIUS = 22.4
OUTPUT_FLANGE_REGISTER_INNER_RADIUS = OUTPUT_REGISTER_RADIUS
OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS = OUTPUT_BOLT_CIRCLE_RADIUS
OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS = 1.65
OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS = 3.0
OUTPUT_FLANGE_SCREW_SHANK_RADIUS = 1.5
OUTPUT_FLANGE_BOLT_COUNT = OUTPUT_BOLT_COUNT
OUTPUT_FLANGE_BOLT_ANGLES_DEGREES = OUTPUT_BOLT_ANGLES_DEGREES
CASE_CLAMP_INNER_RADIUS = ACTUATOR_CASE_OUTER_RADIUS + 0.15
CASE_CLAMP_OUTER_RADIUS = 30.5
CASE_CLAMP_WIDTH = 8.0
CASE_CLAMP_SLIT_WIDTH = 2.2
CASE_CLAMP_PINCH_HOLE_RADIUS = 2.15
CASE_CLAMP_BOLT_SHANK_RADIUS = 2.0
CASE_CLAMP_PINCH_AXIS_RADIUS = 32.0
CASE_CLAMP_PINCH_HALF_SPAN = 7.0
THIGH_CASE_CLAMP_Z = ROOT_AXIS[2] - (ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z)
KNEE_CASE_CLAMP_Z = KNEE_DRIVE_OUTPUT_Z - (
ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z
)
WHEEL_CASE_CLAMP_Z = WHEEL_AXIS[2] - (
ACTUATOR_OUTPUT_FACE_Z - ACTUATOR_CASE_CLAMP_Z
)
PIN_CLEARANCE_RADIUS = 3.2
ROD_PIN_CLEARANCE_RADIUS = 2.2
LIGHTENING_CORNER_RADIUS = 4.0
UPPER_LINK_Z = 2.5
UPPER_LINK_THICKNESS = 5.0
UPPER_LINK_ROOT_RADIUS = 30.5
UPPER_LINK_KNEE_RADIUS = 17.0
UPPER_LINK_WEB_WIDTH = 28.0
UPPER_LINK_WINDOW_LENGTH = 28.0
UPPER_LINK_WINDOW_WIDTH = 14.0
KNEE_BEARING_OUTER_RADIUS = 14.0
KNEE_BEARING_BORE_RADIUS = 6.0
SHANK_LINK_Z = 10.5
SHANK_LINK_THICKNESS = 5.0
SHANK_KNEE_RADIUS = 18.0
SHANK_WHEEL_RADIUS = 31.5
SHANK_WEB_WIDTH = 24.0
SHANK_WINDOW_LENGTH = 25.0
SHANK_WINDOW_WIDTH = 12.0
REMOTE_CRANK_Z = 88.1
REMOTE_CRANK_THICKNESS = 5.0
REMOTE_CRANK_WIDTH = 12.0
DISTAL_CRANK_Z = 88.1
DISTAL_CRANK_THICKNESS = 5.0
DISTAL_CRANK_WIDTH = 12.0
PUSHROD_Z = 83.1
PUSHROD_THICKNESS = 3.6
PUSHROD_WIDTH = 9.0
WHEEL_TIRE_RADIUS = 48.0
WHEEL_TIRE_WIDTH = 18.0
WHEEL_TIRE_BORE_RADIUS = 34.0
WHEEL_HUB_PLATE_RADIUS = 24.5
WHEEL_HUB_PLATE_THICKNESS = 5.0
WHEEL_SPOKE_WIDTH = 5.5
WHEEL_SPOKE_COUNT = 8
BODY_STANDOFF_Z = 22.0
BODY_STANDOFF_THICKNESS = 8.0
KNEE_STACK_CLAMP_Z = KNEE_CASE_CLAMP_Z
KNEE_STACK_CLAMP_THICKNESS = CASE_CLAMP_WIDTH
assert len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES) == OUTPUT_FLANGE_BOLT_COUNT
assert OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS == 17.0
assert OUTPUT_FLANGE_REGISTER_INNER_RADIUS == 7.98
assert OUTPUT_REGISTER_HEIGHT == 1.5
assert CASE_CLAMP_INNER_RADIUS > ACTUATOR_CASE_OUTER_RADIUS
assert OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS + OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS < 21.0
def validate_leg_interface_dimensions() -> None:
"""Validate shared actuator/leg hole, register, and clamp clearances."""
generated_angles = tuple(
OUTPUT_FLANGE_BOLT_ANGLES_DEGREES[0]
+ 360.0 * index / OUTPUT_FLANGE_BOLT_COUNT
for index in range(OUTPUT_FLANGE_BOLT_COUNT)
)
assert generated_angles == OUTPUT_FLANGE_BOLT_ANGLES_DEGREES
assert OUTPUT_TAP_RADIUS < OUTPUT_FLANGE_SCREW_SHANK_RADIUS
assert OUTPUT_FLANGE_SCREW_SHANK_RADIUS < OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS
assert OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS > OUTPUT_FLANGE_SCREW_SHANK_RADIUS
assert CASE_CLAMP_BOLT_SHANK_RADIUS < CASE_CLAMP_PINCH_HOLE_RADIUS
assert abs(CASE_CLAMP_INNER_RADIUS - ACTUATOR_CASE_OUTER_RADIUS - 0.15) < 1.0e-9
print(
"leg_interface_dimensions: "
f"output_holes={OUTPUT_FLANGE_BOLT_COUNT} "
f"pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} "
f"tap_d={OUTPUT_TAP_RADIUS * 2.0:.2f} "
f"screw_d={OUTPUT_FLANGE_SCREW_SHANK_RADIUS * 2.0:.2f} "
f"clearance_d={OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS * 2.0:.2f} "
f"pilot_d={OUTPUT_FLANGE_REGISTER_INNER_RADIUS * 2.0:.2f}"
)
@@ -0,0 +1,61 @@
"""Materials for the leg-wheel robot dog leg example."""
from __future__ import annotations
import simplecadapi as scad
def make_leg_materials_rdict() -> dict[str, scad.Material]:
"""Create reusable material definitions for the leg assembly."""
return {
"link": scad.make_material_rmaterial(
material_id="leg_link_turquoise_7075",
name="Turquoise anodized 7075 link plates",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.02, 0.67, 0.78),
),
"linkage": scad.make_material_rmaterial(
material_id="leg_linkage_orange_ti",
name="Orange anodized titanium linkage hardware",
density=4.43e-6,
density_unit="kg/mm^3",
color=(0.95, 0.25, 0.06),
),
"bracket": scad.make_material_rmaterial(
material_id="leg_bracket_violet_7075",
name="Violet anodized 7075 actuator clamps",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.48, 0.19, 0.76),
),
"wheel_hub": scad.make_material_rmaterial(
material_id="leg_wheel_hub_gold_7075",
name="Gold anodized 7075 wheel hub",
density=2.81e-6,
density_unit="kg/mm^3",
color=(0.94, 0.62, 0.05),
),
"fastener": scad.make_material_rmaterial(
material_id="leg_fastener_black_12_9_steel",
name="Black oxide class 12.9 fastener steel",
density=7.85e-6,
density_unit="kg/mm^3",
color=(0.08, 0.10, 0.13),
),
"bushing": scad.make_material_rmaterial(
material_id="leg_bushing_bronze",
name="Oil-impregnated bearing bronze",
density=8.80e-6,
density_unit="kg/mm^3",
color=(0.63, 0.34, 0.12),
),
"tire": scad.make_material_rmaterial(
material_id="leg_wheel_rubber",
name="Dark rubber tire",
density=1.15e-6,
density_unit="kg/mm^3",
color=(0.025, 0.03, 0.035),
),
}
@@ -0,0 +1,592 @@
"""Bolt-aligned link, crank, pushrod, shank, and wheel parts for Example 18."""
from __future__ import annotations
import math
import simplecadapi as scad
from leg_common import (
make_bolt_circle_cutters_rsolidlist,
make_part_with_connectors_rpart,
make_rounded_bar_rsolid,
make_rounded_slot_cutter_rsolid,
)
from brackets import make_split_case_clamp_rsolid
from leg_dimensions import (
CASE_CLAMP_PINCH_AXIS_RADIUS,
CASE_CLAMP_PINCH_HALF_SPAN,
CASE_CLAMP_WIDTH,
DISTAL_CRANK_LENGTH,
DISTAL_CRANK_THICKNESS,
DISTAL_CRANK_WIDTH,
DISTAL_CRANK_Z,
DISTAL_PUSHROD_PIN,
KNEE_AXIS,
KNEE_BEARING_BORE_RADIUS,
KNEE_BEARING_OUTER_RADIUS,
KNEE_DRIVE_AXIS,
KNEE_PIVOT_Z,
OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
OUTPUT_FLANGE_OUTER_RADIUS,
OUTPUT_FLANGE_REGISTER_INNER_RADIUS,
PIN_CLEARANCE_RADIUS,
PROXIMAL_PUSHROD_PIN,
PUSHROD_THICKNESS,
PUSHROD_WIDTH,
PUSHROD_Z,
REMOTE_CRANK_LENGTH,
REMOTE_CRANK_THICKNESS,
REMOTE_CRANK_WIDTH,
REMOTE_CRANK_Z,
ROD_PIN_AXIS_Z,
ROD_PIN_CLEARANCE_RADIUS,
ROOT_AXIS,
SHANK_KNEE_RADIUS,
SHANK_LENGTH,
SHANK_LINK_THICKNESS,
SHANK_LINK_Z,
SHANK_WEB_WIDTH,
SHANK_WHEEL_RADIUS,
SHANK_WINDOW_LENGTH,
SHANK_WINDOW_WIDTH,
UPPER_LINK_KNEE_RADIUS,
UPPER_LINK_ROOT_RADIUS,
UPPER_LINK_THICKNESS,
UPPER_LINK_WEB_WIDTH,
UPPER_LINK_WINDOW_LENGTH,
UPPER_LINK_WINDOW_WIDTH,
UPPER_LINK_Z,
WHEEL_AXIS,
WHEEL_HUB_PLATE_RADIUS,
WHEEL_HUB_PLATE_THICKNESS,
WHEEL_SPOKE_COUNT,
WHEEL_SPOKE_WIDTH,
WHEEL_TIRE_BORE_RADIUS,
WHEEL_TIRE_RADIUS,
WHEEL_TIRE_WIDTH,
WHEEL_CASE_CLAMP_Z,
)
def make_upper_link_plate_rpart(*, material: scad.Material) -> scad.Part:
"""Build the output-bolted upper link plate with knee bearing holes."""
plate = _make_axis_plate_base_rsolid(
start=ROOT_AXIS,
end=KNEE_AXIS,
z_center=UPPER_LINK_Z,
thickness=UPPER_LINK_THICKNESS,
start_radius=UPPER_LINK_ROOT_RADIUS,
end_radius=UPPER_LINK_KNEE_RADIUS,
web_width=UPPER_LINK_WEB_WIDTH,
tag="role.upper_link_plate_base",
)
z_min = UPPER_LINK_Z - UPPER_LINK_THICKNESS / 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05,
height=UPPER_LINK_THICKNESS + 2.0,
bottom_face_center=(ROOT_AXIS[0], ROOT_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=KNEE_BEARING_BORE_RADIUS,
height=UPPER_LINK_THICKNESS + 2.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
cutters.extend(
make_bolt_circle_cutters_rsolidlist(
center=ROOT_AXIS,
bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
z_min=z_min - 1.0,
height=UPPER_LINK_THICKNESS + 2.0,
counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
counterbore_depth=3.0,
counterbore_from_top=True,
counterbore_face_z=z_min + UPPER_LINK_THICKNESS,
)
)
cutters.extend(
_make_link_window_cutters(
start=ROOT_AXIS,
end=KNEE_AXIS,
z_center=UPPER_LINK_Z,
thickness=UPPER_LINK_THICKNESS,
fractions=(0.40, 0.67),
length=UPPER_LINK_WINDOW_LENGTH,
width=UPPER_LINK_WINDOW_WIDTH,
tag_prefix="upper_link_window",
)
)
plate = scad.cut_rsolid(plate, cutters, skip_non_intersecting=False)
plate = scad.apply_tag(shape=plate, tag="role.upper_link_plate")
print(
f"upper_link_plate: output_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} "
f"output_pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} "
f"knee_bushing_bore={KNEE_BEARING_BORE_RADIUS * 2.0:.1f} faces={len(plate.get_faces())} "
f"volume={plate.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="upper_link_plate",
body=plate,
name="Upper link plate bolted to actuator output flange and knee bearing retainer",
material=material,
connectors=(
("output_axis", ROOT_AXIS, "z", "Actuator output flange datum"),
("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], KNEE_PIVOT_Z), "z", "Knee bearing datum"),
*_output_bolt_connectors(center=ROOT_AXIS, face_z=z_min + UPPER_LINK_THICKNESS, axis="z"),
),
)
def make_proximal_crank_rpart(*, material: scad.Material) -> scad.Part:
"""Create the short crank on the independent knee-drive actuator output."""
crank = _make_axis_plate_base_rsolid(
start=KNEE_DRIVE_AXIS,
end=PROXIMAL_PUSHROD_PIN,
z_center=REMOTE_CRANK_Z,
thickness=REMOTE_CRANK_THICKNESS,
start_radius=OUTPUT_FLANGE_OUTER_RADIUS,
end_radius=REMOTE_CRANK_WIDTH / 2.0 + 2.0,
web_width=REMOTE_CRANK_WIDTH,
tag="role.proximal_output_crank_base",
)
z_min = REMOTE_CRANK_Z - REMOTE_CRANK_THICKNESS / 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05,
height=REMOTE_CRANK_THICKNESS + 2.0,
bottom_face_center=(KNEE_DRIVE_AXIS[0], KNEE_DRIVE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=ROD_PIN_CLEARANCE_RADIUS,
height=REMOTE_CRANK_THICKNESS + 2.0,
bottom_face_center=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
cutters.extend(
make_bolt_circle_cutters_rsolidlist(
center=KNEE_DRIVE_AXIS,
bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
z_min=z_min - 1.0,
height=REMOTE_CRANK_THICKNESS + 2.0,
counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
counterbore_depth=3.0,
counterbore_from_top=True,
counterbore_face_z=z_min + REMOTE_CRANK_THICKNESS,
)
)
crank = scad.cut_rsolid(crank, cutters, skip_non_intersecting=False)
crank = scad.apply_tag(shape=crank, tag="role.proximal_output_crank")
print(
f"knee_drive_output_crank: length={REMOTE_CRANK_LENGTH:.1f} "
f"output_flange_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} "
f"faces={len(crank.get_faces())} volume={crank.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="proximal_output_crank",
body=crank,
name="Knee-drive crank plate using the second actuator output flange holes",
material=material,
connectors=(
("output_axis", KNEE_DRIVE_AXIS, "z", "Knee-drive actuator output flange datum"),
("rod_pin", (PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Proximal pushrod pin datum"),
*_output_bolt_connectors(
center=KNEE_DRIVE_AXIS,
face_z=z_min + REMOTE_CRANK_THICKNESS,
axis="z",
),
),
)
def make_pushrod_rpart(*, material: scad.Material) -> scad.Part:
"""Create the flat pushrod with real pin-clearance holes."""
pushrod = make_rounded_bar_rsolid(
start=(PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], PUSHROD_Z),
end=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], PUSHROD_Z),
width=PUSHROD_WIDTH,
thickness=PUSHROD_THICKNESS,
end_hole_radius=ROD_PIN_CLEARANCE_RADIUS,
lightening_hole_radius=None,
lightening_count=0,
tag="role.knee_pushrod_plate",
)
print(
f"knee_pushrod: pin_distance={_xy_distance(PROXIMAL_PUSHROD_PIN, DISTAL_PUSHROD_PIN):.1f} "
f"pin_hole_diameter={ROD_PIN_CLEARANCE_RADIUS * 2.0:.1f}"
)
return make_part_with_connectors_rpart(
part_id="knee_pushrod",
body=pushrod,
name="Flat pushrod plate with matched clevis pin holes",
material=material,
connectors=(
("proximal_pin", (PROXIMAL_PUSHROD_PIN[0], PROXIMAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Proximal crank pin datum"),
("distal_pin", (DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Integral shank ear pin datum"),
),
)
def make_shank_link_rpart(*, material: scad.Material) -> scad.Part:
"""Build the lower shank plate with an integral pushrod extension ear."""
shank = _make_axis_plate_base_rsolid(
start=KNEE_AXIS,
end=WHEEL_AXIS,
z_center=SHANK_LINK_Z,
thickness=SHANK_LINK_THICKNESS,
start_radius=SHANK_KNEE_RADIUS,
end_radius=SHANK_WHEEL_RADIUS,
web_width=SHANK_WEB_WIDTH,
tag="role.shank_link_base",
)
z_min = SHANK_LINK_Z - SHANK_LINK_THICKNESS / 2.0
distal_z_min = DISTAL_CRANK_Z - DISTAL_CRANK_THICKNESS / 2.0
distal_z_top = distal_z_min + DISTAL_CRANK_THICKNESS
distal_drive_ear = _make_axis_plate_base_rsolid(
start=KNEE_AXIS,
end=DISTAL_PUSHROD_PIN,
z_center=DISTAL_CRANK_Z,
thickness=DISTAL_CRANK_THICKNESS,
start_radius=KNEE_BEARING_OUTER_RADIUS,
end_radius=DISTAL_CRANK_WIDTH / 2.0 + 2.0,
web_width=DISTAL_CRANK_WIDTH,
tag="role.shank_integral_pushrod_ear_base",
)
standoff_outer = scad.make_cylinder_rsolid(
radius=KNEE_BEARING_OUTER_RADIUS,
height=distal_z_top - z_min,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min),
axis=(0.0, 0.0, 1.0),
)
standoff_inner = scad.make_cylinder_rsolid(
radius=KNEE_BEARING_BORE_RADIUS,
height=distal_z_top - z_min + 2.0,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
knee_standoff = scad.cut_rsolid(standoff_outer, standoff_inner, skip_non_intersecting=False)
shank = scad.union_rsolid([shank, distal_drive_ear, knee_standoff], glue=False)
wheel_clamp = make_split_case_clamp_rsolid(
center=WHEEL_AXIS,
z_center=WHEEL_CASE_CLAMP_Z,
tag="role.wheel_actuator_split_clamp",
)
clamp_z_min = WHEEL_CASE_CLAMP_Z - CASE_CLAMP_WIDTH / 2.0
clamp_post_height = z_min + SHANK_LINK_THICKNESS - clamp_z_min
clamp_posts = [
scad.make_cylinder_rsolid(
radius=3.8,
height=clamp_post_height,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1] + sign * 29.5, clamp_z_min),
axis=(0.0, 0.0, 1.0),
)
for sign in (-1.0, 1.0)
]
shank = scad.union_rsolid(shank, wheel_clamp, clamp_posts, glue=False)
integrated_knee_height = distal_z_top - z_min + 2.0
cutters = [
scad.make_cylinder_rsolid(
radius=PIN_CLEARANCE_RADIUS + 2.8,
height=integrated_knee_height,
bottom_face_center=(KNEE_AXIS[0], KNEE_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=ROD_PIN_CLEARANCE_RADIUS,
height=DISTAL_CRANK_THICKNESS + 2.0,
bottom_face_center=(DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], distal_z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
scad.make_cylinder_rsolid(
radius=19.5,
height=SHANK_LINK_THICKNESS + 2.0,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], z_min - 1.0),
axis=(0.0, 0.0, 1.0),
),
]
cutters.extend(
_make_link_window_cutters(
start=KNEE_AXIS,
end=WHEEL_AXIS,
z_center=SHANK_LINK_Z,
thickness=SHANK_LINK_THICKNESS,
fractions=(0.42, 0.66),
length=SHANK_WINDOW_LENGTH,
width=SHANK_WINDOW_WIDTH,
tag_prefix="shank_window",
)
)
shank = scad.cut_rsolid(shank, cutters, skip_non_intersecting=False)
shank = scad.apply_tag(shape=shank, tag="role.shank_wheel_plate")
print(
"shank_link: wheel_case_mount=split_clamp "
f"clamp_bore_d={50.3:.1f} "
f"integral_pushrod_ear={DISTAL_CRANK_LENGTH:.1f} "
f"length={SHANK_LENGTH:.1f} faces={len(shank.get_faces())} volume={shank.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="shank_link",
body=shank,
name="Lower shank plate with integral pushrod ear and wheel actuator split clamp",
material=material,
connectors=(
("knee_axis", (KNEE_AXIS[0], KNEE_AXIS[1], KNEE_PIVOT_Z), "z", "Knee revolute datum"),
("rod_pin", (DISTAL_PUSHROD_PIN[0], DISTAL_PUSHROD_PIN[1], ROD_PIN_AXIS_Z), "z", "Integral shank pushrod pin datum"),
(
"wheel_case_axis",
(WHEEL_AXIS[0], WHEEL_AXIS[1], WHEEL_CASE_CLAMP_Z),
"z",
"Wheel hub actuator split-clamp datum",
),
(
"wheel_clamp_bolt_seat",
(
WHEEL_AXIS[0] + CASE_CLAMP_PINCH_AXIS_RADIUS,
WHEEL_AXIS[1] + CASE_CLAMP_PINCH_HALF_SPAN - 0.9,
WHEEL_CASE_CLAMP_Z,
),
"y",
"Wheel collar M4 bolt head seat",
),
),
)
def make_wheel_tire_rpart(*, material: scad.Material) -> scad.Part:
"""Create the rubber tire ring as a separate serviceable part."""
tire_center_z = -1.5
tire_outer = scad.make_cylinder_rsolid(
radius=WHEEL_TIRE_RADIUS,
height=WHEEL_TIRE_WIDTH,
bottom_face_center=(
WHEEL_AXIS[0],
WHEEL_AXIS[1],
tire_center_z - WHEEL_TIRE_WIDTH / 2.0,
),
axis=(0.0, 0.0, 1.0),
)
tire_bore = scad.make_cylinder_rsolid(
radius=WHEEL_TIRE_BORE_RADIUS,
height=WHEEL_TIRE_WIDTH + 2.0,
bottom_face_center=(
WHEEL_AXIS[0],
WHEEL_AXIS[1],
tire_center_z - WHEEL_TIRE_WIDTH / 2.0 - 1.0,
),
axis=(0.0, 0.0, 1.0),
)
tire_ring = scad.cut_rsolid(tire_outer, tire_bore, skip_non_intersecting=False)
tire_ring = scad.apply_tag(shape=tire_ring, tag="role.replaceable_rubber_tire")
print(
f"wheel_tire: tire_radius={WHEEL_TIRE_RADIUS:.1f} width={WHEEL_TIRE_WIDTH:.1f} "
f"faces={len(tire_ring.get_faces())} volume={tire_ring.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="wheel_tire",
body=tire_ring,
name="Replaceable rubber wheel tire ring",
material=material,
connectors=(("hub_axis", WHEEL_AXIS, "z", "Wheel hub overmold datum"),),
)
def make_wheel_hub_rpart(*, material: scad.Material) -> scad.Part:
"""Create the rigid 7075 hub and spokes bolted to the actuator flange."""
hub_z = WHEEL_HUB_PLATE_THICKNESS / 2.0
hub_z_min = hub_z - WHEEL_HUB_PLATE_THICKNESS / 2.0
hub = scad.make_cylinder_rsolid(
radius=WHEEL_HUB_PLATE_RADIUS,
height=WHEEL_HUB_PLATE_THICKNESS,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], hub_z_min),
axis=(0.0, 0.0, 1.0),
)
spokes = [
_make_wheel_spoke_rsolid(
angle_degrees=22.5 + 360.0 * index / WHEEL_SPOKE_COUNT
)
for index in range(WHEEL_SPOKE_COUNT)
]
wheel = scad.union_rsolid([hub, spokes], glue=False)
cutters = [
scad.make_cylinder_rsolid(
radius=OUTPUT_FLANGE_REGISTER_INNER_RADIUS + 0.05,
height=WHEEL_HUB_PLATE_THICKNESS + 2.0,
bottom_face_center=(WHEEL_AXIS[0], WHEEL_AXIS[1], hub_z_min - 1.0),
axis=(0.0, 0.0, 1.0),
)
]
cutters.extend(
make_bolt_circle_cutters_rsolidlist(
center=WHEEL_AXIS,
bolt_circle_radius=OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS,
angles_degrees=OUTPUT_FLANGE_BOLT_ANGLES_DEGREES,
hole_radius=OUTPUT_FLANGE_BOLT_CLEARANCE_RADIUS,
z_min=hub_z_min - 1.0,
height=WHEEL_HUB_PLATE_THICKNESS + 2.0,
counterbore_radius=OUTPUT_FLANGE_BOLT_COUNTERBORE_RADIUS,
counterbore_depth=3.0,
counterbore_from_top=True,
counterbore_face_z=hub_z_min + WHEEL_HUB_PLATE_THICKNESS,
)
)
wheel = scad.cut_rsolid(wheel, cutters, skip_non_intersecting=False)
wheel = scad.apply_tag(shape=wheel, tag="role.rigid_spoked_wheel_hub")
print(
f"wheel_hub: output_holes={len(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES)} "
f"output_pcd={OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * 2.0:.1f} spokes={WHEEL_SPOKE_COUNT} "
f"faces={len(wheel.get_faces())} volume={wheel.get_volume():.3f}"
)
return make_part_with_connectors_rpart(
part_id="wheel_hub",
body=wheel,
name="7075 spoked wheel hub bolted to the actuator output flange",
material=material,
connectors=(
("wheel_axis", WHEEL_AXIS, "z", "Wheel spin datum"),
("tire_axis", WHEEL_AXIS, "z", "Replaceable tire datum"),
*_output_bolt_connectors(
center=WHEEL_AXIS,
face_z=hub_z_min + WHEEL_HUB_PLATE_THICKNESS,
axis="z",
),
),
)
def _make_axis_plate_base_rsolid(
*,
start: tuple[float, float, float],
end: tuple[float, float, float],
z_center: float,
thickness: float,
start_radius: float,
end_radius: float,
web_width: float,
tag: str,
) -> scad.Solid:
length = _xy_distance(start, end)
if length <= max(start_radius, end_radius):
raise ValueError("axis plate endpoints are too close")
z_min = z_center - thickness / 2.0
web = scad.make_box_rsolid(
width=length,
height=web_width,
depth=thickness,
bottom_face_center=(length / 2.0, 0.0, z_min),
)
start_boss = scad.make_cylinder_rsolid(
radius=start_radius,
height=thickness,
bottom_face_center=(0.0, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
end_boss = scad.make_cylinder_rsolid(
radius=end_radius,
height=thickness,
bottom_face_center=(length, 0.0, z_min),
axis=(0.0, 0.0, 1.0),
)
plate = scad.union_rsolid([web, start_boss, end_boss], glue=False)
angle_degrees = math.degrees(math.atan2(end[1] - start[1], end[0] - start[0]))
plate = scad.rotate_shape(
shape=plate,
angle=angle_degrees,
axis=(0.0, 0.0, 1.0),
origin=(0.0, 0.0, z_center),
)
plate = scad.translate_shape(shape=plate, vector=(start[0], start[1], 0.0))
return scad.apply_tag(shape=plate, tag=tag)
def _make_link_window_cutters(
*,
start: tuple[float, float, float],
end: tuple[float, float, float],
z_center: float,
thickness: float,
fractions: tuple[float, ...],
length: float,
width: float,
tag_prefix: str,
) -> list[scad.Solid]:
angle_degrees = math.degrees(math.atan2(end[1] - start[1], end[0] - start[0]))
cutters = []
for index, fraction in enumerate(fractions, start=1):
cutters.append(
make_rounded_slot_cutter_rsolid(
center=(
start[0] + (end[0] - start[0]) * fraction,
start[1] + (end[1] - start[1]) * fraction,
z_center,
),
length=length,
width=width,
height=thickness + 2.0,
angle_degrees=angle_degrees,
tag=f"role.{tag_prefix}_{index}",
)
)
return cutters
def _make_wheel_spoke_rsolid(*, angle_degrees: float) -> scad.Solid:
hub_overlap_radius = WHEEL_HUB_PLATE_RADIUS - 1.5
rim_overlap_radius = WHEEL_TIRE_BORE_RADIUS + 1.5
length = rim_overlap_radius - hub_overlap_radius
radial_center = (hub_overlap_radius + rim_overlap_radius) / 2.0
z_center = WHEEL_HUB_PLATE_THICKNESS / 2.0
spoke = scad.make_box_rsolid(
width=length,
height=WHEEL_SPOKE_WIDTH,
depth=WHEEL_HUB_PLATE_THICKNESS,
bottom_face_center=(WHEEL_AXIS[0] + radial_center, WHEEL_AXIS[1], z_center - WHEEL_HUB_PLATE_THICKNESS / 2.0),
)
return scad.rotate_shape(
shape=spoke,
angle=angle_degrees,
axis=(0.0, 0.0, 1.0),
origin=WHEEL_AXIS,
)
def _xy_distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def _output_bolt_connectors(
*, center: tuple[float, float, float], face_z: float, axis: str
) -> tuple[tuple[str, tuple[float, float, float], str, str], ...]:
connectors = []
for index, angle_degrees in enumerate(OUTPUT_FLANGE_BOLT_ANGLES_DEGREES, start=1):
angle = math.radians(angle_degrees)
connectors.append(
(
f"output_bolt_{index}_head_top",
(
center[0] + OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * math.cos(angle),
center[1] + OUTPUT_FLANGE_BOLT_CIRCLE_RADIUS * math.sin(angle),
face_z,
),
axis,
f"M3 output screw {index} head top",
)
)
return tuple(connectors)
@@ -0,0 +1,90 @@
"""Build, validate, and export the leg-wheel robot dog leg example."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import simplecadapi as scad
from leg_assembly import make_leg_wheel_robot_dog_leg_rassembly
from actuator import make_actuator_materials_rdict
from leg_common import ground_compound
from leg_materials import make_leg_materials_rdict
from leg_dimensions import validate_leg_interface_dimensions
# Example 16's reducer graph is intentionally deep because of herringbone gears.
sys.setrecursionlimit(40000)
OUT_DIR = Path("examples/out/leg_wheel_robot_dog_leg")
def _build_leg_wheel_robot_dog_leg():
validate_leg_interface_dimensions()
actuator_materials = make_actuator_materials_rdict()
leg_materials = make_leg_materials_rdict()
with scad.GraphSession(graph_id="leg_wheel_robot_dog_leg") as session:
assembly = make_leg_wheel_robot_dog_leg_rassembly(
actuator_materials=actuator_materials,
leg_materials=leg_materials,
)
preview = scad.make_compound_from_assembly_rcompound(assembly=assembly)
ground_compound(label="leg_preview", compound=preview)
leaf_ops = [node.op for node in session.graph.leaf_nodes()]
print(f"leg_graph_results: leaves={len(leaf_ops)} ops={','.join(leaf_ops)}")
if leaf_ops != ["make_compound_from_assembly_rcompound"]:
raise RuntimeError("Leg graph contains detached source results")
session_json = scad.export_session_json(session=session)
model_json = scad.export_model_json(session=session)
return assembly, preview, model_json, session_json
def main() -> None:
OUT_DIR.mkdir(parents=True, exist_ok=True)
model_path = OUT_DIR / "leg_wheel_robot_dog_leg.model.json"
session_path = OUT_DIR / "leg_wheel_robot_dog_leg.session.json"
step_path = OUT_DIR / "leg_wheel_robot_dog_leg.step"
fcstd_path = OUT_DIR / "leg_wheel_robot_dog_leg.FCStd"
if fcstd_path.exists():
fcstd_path.unlink()
assembly, preview, model_json, session_json = _build_leg_wheel_robot_dog_leg()
model_path.write_text(model_json, encoding="utf-8")
session_path.write_text(session_json, encoding="utf-8")
scad.export_step(shapes=preview, filename=str(step_path))
imported = scad.import_model_json(json_str=model_json)
replayed = scad.replay_model_json(json_str=model_json)
payload = json.loads(model_json)
fcstd_status = "not attempted"
try:
scad.translator.freecad_translator.translate_model_json_to_fcstd(
json_str=model_json,
output_path=str(fcstd_path.resolve()),
document_name="LegWheelRobotDogLeg",
freecad_cmd=None,
)
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"skipped ({exc.__class__.__name__}: {exc})"
print(f"assembly={assembly.assembly_id}")
print("components=" + ",".join(assembly.component_ids()))
print("constraints=" + ",".join(assembly.constraint_ids()))
print(f"preview_solids={len(preview.get_solids())}")
print(f"preview_volume={preview.get_volume():.3f}")
print(f"imported_keys={','.join(sorted(imported.keys()))}")
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"session={session_path}")
print(f"step={step_path}")
print(f"fcstd={fcstd_status}")
if __name__ == "__main__":
main()