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

647 lines
20 KiB
Python

from __future__ import annotations
from math import atan2, cos, degrees, hypot, radians, sin
from build123d import (
Align,
Axis,
BuildPart,
BuildSketch,
Color,
Compound,
Cone,
Cylinder,
Face,
Location,
Locations,
Mode,
Plane,
Polygon,
RectangleRounded,
Torus,
Wire,
add,
extrude,
fillet,
loft,
)
DISPLAY_NAME = "Cutaway miniature turbofan jet engine"
# Units: millimeters.
# Origin: front intake center at X=0 on the engine axis.
# Engine axis: +X, centered through Y=0, Z=0.
MODEL_LABEL = "cutaway_miniature_turbofan_engine"
CUTAWAY_START_DEG = -5.0
CUTAWAY_END_DEG = 95.0
VISIBLE_SECTOR_START_DEG = CUTAWAY_END_DEG
VISIBLE_SECTOR_END_DEG = 360.0 + CUTAWAY_START_DEG
NACELLE_LENGTH = 180.0
NACELLE_OUTER_RADIUS = 55.0
NACELLE_INNER_RADIUS = 48.0
BLADE_ROOT_OVERLAP = 1.5
STATOR_TIP_OVERLAP = 1.0
STRUT_OVERLAP = 0.8
BASE_CENTER_X = 90.0
BASE_LENGTH = 140.0
BASE_WIDTH = 70.0
BASE_THICKNESS = 8.0
BASE_TOP_Z = -110.0
BASE_BOTTOM_Z = BASE_TOP_Z - BASE_THICKNESS
def _polar_yz(radius: float, angle_rad: float) -> tuple[float, float]:
return (radius * cos(angle_rad), radius * sin(angle_rad))
def _point_yz(x_pos: float, radius: float, angle_rad: float) -> tuple[float, float, float]:
y_pos, z_pos = _polar_yz(radius, angle_rad)
return (x_pos, y_pos, z_pos)
def _annular_sector_points(
*,
outer_radius: float,
inner_radius: float,
start_deg: float = VISIBLE_SECTOR_START_DEG,
end_deg: float = VISIBLE_SECTOR_END_DEG,
segments: int = 96,
) -> list[tuple[float, float]]:
outer_points = []
inner_points = []
for index in range(segments + 1):
angle = radians(start_deg + (end_deg - start_deg) * index / segments)
outer_points.append(_polar_yz(outer_radius, angle))
inner_points.append(_polar_yz(inner_radius, angle))
return outer_points + list(reversed(inner_points))
def _annular_sector_face(
*,
x_pos: float,
outer_radius: float,
inner_radius: float,
start_deg: float = VISIBLE_SECTOR_START_DEG,
end_deg: float = VISIBLE_SECTOR_END_DEG,
segments: int = 72,
) -> Face:
points = []
for index in range(segments + 1):
angle = radians(start_deg + (end_deg - start_deg) * index / segments)
points.append(_point_yz(x_pos, outer_radius, angle))
for index in range(segments, -1, -1):
angle = radians(start_deg + (end_deg - start_deg) * index / segments)
points.append(_point_yz(x_pos, inner_radius, angle))
return Face.make_surface(Wire.make_polygon(points, close=True))
def _yz_angle_degrees(y_pos: float, z_pos: float) -> float:
return degrees(atan2(z_pos, y_pos)) % 360.0
def _angle_delta_deg(a: float, b: float) -> float:
return abs((a - b + 180.0) % 360.0 - 180.0)
def _in_cutaway(angle_deg: float) -> bool:
angle = angle_deg % 360.0
start = CUTAWAY_START_DEG % 360.0
end = CUTAWAY_END_DEG % 360.0
if start > end:
return angle >= start or angle <= end
return start <= angle <= end
def _safe_fillet(part, edges, radius: float):
if not edges:
return part
try:
return fillet(edges, radius=radius)
except Exception:
return part
def _label(part, label: str, color: Color | None = None):
part.label = label
if color is not None:
part.color = color
return part
def _make_compound(label: str, parts: list, color: Color | None = None) -> Compound:
compound = Compound(obj=parts, children=parts, label=label)
if color is not None:
compound.color = color
return compound
def _make_cylinder_x(
*,
label: str,
x_start: float,
x_end: float,
radius: float,
color: Color,
) -> object:
with BuildPart() as cylinder_part:
with Locations(Location(((x_start + x_end) / 2.0, 0.0, 0.0))):
Cylinder(
radius=radius,
height=x_end - x_start,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
return _label(cylinder_part.part, label, color)
def _make_annular_sector(
*,
label: str,
x_start: float,
x_end: float,
outer_radius: float,
inner_radius: float,
color: Color,
fillet_cut_edges: float | None = None,
) -> object:
with BuildPart() as sector:
with BuildSketch(Plane.YZ):
Polygon(
_annular_sector_points(
outer_radius=outer_radius,
inner_radius=inner_radius,
),
align=None,
)
extrude(amount=x_end - x_start)
part = sector.part.moved(Location((x_start, 0.0, 0.0)))
if fillet_cut_edges is not None:
cut_edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
if bbox.size.X < (x_end - x_start) * 0.85:
continue
angle = _yz_angle_degrees(center.Y, center.Z)
radius = hypot(center.Y, center.Z)
if (
min(inner_radius, outer_radius) - 1.0
<= radius
<= max(inner_radius, outer_radius) + 1.0
and (
_angle_delta_deg(angle, CUTAWAY_START_DEG) < 1.0
or _angle_delta_deg(angle, CUTAWAY_END_DEG) < 1.0
)
):
cut_edges.append(edge)
part = _safe_fillet(part, cut_edges, fillet_cut_edges)
return _label(part, label, color)
def _make_tapered_annular_sector(
*,
label: str,
x_start: float,
x_end: float,
outer_start_radius: float,
inner_start_radius: float,
outer_end_radius: float,
inner_end_radius: float,
color: Color,
) -> object:
part = loft(
[
_annular_sector_face(
x_pos=x_start,
outer_radius=outer_start_radius,
inner_radius=inner_start_radius,
),
_annular_sector_face(
x_pos=x_end,
outer_radius=outer_end_radius,
inner_radius=inner_end_radius,
),
]
)
return _label(part, label, color)
def _make_intake_lip() -> object:
with BuildPart() as lip:
with Locations(Location((5.0, 0.0, 0.0))):
Torus(
major_radius=50.0,
minor_radius=5.0,
major_angle=260.0,
rotation=(0.0, 90.0, 0.0),
)
part = lip.part.rotate(Axis.X, -175.0)
return _label(part, "rounded_intake_lip_5mm_radius", Color(0.74, 0.77, 0.78, 1.0))
def _make_nacelle_shell() -> Compound:
parts = [
_make_annular_sector(
label="nacelle_hollow_duct_100deg_cutaway",
x_start=0.0,
x_end=NACELLE_LENGTH,
outer_radius=NACELLE_OUTER_RADIUS,
inner_radius=NACELLE_INNER_RADIUS,
color=Color(0.73, 0.76, 0.78, 1.0),
fillet_cut_edges=2.0,
),
_make_intake_lip(),
_make_tapered_annular_sector(
label="rear_exhaust_ring_od92_id74",
x_start=160.0,
x_end=180.0,
outer_start_radius=48.0,
inner_start_radius=39.0,
outer_end_radius=46.0,
inner_end_radius=37.0,
color=Color(0.62, 0.65, 0.67, 1.0),
),
]
return _make_compound("nacelle_shell", parts)
def _normalized(vector: tuple[float, float, float]) -> tuple[float, float, float]:
length = hypot(hypot(vector[0], vector[1]), vector[2])
return (vector[0] / length, vector[1] / length, vector[2] / length)
def _scale(vector: tuple[float, float, float], amount: float) -> tuple[float, float, float]:
return (vector[0] * amount, vector[1] * amount, vector[2] * amount)
def _add_vec(
a: tuple[float, float, float],
b: tuple[float, float, float],
) -> tuple[float, float, float]:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def _section_face(
*,
center: tuple[float, float, float],
chord_direction: tuple[float, float, float],
thickness_direction: tuple[float, float, float],
chord: float,
thickness: float,
) -> Face:
chord_half = _scale(chord_direction, chord / 2.0)
thickness_half = _scale(thickness_direction, thickness / 2.0)
points = [
_add_vec(_add_vec(center, chord_half), thickness_half),
_add_vec(_add_vec(center, _scale(chord_half, -1.0)), thickness_half),
_add_vec(_add_vec(center, _scale(chord_half, -1.0)), _scale(thickness_half, -1.0)),
_add_vec(_add_vec(center, chord_half), _scale(thickness_half, -1.0)),
]
return Face.make_surface(Wire.make_polygon(points, close=True))
def _make_radial_blade(
*,
label: str,
x_center: float,
base_angle: float,
root_radius: float,
tip_radius: float,
chord: float,
thickness: float,
sweep_deg: float,
twist_root_deg: float,
twist_tip_deg: float,
color: Color,
attachment_radius: float | None = None,
axial_sweep: float = 0.0,
) -> object:
span = tip_radius - root_radius
stations = []
if attachment_radius is not None and attachment_radius < root_radius:
stations.append((attachment_radius, 0.0, 0.52, 1.08))
stations.extend(
[
(root_radius, 0.0, 1.00, 1.00),
(root_radius + span * 0.38, 0.38, 1.04, 0.92),
(root_radius + span * 0.72, 0.72, 0.96, 0.84),
(tip_radius, 1.0, 0.78, 0.68),
]
)
faces = []
for radius, t, chord_scale, thickness_scale in stations:
angle = base_angle + radians(sweep_deg) * (t**1.25)
twist = radians(twist_root_deg + (twist_tip_deg - twist_root_deg) * t)
tangent = (0.0, -sin(angle), cos(angle))
x_axis = (1.0, 0.0, 0.0)
chord_direction = _normalized(
(
cos(twist) * x_axis[0] + sin(twist) * tangent[0],
cos(twist) * x_axis[1] + sin(twist) * tangent[1],
cos(twist) * x_axis[2] + sin(twist) * tangent[2],
)
)
thickness_direction = _normalized(
(
-sin(twist) * x_axis[0] + cos(twist) * tangent[0],
-sin(twist) * x_axis[1] + cos(twist) * tangent[1],
-sin(twist) * x_axis[2] + cos(twist) * tangent[2],
)
)
center = (
x_center + axial_sweep * (t - 0.5),
radius * cos(angle),
radius * sin(angle),
)
faces.append(
_section_face(
center=center,
chord_direction=chord_direction,
thickness_direction=thickness_direction,
chord=chord * chord_scale,
thickness=thickness * thickness_scale,
)
)
part = loft(faces, ruled=False)
return _label(part, label, color)
def _make_fan_rotor() -> Compound:
parts = [
_make_cylinder_x(
label="fan_hub_d26_l18",
x_start=19.0,
x_end=37.0,
radius=13.0,
color=Color(0.31, 0.38, 0.43, 1.0),
)
]
for index in range(24):
parts.append(
_make_radial_blade(
label=f"fan_blade_{index + 1:02d}",
x_center=28.0,
base_angle=radians(index * 360.0 / 24.0),
root_radius=16.0,
tip_radius=45.0,
chord=18.0,
thickness=2.0,
sweep_deg=24.0,
twist_root_deg=-20.0,
twist_tip_deg=15.0,
attachment_radius=13.0 - BLADE_ROOT_OVERLAP,
axial_sweep=3.0,
color=Color(0.77, 0.82, 0.86, 1.0),
)
)
return _make_compound("fan_rotor", parts)
def _make_compressor_rotor() -> Compound:
parts = []
stage_centers = [57.0, 69.0, 81.0, 93.0]
for stage_index, x_center in enumerate(stage_centers):
parts.append(
_make_cylinder_x(
label=f"compressor_stage_{stage_index + 1}_disk_d42",
x_start=x_center - 2.0,
x_end=x_center + 2.0,
radius=21.0,
color=Color(0.45, 0.50, 0.54, 1.0),
)
)
phase = radians(stage_index * 10.0)
stage_angle_offset = -4.0 if stage_index % 2 else 4.0
for blade_index in range(18):
parts.append(
_make_radial_blade(
label=f"compressor_s{stage_index + 1}_blade_{blade_index + 1:02d}",
x_center=x_center,
base_angle=phase + radians(blade_index * 360.0 / 18.0),
root_radius=22.0,
tip_radius=34.0,
chord=8.0,
thickness=1.2,
sweep_deg=8.0 + stage_angle_offset,
twist_root_deg=26.0 + stage_angle_offset,
twist_tip_deg=12.0 + stage_angle_offset,
attachment_radius=21.0 - BLADE_ROOT_OVERLAP,
axial_sweep=1.2,
color=Color(0.69, 0.73, 0.76, 1.0),
)
)
return _make_compound("compressor_rotor_four_stages", parts)
def _make_stator_vanes() -> Compound:
parts = []
for ring_index, x_center in enumerate([63.0, 75.0, 87.0]):
phase = radians(360.0 / 32.0 + ring_index * 6.0)
for vane_index in range(16):
parts.append(
_make_radial_blade(
label=f"stator_r{ring_index + 1}_vane_{vane_index + 1:02d}",
x_center=x_center,
base_angle=phase + radians(vane_index * 360.0 / 16.0),
root_radius=36.0,
tip_radius=NACELLE_INNER_RADIUS + STATOR_TIP_OVERLAP,
chord=7.5,
thickness=1.0,
sweep_deg=-7.0,
twist_root_deg=-30.0,
twist_tip_deg=-18.0,
axial_sweep=-0.8,
color=Color(0.79, 0.65, 0.37, 1.0),
)
)
return _make_compound("stator_vanes_three_rings", parts)
def _make_turbine_rotor() -> Compound:
parts = []
for stage_index, x_center in enumerate([128.0, 145.0]):
parts.append(
_make_cylinder_x(
label=f"turbine_stage_{stage_index + 1}_disk_d46",
x_start=x_center - 2.5,
x_end=x_center + 2.5,
radius=23.0,
color=Color(0.49, 0.45, 0.41, 1.0),
)
)
phase = radians(stage_index * 12.0)
for blade_index in range(22):
parts.append(
_make_radial_blade(
label=f"turbine_s{stage_index + 1}_blade_{blade_index + 1:02d}",
x_center=x_center,
base_angle=phase + radians(blade_index * 360.0 / 22.0),
root_radius=24.0,
tip_radius=40.0,
chord=12.0,
thickness=2.0,
sweep_deg=-34.0,
twist_root_deg=-44.0,
twist_tip_deg=-8.0,
attachment_radius=23.0 - BLADE_ROOT_OVERLAP,
axial_sweep=2.2,
color=Color(0.74, 0.59, 0.42, 1.0),
)
)
return _make_compound("turbine_rotor_two_stages", parts)
def _make_exhaust_cone() -> object:
part = loft(
[
Face.make_surface(
Wire.make_circle(
18.0,
plane=Plane((145.0, 0.0, 0.0), x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0)),
)
),
Face.make_surface(
Wire.make_circle(
7.0,
plane=Plane((178.0, 0.0, 0.0), x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0)),
)
),
]
)
return _label(part, "exhaust_cone_taper_d36_to_d14", Color(0.38, 0.40, 0.42, 1.0))
def _make_support_strut(index: int, angle_deg: float, *, trimmed: bool = False) -> object:
angle = radians(angle_deg)
inner_radius = 4.0 - STRUT_OVERLAP
outer_radius = 31.0 if trimmed else NACELLE_INNER_RADIUS + STRUT_OVERLAP
radial_direction = (0.0, cos(angle), sin(angle))
tangent_direction = (0.0, -sin(angle), cos(angle))
x_direction = (1.0, 0.0, 0.0)
faces = []
for radius in (inner_radius, outer_radius):
center = (110.0, radius * radial_direction[1], radius * radial_direction[2])
faces.append(
_section_face(
center=center,
chord_direction=x_direction,
thickness_direction=tangent_direction,
chord=5.0,
thickness=4.0,
)
)
part = loft(faces, ruled=True)
suffix = "_trimmed" if trimmed else ""
return _label(part, f"support_strut_{index}_{int(angle_deg)}deg{suffix}", Color(0.33, 0.36, 0.38, 1.0))
def _make_support_struts() -> Compound:
parts = []
for index, angle_deg in enumerate([5.0, 95.0, 185.0, 275.0], start=1):
trimmed = _in_cutaway(angle_deg) and _angle_delta_deg(angle_deg, CUTAWAY_END_DEG) > 0.5
parts.append(_make_support_strut(index, angle_deg, trimmed=trimmed))
return _make_compound("support_struts_cutaway_trimmed", parts)
def _make_mounting_pylon() -> object:
def pylon_face(z_pos: float, x_width: float, y_width: float, x_center: float) -> Face:
points = [
(x_center - x_width / 2.0, 0.0, z_pos),
(x_center - x_width * 0.18, y_width / 2.0, z_pos),
(x_center + x_width * 0.35, y_width / 2.0, z_pos),
(x_center + x_width / 2.0, 0.0, z_pos),
(x_center + x_width * 0.35, -y_width / 2.0, z_pos),
(x_center - x_width * 0.18, -y_width / 2.0, z_pos),
]
return Face.make_surface(Wire.make_polygon(points, close=True))
part = loft(
[
pylon_face(BASE_TOP_Z, 22.0, 10.0, 90.0),
pylon_face(-82.0, 19.0, 9.0, 90.0),
pylon_face(-55.0, 18.0, 8.0, 90.0),
],
ruled=False,
)
return _label(part, "mounting_pylon_swept_aero_support", Color(0.46, 0.49, 0.50, 1.0))
def _make_display_base() -> object:
with BuildPart() as base:
with BuildSketch(Plane.XY):
RectangleRounded(
BASE_LENGTH,
BASE_WIDTH,
5.0,
align=(Align.CENTER, Align.CENTER),
)
extrude(amount=BASE_THICKNESS)
for x_offset in (-50.0, 50.0):
for y_pos in (-22.0, 22.0):
x_pos = BASE_CENTER_X + x_offset
with Locations(Location((x_pos - BASE_CENTER_X, y_pos, -0.5))):
Cylinder(
radius=3.0,
height=BASE_THICKNESS + 1.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with Locations(Location((x_pos - BASE_CENTER_X, y_pos, BASE_THICKNESS))):
Cone(
bottom_radius=3.0,
top_radius=5.5,
height=2.6,
align=(Align.CENTER, Align.CENTER, Align.MAX),
mode=Mode.SUBTRACT,
)
part = base.part.moved(Location((BASE_CENTER_X, 0.0, BASE_BOTTOM_Z)))
outer_edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
if abs(center.Z - BASE_BOTTOM_Z) > 0.03 and abs(center.Z - BASE_TOP_Z) > 0.03:
continue
if (
abs(center.X - BASE_CENTER_X) > BASE_LENGTH / 2.0 - 6.0
or abs(center.Y) > BASE_WIDTH / 2.0 - 6.0
):
outer_edges.append(edge)
part = _safe_fillet(part, outer_edges, 3.0)
return _label(part, "display_base_rounded_rectangle_countersunk_holes", Color(0.22, 0.24, 0.25, 1.0))
def gen_step():
"""Return a labeled multi-body cutaway turbofan display model."""
bodies = [
_make_nacelle_shell(),
_make_fan_rotor(),
_make_compressor_rotor(),
_make_turbine_rotor(),
_make_cylinder_x(
label="central_shaft_d8_x15_to_x165",
x_start=15.0,
x_end=165.0,
radius=4.0,
color=Color(0.18, 0.20, 0.22, 1.0),
),
_make_stator_vanes(),
_make_exhaust_cone(),
_make_support_struts(),
_make_mounting_pylon(),
_make_display_base(),
]
return _make_compound(MODEL_LABEL, bodies)