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 Hexidor Board Game +### 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@