From 3f5323afcda18da5c0d7370edfb2c3a9364177d6 Mon Sep 17 00:00:00 2001 From: Richard Ward Date: Thu, 17 Jul 2025 09:19:07 +0100 Subject: [PATCH] Add declarative motor bracket example and builder helpers --- parametric_cad/__init__.py | 5 +- parametric_cad/core.py | 20 +++++++- .../examples/declarative_motor_bracket.py | 47 +++++++++++++++++++ parametric_cad/primitives/cylinder.py | 26 +++++++--- tests/test_primitives.py | 11 ++++- 5 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 parametric_cad/examples/declarative_motor_bracket.py diff --git a/parametric_cad/__init__.py b/parametric_cad/__init__.py index f633efd..ce22fd0 100644 --- a/parametric_cad/__init__.py +++ b/parametric_cad/__init__.py @@ -1,7 +1,8 @@ """Consolidated parametric_cad package.""" -from .core import tm, safe_difference +from .core import tm, safe_difference, combine from .primitives.box import Box +from .primitives.cylinder import Cylinder from .primitives.gear import SpurGear from .primitives.sprocket import ChainSprocket from .mechanisms.butthinge import ButtHinge @@ -10,7 +11,9 @@ from .export.stl import STLExporter __all__ = [ "tm", "safe_difference", + "combine", "Box", + "Cylinder", "SpurGear", "ChainSprocket", "ButtHinge", diff --git a/parametric_cad/core.py b/parametric_cad/core.py index 00404f8..8162455 100644 --- a/parametric_cad/core.py +++ b/parametric_cad/core.py @@ -7,6 +7,7 @@ wrapper allows the backend to be swapped or mocked easily. """ import trimesh as _trimesh +from typing import Iterable, Any def safe_difference(mesh, other, *, engine="scad"): @@ -42,4 +43,21 @@ def safe_difference(mesh, other, *, engine="scad"): # importing ``trimesh`` themselves. tm = _trimesh -__all__ = ["tm", "safe_difference"] +def combine(objects: Iterable[Any]) -> _trimesh.Trimesh: + """Return a union of ``objects``. + + Each object may be a :class:`~trimesh.Trimesh` or have a ``mesh`` + method returning one. + """ + meshes = [] + for obj in objects: + if isinstance(obj, _trimesh.Trimesh): + meshes.append(obj) + elif hasattr(obj, "mesh"): + m = obj.mesh + meshes.append(m() if callable(m) else m) + else: + raise TypeError(f"Object {obj!r} cannot be converted to a mesh") + return _trimesh.util.concatenate(meshes) + +__all__ = ["tm", "safe_difference", "combine"] diff --git a/parametric_cad/examples/declarative_motor_bracket.py b/parametric_cad/examples/declarative_motor_bracket.py new file mode 100644 index 0000000..eebca4c --- /dev/null +++ b/parametric_cad/examples/declarative_motor_bracket.py @@ -0,0 +1,47 @@ +from math import pi + +from parametric_cad.primitives.box import Box +from parametric_cad.primitives.cylinder import Cylinder +from parametric_cad.core import combine, safe_difference +from parametric_cad.export.stl import STLExporter + +# Basic dimensions for a 540/550 motor bracket +BASE_LENGTH = 50.0 +BASE_WIDTH = 40.0 +PLATE_HEIGHT = 40.0 +THICKNESS = 3.0 +MOTOR_HOLE_SPACING = 25.0 +MOTOR_HOLE_DIAMETER = 3.2 +SHAFT_CLEARANCE_DIAMETER = 10.0 +MOTOR_MOUNT_HEIGHT = 20.0 + +# Create the two plates of the bracket +base = Box(BASE_LENGTH, BASE_WIDTH, THICKNESS).at(0, 0, 0) +plate = Box(BASE_LENGTH, THICKNESS, PLATE_HEIGHT).at( + 0, BASE_WIDTH - THICKNESS, THICKNESS +) + +# Union the plates into a single mesh +bracket = combine([base, plate]) + +# Define mounting and shaft clearance holes +hole_y = BASE_WIDTH - THICKNESS / 2 +hole_z = THICKNESS + MOTOR_MOUNT_HEIGHT +holes = [ + Cylinder(MOTOR_HOLE_DIAMETER / 2, THICKNESS + 0.2) + .rotate([1, 0, 0], pi / 2) + .at(BASE_LENGTH / 2 - MOTOR_HOLE_SPACING / 2, hole_y, hole_z), + Cylinder(MOTOR_HOLE_DIAMETER / 2, THICKNESS + 0.2) + .rotate([1, 0, 0], pi / 2) + .at(BASE_LENGTH / 2 + MOTOR_HOLE_SPACING / 2, hole_y, hole_z), + Cylinder(SHAFT_CLEARANCE_DIAMETER / 2, THICKNESS + 0.2) + .rotate([1, 0, 0], pi / 2) + .at(BASE_LENGTH / 2, hole_y, hole_z), +] + +# Subtract holes from the bracket body +bracket = safe_difference(bracket, [h.mesh() for h in holes]) + +# Export result +exporter = STLExporter(output_dir="output/declarative_motor_bracket_output") +exporter.export_mesh(bracket, "declarative_motor_bracket") diff --git a/parametric_cad/primitives/cylinder.py b/parametric_cad/primitives/cylinder.py index 6203f78..26bf960 100644 --- a/parametric_cad/primitives/cylinder.py +++ b/parametric_cad/primitives/cylinder.py @@ -1,18 +1,32 @@ from parametric_cad.core import tm +from typing import Sequence, Optional class Cylinder: - def __init__(self, radius, height, sections=32): + def __init__(self, radius: float, height: float, sections: int = 32): self.radius = radius self.height = height self.sections = sections - self._position = (0, 0, 0) + self._position = (0.0, 0.0, 0.0) + self._rotation: Optional[Sequence[float]] = None - def at(self, x, y, z): + def at(self, x: float, y: float, z: float) -> "Cylinder": self._position = (x, y, z) return self - def mesh(self): - cyl = tm.creation.cylinder(radius=self.radius, height=self.height, - sections=self.sections) + def rotate(self, axis: Sequence[float], angle: float) -> "Cylinder": + """Rotate the cylinder around ``axis`` by ``angle`` radians.""" + self._rotation = (axis, angle) + return self + + def mesh(self) -> tm.Trimesh: + cyl = tm.creation.cylinder( + radius=self.radius, + height=self.height, + sections=self.sections, + ) + if self._rotation is not None: + axis, angle = self._rotation + rot = tm.transformations.rotation_matrix(angle, axis) + cyl.apply_transform(rot) cyl.apply_translation(self._position) return cyl diff --git a/tests/test_primitives.py b/tests/test_primitives.py index a2c7b43..c15bb7c 100644 --- a/tests/test_primitives.py +++ b/tests/test_primitives.py @@ -1,6 +1,6 @@ import pytest import numpy as np -from parametric_cad.core import tm, safe_difference +from parametric_cad.core import tm, safe_difference, combine from math import cos, sin, pi from parametric_cad.primitives.box import Box @@ -44,6 +44,15 @@ def test_safe_difference_returns_mesh(): assert isinstance(result, tm.Trimesh) +def test_combine_and_rotation(): + box = Box(1.0, 1.0, 1.0) + cyl = Cylinder(radius=0.5, height=2.0).rotate([1, 0, 0], pi / 2) + combined = combine([box, cyl]) + assert isinstance(combined, tm.Trimesh) + # Cylinder rotated around X should extend its height along Y axis + assert combined.extents[1] >= 2.0 + + def test_chain_sprocket_properties_and_mesh(): sprocket = ChainSprocket(pitch=12.7, roller_diameter=7.75, teeth=10) expected_pitch_dia = 2 * sprocket.pitch / (2 * sin(pi / sprocket.teeth))