270 lines
12 KiB
Python
270 lines
12 KiB
Python
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())
|