Refactored Joints into separate module, integrated with BuildPart -

Issue#226
This commit is contained in:
gumyr 2023-08-24 13:32:35 -04:00
parent 191cfdcba6
commit 63340c1b7d
19 changed files with 138 additions and 795 deletions

View file

@ -615,6 +615,17 @@
<line x1="-74.652943" y1="14.227562" x2="-74.099447" y2="14.121448" />
<line x1="-73.356569" y1="18.98605" x2="-73.173546" y2="18.950961" />
<path d="M -72.09878,16.706271 Q -72.112749,16.606195 -72.125555,16.510115" />
<line x1="-128.876661" y1="32.308173" x2="-112.759048" y2="36.069606" />
<line x1="-96.641435" y1="39.83104" x2="-80.523822" y2="43.592474" />
<line x1="-144.497936" y1="26.008149" x2="-128.380323" y2="29.769583" />
<line x1="-80.027484" y1="41.053885" x2="-63.909871" y2="44.815318" />
<line x1="-112.26271" y1="33.531017" x2="-96.145097" y2="37.292451" />
<path d="M -66.719166,50.621411 A 5.0,3.620891854720831 -76.86376149427704 0,1 -67.855502,50.603708" />
<path d="M -65.582829,40.865383 A 5.0,3.620891854720831 -76.86376149427704 0,1 -66.719166,50.621411" />
<path d="M -64.687406,41.074352 A 5.0,3.620891854720831 -76.86376149427704 0,1 -65.823743,50.830379" />
<path d="M -79.585966,21.975885 A 5.115900974233,3.357932704042375 79.14709695164629 0,1 -79.61497,22.019933" />
<path d="M -82.671784,23.04734 A 5.115900974233,3.357932704042375 79.14709695164629 0,1 -85.457884,15.671632" />
<path d="M -84.900483,14.396641 A 5.115900974233,3.357932704042375 79.14709695164629 0,1 -84.439222,13.828359" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

View file

@ -230,6 +230,14 @@
<line x1="-151.398097" y1="56.0656" x2="-134.961465" y2="53.976305" />
<line x1="-120.834215" y1="52.18056" x2="-101.192531" y2="49.683863" />
<line x1="-87.065282" y1="47.888118" x2="-66.975763" y2="45.334496" />
<line x1="-148.096912" y1="54.524191" x2="-131.212446" y2="52.377971" />
<line x1="-80.559045" y1="45.939308" x2="-63.674578" y2="43.793087" />
<line x1="-114.327979" y1="50.23175" x2="-97.443512" y2="48.085529" />
<line x1="-151.650292" y1="49.121307" x2="-134.765826" y2="46.975086" />
<line x1="-84.112425" y1="40.536424" x2="-67.227958" y2="38.390203" />
<line x1="-117.881359" y1="44.828865" x2="-100.996892" y2="42.682644" />
<path d="M -66.92042,38.271137 A 5.0,3.5251361507946415 -97.24414052335966 0,1 -65.659445,48.191315" />
<path d="M -65.982394,38.151902 A 5.000000233566643,3.5251363154654856 -97.24414052335966 0,1 -64.721419,48.072081" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

View file

@ -314,6 +314,16 @@
<line x1="-117.955706" y1="47.673077" x2="-134.762521" y2="49.781204" />
<line x1="-83.819354" y1="47.624166" x2="-100.626169" y2="49.732294" />
<line x1="-84.342076" y1="43.456822" x2="-101.148891" y2="45.564949" />
<line x1="-147.738653" y1="54.403842" x2="-130.931838" y2="52.295715" />
<line x1="-80.511393" y1="45.971332" x2="-63.704578" y2="43.863205" />
<line x1="-114.125023" y1="50.187587" x2="-97.318208" y2="48.07946" />
<line x1="-151.307975" y1="49.011" x2="-134.50116" y2="46.902872" />
<line x1="-84.080715" y1="40.57849" x2="-67.2739" y2="38.470362" />
<line x1="-117.694345" y1="44.794745" x2="-100.88753" y2="42.686617" />
<path d="M -66.962476,38.354124 A 5.0,3.542198278829656 -97.14943683030337 0,1 -65.7179,48.276373" />
<path d="M -66.028765,38.237006 A 5.0,3.542198278829656 -97.14943683030337 0,1 -64.784188,48.159255" />
<line x1="-131.410037" y1="54.718996" x2="-114.603222" y2="52.610868" />
<line x1="-97.796407" y1="50.502741" x2="-80.989592" y2="48.394613" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

@ -325,6 +325,14 @@
<line x1="-94.645553" y1="60.151243" x2="-90.94109" y2="50.976649" />
<line x1="-127.074086" y1="51.636653" x2="-123.369623" y2="42.462059" />
<line x1="-121.510496" y1="53.883086" x2="-117.806033" y2="44.708492" />
<line x1="-144.5044" y1="26.011968" x2="-128.385434" y2="29.772862" />
<line x1="-80.028536" y1="41.055544" x2="-63.909571" y2="44.816438" />
<line x1="-112.266468" y1="33.533756" x2="-96.147502" y2="37.29465" />
<path d="M -66.718535,50.622524 A 5.0,3.6206548152316524 -76.86664507048847 0,1 -67.854627,50.604861" />
<path d="M -65.582444,40.866423 A 5.0,3.6206548152316524 -76.86664507048847 0,1 -66.718535,50.622524" />
<path d="M -64.686946,41.075361 A 5.0,3.6206548152316524 -76.86664507048847 0,1 -65.823037,50.831463" />
<line x1="-128.881738" y1="32.311461" x2="-112.762772" y2="36.072355" />
<line x1="-96.643806" y1="39.833249" x2="-80.52484" y2="43.594143" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

