Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Benchmark Models
Benchmark CAD fixtures for repeatable geometry generation, import/export, and
viewer behavior checks. Each benchmark is intentionally more structured than the
small examples in `../simple/` and is meant to exercise a distinct modeling
surface.
## Contents
- `benchmark_*.py`: build123d generator source for each benchmark.
- `benchmark_*.step`: canonical generated STEP output for each benchmark.
- `.benchmark_*.step.glb`: generated render/selector sidecar paired with the
STEP file.
- `benchmark_common.py`: shared helper functions for benchmark generators.
- `validate_benchmark.py`: local benchmark validation helper.
## Coverage
The benchmark set covers calibration blocks, flanges, brackets, shafts,
enclosures, clevises, engine-like fins, impellers, spiral stairs, and planetary
gear stages.
Keep benchmark examples here when they are intended as regression fixtures or
represent a richer version of a simpler shape.
@@ -0,0 +1,31 @@
from build123d import Align, Box, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import line_edges_at_z, safe_chamfer
LENGTH = 100.0
WIDTH = 60.0
HEIGHT = 20.0
HOLE_DIAMETER = 8.0
HOLE_LOCATIONS = ((-35.0, -20.0), (-35.0, 20.0), (35.0, -20.0), (35.0, 20.0))
TOP_CHAMFER = 2.0
def gen_step():
"""Return the rectangular calibration block benchmark model in millimeters."""
with BuildPart() as block:
Box(LENGTH, WIDTH, HEIGHT, align=(Align.CENTER, Align.CENTER, Align.MIN))
for x_pos, y_pos in HOLE_LOCATIONS:
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=HOLE_DIAMETER / 2.0,
height=HEIGHT + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = block.part
part = safe_chamfer(part, line_edges_at_z(part, HEIGHT), length=TOP_CHAMFER)
part.label = "benchmark_01_rectangular_calibration_block"
return part
@@ -0,0 +1,50 @@
from math import tau
from build123d import Align, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import circular_edges, polar_point, safe_fillet
OUTER_DIAMETER = 80.0
THICKNESS = 10.0
CENTRAL_BORE_DIAMETER = 30.0
BOLT_HOLE_COUNT = 6
BOLT_HOLE_DIAMETER = 6.0
BOLT_CIRCLE_DIAMETER = 60.0
OUTER_FILLET = 1.5
def gen_step():
"""Return the circular flange benchmark model in millimeters."""
with BuildPart() as flange:
Cylinder(
radius=OUTER_DIAMETER / 2.0,
height=THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, -1.0))):
Cylinder(
radius=CENTRAL_BORE_DIAMETER / 2.0,
height=THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for index in range(BOLT_HOLE_COUNT):
x_pos, y_pos = polar_point(BOLT_CIRCLE_DIAMETER / 2.0, tau * index / BOLT_HOLE_COUNT)
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=BOLT_HOLE_DIAMETER / 2.0,
height=THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = flange.part
edges = []
edges.extend(circular_edges(part, radius=OUTER_DIAMETER / 2.0, axis="z", coordinate=0.0))
edges.extend(circular_edges(part, radius=OUTER_DIAMETER / 2.0, axis="z", coordinate=THICKNESS))
part = safe_fillet(part, edges, radius=OUTER_FILLET)
part.label = "benchmark_02_circular_flange_bolt_pattern"
return part
@@ -0,0 +1,113 @@
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
)
from benchmark_common import safe_fillet
BASE_LENGTH = 80.0
BASE_WIDTH = 50.0
BASE_THICKNESS = 8.0
BACK_LENGTH = 80.0
BACK_THICKNESS = 8.0
BACK_HEIGHT = 50.0
BACK_CENTER_Y = BASE_WIDTH / 2.0 - BACK_THICKNESS / 2.0
HOLE_DIAMETER = 6.0
BASE_HOLE_LOCATIONS = ((-25.0, -10.0), (25.0, -10.0))
BACK_HOLE_LOCATIONS = ((-25.0, 30.0), (25.0, 30.0))
GUSSET_X_THICKNESS = 8.0
GUSSET_X_LOCATIONS = (-20.0, 20.0)
GUSSET_DEPTH = 30.0
GUSSET_HEIGHT = 30.0
TRANSITION_FILLET = 2.0
def _make_gusset(x_center: float):
back_front_y = BACK_CENTER_Y - BACK_THICKNESS / 2.0
front_y = back_front_y - GUSSET_DEPTH
top_z = BASE_THICKNESS + GUSSET_HEIGHT
with BuildPart() as gusset:
with BuildSketch(Plane.YZ):
Polygon(
[
(back_front_y, BASE_THICKNESS),
(front_y, BASE_THICKNESS),
(back_front_y, top_z),
],
align=None,
)
extrude(amount=GUSSET_X_THICKNESS)
part = gusset.part.moved(Location((x_center - GUSSET_X_THICKNESS / 2.0, 0.0, 0.0)))
part.label = f"gusset_x_{x_center:.0f}"
return part
def _transition_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
if str(edge.geom_type).endswith("LINE"):
if abs(bbox.min.Z - BASE_THICKNESS) < 0.05 and abs(bbox.max.Z - BASE_THICKNESS) < 0.05:
center_y = bbox.center().Y
if BACK_CENTER_Y - BACK_THICKNESS / 2.0 - 0.05 <= center_y <= BASE_WIDTH / 2.0 + 0.05:
if bbox.size.X > 20.0:
edges.append(edge)
return edges
def gen_step():
"""Return the L-bracket benchmark model in millimeters."""
with BuildPart() as bracket:
Box(BASE_LENGTH, BASE_WIDTH, BASE_THICKNESS, align=(Align.CENTER, Align.CENTER, Align.MIN))
with Locations(Location((0.0, BACK_CENTER_Y, BASE_THICKNESS))):
Box(
BACK_LENGTH,
BACK_THICKNESS,
BACK_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
for x_pos, y_pos in BASE_HOLE_LOCATIONS:
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=HOLE_DIAMETER / 2.0,
height=BASE_THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for x_pos, z_pos in BACK_HOLE_LOCATIONS:
with Locations(Location((x_pos, BACK_CENTER_Y, z_pos))):
Cylinder(
radius=HOLE_DIAMETER / 2.0,
height=BACK_THICKNESS + 2.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
for x_center in GUSSET_X_LOCATIONS:
add(_make_gusset(x_center), mode=Mode.ADD)
part = bracket.part
part = safe_fillet(part, _transition_edges(part), radius=TRANSITION_FILLET)
part.label = "benchmark_03_l_bracket_gussets_two_hole_directions"
return part
@@ -0,0 +1,53 @@
from build123d import Align, Box, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import circular_edges, safe_chamfer
TOTAL_LENGTH = 120.0
LEFT_LENGTH = 30.0
MIDDLE_LENGTH = 60.0
RIGHT_LENGTH = 30.0
SMALL_DIAMETER = 20.0
MIDDLE_DIAMETER = 30.0
END_CHAMFER = 1.0
KEYWAY_X_START = 40.0
KEYWAY_X_END = 80.0
KEYWAY_WIDTH = 6.0
KEYWAY_DEPTH = 3.0
def _add_shaft_section(x_start: float, x_end: float, diameter: float):
with Locations(Location(((x_start + x_end) / 2.0, 0.0, 0.0))):
Cylinder(
radius=diameter / 2.0,
height=x_end - x_start,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.ADD,
)
def gen_step():
"""Return the stepped shaft with keyway benchmark model in millimeters."""
with BuildPart() as shaft:
_add_shaft_section(0.0, LEFT_LENGTH, SMALL_DIAMETER)
_add_shaft_section(LEFT_LENGTH, LEFT_LENGTH + MIDDLE_LENGTH, MIDDLE_DIAMETER)
_add_shaft_section(LEFT_LENGTH + MIDDLE_LENGTH, TOTAL_LENGTH, SMALL_DIAMETER)
with Locations(Location(((KEYWAY_X_START + KEYWAY_X_END) / 2.0, 0.0, MIDDLE_DIAMETER / 2.0 - KEYWAY_DEPTH))):
Box(
KEYWAY_X_END - KEYWAY_X_START,
KEYWAY_WIDTH,
KEYWAY_DEPTH + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = shaft.part
end_edges = []
end_edges.extend(circular_edges(part, radius=SMALL_DIAMETER / 2.0, axis="x", coordinate=0.0))
end_edges.extend(circular_edges(part, radius=SMALL_DIAMETER / 2.0, axis="x", coordinate=TOTAL_LENGTH))
part = safe_chamfer(part, end_edges, length=END_CHAMFER)
part.label = "benchmark_04_stepped_shaft_keyway"
return part
@@ -0,0 +1,75 @@
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_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 the open-top electronics enclosure benchmark model 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_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 = "benchmark_05_open_top_electronics_enclosure_bosses"
return part
@@ -0,0 +1,164 @@
from math import cos, radians, sin
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
)
from benchmark_common import rounded_triangle_points, safe_fillet
BASE_LENGTH = 120.0
BASE_WIDTH = 60.0
BASE_THICKNESS = 10.0
BASE_FILLET = 3.0
LUG_X_EXTENT = 36.0
LUG_RADIUS = 18.0
LUG_HEIGHT_ABOVE_BASE = 42.0
LUG_THICKNESS_Y = 18.0
LUG_GAP_Y = 16.0
LUG_FILLET = 2.0
CLEVIS_HOLE_DIAMETER = 14.0
CLEVIS_HOLE_CENTER_Z = 34.0
BASE_HOLE_DIAMETER = 7.0
BASE_HOLE_LOCATIONS = ((-45.0, -20.0), (-45.0, 20.0), (45.0, -20.0), (45.0, 20.0))
CUTOUT_CORNER_RADIUS = 3.0
RIB_THICKNESS_X = 6.0
def _lug_profile_points() -> list[tuple[float, float]]:
half_x = LUG_X_EXTENT / 2.0
arc_center_z = BASE_THICKNESS + LUG_HEIGHT_ABOVE_BASE - LUG_RADIUS
points = [(-half_x, BASE_THICKNESS), (half_x, BASE_THICKNESS), (half_x, arc_center_z)]
for step in range(1, 12):
angle = radians(180.0 * step / 12.0)
points.append((LUG_RADIUS * cos(angle), arc_center_z + LUG_RADIUS * sin(angle)))
points.append((-half_x, arc_center_z))
return points
def _make_lug(y_min: float, label: str):
with BuildPart() as lug:
with BuildSketch(Plane.XZ):
Polygon(_lug_profile_points(), align=None)
extrude(amount=LUG_THICKNESS_Y)
part = lug.part.moved(Location((0.0, y_min, 0.0)))
part.label = label
return part
def _make_rib(sign: float):
outer_y = sign * (LUG_GAP_Y / 2.0 + LUG_THICKNESS_Y)
foot_y = sign * (BASE_WIDTH / 2.0)
top_z = BASE_THICKNESS + 28.0
with BuildPart() as rib:
with BuildSketch(Plane.YZ):
Polygon(
[
(outer_y, BASE_THICKNESS),
(foot_y, BASE_THICKNESS),
(outer_y, top_z),
],
align=None,
)
extrude(amount=RIB_THICKNESS_X)
part = rib.part.moved(Location((-RIB_THICKNESS_X / 2.0, 0.0, 0.0)))
part.label = "positive_y_diagonal_rib" if sign > 0.0 else "negative_y_diagonal_rib"
return part
def _outer_base_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
on_outer_x = abs(abs(center.X) - BASE_LENGTH / 2.0) < 0.05
on_outer_y = abs(abs(center.Y) - BASE_WIDTH / 2.0) < 0.05
on_top_or_bottom = abs(center.Z - 0.0) < 0.05 or abs(center.Z - BASE_THICKNESS) < 0.05
if (on_outer_x or on_outer_y) and on_top_or_bottom:
edges.append(edge)
return edges
def _lug_base_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
if abs(center.Z - BASE_THICKNESS) > 0.05:
continue
if abs(abs(center.Y) - LUG_GAP_Y / 2.0) < 0.05 and bbox.size.X > LUG_X_EXTENT - 1.0:
edges.append(edge)
return edges
def _subtract_rounded_triangle(vertices: list[tuple[float, float]]):
with BuildSketch(Plane.XY) as cutout:
Polygon(
rounded_triangle_points(vertices, radius=CUTOUT_CORNER_RADIUS, segments=5),
align=None,
)
extrude(cutout.sketch, amount=BASE_THICKNESS + 2.0, mode=Mode.SUBTRACT)
def gen_step():
"""Return the clevis bracket benchmark model in millimeters."""
positive_lug_y_min = LUG_GAP_Y / 2.0 + LUG_THICKNESS_Y
negative_lug_y_min = -LUG_GAP_Y / 2.0
with BuildPart() as bracket:
Box(BASE_LENGTH, BASE_WIDTH, BASE_THICKNESS, align=(Align.CENTER, Align.CENTER, Align.MIN))
base = safe_fillet(bracket.part, _outer_base_edges(bracket.part), radius=BASE_FILLET)
with BuildPart() as bracket:
add(base, mode=Mode.ADD)
add(_make_lug(positive_lug_y_min, "positive_y_clevis_lug"), mode=Mode.ADD)
add(_make_lug(negative_lug_y_min, "negative_y_clevis_lug"), mode=Mode.ADD)
add(_make_rib(1.0), mode=Mode.ADD)
add(_make_rib(-1.0), mode=Mode.ADD)
with Locations(Location((0.0, 0.0, CLEVIS_HOLE_CENTER_Z))):
Cylinder(
radius=CLEVIS_HOLE_DIAMETER / 2.0,
height=BASE_WIDTH + 8.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
for x_pos, y_pos in BASE_HOLE_LOCATIONS:
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=BASE_HOLE_DIAMETER / 2.0,
height=BASE_THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
_subtract_rounded_triangle([(-54.0, -13.0), (-25.0, 0.0), (-54.0, 13.0)])
_subtract_rounded_triangle([(54.0, -13.0), (25.0, 0.0), (54.0, 13.0)])
part = bracket.part
part = safe_fillet(part, _lug_base_edges(part), radius=LUG_FILLET)
part.label = "benchmark_06_aerospace_clevis_bracket_lightening_cutouts"
return part
@@ -0,0 +1,122 @@
from math import cos, radians, sin, tau
from build123d import Align, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import circular_edges, polar_point, safe_fillet
BARREL_DIAMETER = 36.0
BARREL_HEIGHT = 70.0
FIN_COUNT = 12
FIN_DIAMETER = 62.0
FIN_THICKNESS = 2.0
FIN_Z_VALUES = tuple(10.0 + 5.0 * index for index in range(FIN_COUNT))
BASE_FLANGE_DIAMETER = 70.0
BASE_FLANGE_THICKNESS = 8.0
MOUNT_HOLE_COUNT = 6
MOUNT_HOLE_DIAMETER = 5.0
MOUNT_BOLT_CIRCLE_DIAMETER = 56.0
TOP_CAP_DIAMETER = 44.0
TOP_CAP_BOTTOM_Z = 70.0
TOP_CAP_HEIGHT = 8.0
BOSS_DIAMETER = 12.0
BOSS_LENGTH = 24.0
BOSS_ANGLE_DEGREES = 35.0
BOSS_BORE_DIAMETER = 5.0
EDGE_FILLET = 0.95
def _boss_center() -> tuple[float, float, float]:
angle = radians(BOSS_ANGLE_DEGREES)
start = (TOP_CAP_DIAMETER / 2.0 - 1.0, 0.0, TOP_CAP_BOTTOM_Z + TOP_CAP_HEIGHT / 2.0)
direction = (cos(angle), 0.0, sin(angle))
return (
start[0] + direction[0] * BOSS_LENGTH / 2.0,
0.0,
start[2] + direction[2] * BOSS_LENGTH / 2.0,
)
def _edge_fillets(part):
edges = []
for z_pos in FIN_Z_VALUES:
edges.extend(circular_edges(part, radius=FIN_DIAMETER / 2.0, axis="z", coordinate=z_pos))
edges.extend(circular_edges(part, radius=FIN_DIAMETER / 2.0, axis="z", coordinate=z_pos + FIN_THICKNESS))
edges.extend(circular_edges(part, radius=BASE_FLANGE_DIAMETER / 2.0, axis="z", coordinate=0.0))
edges.extend(circular_edges(part, radius=BASE_FLANGE_DIAMETER / 2.0, axis="z", coordinate=BASE_FLANGE_THICKNESS))
return edges
def gen_step():
"""Return the radial-engine-style cylinder benchmark model in millimeters."""
boss_rotation_y = 90.0 - BOSS_ANGLE_DEGREES
boss_center = _boss_center()
with BuildPart() as cylinder:
Cylinder(
radius=BARREL_DIAMETER / 2.0,
height=BARREL_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
Cylinder(
radius=BASE_FLANGE_DIAMETER / 2.0,
height=BASE_FLANGE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
for z_pos in FIN_Z_VALUES:
with Locations(Location((0.0, 0.0, z_pos))):
Cylinder(
radius=FIN_DIAMETER / 2.0,
height=FIN_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location((0.0, 0.0, TOP_CAP_BOTTOM_Z))):
Cylinder(
radius=TOP_CAP_DIAMETER / 2.0,
height=TOP_CAP_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location(boss_center)):
Cylinder(
radius=BOSS_DIAMETER / 2.0,
height=BOSS_LENGTH,
rotation=(0.0, boss_rotation_y, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.ADD,
)
for index in range(MOUNT_HOLE_COUNT):
x_pos, y_pos = polar_point(MOUNT_BOLT_CIRCLE_DIAMETER / 2.0, tau * index / MOUNT_HOLE_COUNT)
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=MOUNT_HOLE_DIAMETER / 2.0,
height=BASE_FLANGE_THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with Locations(Location(boss_center)):
Cylinder(
radius=BOSS_BORE_DIAMETER / 2.0,
height=BOSS_LENGTH + 6.0,
rotation=(0.0, boss_rotation_y, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
part = cylinder.part
part = safe_fillet(part, _edge_fillets(part), radius=EDGE_FILLET)
part.label = "benchmark_07_radial_engine_cylinder_cooling_fins"
return part
@@ -0,0 +1,152 @@
from math import asin, cos, hypot, radians, sin, tau
from build123d import (
Align,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
)
from benchmark_common import circular_edges, polar_point, safe_fillet
BACKPLATE_DIAMETER = 90.0
BACKPLATE_THICKNESS = 6.0
BACKPLATE_FILLET = 1.5
HUB_DIAMETER = 26.0
HUB_HEIGHT = 22.0
BORE_DIAMETER = 8.0
BLADE_COUNT = 12
BLADE_ROOT_RADIUS = 18.0
BLADE_TIP_RADIUS = 43.0
BLADE_THICKNESS = 3.0
BLADE_HEIGHT = 16.0
BLADE_BACKWARD_SWEEP = radians(45.0)
BLADE_SEGMENTS = 5
BLADE_ROOT_FILLET = 1.0
def _clamp_radius(point: tuple[float, float]) -> tuple[float, float]:
radius = hypot(point[0], point[1])
if radius < BLADE_ROOT_RADIUS:
scale = BLADE_ROOT_RADIUS / radius
return (point[0] * scale, point[1] * scale)
if radius > BLADE_TIP_RADIUS:
scale = BLADE_TIP_RADIUS / radius
return (point[0] * scale, point[1] * scale)
return point
def _blade_center(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 * 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 = 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_end_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 * 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 = []
right_side = []
half_thickness = BLADE_THICKNESS / 2.0
for index in range(BLADE_SEGMENTS + 1):
t = index / BLADE_SEGMENTS
if index == 0 or index == BLADE_SEGMENTS:
left, right = _blade_end_points(base_angle, t)
else:
center = _blade_center(base_angle, t)
normal = _blade_normal(base_angle, t)
left = _clamp_radius((center[0] + normal[0] * half_thickness, center[1] + normal[1] * half_thickness))
right = _clamp_radius((center[0] - normal[0] * half_thickness, center[1] - normal[1] * half_thickness))
left_side.append(left)
right_side.append(right)
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)
part = blade.part.moved(Location((0.0, 0.0, BACKPLATE_THICKNESS)))
part.label = f"backward_curved_blade_{index + 1:02d}"
return part
def _blade_root_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
if abs(center.Z - BACKPLATE_THICKNESS) > 0.05:
continue
radial_distance = hypot(center.X, center.Y)
if BLADE_ROOT_RADIUS - 3.0 <= radial_distance <= 30.0:
if str(edge.geom_type).endswith("LINE"):
edges.append(edge)
return edges
def gen_step():
"""Return the centrifugal impeller benchmark model in millimeters."""
total_height = BACKPLATE_THICKNESS + HUB_HEIGHT
with BuildPart() as impeller:
Cylinder(
radius=BACKPLATE_DIAMETER / 2.0,
height=BACKPLATE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
for blade_index in range(BLADE_COUNT):
add(_make_blade(blade_index), mode=Mode.ADD)
with Locations(Location((0.0, 0.0, BACKPLATE_THICKNESS))):
Cylinder(
radius=HUB_DIAMETER / 2.0,
height=HUB_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location((0.0, 0.0, -1.0))):
Cylinder(
radius=BORE_DIAMETER / 2.0,
height=total_height + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = impeller.part
backplate_edges = []
backplate_edges.extend(circular_edges(part, radius=BACKPLATE_DIAMETER / 2.0, axis="z", coordinate=0.0))
backplate_edges.extend(circular_edges(part, radius=BACKPLATE_DIAMETER / 2.0, axis="z", coordinate=BACKPLATE_THICKNESS))
part = safe_fillet(part, backplate_edges, radius=BACKPLATE_FILLET)
part = safe_fillet(part, _blade_root_edges(part), radius=BLADE_ROOT_FILLET)
part.label = "benchmark_08_centrifugal_impeller_backward_curved_blades"
return part
@@ -0,0 +1,149 @@
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,
)
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_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(angle_degrees)
return (radius * cos(angle), radius * sin(angle))
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 = "base_disk"
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"
part.color = Color(0.70, 0.72, 0.74, 1.0)
return part
def _make_tread_profile():
half_sweep = TREAD_SWEEP_DEGREES / 2.0
with BuildPart() as tread:
with BuildSketch(Plane.XY):
with BuildLine():
CenterArc((0.0, 0.0), TREAD_OUTER_RADIUS, -half_sweep, TREAD_SWEEP_DEGREES)
Line(_polar_point(TREAD_OUTER_RADIUS, half_sweep), _polar_point(TREAD_INNER_RADIUS, half_sweep))
CenterArc((0.0, 0.0), TREAD_INNER_RADIUS, half_sweep, -TREAD_SWEEP_DEGREES)
Line(_polar_point(TREAD_INNER_RADIUS, -half_sweep), _polar_point(TREAD_OUTER_RADIUS, -half_sweep))
make_face()
extrude(amount=TREAD_THICKNESS)
return tread.part
def _make_tread(index: int):
angle = index * TREAD_ROTATION_DEGREES
bottom_z = TREAD_FIRST_BOTTOM_Z + index * TREAD_RISE
part = _make_tread_profile().moved(Location((0.0, 0.0, bottom_z), (0.0, 0.0, angle)))
part.label = f"helical_tread_{index + 1:02d}"
part.color = Color(0.78, 0.58, 0.35, 1.0)
return part
def _handrail_center_z(angle_degrees: float) -> float:
return HANDRAIL_START_Z + (HANDRAIL_END_Z - HANDRAIL_START_Z) * 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),
)
profile_plane = Plane(
origin=(HANDRAIL_RADIUS, 0.0, HANDRAIL_START_Z),
x_dir=(1.0, 0.0, 0.0),
z_dir=path.tangent_at(0.0),
)
with BuildPart() as rail:
with BuildSketch(profile_plane):
Circle(HANDRAIL_DIAMETER / 2.0)
sweep(path=path, is_frenet=True)
part = rail.part
part.label = "helical_handrail"
part.color = Color(0.22, 0.28, 0.34, 1.0)
return part
def _make_baluster(index: int):
angle = index * TREAD_ROTATION_DEGREES
tread_top_z = TREAD_FIRST_BOTTOM_Z + index * TREAD_RISE + TREAD_THICKNESS
rail_center_z = _handrail_center_z(angle)
x_pos, y_pos = _polar_point(TREAD_OUTER_RADIUS, angle)
with BuildPart() as baluster:
Cylinder(
radius=BALUSTER_DIAMETER / 2.0,
height=rail_center_z - tread_top_z,
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}"
part.color = Color(0.30, 0.33, 0.36, 1.0)
return part
def gen_step():
"""Return the miniature spiral staircase benchmark model in millimeters."""
parts = [_make_base(), _make_column()]
parts.extend(_make_tread(index) for index in range(TREAD_COUNT))
parts.append(_make_handrail())
parts.extend(_make_baluster(index) for index in range(TREAD_COUNT))
return Compound(obj=parts, children=parts, label="benchmark_09_spiral_staircase_helical_handrail")
@@ -0,0 +1,181 @@
from math import tau
from build123d import (
Align,
BuildPart,
BuildSketch,
Color,
Compound,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
extrude,
)
from benchmark_common import polar_point, trapezoid_tooth_profile
GEAR_THICKNESS = 8.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_COUNT = 3
PLANET_TEETH = 18
PLANET_PITCH_DIAMETER = 36.0
PLANET_ROOT_DIAMETER = 31.0
PLANET_OUTSIDE_DIAMETER = 41.0
PLANET_CENTER_RADIUS = 42.0
PLANET_BORE_DIAMETER = 6.8
RING_TEETH = 60
RING_INTERNAL_PITCH_DIAMETER = 120.0
RING_INTERNAL_ROOT_DIAMETER = 126.0
RING_INTERNAL_TIP_DIAMETER = 114.0
RING_OUTSIDE_DIAMETER = 140.0
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
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,
),
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], 0.0)))
part.label = label
part.color = color
return part
def _make_internal_ring_gear():
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=-tau / RING_TEETH / 2.0,
root_span_fraction=0.68,
tip_span_fraction=0.34,
),
align=None,
)
extrude(amount=GEAR_THICKNESS, mode=Mode.SUBTRACT)
part = ring.part
part.label = "ring_gear_60_internal_teeth"
part.color = Color(0.66, 0.68, 0.70, 1.0)
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),
)
part = carrier.part
part.label = "carrier_plate"
part.color = Color(0.52, 0.62, 0.58, 1.0)
return part
def _make_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 = Color(0.22, 0.25, 0.29, 1.0)
return part
def _planet_center(index: int) -> tuple[float, float]:
return polar_point(PLANET_CENTER_RADIUS, tau * index / PLANET_COUNT)
def gen_step():
"""Return the simplified planetary gear benchmark assembly in millimeters."""
parts = [
_make_carrier_plate(),
_make_internal_ring_gear(),
_make_external_gear(
label="sun_gear_24_external_teeth",
teeth=SUN_TEETH,
root_diameter=SUN_ROOT_DIAMETER,
outside_diameter=SUN_OUTSIDE_DIAMETER,
phase=-tau / SUN_TEETH / 2.0,
bore_diameter=SUN_BORE_DIAMETER,
color=Color(0.94, 0.60, 0.22, 1.0),
),
]
for index in range(PLANET_COUNT):
center = _planet_center(index)
parts.append(
_make_external_gear(
label=f"planet_gear_{index + 1}_18_external_teeth",
teeth=PLANET_TEETH,
root_diameter=PLANET_ROOT_DIAMETER,
outside_diameter=PLANET_OUTSIDE_DIAMETER,
phase=tau * index / PLANET_COUNT,
center=center,
bore_diameter=PLANET_BORE_DIAMETER,
color=Color(0.24, 0.51, 0.76, 1.0),
)
)
parts.append(_make_pin(f"planet_pin_{index + 1}", center))
return Compound(obj=parts, children=parts, label="benchmark_10_simplified_planetary_gear_stage")
@@ -0,0 +1,177 @@
from __future__ import annotations
from math import cos, hypot, sin, tau
from typing import Iterable
from build123d import Axis, BuildPart, Color, Location, Mode, chamfer, fillet
TOLERANCE = 1e-6
def polar_point(radius: float, angle: float) -> tuple[float, float]:
return (radius * cos(angle), radius * sin(angle))
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 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 line_edges_at_z(part, z_value: float, *, tol: float = 0.02):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
if abs(bbox.min.Z - z_value) <= tol and abs(bbox.max.Z - z_value) <= tol:
if str(edge.geom_type).endswith("LINE"):
edges.append(edge)
return edges
def circular_edges(
part,
*,
radius: float | None = None,
axis: str | None = None,
coordinate: float | None = None,
tol: float = 0.05,
):
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) > tol:
continue
if axis is not None:
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 or "z"]
if abs(coord - coordinate) > tol:
continue
edges.append(edge)
return edges
def cylinder_axis_x(radius: float, length: float, center: tuple[float, float, float], *, mode=Mode.ADD):
from build123d import Align, Cylinder
return Cylinder(
radius=radius,
height=length,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=mode,
).locate(Location(center))
def cylinder_axis_y(radius: float, length: float, center: tuple[float, float, float], *, mode=Mode.ADD):
from build123d import Align, Cylinder
return Cylinder(
radius=radius,
height=length,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=mode,
).locate(Location(center))
def rounded_triangle_points(
vertices: list[tuple[float, float]],
*,
radius: float,
segments: int = 5,
) -> list[tuple[float, float]]:
"""Approximate a rounded triangular loop with quadratic corner arcs."""
points: list[tuple[float, float]] = []
count = len(vertices)
for index, vertex in enumerate(vertices):
previous = vertices[(index - 1) % count]
next_vertex = vertices[(index + 1) % count]
to_previous = (previous[0] - vertex[0], previous[1] - vertex[1])
to_next = (next_vertex[0] - vertex[0], next_vertex[1] - vertex[1])
previous_length = hypot(*to_previous)
next_length = hypot(*to_next)
offset = min(radius, previous_length * 0.35, next_length * 0.35)
start = (
vertex[0] + to_previous[0] / previous_length * offset,
vertex[1] + to_previous[1] / previous_length * offset,
)
end = (
vertex[0] + to_next[0] / next_length * offset,
vertex[1] + to_next[1] / next_length * offset,
)
for step in range(segments + 1):
t = step / segments
x_pos = (1.0 - t) ** 2 * start[0] + 2.0 * (1.0 - t) * t * vertex[0] + t**2 * end[0]
y_pos = (1.0 - t) ** 2 * start[1] + 2.0 * (1.0 - t) * t * vertex[1] + t**2 * end[1]
points.append((x_pos, y_pos))
return points
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]]:
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 label(part, name: str, color: Color | None = None):
part.label = name
if color is not None:
part.color = color
return part
@@ -0,0 +1,269 @@
from __future__ import annotations
from math import atan2, degrees, hypot
from pathlib import Path
from build123d import import_step
ROOT = Path(__file__).resolve().parent
BASIC_TOL = 0.2
COMPLEX_TOL = 0.5
ANGLE_TOL = 2.0
class BenchmarkFailure(AssertionError):
pass
def close(actual: float, expected: float, tol: float = BASIC_TOL) -> bool:
return abs(actual - expected) <= tol
def check(condition: bool, message: str) -> None:
if not condition:
raise BenchmarkFailure(message)
def bbox(shape):
box = shape.bounding_box()
return box.min, box.max, (
box.max.X - box.min.X,
box.max.Y - box.min.Y,
box.max.Z - box.min.Z,
)
def assert_bbox(shape, expected_size, expected_min=None, tol: float = BASIC_TOL):
min_v, max_v, size = bbox(shape)
for actual, expected, axis in zip(size, expected_size, "xyz"):
check(close(actual, expected, tol), f"bbox {axis} size {actual:.3f} != {expected:.3f}")
if expected_min is not None:
for actual, expected, axis in zip((min_v.X, min_v.Y, min_v.Z), expected_min, "xyz"):
check(close(actual, expected, tol), f"bbox min {axis} {actual:.3f} != {expected:.3f}")
def axis_name(direction) -> str:
components = {"x": abs(direction.X), "y": abs(direction.Y), "z": abs(direction.Z)}
return max(components, key=components.get)
def cyl_faces(shape, *, radius: float | None = None, axis: str | None = None, tol: float = BASIC_TOL):
faces = []
for face in shape.faces():
if "CYLINDER" not in str(face.geom_type):
continue
if radius is not None and not close(face.radius, radius, tol):
continue
if axis is not None and axis_name(face.axis_of_rotation.direction) != axis:
continue
faces.append(face)
return faces
def torus_count(shape) -> int:
return sum(1 for face in shape.faces() if "TORUS" in str(face.geom_type))
def cone_count(shape) -> int:
return sum(1 for face in shape.faces() if "CONE" in str(face.geom_type))
def cylinder_centers(shape, *, radius: float, axis: str):
centers = []
for face in cyl_faces(shape, radius=radius, axis=axis):
pos = face.axis_of_rotation.position
centers.append((pos.X, pos.Y, pos.Z))
return centers
def expect_cylinder_at(shape, *, radius: float, axis: str, x=None, y=None, z=None, tol: float = BASIC_TOL):
for center in cylinder_centers(shape, radius=radius, axis=axis):
if x is not None and not close(center[0], x, tol):
continue
if y is not None and not close(center[1], y, tol):
continue
if z is not None and not close(center[2], z, tol):
continue
return
raise BenchmarkFailure(f"missing cylinder radius {radius} axis {axis} at x={x} y={y} z={z}")
def plane_faces_at(shape, *, axis: str, coordinate: float, tol: float = BASIC_TOL):
index = "xyz".index(axis)
faces = []
for face in shape.faces():
if "PLANE" not in str(face.geom_type):
continue
box = face.bounding_box()
mins = (box.min.X, box.min.Y, box.min.Z)
maxs = (box.max.X, box.max.Y, box.max.Z)
if close(mins[index], coordinate, tol) and close(maxs[index], coordinate, tol):
faces.append(face)
return faces
def assert_common(shape, *, solids: int | None = None, min_solids: int | None = None):
check(shape.is_valid, "STEP imports but imported B-rep is invalid")
check(shape.is_manifold, "STEP imports but B-rep is non-manifold")
if solids is not None:
check(len(shape.solids()) == solids, f"solid count {len(shape.solids())} != {solids}")
if min_solids is not None:
check(len(shape.solids()) >= min_solids, f"solid count {len(shape.solids())} < {min_solids}")
def validate_01(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (100.0, 60.0, 20.0), (-50.0, -30.0, 0.0))
for x in (-35.0, 35.0):
for y in (-20.0, 20.0):
expect_cylinder_at(shape, radius=4.0, axis="z", x=x, y=y)
check(len(cyl_faces(shape, radius=4.0, axis="z")) == 4, "expected four 8 mm through holes")
top_faces = plane_faces_at(shape, axis="z", coordinate=20.0)
check(any(close(face.bounding_box().size.X, 96.0) and close(face.bounding_box().size.Y, 56.0) for face in top_faces), "top chamfered face is not inset by 2 mm")
check(cone_count(shape) == 0 and torus_count(shape) == 0, "unexpected hole chamfers/roundovers")
def validate_02(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (80.0, 80.0, 10.0), (-40.0, -40.0, 0.0))
expect_cylinder_at(shape, radius=15.0, axis="z", x=0.0, y=0.0)
bolt_centers = cylinder_centers(shape, radius=3.0, axis="z")
check(len(bolt_centers) == 6, "expected six bolt holes")
angles = sorted(degrees(atan2(y, x)) % 360.0 for x, y, _ in bolt_centers)
for x, y, _ in bolt_centers:
check(close(hypot(x, y), 30.0, COMPLEX_TOL), "bolt hole is off 60 mm bolt circle")
gaps = [(angles[(i + 1) % 6] - angles[i]) % 360.0 for i in range(6)]
check(all(close(gap, 60.0, ANGLE_TOL) for gap in gaps), "bolt holes are not spaced 60 degrees apart")
check(torus_count(shape) >= 2, "missing top/bottom outer edge fillets")
def validate_03(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (80.0, 50.0, 58.0), (-40.0, -25.0, 0.0))
for x in (-25.0, 25.0):
expect_cylinder_at(shape, radius=3.0, axis="z", x=x, y=-10.0)
expect_cylinder_at(shape, radius=3.0, axis="y", x=x, z=30.0)
check(len(cyl_faces(shape, radius=3.0, axis="z")) == 2, "base hole count is not two")
check(len(cyl_faces(shape, radius=3.0, axis="y")) == 2, "back plate hole count is not two")
check(len(shape.faces()) >= 20, "missing gusset/fillet detail")
def validate_04(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (120.0, 30.0, 30.0), (0.0, -15.0, -15.0))
check(len(cyl_faces(shape, radius=10.0, axis="x")) == 2, "expected two 20 mm shaft sections")
check(len(cyl_faces(shape, radius=15.0, axis="x")) == 1, "expected one 30 mm shaft section")
keyway_planes = plane_faces_at(shape, axis="z", coordinate=12.0)
check(any(close(face.bounding_box().size.X, 40.0) and close(face.bounding_box().size.Y, 6.0) for face in keyway_planes), "keyway floor is not 40 x 6 mm at 3 mm depth")
check(cone_count(shape) >= 2, "missing 1 mm end chamfers")
def validate_05(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (100.0, 70.0, 30.0), (-50.0, -35.0, 0.0))
check(any(close(face.bounding_box().size.X, 94.0) and close(face.bounding_box().size.Y, 64.0) for face in plane_faces_at(shape, axis="z", coordinate=3.0)), "inside floor/wall thickness check failed")
for x in (-35.0, 35.0):
for y in (-25.0, 25.0):
expect_cylinder_at(shape, radius=5.0, axis="z", x=x, y=y)
expect_cylinder_at(shape, radius=1.5, axis="z", x=x, y=y)
check(len(cyl_faces(shape, radius=5.0, axis="z")) == 4, "standoff count is not four")
check(len(cyl_faces(shape, radius=1.5, axis="z")) == 4, "blind hole count is not four")
check(len(cyl_faces(shape, radius=2.0, axis="z")) >= 4, "missing 2 mm outside vertical corner fillets")
def validate_06(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (120.0, 60.0, 52.0), (-60.0, -30.0, 0.0))
expect_cylinder_at(shape, radius=7.0, axis="y", x=0.0, z=34.0)
for x in (-45.0, 45.0):
for y in (-20.0, 20.0):
expect_cylinder_at(shape, radius=3.5, axis="z", x=x, y=y)
check(len(cyl_faces(shape, radius=7.0, axis="y")) == 2, "clevis through-hole should pass through two separate lugs")
check(len(cyl_faces(shape, radius=3.5, axis="z")) == 4, "base mounting hole count is not four")
check(len(cyl_faces(shape, radius=2.0, axis="x")) >= 2, "missing lug-to-base radius detail")
check(len(shape.faces()) >= 90, "missing cutout/rib/lug detail")
def validate_07(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (79.1, 70.0, 92.7), (-35.0, -35.0, 0.0), tol=COMPLEX_TOL)
check(len(cyl_faces(shape, radius=31.0, axis="z")) == 12, "cooling fin count is not 12")
check(len(cyl_faces(shape, radius=2.5, axis="z")) == 6, "mounting hole count is not six")
check(len(cyl_faces(shape, radius=18.0, axis="z")) >= 1, "missing 36 mm barrel")
check(len(cyl_faces(shape, radius=22.0, axis="z")) == 1, "missing 44 mm top cap")
boss = cyl_faces(shape, radius=6.0)
bore = [face for face in cyl_faces(shape, radius=2.5) if axis_name(face.axis_of_rotation.direction) == "x"]
check(len(boss) == 1 and len(bore) == 1, "missing angled boss or boss bore")
for face in (boss[0], bore[0]):
direction = face.axis_of_rotation.direction
angle = degrees(atan2(abs(direction.Z), abs(direction.X)))
check(close(angle, 35.0, ANGLE_TOL), "boss axis is not 35 degrees upward")
check(torus_count(shape) >= 24, "missing fin/flange edge fillets")
def validate_08(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (90.0, 90.0, 28.0), (-45.0, -45.0, 0.0), tol=COMPLEX_TOL)
expect_cylinder_at(shape, radius=4.0, axis="z", x=0.0, y=0.0)
check(len(cyl_faces(shape, radius=13.0, axis="z")) == 1, "missing 26 mm hub")
check(len(shape.faces()) >= 220, "missing repeated curved blade detail")
check(torus_count(shape) >= 2, "missing backplate edge fillets")
def validate_09(shape):
assert_common(shape, min_solids=43)
assert_bbox(shape, (137.0, 137.0, 140.0), (-68.5, -68.5, 0.0), tol=COMPLEX_TOL)
check(len(cyl_faces(shape, radius=7.0, axis="z")) == 1, "missing central column")
check(len(cyl_faces(shape, radius=45.0, axis="z")) == 1, "missing base disk")
check(len(cyl_faces(shape, radius=62.0, axis="z")) == 20, "tread outer radius/count failed")
check(len(cyl_faces(shape, radius=10.0, axis="z")) == 20, "tread inner radius/count failed")
check(len(cyl_faces(shape, radius=1.5, axis="z")) == 20, "baluster count failed")
check(len(shape.solids()) == 43, "staircase assembly should have 43 solids")
def validate_10(shape):
assert_common(shape, solids=9)
assert_bbox(shape, (140.0, 140.0, 14.0), (-70.0, -70.0, -5.0), tol=COMPLEX_TOL)
check(len(cyl_faces(shape, radius=3.0, axis="z")) == 3, "planet pin count failed")
check(len(cyl_faces(shape, radius=5.0, axis="z")) == 1, "sun bore failed")
check(len(shape.faces()) >= 550 and len(shape.edges()) >= 1600, "gear tooth detail is missing")
VALIDATORS = [
("1. Rectangular calibration block with four holes", "benchmark_01_rectangular_calibration_block.step", validate_01),
("2. Circular flange with bolt-hole pattern", "benchmark_02_circular_flange.step", validate_02),
("3. L-bracket with gussets and two hole directions", "benchmark_03_l_bracket.step", validate_03),
("4. Stepped shaft with keyway", "benchmark_04_stepped_shaft_keyway.step", validate_04),
("5. Open-top electronics enclosure with bosses", "benchmark_05_open_top_electronics_enclosure.step", validate_05),
("6. Aerospace-style clevis bracket with lightening cutouts", "benchmark_06_clevis_bracket_lightening_cutouts.step", validate_06),
("7. Radial-engine-style cylinder with cooling fins", "benchmark_07_radial_engine_cylinder.step", validate_07),
("8. Centrifugal impeller with backward-curved blades", "benchmark_08_centrifugal_impeller.step", validate_08),
("9. Spiral staircase with helical handrail", "benchmark_09_spiral_staircase.step", validate_09),
("10. Simplified planetary gear stage", "benchmark_10_planetary_gear_stage.step", validate_10),
]
def main() -> int:
failures = []
for title, filename, validator in VALIDATORS:
step_path = ROOT / filename
try:
shape = import_step(step_path)
validator(shape)
except Exception as exc:
failures.append((title, exc))
print(f"FAIL {title}: {exc}")
else:
print(f"PASS {title}")
if failures:
print(f"\n{len(failures)} benchmark(s) failed.")
return 1
print("\nAll benchmark checks passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())