Assembly 3D transformations

78 views
Skip to first unread message

neri-engineering

unread,
Nov 1, 2024, 5:31:44 AM11/1/24
to CadQuery
I think I already know the answer to my own question but I will ask it anyways in case I'm blatantly missing something obvious here.  I would like to file a feature request with good, detailed explanation shortly, but first a sanity check.  (Also it is my suspicion that Assembly is a very new API, but it can be immensely useful for grouping parts together, just like in any other 3D rendering or CAD modeling software - i.e. hierarchy.)


So, Workplane has this ability to move itself in 3D space.

rotate(axisStartPoint= (0,0,0),
       axisEndPoint= (0,1,0),
       angleDegrees= deg_theta)

translate((x, y, z))

However I was going to ask if there is a preferred way to move an Assembly relative to other assemblies, or relative to absolute coordinates, after the Assembly has already been assembled.

Sent with Proton Mail secure email.

Adam Urbanczyk

unread,
Nov 1, 2024, 12:42:54 PM11/1/24
to CadQuery
Assemblies are meant to be mostly immutable. What is the use case? You can always assemble the assy again.

Alternatives:

1. Add it as a subassy and specify the location .add(assy, loc=Location(...)) (see: https://cadquery.readthedocs.io/en/latest/classreference.html#cadquery.Assembly.add
2. Modify the .loc field by hand

neri-engineering

unread,
Nov 1, 2024, 1:30:33 PM11/1/24
to Adam Urbanczyk, CadQuery
Good question.  My use case is to use these parts groupings to quickly see how the entirety of the device behaves, with respect to clearances etc., or for computer animations.

Here is a classic example of that.  It's a car differential.  From the perspective of the case which holds the little bevel gears in a circle, it's not moving; it's stationary.  The little bevel gears inside will be at an angle which is somehow defined by the angle of right drive shaft (from its perspective).  The left drive shaft is the negative of that (from its perspective).  That is one assembly - it's the housing which has the little gears, plus the little gears at some angle which is a function of right drive shaft.

But the entire array of little bevel gears is spinning.  The entire assembly has some angle which is a function of the main car driveshaft angle of rotation, that's the shaft which delivers power from the front engine, and the main drive shaft meets the bevel ring gear which is rotating fixed with the container which houses those little differential bevel gears.

So I want to visualize how the thing will be, with these two inputs - angle of rotation of main drive shaft, and the right half shaft angle w.r.t. the differential case.  Instead of calculating every little angle and offset, and redoing it for each state, I instead group them into assemblies, where each assembly is governed by some constraints.  In this case one assembly is inside another, and when the angle of the outer assembly changes it affects the angles of the little things inside.  This sort of technique is always used.  It's just good organization.

This is a classic case of 3D computer graphics or CAD modeling, where in order to bring little parts into absolute coordinates you simply do matrix multiplication, by recursing into the tree structure of parts hierarchies.

I know this would be useful as a feature, in fact I would say it's rather necessary as a feature.  I could even implement it and submit a patch.  However I'm super busy trying to get my design manufactured as a prototype, in the next few weeks.

Sent with Proton Mail secure email.

--
cadquery home: https://github.com/CadQuery/cadquery
post issues at https://github.com/CadQuery/cadquery/issues
run it at home at : https://github.com/CadQuery/CQ-editor
---
You received this message because you are subscribed to the Google Groups "CadQuery" group.
To unsubscribe from this group and stop receiving emails from it, send an email to cadquery+u...@googlegroups.com.
To view this discussion visit https://groups.google.com/d/msgid/cadquery/d287f8a2-4118-4ada-9162-bd4fc2a1be74n%40googlegroups.com.

Adam Urbanczyk

unread,
Nov 1, 2024, 3:38:41 PM11/1/24
to CadQuery
So what prevents you from making a new static assy with you parts as subassys? That operation is very cheap. You can also use .loc, it is a public API. There is no need to patch anything AFAICT.

neri-engineering

unread,
Nov 1, 2024, 3:58:02 PM11/1/24
to Adam Urbanczyk, CadQuery
I'll try it.
We'll see how it goes.
I will report back with further insights, if I have any.


Sent with Proton Mail secure email.

Neri

unread,
Nov 2, 2024, 4:39:20 PM11/2/24
to CadQuery
I got "transformations" to work for Assembly but I hit another issue.  It's that AssemblyObjects in assembly.py does not include Assembly, only Shape and Workplane (and 'None'??).

In other words an Assembly cannot contain other Assembly objects as children.  I feel that this is a change that needs to happen.  Nesting of objects, with hierarchies, is a standard practice in the area of computer graphics and CAD modeling.  Again, the differential transfer case is an assembly which contains little bevel gears, but that transfer case is rotating.  The differential is an assembly w.r.t. the car.  The diff may be bouncing up and down as the car goes over bumps.  This sort of nesting is extremely important for proper organization of work.  For now CadQuery is very good and powerful at creating individual pieces, but not for visualizing the entire project as a whole, due to some very minor shortcomings which are super easy to fix.

I will likely share my transformation code; it's working flawlessly, but needs at least a few hours to mature, before I share it.

Adam Urbanczyk

unread,
Nov 2, 2024, 5:37:37 PM11/2/24
to CadQuery

Neri

unread,
Nov 3, 2024, 12:01:28 AM11/3/24
to CadQuery
Ah, I was trying to start with the Assembly() constructor, passing as its object an Assembly.  Apparently you can add() an Assembly to an Assembly but you can't do it like that with constructor?  Maybe I'll just create an empty Assembly and then add whatever I need inside of it.  Probably a reason why it's this way... Assembly(Assembly) may be ambiguous, i.e. are you trying to create a copy or are you trying to nest, sir?

We'll see how I can get my transformation code to work now with nested assemblies, there's a good chance it'll just work they way it is currently.  Please hold tight, I will likely share my progress on the transformation code...

neri-engineering

unread,
Nov 3, 2024, 4:48:00 AM11/3/24
to Neri, CadQuery
Okay, I'm getting good results.  First the results:


assembly.png


The exact code which generates this, except for the dependencies & imports:


theta = 23  # Angle of cross w.r.t. eyelet.
phi = 45  # Angle of rotation (z axis) of entire assembly.

eyelet = green_eyelet_assy(show_screw= True, cutaway= True)  # Assembly object.
cross = red_cross_assy(show_minor_pin= True)  # Assembly object.

cross = rotateX(cross, theta)  # My special functions, handle all object types.

both = Assembly()
both.add(eyelet)
both.add(cross)

both = rotateZ(both, phi/2)  # Again my special functions.  Showing that
both = rotateZ(both, phi/2)  # compounding transforms works too.  But is the
                             # order of matrix multiplication correct?  Yes,
                             # this example does not show it but I have the
                             # matrix multiplication order correct.

show_object(both)


As you can see.  A few things to note.  The red cross assembly contains the center pivot pin also, because it's a piece that's tight-fitting inside the red cross, but fits less tightly in the green eyelet.  So it's part of the red cross assembly, as it will be following that assembly - we can consider the parts of the red cross assembly to be moving as one rigid body.

It's good to note that rotating the assembly has no effect on the underlying Workplane and Shape objects - this is expected and desired.  The transformations only affect the Location object associated with the Assembly.

In the example above you'll note that I can combine assemblies into a larger Assembly.  When I rotate that larger Assembly along the Z axis, I get the desired result.  Moreover, I can do two smaller Z rotations and the result is compounded, as expected.  Am I multiplying the matrices in the correct order?  Yes I am, I tried more complex examples and they turned out correctly.

So this is exactly what I was talking about.  It's a use case.  The assembly consisting of both red & green will be rotating relative to something else, and so on.  It's a highly complex system of parts.  But I have all of the angles computed and derived.

You'll notice that I have these "rotateZ()" functions and so on.  This is the secret sauce that I have not shared yet.  The code is still maturing, at least for a few more hours.

Sent with Proton Mail secure email.

Neri

unread,
Nov 5, 2024, 5:47:49 PM11/5/24
to CadQuery
Hello dear readers,

I think I'd better wrap this up with my findings before I forget, I'm so busy that otherwise I'll forget that I left some strings untied.

Nesting of assemblies is extremely important for organizing work.  I now interpret the 'loc' associated with Assembly to mean, w.r.t. the parent (or "containing") frame of reference, where is the assembly placed?  Where is it located, what is its orientation?  The matrix tells you that.  This is good 3D computer graphics organizational practice.

As an example, this was one of my previous projects in OpenSCAD, where there were multiple frames of reference.  You can think of each object being an assembly, in other words there are six rigid bodies rotating independently.  The hierarchy looked like so:

hierarchy.png

You can see the nesting between frames of reference, but in each nesting there is essentially one rotation along one axis, except for the last piece, the green shaft.  Each of the angles ('delta', 'beta', 'omega', 'alpha', etc) is defined as a function which takes the same two input parameters: white_theta and gamma.  In the CadQuery world each of these six objects, white_shaft, orange_ring_gear, blue_ring, purple_bevel_gear, red_inner_cross, & green_shaft, would be an Assembly consisting of multiple pieces (e.g. screws, shims, etc, each having a different color).  So it's totally obvious to me that rotations/translations on Assemblies are A MUST but are you convinced of that?

bearing-pinch.png

So my transformation code is working, and handles all object types: Workplane, Shape, & Assembly.  While Workplane & Shape have the .translate() and .rotate() member functions, I can't hack the Assembly source code to do that, so I'm using a different approach, and "external tranformator" sort of coding pattern.  But the important part is that my code works on all object types, I can transform more than once and the transform will be concatenated in the correct order, and the nesting between assemblies is also working w.r.t. transformations.  The full listing of the code is here: https://svn.code.sf.net/p/nl10/code/cq-code/common/transforms.py

I'm pasting an incomplete snippet of the code linked above to this correspondence, for reference.  You can use that code, to implement the .rotate() and .translate() in Assembly class if you like.  I'm not going to go through that effort right now.  Back to work, I have so many important things to take care of.  Have a nice day.



import math
from cadquery import Assembly
from cadquery import Location
from cadquery import Shape
from cadquery import Workplane as Workplane
from OCP.gp import gp_Trsf

# Returns an angle equivalent to the input angle but in the range [0,360).
# Negative and positive input values accepted.  E.g., -21.5 -> 338.5.
def deg_canonical_360(deg_theta):
    return deg_theta - math.floor(deg_theta / 360) * 360

# Convenience method for translating a Workplane, a Shape, or an Assembly, in
# CadQuery.  The 'x', 'y', and 'z' all default to zero, such that only single
# values could be explicitly specified.
def translate(piece, x= 0, y= 0, z= 0):
    return _transform_3D_general(piece, __translate(x, y, z))

def __translate(x, y, z):  # Ooohhh, double underscore!  These return xforms.
    return (( 1,  0,  0,  x ),
            ( 0,  1,  0,  y ),
            ( 0,  0,  1,  z ))

# Convenience method for mirroring a Workplane, a Shape, or an Assembly in
# CadQuery, along the z axis.  More specifically, all z values of coordinates
# are negated.  An even number of reflections, of any kind, brings the object
# to a state which can be reached via rigid body transform.
def mirrorZ(piece):
    return _scale(piece, z= -1)

# Performs a uniform scale in 3D.  The 'piece' can be a Workplane, a Shape, or
# an Assembly, in CadQuery.
def scale(piece, scale_f= 1):
    return _scale(piece, x= scale_f, y= scale_f, z= scale_f)

# Internal function which potentially performs non-uniform scale operations in
# 3D.
def _scale(piece, x= 1, y= 1, z= 1):
    return _transform_3D_general(piece, __scale(x, y, z))

def __scale(x, y, z):  # Ooohhh, double underscore!  These return xforms.
    return (( x,  0,  0,  0 ),
            ( 0,  y,  0,  0 ),
            ( 0,  0,  z,  0))

# Convenience function for rotating a piece around the z axis, right hand rule.
# The 'piece' can be a Workplane, a Shape, or an Assembly, in CadQuery.  90°
# orthogonal rotations, and multiples thereof, are handled as special cases in
# order to provide mathematical exactness.  The default value for 'degrees' is
# 90, such that a shorthand 'rotateZ(piece)' could be used to rotate an object
# by 90° along the z axis.
def rotateZ(piece, degrees= 90):
    return _transform_3D_general(piece, __rotateZ(degrees))

def __rotateZ(degrees):  # Ooohhh, double underscore!  These return xforms.
    canonical = deg_canonical_360(degrees)
    xform = None
    if canonical ==   0:  xform = (( 1,  0,  0,  0 ),  ########################
                                   ( 0,  1,  0,  0 ),
                                   ( 0,  0,  1,  0 ))
    if canonical ==  90:  xform = (( 0, -1,  0,  0 ),  ########################
                                   ( 1,  0,  0,  0 ),
                                   ( 0,  0,  1,  0 ))
    if canonical == 180:  xform = ((-1,  0,  0,  0 ),  ########################
                                   ( 0, -1,  0,  0 ),
                                   ( 0,  0,  1,  0 ))
    if canonical == 270:  xform = (( 0,  1,  0,  0 ),  ########################
                                   (-1,  0,  0,  0 ),
                                   ( 0,  0,  1,  0 ))
    if xform is None:
        angle = math.radians(degrees)
        sa    = math.sin(angle)
        ca    = math.cos(angle)
        xform = (( ca, -sa,   0,   0 ),
                 ( sa,  ca,   0,   0 ),
                 (  0,   0,   1,   0 ))
    return xform

# Performs a general rotation through an axis which begins at the origin, whose
# direction is specified by the 3-tuple 'axis' (not necessarily normalized).
# The 'piece' can be a Workplane, a Shape, or an Assembly, in CadQuery.
# Special handling of 90° rotations (or multiples thereof) along the x,y,z
# coordinate axes is not done by this function; please use the other rotation
# functions for that.  To rotate about an axis that isn't passing through the
# origin, it is recommended to first translate the piece, then rotate using
# this function, then translate by the inverse translation, or to do it all in
# one go by multipliying the corresponding matrices in the correct order.
def rotate_3D_general(piece, axis, degrees):
    return _transform_3D_general(piece, __rotate_3D_general(axis, degrees))

def __rotate_3D_general(axis, degrees):  # Double underscore!  Returns xforms.
    veclen = math.sqrt(pow(axis[0], 2) + pow(axis[1], 2) + pow(axis[2], 2))
    x      = axis[0] / veclen
    y      = axis[1] / veclen
    z      = axis[2] / veclen
    angle  = math.radians(degrees)
    sa     = math.sin(angle)
    omca   = 1 - math.cos(angle)
    xform  = (( 1-omca*(z*z+y*y),    -z*sa+x*y*omca,     y*sa+z*x*omca,   0 ),
              (    z*sa+x*y*omca,  1-omca*(z*z+x*x),    -x*sa+z*y*omca,   0 ),
              (   -y*sa+z*x*omca,     x*sa+z*y*omca,  1-omca*(y*y+x*x),   0 ))
    return xform

# This is mostly a demonstration of how to concatenate transforms within this
# API.  Please note that 'axis_end' is literally the tip of the axis of
# rotation; it's NOT a direction vector for the axis of rotation.  The
# direction would in fact be the difference vector, end minus start.
def rotate_3D_arbitrary_axis(piece, axis_start= (0, 0, 0),
                                    axis_end= (0, 0, 1), degrees= 0):
    return _transform_3D_general(piece,
                                 __rotate_3D_arbitrary_axis(axis_start,
                                                            axis_end,
                                                            degrees))

def __rotate_3D_arbitrary_axis(axis_start, axis_end, degrees):
    # Ooohhh, double underscore!  These return xforms.
    tr_toO = __translate(-axis_start[0], -axis_start[1], -axis_start[2])
    tr_rot = __rotate_3D_general((axis_end[0] - axis_start[0],
                                  axis_end[1] - axis_start[1],
                                  axis_end[2] - axis_start[2]), degrees)
    tr_frO = __translate(axis_start[0], axis_start[1], axis_start[2])
    return __multiply_transforms_3D(tr_frO,
                                    __multiply_transforms_3D(tr_rot, tr_toO))

# A Workplane, a Shape, or an Assembly [in CadQuery] can be subjected to an
# arbitrary transformation (usually, but not always, a rigid body motion such
# as rotation or translation).  This exposes that ability.  I cannot advertise
# which sorts of transforms are supported and which ones are not; this depends
# on what the underlying OpenCascade layer accepts, and what it does not.  This
# transform is compounded onto any transforms that the object is already
# subjected to.  This is meant as a low level implementation facility for
# higher-level, user-friendly APIs such as rotate() and translate().
def _transform_3D_general(piece, xform= ((1, 0, 0, 0),
                                         (0, 1, 0, 0),
                                         (0, 0, 1, 0)) ):
    rowX_4tuple = xform[0]
    rowY_4tuple = xform[1]
    rowZ_4tuple = xform[2]
    compound_this_xform = gp_Trsf()
    compound_this_xform.SetValues(
        rowX_4tuple[0], rowX_4tuple[1], rowX_4tuple[2], rowX_4tuple[3],
        rowY_4tuple[0], rowY_4tuple[1], rowY_4tuple[2], rowY_4tuple[3],
        rowZ_4tuple[0], rowZ_4tuple[1], rowZ_4tuple[2], rowZ_4tuple[3])
    if isinstance(piece, Assembly):
        existing_xform = piece.loc.wrapped.Transformation()
        piece.loc = Location(compound_this_xform.Multiplied(existing_xform))
        return piece
    if isinstance(piece, Shape):
        return piece._apply_transform(compound_this_xform)
    if isinstance(piece, Workplane):
        return piece.newObject(
            [
                obj._apply_transform(compound_this_xform)
                if isinstance(obj, Shape)
                else obj
                for obj in piece.objects
            ]
        )
    raise TypeError(
        "_transform_3D_general(): expected Workplane, Shape, or Assembly")

# Pass specified point in 3D through specified transformation.  This is here
# for reference just to increase understanding where there is ambiguity.
def _pass_point_3D_transform(xform= ((1, 0, 0, 0),
                                     (0, 1, 0, 0),
                                     (0, 0, 1, 0)),  pt_3tuple= (0, 0, 0) ):
    rowX_4tuple = xform[0]
    rowY_4tuple = xform[1]
    rowZ_4tuple = xform[2]
    return ( _m44rp(rowX_4tuple, pt_3tuple),
             _m44rp(rowY_4tuple, pt_3tuple),
             _m44rp(rowZ_4tuple, pt_3tuple) )

def _m44rp(row_4tuple, pt_3tuple):
    return (row_4tuple[0] * pt_3tuple[0] +
            row_4tuple[1] * pt_3tuple[1] +
            row_4tuple[2] * pt_3tuple[2] +
            row_4tuple[3])

def __multiply_transforms_3D(xform_apply_last= ((1, 0, 0, 0),
                                                (0, 1, 0, 0),
                                                (0, 0, 1, 0)),
            xform_apply_first= ((1, 0, 0, 0),
                                (0, 1, 0, 0),
                                (0, 0, 1, 0)) ):
    m0 = xform_apply_last[0]
    m1 = xform_apply_last[1]
    m2 = xform_apply_last[2]
    n = ( xform_apply_first[0],
          xform_apply_first[1],
          xform_apply_first[2],
          (0, 0, 0, 1) )
    return (( _m44rc(m0,n,0), _m44rc(m0,n,1), _m44rc(m0,n,2), _m44rc(m0,n,3) ),
            ( _m44rc(m1,n,0), _m44rc(m1,n,1), _m44rc(m1,n,2), _m44rc(m1,n,3) ),
            ( _m44rc(m2,n,0), _m44rc(m2,n,1), _m44rc(m2,n,2), _m44rc(m2,n,3) ))

def _m44rc(row_4tuple, mat_4x4, col):
    return (row_4tuple[0] * mat_4x4[0][col] +
            row_4tuple[1] * mat_4x4[1][col] +
            row_4tuple[2] * mat_4x4[2][col] +
            row_4tuple[3] * mat_4x4[3][col])

Adam Urbanczyk

unread,
Nov 6, 2024, 3:40:32 PM11/6/24
to CadQuery
I'm not convinced at all. cq.Location wraps TopLoc_Location which (roughly) wraps qp_Trsf. 

Why do you want to reinvent an existing API with one that uses gp_Trsf anyway? 

Do you want more cq.Location constructors? 

Did you maybe miss that the following is possible?

loc_t = cq.Location(x=10, y= 20)
loc_rot = cq.Location(rx=45)*cq.Location(ry=30)

2 observations:
1) Note that scaling did result in broken geometries in the past.
2) You are missing the point of assys if you want to use translate and rotate on the underlying {cq.Shape, cq.Workplane} objects. The point being: decoupling of location hierarchy and [sub]components.
Reply all
Reply to author
Forward
0 new messages