@ -196,6 +196,10 @@
<line x1="-10.838025" y1="0.068747" x2="-10.838025" y2="-17.786514" />
<line x1="-15.038025" y1="35.779271" x2="-15.038025" y2="17.924009" />
<line x1="-10.838025" y1="35.779271" x2="-10.838025" y2="17.924009" />
<line x1="-15.93575" y1="-20.460509" x2="-15.93575" y2="-2.605247" />
<line x1="-15.93575" y1="15.250014" x2="-15.93575" y2="33.105276" />
<line x1="-8.93632" y1="-19.789636" x2="-8.93632" y2="-1.934374" />
<line x1="-8.93632" y1="15.920888" x2="-8.93632" y2="33.776149" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

View file

@ -361,6 +361,8 @@
<path d="M 0.423725,-2.608461 L 0.29739,-2.583239" />
<line x1="-2.359955" y1="-4.607115" x2="-2.359955" y2="-4.776131" />
<line x1="2.557513" y1="-4.270011" x2="2.557513" y2="-4.776131" />
<path d="M -4.055597,5.260365 A 5.115900974233,3.7783060347967656 -2.358734586570796e-15 0,1 3.078539,-0.015856" />
<path d="M 4.253155,0.850436 A 5.115900974233,3.7783060347967656 -2.358734586570796e-15 0,1 -2.88098,6.126658" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

View file

@ -207,6 +207,15 @@
<line x1="-12.39424" y1="-37.298384" x2="-12.39424" y2="-17.030545" />
<line x1="-12.39424" y1="-2.128481" x2="-12.39424" y2="18.139358" />
<line x1="-12.39424" y1="33.041422" x2="-12.39424" y2="50.190613" />
<line x1="-13.297896" y1="-40.504287" x2="-13.297896" y2="-22.919336" />
<line x1="-13.297896" y1="29.835519" x2="-13.297896" y2="47.42047" />
<line x1="-13.297896" y1="-5.334384" x2="-13.297896" y2="12.250567" />
<line x1="-6.323763" y1="-39.710214" x2="-6.323763" y2="-22.125263" />
<line x1="-6.323763" y1="30.629592" x2="-6.323763" y2="48.214544" />
<line x1="-6.323763" y1="-4.540311" x2="-6.323763" y2="13.04464" />
<path d="M -5.39424,51.167554 A 5.0,3.402724860456303 0.0 1,0 -15.39424,51.167554" />
<path d="M -5.39424,52.144496 A 5.0,3.402724860456303 0.0 1,0 -15.39424,52.144496" />
<path d="M -13.379256,54.570555 A 3.666666666666667,2.4953315643346223 0.0 0,0 -7.409224,51.672321 A 3.666666666666667,2.4953315643346223 0.0 0,0 -13.379256,54.570555" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

View file

@ -94,18 +94,14 @@ Methods and functions specific to exporting and importing build123d objects are
:noindex:
*************
Joint Objects
*************
Joint classes which are used to position Solid and Compound objects relative to each
other are defined below.
************
Joint Object
************
Base Joint class which is used to position Solid and Compound objects relative to each
other are defined below. The :ref:`joints` section contains the class description of the
derived Joint classes.
.. py:module:: topology
:noindex:
.. autoclass:: Joint
.. autoclass:: RigidJoint
.. autoclass:: RevoluteJoint
.. autoclass:: LinearJoint
.. autoclass:: CylindricalJoint
.. autoclass:: BallJoint

View file

