Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
@@ -0,0 +1,205 @@
const Z_AXIS = [0, 0, 1];
const ORIGIN = [0, 0, 0];
const PLANETS = ["planet1", "planet2", "planet3"];
const PINS = ["pin1", "pin2", "pin3"];
const SUN_TEETH = 24;
const PLANET_TEETH = 18;
const RING_TEETH = 60;
// Fixed-ring planetary kinematics: carrier follows the sun by Ns / (Ns + Nr).
const CARRIER_RATIO_FIXED_RING = SUN_TEETH / (SUN_TEETH + RING_TEETH);
// 3.5 sun revolutions returns the carrier to 360 deg and each gear to an equivalent tooth phase.
const FULL_MESH_CYCLE_DEG = 1260;
let orbitGuide = null;
function finite(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function radialUnit(feature) {
const center = Array.isArray(feature?.center) ? feature.center : ORIGIN;
const length = Math.hypot(center[0] || 0, center[1] || 0);
if (length <= 1e-6) {
return [1, 0, 0];
}
return [(center[0] || 0) / length, (center[1] || 0) / length, 0];
}
function scaled(vector, scale) {
return vector.map((value) => value * scale);
}
function createOrbitGuide(THREE) {
const radius = 42;
const points = [];
for (let index = 0; index < 144; index += 1) {
const angle = (index / 144) * Math.PI * 2;
points.push(new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 4));
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x94a3b8,
transparent: true,
opacity: 0.34,
depthWrite: false
});
const line = new THREE.LineLoop(geometry, material);
line.name = "planetary-gear-orbit-guide";
line.renderOrder = 30;
return line;
}
export default {
manifest: {
schemaVersion: 1,
step: {
path: "models/fun/planetary_gear_assembly.step"
},
units: {
length: "mm",
angle: "deg",
time: "s"
},
features: {
carrier: { ref: "#o1.1.1", label: "Carrier plate", description: "The plate that carries planet pins around the sun axis." },
ring: { ref: "#o1.2.1", label: "Ring gear", description: "Stationary internal gear, visually faded or hidden by controls." },
sun: { ref: "#o1.3.1", label: "Sun gear", description: "Central driven gear." },
planet1: { ref: "#o1.4.1", label: "Planet gear 1" },
pin1: { ref: "#o1.5.1", label: "Planet pin 1" },
planet2: { ref: "#o1.6.1", label: "Planet gear 2" },
pin2: { ref: "#o1.7.1", label: "Planet pin 2" },
planet3: { ref: "#o1.8.1", label: "Planet gear 3" },
pin3: { ref: "#o1.9.1", label: "Planet pin 3" }
},
parameters: {
drive: { type: "number", label: "Drive", description: "Sun gear input angle across one closed mesh cycle.", default: 0, min: 0, max: FULL_MESH_CYCLE_DEG, step: 1, unit: "deg" },
explode: { type: "number", label: "Explode", description: "Radially separates meshing features for inspection.", default: 0, min: 0, max: 1, step: 0.01 },
ringVisible: { type: "boolean", label: "Ring gear", default: true },
orbitGuides: { type: "boolean", label: "Orbit guide", default: false },
highlightMeshing: { type: "boolean", label: "Mesh highlight", default: false },
viewMode: {
type: "select",
label: "View",
default: "mesh",
options: [
{ value: "mesh", label: "Mesh study" },
{ value: "cutaway", label: "Cutaway" },
{ value: "carrier", label: "Carrier focus" }
]
}
},
animations: {
meshCycle: {
label: "Mesh cycle",
duration: 6,
loop: true,
update({ cycle, set }) {
set("drive", (cycle % 1) * FULL_MESH_CYCLE_DEG);
}
},
inspectExplode: {
label: "Explode inspect",
duration: 5,
loop: true,
update({ progress, set }) {
set("drive", progress * FULL_MESH_CYCLE_DEG);
set("explode", Math.sin(progress * Math.PI));
}
}
}
},
setup({ THREE, modelGroup, cleanup }) {
if (!THREE || !modelGroup) {
return;
}
orbitGuide = createOrbitGuide(THREE);
modelGroup.add(orbitGuide);
cleanup(() => {
modelGroup.remove(orbitGuide);
orbitGuide.geometry?.dispose?.();
orbitGuide.material?.dispose?.();
orbitGuide = null;
});
},
update({ params, features, effects, time }) {
const drive = finite(params.drive);
const explode = finite(params.explode);
const sunAngle = drive;
const carrierAngle = sunAngle * CARRIER_RATIO_FIXED_RING;
const planetSpinRelativeToCarrier = -(SUN_TEETH / PLANET_TEETH) * (sunAngle - carrierAngle);
const lift = explode * 7;
const cutaway = params.viewMode === "cutaway";
const carrierFocus = params.viewMode === "carrier";
const highlight = params.highlightMeshing === true &&
(!time.playing || (time.progress > 0.45 && time.progress < 0.6));
if (orbitGuide) {
orbitGuide.visible = params.orbitGuides !== false;
orbitGuide.rotation.z = (carrierAngle * Math.PI) / 180;
}
effects.visible("ring", params.ringVisible !== false);
effects.style("ring", {
opacity: cutaway ? 0.22 : 1,
edgeOpacity: cutaway ? 0.38 : 1
});
effects.style("sun", {
emissive: highlight ? "#7c2d12" : "",
emissiveIntensity: highlight ? 0.32 : 0
});
effects.style(PLANETS, {
emissive: highlight ? "#075985" : "",
emissiveIntensity: highlight ? 0.22 : 0
});
effects.style(PINS, {
opacity: 1
});
effects.style("carrier", {
opacity: 1,
emissive: carrierFocus ? "#14532d" : "",
emissiveIntensity: carrierFocus ? 0.2 : 0
});
if (highlight) {
effects.highlight(["sun", ...PLANETS], true);
}
effects.transform("sun", {
transforms: [
{ rotate: { axis: Z_AXIS, origin: features.sun.center, angleDeg: sunAngle } },
{ translate: [0, 0, lift] }
]
});
effects.transform("carrier", {
transforms: [
{ rotate: { axis: Z_AXIS, origin: ORIGIN, angleDeg: carrierAngle } },
{ translate: [0, 0, -explode * 4] }
]
});
for (const planetId of PLANETS) {
const radial = radialUnit(features[planetId]);
effects.transform(planetId, {
transforms: [
{ rotate: { axis: Z_AXIS, origin: features[planetId].center, angleDeg: planetSpinRelativeToCarrier } },
{ translate: scaled(radial, explode * 16) },
{ rotate: { axis: Z_AXIS, origin: ORIGIN, angleDeg: carrierAngle } }
]
});
}
for (const pinId of PINS) {
const radial = radialUnit(features[pinId]);
effects.transform(pinId, {
transforms: [
{ translate: scaled(radial, explode * 16) },
{ rotate: { axis: Z_AXIS, origin: ORIGIN, angleDeg: carrierAngle } },
{ translate: [0, 0, -explode * 4] }
]
});
}
}
};
+17
View File
@@ -0,0 +1,17 @@
# Fun Models
Standalone generated CAD examples used for demos, visual inspection, and
occasional print/export experiments. These models are more expressive than the
benchmark fixtures and are not expected to form a systematic test suite.
## Contents
- `*.py`: build123d generator source for authored models.
- `*.step`: canonical generated STEP output.
- `.*.step.glb`: generated render/selector sidecars paired with STEP files.
- `.*.step.js`: optional interaction sidecars paired with STEP files.
- `*.stl`, `*.3mf`, and direct `*.glb`: durable exported/printable artifacts
when the export itself is useful as a fixture.
Avoid adding screenshots, videos, throwaway slicer profiles, or timestamped
review captures here.
@@ -0,0 +1,229 @@
from math import asin, cos, hypot, radians, sin, tau
from build123d import (
Align,
BuildPart,
BuildSketch,
Color,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
fillet,
)
# Units: millimeters.
# Origin: impeller axis centered at X=0, Y=0.
# XY: backplate plane. +Z: vertical impeller axis.
# Assumed rotation direction: counter-clockwise viewed from +Z, so blade tips
# trail clockwise by the specified backward sweep.
BACKPLATE_DIAMETER = 90.0
BACKPLATE_RADIUS = BACKPLATE_DIAMETER / 2.0
BACKPLATE_THICKNESS = 6.0
BACKPLATE_OUTER_FILLET_RADIUS = 1.5
HUB_DIAMETER = 26.0
HUB_RADIUS = HUB_DIAMETER / 2.0
HUB_HEIGHT_ABOVE_BACKPLATE = 22.0
BORE_DIAMETER = 8.0
BORE_RADIUS = BORE_DIAMETER / 2.0
BLADE_COUNT = 12
BLADE_ROOT_RADIUS = 18.0
BLADE_TIP_RADIUS = 43.0
BLADE_THICKNESS = 3.0
BLADE_HEIGHT_ABOVE_BACKPLATE = 16.0
BLADE_BACKWARD_SWEEP_ANGLE = radians(45.0)
BLADE_CURVE_SEGMENTS = 5
BLADE_ROOT_FILLET_RADIUS = 1.0
BLADE_ROOT_FILLET_MAX_RADIUS = 30.0
MODEL_LABEL = "centrifugal_impeller_12_backward_curved_blades"
def _polar_point(radius: float, angle: float) -> tuple[float, float]:
return (radius * cos(angle), radius * sin(angle))
def _clamp_radius(point: tuple[float, float]) -> tuple[float, float]:
x_pos, y_pos = point
radius = hypot(x_pos, y_pos)
if radius < BLADE_ROOT_RADIUS:
scale = BLADE_ROOT_RADIUS / radius
return (x_pos * scale, y_pos * scale)
if radius > BLADE_TIP_RADIUS:
scale = BLADE_TIP_RADIUS / radius
return (x_pos * scale, y_pos * scale)
return point
def _blade_center_point(base_angle: float, t: float) -> tuple[float, float]:
radius = BLADE_ROOT_RADIUS + (BLADE_TIP_RADIUS - BLADE_ROOT_RADIUS) * t
angle = base_angle - BLADE_BACKWARD_SWEEP_ANGLE * t
return _polar_point(radius, angle)
def _blade_normal(base_angle: float, t: float) -> tuple[float, float]:
radius = BLADE_ROOT_RADIUS + (BLADE_TIP_RADIUS - BLADE_ROOT_RADIUS) * t
dr_dt = BLADE_TIP_RADIUS - BLADE_ROOT_RADIUS
dtheta_dt = -BLADE_BACKWARD_SWEEP_ANGLE
angle = base_angle + dtheta_dt * t
dx_dt = dr_dt * cos(angle) - radius * sin(angle) * dtheta_dt
dy_dt = dr_dt * sin(angle) + radius * cos(angle) * dtheta_dt
length = hypot(dx_dt, dy_dt)
return (-dy_dt / length, dx_dt / length)
def _blade_endpoint_points(base_angle: float, t: float) -> tuple[tuple[float, float], tuple[float, float]]:
radius = BLADE_ROOT_RADIUS if t == 0.0 else BLADE_TIP_RADIUS
angle = base_angle - BLADE_BACKWARD_SWEEP_ANGLE * t
half_angle = asin((BLADE_THICKNESS / 2.0) / radius)
return (
_polar_point(radius, angle + half_angle),
_polar_point(radius, angle - half_angle),
)
def _blade_outline(base_angle: float) -> list[tuple[float, float]]:
left_side: list[tuple[float, float]] = []
right_side: list[tuple[float, float]] = []
half_thickness = BLADE_THICKNESS / 2.0
for index in range(BLADE_CURVE_SEGMENTS + 1):
t = index / BLADE_CURVE_SEGMENTS
if index == 0 or index == BLADE_CURVE_SEGMENTS:
left_point, right_point = _blade_endpoint_points(base_angle, t)
left_side.append(left_point)
right_side.append(right_point)
continue
x_pos, y_pos = _blade_center_point(base_angle, t)
nx, ny = _blade_normal(base_angle, t)
left_side.append(
_clamp_radius((x_pos + nx * half_thickness, y_pos + ny * half_thickness))
)
right_side.append(
_clamp_radius((x_pos - nx * half_thickness, y_pos - ny * half_thickness))
)
return left_side + list(reversed(right_side))
def _make_blade(index: int):
base_angle = tau * index / BLADE_COUNT
with BuildPart() as blade:
with BuildSketch(Plane.XY):
Polygon(_blade_outline(base_angle), align=None)
extrude(amount=BLADE_HEIGHT_ABOVE_BACKPLATE)
part = blade.part.moved(Location((0.0, 0.0, BACKPLATE_THICKNESS)))
part.label = f"blade_{index + 1:02d}"
return part
def _make_backplate():
with BuildPart() as backplate:
Cylinder(
radius=BACKPLATE_RADIUS,
height=BACKPLATE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = backplate.part
outer_edges = []
outer_edges.extend(_edges_near_z_radius(part, 0.0, BACKPLATE_RADIUS))
outer_edges.extend(_edges_near_z_radius(part, BACKPLATE_THICKNESS, BACKPLATE_RADIUS))
part = fillet(outer_edges, radius=BACKPLATE_OUTER_FILLET_RADIUS)
part.label = "filleted_backplate"
return part
def _edge_z(edge) -> float:
return edge.bounding_box().center().Z
def _edge_xy_radius(edge) -> float:
center = edge.bounding_box().center()
return hypot(center.X, center.Y)
def _circle_radius(edge) -> float | None:
try:
return edge.radius
except ValueError:
return None
def _edges_near_z_radius(part, z_value: float, radius: float, tolerance: float = 0.02):
edges = []
for edge in part.edges():
circle_radius = _circle_radius(edge)
if circle_radius is None:
continue
if abs(_edge_z(edge) - z_value) <= tolerance and abs(circle_radius - radius) <= tolerance:
edges.append(edge)
return edges
def _blade_root_edges(part):
min_radius = BLADE_ROOT_RADIUS - BLADE_THICKNESS
edges = []
for edge in part.edges():
if _circle_radius(edge) is not None:
continue
if abs(_edge_z(edge) - BACKPLATE_THICKNESS) > 0.02:
continue
if min_radius <= _edge_xy_radius(edge) <= BLADE_ROOT_FILLET_MAX_RADIUS:
edges.append(edge)
return edges
def gen_step():
"""Return a single solid centrifugal impeller in millimeters."""
total_height = BACKPLATE_THICKNESS + HUB_HEIGHT_ABOVE_BACKPLATE
backplate = _make_backplate()
with BuildPart() as impeller:
add(backplate, mode=Mode.ADD)
with Locations(Location((0.0, 0.0, BACKPLATE_THICKNESS))):
Cylinder(
radius=HUB_RADIUS,
height=HUB_HEIGHT_ABOVE_BACKPLATE,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
for blade_index in range(BLADE_COUNT):
add(_make_blade(blade_index), mode=Mode.ADD)
with Locations(Location((0.0, 0.0, -1.0))):
Cylinder(
radius=BORE_RADIUS,
height=total_height + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = impeller.part
root_edges = _blade_root_edges(part)
root_edges.extend(_edges_near_z_radius(part, BACKPLATE_THICKNESS, HUB_RADIUS))
if root_edges:
part = fillet(root_edges, radius=BLADE_ROOT_FILLET_RADIUS)
part.label = MODEL_LABEL
part.color = Color(0.62, 0.66, 0.70, 1.0)
return part
@@ -0,0 +1,646 @@
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)
@@ -0,0 +1,79 @@
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
RectangleRounded,
extrude,
)
OUTER_LENGTH = 100.0
OUTER_WIDTH = 70.0
OUTER_HEIGHT = 30.0
WALL_THICKNESS = 3.0
FLOOR_THICKNESS = 3.0
OUTER_CORNER_FILLET_RADIUS = 2.0
STANDOFF_DIAMETER = 10.0
STANDOFF_HEIGHT = 12.0
STANDOFF_HOLE_DIAMETER = 3.0
STANDOFF_HOLE_DEPTH = 8.0
STANDOFF_LOCATIONS = (
(-35.0, -25.0),
(-35.0, 25.0),
(35.0, -25.0),
(35.0, 25.0),
)
def gen_step():
"""Return a single solid open-top electronics enclosure base in millimeters."""
inner_length = OUTER_LENGTH - 2.0 * WALL_THICKNESS
inner_width = OUTER_WIDTH - 2.0 * WALL_THICKNESS
cavity_height = OUTER_HEIGHT - FLOOR_THICKNESS
standoff_top_z = FLOOR_THICKNESS + STANDOFF_HEIGHT
with BuildPart() as enclosure:
with BuildSketch(Plane.XY):
RectangleRounded(
OUTER_LENGTH,
OUTER_WIDTH,
OUTER_CORNER_FILLET_RADIUS,
align=(Align.CENTER, Align.CENTER),
)
extrude(amount=OUTER_HEIGHT)
with Locations(Location((0.0, 0.0, FLOOR_THICKNESS))):
Box(
inner_length,
inner_width,
cavity_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for x_pos, y_pos in STANDOFF_LOCATIONS:
with Locations(Location((x_pos, y_pos, FLOOR_THICKNESS))):
Cylinder(
radius=STANDOFF_DIAMETER / 2.0,
height=STANDOFF_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location((x_pos, y_pos, standoff_top_z))):
Cylinder(
radius=STANDOFF_HOLE_DIAMETER / 2.0,
height=STANDOFF_HOLE_DEPTH,
align=(Align.CENTER, Align.CENTER, Align.MAX),
mode=Mode.SUBTRACT,
)
part = enclosure.part
part.label = "electronics_enclosure_base"
return part
+426
View File
@@ -0,0 +1,426 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from build123d import (
Box,
Color,
Compound,
Cylinder,
Face,
Location,
Plane,
Polygon,
Shape,
Torus,
Wire,
BuildPart,
BuildSketch,
chamfer,
extrude,
fillet,
loft,
)
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
DISPLAY_NAME = "Roadable VTOL flying car concept"
# Units: millimeters.
# Origin: center of the road car cabin footprint.
# +X: forward/nose, +Y: driver-left/port side, +Z: up.
MODEL_LABEL = "roadable_vtol_flying_car_concept"
COLORS = {
"pearl_body": (0.82, 0.86, 0.84, 1.0),
"graphite": (0.03, 0.035, 0.04, 1.0),
"dark_glass": (0.08, 0.16, 0.24, 0.58),
"warm_lens": (1.0, 0.74, 0.28, 1.0),
"red_lens": (0.95, 0.08, 0.05, 1.0),
"titanium": (0.52, 0.56, 0.58, 1.0),
"brushed_aluminum": (0.72, 0.74, 0.73, 1.0),
"rotor_blue": (0.24, 0.42, 0.56, 1.0),
"safety_orange": (1.0, 0.35, 0.06, 1.0),
"rubber": (0.005, 0.006, 0.007, 1.0),
}
Vector3 = tuple[float, float, float]
BODY_STATIONS = [
(-182.0, 48.0, 24.0, 43.0),
(-146.0, 82.0, 39.0, 42.0),
(-78.0, 102.0, 50.0, 42.0),
(20.0, 104.0, 54.0, 43.0),
(105.0, 82.0, 44.0, 43.0),
(162.0, 42.0, 28.0, 43.0),
(183.0, 10.0, 10.0, 43.0),
]
CANOPY_STATIONS = [
(-62.0, 45.0, 10.0, 65.0),
(-22.0, 56.0, 24.0, 72.0),
(32.0, 52.0, 25.0, 72.0),
(74.0, 30.0, 10.0, 65.0),
]
DUCT_OUTER_RADIUS = 48.0
DUCT_INNER_RADIUS = 36.0
DUCT_HEIGHT = 12.0
DUCT_Z = 58.0
DUCT_FRONT_X = 112.0
DUCT_REAR_X = -114.0
DUCT_CENTER_Y = 112.0
ROTOR_Z = DUCT_Z + 0.9
def style(shape: Shape, label: str, color_name: str) -> Shape:
shape.label = label
shape.color = Color(*COLORS[color_name])
return shape
def group(label: str, children: Sequence[Shape], color_name: str | None = None) -> Compound:
compound = Compound(obj=list(children), children=list(children), label=label)
if color_name is not None:
compound.color = Color(*COLORS[color_name])
return compound
def softened(shape: Shape, radius: float) -> Shape:
if radius <= 0.0:
return shape
try:
return fillet(shape.edges(), radius=radius)
except Exception:
try:
return chamfer(shape.edges(), length=radius * 0.35)
except Exception:
return shape
def superellipse_section(
x_pos: float,
width: float,
height: float,
z_center: float,
*,
exponent: float = 2.65,
segments: int = 40,
) -> Face:
points: list[Vector3] = []
half_width = width / 2.0
half_height = height / 2.0
for index in range(segments):
angle = 2.0 * math.pi * index / segments
y_pos = math.copysign(abs(math.cos(angle)) ** (2.0 / exponent), math.cos(angle)) * half_width
z_pos = z_center + math.copysign(abs(math.sin(angle)) ** (2.0 / exponent), math.sin(angle)) * half_height
points.append((x_pos, y_pos, z_pos))
return Face.make_surface(Wire.make_polygon(points, close=True))
def lofted_superellipse(label: str, stations: Sequence[tuple[float, float, float, float]], color_name: str) -> Shape:
faces = [superellipse_section(*station) for station in stations]
return style(softened(loft(faces, ruled=False), 0.75), label, color_name)
def cylinder_x(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return style(Cylinder(radius, length, rotation=(0.0, 90.0, 0.0)).moved(Location(center)), label, color_name)
def cylinder_y(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return style(Cylinder(radius, length, rotation=(90.0, 0.0, 0.0)).moved(Location(center)), label, color_name)
def cylinder_z(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return style(Cylinder(radius, length).moved(Location(center)), label, color_name)
def cylinder_between(label: str, p1: Vector3, p2: Vector3, radius: float, color_name: str) -> Shape:
axis = (p2[0] - p1[0], p2[1] - p1[1], p2[2] - p1[2])
length = math.sqrt(axis[0] ** 2 + axis[1] ** 2 + axis[2] ** 2)
direction = (axis[0] / length, axis[1] / length, axis[2] / length)
wrapped = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(*p1), gp_Dir(*direction)),
radius,
length,
).Shape()
return style(Shape(obj=wrapped), label, color_name)
def annular_z(label: str, center: Vector3, outer_radius: float, inner_radius: float, height: float, color_name: str) -> Shape:
outer = Cylinder(outer_radius, height).moved(Location(center))
inner = Cylinder(inner_radius, height + 3.0).moved(Location(center))
return style(softened(outer - inner, 0.65), label, color_name)
def annular_x(label: str, center: Vector3, outer_radius: float, inner_radius: float, length: float, color_name: str) -> Shape:
outer = Cylinder(outer_radius, length, rotation=(0.0, 90.0, 0.0)).moved(Location(center))
inner = Cylinder(inner_radius, length + 3.0, rotation=(0.0, 90.0, 0.0)).moved(Location(center))
return style(softened(outer - inner, 0.65), label, color_name)
def annular_y(label: str, center: Vector3, outer_radius: float, inner_radius: float, length: float, color_name: str) -> Shape:
outer = Cylinder(outer_radius, length, rotation=(90.0, 0.0, 0.0)).moved(Location(center))
inner = Cylinder(inner_radius, length + 3.0, rotation=(90.0, 0.0, 0.0)).moved(Location(center))
return style(softened(outer - inner, 0.55), label, color_name)
def prism_from_xz(label: str, y_center: float, points_xz: Sequence[tuple[float, float]], thickness: float, color_name: str) -> Shape:
faces = []
for y_pos in (y_center - thickness / 2.0, y_center + thickness / 2.0):
points = [(x_pos, y_pos, z_pos) for x_pos, z_pos in points_xz]
faces.append(Face.make_surface(Wire.make_polygon(points, close=True)))
return style(softened(loft(faces, ruled=True), 0.45), label, color_name)
def prism_from_yz(label: str, x_center: float, points_yz: Sequence[tuple[float, float]], thickness: float, color_name: str) -> Shape:
faces = []
for x_pos in (x_center - thickness / 2.0, x_center + thickness / 2.0):
points = [(x_pos, y_pos, z_pos) for y_pos, z_pos in points_yz]
faces.append(Face.make_surface(Wire.make_polygon(points, close=True)))
return style(softened(loft(faces, ruled=True), 0.35), label, color_name)
def body_shell() -> Compound:
fuselage = lofted_superellipse("one_piece_aero_road_car_fuselage", BODY_STATIONS, "pearl_body")
belly = style(
softened(Box(225.0, 42.0, 8.0).moved(Location((-18.0, 0.0, 18.0))), 1.2),
"flat_structural_battery_spine",
"graphite",
)
canopy = lofted_superellipse("teardrop_panoramic_canopy", CANOPY_STATIONS, "dark_glass")
splitter = style(
softened(Box(42.0, 54.0, 3.6).moved(Location((172.0, 0.0, 32.0))), 0.8),
"front_aero_splitter_under_nose",
"graphite",
)
rear_diffuser = style(
softened(Box(58.0, 68.0, 5.0).moved(Location((-167.0, 0.0, 27.0))), 0.8),
"rear_diffuser_plane",
"graphite",
)
lights = [
cylinder_x("left_warm_headlamp", (184.0, 17.5, 43.5), 4.2, 3.4, "warm_lens"),
cylinder_x("right_warm_headlamp", (184.0, -17.5, 43.5), 4.2, 3.4, "warm_lens"),
cylinder_x("left_rear_marker_lamp", (-184.0, 28.0, 42.0), 3.2, 3.4, "red_lens"),
cylinder_x("right_rear_marker_lamp", (-184.0, -28.0, 42.0), 3.2, 3.4, "red_lens"),
]
return group(
"aero_car_body",
[fuselage, belly, canopy, splitter, rear_diffuser, *lights],
"pearl_body",
)
def wing_panel(label: str, side: float) -> Shape:
base_points = [
(74.0, 43.0),
(96.0, 138.0),
(-92.0, 128.0),
(-66.0, 43.0),
]
points = [(x_pos, side * y_pos) for x_pos, y_pos in base_points]
if side < 0:
points = list(reversed(points))
with BuildPart() as wing:
with BuildSketch(Plane.XY):
Polygon(points, align=None)
extrude(amount=5.2)
panel = wing.part.moved(Location((0.0, 0.0, 51.5)))
return style(softened(panel, 0.7), label, "brushed_aluminum")
def stabilizers() -> Compound:
fin_profile = [(-174.0, 54.0), (-129.0, 55.0), (-114.0, 108.0), (-166.0, 91.0)]
return group(
"twin_rear_vertical_stabilizers",
[
prism_from_xz("port_vertical_tail_fin", 36.0, fin_profile, 5.0, "safety_orange"),
prism_from_xz("starboard_vertical_tail_fin", -36.0, fin_profile, 5.0, "safety_orange"),
],
"safety_orange",
)
def lifting_wings() -> Compound:
center_fairing = style(
softened(Box(120.0, 96.0, 7.0).moved(Location((-2.0, 0.0, 53.0))), 1.0),
"flush_wing_centerbox_between_ducts",
"brushed_aluminum",
)
return group(
"folding_lifting_wing_set",
[
center_fairing,
wing_panel("port_deployable_lifting_wing", 1.0),
wing_panel("starboard_deployable_lifting_wing", -1.0),
stabilizers(),
],
"brushed_aluminum",
)
def rotor_blade_z(label: str, center: Vector3, angle_deg: float, color_name: str) -> Shape:
root_radius = 9.0
tip_radius = DUCT_INNER_RADIUS - 4.0
root_width = 9.0
tip_width = 4.4
angle = math.radians(angle_deg)
radial = (math.cos(angle), math.sin(angle))
tangent = (-math.sin(angle), math.cos(angle))
corners = []
for distance, half_width in (
(root_radius, root_width / 2.0),
(tip_radius, tip_width / 2.0),
(tip_radius, -tip_width / 2.0),
(root_radius, -root_width / 2.0),
):
corners.append(
(
center[0] + radial[0] * distance + tangent[0] * half_width,
center[1] + radial[1] * distance + tangent[1] * half_width,
)
)
with BuildPart() as blade:
with BuildSketch(Plane.XY):
Polygon(corners, align=None)
extrude(amount=1.8)
return style(blade.part.moved(Location((0.0, 0.0, center[2] - 0.9))), label, color_name)
def lift_duct_module(label: str, center: Vector3, front: bool, side: float) -> Compound:
duct_label = "front" if front else "rear"
side_label = "port" if side > 0 else "starboard"
parts: list[Shape] = [
annular_z(f"{side_label}_{duct_label}_protective_lift_duct_ring", center, DUCT_OUTER_RADIUS, DUCT_INNER_RADIUS, DUCT_HEIGHT, "graphite"),
style(
Torus(DUCT_OUTER_RADIUS - 2.4, 1.3).moved(Location((center[0], center[1], center[2] + DUCT_HEIGHT / 2.0))),
f"{side_label}_{duct_label}_upper_guard_lip",
"titanium",
),
cylinder_z(f"{side_label}_{duct_label}_rotor_hub", (center[0], center[1], ROTOR_Z), 8.5, 6.0, "titanium"),
]
for index in range(6):
parts.append(
rotor_blade_z(
f"{side_label}_{duct_label}_lift_rotor_blade_{index + 1}",
(center[0], center[1], ROTOR_Z + 1.6),
index * 60.0 + (15.0 if front else -15.0),
"rotor_blue",
)
)
for index, angle_deg in enumerate((0.0, 90.0, 180.0, 270.0), start=1):
angle = math.radians(angle_deg)
p1 = (center[0], center[1], center[2])
p2 = (
center[0] + math.cos(angle) * (DUCT_INNER_RADIUS - 2.0),
center[1] + math.sin(angle) * (DUCT_INNER_RADIUS - 2.0),
center[2],
)
parts.append(cylinder_between(f"{side_label}_{duct_label}_duct_cross_strut_{index}", p1, p2, 1.8, "titanium"))
body_attach = (center[0] + (-31.0 if front else 31.0), side * 50.0, center[2] - 3.0)
parts.append(cylinder_between(f"{side_label}_{duct_label}_faired_duct_boom", body_attach, center, 4.8, "brushed_aluminum"))
return group(label, parts, "graphite")
def lift_system() -> Compound:
duct_centers = [
("port_front_lift_fan_module", (DUCT_FRONT_X, DUCT_CENTER_Y, DUCT_Z), True, 1.0),
("starboard_front_lift_fan_module", (DUCT_FRONT_X, -DUCT_CENTER_Y, DUCT_Z), True, -1.0),
("port_rear_lift_fan_module", (DUCT_REAR_X, DUCT_CENTER_Y, DUCT_Z), False, 1.0),
("starboard_rear_lift_fan_module", (DUCT_REAR_X, -DUCT_CENTER_Y, DUCT_Z), False, -1.0),
]
return group(
"quad_tilt_lift_duct_system",
[lift_duct_module(label, center, front, side) for label, center, front, side in duct_centers],
"graphite",
)
def rear_blade_x(label: str, center: Vector3, angle_deg: float) -> Shape:
root_radius = 7.0
tip_radius = 20.5
root_width = 6.0
tip_width = 2.8
angle = math.radians(angle_deg)
radial = (math.cos(angle), math.sin(angle))
tangent = (-math.sin(angle), math.cos(angle))
points: list[tuple[float, float]] = []
for distance, half_width in (
(root_radius, root_width / 2.0),
(tip_radius, tip_width / 2.0),
(tip_radius, -tip_width / 2.0),
(root_radius, -root_width / 2.0),
):
y_pos = center[1] + radial[0] * distance + tangent[0] * half_width
z_pos = center[2] + radial[1] * distance + tangent[1] * half_width
points.append((y_pos, z_pos))
return prism_from_yz(label, center[0], points, 1.8, "rotor_blue")
def rear_thruster() -> Compound:
center = (-201.0, 0.0, 56.0)
parts: list[Shape] = [
annular_x("rear_cruise_thrust_duct", center, 27.0, 20.5, 28.0, "graphite"),
cylinder_x("rear_cruise_rotor_hub", center, 6.0, 16.0, "titanium"),
cylinder_between("upper_rear_thruster_pylon", (-174.0, 0.0, 61.0), (-190.0, 0.0, 72.0), 3.4, "brushed_aluminum"),
cylinder_between("lower_rear_thruster_pylon", (-174.0, 0.0, 39.0), (-190.0, 0.0, 42.0), 3.0, "brushed_aluminum"),
]
for index in range(7):
parts.append(rear_blade_x(f"rear_cruise_propulsor_blade_{index + 1}", center, index * 360.0 / 7.0 + 8.0))
return group("rear_cruise_propulsor", parts, "graphite")
def wheel_module(label: str, x_pos: float, side: float) -> Compound:
y_pos = side * 61.0
wheel_center = (x_pos, y_pos, 20.0)
side_label = "port" if side > 0 else "starboard"
axle_end = (x_pos, side * 45.0, 20.0)
fairing = style(
softened(Box(37.0, 17.0, 14.0).moved(Location((x_pos, side * 52.0, 31.0))), 2.0),
f"{side_label}_{label}_flush_retractable_wheel_fairing",
"pearl_body",
)
parts = [
fairing,
cylinder_between(f"{side_label}_{label}_short_axle", axle_end, wheel_center, 3.4, "titanium"),
annular_y(f"{side_label}_{label}_low_profile_tire", wheel_center, 20.0, 11.0, 12.0, "rubber"),
cylinder_y(f"{side_label}_{label}_silver_wheel_hub", wheel_center, 7.0, 15.0, "brushed_aluminum"),
]
return group(f"{side_label}_{label}_road_wheel_module", parts, "rubber")
def road_gear() -> Compound:
return group(
"retractable_road_wheel_set",
[
wheel_module("front", 108.0, 1.0),
wheel_module("front", 108.0, -1.0),
wheel_module("rear", -105.0, 1.0),
wheel_module("rear", -105.0, -1.0),
],
"rubber",
)
def gen_step():
return group(
MODEL_LABEL,
[
body_shell(),
lifting_wings(),
lift_system(),
rear_thruster(),
road_gear(),
],
)
@@ -0,0 +1,552 @@
from __future__ import annotations
import math
from collections.abc import Iterable, Sequence
from build123d import *
from build123d import Shape
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf
DISPLAY_NAME = "Futuristic lunar rover wheel suspension corner"
# Coordinate convention:
# - millimeters
# - wheel axis is global Y
# - visible outside wheel face points toward negative Y
# - wheel center is fixed at X=0, Y=0, Z=80
WHEEL_CENTER = (0.0, 0.0, 80.0)
BASE_CENTER_X = -20.0
BASE_TOP_Z = 8.0
COLORS = {
"dark_graphite": (0.005, 0.006, 0.007, 1.0),
"matte_black": (0.002, 0.002, 0.002, 1.0),
"satin_titanium": (0.42, 0.43, 0.40, 1.0),
"pale_aluminum": (0.78, 0.80, 0.78, 1.0),
"brushed_steel": (0.50, 0.52, 0.54, 1.0),
"anodized_orange": (1.00, 0.34, 0.08, 1.0),
"spring_orange": (1.00, 0.42, 0.02, 1.0),
"brushed_aluminum": (0.66, 0.68, 0.66, 1.0),
"fastener_steel": (0.18, 0.19, 0.20, 1.0),
}
Vector3 = tuple[float, float, float]
def v_add(a: Vector3, b: Vector3) -> Vector3:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def v_sub(a: Vector3, b: Vector3) -> Vector3:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def v_mul(a: Vector3, scalar: float) -> Vector3:
return (a[0] * scalar, a[1] * scalar, a[2] * scalar)
def v_dot(a: Vector3, b: Vector3) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def v_cross(a: Vector3, b: Vector3) -> Vector3:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def v_len(a: Vector3) -> float:
return math.sqrt(v_dot(a, a))
def v_norm(a: Vector3) -> Vector3:
length = v_len(a)
if length <= 1e-9:
raise ValueError("zero-length vector")
return (a[0] / length, a[1] / length, a[2] / length)
def style(shape: Shape, label: str, color_name: str) -> Shape:
shape.label = label
shape.color = Color(*COLORS[color_name])
return shape
def group(label: str, children: Sequence[Shape], color_name: str | None = None) -> Compound:
compound = Compound(obj=list(children), children=list(children), label=label)
if color_name is not None:
compound.color = Color(*COLORS[color_name])
return compound
def transformed(shape: Shape, center: Vector3, axis_x: Vector3, axis_y: Vector3, axis_z: Vector3) -> Shape:
x_dir = v_norm(axis_x)
y_dir = v_norm(axis_y)
z_dir = v_norm(axis_z)
trsf = gp_Trsf()
trsf.SetValues(
x_dir[0],
y_dir[0],
z_dir[0],
center[0],
x_dir[1],
y_dir[1],
z_dir[1],
center[1],
x_dir[2],
y_dir[2],
z_dir[2],
center[2],
)
return Shape(obj=BRepBuilderAPI_Transform(shape.wrapped, trsf, True).Shape())
def oriented_box(
label: str,
center: Vector3,
size_x: float,
size_y: float,
size_z: float,
axis_x: Vector3 = (1.0, 0.0, 0.0),
axis_y: Vector3 = (0.0, 1.0, 0.0),
axis_z: Vector3 = (0.0, 0.0, 1.0),
color_name: str = "pale_aluminum",
fillet_radius: float = 0.0,
) -> Shape:
shape = transformed(Box(size_x, size_y, size_z), center, axis_x, axis_y, axis_z)
if fillet_radius > 0.0:
shape = softened(shape, fillet_radius)
return style(shape, label, color_name)
def cylinder_between(label: str, p1: Vector3, p2: Vector3, radius: float, color_name: str) -> Shape:
axis = v_sub(p2, p1)
length = v_len(axis)
if length <= 1e-6:
return style(Sphere(radius).moved(Location(p1)), label, color_name)
direction = v_norm(axis)
wrapped = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(*p1), gp_Dir(*direction)),
radius,
length,
).Shape()
return style(Shape(obj=wrapped), label, color_name)
def cylinder_x(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return style(Cylinder(radius, length, rotation=(0, 90, 0)).moved(Location(center)), label, color_name)
def cylinder_y(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return style(Cylinder(radius, length, rotation=(90, 0, 0)).moved(Location(center)), label, color_name)
def cylinder_z(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return style(Cylinder(radius, length).moved(Location(center)), label, color_name)
def softened(shape: Shape, radius: float) -> Shape:
# Small repeated pads, spokes, tubes, and bolts dominate export time if every
# edge receives a true BREP fillet. Major machined bodies still get rounded
# edges below; small bodies rely on cylindrical profiles and crisp bevel-like
# proportions to keep the STEP robust.
if radius < 0.95:
return shape
try:
return fillet(shape.edges(), radius=radius)
except Exception:
try:
return chamfer(shape.edges(), length=radius * 0.35)
except Exception:
return shape
def annular_y(label: str, center: Vector3, outer_radius: float, inner_radius: float, length: float, color_name: str) -> Shape:
outer = Cylinder(outer_radius, length, rotation=(90, 0, 0)).moved(Location(center))
inner = Cylinder(inner_radius, length + 4.0, rotation=(90, 0, 0)).moved(Location(center))
return style(softened(outer - inner, 1.2), label, color_name)
def wheel_lattice() -> Compound:
center = WHEEL_CENTER
parts: list[Shape] = [
annular_y("wheel_outer_rim_ring", center, 65.0, 56.0, 42.0, "dark_graphite"),
annular_y("wheel_inner_rim_ring", center, 38.0, 25.0, 40.0, "dark_graphite"),
style(Torus(65.0, 1.8, rotation=(90, 0, 0)).moved(Location((0.0, -21.4, 80.0))), "wheel_outer_bead_visible", "dark_graphite"),
style(Torus(65.0, 1.8, rotation=(90, 0, 0)).moved(Location((0.0, 21.4, 80.0))), "wheel_outer_bead_inner", "dark_graphite"),
]
for index in range(16):
theta_inner = (2.0 * math.pi * index) / 16.0
theta_outer = theta_inner + math.radians(5.0)
p1 = (
math.cos(theta_inner) * 19.0,
-13.5,
80.0 + math.sin(theta_inner) * 19.0,
)
p2 = (
math.cos(theta_outer) * 56.0,
13.5,
80.0 + math.sin(theta_outer) * 56.0,
)
spoke = cylinder_between(f"wheel_angular_spoke_{index + 1:02d}", p1, p2, 2.8, "dark_graphite")
parts.append(style(softened(spoke, 0.6), spoke.label or f"wheel_angular_spoke_{index + 1:02d}", "dark_graphite"))
return group("wheel", parts, "dark_graphite")
def tread_elements() -> Compound:
parts: list[Shape] = []
rim_radius = 65.0
tread_height = 5.0
# The diagonal chevron bars have tangent-wise corners, so the pad center is
# tucked slightly below the nominal 65 mm rim surface to keep the full tread
# envelope close to the requested 140 mm outside diameter.
radial_center = rim_radius + tread_height / 2.0 - 1.1
long_size = math.hypot(18.0, 10.0)
for index in range(32):
theta = (2.0 * math.pi * index) / 32.0
normal = (math.cos(theta), 0.0, math.sin(theta))
tangent = (-math.sin(theta), 0.0, math.cos(theta))
chevron_sign = 1.0 if index % 2 == 0 else -1.0
for side_name, y_sign in (("outside", -1.0), ("inside", 1.0)):
axis_x = v_norm(v_add(v_mul((0.0, 1.0, 0.0), y_sign * 18.0), v_mul(tangent, chevron_sign * 10.0)))
axis_z = normal
axis_y = v_norm(v_cross(axis_z, axis_x))
center = v_add(
WHEEL_CENTER,
v_add(
v_mul(normal, radial_center),
v_add(v_mul((0.0, 1.0, 0.0), y_sign * 9.0), v_mul(tangent, chevron_sign * 5.0)),
),
)
pad = oriented_box(
f"tread_chevron_{index + 1:02d}_{side_name}",
center,
long_size,
5.8,
tread_height,
axis_x,
axis_y,
axis_z,
"matte_black",
fillet_radius=0.75,
)
parts.append(pad)
return group("tire_tread_elements", parts, "matte_black")
def hub_and_axle() -> tuple[Shape, Compound]:
hub = cylinder_y("central_hub", WHEEL_CENTER, 17.0, 50.0, "pale_aluminum")
hub = style(softened(hub, 1.2), "central_hub", "pale_aluminum")
axle_parts: list[Shape] = [
cylinder_y("axle_stub", (0.0, 8.0, 80.0), 8.0, 78.0, "fastener_steel"),
cylinder_y("visible_axle_cap", (0.0, -29.0, 80.0), 12.0, 6.0, "fastener_steel"),
]
axle_parts = [style(softened(part, 0.6), part.label or "axle_part", "fastener_steel") for part in axle_parts]
return hub, group("axle_hardware", axle_parts, "fastener_steel")
def brake_disc() -> Shape:
disc = Cylinder(41.0, 5.0, rotation=(90, 0, 0)).moved(Location((0.0, 29.0, 80.0)))
for index in range(24):
theta = (2.0 * math.pi * index) / 24.0
hole_center = (
math.cos(theta) * 31.0,
29.0,
80.0 + math.sin(theta) * 31.0,
)
disc = disc - Cylinder(2.2, 8.0, rotation=(90, 0, 0)).moved(Location(hole_center))
disc = softened(disc, 0.4)
return style(disc, "brake_disc_82mm_vented", "brushed_steel")
def brake_caliper() -> Compound:
angle = math.radians(42.0)
radial = (math.cos(angle), 0.0, math.sin(angle))
tangent = (-math.sin(angle), 0.0, math.cos(angle))
base = v_add(WHEEL_CENTER, v_mul(radial, 45.0))
parts = [
oriented_box(
"brake_caliper_outer_half",
v_add(base, (0.0, 20.8, 0.0)),
30.0,
7.0,
17.0,
tangent,
(0.0, 1.0, 0.0),
radial,
"anodized_orange",
fillet_radius=2.0,
),
oriented_box(
"brake_caliper_inner_half",
v_add(base, (0.0, 37.2, 0.0)),
30.0,
7.0,
17.0,
tangent,
(0.0, 1.0, 0.0),
radial,
"anodized_orange",
fillet_radius=2.0,
),
oriented_box(
"brake_caliper_bridge",
v_add(WHEEL_CENTER, v_add(v_mul(radial, 53.0), (0.0, 29.0, 0.0))),
28.0,
18.0,
5.5,
tangent,
(0.0, 1.0, 0.0),
radial,
"anodized_orange",
fillet_radius=1.3,
),
]
return group("brake_caliper", parts, "anodized_orange")
def upright() -> Compound:
parts: list[Shape] = [
oriented_box("upright_machined_web", (-18.0, 40.0, 80.0), 15.0, 18.0, 72.0, color_name="pale_aluminum", fillet_radius=1.5),
oriented_box("upright_upper_fork_lug", (-10.0, 40.0, 111.0), 30.0, 20.0, 17.0, color_name="pale_aluminum", fillet_radius=1.5),
oriented_box("upright_lower_fork_lug", (-10.0, 40.0, 49.0), 30.0, 20.0, 17.0, color_name="pale_aluminum", fillet_radius=1.5),
annular_y("upright_central_bearing", (0.0, 40.0, 80.0), 19.0, 9.0, 20.0, "pale_aluminum"),
cylinder_y("upright_upper_bushing", (-21.0, 40.0, 111.0), 6.5, 24.0, "satin_titanium"),
cylinder_y("upright_lower_bushing", (-23.0, 40.0, 49.0), 6.5, 24.0, "satin_titanium"),
]
return group("upright", parts, "pale_aluminum")
def bushing(label: str, center: Vector3, length: float = 15.0) -> Shape:
return style(softened(cylinder_y(label, center, 6.2, length, "satin_titanium"), 0.5), label, "satin_titanium")
def control_arms() -> tuple[Compound, Compound]:
upper_upright = (-21.0, 40.0, 111.0)
upper_front = (-94.0, -16.0, 118.0)
upper_rear = (-94.0, 52.0, 118.0)
lower_upright = (-23.0, 40.0, 49.0)
lower_front = (-94.0, -20.0, 45.0)
lower_rear = (-94.0, 52.0, 45.0)
upper_parts = [
cylinder_between("upper_control_arm_front_tube", upper_front, upper_upright, 3.5, "satin_titanium"),
cylinder_between("upper_control_arm_rear_tube", upper_rear, upper_upright, 3.5, "satin_titanium"),
cylinder_between("upper_control_arm_cross_tube", upper_front, upper_rear, 2.8, "satin_titanium"),
bushing("upper_front_chassis_bushing", upper_front),
bushing("upper_rear_chassis_bushing", upper_rear),
bushing("upper_upright_outer_bushing", upper_upright, 18.0),
]
upper_parts = [
style(softened(part, 0.45), part.label or "upper_control_arm_part", "satin_titanium")
if "tube" in (part.label or "")
else part
for part in upper_parts
]
lower_parts = [
cylinder_between("lower_control_arm_front_tube", lower_front, lower_upright, 3.5, "satin_titanium"),
cylinder_between("lower_control_arm_rear_tube", lower_rear, lower_upright, 3.5, "satin_titanium"),
cylinder_between("lower_control_arm_cross_tube", lower_front, lower_rear, 2.8, "satin_titanium"),
bushing("lower_front_chassis_bushing", lower_front),
bushing("lower_rear_chassis_bushing", lower_rear),
bushing("lower_upright_outer_bushing", lower_upright, 18.0),
]
lower_parts = [
style(softened(part, 0.45), part.label or "lower_control_arm_part", "satin_titanium")
if "tube" in (part.label or "")
else part
for part in lower_parts
]
return (
group("upper_control_arm", upper_parts, "satin_titanium"),
group("lower_control_arm", lower_parts, "satin_titanium"),
)
def spring_along_axis(label: str, start: Vector3, end: Vector3) -> Compound:
axis = v_sub(end, start)
length = v_len(axis)
axis_z = v_norm(axis)
fallback = (0.0, 0.0, 1.0)
if abs(v_dot(axis_z, fallback)) > 0.92:
fallback = (1.0, 0.0, 0.0)
axis_x = v_norm(v_cross(fallback, axis_z))
axis_y = v_norm(v_cross(axis_z, axis_x))
wire_radius = 1.5
helix_radius = 10.5
turns = 8
segments_per_turn = 8
total_segments = turns * segments_per_turn
def helix_point(step: int) -> Vector3:
angle = 2.0 * math.pi * turns * step / total_segments
local = (
math.cos(angle) * helix_radius,
math.sin(angle) * helix_radius,
length * step / total_segments,
)
return v_add(
start,
v_add(
v_mul(axis_x, local[0]),
v_add(v_mul(axis_y, local[1]), v_mul(axis_z, local[2])),
),
)
segments: list[Shape] = []
previous = helix_point(0)
for index in range(1, total_segments + 1):
current = helix_point(index)
segments.append(
cylinder_between(
f"{label}_segment_{index:02d}",
previous,
current,
wire_radius,
"spring_orange",
)
)
previous = current
return group(label, segments, "spring_orange")
def coilover_shock() -> Compound:
top = (-90.0, -8.0, 118.0)
bottom = (-48.0, -24.0, 58.0)
axis = v_norm(v_sub(top, bottom))
length = v_len(v_sub(top, bottom))
damper_start = v_add(bottom, v_mul(axis, 9.0))
damper_end = v_add(bottom, v_mul(axis, length * 0.62))
rod_start = v_add(bottom, v_mul(axis, length * 0.52))
rod_end = v_add(top, v_mul(axis, -7.0))
spring_start = v_add(bottom, v_mul(axis, 7.0))
spring_end = v_add(top, v_mul(axis, -7.0))
parts = [
cylinder_between("coilover_damper_body", damper_start, damper_end, 5.0, "brushed_steel"),
cylinder_between("coilover_piston_rod", rod_start, rod_end, 2.5, "fastener_steel"),
spring_along_axis("coilover_helical_spring_8_turns", spring_start, spring_end),
cylinder_y("coilover_top_eyelet", top, 6.0, 14.0, "satin_titanium"),
cylinder_y("coilover_lower_eyelet", bottom, 6.0, 14.0, "satin_titanium"),
]
return group("coilover_shock", parts, "satin_titanium")
def triangular_cutout_tool(label: str, x_center: float, vertices_yz: Sequence[tuple[float, float]]) -> Shape:
plane = Plane(origin=(x_center, 0.0, 0.0), x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))
with BuildPart() as tool:
with BuildSketch(plane):
Polygon(*vertices_yz)
extrude(amount=13.0, both=True)
tool.part.label = label
return tool.part
def chassis_bulkhead() -> tuple[Shape, Compound]:
plate = Box(8.0, 70.0, 95.0).moved(Location((-95.0, 20.0, 80.0)))
cutouts = [
triangular_cutout_tool("bulkhead_upper_triangular_cutout", -95.0, [(3.0, 86.0), (37.0, 86.0), (20.0, 117.0)]),
triangular_cutout_tool("bulkhead_lower_triangular_cutout", -95.0, [(3.0, 74.0), (37.0, 74.0), (20.0, 43.0)]),
]
for tool in cutouts:
plate = plate - tool
for y, z in [(3.0, 86.0), (37.0, 86.0), (20.0, 117.0), (3.0, 74.0), (37.0, 74.0), (20.0, 43.0)]:
plate = plate - cylinder_x("bulkhead_cutout_corner_radius_tool", (-95.0, y, z), 5.0, 13.0, "pale_aluminum")
plate = style(softened(plate, 1.0), "chassis_bulkhead", "pale_aluminum")
supports = group(
"bulkhead_display_support_blocks",
[
oriented_box("bulkhead_left_support_block", (-95.0, -8.0, 20.25), 17.0, 12.0, 24.5, color_name="brushed_aluminum", fillet_radius=1.0),
oriented_box("bulkhead_right_support_block", (-95.0, 48.0, 20.25), 17.0, 12.0, 24.5, color_name="brushed_aluminum", fillet_radius=1.0),
],
"brushed_aluminum",
)
return plate, supports
def countersunk_display_base() -> Shape:
with BuildPart() as part:
with BuildSketch():
RectangleRounded(170.0, 110.0, 6.0)
extrude(amount=8.0)
base = part.part.moved(Location((BASE_CENTER_X, 0.0, 0.0)))
hole_positions = [
(BASE_CENTER_X - 70.0, -43.0),
(BASE_CENTER_X + 70.0, -43.0),
(BASE_CENTER_X - 70.0, 43.0),
(BASE_CENTER_X + 70.0, 43.0),
]
for x, y in hole_positions:
base = base - Cylinder(3.2, 12.0).moved(Location((x, y, 4.0)))
base = base - Cone(3.2, 6.4, 5.0).moved(Location((x, y, 6.3)))
base = softened(base, 1.1)
return style(base, "display_base_rounded_countersunk", "brushed_aluminum")
def fasteners() -> Compound:
parts: list[Shape] = []
for index in range(6):
theta = (2.0 * math.pi * index) / 6.0
center = (math.cos(theta) * 22.0, -28.5, 80.0 + math.sin(theta) * 22.0)
parts.append(style(softened(cylinder_y(f"hub_bolt_{index + 1:02d}", center, 2.6, 5.0, "fastener_steel"), 0.35), f"hub_bolt_{index + 1:02d}", "fastener_steel"))
caliper_bolt_centers = [
v_add(WHEEL_CENTER, (23.0, 17.0, 44.0)),
v_add(WHEEL_CENTER, (43.0, 17.0, 24.0)),
]
for index, center in enumerate(caliper_bolt_centers, start=1):
parts.append(style(softened(cylinder_y(f"caliper_socket_bolt_{index}", center, 2.2, 4.0, "fastener_steel"), 0.3), f"caliper_socket_bolt_{index}", "fastener_steel"))
for index, (y, z) in enumerate(
[
(-16.0, 118.0),
(52.0, 118.0),
(-20.0, 45.0),
(52.0, 45.0),
(-8.0, 34.0),
(48.0, 34.0),
(-8.0, 126.0),
(48.0, 126.0),
],
start=1,
):
parts.append(style(softened(cylinder_x(f"bulkhead_bolt_head_{index:02d}", (-89.6, y, z), 2.8, 3.2, "fastener_steel"), 0.25), f"bulkhead_bolt_head_{index:02d}", "fastener_steel"))
return group("fasteners", parts, "fastener_steel")
def gen_step():
upper_arm, lower_arm = control_arms()
chassis_plate, support_blocks = chassis_bulkhead()
hub, axle = hub_and_axle()
children: list[Shape] = [
countersunk_display_base(),
support_blocks,
wheel_lattice(),
tread_elements(),
hub,
axle,
brake_disc(),
brake_caliper(),
upright(),
upper_arm,
lower_arm,
coilover_shock(),
chassis_plate,
fasteners(),
]
return group("lunar_rover_wheel_suspension_corner", children)
@@ -0,0 +1,546 @@
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
@@ -0,0 +1,189 @@
from math import cos, radians, sin
from build123d import (
Align,
BuildLine,
BuildPart,
BuildSketch,
CenterArc,
Circle,
Color,
Compound,
Cylinder,
Edge,
Line,
Location,
Plane,
extrude,
make_face,
sweep,
)
# Units: millimeters.
# Origin: centered on the staircase axis; base/column bottom starts at Z=0.
# XY: plan view. +Z: staircase rise direction.
BASE_DIAMETER = 90.0
BASE_THICKNESS = 5.0
COLUMN_DIAMETER = 14.0
COLUMN_HEIGHT = 140.0
TREAD_COUNT = 20
TREAD_THICKNESS = 4.0
TREAD_INNER_RADIUS = 10.0
TREAD_OUTER_RADIUS = 62.0
TREAD_SWEEP_DEGREES = 24.0
TREAD_FIRST_BOTTOM_Z = 4.0
TREAD_RISE = 6.0
TREAD_ROTATION_DEGREES = 18.0
HANDRAIL_RADIUS = 66.0
HANDRAIL_TUBE_DIAMETER = 5.0
HANDRAIL_START_Z = 14.0
HANDRAIL_END_Z = 130.0
BALUSTER_DIAMETER = 3.0
def _polar_point(radius: float, angle_degrees: float) -> tuple[float, float]:
angle_radians = radians(angle_degrees)
return (radius * cos(angle_radians), radius * sin(angle_radians))
def _make_base():
with BuildPart() as base:
Cylinder(
radius=BASE_DIAMETER / 2.0,
height=BASE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = base.part
part.label = "circular_base_disk_diameter_90mm_thickness_5mm"
part.color = Color(0.42, 0.43, 0.45, 1.0)
return part
def _make_column():
with BuildPart() as column:
Cylinder(
radius=COLUMN_DIAMETER / 2.0,
height=COLUMN_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = column.part
part.label = "central_column_diameter_14mm_height_140mm"
part.color = Color(0.70, 0.72, 0.74, 1.0)
return part
def _make_tread_profile():
half_sweep = TREAD_SWEEP_DEGREES / 2.0
start_angle = -half_sweep
end_angle = half_sweep
with BuildPart() as tread:
with BuildSketch(Plane.XY):
with BuildLine():
CenterArc((0.0, 0.0), TREAD_OUTER_RADIUS, start_angle, TREAD_SWEEP_DEGREES)
Line(
_polar_point(TREAD_OUTER_RADIUS, end_angle),
_polar_point(TREAD_INNER_RADIUS, end_angle),
)
CenterArc((0.0, 0.0), TREAD_INNER_RADIUS, end_angle, -TREAD_SWEEP_DEGREES)
Line(
_polar_point(TREAD_INNER_RADIUS, start_angle),
_polar_point(TREAD_OUTER_RADIUS, start_angle),
)
make_face()
extrude(amount=TREAD_THICKNESS)
part = tread.part
part.label = "tread_profile_inner_10mm_outer_62mm_sweep_24deg"
part.color = Color(0.78, 0.58, 0.35, 1.0)
return part
def _make_tread(index: int):
angle_degrees = index * TREAD_ROTATION_DEGREES
bottom_z = TREAD_FIRST_BOTTOM_Z + index * TREAD_RISE
tread_profile = _make_tread_profile()
tread = tread_profile.moved(Location((0.0, 0.0, bottom_z), (0.0, 0.0, angle_degrees)))
tread.label = f"tread_{index + 1:02d}_bottom_z_{bottom_z:.0f}mm_angle_{angle_degrees:.0f}deg"
return tread
def _handrail_center_z(angle_degrees: float) -> float:
rail_rise = HANDRAIL_END_Z - HANDRAIL_START_Z
return HANDRAIL_START_Z + rail_rise * angle_degrees / 360.0
def _make_handrail():
rail_height = HANDRAIL_END_Z - HANDRAIL_START_Z
path = Edge.make_helix(
pitch=rail_height,
height=rail_height,
radius=HANDRAIL_RADIUS,
center=(0.0, 0.0, HANDRAIL_START_Z),
)
start_tangent = path.tangent_at(0.0)
profile_plane = Plane(
origin=(HANDRAIL_RADIUS, 0.0, HANDRAIL_START_Z),
x_dir=(1.0, 0.0, 0.0),
z_dir=start_tangent,
)
with BuildPart() as rail:
with BuildSketch(profile_plane):
Circle(HANDRAIL_TUBE_DIAMETER / 2.0)
sweep(path=path, is_frenet=True)
part = rail.part
part.label = "helical_outer_handrail_radius_66mm_diameter_5mm"
part.color = Color(0.22, 0.28, 0.34, 1.0)
return part
def _make_baluster(index: int):
angle_degrees = index * TREAD_ROTATION_DEGREES
tread_top_z = TREAD_FIRST_BOTTOM_Z + index * TREAD_RISE + TREAD_THICKNESS
rail_center_z = _handrail_center_z(angle_degrees)
x_pos, y_pos = _polar_point(TREAD_OUTER_RADIUS, angle_degrees)
height = rail_center_z - tread_top_z
with BuildPart() as baluster:
Cylinder(
radius=BALUSTER_DIAMETER / 2.0,
height=height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = baluster.part.moved(Location((x_pos, y_pos, tread_top_z)))
part.label = f"baluster_{index + 1:02d}_diameter_3mm"
part.color = Color(0.30, 0.33, 0.36, 1.0)
return part
def gen_step():
"""Return a labeled miniature spiral staircase STEP compound in millimeters."""
parts = [
_make_base(),
_make_column(),
]
for index in range(TREAD_COUNT):
parts.append(_make_tread(index))
parts.append(_make_handrail())
for index in range(TREAD_COUNT):
parts.append(_make_baluster(index))
assembly = Compound(
obj=parts,
children=parts,
label="miniature_spiral_staircase",
)
return assembly
@@ -0,0 +1,338 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from build123d import (
Box,
Color,
Compound,
Cone,
Cylinder,
Location,
Shape,
Sphere,
Torus,
chamfer,
fillet,
scale,
)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf
DISPLAY_NAME = "Pelican riding a bicycle"
# Units: millimeters.
# Origin: halfway between the wheel hubs on the bicycle center plane.
# X: rear-to-front bicycle direction, Y: wheel axle direction, +Z: up.
COLORS = {
"rubber_black": (0.003, 0.003, 0.003, 1.0),
"rim_silver": (0.70, 0.72, 0.70, 1.0),
"spoke_steel": (0.82, 0.84, 0.82, 1.0),
"frame_blue": (0.05, 0.28, 0.75, 1.0),
"seat_brown": (0.28, 0.12, 0.05, 1.0),
"pelican_white": (0.96, 0.95, 0.88, 1.0),
"wing_gray": (0.66, 0.68, 0.64, 1.0),
"feather_dark": (0.08, 0.08, 0.075, 1.0),
"bill_yellow": (1.00, 0.78, 0.26, 1.0),
"pouch_orange": (0.95, 0.45, 0.12, 1.0),
"leg_orange": (0.98, 0.50, 0.12, 1.0),
"eye_black": (0.0, 0.0, 0.0, 1.0),
}
Vector3 = tuple[float, float, float]
def _v_add(a: Vector3, b: Vector3) -> Vector3:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def _v_sub(a: Vector3, b: Vector3) -> Vector3:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _v_mul(a: Vector3, scalar: float) -> Vector3:
return (a[0] * scalar, a[1] * scalar, a[2] * scalar)
def _v_dot(a: Vector3, b: Vector3) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _v_cross(a: Vector3, b: Vector3) -> Vector3:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def _v_len(a: Vector3) -> float:
return math.sqrt(_v_dot(a, a))
def _v_norm(a: Vector3) -> Vector3:
length = _v_len(a)
if length <= 1e-9:
raise ValueError("zero-length vector")
return (a[0] / length, a[1] / length, a[2] / length)
def _style(shape: Shape, label: str, color_name: str) -> Shape:
shape.label = label
shape.color = Color(*COLORS[color_name])
return shape
def _group(label: str, children: Sequence[Shape], color_name: str | None = None) -> Compound:
compound = Compound(obj=list(children), children=list(children), label=label)
if color_name is not None:
compound.color = Color(*COLORS[color_name])
return compound
def _softened(shape: Shape, radius: float) -> Shape:
try:
return fillet(shape.edges(), radius=radius)
except Exception:
try:
return chamfer(shape.edges(), length=radius * 0.45)
except Exception:
return shape
def _transformed(shape: Shape, center: Vector3, axis_x: Vector3, axis_y: Vector3, axis_z: Vector3) -> Shape:
x_dir = _v_norm(axis_x)
y_dir = _v_norm(axis_y)
z_dir = _v_norm(axis_z)
trsf = gp_Trsf()
trsf.SetValues(
x_dir[0],
y_dir[0],
z_dir[0],
center[0],
x_dir[1],
y_dir[1],
z_dir[1],
center[1],
x_dir[2],
y_dir[2],
z_dir[2],
center[2],
)
return Shape(obj=BRepBuilderAPI_Transform(shape.wrapped, trsf, True).Shape())
def _oriented_box(
label: str,
center: Vector3,
size_x: float,
size_y: float,
size_z: float,
axis_x: Vector3,
axis_y: Vector3,
axis_z: Vector3,
color_name: str,
*,
radius: float = 0.0,
) -> Shape:
shape = _transformed(Box(size_x, size_y, size_z), center, axis_x, axis_y, axis_z)
if radius > 0.0:
shape = _softened(shape, radius)
return _style(shape, label, color_name)
def _cylinder_between(label: str, p1: Vector3, p2: Vector3, radius: float, color_name: str) -> Shape:
axis = _v_sub(p2, p1)
length = _v_len(axis)
if length <= 1e-6:
return _style(Sphere(radius).moved(Location(p1)), label, color_name)
direction = _v_norm(axis)
wrapped = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(*p1), gp_Dir(*direction)),
radius,
length,
).Shape()
return _style(Shape(obj=wrapped), label, color_name)
def _cylinder_y(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return _style(Cylinder(radius, length, rotation=(90, 0, 0)).moved(Location(center)), label, color_name)
def _cylinder_x(label: str, center: Vector3, radius: float, length: float, color_name: str) -> Shape:
return _style(Cylinder(radius, length, rotation=(0, 90, 0)).moved(Location(center)), label, color_name)
def _ellipsoid(label: str, center: Vector3, radii: Vector3, color_name: str) -> Shape:
shape = scale(Sphere(1.0), by=radii).moved(Location(center))
return _style(shape, label, color_name)
def _flat_cone_x(
label: str,
center: Vector3,
length: float,
base_radius: float,
tip_radius: float,
y_scale: float,
z_scale: float,
color_name: str,
) -> Shape:
cone = Cone(base_radius, tip_radius, length, rotation=(0, 90, 0))
shape = scale(cone, by=(1.0, y_scale, z_scale)).moved(Location(center))
return _style(shape, label, color_name)
def _make_wheel(label: str, center: Vector3) -> Compound:
cx, cy, cz = center
tire_major_radius = 40.0
tire_minor_radius = 4.0
rim_radius = 31.0
spoke_radius = 0.72
hub_radius = 4.2
spoke_count = 20
parts: list[Shape] = [
_style(
Torus(tire_major_radius, tire_minor_radius, rotation=(90, 0, 0)).moved(Location(center)),
f"{label}_continuous_black_tire",
"rubber_black",
),
_style(
Torus(rim_radius, 1.45, rotation=(90, 0, 0)).moved(Location(center)),
f"{label}_silver_rim",
"rim_silver",
),
_cylinder_y(f"{label}_hub_barrel", center, hub_radius, 14.0, "rim_silver"),
_cylinder_y(f"{label}_hub_axle_pin", center, 1.8, 24.0, "spoke_steel"),
]
for index in range(spoke_count):
theta = index * math.tau / spoke_count
rim_point = (cx + math.cos(theta) * (rim_radius - 1.8), cy, cz + math.sin(theta) * (rim_radius - 1.8))
opposite_offset = -0.9 if index % 2 == 0 else 0.9
hub_point = (cx, cy + opposite_offset, cz)
parts.append(_cylinder_between(f"{label}_radial_spoke_{index + 1:02d}", hub_point, rim_point, spoke_radius, "spoke_steel"))
return _group(label, parts)
def _make_bicycle() -> Compound:
rear = (-55.0, 0.0, 44.0)
front = (55.0, 0.0, 44.0)
bottom = (0.0, 0.0, 49.0)
seat_lug = (-15.0, 0.0, 86.0)
head_low = (43.0, 0.0, 66.0)
head_high = (38.0, 0.0, 88.0)
handle_stem = (57.0, 0.0, 99.0)
parts: list[Shape] = [
_make_wheel("rear_wheel", rear),
_make_wheel("front_wheel", front),
_cylinder_between("frame_seat_tube", bottom, seat_lug, 2.3, "frame_blue"),
_cylinder_between("frame_top_tube", seat_lug, head_high, 2.2, "frame_blue"),
_cylinder_between("frame_down_tube", bottom, head_low, 2.5, "frame_blue"),
_cylinder_between("frame_head_tube", head_low, head_high, 2.6, "frame_blue"),
_cylinder_between("frame_left_chain_stay", (bottom[0], -4.2, bottom[2]), (rear[0], -4.2, rear[2]), 1.7, "frame_blue"),
_cylinder_between("frame_right_chain_stay", (bottom[0], 4.2, bottom[2]), (rear[0], 4.2, rear[2]), 1.7, "frame_blue"),
_cylinder_between("frame_left_seat_stay", (seat_lug[0], -3.8, seat_lug[2]), (rear[0], -3.8, rear[2]), 1.55, "frame_blue"),
_cylinder_between("frame_right_seat_stay", (seat_lug[0], 3.8, seat_lug[2]), (rear[0], 3.8, rear[2]), 1.55, "frame_blue"),
_cylinder_between("front_left_fork_blade", (head_low[0], -4.0, head_low[2]), (front[0], -4.0, front[2]), 1.7, "frame_blue"),
_cylinder_between("front_right_fork_blade", (head_low[0], 4.0, head_low[2]), (front[0], 4.0, front[2]), 1.7, "frame_blue"),
_cylinder_between("handlebar_stem", head_high, handle_stem, 1.8, "frame_blue"),
_cylinder_y("handlebar_crossbar", (64.0, 0.0, 101.0), 1.6, 38.0, "frame_blue"),
_cylinder_y("left_black_grip", (64.0, -22.5, 101.0), 2.2, 8.0, "rubber_black"),
_cylinder_y("right_black_grip", (64.0, 22.5, 101.0), 2.2, 8.0, "rubber_black"),
_cylinder_between("seat_post", seat_lug, (-17.0, 0.0, 95.0), 1.75, "spoke_steel"),
_style(_softened(Box(24.0, 17.0, 5.0).moved(Location((-20.0, 0.0, 97.0))), 1.2), "brown_saddle", "seat_brown"),
_style(Torus(7.5, 0.9, rotation=(90, 0, 0)).moved(Location(bottom)), "crank_ring", "spoke_steel"),
_cylinder_y("bottom_bracket_axle", bottom, 3.3, 20.0, "rim_silver"),
]
pedal_front = (12.5, -11.0, 38.5)
pedal_back = (-12.5, 11.0, 60.5)
parts.extend(
[
_cylinder_between("front_crank_arm", bottom, pedal_front, 1.0, "spoke_steel"),
_cylinder_between("rear_crank_arm", bottom, pedal_back, 1.0, "spoke_steel"),
_style(_softened(Box(15.0, 4.0, 2.5).moved(Location(pedal_front)), 0.7), "front_pedal_flat", "rubber_black"),
_style(_softened(Box(15.0, 4.0, 2.5).moved(Location(pedal_back)), 0.7), "rear_pedal_flat", "rubber_black"),
]
)
return _group("bicycle", parts, "frame_blue")
def _make_pelican() -> Compound:
parts: list[Shape] = [
_ellipsoid("pelican_round_body", (-12.0, 0.0, 120.0), (24.0, 15.0, 29.0), "pelican_white"),
_ellipsoid("pelican_chest_belly_highlight", (1.0, 0.0, 112.0), (18.0, 13.0, 20.0), "pelican_white"),
_ellipsoid("left_folded_wing", (-22.0, -13.0, 120.0), (19.0, 3.2, 25.0), "wing_gray"),
_ellipsoid("right_folded_wing", (-22.0, 13.0, 120.0), (19.0, 3.2, 25.0), "wing_gray"),
_ellipsoid("left_dark_wing_tip", (-33.0, -15.7, 104.0), (12.0, 2.2, 10.0), "feather_dark"),
_ellipsoid("right_dark_wing_tip", (-33.0, 15.7, 104.0), (12.0, 2.2, 10.0), "feather_dark"),
_cylinder_between("curved_neck_lower_segment", (-1.0, 0.0, 139.0), (12.0, 0.0, 151.0), 5.0, "pelican_white"),
_cylinder_between("curved_neck_upper_segment", (12.0, 0.0, 151.0), (24.0, 0.0, 157.0), 4.4, "pelican_white"),
_ellipsoid("pelican_head", (28.0, 0.0, 160.0), (10.5, 8.1, 9.5), "pelican_white"),
_flat_cone_x("long_upper_bill", (52.0, 0.0, 160.5), 48.0, 5.2, 1.1, 1.45, 0.55, "bill_yellow"),
_ellipsoid("rounded_lower_bill_pouch", (49.0, 0.0, 151.5), (23.0, 7.0, 7.2), "pouch_orange"),
_cylinder_between("dark_bill_seam_left", (31.0, -7.3, 156.2), (70.0, -2.7, 157.0), 0.45, "feather_dark"),
_cylinder_between("dark_bill_seam_right", (31.0, 7.3, 156.2), (70.0, 2.7, 157.0), 0.45, "feather_dark"),
_ellipsoid("left_eye", (31.5, -7.4, 162.8), (1.45, 0.75, 1.45), "eye_black"),
_ellipsoid("right_eye", (31.5, 7.4, 162.8), (1.45, 0.75, 1.45), "eye_black"),
_ellipsoid("soft_head_crest", (19.0, 0.0, 168.0), (4.0, 4.0, 5.0), "pelican_white"),
]
tail_roots = [(-34.0, -4.5, 108.0), (-38.0, 0.0, 110.0), (-34.0, 4.5, 108.0)]
for index, center in enumerate(tail_roots, start=1):
parts.append(_ellipsoid(f"short_tail_feather_{index}", center, (11.0, 2.0, 5.2), "feather_dark"))
# Bent orange legs and webbed feet make the bird visibly seated on the cycle.
left_hip = (-8.0, -5.7, 96.0)
left_knee = (2.5, -7.5, 74.0)
left_pedal = (12.5, -11.0, 38.5)
right_hip = (-13.0, 5.7, 96.0)
right_knee = (-19.0, 8.4, 74.0)
right_pedal = (-12.5, 11.0, 60.5)
parts.extend(
[
_cylinder_between("left_upper_leg", left_hip, left_knee, 1.8, "leg_orange"),
_cylinder_between("left_lower_leg", left_knee, left_pedal, 1.6, "leg_orange"),
_cylinder_between("right_upper_leg", right_hip, right_knee, 1.8, "leg_orange"),
_cylinder_between("right_lower_leg", right_knee, right_pedal, 1.6, "leg_orange"),
_ellipsoid("left_webbed_foot_on_forward_pedal", _v_add(left_pedal, (1.5, 0.0, 2.0)), (8.5, 3.5, 2.2), "leg_orange"),
_ellipsoid("right_webbed_foot_on_rear_pedal", _v_add(right_pedal, (-1.5, 0.0, 2.0)), (8.5, 3.5, 2.2), "leg_orange"),
]
)
feather_line_specs = [
("left_wing_feather_line_1", (-17.0, -16.2, 136.0), (-33.0, -16.2, 112.0)),
("left_wing_feather_line_2", (-11.0, -16.4, 126.0), (-34.0, -16.4, 101.0)),
("right_wing_feather_line_1", (-17.0, 16.2, 136.0), (-33.0, 16.2, 112.0)),
("right_wing_feather_line_2", (-11.0, 16.4, 126.0), (-34.0, 16.4, 101.0)),
]
for label, p1, p2 in feather_line_specs:
parts.append(_cylinder_between(label, p1, p2, 0.55, "feather_dark"))
return _group("pelican", parts, "pelican_white")
def _make_ground_shadow() -> Compound:
# Thin reference pads give the model a stable visual base without merging
# into the wheels.
front_pad = _style(_softened(Box(92.0, 28.0, 1.0).moved(Location((55.0, 0.0, -2.0))), 1.0), "front_wheel_ground_shadow", "rubber_black")
rear_pad = _style(_softened(Box(92.0, 28.0, 1.0).moved(Location((-55.0, 0.0, -2.0))), 1.0), "rear_wheel_ground_shadow", "rubber_black")
return _group("subtle_ground_shadow", [front_pad, rear_pad])
def gen_step() -> Compound:
bicycle = _make_bicycle()
pelican = _make_pelican()
ground = _make_ground_shadow()
assembly = _group("pelican_riding_bicycle", [bicycle, pelican, ground])
assembly.label = "pelican_riding_bicycle"
return assembly
+537
View File
@@ -0,0 +1,537 @@
from __future__ import annotations
from math import degrees, cos, sin, sqrt
from build123d import (
Align,
BuildPart,
BuildSketch,
Color,
Compound,
Cone,
Cylinder,
Location,
Locations,
Mode,
Plane,
Rectangle,
RectangleRounded,
SlotOverall,
Solid,
Text,
TextAlign,
Torus,
extrude,
)
# Units: millimeters.
# Origin: center of the cup base footprint, XY on table plane, +Z upward.
MODEL_LABEL = "photo_reference_takeaway_coffee_cup"
PAPER = Color(0.86, 0.78, 0.64, 1.0)
PAPER_EDGE = Color(0.90, 0.82, 0.69, 1.0)
LID_WHITE = Color(0.96, 0.94, 0.90, 1.0)
LID_HIGHLIGHT = Color(1.0, 0.985, 0.955, 1.0)
LID_SHADOW = Color(0.82, 0.80, 0.76, 1.0)
BLUE_LOGO = Color(0.00, 0.62, 0.78, 1.0)
DARK_OPENING = Color(0.08, 0.075, 0.065, 1.0)
BODY_HEIGHT = 108.0
BODY_BOTTOM_RADIUS = 30.0
BODY_TOP_RADIUS = 43.0
BODY_WALL = 2.2
BODY_BOTTOM_THICKNESS = 2.8
LID_SKIRT_BOTTOM_Z = 103.5
LID_TOP_Z = 127.0
LOGO_LOCAL_BOTTOM_Z = -2.0
LOGO_LOCAL_TOP_Z = 86.4
LOGO_WORLD_BOTTOM_Z = 34.0
LOGO_Z_SCALE = 0.56
LOGO_X_SCALE = 0.70
LOGO_WORLD_TOP_Z = LOGO_WORLD_BOTTOM_Z + (LOGO_LOCAL_TOP_Z - LOGO_LOCAL_BOTTOM_Z) * LOGO_Z_SCALE
def _set_metadata(part, label: str, color: Color):
part.label = label
part.color = color
return part
def _radius_at_z(z_pos: float) -> float:
slope = (BODY_TOP_RADIUS - BODY_BOTTOM_RADIUS) / BODY_HEIGHT
return BODY_BOTTOM_RADIUS + slope * z_pos
def _annular_cylinder(
*,
label: str,
outer_radius: float,
inner_radius: float,
height: float,
z_bottom: float,
color: Color,
) -> object:
with BuildPart() as ring:
with Locations(Location((0.0, 0.0, z_bottom))):
Cylinder(
radius=outer_radius,
height=height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, z_bottom - 0.75))):
Cylinder(
radius=inner_radius,
height=height + 1.5,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
return _set_metadata(ring.part, label, color)
def _solid_cylinder(
*,
label: str,
radius: float,
height: float,
z_bottom: float,
color: Color,
) -> object:
with BuildPart() as cylinder:
with Locations(Location((0.0, 0.0, z_bottom))):
Cylinder(
radius=radius,
height=height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
return _set_metadata(cylinder.part, label, color)
def _make_paper_cup_body() -> object:
extra_cut_height = 4.0
slope = (BODY_TOP_RADIUS - BODY_BOTTOM_RADIUS) / BODY_HEIGHT
inner_bottom_radius = _radius_at_z(BODY_BOTTOM_THICKNESS) - BODY_WALL
inner_top_radius = BODY_TOP_RADIUS - BODY_WALL + slope * extra_cut_height
with BuildPart() as cup:
Cone(
bottom_radius=BODY_BOTTOM_RADIUS,
top_radius=BODY_TOP_RADIUS,
height=BODY_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, BODY_BOTTOM_THICKNESS))):
Cone(
bottom_radius=inner_bottom_radius,
top_radius=inner_top_radius,
height=BODY_HEIGHT - BODY_BOTTOM_THICKNESS + extra_cut_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
return _set_metadata(
cup.part,
"tapered_beige_paper_cup_open_shell_12oz",
PAPER,
)
def _make_bottom_foot_ring() -> object:
with BuildPart() as foot:
with Locations(Location((0.0, 0.0, 3.2))):
Torus(major_radius=BODY_BOTTOM_RADIUS - 2.0, minor_radius=1.25)
return _set_metadata(foot.part, "subtle_rolled_bottom_paper_foot_ring", PAPER_EDGE)
def _make_lid_skirt_parts() -> list[object]:
parts: list[object] = []
parts.append(
_annular_cylinder(
label="white_plastic_lid_lower_snap_skirt_overhanging_cup",
outer_radius=47.8,
inner_radius=38.8,
height=8.0,
z_bottom=LID_SKIRT_BOTTOM_Z,
color=LID_WHITE,
)
)
parts.append(
_annular_cylinder(
label="thin_shadow_groove_between_lid_beads",
outer_radius=46.4,
inner_radius=42.3,
height=1.0,
z_bottom=112.5,
color=LID_SHADOW,
)
)
for label, z_pos, major_radius, minor_radius in [
("rounded_lower_snap_lip_bead", 108.8, 45.9, 2.1),
("rounded_middle_lid_bead", 113.6, 45.2, 1.55),
("rounded_upper_lid_bead_under_dome", 117.2, 43.4, 1.25),
]:
with BuildPart() as bead:
with Locations(Location((0.0, 0.0, z_pos))):
Torus(major_radius=major_radius, minor_radius=minor_radius)
parts.append(_set_metadata(bead.part, label, LID_WHITE))
return parts
def _make_lid_top() -> list[object]:
parts: list[object] = []
with BuildPart() as dome:
with Locations(Location((0.0, 0.0, 115.5))):
Cone(
bottom_radius=42.8,
top_radius=39.0,
height=9.4,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
parts.append(_set_metadata(dome.part, "shallow_sloped_lid_dome", LID_WHITE))
parts.append(
_solid_cylinder(
label="flat_raised_top_lid_disk",
radius=38.4,
height=2.1,
z_bottom=124.6,
color=LID_HIGHLIGHT,
)
)
with BuildPart() as top_outer_ring:
with Locations(Location((0.0, 0.0, 126.9))):
Torus(major_radius=36.4, minor_radius=0.85)
parts.append(_set_metadata(top_outer_ring.part, "fine_raised_outer_top_emboss_ring", LID_HIGHLIGHT))
with BuildPart() as top_inner_ring:
with Locations(Location((0.0, 0.0, 127.15))):
Torus(major_radius=18.0, minor_radius=0.38)
parts.append(_set_metadata(top_inner_ring.part, "faint_central_emboss_circle", LID_HIGHLIGHT))
return parts
def _make_sip_feature() -> list[object]:
parts: list[object] = []
top_plane = Plane.XY.offset(LID_TOP_Z)
with BuildPart() as raised_sip:
with BuildSketch(top_plane):
with Locations((11.0, 18.4)):
SlotOverall(width=31.0, height=12.2, rotation=-9.0)
extrude(amount=1.2)
parts.append(_set_metadata(raised_sip.part, "raised_oval_sipping_platform_on_lid", LID_HIGHLIGHT))
with BuildPart() as sip_opening:
with BuildSketch(Plane.XY.offset(LID_TOP_Z + 1.22)):
with Locations((15.2, 20.0)):
SlotOverall(width=16.0, height=4.0, rotation=-9.0)
extrude(amount=0.45)
parts.append(_set_metadata(sip_opening.part, "dark_recessed_sip_slot_opening", DARK_OPENING))
with BuildPart() as nose_ridge:
with BuildSketch(Plane.XY.offset(LID_TOP_Z + 0.7)):
with Locations((0.5, 7.5)):
RectangleRounded(width=10.0, height=22.0, radius=2.0, rotation=-8.0)
extrude(amount=0.75)
parts.append(_set_metadata(nose_ridge.part, "low_centered_lid_drink_ridge", LID_HIGHLIGHT))
return parts
def _make_top_embossing() -> list[object]:
parts: list[object] = []
emboss_plane = Plane.XY.offset(LID_TOP_Z + 0.18)
with BuildPart() as caution_text:
with BuildSketch(emboss_plane):
with Locations((-2.5, -10.5)):
Text(
"CAUTION HOT",
font_size=4.0,
text_align=(TextAlign.CENTER, TextAlign.CENTER),
rotation=3.0,
)
with Locations((-4.0, 6.0)):
Text(
"PLEASE RECYCLE",
font_size=3.0,
text_align=(TextAlign.CENTER, TextAlign.CENTER),
rotation=-4.0,
)
extrude(amount=0.28)
parts.append(_set_metadata(caution_text.part, "low_raised_lid_embossed_warning_text", LID_HIGHLIGHT))
for index, (x_pos, y_pos, width, height, rotation) in enumerate(
[
(-18.0, 4.5, 10.0, 1.1, 13.0),
(18.0, 4.5, 10.0, 1.1, -13.0),
(-15.0, -18.0, 8.5, 1.0, -16.0),
(15.0, -18.0, 8.5, 1.0, 16.0),
],
start=1,
):
with BuildPart() as tick:
with BuildSketch(Plane.XY.offset(LID_TOP_Z + 0.16)):
with Locations((x_pos, y_pos)):
Rectangle(width=width, height=height, rotation=rotation)
extrude(amount=0.24)
parts.append(_set_metadata(tick.part, f"short_emboss_tick_mark_{index:02d}", LID_HIGHLIGHT))
return parts
def _make_blue_logo() -> list[object]:
bottle_cells: list[object] = []
z_bottom = LOGO_WORLD_BOTTOM_Z
z_top = LOGO_WORLD_TOP_Z
dz = 0.58
z_pos = z_bottom
cell_index = 1
while z_pos < z_top:
z_next = min(z_pos + dz, z_top)
for interval in _blue_bottle_intervals(z_pos, z_next):
cell = _make_curved_decal_cell(
label=f"blue_bottle_logo_curved_surface_cell_{cell_index:03d}",
x_left_bottom=interval[0],
x_right_bottom=interval[1],
z_bottom=z_pos,
x_left_top=interval[2],
x_right_top=interval[3],
z_top=z_next,
color=BLUE_LOGO,
)
if cell is not None:
bottle_cells.append(cell)
cell_index += 1
z_pos = z_next
mark_cells: list[object] = []
z_pos = 34.65
while z_pos < 37.35:
z_next = min(z_pos + 0.36, 37.35)
interval = _registered_mark_interval((z_pos + z_next) / 2.0)
if interval is not None:
cell = _make_curved_decal_cell(
label=f"blue_bottle_registered_mark_surface_cell_{len(mark_cells) + 1:02d}",
x_left_bottom=interval[0],
x_right_bottom=interval[1],
z_bottom=z_pos,
x_left_top=interval[0],
x_right_top=interval[1],
z_top=z_next,
color=BLUE_LOGO,
inner_offset=0.18,
outer_offset=0.72,
)
if cell is not None:
mark_cells.append(cell)
z_pos = z_next
logo = Compound(
obj=bottle_cells + mark_cells,
children=bottle_cells + mark_cells,
label="front_blue_bottle_logo_wrapped_on_cup_surface",
)
logo.color = BLUE_LOGO
return [logo]
def _blue_bottle_intervals(
z_bottom: float,
z_top: float,
) -> list[tuple[float, float, float, float]]:
intervals: list[tuple[float, float, float, float]] = []
bottom_intervals = _logo_outline_intervals_at(z_bottom + 1.0e-4)
top_intervals = _logo_outline_intervals_at(z_top - 1.0e-4)
for bottom_interval, top_interval in zip(bottom_intervals, top_intervals):
intervals.append((bottom_interval[0], bottom_interval[1], top_interval[0], top_interval[1]))
return intervals
def _logo_outline_intervals_at(z_pos: float) -> list[tuple[float, float]]:
local_z = (z_pos - LOGO_WORLD_BOTTOM_Z) / LOGO_Z_SCALE + LOGO_LOCAL_BOTTOM_Z
if local_z < LOGO_LOCAL_BOTTOM_Z or local_z > LOGO_LOCAL_TOP_Z:
return []
outline = [
(-4.6, 86.4),
(-3.5, 86.1),
(-2.1, 84.6),
(-0.1, 83.7),
(3.0, 83.2),
(7.4, 83.1),
(8.7, 83.4),
(8.3, 82.2),
(5.9, 81.4),
(1.8, 81.2),
(-2.7, 81.7),
(-5.8, 82.0),
(-6.7, 83.0),
(-6.8, 84.8),
(-6.2, 85.9),
(-3.4, 80.7),
(-3.4, 73.5),
(-3.7, 68.2),
(-4.7, 64.3),
(-6.3, 61.1),
(-8.6, 58.7),
(-11.4, 56.8),
(-13.4, 54.4),
(-14.5, 50.8),
(-15.1, 45.2),
(-15.4, 37.0),
(-15.2, 28.4),
(-14.5, 20.0),
(-13.5, 11.9),
(-12.1, 4.1),
(-10.7, 0.7),
(-8.6, -0.8),
(-4.6, -1.7),
(-0.5, -2.0),
(4.0, -1.8),
(8.1, -0.9),
(10.7, 0.8),
(12.1, 4.5),
(13.5, 11.2),
(14.7, 19.4),
(15.4, 28.1),
(15.7, 37.2),
(15.5, 45.9),
(14.6, 52.0),
(13.0, 56.3),
(10.7, 59.8),
(8.0, 62.2),
(5.4, 63.7),
(2.8, 64.8),
(1.1, 66.5),
(0.3, 69.8),
(0.2, 74.9),
(0.2, 80.4),
]
xs: list[float] = []
for index, (x1, z1) in enumerate(outline):
x2, z2 = outline[(index + 1) % len(outline)]
if z1 == z2:
continue
if (z1 <= local_z < z2) or (z2 <= local_z < z1):
t = (local_z - z1) / (z2 - z1)
xs.append(x1 + (x2 - x1) * t)
xs.sort()
return [
(xs[index] * LOGO_X_SCALE, xs[index + 1] * LOGO_X_SCALE)
for index in range(0, len(xs) - 1, 2)
]
def _registered_mark_interval(z_pos: float) -> tuple[float, float] | None:
center_x = 14.2
center_z = 36.0
radius = 1.25
dy = z_pos - center_z
if abs(dy) > radius:
return None
half_width = radius * sqrt(max(0.0, 1.0 - dy * dy / (radius * radius)))
return (center_x - half_width, center_x + half_width)
def _cup_surface_point(arc_x: float, z_pos: float, radial_offset: float) -> tuple[float, float, float]:
radius = _radius_at_z(z_pos) + radial_offset
angle = arc_x / radius
return (radius * sin(angle), -radius * cos(angle), z_pos)
def _make_curved_decal_cell(
*,
label: str,
x_left_bottom: float,
x_right_bottom: float,
z_bottom: float,
x_left_top: float,
x_right_top: float,
z_top: float,
color: Color,
inner_offset: float = 0.16,
outer_offset: float = 0.78,
) -> object | None:
x_left = (x_left_bottom + x_left_top) / 2.0
x_right = (x_right_bottom + x_right_top) / 2.0
width = x_right - x_left
height = z_top - z_bottom
if width < 0.18 or height <= 0.0:
return None
z_center = (z_bottom + z_top) / 2.0
thickness = outer_offset - inner_offset
max_segment_width = 3.2
segment_count = max(1, int(width / max_segment_width + 0.999))
segment_parts = []
for segment_index in range(segment_count):
segment_left = x_left + width * segment_index / segment_count
segment_right = x_left + width * (segment_index + 1) / segment_count
segment_width = segment_right - segment_left
x_center = (segment_left + segment_right) / 2.0
segment_parts.append(
_make_surface_decal_box(
label=f"{label}_segment_{segment_index + 1:02d}",
x_center=x_center,
z_center=z_center,
width=segment_width + 0.04,
height=height + 0.035,
thickness=thickness,
radial_offset=inner_offset + thickness / 2.0,
color=color,
)
)
if len(segment_parts) == 1:
return segment_parts[0]
compound = Compound(obj=segment_parts, children=segment_parts, label=label)
compound.color = color
return compound
def _make_surface_decal_box(
*,
label: str,
x_center: float,
z_center: float,
width: float,
height: float,
thickness: float,
radial_offset: float,
color: Color,
) -> object:
radius = _radius_at_z(z_center) + radial_offset
angle = x_center / radius
center = _cup_surface_point(x_center, z_center, radial_offset)
box = Solid.make_box(width, thickness, height)
box = box.moved(Location((-width / 2.0, -thickness / 2.0, -height / 2.0)))
box = box.moved(Location(center, (0.0, 0.0, degrees(angle))))
return _set_metadata(box, label, color)
def gen_step():
"""Return a photo-inspired takeaway coffee cup model in millimeters."""
parts: list[object] = [
_make_paper_cup_body(),
_make_bottom_foot_ring(),
]
parts.extend(_make_lid_skirt_parts())
parts.extend(_make_lid_top())
parts.extend(_make_sip_feature())
parts.extend(_make_top_embossing())
parts.extend(_make_blue_logo())
return Compound(obj=parts, children=parts, label=MODEL_LABEL)
@@ -0,0 +1,282 @@
from math import cos, pi, sin, tau
from build123d import (
Align,
BuildPart,
BuildSketch,
Color,
Compound,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
extrude,
)
# Units: millimeters.
# Origin: sun/ring center on the carrier axis.
# XY: gear plane. +Z: gear and pin axes.
GEAR_THICKNESS = 8.0
GEAR_BOTTOM_Z = 0.0
SUN_TEETH = 24
SUN_PITCH_DIAMETER = 48.0
SUN_ROOT_DIAMETER = 42.0
SUN_OUTSIDE_DIAMETER = 54.0
SUN_BORE_DIAMETER = 10.0
PLANET_TEETH = 18
PLANET_PITCH_DIAMETER = 36.0
PLANET_ROOT_DIAMETER = 31.0
PLANET_OUTSIDE_DIAMETER = 41.0
PLANET_CENTER_RADIUS = 42.0
PLANET_COUNT = 3
PLANET_BORE_DIAMETER = 6.8
EXTERNAL_TOOTH_ROOT_SPAN_FRACTION = 0.42
EXTERNAL_TOOTH_TIP_SPAN_FRACTION = 0.18
def _srgb_channel_to_linear(channel: int) -> float:
value = channel / 255.0
if value <= 0.04045:
return value / 12.92
return ((value + 0.055) / 1.055) ** 2.4
def _srgb_color(hex_color: str) -> Color:
value = hex_color.removeprefix("#")
if len(value) != 6:
raise ValueError(f"Expected #rrggbb color, got {hex_color!r}")
return Color(
_srgb_channel_to_linear(int(value[0:2], 16)),
_srgb_channel_to_linear(int(value[2:4], 16)),
_srgb_channel_to_linear(int(value[4:6], 16)),
1.0,
)
# Match the docs app's dark-mode hero render source colors.
PLANET_COLORS = (
_srgb_color("#61d4f6"),
_srgb_color("#a6ea90"),
_srgb_color("#ed9ee5"),
)
RING_TEETH = 60
RING_INTERNAL_PITCH_DIAMETER = 120.0
RING_INTERNAL_ROOT_DIAMETER = 126.0
RING_INTERNAL_TIP_DIAMETER = 115.0
RING_OUTSIDE_DIAMETER = 140.0
RING_TOOTH_ROOT_SPAN_FRACTION = 0.68
RING_TOOTH_TIP_SPAN_FRACTION = 0.34
RING_COLOR = _srgb_color("#c5adef")
SUN_COLOR = _srgb_color("#fdce76")
CARRIER_COLOR = _srgb_color("#bfcec8")
PIN_COLOR = _srgb_color("#818993")
CARRIER_DIAMETER = 105.0
CARRIER_BOTTOM_Z = -5.0
CARRIER_TOP_Z = -1.0
CARRIER_THICKNESS = CARRIER_TOP_Z - CARRIER_BOTTOM_Z
PIN_DIAMETER = 6.0
PIN_HEIGHT = 14.0
PIN_BOTTOM_Z = -5.0
PIN_CARRIER_CLEARANCE_DIAMETER = 6.4
def _polar_point(radius: float, angle: float) -> tuple[float, float]:
return (radius * cos(angle), radius * sin(angle))
def _trapezoid_tooth_profile(
*,
teeth: int,
root_radius: float,
tip_radius: float,
phase: float,
root_span_fraction: float = 0.72,
tip_span_fraction: float = 0.38,
) -> list[tuple[float, float]]:
"""Return a closed-profile point loop with straight-sided schematic teeth."""
pitch_angle = tau / teeth
points: list[tuple[float, float]] = []
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 _make_external_gear(
*,
label: str,
teeth: int,
root_diameter: float,
outside_diameter: float,
phase: float,
center: tuple[float, float] = (0.0, 0.0),
bore_diameter: float | None = None,
color: Color | None = None,
):
with BuildPart() as gear:
with BuildSketch(Plane.XY):
Polygon(
_trapezoid_tooth_profile(
teeth=teeth,
root_radius=root_diameter / 2.0,
tip_radius=outside_diameter / 2.0,
phase=phase,
root_span_fraction=EXTERNAL_TOOTH_ROOT_SPAN_FRACTION,
tip_span_fraction=EXTERNAL_TOOTH_TIP_SPAN_FRACTION,
),
align=None,
)
extrude(amount=GEAR_THICKNESS)
if bore_diameter is not None:
with Locations(Location((0.0, 0.0, -0.5))):
Cylinder(
radius=bore_diameter / 2.0,
height=GEAR_THICKNESS + 1.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = gear.part.moved(Location((center[0], center[1], GEAR_BOTTOM_Z)))
part.label = label
part.color = color
return part
def _make_internal_ring_gear(*, label: str, phase: float, color: Color | None = None):
with BuildPart() as ring:
Cylinder(
radius=RING_OUTSIDE_DIAMETER / 2.0,
height=GEAR_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with BuildSketch(Plane.XY):
Polygon(
_trapezoid_tooth_profile(
teeth=RING_TEETH,
root_radius=RING_INTERNAL_ROOT_DIAMETER / 2.0,
tip_radius=RING_INTERNAL_TIP_DIAMETER / 2.0,
phase=phase,
root_span_fraction=RING_TOOTH_ROOT_SPAN_FRACTION,
tip_span_fraction=RING_TOOTH_TIP_SPAN_FRACTION,
),
align=None,
)
extrude(amount=GEAR_THICKNESS, mode=Mode.SUBTRACT)
part = ring.part
part.label = label
part.color = color
return part
def _make_carrier_plate():
with BuildPart() as carrier:
with Locations(Location((0.0, 0.0, CARRIER_BOTTOM_Z))):
Cylinder(
radius=CARRIER_DIAMETER / 2.0,
height=CARRIER_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
for index in range(PLANET_COUNT):
center = _polar_point(PLANET_CENTER_RADIUS, tau * index / PLANET_COUNT)
with Locations(Location((center[0], center[1], CARRIER_BOTTOM_Z - 0.1))):
Cylinder(
radius=PIN_CARRIER_CLEARANCE_DIAMETER / 2.0,
height=CARRIER_THICKNESS + 0.2,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = carrier.part
part.label = "carrier_plate"
part.color = CARRIER_COLOR
return part
def _make_planet_pin(*, label: str, center: tuple[float, float]):
with BuildPart() as pin:
with Locations(Location((center[0], center[1], PIN_BOTTOM_Z))):
Cylinder(
radius=PIN_DIAMETER / 2.0,
height=PIN_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = pin.part
part.label = label
part.color = PIN_COLOR
return part
def _planet_center(index: int) -> tuple[float, float]:
angle = tau * index / PLANET_COUNT
return _polar_point(PLANET_CENTER_RADIUS, angle)
def gen_step():
"""Return a labeled simplified planetary gear assembly in millimeters."""
sun_pitch_angle = tau / SUN_TEETH
ring_pitch_angle = tau / RING_TEETH
parts = [
_make_carrier_plate(),
_make_internal_ring_gear(
label="ring_gear_60_internal_teeth",
phase=-ring_pitch_angle / 2.0,
color=RING_COLOR,
),
_make_external_gear(
label="sun_gear_24_teeth",
teeth=SUN_TEETH,
root_diameter=SUN_ROOT_DIAMETER,
outside_diameter=SUN_OUTSIDE_DIAMETER,
phase=-sun_pitch_angle / 2.0,
bore_diameter=SUN_BORE_DIAMETER,
color=SUN_COLOR,
),
]
for index in range(PLANET_COUNT):
planet_angle = tau * index / PLANET_COUNT
center = _planet_center(index)
parts.append(
_make_external_gear(
label=f"planet_gear_{index + 1}_18_teeth",
teeth=PLANET_TEETH,
root_diameter=PLANET_ROOT_DIAMETER,
outside_diameter=PLANET_OUTSIDE_DIAMETER,
phase=planet_angle,
center=center,
bore_diameter=PLANET_BORE_DIAMETER,
color=PLANET_COLORS[index],
)
)
parts.append(_make_planet_pin(label=f"planet_pin_{index + 1}", center=center))
assembly = Compound(
obj=parts,
children=parts,
label="simplified_planetary_gear_assembly",
)
return assembly
@@ -0,0 +1,597 @@
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
@@ -0,0 +1,613 @@
from math import atan2, cos, degrees, radians, sin, tau
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Circle,
Color,
Compound,
Cone,
Cylinder,
Edge,
Location,
Locations,
Mode,
Plane,
Polygon,
RectangleRounded,
add,
extrude,
fillet,
sweep,
)
# Units: millimeters.
# Origin: axis 1/base yaw center at X=0, Y=0. XY is the floor/display plane, +Z is up.
# Static display pose: raised shoulder, bent elbow, compact 3-axis wrist reaching mostly +X.
DISPLAY_BASE_LENGTH = 180.0
DISPLAY_BASE_WIDTH = 140.0
DISPLAY_BASE_THICKNESS = 12.0
DISPLAY_BASE_CENTER = (60.0, 0.0, 0.0)
FIXED_BASE_DIAMETER = 82.0
FIXED_BASE_BOTTOM_Z = 12.0
FIXED_BASE_TOP_Z = 42.0
TURNTABLE_DIAMETER = 72.0
TURNTABLE_BOTTOM_Z = 42.0
TURNTABLE_TOP_Z = 58.0
SHOULDER_CENTER = (0.0, 0.0, 90.0)
ELBOW_CENTER = (74.0, 0.0, 142.0)
WRIST_ROLL_CENTER = (154.0, 0.0, 128.0)
WRIST_PITCH_CENTER = (174.0, 0.0, 128.0)
TOOL_ROLL_CENTER = (196.0, 0.0, 128.0)
TOOL_FLANGE_CENTER = (216.0, 0.0, 128.0)
MATTE_WHITE = Color(0.91, 0.92, 0.90, 1.0)
LIGHT_GRAY = Color(0.73, 0.76, 0.76, 1.0)
DARK_GRAPHITE = Color(0.10, 0.11, 0.12, 1.0)
DEEP_GRAPHITE = Color(0.045, 0.048, 0.052, 1.0)
BRUSHED_STEEL = Color(0.74, 0.76, 0.76, 1.0)
DARK_STEEL = Color(0.24, 0.25, 0.26, 1.0)
BLUE_ACCENT = Color(0.02, 0.30, 0.82, 1.0)
ORANGE_ACCENT = Color(1.0, 0.46, 0.10, 1.0)
MATTE_BLACK = Color(0.005, 0.006, 0.007, 1.0)
def _set_metadata(part, label: str, color: Color):
part.label = label
part.color = color
return part
def _safe_fillet(edges, radius: float) -> None:
selected = list(edges)
if not selected:
return
try:
fillet(selected, radius=radius)
except Exception:
pass
def _axis_rotation(axis: str) -> tuple[float, float, float]:
if axis == "x":
return (0.0, 90.0, 0.0)
if axis == "y":
return (90.0, 0.0, 0.0)
return (0.0, 0.0, 0.0)
def _make_axis_cylinder(
label: str,
axis: str,
center: tuple[float, float, float],
radius: float,
length: float,
color: Color,
fillet_radius: float = 0.0,
):
with BuildPart() as cyl:
Cylinder(
radius=radius,
height=length,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
if fillet_radius > 0.0:
_safe_fillet(cyl.edges(), fillet_radius)
part = cyl.part.moved(Location(center, _axis_rotation(axis)))
return _set_metadata(part, label, color)
def _make_axis_ring(
label: str,
axis: str,
center: tuple[float, float, float],
outer_radius: float,
inner_radius: float,
thickness: float,
color: Color,
):
with BuildPart() as ring:
Cylinder(
radius=outer_radius,
height=thickness,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
Cylinder(
radius=inner_radius,
height=thickness + 0.4,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
_safe_fillet(ring.edges(), 0.25)
part = ring.part.moved(Location(center, _axis_rotation(axis)))
return _set_metadata(part, label, color)
def _make_screw_head(
label: str,
axis: str,
center: tuple[float, float, float],
radius: float,
height: float,
color: Color = BRUSHED_STEEL,
):
with BuildPart() as screw:
Cylinder(
radius=radius,
height=height,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
with Locations(Location((0.0, 0.0, height / 2.0 - 0.18))):
Cylinder(
radius=radius * 0.43,
height=0.45,
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
_safe_fillet(screw.edges(), min(0.5, radius * 0.22))
part = screw.part.moved(Location(center, _axis_rotation(axis)))
return _set_metadata(part, label, color)
def _make_rounded_box(
label: str,
size: tuple[float, float, float],
center: tuple[float, float, float],
radius: float,
color: Color,
rotation: tuple[float, float, float] = (0.0, 0.0, 0.0),
):
with BuildPart() as box:
Box(*size, align=(Align.CENTER, Align.CENTER, Align.CENTER))
if radius > 0.0:
_safe_fillet(box.edges(), radius)
part = box.part.moved(Location(center, rotation))
return _set_metadata(part, label, color)
def _rounded_plate_profile_points(
x_min: float,
x_max: float,
z_min: float,
z_max: float,
radius: float,
steps: int = 8,
) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
centers = (
(x_max - radius, z_min + radius, -90.0, 0.0),
(x_max - radius, z_max - radius, 0.0, 90.0),
(x_min + radius, z_max - radius, 90.0, 180.0),
(x_min + radius, z_min + radius, 180.0, 270.0),
)
for cx, cz, start, end in centers:
for step in range(steps + 1):
angle = radians(start + (end - start) * step / steps)
points.append((cx + radius * cos(angle), cz + radius * sin(angle)))
return points
def _outer_rect_edges(part, length: float, width: float, z_min: float, z_max: float, tol: float = 0.08):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
on_outer_x = abs(abs(center.X) - length / 2.0) <= tol
on_outer_y = abs(abs(center.Y) - width / 2.0) <= tol
on_top_or_bottom = abs(center.Z - z_min) <= tol or abs(center.Z - z_max) <= tol
if (on_outer_x or on_outer_y) and on_top_or_bottom:
edges.append(edge)
return edges
def _make_display_base():
with BuildPart() as base:
with BuildSketch(Plane.XY):
RectangleRounded(
DISPLAY_BASE_LENGTH,
DISPLAY_BASE_WIDTH,
8.0,
align=(Align.CENTER, Align.CENTER),
)
extrude(amount=DISPLAY_BASE_THICKNESS)
for x_abs in (-20.0, 120.0):
for y_pos in (-50.0, 50.0):
x_pos = x_abs - DISPLAY_BASE_CENTER[0]
with Locations(Location((x_pos, y_pos, -0.6))):
Cylinder(
radius=3.0,
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 - 3.2))):
Cone(
bottom_radius=3.0,
top_radius=6.0,
height=3.4,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
_safe_fillet(
_outer_rect_edges(base.part, DISPLAY_BASE_LENGTH, DISPLAY_BASE_WIDTH, 0.0, DISPLAY_BASE_THICKNESS),
3.0,
)
part = base.part.moved(Location(DISPLAY_BASE_CENTER))
return _set_metadata(part, "rounded_rectangular_display_base_180x140x12_with_four_countersunk_holes", DARK_GRAPHITE)
def _make_fixed_base():
return _make_axis_cylinder(
"fixed_cylindrical_base_axis1_od82_z12_to_z42",
"z",
(0.0, 0.0, (FIXED_BASE_BOTTOM_Z + FIXED_BASE_TOP_Z) / 2.0),
FIXED_BASE_DIAMETER / 2.0,
FIXED_BASE_TOP_Z - FIXED_BASE_BOTTOM_Z,
LIGHT_GRAY,
fillet_radius=2.0,
)
def _make_turntable():
return _make_axis_cylinder(
"rotating_base_turntable_axis1_od72_z42_to_z58",
"z",
(0.0, 0.0, (TURNTABLE_BOTTOM_Z + TURNTABLE_TOP_Z) / 2.0),
TURNTABLE_DIAMETER / 2.0,
TURNTABLE_TOP_Z - TURNTABLE_BOTTOM_Z,
DARK_GRAPHITE,
fillet_radius=1.5,
)
def _make_pedestal():
with BuildPart() as pedestal:
with BuildSketch(Plane.XY):
RectangleRounded(46.0, 58.0, 7.0, align=(Align.CENTER, Align.CENTER))
extrude(amount=43.0)
_safe_fillet(pedestal.edges(), 2.5)
part = pedestal.part.moved(Location((0.0, 0.0, 47.0)))
return _set_metadata(part, "rounded_shoulder_pedestal_46x58x43_to_axis2_center", MATTE_WHITE)
def _make_shoulder_lug(label: str, y_center: float):
with BuildPart() as lug:
with Locations(Location((3.0, y_center, 73.5))):
Box(34.0, 12.0, 37.0, align=(Align.CENTER, Align.CENTER, Align.CENTER))
with Locations(Location((0.0, y_center, 90.0))):
Cylinder(
radius=22.0,
height=12.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
Cylinder(
radius=13.0,
height=14.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
_safe_fillet(lug.edges(), 1.6)
return _set_metadata(lug.part, label, MATTE_WHITE)
def _make_upper_arm_link(label: str, y_center: float):
shoulder = SHOULDER_CENTER
elbow = ELBOW_CENTER
dx = elbow[0] - shoulder[0]
dz = elbow[2] - shoulder[2]
link_length = (dx * dx + dz * dz) ** 0.5 + 8.0
link_angle = -degrees(atan2(dz, dx))
mid = ((shoulder[0] + elbow[0]) / 2.0, y_center, (shoulder[2] + elbow[2]) / 2.0)
with BuildPart() as link:
with BuildSketch(Plane.XZ.offset(-5.0)):
RectangleRounded(link_length, 18.0, 8.0, align=(Align.CENTER, Align.CENTER))
extrude(amount=10.0)
with BuildSketch(Plane.XZ.offset(-6.2)):
RectangleRounded(56.0, 7.4, 3.5, align=(Align.CENTER, Align.CENTER))
extrude(amount=12.4, mode=Mode.SUBTRACT)
_safe_fillet(link.edges(), 1.5)
part = link.part.moved(Location(mid, (0.0, link_angle, 0.0)))
return _set_metadata(part, label, MATTE_WHITE)
def _make_forearm_housing():
elbow = ELBOW_CENTER
wrist = WRIST_ROLL_CENTER
dx = wrist[0] - elbow[0]
dz = wrist[2] - elbow[2]
length = (dx * dx + dz * dz) ** 0.5
angle = -degrees(atan2(dz, dx))
center = ((elbow[0] + wrist[0]) / 2.0, 0.0, (elbow[2] + wrist[2]) / 2.0)
with BuildPart() as forearm:
Cone(
bottom_radius=16.0,
top_radius=12.0,
height=length,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
_safe_fillet(forearm.edges(), 1.6)
part = forearm.part.moved(Location(center, (0.0, angle, 0.0)))
return _set_metadata(part, "tapered_rounded_forearm_housing_elbow_to_wrist_axis4", MATTE_WHITE)
def _make_forearm_panel(label: str, side: float):
elbow = ELBOW_CENTER
wrist = WRIST_ROLL_CENTER
dx = wrist[0] - elbow[0]
dz = wrist[2] - elbow[2]
angle = -degrees(atan2(dz, dx))
center = ((elbow[0] + wrist[0]) / 2.0, 0.0, (elbow[2] + wrist[2]) / 2.0)
with BuildPart() as panel:
with Locations(Location((6.0, side * 15.3, 0.0))):
Box(50.0, 1.2, 10.0, align=(Align.CENTER, Align.CENTER, Align.CENTER))
_safe_fillet(panel.edges(), 0.7)
part = panel.part.moved(Location(center, (0.0, angle, 0.0)))
return _set_metadata(part, label, DEEP_GRAPHITE)
def _rotated_y_point(
local: tuple[float, float, float],
center: tuple[float, float, float],
angle_deg: float,
) -> tuple[float, float, float]:
angle = radians(angle_deg)
x_pos = center[0] + local[0] * cos(angle) + local[2] * sin(angle)
y_pos = center[1] + local[1]
z_pos = center[2] - local[0] * sin(angle) + local[2] * cos(angle)
return (x_pos, y_pos, z_pos)
def _forearm_panel_screws(parts: list):
elbow = ELBOW_CENTER
wrist = WRIST_ROLL_CENTER
dx = wrist[0] - elbow[0]
dz = wrist[2] - elbow[2]
angle = -degrees(atan2(dz, dx))
center = ((elbow[0] + wrist[0]) / 2.0, 0.0, (elbow[2] + wrist[2]) / 2.0)
for side_name, side in (("positive_y", 1.0), ("negative_y", -1.0)):
for index, local_x in enumerate((-16.0, 25.0), start=1):
screw_center = _rotated_y_point((local_x, side * 16.1, 0.0), center, angle)
parts.append(
_make_screw_head(
f"forearm_recessed_panel_{side_name}_socket_screw_{index}",
"y",
screw_center,
1.55,
1.0,
)
)
def _add_circular_screws(
parts: list,
prefix: str,
axis: str,
axis_center: tuple[float, float, float],
face_coordinates: tuple[float, ...],
bolt_circle_radius: float,
count: int,
screw_radius: float,
screw_height: float,
start_angle: float = 0.0,
):
for face_index, face_coord in enumerate(face_coordinates, start=1):
for index in range(count):
angle = start_angle + tau * index / count
if axis == "z":
center = (
axis_center[0] + bolt_circle_radius * cos(angle),
axis_center[1] + bolt_circle_radius * sin(angle),
face_coord,
)
elif axis == "y":
center = (
axis_center[0] + bolt_circle_radius * cos(angle),
face_coord,
axis_center[2] + bolt_circle_radius * sin(angle),
)
else:
center = (
face_coord,
axis_center[1] + bolt_circle_radius * cos(angle),
axis_center[2] + bolt_circle_radius * sin(angle),
)
parts.append(
_make_screw_head(
f"{prefix}_face_{face_index}_socket_head_screw_{index + 1:02d}",
axis,
center,
screw_radius,
screw_height,
)
)
def _make_wrist_yoke_lug(label: str, y_center: float):
with BuildPart() as lug:
with Locations(Location((164.5, y_center, 128.0))):
Box(25.0, 7.0, 17.0, align=(Align.CENTER, Align.CENTER, Align.CENTER))
with Locations(Location((174.0, y_center, 128.0))):
Cylinder(
radius=14.5,
height=7.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
Cylinder(
radius=8.0,
height=8.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
_safe_fillet(lug.edges(), 1.0)
return _set_metadata(lug.part, label, MATTE_WHITE)
def _make_tool_flange():
with BuildPart() as flange:
Cylinder(
radius=19.0,
height=7.0,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
)
Cylinder(
radius=5.0,
height=9.0,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
for index in range(6):
angle = tau * index / 6.0
y_pos = 14.0 * cos(angle)
z_pos = 14.0 * sin(angle)
with Locations(Location((0.0, y_pos, z_pos))):
Cylinder(
radius=2.0,
height=9.0,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
_safe_fillet(flange.edges(), 0.6)
part = flange.part.moved(Location(TOOL_FLANGE_CENTER))
return _set_metadata(part, "standard_tool_flange_axis6_od38_thick7_with_10mm_bore_and_6x4mm_holes", BRUSHED_STEEL)
def _make_cable_guide():
points = [
(-15.0, -31.0, 94.0),
(8.0, -31.0, 101.0),
(36.0, -30.0, 119.0),
(66.0, -28.0, 139.0),
(78.0, -27.0, 148.0),
(104.0, -28.0, 142.0),
(134.0, -27.0, 135.0),
(155.0, -22.0, 132.0),
(174.0, -18.0, 130.0),
]
path = Edge.make_spline(points)
tangent = (
points[1][0] - points[0][0],
points[1][1] - points[0][1],
points[1][2] - points[0][2],
)
profile_plane = Plane(origin=points[0], x_dir=(0.0, 1.0, 0.0), z_dir=tangent)
with BuildPart() as cable:
with BuildSketch(profile_plane):
Circle(2.0)
sweep(path=path, is_frenet=True)
_safe_fillet(cable.edges(), 0.2)
return _set_metadata(cable.part, "matte_black_external_cable_guide_4mm_swept_tube_shoulder_to_wrist", MATTE_BLACK)
def _make_cable_clamp(label: str, center: tuple[float, float, float], angle_y: float):
with BuildPart() as clamp:
Box(12.0, 6.0, 5.0, align=(Align.CENTER, Align.CENTER, Align.CENTER))
Cylinder(
radius=2.2,
height=14.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
_safe_fillet(clamp.edges(), 0.6)
part = clamp.part.moved(Location(center, (0.0, angle_y, 0.0)))
return _set_metadata(part, label, DARK_STEEL)
def _make_axis_label_ring_set(parts: list):
parts.append(_make_axis_ring("blue_accent_ring_axis1_base_yaw_vertical_z", "z", (0.0, 0.0, 58.9), 38.0, 35.4, 1.6, BLUE_ACCENT))
parts.append(_make_axis_ring("orange_accent_ring_axis2_shoulder_pitch_y_left", "y", (0.0, -37.1, 90.0), 25.0, 22.0, 1.8, ORANGE_ACCENT))
parts.append(_make_axis_ring("orange_accent_ring_axis2_shoulder_pitch_y_right", "y", (0.0, 37.1, 90.0), 25.0, 22.0, 1.8, ORANGE_ACCENT))
parts.append(_make_axis_ring("blue_accent_ring_axis3_elbow_pitch_y_left", "y", (74.0, -34.6, 142.0), 24.0, 21.0, 1.8, BLUE_ACCENT))
parts.append(_make_axis_ring("blue_accent_ring_axis3_elbow_pitch_y_right", "y", (74.0, 34.6, 142.0), 24.0, 21.0, 1.8, BLUE_ACCENT))
parts.append(_make_axis_ring("orange_accent_ring_axis4_wrist_roll_x", "x", (154.0, 0.0, 128.0), 17.0, 15.0, 2.0, ORANGE_ACCENT))
parts.append(_make_axis_ring("blue_accent_ring_axis5_wrist_pitch_y", "y", (174.0, 0.0, 128.0), 16.0, 14.0, 2.0, BLUE_ACCENT))
parts.append(_make_axis_ring("orange_accent_ring_axis6_tool_roll_x", "x", (196.0, 0.0, 128.0), 14.0, 12.0, 2.0, ORANGE_ACCENT))
def gen_step():
"""Return a labeled six-axis industrial robot arm display assembly in millimeters."""
parts = [
_make_display_base(),
_make_fixed_base(),
_make_turntable(),
_make_axis_ring("dark_circular_seam_between_fixed_base_and_turntable_axis1", "z", (0.0, 0.0, 42.2), 41.4, 36.0, 0.8, DEEP_GRAPHITE),
_make_pedestal(),
_make_shoulder_lug("shoulder_yoke_negative_y_lug_with_axis2_bore", -24.0),
_make_shoulder_lug("shoulder_yoke_positive_y_lug_with_axis2_bore", 24.0),
_make_axis_cylinder("dark_graphite_shoulder_motor_housing_axis2_od44_length72", "y", SHOULDER_CENTER, 22.0, 72.0, DARK_GRAPHITE, 1.6),
_make_axis_cylinder("left_shoulder_raised_joint_cap_od48_thick3", "y", (0.0, -37.5, 90.0), 24.0, 3.0, LIGHT_GRAY, 0.6),
_make_axis_cylinder("right_shoulder_raised_joint_cap_od48_thick3", "y", (0.0, 37.5, 90.0), 24.0, 3.0, LIGHT_GRAY, 0.6),
_make_upper_arm_link("upper_arm_negative_y_rounded_side_link_with_oval_lightening_cutout", -18.0),
_make_upper_arm_link("upper_arm_positive_y_rounded_side_link_with_oval_lightening_cutout", 18.0),
_make_axis_cylinder("dark_graphite_elbow_joint_housing_axis3_od42_length66", "y", ELBOW_CENTER, 21.0, 66.0, DARK_GRAPHITE, 1.8),
_make_axis_cylinder("negative_y_elbow_raised_joint_cap_od46_thick3", "y", (74.0, -34.5, 142.0), 23.0, 3.0, LIGHT_GRAY, 0.6),
_make_axis_cylinder("positive_y_elbow_raised_joint_cap_od46_thick3", "y", (74.0, 34.5, 142.0), 23.0, 3.0, LIGHT_GRAY, 0.6),
_make_forearm_housing(),
_make_forearm_panel("negative_y_dark_recessed_side_panel_on_tapered_forearm", -1.0),
_make_forearm_panel("positive_y_dark_recessed_side_panel_on_tapered_forearm", 1.0),
_make_axis_cylinder("dark_graphite_wrist_roll_housing_axis4_od30_length28", "x", WRIST_ROLL_CENTER, 15.0, 28.0, DARK_GRAPHITE, 1.1),
_make_wrist_yoke_lug("wrist_pitch_fork_negative_y_lug_axis5", -20.0),
_make_wrist_yoke_lug("wrist_pitch_fork_positive_y_lug_axis5", 20.0),
_make_axis_cylinder("dark_graphite_wrist_pitch_joint_axis5_od28_length46", "y", WRIST_PITCH_CENTER, 14.0, 46.0, DARK_GRAPHITE, 1.0),
_make_axis_cylinder("negative_y_wrist_pitch_cap_od31_thick2", "y", (174.0, -24.0, 128.0), 15.5, 2.0, LIGHT_GRAY, 0.4),
_make_axis_cylinder("positive_y_wrist_pitch_cap_od31_thick2", "y", (174.0, 24.0, 128.0), 15.5, 2.0, LIGHT_GRAY, 0.4),
_make_axis_cylinder("dark_graphite_final_wrist_tool_roll_cylinder_axis6_od24_length30", "x", TOOL_ROLL_CENTER, 12.0, 30.0, DARK_GRAPHITE, 0.9),
_make_axis_cylinder("short_steel_tool_flange_pilot_hub_axis6_od18_length5", "x", (211.0, 0.0, 128.0), 9.0, 5.0, BRUSHED_STEEL, 0.4),
_make_tool_flange(),
_make_cable_guide(),
]
_make_axis_label_ring_set(parts)
_add_circular_screws(parts, "turntable_top_bolt_circle_58mm", "z", (0.0, 0.0, 0.0), (59.0,), 29.0, 8, 2.1, 1.7, start_angle=tau / 16.0)
_add_circular_screws(parts, "shoulder_axis2_joint_cap", "y", SHOULDER_CENTER, (-39.0, 39.0), 17.0, 6, 1.6, 1.1, start_angle=tau / 12.0)
_add_circular_screws(parts, "elbow_axis3_joint_cap", "y", ELBOW_CENTER, (-36.0, 36.0), 16.0, 6, 1.55, 1.1, start_angle=tau / 12.0)
_add_circular_screws(parts, "wrist_pitch_axis5_cap", "y", WRIST_PITCH_CENTER, (-25.2, 25.2), 10.5, 4, 1.25, 0.9, start_angle=tau / 8.0)
_add_circular_screws(parts, "wrist_roll_axis4_cap", "x", WRIST_ROLL_CENTER, (139.2, 168.8), 10.5, 4, 1.25, 0.9, start_angle=tau / 8.0)
_add_circular_screws(parts, "tool_flange_visible_fastener_circle", "x", TOOL_FLANGE_CENTER, (220.0,), 14.0, 6, 1.35, 0.8, start_angle=tau / 12.0)
_forearm_panel_screws(parts)
upper_angle = -degrees(atan2(ELBOW_CENTER[2] - SHOULDER_CENTER[2], ELBOW_CENTER[0] - SHOULDER_CENTER[0]))
forearm_angle = -degrees(atan2(WRIST_ROLL_CENTER[2] - ELBOW_CENTER[2], WRIST_ROLL_CENTER[0] - ELBOW_CENTER[0]))
parts.extend(
[
_make_cable_clamp("shoulder_cable_saddle_clamp_attached_to_rear_yoke", (-8.0, -29.5, 97.0), 0.0),
_make_cable_clamp("upper_arm_cable_saddle_clamp_following_raised_link", (39.0, -29.5, 121.0), upper_angle),
_make_cable_clamp("elbow_cable_saddle_clamp_at_cross_over", (78.0, -27.5, 148.0), 0.0),
_make_cable_clamp("forearm_cable_saddle_clamp_on_tapered_cover", (131.0, -27.5, 136.0), forearm_angle),
_make_cable_clamp("wrist_cable_saddle_clamp_near_axis4", (158.0, -21.5, 132.0), 0.0),
]
)
assembly = Compound(
obj=parts,
children=parts,
label="six_axis_industrial_robot_arm_static_display_pose",
)
return assembly
@@ -0,0 +1,375 @@
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)