Import CAD projects and cad-router v1
This commit is contained in:
@@ -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] }
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user