from __future__ import annotations from math import cos, hypot, sin, tau from build123d import ( Align, Box, BuildPart, BuildSketch, Color, Compound, Cylinder, Face, Location, Locations, Mode, Plane, Polygon, Wire, chamfer, extrude, fillet, loft, ) # Units: millimeters. # Origin: central tube axis. XY is the blade layout plane. +Z is upward through # the hollow hub. The image is interpreted as a stylized six-blade open propeller # with separate visible blade/rim/hub solids rather than a single fused casting. MODEL_LABEL = "six_blade_open_propeller_from_reference" WARM_IVORY = Color(0.82, 0.78, 0.65, 1.0) WARM_TAUPE = Color(0.72, 0.70, 0.61, 1.0) BLADE_COUNT = 6 BLADE_ROOT_RADIUS = 10.8 BLADE_TIP_RADIUS = 68.0 BLADE_TIP_SWEEP = 14.5 BLADE_CURVE_EXPONENT = 1.55 BLADE_WIDTH = 31.5 BLADE_THICKNESS = 2.8 BLADE_SEGMENTS = 24 BLADE_EDGE_BLEND_RADIUS = 0.55 BLADE_EDGE_BLEND_MIN_SPAN = 0.2 BLADE_Z_OFFSET = -0.35 BLADE_TIP_TWIST_SPAN = 7.0 BLADE_TIP_CENTER_Z_DROP = 1.4 HUB_OUTER_RADIUS = 29.0 HUB_INNER_RADIUS = 23.5 HUB_HEIGHT = 30.0 HUB_WALL_FILLET = 0.65 HUB_CUTOUT_WIDTH = 10.0 HUB_CUTOUT_DEPTH = (HUB_OUTER_RADIUS - HUB_INNER_RADIUS) * 0.5 HUB_CUTOUT_BOTTOM_Z = -0.5 HUB_CUTOUT_HEIGHT = HUB_HEIGHT + 1.0 HUB_CUTOUT_BORE_OVERLAP = 1.0 def _label(part, label: str, color: Color): part.label = label part.color = color return part def _safe_chamfer(part, length: float): try: return chamfer(part.edges(), length=length) except Exception: return part def _safe_fillet(part, radius: float): try: return fillet(part.edges(), radius=radius) except Exception: return part def _safe_fillet_edges(part, edges, radius: float): selected = list(edges) if not selected: return part try: return fillet(selected, radius=radius) except Exception: return part def _safe_chamfer_edges(part, edges, length: float): selected = list(edges) if not selected: return part try: return chamfer(selected, length=length) except Exception: return part def _circle_radius(edge) -> float | None: try: return edge.radius except Exception: return None def _edge_z(edge) -> float: return edge.bounding_box().center().Z def _edge_axis_spans(edge) -> tuple[float, float, float]: bbox = edge.bounding_box() return ( bbox.max.X - bbox.min.X, bbox.max.Y - bbox.min.Y, bbox.max.Z - bbox.min.Z, ) def _non_planar_oriented_edges(part): edges = [] for edge in part.edges(): if all(span > BLADE_EDGE_BLEND_MIN_SPAN for span in _edge_axis_spans(edge)): edges.append(edge) return edges def _hub_rim_edges(part, outer_radius: float, inner_radius: float, height: float): edges = [] for edge in part.edges(): radius = _circle_radius(edge) if radius is None: continue if min(abs(radius - outer_radius), abs(radius - inner_radius)) > 0.08: continue if min(abs(_edge_z(edge)), abs(_edge_z(edge) - height)) > 0.08: continue edges.append(edge) return edges def _blade_center(t: float, *, root_radius: float, tip_radius: float) -> tuple[float, float]: span = tip_radius - root_radius x_pos = root_radius + span * t y_pos = BLADE_TIP_SWEEP * (0.5 - t**BLADE_CURVE_EXPONENT) return (x_pos, y_pos) def _blade_tangent(t: float, *, root_radius: float, tip_radius: float) -> tuple[float, float]: low = max(0.0, t - 0.01) high = min(1.0, t + 0.01) x0, y0 = _blade_center(low, root_radius=root_radius, tip_radius=tip_radius) x1, y1 = _blade_center(high, root_radius=root_radius, tip_radius=tip_radius) dx = x1 - x0 dy = y1 - y0 length = hypot(dx, dy) return (dx / length, dy / length) def _blade_width(t: float, width_offset: float) -> float: return max(2.0, BLADE_WIDTH + width_offset) def _blade_outline( *, root_offset: float = 0.0, tip_offset: float = 0.0, width_offset: float = 0.0, ) -> list[tuple[float, float]]: root_radius = BLADE_ROOT_RADIUS + root_offset tip_radius = BLADE_TIP_RADIUS + tip_offset left_side: list[tuple[float, float]] = [] right_side: list[tuple[float, float]] = [] for index in range(BLADE_SEGMENTS + 1): t = index / BLADE_SEGMENTS x_pos, y_pos = _blade_center(t, root_radius=root_radius, tip_radius=tip_radius) tangent_x, tangent_y = _blade_tangent( t, root_radius=root_radius, tip_radius=tip_radius, ) normal_x, normal_y = -tangent_y, tangent_x half_width = _blade_width(t, width_offset) / 2.0 left_side.append((x_pos + normal_x * half_width, y_pos + normal_y * half_width)) right_side.append((x_pos - normal_x * half_width, y_pos - normal_y * half_width)) tip_tangent_x, tip_tangent_y = _blade_tangent( 1.0, root_radius=root_radius, tip_radius=tip_radius, ) tip_normal_x, tip_normal_y = -tip_tangent_y, tip_tangent_x tip_x, tip_y = _blade_center(1.0, root_radius=root_radius, tip_radius=tip_radius) tip_half_width = _blade_width(1.0, width_offset) / 2.0 cap_points = [] for index in range(1, 11): angle = index * tau / 20.0 cap_points.append( ( tip_x + tip_normal_x * cos(angle) * tip_half_width + tip_tangent_x * sin(angle) * tip_half_width, tip_y + tip_normal_y * cos(angle) * tip_half_width + tip_tangent_y * sin(angle) * tip_half_width, ) ) return left_side + cap_points + list(reversed(right_side)) def _outline_solid(points: list[tuple[float, float]], height: float): with BuildPart() as part: with BuildSketch(Plane.XY): Polygon(points, align=None) extrude(amount=height) return part.part def _place_blade_part(part, index: int): blade_angle = index * 360.0 / BLADE_COUNT return part.moved(Location((0.0, 0.0, 0.0), (0.0, 0.0, blade_angle))) def _blade_twist_span(t: float) -> float: root_span = HUB_HEIGHT - BLADE_THICKNESS blend = t**0.85 return root_span * (1.0 - blend) + BLADE_TIP_TWIST_SPAN * blend def _blade_center_z(t: float) -> float: return HUB_HEIGHT / 2.0 + BLADE_Z_OFFSET - BLADE_TIP_CENTER_Z_DROP * t def _make_blade_section_face( *, x_pos: float, y_pos: float, tangent_x: float, tangent_y: float, width: float, twist_span: float, center_z: float, ) -> Face: normal_x, normal_y = -tangent_y, tangent_x half_width = width / 2.0 half_thickness = BLADE_THICKNESS / 2.0 half_twist_span = twist_span / 2.0 high_edge = ( x_pos + normal_x * half_width, y_pos + normal_y * half_width, center_z + half_twist_span, ) low_edge = ( x_pos - normal_x * half_width, y_pos - normal_y * half_width, center_z - half_twist_span, ) points = [ (high_edge[0], high_edge[1], high_edge[2] + half_thickness), (low_edge[0], low_edge[1], low_edge[2] + half_thickness), (low_edge[0], low_edge[1], low_edge[2] - half_thickness), (high_edge[0], high_edge[1], high_edge[2] - half_thickness), ] return Face.make_surface(Wire.make_polygon(points, close=True)) def _make_section_face(t: float) -> Face: x_pos, y_pos = _blade_center(t, root_radius=BLADE_ROOT_RADIUS, tip_radius=BLADE_TIP_RADIUS) tangent_x, tangent_y = _blade_tangent( t, root_radius=BLADE_ROOT_RADIUS, tip_radius=BLADE_TIP_RADIUS, ) return _make_blade_section_face( x_pos=x_pos, y_pos=y_pos, tangent_x=tangent_x, tangent_y=tangent_y, width=_blade_width(t, 0.0), twist_span=_blade_twist_span(t), center_z=_blade_center_z(t), ) def _make_blade_body_local(): faces = [ _make_section_face(index / BLADE_SEGMENTS) for index in range(BLADE_SEGMENTS + 1) ] return loft(faces, ruled=False) def _make_blade_body(index: int): body = _make_blade_body_local() hub_trim = Cylinder( radius=HUB_OUTER_RADIUS, height=120.0, align=(Align.CENTER, Align.CENTER, Align.MIN), ).moved(Location((0.0, 0.0, -45.0))) body = body - hub_trim body = _safe_fillet_edges( body, _non_planar_oriented_edges(body), BLADE_EDGE_BLEND_RADIUS, ) body = _place_blade_part(body, index) return _label(body, f"blade_{index + 1:02d}_broad_swept_petal", WARM_IVORY) def _annular_cylinder( *, label: str, outer_radius: float, inner_radius: float, height: float, z_bottom: float, color: Color, ): with BuildPart() as ring: Cylinder( radius=outer_radius, height=height, align=(Align.CENTER, Align.CENTER, Align.MIN), ) Cylinder( radius=inner_radius, height=height + 2.0, align=(Align.CENTER, Align.CENTER, Align.MIN), mode=Mode.SUBTRACT, ) # Internal keyway-style recess: open to the bore, blind before the # outside wall, matching the rectangular cut visible inside the hub. slot_depth = HUB_CUTOUT_DEPTH + HUB_CUTOUT_BORE_OVERLAP slot_x = inner_radius - HUB_CUTOUT_BORE_OVERLAP + slot_depth / 2.0 slot_z = HUB_CUTOUT_BOTTOM_Z + HUB_CUTOUT_HEIGHT / 2.0 with Locations(Location((slot_x, 0.0, slot_z))): Box( slot_depth, HUB_CUTOUT_WIDTH, HUB_CUTOUT_HEIGHT, mode=Mode.SUBTRACT, ) part = ring.part.moved(Location((0.0, 0.0, z_bottom))) part = _safe_chamfer_edges( part, _hub_rim_edges(part, outer_radius, inner_radius, height), HUB_WALL_FILLET, ) return _label(part, label, color) def _make_hub_shell(): return _annular_cylinder( label="hollow_cylindrical_center_tube_open_bore", outer_radius=HUB_OUTER_RADIUS, inner_radius=HUB_INNER_RADIUS, height=HUB_HEIGHT, z_bottom=0.0, color=WARM_TAUPE, ) def gen_step(): parts = [_make_hub_shell()] for index in range(BLADE_COUNT): parts.append(_make_blade_body(index)) return Compound(obj=parts, children=parts, label=MODEL_LABEL)