@ -1,3 +1,5 @@
.. _joints:
######
Joints
######
@ -10,15 +12,15 @@ in pairs - a :class:`~topology.Joint` can only be connected to another :class:`~
+---------------------------------------+---------------------------------------------------------------------+--------------------+
| :class:`~topology.Joint` | connect_to | Example |
+=======================================+=====================================================================+====================+
| :class:`~topology.BallJoint` | :class:`~topology.RigidJoint` | Gimbal |
| :class:`~joints.BallJoint` | :class:`~joints.RigidJoint` | Gimbal |
+---------------------------------------+---------------------------------------------------------------------+--------------------+
| :class:`~topology.CylindricalJoint` | :class:`~topology.RigidJoint` | Screw |
| :class:`~joints.CylindricalJoint` | :class:`~joints.RigidJoint` | Screw |
+---------------------------------------+---------------------------------------------------------------------+--------------------+
| :class:`~topology.LinearJoint` | :class:`~topology.RigidJoint`, :class:`~topology.RevoluteJoint` | Slider or Pin Slot |
| :class:`~joints.LinearJoint` | :class:`~joints.RigidJoint`, :class:`~joints.RevoluteJoint` | Slider or Pin Slot |
+---------------------------------------+---------------------------------------------------------------------+--------------------+
| :class:`~topology.RevoluteJoint` | :class:`~topology.RigidJoint` | Hinge |
| :class:`~joints.RevoluteJoint` | :class:`~joints.RigidJoint` | Hinge |
+---------------------------------------+---------------------------------------------------------------------+--------------------+
| :class:`~topology.RigidJoint` | :class:`~topology.RigidJoint` | Fixed |
| :class:`~joints.RigidJoint` | :class:`~joints.RigidJoint` | Fixed |
+---------------------------------------+---------------------------------------------------------------------+--------------------+
Objects may have many joints bound to them each with an identifying label. All :class:`~topology.Joint`
@ -27,17 +29,20 @@ their position and orientation (the `ocp-vscode <https://github.com/bernhard-42/
has built-in support for displaying joints).
.. note::
The :class:`~build_part.BuildPart` builder will currently over-write joints unless they appear at
the end of the part definition.
If joints are created within the scope of a :class:`~build_part.BuildPart` builder, the ``to_part``
parameter need not be specified as the builder will, on exit, automatically transfer the joints created in its
scope to the part created.
The following sections provide more detail on the available joints and describes how they are used.
.. py:module:: joints
***********
Rigid Joint
***********
A rigid joint positions two components relative to each another with no freedom of movement. When a
:class:`~topology.RigidJoint` is instantiated it's assigned a ``label``, a part to bind to (``to_part``),
:class:`~joints.RigidJoint` is instantiated it's assigned a ``label``, a part to bind to (``to_part``),
and a ``joint_location`` which defines both the position and orientation of the joint (see
:class:`~geometry.Location`) - as follows:
@ -65,6 +70,7 @@ flanges are attached to the ends of a curved pipe:
.. image:: assets/rigid_joints_pipe.png
.. literalinclude:: rigid_joints_pipe.py
:emphasize-lines: 19-20, 23-24
Note how the locations of the joints are determined by the :meth:`~topology.Mixin1D.location_at` method
and how the ``-`` negate operator is used to reverse the direction of the location without changing its
@ -72,19 +78,23 @@ poosition. Also note that the ``WeldNeckFlange`` class predefines two joints, o
one at the face end - both of which are shown in the above image (generated by ocp-vscode with the
``render_joints=True`` flag set in the ``show`` function).
.. autoclass:: RigidJoint
**************
Revolute Joint
**************
Component rotates around axis like a hinge. The :ref:`joint_tutorial` covers Revolute Joints in detail.
During instantiation of a :class:`~topology.RevoluteJoint` there are three parameters not present with
During instantiation of a :class:`~joints.RevoluteJoint` there are three parameters not present with
Rigid Joints: ``axis``, ``angle_reference``, and ``range`` that allow the circular motion to be fully
defined.
When :meth:`~topology.Joint.connect_to` with a Revolute Joint, an extra ``angle`` parameter is present
which allows one to change the relative position of joined parts by changing a single value.
.. autoclass:: RevoluteJoint
************
Linear Joint
@ -107,20 +117,22 @@ The code to generate these components follows:
Note how the slide is constructed in a different orientation than the direction of motion. The
three highlighted lines of code show how the joints are created and connected together:
* The :class:`~topology.LinearJoint` has an axis and limits of movement
* The :class:`~topology.RigidJoint` has a single location, orientated such that the knob will ultimately be "up"
* The :class:`~joints.LinearJoint` has an axis and limits of movement
* The :class:`~joints.RigidJoint` has a single location, orientated such that the knob will ultimately be "up"
* The ``connect_to`` specifies a position that must be within the predefined limits.
The slider can be moved back and forth by just changing the ``position`` value. Values outside
of the limits will raise an exception.
.. autoclass:: LinearJoint
*****************
Cylindrical Joint
*****************
A :class:`~topology.CylindricalJoint` allows a component to rotate around and moves along a single axis
like a screw combining the functionality of a :class:`~topology.LinearJoint` and a
:class:`~topology.RevoluteJoint` joint. The ``connect_to`` for these joints have both ``position`` and
A :class:`~joints.CylindricalJoint` allows a component to rotate around and moves along a single axis
like a screw combining the functionality of a :class:`~joints.LinearJoint` and a
:class:`~joints.RevoluteJoint` joint. The ``connect_to`` for these joints have both ``position`` and
``angle`` parameters as shown below extracted from the joint tutorial.
@ -128,20 +140,22 @@ like a screw combining the functionality of a :class:`~topology.LinearJoint` and
hinge_outer.joints["hole2"].connect_to(m6_joint, position=5 * MM, angle=30)
.. autoclass:: CylindricalJoint
**********
Ball Joint
**********
A component rotates around all 3 axes using a gimbal system (3 nested rotations). A :class:`~topology.BallJoint`
A component rotates around all 3 axes using a gimbal system (3 nested rotations). A :class:`~joints.BallJoint`
is found within a rod end as shown here:
.. image:: assets/rod_end.png
.. literalinclude:: rod_end.py
:emphasize-lines: 37-42,49,51
:emphasize-lines: 37-41,48,50
Note how limits are defined during the instantiation of the ball joint when ensures that the pin or bolt
within the rod end does not interfer with the rod end itself. The ``connect_to`` sets the three angles
(only two are significant in this example).
.. autoclass:: BallJoint

View file

@ -16,12 +16,8 @@ with BuildPart() as pipe_builder:
sweep()
# Add the joints
RigidJoint(
label="inlet", to_part=pipe_builder.part, joint_location=-path.location_at(0)
)
RigidJoint(
label="outlet", to_part=pipe_builder.part, joint_location=path.location_at(1)
)
RigidJoint(label="inlet", joint_location=-path.location_at(0))
RigidJoint(label="outlet", joint_location=path.location_at(1))
# Place the flanges at the ends of the pipe
pipe_builder.part.joints["inlet"].connect_to(flange_inlet.joints["pipe"])

View file

@ -36,7 +36,6 @@ with BuildPart() as rod_end:
# Create the ball joint
BallJoint(
"socket",
rod_end.part,
joint_location=Location(),
angular_range=((-14, 14), (-14, 14), (0, 360)),
)
@ -46,7 +45,7 @@ with BuildPart() as ball:
Box(50, 50, 13, mode=Mode.INTERSECT)
Hole(4)
ball.part.color = Color("aliceblue")
RigidJoint("ball", ball.part, joint_location=Location())
RigidJoint("ball", joint_location=Location())
rod_end.part.joints["socket"].connect_to(ball.part.joints["ball"], angles=(5, 10, 0))

View file

@ -27,7 +27,7 @@ with BuildPart() as latch:
SlotOverall(32, 8)
extrude(amount=-2, mode=Mode.SUBTRACT)
# The slider will move align the x axis 12mm in each direction
LinearJoint("latch", latch.part, axis=Axis.X, linear_range=(-12, 12))
LinearJoint("latch", axis=Axis.X, linear_range=(-12, 12))
with BuildPart() as slide:
# The slide will be a little smaller than the hole
@ -49,7 +49,7 @@ with BuildPart() as slide:
split(bisect_by=Plane.XZ)
revolve(axis=Axis.X)
# Align the joint to Plane.ZY flipped
RigidJoint("slide", slide.part, Location(-Plane.ZY))
RigidJoint("slide", joint_location=Location(-Plane.ZY))
# Position the slide in the latch: -12 >= position <= 12
latch.part.joints["latch"].connect_to(slide.part.joints["slide"], position=12)

View file

