@ -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.
|
||||
|
@ -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
413
cadquery/assembly.py
Normal 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)
|
@ -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)
|
||||
|
152
cadquery/occ_impl/assembly.py
Normal file
152
cadquery/occ_impl/assembly.py
Normal 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
|
63
cadquery/occ_impl/exporters/assembly.py
Normal file
63
cadquery/occ_impl/exporters/assembly.py
Normal 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
|
@ -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())
|
||||
|
@ -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
225
cadquery/occ_impl/solver.py
Normal 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)
|
||||
]
|
@ -20,6 +20,8 @@ requirements:
|
||||
- ezdxf
|
||||
- ipython
|
||||
- typing_extensions
|
||||
- nptyping
|
||||
- scipy
|
||||
|
||||
test:
|
||||
requires:
|
||||
|
BIN
doc/_static/assy.png
vendored
Normal file
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
BIN
doc/_static/simple_assy.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
@ -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
|
||||
|
@ -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
|
||||
---------------------
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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=."
|
||||
|
9
mypy.ini
9
mypy.ini
@ -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
190
tests/test_assembly.py
Normal 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)
|
||||
)
|
@ -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()
|
||||
|
@ -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()
|
||||
|
Reference in New Issue
Block a user