Assembly Build Cycle

If a Part is a blueprint for an atomic component, then an Assembly is a blueprint for, not just where those components fit together, but also the routine to determining how they fit together, and how they may be altered to be assembled.

An Assembly is built in the following order:

  1. add components (with make_components())
  2. add constraints (with make_constraints())
  3. solve constraints (places components)
  4. alter parts (with make_alterations())
  5. go back to step 1 (more on this in Multiple build cycles)

Single build cycle

For a single build cycle, we implement:

To illustrate let’s make a plate with cylinder sticking out of it. The plate will have a hole to fasten the cylinder. The hole will be in a configurable location, and mounted at an angle.

Cylinder Part

import cadquery
import cqparts
from cqparts.params import *
from cqparts.display import display, render_props
from cqparts.utils.geometry import CoordSystem
from cqparts.constraint import Mate

class Cylinder(cqparts.Part):
    diam = PositiveFloat(10, doc="cylinder's diameter")
    length = PositiveFloat(10, doc="cylinder's length")
    embed = PositiveFloat(2, doc="embedding distance")
    hole_diam = PositiveFloat(2.72, doc="pilot hole diam")

    _render = render_props(alpha=0.8)

    def make_base_cylinder(self):
        # This is used as a basis for make() and cutaway()
        return cadquery.Workplane('XY') \
            .circle(self.diam/2).extrude(self.embed + self.length)

    def make(self):
        # Use the base cylindrical shape, and cut a hole through it
        return self.make_base_cylinder() \
            .faces(">Z").hole(self.hole_diam / 2)

    @property
    def cutaway(self):
        # Use the base cylindrical shape, no alterations
        return self.make_base_cylinder()

    @property
    def mate_embedded(self):
        return Mate(self, CoordSystem((0, 0, self.embed)))
display(Cylinder())

Plate Part

from math import sin, cos, radians

class Plate(cqparts.Part):
    length = PositiveFloat(20, doc="plate length")
    width = PositiveFloat(20, doc="plate width")
    thickness = PositiveFloat(10, doc="plate thickness")
    hole_diam = PositiveFloat(3, doc="hole diameter")
    connection_offset = Float(4, doc="hole's offset from plate center along x-axis")
    connection_angle = Float(15, doc="angle of mate point")

    def make(self):
        plate = cadquery.Workplane('XY') \
            .box(self.length, self.width, self.thickness)
        hole_tool = cadquery.Workplane('XY', origin=(0, 0, -self.thickness * 5)) \
            .circle(self.hole_diam / 2).extrude(self.thickness * 10)
        hole_tool = self.mate_hole.local_coords + hole_tool
        return plate.cut(hole_tool)

    @property
    def mate_hole(self):
        return Mate(self, CoordSystem(
            origin=(self.connection_offset, 0, self.thickness/2),
            xDir=(1, 0, 0),
            normal=(0, -sin(radians(self.connection_angle)), cos(radians(self.connection_angle))),
        ))

Note that the hole through the plate is at an angle (by default \(15°\))

display(Plate())

Demo Assembly

And finally, lets combine the two to fully utilise a single build cycle.

from cqparts.constraint import Fixed, Coincident

class Thing(cqparts.Assembly):

    # Components are updated to self.components first
    def make_components(self):
        return {
            'pla': Plate(),
            'cyl': Cylinder(),
        }

    # Then constraints are appended to self.constraints (second)
    def make_constraints(self):
        plate = self.components['pla']
        cylinder = self.components['cyl']
        return [
            Fixed(
                mate=plate.mate_origin,
                world_coords=CoordSystem(origin=(-1,-5,-2), xDir=(-0.5,1,0))  # a bit of random placement
            ),
            Coincident(
                mate=cylinder.mate_embedded,
                to_mate=plate.mate_hole,
            ),
        ]

    # In between updating components, and adding constraints:
    #   self.solve() is run.
    # This gives each component a valid world_coords value, so
    # we can use it in the next step...

    # Lastly, this function is run (any return is ignored)
    def make_alterations(self):
        # get Cylinder's location relative to the Plate
        coords = self.components['cyl'].world_coords - self.components['pla'].world_coords
        # apply that to the "cutout" we want to subtract from the plate
        cutout = coords + self.components['cyl'].cutaway
        self.components['pla'].local_obj = self.components['pla'].local_obj.cut(cutout)

I got a bit lazy with the parameters there; Thing doesn’t take any.

But in the end we get:

thing = Thing()
display(thing)

But more importantly, the plate’s geometry now looks like this; displayed in its local part coordinates:

display(thing.find('pla'))

The Plate geometry is altered when used in the Thing Assembly, and the changes to Plate could be completely different if used in a different Assembly.

But Why?: Consider a builder constructing a wall, a part they may use is a length of timber. During the wall’s construction, the timber gets holes in it, from nails, screws, bolts, or perhaps water pipes are threaded through. All of this detail has nothing to do with the timber part before it’s assembled. The alterations to the length of timber is the onus of the assembly; the wall itself.

Multiple build cycles

The cycles that make an assembly can be run multiple times in one build.

To do this, an assembly can return a generator using yield as opposed to a return statement.

To demonstrate, let’s replace the role of the Coincident by stacking some primative shapes using only Fixed (a needless restriction, but it serves as a good example).

To simplify things, we’re going to use the Part classes registered in the cqparts_misc.basic module.

from cqparts_misc.basic.primatives import Cube, Box, Sphere

class BlockStack(cqparts.Assembly):
    def make_components(self):
        print("make Box 'a'")
        yield {'a': Box(length=10, width=10, height=20)}  # grey

        print("make 2 Cubes 'b', and 'c'")
        yield {
            'b': Cube(size=8, _render={'color': (255, 0, 0)}),  # red
            'c': Cube(size=3, _render={'color': (0, 255, 0)}),  # green
        }

        print("make sphere 'd'")
        yield {'d': Sphere(radius=3, _render={'color': (0, 0, 255)})}  # blue

    def make_constraints(self):
        print("place 'a' at origin")
        a = self.components['a']
        yield [Fixed(a.mate_origin, CoordSystem((0,0,-10)))]

        print("place 'b' & 'c' relative to 'a'")
        b = self.components['b']
        c = self.components['c']
        yield [
            Fixed(b.mate_bottom, a.world_coords + a.mate_pos_x.local_coords),
            Fixed(c.mate_bottom, a.world_coords + a.mate_neg_y.local_coords),
        ]

        print("place sphere 'd' on cube 'b'")
        d = self.components['d']
        yield [Fixed(d.mate_origin, b.world_coords + b.mate_pos_x.local_coords)]

    def make_alterations(self):
        print("first round alteration(s)")
        yield
        print("second round alteration(s)")
        yield
        print("third round alteration(s)")
        yield

block_stack = BlockStack()
block_stack.build()

Note that in the 2nd and 3rd yield statements of make_constraints() we reference the .world_coords of some components. That’s because solve() is run after each list of constraints is yielded.

When the assembly is built, we can see the print statements occur in the order:

make Box 'a'
place 'a' at origin
first round alteration(s)
make 2 Cubes 'b', and 'c'
place 'b' & 'c' relative to 'a'
second round alteration(s)
make sphere 'd'
place sphere 'd' on cube 'b'
third round alteration(s)

And the final result:

display(block_stack)