@ -133,7 +133,6 @@ class Hinge(Compound):
# Leaf attachment
RigidJoint(
label="leaf",
to_part=leaf_builder.part,
joint_location=Location(
(width - barrel_diameter, 0, length / 2), (90, 0, 0)
),
@ -142,13 +141,13 @@ class Hinge(Compound):
if inner:
RigidJoint(
"hinge_axis",
leaf_builder.part,
Location((width - barrel_diameter / 2, barrel_diameter / 2, 0)),
joint_location=Location(
(width - barrel_diameter / 2, barrel_diameter / 2, 0)
),
)
else:
RevoluteJoint(
"hinge_axis",
leaf_builder.part,
axis=Axis(
(width - barrel_diameter / 2, barrel_diameter / 2, 0), (0, 0, 1)
),
@ -159,13 +158,11 @@ class Hinge(Compound):
for hole, hole_location in enumerate(hole_locations):
CylindricalJoint(
label="hole" + str(hole),
to_part=leaf_builder.part,
axis=hole_location.to_axis(),
linear_range=(-2 * CM, 2 * CM),
angular_range=(0, 360),
)
# [End Fastener holes]
super().__init__(leaf_builder.part.wrapped, joints=leaf_builder.part.joints)
# [Hinge Class]
@ -202,10 +199,8 @@ with BuildPart() as box_builder:
Hole(3 * MM, 1 * CM)
RigidJoint(
"hinge_attachment",
box_builder.part,
Location((-15 * CM, 0, 4 * CM), (180, 90, 0)),
joint_location=Location((-15 * CM, 0, 4 * CM), (180, 90, 0)),
)
# [Demonstrate that objects with Joints can be moved and the joints follow]
box = box_builder.part.moved(Location((0, 0, 5 * CM)))
@ -217,8 +212,7 @@ with BuildPart() as lid_builder:
Hole(3 * MM, 1 * CM)
RigidJoint(
"hinge_attachment",
lid_builder.part,
Location((0, 0, 0), (0, 0, 180)),
joint_location=Location((0, 0, 0), (0, 0, 180)),
)
lid = lid_builder.part

View file

@ -80,7 +80,7 @@ The second joint to add is either a :class:`~topology.RigidJoint` (on the inner
.. literalinclude:: tutorial_joints.py
:start-after: [Create the Joints]
:end-before: [Fastener holes]
:emphasize-lines: 10-25
:emphasize-lines: 10-24
The inner leaf just pivots around the outer leaf and therefore the simple :class:`~topology.RigidJoint` is
used to define the Location of this pivot. The outer leaf contains the more complex
@ -141,7 +141,7 @@ the joint used to attach the outer hinge leaf.
.. literalinclude:: tutorial_joints.py
:start-after: [Create the box with a RigidJoint to mount the hinge]
:end-before: [Demonstrate that objects with Joints can be moved and the joints follow]
:emphasize-lines: 13-17
:emphasize-lines: 13-16
Since the hinge will be fixed to the box another :class:`~topology.RigidJoint` is used mark where the hinge
will go. Note that the orientation of this :class:`~topology.Joint` will control how the hinge leaf is
@ -172,7 +172,7 @@ Much like the box, the lid is created in a :class:`~build_part.BuildPart` contex
.. literalinclude:: tutorial_joints.py
:start-after: [The lid with a RigidJoint for the hinge]
:end-before: [A screw to attach the hinge to the box]
:emphasize-lines: 6-10
:emphasize-lines: 6-9
Again, the original orientation of the lid and hinge inner leaf are not important, when the
joints are connected together the parts will move into the correct position.
@ -293,3 +293,10 @@ and ``other`` will move to the appropriate :class:`~geometry.Location`.
show_object(m6_joint.symbol, name="m6 screw symbol")
or, with the ocp_vscode viewer
.. code:: python
show(box, render_joints=True)

View file

@ -1,20 +1,22 @@
"""build123d import definitions"""
from build123d.build_common import *
from build123d.build_enums import *
from build123d.build_line import *
from build123d.build_sketch import *
from build123d.build_part import *
from build123d.build_sketch import *
from build123d.exporters import *
from build123d.geometry import *
from build123d.topology import *
from build123d.build_enums import *
from build123d.importers import *
from build123d.joints import *
from build123d.mesher import *
from build123d.objects_curve import *
from build123d.objects_part import *
from build123d.objects_sketch import *
from build123d.operations_generic import *
from build123d.operations_part import *
from build123d.operations_sketch import *
from build123d.objects_part import *
from build123d.objects_sketch import *
from build123d.objects_curve import *
from build123d.mesher import *
from build123d.topology import *
from .version import version as __version__
__all__ = [

View file

@ -211,10 +211,16 @@ class Builder(ABC):
return self
def _exit_extras(self):
"""Any builder specific exit actions"""
pass
def __exit__(self, exception_type, exception_value, traceback):
"""Upon exiting restore context and send object to parent"""
self._current.reset(self._reset_tok)
self._exit_extras() # custom builder exit code
if self.builder_parent is not None and self.mode != Mode.PRIVATE:
logger.debug(
"Transferring object(s) to %s", type(self.builder_parent).__name__

View file

@ -35,7 +35,7 @@ from typing import Union
from build123d.build_common import Builder, logger
from build123d.build_enums import Mode
from build123d.geometry import Location, Plane
from build123d.topology import Edge, Face, Part, Solid, Wire
from build123d.topology import Edge, Face, Joint, Part, Solid, Wire
class BuildPart(Builder):
@ -71,11 +71,17 @@ class BuildPart(Builder):
"""Return a wire representation of the pending edges"""
return Wire.combine(self.pending_edges)[0]
@property
def location(self) -> Location:
"""Builder's location"""
return self.part.location if self.part is not None else Location()
def __init__(
self,
*workplanes: Union[Face, Plane, Location],
mode: Mode = Mode.ADD,
):
self.joints: dict[str, Joint] = {}
self.part: Part = None
self.pending_faces: list[Face] = []
self.pending_face_planes: list[Plane] = []
@ -106,3 +112,10 @@ class BuildPart(Builder):
edge.location,
)
self.pending_edges.append(edge)
def _exit_extras(self):
"""Transfer joints on exit"""
if self.joints:
self.part.joints = self.joints
for joint in self.part.joints.values():
joint.parent = self.part

View file

@ -6810,509 +6810,6 @@ class Joint(ABC):
return NotImplementedError
class RigidJoint(Joint):
"""RigidJoint
A rigid joint fixes two components to one another.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
joint_location (Location): global location of joint
Attributes:
relative_location (Location): joint location relative to bound object
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol (XYZ indicator) as bound to part"""
size = self.parent.bounding_box().diagonal / 12
return Compound.make_triad(axes_scale=size).locate(
self.parent.location * self.relative_location
)
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
joint_location: Location = Location(),
):
self.relative_location = to_part.location.inverse() * joint_location
to_part.joints[label] = self
super().__init__(label, to_part)
def relative_to(self, other: Joint, **kwargs) -> Location:
"""relative_to
Return the relative position to move the other.
Args:
other (RigidJoint): joint to connect to
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
return self.relative_location * other.relative_location.inverse()
class RevoluteJoint(Joint):
"""RevoluteJoint
Component rotates around axis like a hinge.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
axis (Axis): axis of rotation
angle_reference (VectorLike, optional): direction normal to axis defining where
angles will be measured from. Defaults to None.
range (tuple[float, float], optional): (min,max) angle of joint. Defaults to (0, 360).
Attributes:
angle (float): angle of joint
angle_reference (Vector): reference for angular poitions
angular_range (tuple[float,float]): min and max angular position of joint
relative_axis (Axis): joint axis relative to bound part
Raises:
ValueError: angle_reference must be normal to axis
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol representing the axis of rotation as bound to part"""
radius = self.parent.bounding_box().diagonal / 30
return Compound.make_compound(
[
Edge.make_line((0, 0, 0), (0, 0, radius * 10)),
Edge.make_circle(radius),
]
).move(self.parent.location * self.relative_axis.location)
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
axis: Axis = Axis.Z,
angle_reference: VectorLike = None,
angular_range: tuple[float, float] = (0, 360),
):
self.angular_range = angular_range
if angle_reference:
if not axis.is_normal(Axis((0, 0, 0), angle_reference)):
raise ValueError("angle_reference must be normal to axis")
self.angle_reference = Vector(angle_reference)
else:
self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir
self.angle = None
self.relative_axis = axis.located(to_part.location.inverse())
to_part.joints[label] = self
super().__init__(label, to_part)
def relative_to(
self, other: Joint, angle: float = None
): # pylint: disable=arguments-differ
"""relative_to
Return the relative location from this joint to the RigidJoint of another object
- a hinge joint.
Args:
other (RigidJoint): joint to connect to
angle (float, optional): angle within angular range. Defaults to minimum.
Raises:
TypeError: other must of type RigidJoint
ValueError: angle out of range
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
angle = self.angular_range[0] if angle is None else angle
if angle < self.angular_range[0] or angle > self.angular_range[1]:
raise ValueError(f"angle ({angle}) must in range of {self.angular_range}")
self.angle = angle
# Avoid strange rotations when angle is zero by using 360 instead
angle = 360.0 if angle == 0.0 else angle
rotation = Location(
Plane(
origin=(0, 0, 0),
x_dir=self.angle_reference.rotate(Axis.Z, angle),
z_dir=(0, 0, 1),
)
)
return (
self.relative_axis.location * rotation * other.relative_location.inverse()
)
class LinearJoint(Joint):
"""LinearJoint
Component moves along a single axis.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
axis (Axis): axis of linear motion
range (tuple[float, float], optional): (min,max) position of joint.
Defaults to (0, inf).
Attributes:
axis (Axis): joint axis
angle (float): angle of joint
linear_range (tuple[float,float]): min and max positional values
position (float): joint position
relative_axis (Axis): joint axis relative to bound part
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol of the linear axis positioned relative to_part"""
radius = (self.linear_range[1] - self.linear_range[0]) / 15
return Compound.make_compound(
[
Edge.make_line(
(0, 0, self.linear_range[0]), (0, 0, self.linear_range[1])
),
Edge.make_circle(radius),
]
).move(self.parent.location * self.relative_axis.location)
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
axis: Axis = Axis.Z,
linear_range: tuple[float, float] = (0, inf),
):
self.axis = axis
self.linear_range = linear_range
self.position = None
self.relative_axis = axis.located(to_part.location.inverse())
self.angle = None
to_part.joints[label]: dict[str, Joint] = self
super().__init__(label, to_part)
@overload
def relative_to(
self, other: RigidJoint, position: float = None
): # pylint: disable=arguments-differ
"""relative_to - RigidJoint
Return the relative location from this joint to the RigidJoint of another object
- a slider joint.
Args:
other (RigidJoint): joint to connect to
position (float, optional): position within joint range. Defaults to middle.
"""
@overload
def relative_to(
self, other: RevoluteJoint, position: float = None, angle: float = None
): # pylint: disable=arguments-differ
"""relative_to - RevoluteJoint
Return the relative location from this joint to the RevoluteJoint of another object
- a pin slot joint.
Args:
other (RigidJoint): joint to connect to
position (float, optional): position within joint range. Defaults to middle.
angle (float, optional): angle within angular range. Defaults to minimum.
"""
def relative_to(self, *args, **kwargs): # pylint: disable=arguments-differ
"""Return the relative position of other to linear joint defined by self"""
# Parse the input parameters
other, position, angle = None, None, None
if args:
other = args[0]
position = args[1] if len(args) >= 2 else position
angle = args[2] if len(args) == 3 else angle
if kwargs:
other = kwargs["other"] if "other" in kwargs else other
position = kwargs["position"] if "position" in kwargs else position
angle = kwargs["angle"] if "angle" in kwargs else angle
if not isinstance(other, (RigidJoint, RevoluteJoint)):
raise TypeError(
f"other must of type RigidJoint or RevoluteJoint not {type(other)}"
)
position = sum(self.linear_range) / 2 if position is None else position
if not self.linear_range[0] <= position <= self.linear_range[1]:
raise ValueError(
f"position ({position}) must in range of {self.linear_range}"
)
self.position = position
if isinstance(other, RevoluteJoint):
angle = other.angular_range[0] if angle is None else angle
if not other.angular_range[0] <= angle <= other.angular_range[1]:
raise ValueError(
f"angle ({angle}) must in range of {other.angular_range}"
)
rotation = Location(
Plane(
origin=(0, 0, 0),
x_dir=other.angle_reference.rotate(other.relative_axis, angle),
z_dir=other.relative_axis.direction,
)
)
else:
angle = 0.0
rotation = Location()
self.angle = angle
joint_relative_position = (
Location(
self.relative_axis.position + self.relative_axis.direction * position,
)
* rotation
)
if isinstance(other, RevoluteJoint):
other_relative_location = Location(other.relative_axis.position)
else:
other_relative_location = other.relative_location
return joint_relative_position * other_relative_location.inverse()
class CylindricalJoint(Joint):
"""CylindricalJoint
Component rotates around and moves along a single axis like a screw.
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
axis (Axis): axis of rotation and linear motion
angle_reference (VectorLike, optional): direction normal to axis defining where
angles will be measured from. Defaults to None.
linear_range (tuple[float, float], optional): (min,max) position of joint.
Defaults to (0, inf).
angular_range (tuple[float, float], optional): (min,max) angle of joint.
Defaults to (0, 360).
Attributes:
axis (Axis): joint axis
linear_position (float): linear joint position
rotational_position (float): revolute joint angle in degrees
angle_reference (Vector): reference for angular poitions
angular_range (tuple[float,float]): min and max angular position of joint
linear_range (tuple[float,float]): min and max positional values
relative_axis (Axis): joint axis relative to bound part
position (float): joint position
angle (float): angle of joint
Raises:
ValueError: angle_reference must be normal to axis
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol representing the cylindrical axis as bound to part"""
radius = (self.linear_range[1] - self.linear_range[0]) / 15
return Compound.make_compound(
[
Edge.make_line(
(0, 0, self.linear_range[0]), (0, 0, self.linear_range[1])
),
Edge.make_circle(radius),
]
).move(self.parent.location * self.relative_axis.location)
# @property
# def axis_location(self) -> Location:
# """Current global location of joint axis"""
# return self.parent.location * self.relative_axis.location
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
axis: Axis = Axis.Z,
angle_reference: VectorLike = None,
linear_range: tuple[float, float] = (0, inf),
angular_range: tuple[float, float] = (0, 360),
):
self.axis = axis
self.linear_position = None
self.rotational_position = None
if angle_reference:
if not axis.is_normal(Axis((0, 0, 0), angle_reference)):
raise ValueError("angle_reference must be normal to axis")
self.angle_reference = Vector(angle_reference)
else:
self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir
self.angular_range = angular_range
self.linear_range = linear_range
self.relative_axis = axis.located(to_part.location.inverse())
self.position = None
self.angle = None
to_part.joints[label]: dict[str, Joint] = self
super().__init__(label, to_part)
def relative_to(
self, other: RigidJoint, position: float = None, angle: float = None
): # pylint: disable=arguments-differ
"""relative_to - CylindricalJoint
Return the relative location from this joint to the RigidJoint of another object
- a sliding and rotating joint.
Args:
other (RigidJoint): joint to connect to
position (float, optional): position within joint linear range. Defaults to middle.
angle (float, optional): angle within rotational range.
Defaults to angular_range minimum.
Raises:
TypeError: other must be of type RigidJoint
ValueError: position out of range
ValueError: angle out of range
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
position = sum(self.linear_range) / 2 if position is None else position
if not self.linear_range[0] <= position <= self.linear_range[1]:
raise ValueError(
f"position ({position}) must in range of {self.linear_range}"
)
self.position = position
angle = sum(self.angular_range) / 2 if angle is None else angle
if not self.angular_range[0] <= angle <= self.angular_range[1]:
raise ValueError(f"angle ({angle}) must in range of {self.angular_range}")
self.angle = angle
joint_relative_position = Location(
self.relative_axis.position + self.relative_axis.direction * position
)
joint_rotation = Location(
Plane(
origin=(0, 0, 0),
x_dir=self.angle_reference.rotate(self.relative_axis, angle),
z_dir=self.relative_axis.direction,
)
)
return (
joint_relative_position * joint_rotation * other.relative_location.inverse()
)
class BallJoint(Joint):
"""BallJoint
A component rotates around all 3 axes using a gimbal system (3 nested rotations).
Args:
label (str): joint label
to_part (Union[Solid, Compound]): object to attach joint to
joint_location (Location): global location of joint
angular_range
(tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ], optional):
X, Y, Z angle (min, max) pairs. Defaults to ((0, 360), (0, 360), (0, 360)).
angle_reference (Plane, optional): plane relative to part defining zero degrees of
rotation. Defaults to Plane.XY.
Attributes:
relative_location (Location): joint location relative to bound part
angular_range
(tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ]):
X, Y, Z angle (min, max) pairs.
angle_reference (Plane): plane relative to part defining zero degrees of
"""
@property
def symbol(self) -> Compound:
"""A CAD symbol representing joint as bound to part"""
radius = self.parent.bounding_box().diagonal / 30
circle_x = Edge.make_circle(radius, self.angle_reference)
circle_y = Edge.make_circle(radius, self.angle_reference.rotated((90, 0, 0)))
circle_z = Edge.make_circle(radius, self.angle_reference.rotated((0, 90, 0)))
return Compound.make_compound(
[
circle_x,
circle_y,
circle_z,
Compound.make_text(
"X", radius / 5, align=(Align.CENTER, Align.CENTER)
).locate(circle_x.location_at(0.125) * Rotation(90, 0, 0)),
Compound.make_text(
"Y", radius / 5, align=(Align.CENTER, Align.CENTER)
).locate(circle_y.location_at(0.625) * Rotation(90, 0, 0)),
Compound.make_text(
"Z", radius / 5, align=(Align.CENTER, Align.CENTER)
).locate(circle_z.location_at(0.125) * Rotation(90, 0, 0)),
]
).move(self.parent.location * self.relative_location)
def __init__(
self,
label: str,
to_part: Union[Solid, Compound],
joint_location: Location = Location(),
angular_range: tuple[
tuple[float, float], tuple[float, float], tuple[float, float]
] = ((0, 360), (0, 360), (0, 360)),
angle_reference: Plane = Plane.XY,
):
self.relative_location = to_part.location.inverse() * joint_location
to_part.joints[label] = self
self.angular_range = angular_range
self.angle_reference = angle_reference
super().__init__(label, to_part)
def relative_to(
self, other: RigidJoint, angles: RotationLike = None
): # pylint: disable=arguments-differ
"""relative_to - CylindricalJoint
Return the relative location from this joint to the RigidJoint of another object
Args:
other (RigidJoint): joint to connect to
angles (RotationLike, optional): orientation of other's parent relative to
self. Defaults to the minimums of the angle ranges.
Raises:
TypeError: invalid other joint type
ValueError: angles out of range
"""
if not isinstance(other, RigidJoint):
raise TypeError(f"other must of type RigidJoint not {type(other)}")
rotation = (
Rotation(*[self.angular_range[i][0] for i in [0, 1, 2]])
if angles is None
else Rotation(*angles)
) * self.angle_reference.location
for i, rotations in zip(
[0, 1, 2],
[rotation.orientation.X, rotation.orientation.Y, rotation.orientation.Z],
):
if not self.angular_range[i][0] <= rotations <= self.angular_range[i][1]:
raise ValueError(
f"angles ({angles}) must in range of {self.angular_range}"
)
return self.relative_location * rotation * other.relative_location.inverse()
def downcast(obj: TopoDS_Shape) -> TopoDS_Shape:
"""Downcasts a TopoDS object to suitable specialized type

