Merge pull request #440 from CadQuery/assembly

Assembly support
This commit is contained in:
Jeremy Wright
2020-10-01 06:48:06 -04:00
committed by GitHub
23 changed files with 1255 additions and 76 deletions

View File

@ -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
<img src="https://bruceisonfire.net/wp-content/uploads/2020/04/16-945x709.jpg" alt="Hexidor Board Game" width="400"/>
### Spindle assembly
Thanks to @marcus7070 for this example from [here](https://github.com/marcus7070/spindle-assy-example).
<img src="./doc/_static/assy.png" width="400">
### 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.

View File

@ -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"

413
cadquery/assembly.py Normal file
View File

@ -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)

View File

@ -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,17 +22,23 @@ template = """
template_content_indent = " "
def cq_directive(
name,
arguments,
options,
content,
lineno,
content_offset,
block_text,
state,
state_machine,
):
class cq_directive(Directive):
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,
}
def run(self):
options = self.options
content = self.content
state_machine = self.state_machine
# only consider inline snippets
plot_code = "\n".join(content)
@ -43,12 +48,12 @@ def cq_directive(
out_svg = "Your Script Did not assign call build_output() function!"
try:
_s = io.StringIO()
result = cqgi.parse(plot_code).build()
if result.success:
exporters.exportShape(result.first_result.shape, "SVG", _s)
out_svg = _s.getvalue()
out_svg = exporters.getSVG(
exporters.toCompound(result.first_result.shape)
)
else:
raise result.exception
@ -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)

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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()

225
cadquery/occ_impl/solver.py Normal file
View File

@ -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)
]

View File

@ -20,6 +20,8 @@ requirements:
- ezdxf
- ipython
- typing_extensions
- nptyping
- scipy
test:
requires:

BIN
doc/_static/assy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
doc/_static/simple_assy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -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

View File

@ -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
---------------------

View File

@ -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

View File

@ -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.

View File

@ -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 <http://www.pythonocc.org/>`_,
`OCP https://github.com/CadQuery/OCP`_,
which is a set of Python bindings for the open-source `OpenCascade <http://www.opencascade.com/>`_ 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 <https://github.com/CadQuery/CQ-editor>`_
* As an Jupyter extension `cadquery-jupyter-extension
<https://github.com/bernhard-42/cadquery-jupyter-extension>`_
* As an Jupyter extension `jupyter-cadquery <https://github.com/bernhard-42/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

View File

@ -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.

View File

@ -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=."

View File

@ -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

190
tests/test_assembly.py Normal file
View File

@ -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(), "Plane"
)
simple_assy.constrain(
simple_assy.name, b1.Faces()[0], subassy2.name, b3.faces("<Z").val(), "Axis"
)
simple_assy.constrain(
subassy1.name,
b2.faces(">Z").val(),
subassy2.name,
b3.faces("<Z").val(),
"Point",
)
assert len(simple_assy.constraints) == 3
nested_assy.constrain("TOP@faces@>Z", "BOTTOM@faces@<Z", "Plane")
nested_assy.constrain("TOP@faces@>X", "BOTTOM@faces@<X", "Axis")
assert len(nested_assy.constraints) == 2
constraint = nested_assy.constraints[0]
assert constraint.objects == ("TOP", "SECOND")
assert (
constraint.sublocs[0]
.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(), 1e-9)
)
assert constraint.sublocs[1].wrapped.IsEqual(
nested_assy.objects["BOTTOM"].loc.wrapped
)
simple_assy.solve()
assert (
simple_assy.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(2, -5, 0), 1e-9)
)
assert (
simple_assy.children[0]
.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(-1, 0.5, 0.5), 1e-6)
)
nested_assy.solve()
assert (
nested_assy.children[0]
.loc.wrapped.Transformation()
.TranslationPart()
.IsEqual(gp_XYZ(2, -4, 0.75), 1e-6)
)

View File

@ -2,7 +2,7 @@
import math
import unittest
from tests import BaseTest
from OCP.gp import gp_Vec, gp_Pnt, gp_Ax2, gp_Circ, gp_Elips, gp, gp_XYZ
from OCP.gp import gp_Vec, gp_Pnt, gp_Ax2, gp_Circ, gp_Elips, gp, gp_XYZ, gp_Trsf
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
from cadquery import *
@ -408,6 +408,16 @@ class TestCadObjects(BaseTest):
angle = loc2.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
self.assertAlmostEqual(45, angle)
# gp_Trsf
T = gp_Trsf()
T.SetTranslation(gp_Vec(0, 0, 1))
loc3 = Location(T)
assert (
loc1.wrapped.Transformation().TranslationPart().Z()
== loc3.wrapped.Transformation().TranslationPart().Z()
)
if __name__ == "__main__":
unittest.main()

View File

@ -3609,3 +3609,19 @@ class TestCadQuery(BaseTest):
self.assertAlmostEqual(T3.TranslationPart().X(), r, 6)
self.assertAlmostEqual(T4.TranslationPart().X(), r, 6)
def testNormal(self):
circ = Workplane().circle(1).edges().val()
n = circ.normal()
self.assertTupleAlmostEquals(n.toTuple(), (0, 0, 1), 6)
ell = Workplane().ellipse(1, 2).edges().val()
n = ell.normal()
self.assertTupleAlmostEquals(n.toTuple(), (0, 0, 1), 6)
with self.assertRaises(ValueError):
edge = Workplane().rect(1, 2).edges().val()
n = edge.normal()