diff --git a/README.md b/README.md
index fd98f622..6fd791f0 100644
--- a/README.md
+++ b/README.md
@@ -27,6 +27,7 @@ CadQuery is often compared to [OpenSCAD](http://www.openscad.org/). Like OpenSCA
* Output high quality (loss-less) CAD formats like STEP and DXF in addition to STL and AMF.
* Provide a non-proprietary, plain text model format that can be edited and executed with only a web browser.
* Offer advanced modeling capabilities such as fillets, curvelinear extrudes, parametric curves and lofts.
+* Build nested assemblies out of individual parts and other assemblies.
### Why this fork
@@ -111,6 +112,12 @@ Hexidor is an expanded take on the Quoridor board game, and the development proc
+### Spindle assembly
+
+Thanks to @marcus7070 for this example from [here](https://github.com/marcus7070/spindle-assy-example).
+
+
+
### 3D Printed Resin Mold
Thanks to @eddieliberato for sharing [this example](https://jungletools.blogspot.com/2017/06/an-anti-kink-device-for-novel-high-tech.html) of an anti-kink resin mold for a cable.
diff --git a/cadquery/__init__.py b/cadquery/__init__.py
index 28810cbc..d8fc51c4 100644
--- a/cadquery/__init__.py
+++ b/cadquery/__init__.py
@@ -28,6 +28,7 @@ from .selectors import (
Selector,
)
from .cq import CQ, Workplane
+from .assembly import Assembly, Color, Constraint
from . import selectors
from . import plugins
@@ -35,6 +36,9 @@ from . import plugins
__all__ = [
"CQ",
"Workplane",
+ "Assembly",
+ "Color",
+ "Constraint",
"plugins",
"selectors",
"Plane",
@@ -64,4 +68,4 @@ __all__ = [
"plugins",
]
-__version__ = "2.0"
+__version__ = "2.1dev"
diff --git a/cadquery/assembly.py b/cadquery/assembly.py
new file mode 100644
index 00000000..c895260f
--- /dev/null
+++ b/cadquery/assembly.py
@@ -0,0 +1,413 @@
+from functools import reduce
+from typing import Union, Optional, List, Dict, Any, overload, Tuple, Iterator, cast
+from typing_extensions import Literal
+from uuid import uuid1 as uuid
+
+from .cq import Workplane
+from .occ_impl.shapes import Shape, Face, Edge
+from .occ_impl.geom import Location, Vector
+from .occ_impl.assembly import Color
+from .occ_impl.solver import (
+ ConstraintSolver,
+ ConstraintMarker,
+ Constraint as ConstraintPOD,
+)
+from .occ_impl.exporters.assembly import exportAssembly, exportCAF
+
+
+AssemblyObjects = Union[Shape, Workplane, None]
+ConstraintKinds = Literal["Plane", "Point", "Axis"]
+ExportLiterals = Literal["STEP", "XML"]
+
+
+class Constraint(object):
+ """
+ Geometrical constraint between two shapes of an assembly.
+ """
+
+ objects: Tuple[str, ...]
+ args: Tuple[Shape, ...]
+ sublocs: Tuple[Location, ...]
+ kind: ConstraintKinds
+ param: Any
+
+ def __init__(
+ self,
+ objects: Tuple[str, ...],
+ args: Tuple[Shape, ...],
+ sublocs: Tuple[Location, ...],
+ kind: ConstraintKinds,
+ param: Any = None,
+ ):
+ """
+ Construct a constraint.
+
+ :param objects: object names refernced in the constraint
+ :param args: subshapes (e.g. faces or edges) of the objects
+ :param sublocs: locations of the objects (only relevant if the objects are nested in a sub-assembly)
+ :param kind: constraint kind
+ :param param: optional arbitrary paramter passed to the solver
+ """
+
+ self.objects = objects
+ self.args = args
+ self.sublocs = sublocs
+ self.kind = kind
+ self.param = param
+
+ def _getAxis(self, arg: Shape) -> Vector:
+
+ if isinstance(arg, Face):
+ rv = arg.normalAt()
+ elif isinstance(arg, Edge) and arg.geomType() != "CIRCLE":
+ rv = arg.tangentAt()
+ elif isinstance(arg, Edge) and arg.geomType() == "CIRCLE":
+ rv = arg.normal()
+ else:
+ raise ValueError(f"Cannot construct Axis for {arg}")
+
+ return rv
+
+ def toPOD(self) -> ConstraintPOD:
+ """
+ Convert the constraint to a representation used by the solver.
+ """
+
+ rv: List[Tuple[ConstraintMarker, ...]] = []
+
+ for arg, loc in zip(self.args, self.sublocs):
+
+ arg = arg.located(loc * arg.location())
+
+ if self.kind == "Axis":
+ rv.append((self._getAxis(arg).toDir(),))
+ elif self.kind == "Point":
+ rv.append((arg.Center().toPnt(),))
+ elif self.kind == "Plane":
+ rv.append((self._getAxis(arg).toDir(), arg.Center().toPnt()))
+ else:
+ raise ValueError(f"Unknown constraint kind {self.kind}")
+
+ rv.append(self.param)
+
+ return cast(ConstraintPOD, tuple(rv))
+
+
+class Assembly(object):
+ """Nested assembly of Workplane and Shape objects defining their relative positions.
+ """
+
+ loc: Location
+ name: str
+ color: Optional[Color]
+ metadata: Dict[str, Any]
+
+ obj: AssemblyObjects
+ parent: Optional["Assembly"]
+ children: List["Assembly"]
+
+ objects: Dict[str, "Assembly"]
+ constraints: List[Constraint]
+
+ def __init__(
+ self,
+ obj: AssemblyObjects = None,
+ loc: Optional[Location] = None,
+ name: Optional[str] = None,
+ color: Optional[Color] = None,
+ ):
+ """
+ construct an assembly
+
+ :param obj: root object of the assembly (deafault: None)
+ :param loc: location of the root object (deafault: None, interpreted as identity transformation)
+ :param name: unique name of the root object (default: None, reasulting in an UUID being generated)
+ :param color: color of the added object (default: None)
+ :return: An Assembly object.
+
+
+ To create an empty assembly use::
+
+ assy = Assembly(None)
+
+ To create one constraint a root object::
+
+ b = Workplane().box(1,1,1)
+ assy = Assembly(b, Location(Vector(0,0,1)), name="root")
+
+ """
+
+ self.obj = obj
+ self.loc = loc if loc else Location()
+ self.name = name if name else str(uuid())
+ self.color = color if color else None
+ self.parent = None
+
+ self.children = []
+ self.constraints = []
+ self.objects = {self.name: self}
+
+ def _copy(self) -> "Assembly":
+ """
+ Make a deep copy of an assembly
+ """
+
+ rv = self.__class__(self.obj, self.loc, self.name, self.color)
+
+ for ch in self.children:
+ ch_copy = ch._copy()
+ ch_copy.parent = rv
+
+ rv.children.append(ch_copy)
+ rv.objects[ch_copy.name] = ch_copy
+ rv.objects.update(ch_copy.objects)
+
+ return rv
+
+ @overload
+ def add(
+ self,
+ obj: "Assembly",
+ loc: Optional[Location] = None,
+ name: Optional[str] = None,
+ color: Optional[Color] = None,
+ ) -> "Assembly":
+ """
+ add a subassembly to the current assembly.
+
+ :param obj: subassembly to be added
+ :param loc: location of the root object (deafault: None, resulting in the location stored in the subassembly being used)
+ :param name: unique name of the root object (default: None, resulting in the name stored in the subassembly being used)
+ :param color: color of the added object (default: None, resulting in the color stored in the subassembly being used)
+ """
+ ...
+
+ @overload
+ def add(
+ self,
+ obj: AssemblyObjects,
+ loc: Optional[Location] = None,
+ name: Optional[str] = None,
+ color: Optional[Color] = None,
+ ) -> "Assembly":
+ """
+ add a subassembly to the current assembly with explicit location and name
+
+ :param obj: object to be added as a subassembly
+ :param loc: location of the root object (deafault: None, interpreted as identity transformation)
+ :param name: unique name of the root object (default: None, resulting in an UUID being generated)
+ :param color: color of the added object (default: None)
+ """
+ ...
+
+ def add(self, arg, **kwargs):
+ """
+ add a subassembly to the current assembly.
+ """
+
+ if isinstance(arg, Assembly):
+
+ subassy = arg._copy()
+
+ subassy.loc = kwargs["loc"] if kwargs.get("loc") else arg.loc
+ subassy.name = kwargs["name"] if kwargs.get("name") else arg.name
+ subassy.color = kwargs["color"] if kwargs.get("color") else arg.color
+ subassy.parent = self
+
+ self.children.append(subassy)
+ self.objects[subassy.name] = subassy
+ self.objects.update(subassy.objects)
+
+ else:
+ assy = Assembly(arg, **kwargs)
+ assy.parent = self
+
+ self.add(assy)
+
+ return self
+
+ def _query(self, q: str) -> Tuple[str, Optional[Shape]]:
+ """
+ Execute a selector query on the assembly.
+ The query is expected to be in the following format:
+
+ name@kind@args
+
+ for example:
+
+ obj_name@faces@>Z
+
+ """
+
+ name, kind, arg = q.split("@")
+
+ tmp = Workplane()
+ obj = self.objects[name].obj
+
+ if isinstance(obj, (Workplane, Shape)):
+ tmp.add(obj)
+ res = getattr(tmp, kind)(arg)
+
+ return name, res.val() if isinstance(res.val(), Shape) else None
+
+ def _subloc(self, name: str) -> Tuple[Location, str]:
+ """
+ Calculate relative location of an object in a subassembly.
+
+ Returns the relative posiitons as well as the name of the top assembly.
+ """
+
+ rv = Location()
+ obj = self.objects[name]
+ name_out = name
+
+ if obj not in self.children and obj is not self:
+ locs = []
+ while not obj.parent is self:
+ locs.append(obj.loc)
+ obj = cast(Assembly, obj.parent)
+ name_out = obj.name
+
+ rv = reduce(lambda l1, l2: l1 * l2, locs)
+
+ return (rv, name_out)
+
+ @overload
+ def constrain(
+ self, q1: str, q2: str, kind: ConstraintKinds, param: Any = None
+ ) -> "Assembly":
+ ...
+
+ @overload
+ def constrain(
+ self,
+ id1: str,
+ s1: Shape,
+ id2: str,
+ s2: Shape,
+ kind: ConstraintKinds,
+ param: Any = None,
+ ) -> "Assembly":
+ ...
+
+ def constrain(self, *args, param=None):
+ """
+ Define a new constraint.
+ """
+
+ if len(args) == 3:
+ q1, q2, kind = args
+ id1, s1 = self._query(q1)
+ id2, s2 = self._query(q2)
+ elif len(args) == 4:
+ q1, q2, kind, param = args
+ id1, s1 = self._query(q1)
+ id2, s2 = self._query(q2)
+ elif len(args) == 5:
+ id1, s1, id2, s2, kind = args
+ elif len(args) == 6:
+ id1, s1, id2, s2, kind, param = args
+ else:
+ raise ValueError(f"Incompatibile arguments: {args}")
+
+ loc1, id1_top = self._subloc(id1)
+ loc2, id2_top = self._subloc(id2)
+ self.constraints.append(
+ Constraint((id1_top, id2_top), (s1, s2), (loc1, loc2), kind, param)
+ )
+
+ return self
+
+ def solve(self) -> "Assembly":
+ """
+ Solve the constraints.
+ """
+
+ # get all entities and number them
+ ents = {}
+
+ i = 0
+ lock_ix = 0
+ for c in self.constraints:
+ for name in c.objects:
+ if name not in ents:
+ ents[name] = i
+ if name == self.name:
+ lock_ix = i
+ i += 1
+
+ locs = [self.objects[n].loc for n in ents]
+
+ # construct the constraint mapping
+ constraints = []
+ for c in self.constraints:
+ constraints.append(((ents[c.objects[0]], ents[c.objects[1]]), c.toPOD()))
+
+ # instantiate the solver
+ solver = ConstraintSolver(locs, constraints, locked=[lock_ix])
+
+ # solve
+ locs_new = solver.solve()
+
+ # update positions
+ for loc_new, n in zip(locs_new, ents):
+ self.objects[n].loc = loc_new
+
+ return self
+
+ def save(
+ self, path: str, exportType: Optional[ExportLiterals] = None
+ ) -> "Assembly":
+ """
+ save as STEP or OCCT native XML file
+
+ :param path: filepath
+ :param exportType: export format (deafault: None, results in format being inferred form the path)
+ """
+
+ if exportType is None:
+ t = path.split(".")[-1].upper()
+ if t in ("STEP", "XML"):
+ exportType = cast(ExportLiterals, t)
+ else:
+ raise ValueError("Unknown extension, specify export type explicitly")
+
+ if exportType == "STEP":
+ exportAssembly(self, path)
+ elif exportType == "XML":
+ exportCAF(self, path)
+ else:
+ raise ValueError(f"Unknown format: {exportType}")
+
+ return self
+
+ @classmethod
+ def load(cls, path: str) -> "Assembly":
+
+ raise NotImplementedError
+
+ @property
+ def shapes(self) -> List[Shape]:
+ """
+ List of Shape objects in the .obj field
+ """
+
+ rv: List[Shape] = []
+
+ if isinstance(self.obj, Shape):
+ rv = [self.obj]
+ elif isinstance(self.obj, Workplane):
+ rv = [el for el in self.obj.vals() if isinstance(el, Shape)]
+
+ return rv
+
+ def traverse(self) -> Iterator[Tuple[str, "Assembly"]]:
+ """
+ Yield (name, child) pairs in a bottom-up manner
+ """
+
+ for ch in self.children:
+ for el in ch.traverse():
+ yield el
+
+ yield (self.name, self)
diff --git a/cadquery/cq_directive.py b/cadquery/cq_directive.py
index bfbfc0de..1722c7d8 100644
--- a/cadquery/cq_directive.py
+++ b/cadquery/cq_directive.py
@@ -4,10 +4,9 @@ A special directive for including a cq object.
"""
import traceback
-from cadquery import *
+from cadquery import exporters
from cadquery import cqgi
-import io
-from docutils.parsers.rst import directives
+from docutils.parsers.rst import directives, Directive
template = """
@@ -23,60 +22,66 @@ template = """
template_content_indent = " "
-def cq_directive(
- name,
- arguments,
- options,
- content,
- lineno,
- content_offset,
- block_text,
- state,
- state_machine,
-):
- # only consider inline snippets
- plot_code = "\n".join(content)
+class cq_directive(Directive):
- # Since we don't have a filename, use a hash based on the content
- # the script must define a variable called 'out', which is expected to
- # be a CQ object
- out_svg = "Your Script Did not assign call build_output() function!"
+ has_content = True
+ required_arguments = 0
+ optional_arguments = 2
+ option_spec = {
+ "height": directives.length_or_unitless,
+ "width": directives.length_or_percentage_or_unitless,
+ "align": directives.unchanged,
+ }
- try:
- _s = io.StringIO()
- result = cqgi.parse(plot_code).build()
+ def run(self):
- if result.success:
- exporters.exportShape(result.first_result.shape, "SVG", _s)
- out_svg = _s.getvalue()
- else:
- raise result.exception
+ options = self.options
+ content = self.content
+ state_machine = self.state_machine
- except Exception:
- traceback.print_exc()
- out_svg = traceback.format_exc()
+ # only consider inline snippets
+ plot_code = "\n".join(content)
- # now out
- # Now start generating the lines of output
- lines = []
+ # Since we don't have a filename, use a hash based on the content
+ # the script must define a variable called 'out', which is expected to
+ # be a CQ object
+ out_svg = "Your Script Did not assign call build_output() function!"
- # get rid of new lines
- out_svg = out_svg.replace("\n", "")
+ try:
+ result = cqgi.parse(plot_code).build()
- txt_align = "left"
- if "align" in options:
- txt_align = options["align"]
+ if result.success:
+ out_svg = exporters.getSVG(
+ exporters.toCompound(result.first_result.shape)
+ )
+ else:
+ raise result.exception
- lines.extend((template % locals()).split("\n"))
+ except Exception:
+ traceback.print_exc()
+ out_svg = traceback.format_exc()
- lines.extend(["::", ""])
- lines.extend([" %s" % row.rstrip() for row in plot_code.split("\n")])
- lines.append("")
+ # now out
+ # Now start generating the lines of output
+ lines = []
- if len(lines):
- state_machine.insert_input(lines, state_machine.input_lines.source(0))
+ # get rid of new lines
+ out_svg = out_svg.replace("\n", "")
- return []
+ txt_align = "left"
+ if "align" in options:
+ txt_align = options["align"]
+
+ lines.extend((template % locals()).split("\n"))
+
+ lines.extend(["::", ""])
+ lines.extend([" %s" % row.rstrip() for row in plot_code.split("\n")])
+ lines.append("")
+
+ if len(lines):
+ state_machine.insert_input(lines, state_machine.input_lines.source(0))
+
+ return []
def setup(app):
@@ -84,10 +89,4 @@ def setup(app):
setup.config = app.config
setup.confdir = app.confdir
- options = {
- "height": directives.length_or_unitless,
- "width": directives.length_or_percentage_or_unitless,
- "align": directives.unchanged,
- }
-
- app.add_directive("cq_plot", cq_directive, True, (0, 2, 0), **options)
+ app.add_directive("cq_plot", cq_directive)
diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py
new file mode 100644
index 00000000..adb832cd
--- /dev/null
+++ b/cadquery/occ_impl/assembly.py
@@ -0,0 +1,152 @@
+from typing import Iterable, Tuple, Dict, overload, Optional
+from typing_extensions import Protocol
+
+from OCP.TDocStd import TDocStd_Document
+from OCP.TCollection import TCollection_ExtendedString
+from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType
+from OCP.TDataStd import TDataStd_Name
+from OCP.TDF import TDF_Label
+from OCP.TopLoc import TopLoc_Location
+from OCP.Quantity import Quantity_ColorRGBA
+
+from .geom import Location
+from .shapes import Shape, Compound
+
+
+class Color(object):
+ """
+ Wrapper for the OCCT color object Quantity_ColorRGBA.
+ """
+
+ wrapped: Quantity_ColorRGBA
+
+ @overload
+ def __init__(self, name: str):
+ """
+ Construct a Color from a name.
+
+ :param name: name of the color, e.g. green
+ """
+ ...
+
+ @overload
+ def __init__(self, r: float, g: float, b: float, a: float = 0):
+ """
+ Construct a Color from RGB(A) values.
+
+ :param r: red value, 0-1
+ :param g: green value, 0-1
+ :param b: blue value, 0-1
+ :param a: alpha value, 0-1 (default: 0)
+ """
+ ...
+
+ def __init__(self, *args, **kwargs):
+
+ if len(args) == 1:
+ self.wrapped = Quantity_ColorRGBA()
+ exists = Quantity_ColorRGBA.ColorFromName_s(args[0], self.wrapped)
+ if not exists:
+ raise ValueError(f"Unknown color name: {args[0]}")
+ elif len(args) == 3:
+ r, g, b = args
+ self.wrapped = Quantity_ColorRGBA(r, g, b, 1)
+ if kwargs.get("a"):
+ self.wrapped.SetAlpha(kwargs.get("a"))
+ elif len(args) == 4:
+ r, g, b, a = args
+ self.wrapped = Quantity_ColorRGBA(r, g, b, a)
+ else:
+ raise ValueError(f"Unsupported arguments: {args}, {kwargs}")
+
+
+class AssemblyProtocol(Protocol):
+ @property
+ def loc(self) -> Location:
+ ...
+
+ @property
+ def name(self) -> str:
+ ...
+
+ @property
+ def parent(self) -> Optional["AssemblyProtocol"]:
+ ...
+
+ @property
+ def color(self) -> Optional[Color]:
+ ...
+
+ @property
+ def shapes(self) -> Iterable[Shape]:
+ ...
+
+ @property
+ def children(self) -> Iterable["AssemblyProtocol"]:
+ ...
+
+ def traverse(self) -> Iterable[Tuple[str, "AssemblyProtocol"]]:
+ ...
+
+
+def setName(l: TDF_Label, name: str, tool):
+
+ TDataStd_Name.Set_s(l, TCollection_ExtendedString(name))
+
+
+def setColor(l: TDF_Label, color: Color, tool):
+
+ tool.SetColor(l, color.wrapped, XCAFDoc_ColorType.XCAFDoc_ColorSurf)
+
+
+def toCAF(
+ assy: AssemblyProtocol, coloredSTEP: bool = False
+) -> Tuple[TDF_Label, TDocStd_Document]:
+
+ # prepare a doc
+ doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
+ tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
+ tool.SetAutoNaming_s(False)
+ ctool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
+
+ # add root
+ top = tool.NewShape()
+ TDataStd_Name.Set_s(top, TCollection_ExtendedString("CQ assembly"))
+
+ # add leafs and subassemblies
+ subassys: Dict[str, Tuple[TDF_Label, Location]] = {}
+ for k, v in assy.traverse():
+ # leaf part
+ lab = tool.NewShape()
+ tool.SetShape(lab, Compound.makeCompound(v.shapes).wrapped)
+ setName(lab, f"{k}_part", tool)
+
+ # assy part
+ subassy = tool.NewShape()
+ tool.AddComponent(subassy, lab, TopLoc_Location())
+ setName(subassy, k, tool)
+
+ # handle colors - this logic is needed for proper STEP export
+ color = v.color
+ tmp = v
+ if coloredSTEP:
+ while not color and tmp.parent:
+ tmp = tmp.parent
+ color = tmp.color
+ if color:
+ setColor(lab, color, ctool)
+ else:
+ if color:
+ setColor(subassy, color, ctool)
+
+ subassys[k] = (subassy, v.loc)
+
+ for ch in v.children:
+ tool.AddComponent(
+ subassy, subassys[ch.name][0], subassys[ch.name][1].wrapped
+ )
+
+ tool.AddComponent(top, subassys[assy.name][0], assy.loc.wrapped)
+ tool.UpdateAssemblies()
+
+ return top, doc
diff --git a/cadquery/occ_impl/exporters/assembly.py b/cadquery/occ_impl/exporters/assembly.py
new file mode 100644
index 00000000..146ea655
--- /dev/null
+++ b/cadquery/occ_impl/exporters/assembly.py
@@ -0,0 +1,63 @@
+import os.path
+
+from OCP.XSControl import XSControl_WorkSession
+from OCP.STEPCAFControl import STEPCAFControl_Writer
+from OCP.STEPControl import STEPControl_StepModelType
+from OCP.IFSelect import IFSelect_ReturnStatus
+from OCP.XCAFApp import XCAFApp_Application
+from OCP.XmlDrivers import (
+ XmlDrivers_DocumentStorageDriver,
+ XmlDrivers_DocumentRetrievalDriver,
+)
+from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString
+from OCP.PCDM import PCDM_StoreStatus
+
+from ..assembly import AssemblyProtocol, toCAF
+
+
+def exportAssembly(assy: AssemblyProtocol, path: str) -> bool:
+
+ _, doc = toCAF(assy, True)
+
+ session = XSControl_WorkSession()
+ writer = STEPCAFControl_Writer(session, False)
+ writer.SetColorMode(True)
+ writer.SetLayerMode(True)
+ writer.SetNameMode(True)
+ writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs)
+
+ status = writer.Write(path)
+
+ return status == IFSelect_ReturnStatus.IFSelect_RetDone
+
+
+def exportCAF(assy: AssemblyProtocol, path: str) -> bool:
+
+ folder, fname = os.path.split(path)
+ name, ext = os.path.splitext(fname)
+ ext = ext[1:] if ext[0] == "." else ext
+
+ _, doc = toCAF(assy)
+ app = XCAFApp_Application.GetApplication_s()
+
+ store = XmlDrivers_DocumentStorageDriver(
+ TCollection_ExtendedString("Copyright: Open Cascade, 2001-2002")
+ )
+ ret = XmlDrivers_DocumentRetrievalDriver()
+
+ app.DefineFormat(
+ TCollection_AsciiString("XmlOcaf"),
+ TCollection_AsciiString("Xml XCAF Document"),
+ TCollection_AsciiString(ext),
+ ret,
+ store,
+ )
+
+ doc.SetRequestedFolder(TCollection_ExtendedString(folder))
+ doc.SetRequestedName(TCollection_ExtendedString(name))
+
+ status = app.SaveAs(doc, TCollection_ExtendedString(path))
+
+ app.Close(doc)
+
+ return status == PCDM_StoreStatus.PCDM_SS_OK
diff --git a/cadquery/occ_impl/geom.py b/cadquery/occ_impl/geom.py
index 53958ce7..e3c335a8 100644
--- a/cadquery/occ_impl/geom.py
+++ b/cadquery/occ_impl/geom.py
@@ -862,7 +862,7 @@ class BoundBox(object):
class Location(object):
"""Location in 3D space. Depending on usage can be absolute or relative.
-
+
This class wraps the TopLoc_Location class from OCCT. It can be used to move Shape
objects in both relative and absolute manner. It is the preferred type to locate objects
in CQ.
@@ -895,6 +895,11 @@ class Location(object):
"""Location wrapping the low-level TopLoc_Location object t"""
...
+ @overload
+ def __init__(self, t: gp_Trsf) -> None:
+ """Location wrapping the low-level gp_Trsf object t"""
+ ...
+
@overload
def __init__(self, t: Vector, ax: Vector, angle: float) -> None:
"""Location with translation t and rotation around ax by angle
@@ -919,6 +924,8 @@ class Location(object):
elif isinstance(t, TopLoc_Location):
self.wrapped = t
return
+ elif isinstance(t, gp_Trsf):
+ T = t
elif len(args) == 2:
t, v = args
cs = gp_Ax3(v.toPnt(), t.zDir.toDir(), t.xDir.toDir())
diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py
index 6adf6aee..67d3f868 100644
--- a/cadquery/occ_impl/shapes.py
+++ b/cadquery/occ_impl/shapes.py
@@ -314,8 +314,7 @@ def fix(obj: TopoDS_Shape) -> TopoDS_Shape:
class Shape(object):
"""
- Represents a shape in the system.
- Wrappers the FreeCAD apiSh
+ Represents a shape in the system. Wraps TopoDS_Shape.
"""
wrapped: TopoDS_Shape
@@ -695,6 +694,13 @@ class Shape(object):
return r
+ def location(self) -> Location:
+ """
+ Return the current location
+ """
+
+ return Location(self.wrapped.Location())
+
def locate(self, loc: Location) -> "Shape":
"""
Apply a location in absolute sense to self
@@ -978,6 +984,28 @@ class Edge(Shape, Mixin1D):
return rv
+ def normal(self) -> Vector:
+ """
+ Calculate normal Vector. Only possible for CIRCLE or ELLIPSE
+
+ :param locationParam: location to use in [0,1]
+ :return: tangent vector
+ """
+
+ curve = self._geomAdaptor()
+ gtype = self.geomType()
+
+ if gtype == "CIRCLE":
+ circ = curve.Circle()
+ rv = Vector(circ.Axis().Direction())
+ elif gtype == "ELLIPSE":
+ ell = curve.Ellipse()
+ rv = Vector(ell.Axis().Direction())
+ else:
+ raise ValueError(f"{gtype} has no normal")
+
+ return rv
+
def Center(self) -> Vector:
Properties = GProp_GProps()
diff --git a/cadquery/occ_impl/solver.py b/cadquery/occ_impl/solver.py
new file mode 100644
index 00000000..66961c06
--- /dev/null
+++ b/cadquery/occ_impl/solver.py
@@ -0,0 +1,225 @@
+from typing import Tuple, Union, Any, Callable, List, Optional
+from nptyping import NDArray as Array
+
+from numpy import array, eye, zeros, pi
+from scipy.optimize import minimize
+
+from OCP.gp import gp_Vec, gp_Dir, gp_Pnt, gp_Trsf, gp_Quaternion
+
+from .geom import Location
+
+DOF6 = Tuple[float, float, float, float, float, float]
+ConstraintMarker = Union[gp_Dir, gp_Pnt]
+Constraint = Tuple[
+ Tuple[ConstraintMarker, ...], Tuple[Optional[ConstraintMarker], ...], Optional[Any]
+]
+
+NDOF = 6
+DIR_SCALING = 1e4
+DIFF_EPS = 1e-9
+
+
+class ConstraintSolver(object):
+
+ entities: List[DOF6]
+ constraints: List[Tuple[Tuple[int, Optional[int]], Constraint]]
+ locked: List[int]
+ ne: int
+ nc: int
+
+ def __init__(
+ self,
+ entities: List[Location],
+ constraints: List[Tuple[Tuple[int, int], Constraint]],
+ locked: List[int] = [],
+ ):
+
+ self.entities = [self._locToDOF6(loc) for loc in entities]
+ self.constraints = []
+
+ # decompose into simple constraints
+ for k, v in constraints:
+ ms1, ms2, d = v
+ if ms2:
+ for m1, m2 in zip(ms1, ms2):
+ self.constraints.append((k, ((m1,), (m2,), d)))
+ else:
+ raise NotImplementedError(
+ "Single marker constraints are not implemented"
+ )
+
+ self.ne = len(entities)
+ self.locked = locked
+ self.nc = len(self.constraints)
+
+ @staticmethod
+ def _locToDOF6(loc: Location) -> DOF6:
+
+ T = loc.wrapped.Transformation()
+ v = T.TranslationPart()
+ q = T.GetRotation()
+
+ alpha_2 = (1 - q.W()) / (1 + q.W())
+ a = (alpha_2 + 1) * q.X() / 2
+ b = (alpha_2 + 1) * q.Y() / 2
+ c = (alpha_2 + 1) * q.Z() / 2
+
+ return (v.X(), v.Y(), v.Z(), a, b, c)
+
+ def _build_transform(
+ self, x: float, y: float, z: float, a: float, b: float, c: float
+ ) -> gp_Trsf:
+
+ rv = gp_Trsf()
+ m = a ** 2 + b ** 2 + c ** 2
+
+ rv.SetRotation(
+ gp_Quaternion(
+ 2 * a / (m + 1), 2 * b / (m + 1), 2 * c / (m + 1), (1 - m) / (m + 1),
+ )
+ )
+
+ rv.SetTranslationPart(gp_Vec(x, y, z))
+
+ return rv
+
+ def _cost(
+ self,
+ ) -> Tuple[
+ Callable[[Array[(Any,), float]], float],
+ Callable[[Array[(Any,), float]], Array[(Any,), float]],
+ ]:
+ def pt_cost(
+ m1: gp_Pnt,
+ m2: gp_Pnt,
+ t1: gp_Trsf,
+ t2: gp_Trsf,
+ val: Optional[float] = None,
+ ) -> float:
+
+ val = 0 if val is None else val
+
+ return (
+ val - (m1.Transformed(t1).XYZ() - m2.Transformed(t2).XYZ()).Modulus()
+ ) ** 2
+
+ def dir_cost(
+ m1: gp_Dir,
+ m2: gp_Dir,
+ t1: gp_Trsf,
+ t2: gp_Trsf,
+ val: Optional[float] = None,
+ ) -> float:
+
+ val = pi if val is None else val
+
+ return (
+ DIR_SCALING * (val - m1.Transformed(t1).Angle(m2.Transformed(t2))) ** 2
+ )
+
+ def f(x):
+
+ constraints = self.constraints
+ ne = self.ne
+
+ rv = 0
+
+ transforms = [
+ self._build_transform(*x[NDOF * i : NDOF * (i + 1)]) for i in range(ne)
+ ]
+
+ for i, ((k1, k2), (ms1, ms2, d)) in enumerate(constraints):
+ t1 = transforms[k1] if k1 not in self.locked else gp_Trsf()
+ t2 = transforms[k2] if k2 not in self.locked else gp_Trsf()
+
+ for m1, m2 in zip(ms1, ms2):
+ if isinstance(m1, gp_Pnt):
+ rv += pt_cost(m1, m2, t1, t2, d)
+ elif isinstance(m1, gp_Dir):
+ rv += dir_cost(m1, m2, t1, t2, d)
+ else:
+ raise NotImplementedError(f"{m1,m2}")
+
+ return rv
+
+ def jac(x):
+
+ constraints = self.constraints
+ ne = self.ne
+
+ delta = DIFF_EPS * eye(NDOF)
+
+ rv = zeros(NDOF * ne)
+
+ transforms = [
+ self._build_transform(*x[NDOF * i : NDOF * (i + 1)]) for i in range(ne)
+ ]
+
+ transforms_delta = [
+ self._build_transform(*(x[NDOF * i : NDOF * (i + 1)] + delta[j, :]))
+ for i in range(ne)
+ for j in range(NDOF)
+ ]
+
+ for i, ((k1, k2), (ms1, ms2, d)) in enumerate(constraints):
+ t1 = transforms[k1] if k1 not in self.locked else gp_Trsf()
+ t2 = transforms[k2] if k2 not in self.locked else gp_Trsf()
+
+ for m1, m2 in zip(ms1, ms2):
+ if isinstance(m1, gp_Pnt):
+ tmp = pt_cost(m1, m2, t1, t2, d)
+
+ for j in range(NDOF):
+
+ t1j = transforms_delta[k1 * NDOF + j]
+ t2j = transforms_delta[k2 * NDOF + j]
+
+ if k1 not in self.locked:
+ tmp1 = pt_cost(m1, m2, t1j, t2, d)
+ rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS
+
+ if k2 not in self.locked:
+ tmp2 = pt_cost(m1, m2, t1, t2j, d)
+ rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
+
+ elif isinstance(m1, gp_Dir):
+ tmp = dir_cost(m1, m2, t1, t2, d)
+
+ for j in range(NDOF):
+
+ t1j = transforms_delta[k1 * NDOF + j]
+ t2j = transforms_delta[k2 * NDOF + j]
+
+ if k1 not in self.locked:
+ tmp1 = dir_cost(m1, m2, t1j, t2, d)
+ rv[k1 * NDOF + j] += (tmp1 - tmp) / DIFF_EPS
+
+ if k2 not in self.locked:
+ tmp2 = dir_cost(m1, m2, t1, t2j, d)
+ rv[k2 * NDOF + j] += (tmp2 - tmp) / DIFF_EPS
+ else:
+ raise NotImplementedError(f"{m1,m2}")
+
+ return rv
+
+ return f, jac
+
+ def solve(self) -> List[Location]:
+
+ x0 = array([el for el in self.entities]).ravel()
+ f, jac = self._cost()
+
+ res = minimize(
+ f,
+ x0,
+ jac=jac,
+ method="BFGS",
+ options=dict(disp=True, gtol=1e-12, maxiter=1000),
+ )
+
+ x = res.x
+
+ return [
+ Location(self._build_transform(*x[NDOF * i : NDOF * (i + 1)]))
+ for i in range(self.ne)
+ ]
diff --git a/conda/meta.yaml b/conda/meta.yaml
index 4d112d68..4e55c553 100644
--- a/conda/meta.yaml
+++ b/conda/meta.yaml
@@ -20,6 +20,8 @@ requirements:
- ezdxf
- ipython
- typing_extensions
+ - nptyping
+ - scipy
test:
requires:
diff --git a/doc/_static/assy.png b/doc/_static/assy.png
new file mode 100644
index 00000000..8ee52dba
Binary files /dev/null and b/doc/_static/assy.png differ
diff --git a/doc/_static/simple_assy.png b/doc/_static/simple_assy.png
new file mode 100644
index 00000000..bee932df
Binary files /dev/null and b/doc/_static/simple_assy.png differ
diff --git a/doc/apireference.rst b/doc/apireference.rst
index 82a4ea90..cd1ea622 100644
--- a/doc/apireference.rst
+++ b/doc/apireference.rst
@@ -185,3 +185,19 @@ as a basis for futher operations.
SubtractSelector
InverseSelector
StringSyntaxSelector
+
+Assemblies
+----------
+
+Workplane and Shape objects can be connected together into assemblies
+
+.. currentmodule:: cadquery
+
+.. autosummary::
+
+ Assembly
+ Assembly.add
+ Assembly.save
+ Assembly.constrain
+ Constraint
+ Color
diff --git a/doc/classreference.rst b/doc/classreference.rst
index ce88a2a7..83579446 100644
--- a/doc/classreference.rst
+++ b/doc/classreference.rst
@@ -18,6 +18,8 @@ Core Classes
.. autosummary::
CQ
Workplane
+ Assembly
+ Constraint
Topological Classes
----------------------
@@ -39,6 +41,7 @@ Geometry Classes
Vector
Matrix
Plane
+ Location
Selector Classes
---------------------
diff --git a/doc/conf.py b/doc/conf.py
index 5e61b295..327d6d70 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -35,12 +35,15 @@ import cadquery
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = [
"sphinx.ext.autodoc",
+ "sphinx_autodoc_typehints",
"sphinx.ext.viewcode",
"sphinx.ext.autosummary",
"cadquery.cq_directive",
"sphinxcadquery.sphinxcadquery",
]
+always_document_param_types = True
+
# Configure `sphinxcadquery`
sphinxcadquery_include_source = True
diff --git a/doc/extending.rst b/doc/extending.rst
index e31185f1..dc9279de 100644
--- a/doc/extending.rst
+++ b/doc/extending.rst
@@ -9,14 +9,14 @@ methods:
* You can load plugins others have developed. This is by far the easiest way to access other code
* You can define your own plugins.
- * You can use PythonOCC scripting directly
+ * You can use OCP scripting directly
-Using PythonOCC Script
------------------------
+Using OpenCascade methods
+-------------------------
-The easiest way to extend CadQuery is to simply use PythonOCC scripting inside of your build method. Just about
-any valid PythonOCC script will execute just fine. For example, this simple CadQuery script::
+The easiest way to extend CadQuery is to simply use OpenCascade/OCP scripting inside of your build method. Just about
+any valid OCP script will execute just fine. For example, this simple CadQuery script::
return cq.Workplane("XY").box(1.0,2.0,3.0).val()
@@ -24,8 +24,8 @@ is actually equivalent to::
return cq.Shape.cast(BRepPrimAPI_MakeBox(gp_Ax2(Vector(-0.1, -1.0, -1.5), Vector(0, 0, 1)), 1.0, 2.0, 3.0).Shape())
-As long as you return a valid PythonOCC Shape, you can use any PythonOCC methods you like. You can even mix and match the
-two. For example, consider this script, which creates a PythonOCC box, but then uses CadQuery to select its faces::
+As long as you return a valid OCP Shape, you can use any OCP methods you like. You can even mix and match the
+two. For example, consider this script, which creates a OCP box, but then uses CadQuery to select its faces::
box = cq.Shape.cast(BRepPrimAPI_MakeBox(gp_Ax2(Vector(-0.1, -1.0, -1.5), Vector(0, 0, 1)), 1.0, 2.0, 3.0).Shape())
cq = Workplane(box).faces(">Z").size() # returns 6
@@ -34,10 +34,10 @@ two. For example, consider this script, which creates a PythonOCC box, but then
Extending CadQuery: Plugins
----------------------------
-Though you can get a lot done with PythonOCC, the code gets pretty nasty in a hurry. CadQuery shields you from
-a lot of the complexity of the PythonOCC API.
+Though you can get a lot done with OpenCascade, the code gets pretty nasty in a hurry. CadQuery shields you from
+a lot of the complexity of the OpenCascade API.
-You can get the best of both worlds by wrapping your PythonOCC script into a CadQuery plugin.
+You can get the best of both worlds by wrapping your OCP script into a CadQuery plugin.
A CadQuery plugin is simply a function that is attached to the CadQuery :py:meth:`cadquery.CQ` or :py:meth:`cadquery.Workplane` class.
When connected, your plugin can be used in the chain just like the built-in functions.
@@ -51,8 +51,8 @@ The Stack
Every CadQuery object has a local stack, which contains a list of items. The items on the stack will be
one of these types:
- * **A CadQuery SolidReference object**, which holds a reference to a PythonOCC solid
- * **A PythonOCC object**, a Vertex, Edge, Wire, Face, Shell, Solid, or Compound
+ * **A CadQuery SolidReference object**, which holds a reference to a OCP solid
+ * **A OCP object**, a Vertex, Edge, Wire, Face, Shell, Solid, or Compound
The stack is available by using self.objects, and will always contain at least one object.
diff --git a/doc/intro.rst b/doc/intro.rst
index 22dde7d9..5bb6642d 100644
--- a/doc/intro.rst
+++ b/doc/intro.rst
@@ -19,7 +19,7 @@ CadQuery is an intuitive, easy-to-use Python library for building parametric 3D
* Provide a non-proprietary, plain text model format that can be edited and executed with only a web browser
CadQuery 2.0 is based on
-`PythonOCC `_,
+`OCP https://github.com/CadQuery/OCP`_,
which is a set of Python bindings for the open-source `OpenCascade `_ modelling kernel.
Using CadQuery, you can build fully parametric models with a very small amount of code. For example, this simple script
@@ -54,8 +54,7 @@ its use in a variety of engineering and scientific applications that create 3D m
If you'd like a GUI, you have a couple of options:
* The Qt-based GUI `CQ-editor `_
- * As an Jupyter extension `cadquery-jupyter-extension
- `_
+ * As an Jupyter extension `jupyter-cadquery `_
Why CadQuery instead of OpenSCAD?
@@ -70,7 +69,7 @@ Like OpenSCAD, CadQuery is an open-source, script based, parametric model genera
by OCC include NURBS, splines, surface sewing, STL repair, STEP import/export, and other complex operations,
in addition to the standard CSG operations supported by CGAL
- 3. **Ability to import/export STEP** We think the ability to begin with a STEP model, created in a CAD package,
+ 3. **Ability to import/export STEP and DXF** We think the ability to begin with a STEP model, created in a CAD package,
and then add parametric features is key. This is possible in OpenSCAD using STL, but STL is a lossy format
4. **Less Code and easier scripting** CadQuery scripts require less code to create most objects, because it is possible to locate
diff --git a/doc/primer.rst b/doc/primer.rst
index b2232c55..24cd0c91 100644
--- a/doc/primer.rst
+++ b/doc/primer.rst
@@ -165,3 +165,33 @@ iterates on each member of the stack.
This is really useful to remember when you author your own plugins. :py:meth:`cadquery.cq.Workplane.each` is useful for this purpose.
+Assemblies
+----------
+
+Simple models can be combined into complex, possibly nested, assemblies.
+
+.. image:: _static/assy.png
+
+A simple example could look as follows::
+
+ from cadquery import *
+
+ w = 10
+ d = 10
+ h = 10
+
+ part1 = Workplane().box(2*w,2*d,h)
+ part2 = Workplane().box(w,d,2*h)
+ part3 = Workplane().box(w,d,3*h)
+
+ assy = (
+ Assembly(part1, loc=Location(Vector(-w,0,h/2)))
+ .add(part2, loc=Location(Vector(1.5*w,-.5*d,h/2)), color=Color(0,0,1,0.5))
+ .add(part3, loc=Location(Vector(-.5*w,-.5*d,2*h)), color=Color("red"))
+ )
+
+Resulting in:
+
+.. image:: _static/simple_assy.png
+
+Note that the locations of the children parts are defined with respect to their parents - in the above example ``part3`` will be located at (-5,-5,20) in the global coordinate system. Assemblies with different colors can be created this way and exported to STEP or the native OCCT xml format.
diff --git a/environment.yml b/environment.yml
index 0041681a..6c9ceb13 100644
--- a/environment.yml
+++ b/environment.yml
@@ -8,8 +8,9 @@ dependencies:
- ipython
- ocp
- pyparsing
- - sphinx=2.4
+ - sphinx=3.2.1
- sphinx_rtd_theme
+ - sphinx-autodoc-typehints
- black
- codecov
- pytest
@@ -17,6 +18,8 @@ dependencies:
- ezdxf
- ipython
- typing_extensions
+ - nptyping
+ - scipy
- pip
- pip:
- "--editable=."
diff --git a/mypy.ini b/mypy.ini
index ef5416ed..9eb662b6 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,5 +1,5 @@
[mypy]
-ignore_missing_imports = False
+ignore_missing_imports = False
[mypy-ezdxf.*]
ignore_missing_imports = True
@@ -9,3 +9,12 @@ ignore_missing_imports = True
[mypy-IPython.*]
ignore_missing_imports = True
+
+[mypy-scipy.*]
+ignore_missing_imports = True
+
+[mypy-numpy.*]
+ignore_missing_imports = True
+
+[mypy-nptyping.*]
+ignore_missing_imports = True
diff --git a/tests/test_assembly.py b/tests/test_assembly.py
new file mode 100644
index 00000000..5c9e3912
--- /dev/null
+++ b/tests/test_assembly.py
@@ -0,0 +1,190 @@
+import pytest
+import os
+
+import cadquery as cq
+from cadquery.occ_impl.exporters.assembly import exportAssembly, exportCAF
+
+from OCP.gp import gp_XYZ
+
+
+@pytest.fixture
+def simple_assy():
+
+ b1 = cq.Solid.makeBox(1, 1, 1)
+ b2 = cq.Workplane().box(1, 1, 2)
+ b3 = cq.Workplane().pushPoints([(0, 0), (-2, -5)]).box(1, 1, 3)
+
+ assy = cq.Assembly(b1, loc=cq.Location(cq.Vector(2, -5, 0)))
+ assy.add(b2, loc=cq.Location(cq.Vector(1, 1, 0)))
+ assy.add(b3, loc=cq.Location(cq.Vector(2, 3, 0)))
+
+ return assy
+
+
+@pytest.fixture
+def nested_assy():
+
+ b1 = cq.Workplane().box(1, 1, 1)
+ b2 = cq.Workplane().box(1, 1, 1)
+ b3 = cq.Workplane().pushPoints([(-2, 0), (2, 0)]).box(1, 1, 0.5)
+
+ assy = cq.Assembly(b1, loc=cq.Location(cq.Vector(0, 0, 0)), name="TOP")
+ assy2 = cq.Assembly(b2, loc=cq.Location(cq.Vector(0, 4, 0)), name="SECOND")
+ assy2.add(b3, loc=cq.Location(cq.Vector(0, 4, 0)), name="BOTTOM")
+
+ assy.add(assy2, color=cq.Color("green"))
+
+ return assy
+
+
+def test_color():
+
+ c1 = cq.Color("red")
+ assert c1.wrapped.GetRGB().Red() == 1
+ assert c1.wrapped.Alpha() == 1
+
+ c2 = cq.Color(1, 0, 0)
+ assert c2.wrapped.GetRGB().Red() == 1
+ assert c2.wrapped.Alpha() == 1
+
+ c3 = cq.Color(1, 0, 0, 0.5)
+ assert c3.wrapped.GetRGB().Red() == 1
+ assert c3.wrapped.Alpha() == 0.5
+
+ with pytest.raises(ValueError):
+ cq.Color("?????")
+
+ with pytest.raises(ValueError):
+ cq.Color(1, 2, 3, 4, 5)
+
+
+def test_assembly(simple_assy, nested_assy):
+
+ # basic checks
+ assert len(simple_assy.objects) == 3
+ assert len(simple_assy.children) == 2
+ assert len(simple_assy.shapes) == 1
+
+ assert len(nested_assy.objects) == 3
+ assert len(nested_assy.children) == 1
+ assert nested_assy.objects["SECOND"].parent is nested_assy
+
+ # bottom-up traversal
+ kvs = list(nested_assy.traverse())
+
+ assert kvs[0][0] == "BOTTOM"
+ assert len(kvs[0][1].shapes[0].Solids()) == 2
+ assert kvs[-1][0] == "TOP"
+
+
+def test_step_export(nested_assy):
+
+ exportAssembly(nested_assy, "nested.step")
+
+ w = cq.importers.importStep("nested.step")
+ assert w.solids().size() == 4
+
+ # check that locations were applied correctly
+ c = cq.Compound.makeCompound(w.solids().vals()).Center()
+ c.toTuple()
+ assert pytest.approx(c.toTuple()) == (0, 4, 0)
+
+
+def test_native_export(simple_assy):
+
+ exportCAF(simple_assy, "assy.xml")
+
+ # only sanity check for now
+ assert os.path.exists("assy.xml")
+
+
+def test_save(simple_assy, nested_assy):
+
+ simple_assy.save("simple.step")
+ assert os.path.exists("simple.step")
+
+ simple_assy.save("simple.xml")
+ assert os.path.exists("simple.xml")
+
+ simple_assy.save("simple.step")
+ assert os.path.exists("simple.step")
+
+ simple_assy.save("simple.stp", "STEP")
+ assert os.path.exists("simple.stp")
+
+ simple_assy.save("simple.caf", "XML")
+ assert os.path.exists("simple.caf")
+
+ with pytest.raises(ValueError):
+ simple_assy.save("simple.dxf")
+
+ with pytest.raises(ValueError):
+ simple_assy.save("simple.step", "DXF")
+
+
+def test_constrain(simple_assy, nested_assy):
+
+ subassy1 = simple_assy.children[0]
+ subassy2 = simple_assy.children[1]
+
+ b1 = simple_assy.obj
+ b2 = subassy1.obj
+ b3 = subassy2.obj
+
+ simple_assy.constrain(
+ simple_assy.name, b1.Faces()[0], subassy1.name, b2.faces("Z").val(),
+ subassy2.name,
+ b3.faces("Z", "BOTTOM@faces@X", "BOTTOM@faces@