View file

@ -59,18 +59,13 @@ from build123d.geometry import (
Vector,
VectorLike,
)
from build123d.importers import import_brep, import_step, import_stl, import_svg
from build123d.importers import import_brep, import_step, import_stl
from build123d.mesher import Mesher
from build123d.topology import (
BallJoint,
Compound,
CylindricalJoint,
Edge,
Face,
LinearJoint,
Plane,
RevoluteJoint,
RigidJoint,
Shape,
ShapeList,
Shell,
@ -1245,234 +1240,6 @@ class TestImportExport(DirectApiTestCase):
self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5)
class TestJoints(DirectApiTestCase):
def test_rigid_joint(self):
base = Solid.make_box(1, 1, 1)
j1 = RigidJoint("top", base, Location(Vector(0.5, 0.5, 1)))
fixed_top = Solid.make_box(1, 1, 1)
j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.5, 0)))
j1.connect_to(j2)
bbox = fixed_top.bounding_box()
self.assertVectorAlmostEquals(bbox.min, (0, 0, 1), 5)
self.assertVectorAlmostEquals(bbox.max, (1, 1, 2), 5)
self.assertVectorAlmostEquals(j2.symbol.location.position, (0.5, 0.5, 1), 6)
self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 0), 6)
def test_revolute_joint_with_angle_reference(self):
revolute_base = Solid.make_cylinder(1, 1)
j1 = RevoluteJoint(
label="top",
to_part=revolute_base,
axis=Axis((0, 0, 1), (0, 0, 1)),
angle_reference=(1, 0, 0),
angular_range=(0, 180),
)
fixed_top = Solid.make_box(1, 0.5, 1)
j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.25, 0)))
j1.connect_to(j2, 90)
bbox = fixed_top.bounding_box()
self.assertVectorAlmostEquals(bbox.min, (-0.25, -0.5, 1), 5)
self.assertVectorAlmostEquals(bbox.max, (0.25, 0.5, 2), 5)
self.assertVectorAlmostEquals(j2.symbol.location.position, (0, 0, 1), 6)
self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 90), 6)
self.assertEqual(len(j1.symbol.edges()), 2)
def test_revolute_joint_without_angle_reference(self):
revolute_base = Solid.make_cylinder(1, 1)
j1 = RevoluteJoint(
label="top",
to_part=revolute_base,
axis=Axis((0, 0, 1), (0, 0, 1)),
)
self.assertVectorAlmostEquals(j1.angle_reference, (1, 0, 0), 5)
def test_revolute_joint_error_bad_angle_reference(self):
"""Test that the angle_reference must be normal to the axis"""
revolute_base = Solid.make_cylinder(1, 1)
with self.assertRaises(ValueError):
RevoluteJoint(
"top",
revolute_base,
axis=Axis((0, 0, 1), (0, 0, 1)),
angle_reference=(1, 0, 1),
)
def test_revolute_joint_error_bad_angle(self):
"""Test that the joint angle is within bounds"""
revolute_base = Solid.make_cylinder(1, 1)
j1 = RevoluteJoint("top", revolute_base, Axis.Z, angular_range=(0, 180))
fixed_top = Solid.make_box(1, 0.5, 1)
j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.25, 0)))
with self.assertRaises(ValueError):
j1.connect_to(j2, 270)
def test_revolute_joint_error_bad_joint_type(self):
"""Test that the joint angle is within bounds"""
revolute_base = Solid.make_cylinder(1, 1)
j1 = RevoluteJoint("top", revolute_base, Axis.Z, (0, 180))
fixed_top = Solid.make_box(1, 0.5, 1)
j2 = RevoluteJoint("bottom", fixed_top, Axis.Z, (0, 180))
with self.assertRaises(TypeError):
j1.connect_to(j2, 0)
def test_linear_rigid_joint(self):
base = Solid.make_box(1, 1, 1)
j1 = LinearJoint(
"top", to_part=base, axis=Axis((0, 0.5, 1), (1, 0, 0)), linear_range=(0, 1)
)
fixed_top = Solid.make_box(1, 1, 1)
j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.5, 0)))
j1.connect_to(j2, 0.25)
bbox = fixed_top.bounding_box()
self.assertVectorAlmostEquals(bbox.min, (-0.25, 0, 1), 5)
self.assertVectorAlmostEquals(bbox.max, (0.75, 1, 2), 5)
self.assertVectorAlmostEquals(j2.symbol.location.position, (0.25, 0.5, 1), 6)
self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 0), 6)
def test_linear_revolute_joint(self):
linear_base = Solid.make_box(1, 1, 1)
j1 = LinearJoint(
label="top",
to_part=linear_base,
axis=Axis((0, 0.5, 1), (1, 0, 0)),
linear_range=(0, 1),
)
revolute_top = Solid.make_box(1, 0.5, 1).locate(Location((-0.5, -0.25, 0)))
j2 = RevoluteJoint(
label="top",
to_part=revolute_top,
axis=Axis((0, 0, 0), (0, 0, 1)),
angle_reference=(1, 0, 0),
angular_range=(0, 180),
)
j1.connect_to(j2, position=0.25, angle=90)
bbox = revolute_top.bounding_box()
self.assertVectorAlmostEquals(bbox.min, (0, 0, 1), 5)
self.assertVectorAlmostEquals(bbox.max, (0.5, 1, 2), 5)
self.assertVectorAlmostEquals(j2.symbol.location.position, (0.25, 0.5, 1), 6)
self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 90), 6)
self.assertEqual(len(j1.symbol.edges()), 2)
# Test invalid position
with self.assertRaises(ValueError):
j1.connect_to(j2, position=5, angle=90)
# Test invalid angle
with self.assertRaises(ValueError):
j1.connect_to(j2, position=0.5, angle=270)
# Test invalid joint
with self.assertRaises(TypeError):
j1.connect_to(Solid.make_box(1, 1, 1), position=0.5, angle=90)
def test_cylindrical_joint(self):
cylindrical_base = (
Solid.make_box(1, 1, 1)
.locate(Location((-0.5, -0.5, 0)))
.cut(Solid.make_cylinder(0.3, 1))
)
j1 = CylindricalJoint(
"base",
cylindrical_base,
Axis((0, 0, 1), (0, 0, -1)),
angle_reference=(1, 0, 0),
linear_range=(0, 1),
angular_range=(0, 90),
)
dowel = Solid.make_cylinder(0.3, 1).cut(
Solid.make_box(1, 1, 1).locate(Location((-0.5, 0, 0)))
)
j2 = RigidJoint("bottom", dowel, Location((0, 0, 0), (0, 0, 0)))
j1.connect_to(j2, 0.25, 90)
dowel_bbox = dowel.bounding_box()
self.assertVectorAlmostEquals(dowel_bbox.min, (0, -0.3, -0.25), 5)
self.assertVectorAlmostEquals(dowel_bbox.max, (0.3, 0.3, 0.75), 5)
self.assertVectorAlmostEquals(j1.symbol.location.position, (0, 0, 1), 6)
self.assertVectorAlmostEquals(
j1.symbol.location.orientation, (-180, 0, -180), 6
)
self.assertEqual(len(j1.symbol.edges()), 2)
# Test invalid position
with self.assertRaises(ValueError):
j1.connect_to(j2, position=5, angle=90)
# Test invalid angle
with self.assertRaises(ValueError):
j1.connect_to(j2, position=0.5, angle=270)
# Test invalid joint
with self.assertRaises(TypeError):
j1.connect_to(Solid.make_box(1, 1, 1), position=0.5, angle=90)
def test_cylindrical_joint_error_bad_angle_reference(self):
"""Test that the angle_reference must be normal to the axis"""
with self.assertRaises(ValueError):
CylindricalJoint(
"base",
Solid.make_box(1, 1, 1),
Axis((0, 0, 1), (0, 0, -1)),
angle_reference=(1, 0, 1),
linear_range=(0, 1),
angular_range=(0, 90),
)
def test_cylindrical_joint_error_bad_position_and_angle(self):
"""Test that the joint angle is within bounds"""
j1 = CylindricalJoint(
"base",
Solid.make_box(1, 1, 1),
Axis((0, 0, 1), (0, 0, -1)),
linear_range=(0, 1),
angular_range=(0, 90),
)
j2 = RigidJoint("bottom", Solid.make_cylinder(1, 1), Location((0.5, 0.25, 0)))
with self.assertRaises(ValueError):
j1.connect_to(j2, position=0.5, angle=270)
with self.assertRaises(ValueError):
j1.connect_to(j2, position=4, angle=30)
def test_ball_joint(self):
socket_base = Solid.make_box(1, 1, 1).cut(
Solid.make_sphere(0.3, Plane((0.5, 0.5, 1)))
)
j1 = BallJoint(
"socket",
socket_base,
Location((0.5, 0.5, 1)),
angular_range=((-45, 45), (-45, 45), (0, 360)),
)
ball_rod = Solid.make_cylinder(0.15, 2).fuse(
Solid.make_sphere(0.3).locate(Location((0, 0, 2)))
)
j2 = RigidJoint("ball", ball_rod, Location((0, 0, 2), (180, 0, 0)))
j1.connect_to(j2, (45, 45, 0))
self.assertVectorAlmostEquals(
ball_rod.faces().filter_by(GeomType.PLANE)[0].center(CenterOf.GEOMETRY),
(1.914213562373095, -0.5, 2),
5,
)
self.assertVectorAlmostEquals(j1.symbol.location.position, (0.5, 0.5, 1), 6)
self.assertVectorAlmostEquals(j1.symbol.location.orientation, (0, 0, 0), 6)
with self.assertRaises(ValueError):
j1.connect_to(j2, (90, 45, 0))
# Test invalid joint
with self.assertRaises(TypeError):
j1.connect_to(Solid.make_box(1, 1, 1), (0, 0, 0))
class TestJupyter(DirectApiTestCase):
def test_repr_javascript(self):
shape = Solid.make_box(1, 1, 1)