from math import cos, radians, sin, tau from build123d import ( Align, Box, BuildPart, BuildSketch, Circle, Color, Compound, Cone, Cylinder, Edge, Location, Locations, Mode, Plane, Polygon, RectangleRounded, RegularPolygon, Torus, extrude, fillet, sweep, ) # Units: millimeters. # Origin: centered on the palm frame. # XY: palm plane. +Z: back of hand/up. Fingers extend generally in +Y. PALM_WIDTH = 70.0 PALM_LENGTH = 58.0 PALM_THICKNESS = 8.0 PALM_CORNER_RADIUS = 5.0 PALM_TOP_Z = PALM_THICKNESS FINGER_LINK_WIDTH = 8.0 FINGER_LINK_THICKNESS = 6.0 FINGER_BASE_Z = PALM_TOP_Z + 4.5 FINGER_YAW_DEGREES = { "index": 2.0, "middle": 0.0, "ring": -2.0, "little": -4.0, } MAIN_FINGER_BASES = { "index": (-24.0, 30.0, FINGER_BASE_Z), "middle": (-8.0, 30.5, FINGER_BASE_Z + 0.5), "ring": (8.0, 30.0, FINGER_BASE_Z), "little": (24.0, 29.0, FINGER_BASE_Z - 0.5), } MAIN_LINK_LENGTHS = { "index": (25.0, 20.0, 16.0), "middle": (25.0, 20.0, 16.0), "ring": (25.0, 20.0, 16.0), "little": (22.0, 17.0, 14.0), } MAIN_FINGER_FLEX = { "index": (15.0, 40.0, 60.0), "middle": (13.0, 37.0, 56.0), "ring": (16.0, 42.0, 62.0), "little": (18.0, 45.0, 65.0), } THUMB_BASE = (-42.0, -5.0, FINGER_BASE_Z - 1.0) THUMB_LINK_LENGTHS = (22.0, 18.0, 14.0) THUMB_YAWS = (50.0, 36.0, 22.0) THUMB_FLEX = (10.0, 30.0, 48.0) DISPLAY_BASE_BOTTOM_Z = -64.0 DISPLAY_BASE_THICKNESS = 8.0 DISPLAY_BASE_TOP_Z = DISPLAY_BASE_BOTTOM_Z + DISPLAY_BASE_THICKNESS WRIST_CENTER = (0.0, -45.0, -12.0) WRIST_RING_OUTER_RADIUS = 27.0 WRIST_RING_INNER_RADIUS = 19.0 BRUSHED_ALUMINUM = Color(0.72, 0.74, 0.74, 1.0) DARK_TITANIUM = Color(0.18, 0.20, 0.22, 1.0) MATTE_BLACK = Color(0.01, 0.011, 0.012, 1.0) DARK_GRAPHITE = Color(0.10, 0.105, 0.11, 1.0) POLISHED_STEEL = Color(0.86, 0.88, 0.88, 1.0) BLUE_ACCENT = Color(0.05, 0.33, 0.86, 1.0) AMBER_ACCENT = Color(1.0, 0.55, 0.12, 1.0) def _safe_fillet(edges, radius: float) -> None: """Apply a cosmetic fillet inside a BuildPart context when topology allows it.""" try: fillet(edges, radius) except Exception: pass def _set_metadata(part, label: str, color: Color): part.label = label part.color = color return part def _teardrop_points(center: tuple[float, float], width: float, height: float) -> list[tuple[float, float]]: cx, cy = center return [ (cx - width / 2.0, cy - height * 0.40), (cx + width / 2.0, cy - height * 0.40), (cx + width * 0.18, cy + height * 0.18), (cx, cy + height * 0.52), (cx - width * 0.18, cy + height * 0.18), ] def _add_teardrop_sketch(center: tuple[float, float], width: float, height: float) -> None: Polygon(_teardrop_points(center, width, height), align=None) Circle(width * 0.28, align=Align.CENTER).locate( Location((center[0], center[1] + height * 0.28, 0.0)) ) def _make_palm_frame(): openings = [ ((-20.0, 0.0), 12.5, 27.0), ((0.0, 5.0), 13.5, 31.0), ((20.0, 0.0), 12.5, 27.0), ] with BuildPart() as palm: with BuildSketch(Plane.XY): RectangleRounded( PALM_WIDTH, PALM_LENGTH, PALM_CORNER_RADIUS, align=(Align.CENTER, Align.CENTER), ) extrude(amount=PALM_THICKNESS) _safe_fillet(palm.edges(), 0.8) for center, width, height in openings: with BuildSketch(Plane.XY.offset(-0.8)): _add_teardrop_sketch(center, width, height) extrude(amount=PALM_THICKNESS + 1.6, mode=Mode.SUBTRACT) return _set_metadata(palm.part, "skeletonized_palm_frame_70x58x8", BRUSHED_ALUMINUM) def _make_palm_ribs(): ribs = [] openings = [ ("left", (-20.0, 0.0), 12.5, 27.0), ("center", (0.0, 5.0), 13.5, 31.0), ("right", (20.0, 0.0), 12.5, 27.0), ] for name, center, width, height in openings: with BuildPart() as rib: with BuildSketch(Plane.XY.offset(PALM_TOP_Z)): _add_teardrop_sketch(center, width + 6.0, height + 6.0) extrude(amount=2.0) with BuildSketch(Plane.XY.offset(PALM_TOP_Z - 0.4)): _add_teardrop_sketch(center, width + 0.6, height + 0.6) extrude(amount=3.0, mode=Mode.SUBTRACT) _safe_fillet(rib.edges(), 1.5) ribs.append(_set_metadata(rib.part, f"raised_palm_rib_{name}_3mm_lip", BRUSHED_ALUMINUM)) return ribs def _make_display_base(): with BuildPart() as base: with BuildSketch(Plane.XY): RectangleRounded(110.0, 80.0, 6.0, align=(Align.CENTER, Align.CENTER)) extrude(amount=DISPLAY_BASE_THICKNESS) _safe_fillet(base.edges(), 3.0) for x_pos in (-44.0, 44.0): for y_pos in (-30.0, 30.0): with Locations(Location((x_pos, y_pos, -0.6))): Cylinder( radius=2.35, height=DISPLAY_BASE_THICKNESS + 1.2, align=(Align.CENTER, Align.CENTER, Align.MIN), mode=Mode.SUBTRACT, ) with Locations(Location((x_pos, y_pos, DISPLAY_BASE_THICKNESS - 2.7))): Cone( bottom_radius=2.35, top_radius=5.1, height=2.9, align=(Align.CENTER, Align.CENTER, Align.MIN), mode=Mode.SUBTRACT, ) part = base.part.moved(Location((0.0, -5.0, DISPLAY_BASE_BOTTOM_Z))) return _set_metadata(part, "rounded_display_base_110x80x8_with_countersunk_holes", DARK_TITANIUM) def _make_support_post(): height = WRIST_CENTER[2] - WRIST_RING_OUTER_RADIUS - DISPLAY_BASE_TOP_Z with BuildPart() as post: Cylinder( radius=6.5, height=height, align=(Align.CENTER, Align.CENTER, Align.MIN), ) _safe_fillet(post.edges(), 0.8) part = post.part.moved(Location((0.0, -45.0, DISPLAY_BASE_TOP_Z))) return _set_metadata(part, "vertical_support_post_to_wrist_gimbal", DARK_TITANIUM) def _make_wrist_gimbal(): parts = [] with BuildPart() as wrist_cylinder: Cylinder( radius=21.0, height=35.0, align=(Align.CENTER, Align.CENTER, Align.CENTER), ) _safe_fillet(wrist_cylinder.edges(), 0.5) cylinder = wrist_cylinder.part.moved(Location(WRIST_CENTER, (90.0, 0.0, 0.0))) parts.append(_set_metadata(cylinder, "wrist_short_cylinder_axis_y_od42_length35", DARK_TITANIUM)) with BuildPart() as ring: Cylinder( radius=WRIST_RING_OUTER_RADIUS, height=6.0, align=(Align.CENTER, Align.CENTER, Align.CENTER), ) Cylinder( radius=WRIST_RING_INNER_RADIUS, height=7.0, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT, ) _safe_fillet(ring.edges(), 0.5) roll_ring = ring.part.moved(Location(WRIST_CENTER, (90.0, 0.0, 0.0))) parts.append(_set_metadata(roll_ring, "perpendicular_wrist_roll_ring_od54_id38_thick6", DARK_TITANIUM)) for side, y_offset in (("front", 3.9), ("rear", -3.9)): for index in range(8): angle = tau * index / 8.0 x_pos = WRIST_CENTER[0] + 23.7 * cos(angle) z_pos = WRIST_CENTER[2] + 23.7 * sin(angle) with BuildPart() as bolt: Cylinder(radius=2.15, height=1.5, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(bolt.edges(), 0.5) part = bolt.part.moved(Location((x_pos, WRIST_CENTER[1] + y_offset, z_pos), (90.0, 0.0, 0.0))) parts.append( _set_metadata( part, f"wrist_ring_{side}_polished_bolt_head_{index + 1:02d}", POLISHED_STEEL, ) ) return parts def _make_servo_housing(label: str, center: tuple[float, float, float], accent_color: Color): with BuildPart() as servo: Box(12.0, 18.0, 8.0, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(servo.edges(), 1.2) body = servo.part.moved(Location(center)) body = _set_metadata(body, f"dark_graphite_servo_housing_{label}_12x18x8", DARK_GRAPHITE) with BuildPart() as panel: Box(8.0, 2.8, 0.6, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(panel.edges(), 0.25) accent = panel.part.moved(Location((center[0], center[1] + 5.5, center[2] + 4.25))) accent = _set_metadata(accent, f"{label}_servo_blue_amber_accent_panel", accent_color) screws = [] for sx in (-4.2, 4.2): for sy in (-6.3, 6.3): screws.append( _make_screw_head( f"{label}_servo_screw_{len(screws) + 1:02d}", (center[0] + sx, center[1] + sy, center[2] + 4.55), radius=1.05, height=0.55, axis="z", ) ) return [body, accent, *screws] def _make_screw_head(label: str, center: tuple[float, float, float], radius: float, height: float, axis: str): with BuildPart() as screw: Cylinder(radius=radius, height=height, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(screw.edges(), 0.35) rotations = { "x": (0.0, 90.0, 0.0), "y": (90.0, 0.0, 0.0), "z": (0.0, 0.0, 0.0), } part = screw.part.moved(Location(center, rotations[axis])) return _set_metadata(part, f"polished_steel_{label}", POLISHED_STEEL) def _make_palm_fasteners(): parts = [] locations = [ (-29.0, -22.0), (29.0, -22.0), (-29.0, 22.0), (29.0, 22.0), (-11.0, -23.0), (11.0, -23.0), (-11.0, 24.0), (11.0, 24.0), ] for index, (x_pos, y_pos) in enumerate(locations, start=1): parts.append( _make_screw_head( f"palm_frame_socket_screw_{index:02d}", (x_pos, y_pos, PALM_TOP_Z + 0.45), radius=1.65, height=0.9, axis="z", ) ) return parts def _make_mechanical_link(label: str, length: float, start: tuple[float, float, float], flex: float, yaw: float): with BuildPart() as link: with BuildSketch(Plane.XY): RectangleRounded( FINGER_LINK_WIDTH, length, 2.0, align=(Align.CENTER, Align.MIN), ) extrude(amount=FINGER_LINK_THICKNESS) with Locations(Location((0.0, length * 0.50, FINGER_LINK_THICKNESS - 0.55))): Box( FINGER_LINK_WIDTH * 0.48, max(5.0, length * 0.48), 1.3, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT, ) for x_pos in (-FINGER_LINK_WIDTH / 2.0 - 0.15, FINGER_LINK_WIDTH / 2.0 + 0.15): with Locations(Location((x_pos, length * 0.52, FINGER_LINK_THICKNESS * 0.52))): Box( 1.15, max(5.0, length * 0.52), 3.3, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT, ) _safe_fillet(link.edges(), 1.0) part = link.part.moved(Location((0.0, 0.0, -FINGER_LINK_THICKNESS / 2.0))) part = part.moved(Location(start, (-flex, 0.0, yaw))) return _set_metadata(part, f"brushed_aluminum_{label}_rounded_recessed_link", BRUSHED_ALUMINUM) def _segment_end(start: tuple[float, float, float], length: float, flex: float, yaw: float) -> tuple[float, float, float]: edge = Edge.make_line((0.0, 0.0, 0.0), (0.0, length, 0.0)).moved( Location(start, (-flex, 0.0, yaw)) ) candidates = [tuple(vertex) for vertex in edge.vertices()] return max( candidates, key=lambda point: (point[0] - start[0]) ** 2 + (point[1] - start[1]) ** 2 + (point[2] - start[2]) ** 2, ) def _make_hinge_pin(label: str, center: tuple[float, float, float], yaw: float): parts = [] with BuildPart() as pin: Cylinder(radius=4.0, height=12.0, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(pin.edges(), 0.5) body = pin.part.moved(Location(center, (0.0, 90.0, yaw))) parts.append(_set_metadata(body, f"polished_steel_{label}_hinge_pin_d8", POLISHED_STEEL)) for side, x_local in (("left", -6.8), ("right", 6.8)): x_pos = center[0] + x_local * cos(radians(yaw)) y_pos = center[1] + x_local * sin(radians(yaw)) with BuildPart() as cap: Cylinder(radius=5.0, height=1.5, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(cap.edges(), 0.5) cap_part = cap.part.moved(Location((x_pos, y_pos, center[2]), (0.0, 90.0, yaw))) parts.append(_set_metadata(cap_part, f"polished_steel_{label}_{side}_circular_end_cap", POLISHED_STEEL)) screw_offset = 0.85 if side == "right" else -0.85 parts.append( _make_screw_head( f"{label}_{side}_visible_cap_screw", ( center[0] + (x_local + screw_offset) * cos(radians(yaw)), center[1] + (x_local + screw_offset) * sin(radians(yaw)), center[2], ), radius=1.35, height=0.65, axis="x", ) ) return parts def _make_fingertip_pad(label: str, start: tuple[float, float, float], flex: float, yaw: float): pad_length = 7.2 with BuildPart() as pad: with BuildSketch(Plane.XY): RectangleRounded(9.5, pad_length, 3.3, align=(Align.CENTER, Align.MIN)) extrude(amount=7.2) _safe_fillet(pad.edges(), 1.0) part = pad.part.moved(Location((0.0, 0.0, -3.6))) part = part.moved(Location(start, (-flex, 0.0, yaw))) return _set_metadata(part, f"matte_black_rubber_{label}_rounded_fingertip_pad", MATTE_BLACK) def _make_thumb_saddle(): with BuildPart() as saddle: Box(13.0, 20.0, 9.0, align=(Align.CENTER, Align.CENTER, Align.CENTER)) _safe_fillet(saddle.edges(), 1.0) part = saddle.part.moved(Location((-35.5, -4.0, PALM_TOP_Z + 1.5), (0.0, 0.0, -28.0))) return _set_metadata(part, "brushed_aluminum_left_side_thumb_mount_saddle", BRUSHED_ALUMINUM) def _make_fingers(): parts = [] for finger_name, base in MAIN_FINGER_BASES.items(): yaw = FINGER_YAW_DEGREES[finger_name] lengths = MAIN_LINK_LENGTHS[finger_name] flex_angles = MAIN_FINGER_FLEX[finger_name] joint = base for joint_index, (length, flex_angle) in enumerate(zip(lengths, flex_angles), start=1): parts.extend(_make_hinge_pin(f"{finger_name}_joint_{joint_index}", joint, yaw)) parts.append( _make_mechanical_link( f"{finger_name}_phalange_{joint_index}_length_{length:.0f}mm", length, joint, flex_angle, yaw, ) ) joint = _segment_end(joint, length, flex_angle, yaw) parts.append(_make_fingertip_pad(finger_name, joint, flex_angles[-1], yaw)) parts.append(_make_thumb_saddle()) thumb_joint = THUMB_BASE for joint_index, (length, flex_angle, yaw) in enumerate( zip(THUMB_LINK_LENGTHS, THUMB_FLEX, THUMB_YAWS), start=1, ): parts.extend(_make_hinge_pin(f"thumb_joint_{joint_index}", thumb_joint, yaw)) parts.append( _make_mechanical_link( f"thumb_phalange_{joint_index}_length_{length:.0f}mm", length, thumb_joint, flex_angle, yaw, ) ) thumb_joint = _segment_end(thumb_joint, length, flex_angle, yaw) parts.append(_make_fingertip_pad("thumb", thumb_joint, THUMB_FLEX[-1], THUMB_YAWS[-1])) return parts def _tube_profile_plane(start: tuple[float, float, float], next_point: tuple[float, float, float]) -> Plane: tangent = ( next_point[0] - start[0], next_point[1] - start[1], next_point[2] - start[2], ) return Plane(origin=start, x_dir=(1.0, 0.0, 0.0), z_dir=tangent) def _make_tendon_tube(label: str, points: list[tuple[float, float, float]]): path = Edge.make_spline(points) with BuildPart() as tube: with BuildSketch(_tube_profile_plane(points[0], points[1])): Circle(1.0) sweep(path=path, is_frenet=True) _safe_fillet(tube.edges(), 0.2) return _set_metadata(tube.part, f"matte_black_tendon_tube_{label}_diameter_2mm", MATTE_BLACK) def _make_servos_and_tendons(): parts = [] servo_centers = { "thumb": (-24.0, -15.0, PALM_TOP_Z + 5.0), "index": (-12.0, -13.0, PALM_TOP_Z + 5.0), "middle": (0.0, -15.5, PALM_TOP_Z + 5.0), "ring": (12.0, -13.0, PALM_TOP_Z + 5.0), "little": (24.0, -15.0, PALM_TOP_Z + 5.0), } for index, (label, center) in enumerate(servo_centers.items()): parts.extend(_make_servo_housing(label, center, BLUE_ACCENT if index % 2 else AMBER_ACCENT)) tendon_targets = { "thumb": THUMB_BASE, "index": MAIN_FINGER_BASES["index"], "middle": MAIN_FINGER_BASES["middle"], "ring": MAIN_FINGER_BASES["ring"], "little": MAIN_FINGER_BASES["little"], } for label, start_center in servo_centers.items(): target = tendon_targets[label] start = (start_center[0], start_center[1] + 9.0, start_center[2] + 3.7) if label == "thumb": points = [ start, (-36.0, -11.0, PALM_TOP_Z + 14.0), (-43.5, -3.0, PALM_TOP_Z + 8.0), (target[0], target[1], target[2] + 1.0), ] else: points = [ start, (start[0] * 0.75, 7.0, PALM_TOP_Z + 14.0), (target[0] * 0.96, 22.0, PALM_TOP_Z + 8.5), (target[0], target[1] - 1.5, target[2] + 0.6), ] parts.append(_make_tendon_tube(label, points)) return parts def _make_knuckle_yokes(): parts = [] for name, center in MAIN_FINGER_BASES.items(): with BuildPart() as yoke: Box(12.0, 7.0, 7.0, align=(Align.CENTER, Align.CENTER, Align.CENTER)) Cylinder( radius=4.25, height=13.0, align=(Align.CENTER, Align.CENTER, Align.CENTER), rotation=(0.0, 90.0, FINGER_YAW_DEGREES[name]), mode=Mode.SUBTRACT, ) _safe_fillet(yoke.edges(), 0.8) part = yoke.part.moved(Location((center[0], center[1] - 2.5, center[2] - 0.6))) parts.append(_set_metadata(part, f"brushed_aluminum_{name}_base_knuckle_yoke", BRUSHED_ALUMINUM)) return parts def _make_decorative_hex_fasteners(): parts = [] for index, (x_pos, y_pos, z_pos) in enumerate( [ (-48.0, -36.0, DISPLAY_BASE_TOP_Z + 0.45), (48.0, -36.0, DISPLAY_BASE_TOP_Z + 0.45), (-48.0, 26.0, DISPLAY_BASE_TOP_Z + 0.45), (48.0, 26.0, DISPLAY_BASE_TOP_Z + 0.45), ], start=1, ): with BuildPart() as fastener: with BuildSketch(Plane.XY): RegularPolygon(3.2, 6, rotation=30.0) extrude(amount=0.9) _safe_fillet(fastener.edges(), 0.25) part = fastener.part.moved(Location((x_pos, y_pos, z_pos))) parts.append(_set_metadata(part, f"polished_steel_display_base_hex_fastener_{index:02d}", POLISHED_STEEL)) return parts def gen_step(): """Return a labeled cybernetic robotic hand end-effector STEP assembly.""" parts = [ _make_display_base(), _make_support_post(), _make_palm_frame(), *_make_palm_ribs(), *_make_palm_fasteners(), *_make_wrist_gimbal(), *_make_knuckle_yokes(), *_make_fingers(), *_make_servos_and_tendons(), *_make_decorative_hex_fasteners(), ] assembly = Compound( obj=parts, children=parts, label="articulated_cybernetic_robotic_hand_end_effector", ) return assembly