Files
cadSet/text-to-cad/models/fun/mechanical_iris_aperture.py
2026-07-22 19:38:36 +08:00

547 lines
18 KiB
Python

from __future__ import annotations
from math import cos, degrees, radians, sin, tau
from typing import Iterable
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Color,
Compound,
Cone,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
RegularPolygon,
chamfer,
extrude,
fillet,
)
# Units: millimeters.
# Origin: center of the aperture on the Z axis.
# XY: ring/blade plane viewed from above. +Z: stack direction upward.
MODEL_LABEL = "mechanical_iris_aperture_assembly"
BRUSHED_STEEL = Color(0.64, 0.67, 0.68, 1.0)
STEEL_HIGHLIGHT = Color(0.76, 0.78, 0.78, 1.0)
GRAPHITE = Color(0.0, 0.0, 0.0, 1.0)
BRASS = Color(0.95, 0.68, 0.25, 1.0)
GUNMETAL = Color(0.13, 0.15, 0.17, 1.0)
DARK_GRAY = Color(0.06, 0.065, 0.07, 1.0)
APERTURE_BLACK = Color(0.0, 0.0, 0.0, 1.0)
BASE_OD = 160.0
BASE_ID = 52.0
BASE_THICKNESS = 8.0
BASE_TOP_Z = BASE_THICKNESS
BASE_MOUNT_HOLE_COUNT = 6
BASE_MOUNT_HOLE_DIAMETER = 5.0
BASE_MOUNT_BCD = 130.0
BASE_COUNTERSINK_TOP_DIAMETER = 10.5
BASE_COUNTERSINK_DEPTH = 2.4
RETAINING_ROOT_OD = 150.0
RETAINING_TOOTH_TIP_OD = 154.0
RETAINING_ID = 70.0
RETAINING_Z_BOTTOM = 8.0
RETAINING_Z_TOP = 13.0
RETAINING_HEIGHT = RETAINING_Z_TOP - RETAINING_Z_BOTTOM
RETAINING_TEETH = 120
BLADE_COUNT = 12
BLADE_THICKNESS = 1.2
BLADE_Z_BOTTOM = 13.05
BLADE_Z_STAGGER = 0.15
BLADE_INNER_NOSE_RADIUS = 18.0
BLADE_NOMINAL_INNER_RADIUS = 24.0
BLADE_OUTER_RADIUS = 68.0
BLADE_SPAN_DEGREES = 38.0
BLADE_PIVOT_RADIUS = 62.0
BLADE_PIVOT_LOCAL_ANGLE = radians(13.0)
BLADE_PIVOT_HOLE_DIAMETER = 3.0
BLADE_DRIVE_PIN_RADIUS = 46.0
BLADE_DRIVE_PIN_LOCAL_ANGLE = radians(-8.0)
ACTUATING_OD = 138.0
ACTUATING_ID = 86.0
ACTUATING_Z_BOTTOM = 14.0
ACTUATING_Z_TOP = 17.0
ACTUATING_HEIGHT = ACTUATING_Z_TOP - ACTUATING_Z_BOTTOM
CAM_SLOT_RADIUS = BLADE_DRIVE_PIN_RADIUS
CAM_SLOT_LENGTH = 18.0
CAM_SLOT_WIDTH = 4.0
FRONT_SCREW_COUNT = 6
FRONT_SCREW_BCD = 120.0
FRONT_SCREW_HEAD_DIAMETER = 7.0
FRONT_SCREW_HEAD_HEIGHT = 3.0
def _polar_point(radius: float, angle: float) -> tuple[float, float]:
return (radius * cos(angle), radius * sin(angle))
def _offset_point(
radius: float,
angle: float,
tangent_offset: float,
) -> tuple[float, float]:
radial_x, radial_y = cos(angle), sin(angle)
tangent_x, tangent_y = -sin(angle), cos(angle)
return (
radius * radial_x + tangent_offset * tangent_x,
radius * radial_y + tangent_offset * tangent_y,
)
def _set_metadata(part, label: str, color: Color):
part.label = label
part.color = color
return part
def _safe_chamfer(part, edges: Iterable, length: float):
selected = list(edges)
if not selected:
return part
try:
return chamfer(selected, length=length)
except Exception:
return part
def _safe_fillet(part, edges: Iterable, radius: float):
selected = list(edges)
if not selected:
return part
try:
return fillet(selected, radius=radius)
except Exception:
return part
def _circular_edges(
part,
*,
radius: float | None = None,
coordinate: float | None = None,
axis: str = "z",
tolerance: float = 0.06,
):
edges = []
for edge in part.edges():
try:
edge_radius = edge.radius
edge_center = edge.arc_center
edge_normal = edge.normal()
except Exception:
continue
if radius is not None and abs(edge_radius - radius) > tolerance:
continue
components = {"x": abs(edge_normal.X), "y": abs(edge_normal.Y), "z": abs(edge_normal.Z)}
if components[axis] < 1.0 - 1e-4:
continue
if coordinate is not None:
coord = {"x": edge_center.X, "y": edge_center.Y, "z": edge_center.Z}[axis]
if abs(coord - coordinate) > tolerance:
continue
edges.append(edge)
return edges
def _edges_at_z(part, z_value: float, *, tolerance: float = 0.04):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
if abs(bbox.min.Z - z_value) <= tolerance and abs(bbox.max.Z - z_value) <= tolerance:
edges.append(edge)
return edges
def _tooth_profile(
*,
teeth: int,
root_radius: float,
tip_radius: float,
phase: float = 0.0,
root_span_fraction: float = 0.72,
tip_span_fraction: float = 0.34,
) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
pitch_angle = tau / teeth
for tooth_index in range(teeth):
center_angle = phase + tooth_index * pitch_angle
points.extend(
(
_polar_point(root_radius, center_angle - root_span_fraction * pitch_angle / 2.0),
_polar_point(tip_radius, center_angle - tip_span_fraction * pitch_angle / 2.0),
_polar_point(tip_radius, center_angle + tip_span_fraction * pitch_angle / 2.0),
_polar_point(root_radius, center_angle + root_span_fraction * pitch_angle / 2.0),
)
)
return points
def _tick_rectangle_points(angle: float, inner_radius: float, outer_radius: float, width: float):
tangent_x, tangent_y = -sin(angle), cos(angle)
p1 = _polar_point(inner_radius, angle)
p2 = _polar_point(outer_radius, angle)
half_width = width / 2.0
return [
(p1[0] - tangent_x * half_width, p1[1] - tangent_y * half_width),
(p2[0] - tangent_x * half_width, p2[1] - tangent_y * half_width),
(p2[0] + tangent_x * half_width, p2[1] + tangent_y * half_width),
(p1[0] + tangent_x * half_width, p1[1] + tangent_y * half_width),
]
def _make_base_ring():
with BuildPart() as base:
Cylinder(
radius=BASE_OD / 2.0,
height=BASE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, -0.75))):
Cylinder(
radius=BASE_ID / 2.0,
height=BASE_THICKNESS + 1.5,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for index in range(BASE_MOUNT_HOLE_COUNT):
x_pos, y_pos = _polar_point(BASE_MOUNT_BCD / 2.0, tau * index / BASE_MOUNT_HOLE_COUNT)
with Locations(Location((x_pos, y_pos, -0.75))):
Cylinder(
radius=BASE_MOUNT_HOLE_DIAMETER / 2.0,
height=BASE_THICKNESS + 1.5,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with Locations(Location((x_pos, y_pos, BASE_TOP_Z - BASE_COUNTERSINK_DEPTH))):
Cone(
bottom_radius=BASE_MOUNT_HOLE_DIAMETER / 2.0,
top_radius=BASE_COUNTERSINK_TOP_DIAMETER / 2.0,
height=BASE_COUNTERSINK_DEPTH + 0.2,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with BuildSketch(Plane.XY.offset(BASE_TOP_Z - 0.22)):
for index in range(72):
angle = tau * index / 72
major = index % 6 == 0
inner_radius = 76.0 if major else 77.2
outer_radius = 79.35
width = 0.72 if major else 0.44
Polygon(_tick_rectangle_points(angle, inner_radius, outer_radius, width), align=None)
extrude(amount=0.36, mode=Mode.SUBTRACT)
part = base.part
chamfer_edges = []
for radius in (BASE_OD / 2.0, BASE_ID / 2.0):
chamfer_edges.extend(_circular_edges(part, radius=radius, coordinate=0.0))
chamfer_edges.extend(_circular_edges(part, radius=radius, coordinate=BASE_THICKNESS))
part = _safe_chamfer(part, chamfer_edges, 1.0)
return _set_metadata(part, "base_ring_annular_disk_160od_52id_countersunk", BRUSHED_STEEL)
def _make_tick_marks() -> list[object]:
ticks = []
for index in range(72):
angle = tau * index / 72
major = index % 6 == 0
inner_radius = 76.0 if major else 77.2
outer_radius = 79.35
width = 0.72 if major else 0.44
with BuildPart() as tick:
with BuildSketch(Plane.XY.offset(BASE_TOP_Z - 0.18)):
Polygon(_tick_rectangle_points(angle, inner_radius, outer_radius, width), align=None)
extrude(amount=0.12)
ticks.append(_set_metadata(tick.part, f"dark_engraved_radial_tick_{index + 1:02d}", DARK_GRAY))
return ticks
def _make_retaining_ring():
root_radius = RETAINING_ROOT_OD / 2.0
tip_radius = RETAINING_TOOTH_TIP_OD / 2.0
with BuildPart() as retaining:
with BuildSketch(Plane.XY.offset(RETAINING_Z_BOTTOM)):
Polygon(
_tooth_profile(
teeth=RETAINING_TEETH,
root_radius=root_radius,
tip_radius=tip_radius,
phase=-tau / RETAINING_TEETH / 2.0,
root_span_fraction=0.72,
tip_span_fraction=0.34,
),
align=None,
)
extrude(amount=RETAINING_HEIGHT)
with Locations(Location((0.0, 0.0, RETAINING_Z_BOTTOM - 0.5))):
Cylinder(
radius=RETAINING_ID / 2.0,
height=RETAINING_HEIGHT + 1.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = retaining.part
outer_chamfers = []
outer_chamfers.extend(_edges_at_z(part, RETAINING_Z_BOTTOM))
outer_chamfers.extend(_edges_at_z(part, RETAINING_Z_TOP))
part = _safe_chamfer(part, outer_chamfers, 0.45)
return _set_metadata(part, "front_retaining_ring_120_external_teeth_150od_70id", STEEL_HIGHLIGHT)
def _blade_outline_points(center_angle: float) -> list[tuple[float, float]]:
local_points: list[tuple[float, float]] = []
for step in range(10):
local_angle = radians(-BLADE_SPAN_DEGREES / 2.0) + radians(BLADE_SPAN_DEGREES) * step / 9
local_points.append((BLADE_OUTER_RADIUS, local_angle))
local_points.extend(
(
(54.0, radians(18.8)),
(36.0, radians(18.2)),
(BLADE_NOMINAL_INNER_RADIUS, radians(18.0)),
(20.0, radians(17.5)),
(BLADE_INNER_NOSE_RADIUS, radians(14.5)),
(BLADE_INNER_NOSE_RADIUS, radians(10.5)),
(BLADE_INNER_NOSE_RADIUS, radians(6.5)),
(BLADE_INNER_NOSE_RADIUS, radians(2.5)),
(BLADE_INNER_NOSE_RADIUS, radians(-1.5)),
(BLADE_INNER_NOSE_RADIUS, radians(-5.5)),
(BLADE_INNER_NOSE_RADIUS, radians(-9.5)),
(BLADE_INNER_NOSE_RADIUS, radians(-13.5)),
(20.0, radians(-16.8)),
(BLADE_NOMINAL_INNER_RADIUS, radians(-18.0)),
(38.0, radians(-18.4)),
(56.0, radians(-18.8)),
)
)
return [_polar_point(radius, center_angle + local_angle) for radius, local_angle in local_points]
def _blade_pivot_center(blade_angle: float) -> tuple[float, float]:
return _polar_point(BLADE_PIVOT_RADIUS, blade_angle + BLADE_PIVOT_LOCAL_ANGLE)
def _blade_drive_pin_center(blade_angle: float) -> tuple[float, float]:
return _polar_point(BLADE_DRIVE_PIN_RADIUS, blade_angle + BLADE_DRIVE_PIN_LOCAL_ANGLE)
def _make_iris_blade(index: int):
blade_angle = tau * index / BLADE_COUNT
z_bottom = BLADE_Z_BOTTOM + index * BLADE_Z_STAGGER
pivot_x, pivot_y = _blade_pivot_center(blade_angle)
with BuildPart() as blade:
with BuildSketch(Plane.XY.offset(z_bottom)):
Polygon(_blade_outline_points(blade_angle), align=None)
extrude(amount=BLADE_THICKNESS)
with Locations(Location((pivot_x, pivot_y, z_bottom - 0.2))):
Cylinder(
radius=BLADE_PIVOT_HOLE_DIAMETER / 2.0,
height=BLADE_THICKNESS + 0.4,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = blade.part
part = _safe_chamfer(part, part.edges(), 0.28)
return _set_metadata(part, f"iris_blade_{index + 1:02d}_matte_black_graphite", GRAPHITE)
def _curved_slot_points(center_angle: float) -> list[tuple[float, float]]:
half_angle = (CAM_SLOT_LENGTH / CAM_SLOT_RADIUS) / 2.0
inner_radius = CAM_SLOT_RADIUS - CAM_SLOT_WIDTH / 2.0
outer_radius = CAM_SLOT_RADIUS + CAM_SLOT_WIDTH / 2.0
start_angle = center_angle - half_angle
end_angle = center_angle + half_angle
points = []
for step in range(8):
angle = start_angle + (end_angle - start_angle) * step / 7
points.append(_polar_point(outer_radius, angle))
for step in range(7, -1, -1):
angle = start_angle + (end_angle - start_angle) * step / 7
points.append(_polar_point(inner_radius, angle))
return points
def _make_actuating_ring():
with BuildPart() as actuator:
with Locations(Location((0.0, 0.0, ACTUATING_Z_BOTTOM))):
Cylinder(
radius=ACTUATING_OD / 2.0,
height=ACTUATING_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, ACTUATING_Z_BOTTOM - 0.5))):
Cylinder(
radius=ACTUATING_ID / 2.0,
height=ACTUATING_HEIGHT + 1.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with BuildSketch(Plane.XY.offset(ACTUATING_Z_BOTTOM - 0.2)):
for index in range(BLADE_COUNT):
blade_angle = tau * index / BLADE_COUNT
slot_angle = blade_angle + BLADE_DRIVE_PIN_LOCAL_ANGLE
Polygon(_curved_slot_points(slot_angle), align=None)
extrude(amount=ACTUATING_HEIGHT + 0.4, mode=Mode.SUBTRACT)
part = actuator.part
chamfer_edges = []
for radius in (ACTUATING_OD / 2.0, ACTUATING_ID / 2.0):
chamfer_edges.extend(_circular_edges(part, radius=radius, coordinate=ACTUATING_Z_BOTTOM))
chamfer_edges.extend(_circular_edges(part, radius=radius, coordinate=ACTUATING_Z_TOP))
part = _safe_chamfer(part, chamfer_edges, 0.8)
return _set_metadata(part, "actuating_ring_12_curved_cam_slots_138od_86id", BRUSHED_STEEL)
def _make_pivot_screw(index: int):
blade_angle = tau * index / BLADE_COUNT
x_pos, y_pos = _blade_pivot_center(blade_angle)
shaft_height = ACTUATING_Z_TOP - BLADE_Z_BOTTOM + 0.2
head_height = 1.6
with BuildPart() as screw:
Cylinder(
radius=1.25,
height=shaft_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, shaft_height))):
Cylinder(
radius=2.5,
height=head_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with BuildSketch(Plane.XY.offset(shaft_height + head_height - 0.72)):
RegularPolygon(radius=1.35, side_count=6, major_radius=True, rotation=30.0)
extrude(amount=0.82, mode=Mode.SUBTRACT)
part = screw.part
head_edges = []
head_edges.extend(_circular_edges(part, radius=2.5, coordinate=shaft_height))
head_edges.extend(_circular_edges(part, radius=2.5, coordinate=shaft_height + head_height))
part = _safe_fillet(part, head_edges, 0.35)
part = part.moved(Location((x_pos, y_pos, BLADE_Z_BOTTOM - 0.1)))
return _set_metadata(part, f"pivot_screw_{index + 1:02d}_brass_head_and_shaft", BRASS)
def _make_drive_pin(index: int):
blade_angle = tau * index / BLADE_COUNT
x_pos, y_pos = _blade_drive_pin_center(blade_angle)
with BuildPart() as pin:
Cylinder(
radius=1.3,
height=ACTUATING_Z_TOP - BLADE_Z_BOTTOM + 0.75,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = pin.part
part = _safe_fillet(part, _circular_edges(part, radius=1.3, coordinate=ACTUATING_Z_TOP - BLADE_Z_BOTTOM + 0.75), 0.45)
part = part.moved(Location((x_pos, y_pos, BLADE_Z_BOTTOM - 0.05)))
return _set_metadata(part, f"drive_pin_{index + 1:02d}_brass_cam_follower", BRASS)
def _make_front_socket_screw(index: int):
angle = tau * index / FRONT_SCREW_COUNT + radians(30.0)
x_pos, y_pos = _polar_point(FRONT_SCREW_BCD / 2.0, angle)
shaft_radius = 2.5
shaft_height = ACTUATING_Z_TOP - RETAINING_Z_BOTTOM + 0.2
head_radius = FRONT_SCREW_HEAD_DIAMETER / 2.0
socket_depth = 1.45
with BuildPart() as screw:
Cylinder(
radius=shaft_radius,
height=shaft_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, shaft_height))):
Cylinder(
radius=head_radius,
height=FRONT_SCREW_HEAD_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with BuildSketch(Plane.XY.offset(shaft_height + FRONT_SCREW_HEAD_HEIGHT - socket_depth)):
RegularPolygon(radius=2.05, side_count=6, major_radius=True, rotation=30.0)
extrude(amount=socket_depth + 0.15, mode=Mode.SUBTRACT)
part = screw.part
head_edges = []
head_edges.extend(_circular_edges(part, radius=head_radius, coordinate=shaft_height))
head_edges.extend(_circular_edges(part, radius=head_radius, coordinate=shaft_height + FRONT_SCREW_HEAD_HEIGHT))
part = _safe_fillet(part, head_edges, 0.5)
part = part.moved(Location((x_pos, y_pos, RETAINING_Z_BOTTOM - 0.1)))
return _set_metadata(part, f"front_socket_head_cap_screw_{index + 1:02d}_gunmetal", GUNMETAL)
def _make_aperture_shadow():
with BuildPart() as shadow:
with Locations(Location((0.0, 0.0, BLADE_Z_BOTTOM - 0.22))):
Cylinder(
radius=18.0,
height=0.12,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
return _set_metadata(shadow.part, "dark_central_aperture_void_36mm_visual_shadow", APERTURE_BLACK)
def gen_step():
"""Return a labeled mechanical iris aperture assembly in millimeters."""
parts = [
_make_aperture_shadow(),
_make_base_ring(),
*_make_tick_marks(),
_make_retaining_ring(),
]
for index in range(BLADE_COUNT):
parts.append(_make_iris_blade(index))
parts.append(_make_actuating_ring())
for index in range(BLADE_COUNT):
parts.append(_make_drive_pin(index))
parts.append(_make_pivot_screw(index))
for index in range(FRONT_SCREW_COUNT):
parts.append(_make_front_socket_screw(index))
assembly = Compound(
obj=parts,
children=parts,
label=MODEL_LABEL,
)
return assembly