Sketch support (#879)

Squashed commit of many changes, see PR #879 for full details.
This commit is contained in:
AU
2021-11-11 10:58:09 +01:00
committed by GitHub
parent e4b6a04b0c
commit 2bf9116b5b
23 changed files with 3323 additions and 305 deletions

View File

@ -14,29 +14,7 @@ resources:
name: CadQuery/conda-packages
endpoint: CadQuery
jobs:
- template: conda-build.yml@templates
parameters:
name: Linux_37
vmImage: 'ubuntu-18.04'
py_maj: 3
py_min: 7
- template: conda-build.yml@templates
parameters:
name: macOS_37
vmImage: 'macOS-10.15'
py_maj: 3
py_min: 7
- template: conda-build.yml@templates
parameters:
name: Windows_37
vmImage: 'vs2017-win2016'
py_maj: 3
py_min: 7
conda_bld: 3.18
jobs:
- template: conda-build.yml@templates
parameters:
name: Linux_38

View File

@ -27,6 +27,7 @@ from .selectors import (
StringSyntaxSelector,
Selector,
)
from .sketch import Sketch
from .cq import CQ, Workplane
from .assembly import Assembly, Color, Constraint
from . import selectors
@ -66,6 +67,7 @@ __all__ = [
"StringSyntaxSelector",
"Selector",
"plugins",
"Sketch",
]
__version__ = "2.1"

View File

@ -46,6 +46,7 @@ from .occ_impl.shapes import (
Solid,
Compound,
sortWiresByBuildOrder,
wiresToFaces,
)
from .occ_impl.exporters.svg import getSVG, exportSVG
@ -59,7 +60,9 @@ from .selectors import (
StringSyntaxSelector,
)
CQObject = Union[Vector, Location, Shape]
from .sketch import Sketch
CQObject = Union[Vector, Location, Shape, Sketch]
VectorLike = Union[Tuple[float, float], Tuple[float, float, float], Vector]
T = TypeVar("T", bound="Workplane")
@ -489,7 +492,9 @@ class Workplane(object):
:rtype TopoDS_Shape or a subclass
"""
return self.val().wrapped
v = self.val()
return v._faces if isinstance(v, Sketch) else v.wrapped
def workplane(
self: T,
@ -2459,6 +2464,8 @@ class Workplane(object):
for o in self.objects:
if isinstance(o, (Vector, Shape)):
pnts.append(loc.inverse * Location(plane, o.Center()))
elif isinstance(o, Sketch):
pnts.append(loc.inverse * Location(plane, o._faces.Center()))
else:
pnts.append(o)
@ -2951,9 +2958,7 @@ class Workplane(object):
:param boolean clean: call :py:meth:`clean` afterwards to have a clean shape
:return: a CQ object with the resulting solid selected.
"""
# group wires together into faces based on which ones are inside the others
# result is a list of lists
wireSets = sortWiresByBuildOrder(self.ctx.popPendingWires())
faces = self._getFaces()
# compute extrusion vector and extrude
eDir = self.plane.zDir.multiply(distance)
@ -2966,9 +2971,9 @@ class Workplane(object):
# underlying cad kernel can only handle simple bosses-- we'll aggregate them if there
# are multiple sets
shapes: List[Shape] = []
for ws in wireSets:
for f in faces:
thisObj = Solid.extrudeLinearWithRotation(
ws[0], ws[1:], self.plane.origin, eDir, angleDegrees
f, self.plane.origin, eDir, angleDegrees
)
shapes.append(thisObj)
@ -3452,19 +3457,16 @@ class Workplane(object):
see :py:meth:`cutBlind` to cut material to a limited depth
"""
wires = self.ctx.popPendingWires()
solidRef = self.findSolid()
rv = []
for solid in solidRef.Solids():
s = solid.dprism(None, wires, thruAll=True, additive=False, taper=-taper)
s = solidRef.dprism(
None, self._getFaces(), thruAll=True, additive=False, taper=-taper
)
if clean:
s = s.clean()
if clean:
s = s.clean()
rv.append(s)
return self.newObject(rv)
return self.newObject([s])
def loft(
self: T, filled: bool = True, ruled: bool = False, combine: bool = True
@ -3473,7 +3475,14 @@ class Workplane(object):
Make a lofted solid, through the set of wires.
:return: a CQ object containing the created loft
"""
wiresToLoft = self.ctx.popPendingWires()
if self.ctx.pendingWires:
wiresToLoft = self.ctx.popPendingWires()
else:
wiresToLoft = [f.outerWire() for f in self._getFaces()]
if not wiresToLoft:
raise ValueError("Nothing to loft")
r: Shape = Solid.makeLoft(wiresToLoft, ruled)
@ -3486,6 +3495,22 @@ class Workplane(object):
return self.newObject([r])
def _getFaces(self) -> List[Face]:
"""
Convert pending wires or sketches to faces for subsequent operation
"""
rv: List[Face] = []
for el in self.objects:
if isinstance(el, Sketch):
rv.extend(el)
if not rv:
rv.extend(wiresToFaces(self.ctx.popPendingWires()))
return rv
def _extrude(
self,
distance: Optional[float] = None,
@ -3493,7 +3518,7 @@ class Workplane(object):
taper: Optional[float] = None,
upToFace: Optional[Union[int, Face]] = None,
additive: bool = True,
) -> Compound:
) -> Union[Solid, Compound]:
"""
Make a prismatic solid from the existing set of pending wires.
@ -3508,13 +3533,13 @@ class Workplane(object):
It is the basis for cutBlind, extrude, cutThruAll, and all similar methods.
"""
def getFacesList(eDir, direction, both=False):
def getFacesList(face, eDir, direction, both=False):
"""
Utility function to make the code further below more clean and tidy
Performs some test and raise appropriate error when no Faces are found for extrusion
"""
facesList = self.findSolid().facesIntersectedByLine(
ws[0].Center(), eDir, direction=direction
face.Center(), eDir, direction=direction
)
if len(facesList) == 0 and both:
raise ValueError(
@ -3525,7 +3550,7 @@ class Workplane(object):
# if we don't find faces in the workplane normal direction we try the other
# direction (as the user might have created a workplane with wrong orientation)
facesList = self.findSolid().facesIntersectedByLine(
ws[0].Center(), eDir.multiply(-1.0), direction=direction
face.Center(), eDir.multiply(-1.0), direction=direction
)
if len(facesList) == 0:
raise ValueError(
@ -3533,10 +3558,13 @@ class Workplane(object):
)
return facesList
# group wires together into faces based on which ones are inside the others
# result is a list of lists
# process sketches or pending wires
faces = self._getFaces()
wireSets = sortWiresByBuildOrder(self.ctx.popPendingWires())
# check for nested geometry and tapered extrusion
for face in faces:
if taper and face.innerWires():
raise ValueError("Inner wires not allowed with tapered extrusion")
# compute extrusion vector and extrude
if upToFace is not None:
@ -3544,31 +3572,18 @@ class Workplane(object):
elif distance is not None:
eDir = self.plane.zDir.multiply(distance)
if additive:
direction = "AlongAxis"
else:
direction = "Opposite"
# one would think that fusing faces into a compound and then extruding would work,
# but it doesnt-- the resulting compound appears to look right, ( right number of faces, etc)
# but then cutting it from the main solid fails with BRep_NotDone.
# the work around is to extrude each and then join the resulting solids, which seems to work
# underlying cad kernel can only handle simple bosses-- we'll aggregate them if there are
# multiple sets
thisObj: Union[Solid, Compound]
direction = "AlongAxis" if additive else "Opposite"
taper = 0.0 if taper is None else taper
toFuse = []
taper = 0.0 if taper is None else taper
baseSolid = None
for ws in wireSets:
if upToFace is not None:
baseSolid = self.findSolid() if baseSolid is None else thisObj
if upToFace is not None:
res = self.findSolid()
for face in faces:
if isinstance(upToFace, int):
facesList = getFacesList(eDir, direction, both=both)
facesList = getFacesList(face, eDir, direction, both=both)
if (
baseSolid.isInside(ws[0].Center())
res.isInside(face.outerWire().Center())
and additive
and upToFace == 0
):
@ -3578,42 +3593,33 @@ class Workplane(object):
else:
limitFace = upToFace
thisObj = Solid.dprism(
baseSolid,
Face.makeFromWires(ws[0]),
ws,
taper=taper,
upToFace=limitFace,
additive=additive,
res = res.dprism(
None, [face], taper=taper, upToFace=limitFace, additive=additive,
)
if both:
facesList2 = getFacesList(eDir.multiply(-1.0), direction, both=both)
facesList2 = getFacesList(
face, eDir.multiply(-1.0), direction, both=both
)
limitFace2 = facesList2[upToFace]
thisObj2 = Solid.dprism(
self.findSolid(),
Face.makeFromWires(ws[0]),
ws,
res = res.dprism(
None,
[face],
taper=taper,
upToFace=limitFace2,
additive=additive,
)
thisObj = Compound.makeCompound([thisObj, thisObj2])
toFuse = [thisObj]
elif taper != 0.0:
thisObj = Solid.extrudeLinear(ws[0], [], eDir, taper=taper)
toFuse.append(thisObj)
else:
thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir, taper=taper)
toFuse.append(thisObj)
else:
for face in faces:
res = Solid.extrudeLinear(face, eDir, taper=taper)
toFuse.append(res)
if both:
thisObj = Solid.extrudeLinear(
ws[0], ws[1:], eDir.multiply(-1.0), taper=taper
)
toFuse.append(thisObj)
res = Solid.extrudeLinear(face, eDir.multiply(-1.0), taper=taper)
toFuse.append(res)
return Compound.makeCompound(toFuse)
return res if upToFace is not None else Compound.makeCompound(toFuse)
def _revolve(
self, angleDegrees: float, axisStart: VectorLike, axisEnd: VectorLike
@ -3631,15 +3637,11 @@ class Workplane(object):
This method is a utility method, primarily for plugin and internal use.
"""
# Get the wires to be revolved
wireSets = sortWiresByBuildOrder(self.ctx.popPendingWires())
# Revolve the wires, make a compound out of them and then fuse them
# Revolve, make a compound out of them and then fuse them
toFuse = []
for ws in wireSets:
thisObj = Solid.revolve(
ws[0], ws[1:], angleDegrees, Vector(axisStart), Vector(axisEnd)
)
for f in self._getFaces():
thisObj = Solid.revolve(f, angleDegrees, Vector(axisStart), Vector(axisEnd))
toFuse.append(thisObj)
return Compound.makeCompound(toFuse)
@ -3685,11 +3687,8 @@ class Workplane(object):
mode = wire
if not multisection:
wireSets = sortWiresByBuildOrder(self.ctx.popPendingWires())
for ws in wireSets:
thisObj = Solid.sweep(
ws[0], ws[1:], p, makeSolid, isFrenet, mode, transition
)
for f in self._getFaces():
thisObj = Solid.sweep(f, p, makeSolid, isFrenet, mode, transition)
toFuse.append(thisObj)
else:
sections = self.ctx.popPendingWires()
@ -4230,6 +4229,55 @@ class Workplane(object):
return self.newObject(rv)
def _locs(self: T) -> List[Location]:
"""
Convert items on the stack into locations.
"""
plane = self.plane
locs: List[Location] = []
for obj in self.objects:
if isinstance(obj, (Vector, Shape)):
locs.append(Location(plane, obj.Center()))
elif isinstance(obj, Location):
locs.append(obj)
if not locs:
locs.append(self.plane.location)
return locs
def sketch(self: T) -> Sketch:
"""
Initialize and return a sketch
:return: Sketch object with the current workplane as a parent.
"""
parent = self.newObject([])
rv = Sketch(parent=parent, locs=self._locs())
parent.objects.append(rv)
return rv
def placeSketch(self: T, *sketches: Sketch) -> T:
"""
Place the provided sketch(es) based on the current items on the stack.
:return: Workplane object with the sketch added.
"""
rv = []
for s in sketches:
s_new = s.copy()
s_new.locs = self._locs()
rv.append(s_new)
return self.newObject(rv)
def _repr_javascript_(self) -> Any:
"""
Special method for rendering current object in a jupyter notebook

View File

@ -9,7 +9,7 @@ from pathlib import Path
from uuid import uuid1 as uuid
from textwrap import indent
from cadquery import exporters, Assembly, Compound, Color
from cadquery import exporters, Assembly, Compound, Color, Sketch
from cadquery import cqgi
from cadquery.occ_impl.jupyter_tools import (
toJSON,
@ -308,6 +308,8 @@ class cq_directive_vtk(Directive):
if isinstance(shape, Assembly):
assy = shape
elif isinstance(shape, Sketch):
assy = Assembly(shape._faces, color=Color(*DEFAULT_COLOR))
else:
assy = Assembly(shape, color=Color(*DEFAULT_COLOR))
else:

405
cadquery/hull.py Normal file
View File

@ -0,0 +1,405 @@
from typing import List, Tuple, Union, Iterable, Set
from math import pi, sin, cos, atan2, sqrt, inf, degrees
from numpy import lexsort, argmin, argmax
from .occ_impl.shapes import Edge, Wire
from .occ_impl.geom import Vector
"""
Convex hull for line segments and circular arcs based on
Yue, Y., Murray, J. L., Corney, J. R., & Clark, D. E. R. (1999).
Convex hull of a planar set of straight and circular line segments. Engineering Computations.
"""
Arcs = List["Arc"]
Points = List["Point"]
Entity = Union["Arc", "Point"]
Hull = List[Union["Arc", "Point", "Segment"]]
class Point:
x: float
y: float
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __repr__(self):
return f"( {self.x},{self.y} )"
def __hash__(self):
return hash((self.x, self.y))
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
class Segment:
a: Point
b: Point
def __init__(self, a: Point, b: Point):
self.a = a
self.b = b
class Arc:
c: Point
s: Point
e: Point
r: float
a1: float
a2: float
ac: float
def __init__(self, c: Point, r: float, a1: float, a2: float):
self.c = c
self.r = r
self.a1 = a1
self.a2 = a2
self.s = Point(r * cos(a1), r * sin(a1))
self.e = Point(r * cos(a2), r * sin(a2))
self.ac = 2 * pi - (a1 - a2)
def atan2p(x, y):
rv = atan2(y, x)
if rv < 0:
rv = (2 * pi + rv) % (2 * pi)
return rv
def convert_and_validate(edges: Iterable[Edge]) -> Tuple[List[Arc], List[Point]]:
arcs: Set[Arc] = set()
points: Set[Point] = set()
for e in edges:
gt = e.geomType()
if gt == "LINE":
p1 = e.startPoint()
p2 = e.endPoint()
points.update((Point(p1.x, p1.y), Point(p2.x, p2.y)))
elif gt == "CIRCLE":
c = e.arcCenter()
r = e.radius()
a1, a2 = e._bounds()
arcs.add(Arc(Point(c.x, c.y), r, a1, a2))
else:
raise ValueError("Unsupported geometry {gt}")
return list(arcs), list(points)
def select_lowest_point(points: Points) -> Tuple[Point, int]:
x = []
y = []
for p in points:
x.append(p.x)
y.append(p.y)
# select the lowest point
ixs = lexsort((x, y))
return points[ixs[0]], ixs[0]
def select_lowest_arc(arcs: Arcs) -> Tuple[Point, Arc]:
x = []
y = []
for a in arcs:
if a.a1 < 1.5 * pi and a.a2 > 1.5 * pi:
x.append(a.c.x)
y.append(a.c.y - a.r)
else:
p, _ = select_lowest_point([a.s, a.e])
x.append(p.x)
y.append(p.y)
ixs = lexsort((x, y))
return Point(x[ixs[0]], y[ixs[0]]), arcs[ixs[0]]
def select_lowest(arcs: Arcs, points: Points) -> Entity:
rv: Entity
p_lowest = select_lowest_point(points) if points else None
a_lowest = select_lowest_arc(arcs) if arcs else None
if p_lowest is None and a_lowest:
rv = a_lowest[1]
elif p_lowest is not None and a_lowest is None:
rv = p_lowest[0]
elif p_lowest and a_lowest:
_, ix = select_lowest_point([p_lowest[0], a_lowest[0]])
rv = p_lowest[0] if ix == 0 else a_lowest[1]
else:
raise ValueError("No entities specified")
return rv
def pt_pt(p1: Point, p2: Point) -> Tuple[float, Segment]:
angle = 0
dx, dy = p2.x - p1.x, p2.y - p1.y
if (dx, dy) != (0, 0):
angle = atan2p(dx, dy)
return angle, Segment(p1, p2)
def _pt_arc(p: Point, a: Arc) -> Tuple[float, float, float, float]:
x, y = p.x, p.y
r = a.r
xc, yc = a.c.x, a.c.y
dx, dy = x - xc, y - yc
l = sqrt(dx ** 2 + dy ** 2)
x1 = r ** 2 / l ** 2 * dx - r / l ** 2 * sqrt(l ** 2 - r ** 2) * dy + xc
y1 = r ** 2 / l ** 2 * dy + r / l ** 2 * sqrt(l ** 2 - r ** 2) * dx + yc
x2 = r ** 2 / l ** 2 * dx + r / l ** 2 * sqrt(l ** 2 - r ** 2) * dy + xc
y2 = r ** 2 / l ** 2 * dy - r / l ** 2 * sqrt(l ** 2 - r ** 2) * dx + yc
return x1, y1, x2, y2
def pt_arc(p: Point, a: Arc) -> Tuple[float, Segment]:
x, y = p.x, p.y
x1, y1, x2, y2 = _pt_arc(p, a)
angles = atan2p(x1 - x, y1 - y), atan2p(x2 - x, y2 - y)
points = Point(x1, y1), Point(x2, y2)
ix = int(argmin(angles))
return angles[ix], Segment(p, points[ix])
def arc_pt(a: Arc, p: Point) -> Tuple[float, Segment]:
x, y = p.x, p.y
x1, y1, x2, y2 = _pt_arc(p, a)
angles = atan2p(x - x1, y - y1), atan2p(x - x2, y - y2)
points = Point(x1, y1), Point(x2, y2)
ix = int(argmax(angles))
return angles[ix], Segment(points[ix], p)
def arc_arc(a1: Arc, a2: Arc) -> Tuple[float, Segment]:
r1 = a1.r
xc1, yc1 = a1.c.x, a1.c.y
r2 = a2.r
xc2, yc2 = a2.c.x, a2.c.y
# construct tangency points for a related point-circle problem
if r1 > r2:
arc_tmp = Arc(a1.c, r1 - r2, a1.a1, a1.a2)
xtmp1, ytmp1, xtmp2, ytmp2 = _pt_arc(a2.c, arc_tmp)
delta_r = r1 - r2
dx1 = (xtmp1 - xc1) / delta_r
dy1 = (ytmp1 - yc1) / delta_r
dx2 = (xtmp2 - xc1) / delta_r
dy2 = (ytmp2 - yc1) / delta_r
elif r1 < r2:
arc_tmp = Arc(a2.c, r2 - r1, a2.a1, a2.a2)
xtmp1, ytmp1, xtmp2, ytmp2 = _pt_arc(a1.c, arc_tmp)
delta_r = r2 - r1
dx1 = (xtmp1 - xc2) / delta_r
dy1 = (ytmp1 - yc2) / delta_r
dx2 = (xtmp2 - xc2) / delta_r
dy2 = (ytmp2 - yc2) / delta_r
else:
dx = xc2 - xc1
dy = yc2 - yc1
l = sqrt(dx ** 2 + dy ** 2)
dx /= l
dy /= l
dx1 = -dy
dy1 = dx
dx2 = dy
dy2 = -dx
# construct the tangency points and angles
x11 = xc1 + dx1 * r1
y11 = yc1 + dy1 * r1
x12 = xc1 + dx2 * r1
y12 = yc1 + dy2 * r1
x21 = xc2 + dx1 * r2
y21 = yc2 + dy1 * r2
x22 = xc2 + dx2 * r2
y22 = yc2 + dy2 * r2
a1_out = atan2p(x21 - x11, y21 - y11)
a2_out = atan2p(x22 - x12, y22 - y12)
# select the feasible angle
a11 = (atan2p(x11 - xc1, y11 - yc1) + pi / 2) % (2 * pi)
a21 = (atan2p(x12 - xc1, y12 - yc1) + pi / 2) % (2 * pi)
ix = int(argmin((abs(a11 - a1_out), abs(a21 - a2_out))))
angles = (a1_out, a2_out)
segments = (
Segment(Point(x11, y11), Point(x21, y21)),
Segment(Point(x12, y12), Point(x22, y22)),
)
return angles[ix], segments[ix]
def get_angle(current: Entity, e: Entity) -> Tuple[float, Segment]:
if current is e:
return inf, Segment(Point(inf, inf), Point(inf, inf))
if isinstance(current, Point):
if isinstance(e, Point):
return pt_pt(current, e)
else:
return pt_arc(current, e)
else:
if isinstance(e, Point):
return arc_pt(current, e)
else:
return arc_arc(current, e)
def update_hull(
current_e: Entity,
ix: int,
entities: List[Entity],
angles: List[float],
segments: List[Segment],
hull: Hull,
) -> Tuple[Entity, float, bool]:
next_e = entities[ix]
connecting_seg = segments[ix]
if isinstance(next_e, Point):
entities.pop(ix)
hull.extend((connecting_seg, next_e))
return next_e, angles[ix], next_e is hull[0]
def finalize_hull(hull: Hull) -> Wire:
rv = []
for el_p, el, el_n in zip(hull, hull[1:], hull[2:]):
if isinstance(el, Segment):
rv.append(Edge.makeLine(Vector(el.a.x, el.a.y), Vector(el.b.x, el.b.y)))
elif (
isinstance(el, Arc)
and isinstance(el_p, Segment)
and isinstance(el_n, Segment)
):
a1 = degrees(atan2p(el_p.b.x - el.c.x, el_p.b.y - el.c.y))
a2 = degrees(atan2p(el_n.a.x - el.c.x, el_n.a.y - el.c.y))
rv.append(
Edge.makeCircle(el.r, Vector(el.c.x, el.c.y), angle1=a1, angle2=a2)
)
el1 = hull[1]
if isinstance(el, Segment) and isinstance(el_n, Arc) and isinstance(el1, Segment):
a1 = degrees(atan2p(el.b.x - el_n.c.x, el.b.y - el_n.c.y))
a2 = degrees(atan2p(el1.a.x - el_n.c.x, el1.a.y - el_n.c.y))
rv.append(
Edge.makeCircle(el_n.r, Vector(el_n.c.x, el_n.c.y), angle1=a1, angle2=a2)
)
return Wire.assembleEdges(rv)
def find_hull(edges: Iterable[Edge]) -> Wire:
# initialize the hull
rv: Hull = []
# split into arcs and points
arcs, points = convert_and_validate(edges)
# select the starting element
start = select_lowest(arcs, points)
rv.append(start)
# initialize
entities: List[Entity] = []
entities.extend(arcs)
entities.extend(points)
current_e = start
current_angle = 0.0
finished = False
# march around
while not finished:
angles = []
segments = []
for e in entities:
angle, segment = get_angle(current_e, e)
angles.append(angle if angle >= current_angle else inf)
segments.append(segment)
next_ix = int(argmin(angles))
current_e, current_angle, finished = update_hull(
current_e, next_ix, entities, angles, segments, rv
)
# convert back to Edges and return
return finalize_hull(rv)

View File

@ -175,6 +175,9 @@ class Vector(object):
def getAngle(self, v: "Vector") -> float:
return self.wrapped.Angle(v.wrapped)
def getSignedAngle(self, v: "Vector") -> float:
return self.wrapped.AngleWithRef(v.wrapped, gp_Vec(0, 0, -1))
def distanceToLine(self):
raise NotImplementedError("Have not needed this yet, but OCCT supports it!")

View File

@ -0,0 +1,81 @@
from math import pi
from ... import cq
from ..shapes import Shape
from .dxf import _importDXF
from OCP.STEPControl import STEPControl_Reader
import OCP.IFSelect
RAD2DEG = 360.0 / (2 * pi)
class ImportTypes:
STEP = "STEP"
DXF = "DXF"
class UNITS:
MM = "mm"
IN = "in"
def importShape(importType, fileName, *args, **kwargs):
"""
Imports a file based on the type (STEP, STL, etc)
:param importType: The type of file that we're importing
:param fileName: THe name of the file that we're importing
"""
# Check to see what type of file we're working with
if importType == ImportTypes.STEP:
return importStep(fileName, *args, **kwargs)
elif importType == ImportTypes.DXF:
return importDXF(fileName, *args, **kwargs)
else:
raise RuntimeError("Unsupported import type: {!r}".format(importType))
# Loads a STEP file into a CQ.Workplane object
def importStep(fileName):
"""
Accepts a file name and loads the STEP file into a cadquery Workplane
:param fileName: The path and name of the STEP file to be imported
"""
# Now read and return the shape
reader = STEPControl_Reader()
readStatus = reader.ReadFile(fileName)
if readStatus != OCP.IFSelect.IFSelect_RetDone:
raise ValueError("STEP File could not be loaded")
for i in range(reader.NbRootsForTransfer()):
reader.TransferRoot(i + 1)
occ_shapes = []
for i in range(reader.NbShapes()):
occ_shapes.append(reader.Shape(i + 1))
# Make sure that we extract all the solids
solids = []
for shape in occ_shapes:
solids.append(Shape.cast(shape))
return cq.Workplane("XY").newObject(solids)
def importDXF(filename, tol=1e-6, exclude=[]):
"""
Loads a DXF file into a cadquery Workplane.
:param fileName: The path and name of the DXF file to be imported
:param tol: The tolerance used for merging edges into wires (default: 1e-6)
:param exclude: a list of layer names not to import (default: [])
"""
faces = _importDXF(filename, tol, exclude)
return cq.Workplane("XY").newObject(faces)

View File

@ -1,13 +1,13 @@
from collections import OrderedDict
from math import pi
from typing import List
from .. import cq
from .geom import Vector
from .shapes import Shape, Edge, Face, sortWiresByBuildOrder, DEG2RAD
from ... import cq
from ..geom import Vector
from ..shapes import Shape, Edge, Face, sortWiresByBuildOrder
import ezdxf
from OCP.STEPControl import STEPControl_Reader
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
from OCP.TopTools import TopTools_HSequenceOfShape
from OCP.gp import gp_Pnt
@ -17,66 +17,9 @@ from OCP.TColStd import TColStd_Array1OfReal, TColStd_Array1OfInteger
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
import OCP.IFSelect
RAD2DEG = 360.0 / (2 * pi)
class ImportTypes:
STEP = "STEP"
DXF = "DXF"
class UNITS:
MM = "mm"
IN = "in"
def importShape(importType, fileName, *args, **kwargs):
"""
Imports a file based on the type (STEP, STL, etc)
:param importType: The type of file that we're importing
:param fileName: THe name of the file that we're importing
"""
# Check to see what type of file we're working with
if importType == ImportTypes.STEP:
return importStep(fileName, *args, **kwargs)
elif importType == ImportTypes.DXF:
return importDXF(fileName, *args, **kwargs)
else:
raise RuntimeError("Unsupported import type: {!r}".format(importType))
# Loads a STEP file into a CQ.Workplane object
def importStep(fileName):
"""
Accepts a file name and loads the STEP file into a cadquery Workplane
:param fileName: The path and name of the STEP file to be imported
"""
# Now read and return the shape
reader = STEPControl_Reader()
readStatus = reader.ReadFile(fileName)
if readStatus != OCP.IFSelect.IFSelect_RetDone:
raise ValueError("STEP File could not be loaded")
for i in range(reader.NbRootsForTransfer()):
reader.TransferRoot(i + 1)
occ_shapes = []
for i in range(reader.NbShapes()):
occ_shapes.append(reader.Shape(i + 1))
# Make sure that we extract all the solids
solids = []
for shape in occ_shapes:
solids.append(Shape.cast(shape))
return cq.Workplane("XY").newObject(solids)
def _dxf_line(el):
try:
@ -140,7 +83,7 @@ def _dxf_spline(el):
if el.weights:
rational = True
weights = OCP.TColStd.TColStd_Array1OfReal(1, len(el.weights))
weights = TColStd_Array1OfReal(1, len(el.weights))
for i, w in enumerate(el.weights):
weights.SetValue(i + 1, w)
@ -214,9 +157,9 @@ def _dxf_convert(elements, tol):
return rv
def importDXF(filename, tol=1e-6, exclude=[]):
def _importDXF(filename: str, tol: float = 1e-6, exclude: List[str] = []) -> List[Face]:
"""
Loads a DXF file into a cadquery Workplane.
Loads a DXF file into a list of faces.
:param fileName: The path and name of the DXF file to be imported
:param tol: The tolerance used for merging edges into wires (default: 1e-6)
@ -236,4 +179,4 @@ def importDXF(filename, tol=1e-6, exclude=[]):
for wire_set in wire_sets:
faces.append(Face.makeFromWires(wire_set[0], wire_set[1:]))
return cq.Workplane("XY").newObject(faces)
return faces

View File

@ -17,6 +17,8 @@ from typing_extensions import Literal, Protocol
from io import BytesIO
from multimethod import multimethod
from vtkmodules.vtkCommonDataModel import vtkPolyData
from vtkmodules.vtkFiltersCore import vtkTriangleFilter, vtkPolyDataNormals
@ -231,6 +233,8 @@ from OCP.Standard import Standard_NoSuchObject, Standard_Failure
from math import pi, sqrt
import warnings
Real = Union[float, int]
TOLERANCE = 1e-6
DEG2RAD = 2 * pi / 360.0
HASH_CODE_MAX = 2147483647 # max 32bit signed int, required by OCC.Core.HashCode
@ -372,6 +376,7 @@ class Shape(object):
"""
wrapped: TopoDS_Shape
forConstruction: bool
def __init__(self, obj: TopoDS_Shape):
self.wrapped = downcast(obj)
@ -399,9 +404,7 @@ class Shape(object):
return self
@classmethod
def cast(
cls: Type["Shape"], obj: TopoDS_Shape, forConstruction: bool = False
) -> "Shape":
def cast(cls, obj: TopoDS_Shape, forConstruction: bool = False) -> "Shape":
"Returns the right type of wrapper, given a OCCT object"
tr = None
@ -871,12 +874,12 @@ class Shape(object):
return self._apply_transform(T)
def copy(self) -> "Shape":
def copy(self: T) -> T:
"""
Creates a new object that is a copy of this object.
"""
return Shape.cast(BRepBuilderAPI_Copy(self.wrapped).Shape())
return self.__class__(BRepBuilderAPI_Copy(self.wrapped).Shape())
def transformShape(self, tMatrix: Matrix) -> "Shape":
"""
@ -933,12 +936,12 @@ class Shape(object):
return self
def located(self, loc: Location) -> "Shape":
def located(self: T, loc: Location) -> T:
"""
Apply a location in absolute sense to a copy of self
"""
r = Shape.cast(self.wrapped.Located(loc.wrapped))
r = self.__class__(self.wrapped.Located(loc.wrapped))
r.forConstruction = self.forConstruction
return r
@ -952,12 +955,12 @@ class Shape(object):
return self
def moved(self, loc: Location) -> "Shape":
def moved(self: T, loc: Location) -> T:
"""
Apply a location in relative sense (i.e. update current location) to a copy of self
"""
r = Shape.cast(self.wrapped.Moved(loc.wrapped))
r = self.__class__(self.wrapped.Moved(loc.wrapped))
r.forConstruction = self.forConstruction
return r
@ -1266,7 +1269,7 @@ class Vertex(Shape):
return Vector(self.toTuple())
@classmethod
def makeVertex(cls: Type["Vertex"], x: float, y: float, z: float) -> "Vertex":
def makeVertex(cls, x: float, y: float, z: float) -> "Vertex":
return cls(BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex())
@ -1296,11 +1299,17 @@ class Mixin1DProtocol(ShapeProtocol, Protocol):
d: float,
mode: Literal["length", "parameter"] = "length",
frame: Literal["frenet", "corrected"] = "frenet",
planar: bool = False,
) -> Location:
...
class Mixin1D(object):
def _bounds(self: Mixin1DProtocol) -> Tuple[float, float]:
curve = self._geomAdaptor()
return curve.FirstParameter(), curve.LastParameter()
def startPoint(self: Mixin1DProtocol) -> Vector:
"""
@ -1464,11 +1473,13 @@ class Mixin1D(object):
d: float,
mode: Literal["length", "parameter"] = "length",
frame: Literal["frenet", "corrected"] = "frenet",
planar: bool = False,
) -> Location:
"""Generate a location along the underlying curve.
:param d: distance or parameter value
:param mode: position calculation mode (default: length)
:param frame: moving frame calculation method (default: frenet)
:param planar: planar mode
:return: A Location object representing local coordinate system at the specified distance.
"""
@ -1493,9 +1504,14 @@ class Mixin1D(object):
pnt = curve.Value(param)
T = gp_Trsf()
T.SetTransformation(
gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3()
)
if planar:
T.SetTransformation(
gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3()
)
else:
T.SetTransformation(
gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3()
)
return Location(TopLoc_Location(T))
@ -1504,15 +1520,17 @@ class Mixin1D(object):
ds: Iterable[float],
mode: Literal["length", "parameter"] = "length",
frame: Literal["frenet", "corrected"] = "frenet",
planar: bool = False,
) -> List[Location]:
"""Generate location along the curve
:param ds: distance or parameter values
:param mode: position calculation mode (default: length)
:param frame: moving frame calculation method (default: frenet)
:param planar: planar mode
:return: A list of Location objects representing local coordinate systems at the specified distances.
"""
return [self.locationAt(d, mode, frame) for d in ds]
return [self.locationAt(d, mode, frame, planar) for d in ds]
class Edge(Shape, Mixin1D):
@ -1551,14 +1569,32 @@ class Edge(Shape, Mixin1D):
return rv
def arcCenter(self) -> Vector:
"""
Center of an underlying circle or ellipse geometry.
"""
g = self.geomType()
a = self._geomAdaptor()
if g == "CIRCLE":
rv = Vector(a.Circle().Position().Location())
elif g == "ELLIPSE":
rv = Vector(a.Ellipse().Position().Location())
else:
raise ValueError(f"{g} has no arc center")
return rv
@classmethod
def makeCircle(
cls: Type["Edge"],
cls,
radius: float,
pnt: VectorLike = Vector(0, 0, 0),
dir: VectorLike = Vector(0, 0, 1),
angle1: float = 360.0,
angle2: float = 360,
orientation=True,
) -> "Edge":
pnt = Vector(pnt)
dir = Vector(dir)
@ -1569,13 +1605,13 @@ class Edge(Shape, Mixin1D):
return cls(BRepBuilderAPI_MakeEdge(circle_gp).Edge())
else: # arc case
circle_geom = GC_MakeArcOfCircle(
circle_gp, angle1 * DEG2RAD, angle2 * DEG2RAD, True
circle_gp, angle1 * DEG2RAD, angle2 * DEG2RAD, orientation
).Value()
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
@classmethod
def makeEllipse(
cls: Type["Edge"],
cls,
x_radius: float,
y_radius: float,
pnt: VectorLike = Vector(0, 0, 0),
@ -1632,7 +1668,7 @@ class Edge(Shape, Mixin1D):
@classmethod
def makeSpline(
cls: Type["Edge"],
cls,
listOfVector: List[Vector],
tangents: Optional[Sequence[Vector]] = None,
periodic: bool = False,
@ -1711,7 +1747,7 @@ class Edge(Shape, Mixin1D):
@classmethod
def makeSplineApprox(
cls: Type["Edge"],
cls,
listOfVector: List[Vector],
tol: float = 1e-3,
smoothing: Optional[Tuple[float, float, float]] = None,
@ -1749,9 +1785,7 @@ class Edge(Shape, Mixin1D):
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def makeThreePointArc(
cls: Type["Edge"], v1: Vector, v2: Vector, v3: Vector
) -> "Edge":
def makeThreePointArc(cls, v1: Vector, v2: Vector, v3: Vector) -> "Edge":
"""
Makes a three point arc through the provided points
:param cls:
@ -1765,7 +1799,7 @@ class Edge(Shape, Mixin1D):
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
@classmethod
def makeTangentArc(cls: Type["Edge"], v1: Vector, v2: Vector, v3: Vector) -> "Edge":
def makeTangentArc(cls, v1: Vector, v2: Vector, v3: Vector) -> "Edge":
"""
Makes a tangent arc from point v1, in the direction of v2 and ends at
v3.
@ -1780,7 +1814,7 @@ class Edge(Shape, Mixin1D):
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
@classmethod
def makeLine(cls: Type["Edge"], v1: Vector, v2: Vector) -> "Edge":
def makeLine(cls, v1: Vector, v2: Vector) -> "Edge":
"""
Create a line between two points
:param v1: Vector that represents the first point
@ -1828,7 +1862,7 @@ class Wire(Shape, Mixin1D):
@classmethod
def combine(
cls: Type["Wire"], listOfWires: Iterable[Union["Wire", Edge]], tol: float = 1e-9
cls, listOfWires: Iterable[Union["Wire", Edge]], tol: float = 1e-9
) -> List["Wire"]:
"""
Attempt to combine a list of wires and edges into a new wire.
@ -1849,7 +1883,7 @@ class Wire(Shape, Mixin1D):
return [cls(el) for el in wires_out]
@classmethod
def assembleEdges(cls: Type["Wire"], listOfEdges: Iterable[Edge]) -> "Wire":
def assembleEdges(cls, listOfEdges: Iterable[Edge]) -> "Wire":
"""
Attempts to build a wire that consists of the edges in the provided list
:param cls:
@ -1878,9 +1912,7 @@ class Wire(Shape, Mixin1D):
return cls(wire_builder.Wire())
@classmethod
def makeCircle(
cls: Type["Wire"], radius: float, center: Vector, normal: Vector
) -> "Wire":
def makeCircle(cls, radius: float, center: Vector, normal: Vector) -> "Wire":
"""
Makes a Circle centered at the provided point, having normal in the provided direction
:param radius: floating point radius of the circle, must be > 0
@ -1895,7 +1927,7 @@ class Wire(Shape, Mixin1D):
@classmethod
def makeEllipse(
cls: Type["Wire"],
cls,
x_radius: float,
y_radius: float,
center: Vector,
@ -1935,9 +1967,7 @@ class Wire(Shape, Mixin1D):
@classmethod
def makePolygon(
cls: Type["Wire"],
listOfVertices: Iterable[Vector],
forConstruction: bool = False,
cls, listOfVertices: Iterable[Vector], forConstruction: bool = False,
) -> "Wire":
# convert list of tuples into Vectors.
wire_builder = BRepBuilderAPI_MakePolygon()
@ -1952,7 +1982,7 @@ class Wire(Shape, Mixin1D):
@classmethod
def makeHelix(
cls: Type["Wire"],
cls,
pitch: float,
height: float,
radius: float,
@ -2114,7 +2144,7 @@ class Face(Shape):
@classmethod
def makeNSidedSurface(
cls: Type["Face"],
cls,
edges: Iterable[Edge],
points: Iterable[gp_Pnt],
continuity: GeomAbs_Shape = GeomAbs_C0,
@ -2181,7 +2211,7 @@ class Face(Shape):
@classmethod
def makePlane(
cls: Type["Face"],
cls,
length: Optional[float] = None,
width: Optional[float] = None,
basePnt: VectorLike = (0, 0, 0),
@ -2203,16 +2233,12 @@ class Face(Shape):
@overload
@classmethod
def makeRuledSurface(
cls: Type["Face"], edgeOrWire1: Edge, edgeOrWire2: Edge
) -> "Face":
def makeRuledSurface(cls, edgeOrWire1: Edge, edgeOrWire2: Edge) -> "Face":
...
@overload
@classmethod
def makeRuledSurface(
cls: Type["Face"], edgeOrWire1: Wire, edgeOrWire2: Wire
) -> "Face":
def makeRuledSurface(cls, edgeOrWire1: Wire, edgeOrWire2: Wire) -> "Face":
...
@classmethod
@ -2229,9 +2255,7 @@ class Face(Shape):
return cls.cast(BRepFill.Face_s(edgeOrWire1.wrapped, edgeOrWire2.wrapped))
@classmethod
def makeFromWires(
cls: Type["Face"], outerWire: Wire, innerWires: List[Wire] = []
) -> "Face":
def makeFromWires(cls, outerWire: Wire, innerWires: List[Wire] = []) -> "Face":
"""
Makes a planar face from one or more wires
"""
@ -2266,7 +2290,7 @@ class Face(Shape):
@classmethod
def makeSplineApprox(
cls: Type["Face"],
cls,
points: List[List[Vector]],
tol: float = 1e-2,
smoothing: Optional[Tuple[float, float, float]] = None,
@ -2362,7 +2386,7 @@ class Shell(Shape):
wrapped: TopoDS_Shell
@classmethod
def makeShell(cls: Type["Shell"], listOfFaces: Iterable[Face]) -> "Shell":
def makeShell(cls, listOfFaces: Iterable[Face]) -> "Shell":
shell_builder = BRepBuilderAPI_Sewing()
@ -2507,32 +2531,47 @@ class Mixin3D(object):
return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace()
@multimethod
def dprism(
self: TS,
basis: Optional[Face],
profiles: List[Wire],
depth: Optional[float] = None,
taper: float = 0,
depth: Optional[Real] = None,
taper: Real = 0,
upToFace: Optional[Face] = None,
thruAll: bool = True,
additive: bool = True,
) -> TS:
) -> "Solid":
"""
Make a prismatic feature (additive or subtractive)
:param basis: face to perfrom the operation on
:param basis: face to perform the operation on
:param profiles: list of profiles
:param depth: depth of the cut or extrusion
:param upToFace: a face to extrude until
:param thruAll: cut thruAll
:param additive: set the kind of operation (additive or subtractive)
:return: a Solid object
"""
sorted_profiles = sortWiresByBuildOrder(profiles)
faces = [Face.makeFromWires(p[0], p[1:]) for p in sorted_profiles]
return self.dprism(basis, faces, depth, taper, upToFace, thruAll, additive)
@dprism.register
def dprism(
self: TS,
basis: Optional[Face],
faces: List[Face],
depth: Optional[Real] = None,
taper: Real = 0,
upToFace: Optional[Face] = None,
thruAll: bool = True,
additive: bool = True,
) -> "Solid":
shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped
for p in sorted_profiles:
face = Face.makeFromWires(p[0], p[1:])
for face in faces:
feat = BRepFeat_MakeDPrism(
shape,
face.wrapped,
@ -2541,6 +2580,7 @@ class Mixin3D(object):
additive,
False,
)
if upToFace is not None:
feat.Perform(upToFace.wrapped)
elif thruAll or depth is None:
@ -2562,7 +2602,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def interpPlate(
cls: Type["Solid"],
cls,
surf_edges,
surf_pts,
thickness,
@ -2681,13 +2721,13 @@ class Solid(Shape, Mixin3D):
return False
@classmethod
def makeSolid(cls: Type["Solid"], shell: Shell) -> "Solid":
def makeSolid(cls, shell: Shell) -> "Solid":
return cls(ShapeFix_Solid().SolidFromShell(shell.wrapped))
@classmethod
def makeBox(
cls: Type["Solid"],
cls,
length: float,
width: float,
height: float,
@ -2706,7 +2746,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def makeCone(
cls: Type["Solid"],
cls,
radius1: float,
radius2: float,
height: float,
@ -2731,7 +2771,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def makeCylinder(
cls: Type["Solid"],
cls,
radius: float,
height: float,
pnt: Vector = Vector(0, 0, 0),
@ -2751,7 +2791,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def makeTorus(
cls: Type["Solid"],
cls,
radius1: float,
radius2: float,
pnt: Vector = Vector(0, 0, 0),
@ -2776,9 +2816,7 @@ class Solid(Shape, Mixin3D):
)
@classmethod
def makeLoft(
cls: Type["Solid"], listOfWire: List[Wire], ruled: bool = False
) -> "Solid":
def makeLoft(cls, listOfWire: List[Wire], ruled: bool = False) -> "Solid":
"""
makes a loft from a list of wires
The wires will be converted into faces when possible-- it is presumed that nobody ever actually
@ -2798,7 +2836,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def makeWedge(
cls: Type["Solid"],
cls,
dx: float,
dy: float,
dz: float,
@ -2822,7 +2860,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def makeSphere(
cls: Type["Solid"],
cls,
radius: float,
pnt: Vector = Vector(0, 0, 0),
dir: Vector = Vector(0, 0, 1),
@ -2846,7 +2884,7 @@ class Solid(Shape, Mixin3D):
@classmethod
def _extrudeAuxSpine(
cls: Type["Solid"], wire: TopoDS_Wire, spine: TopoDS_Wire, auxSpine: TopoDS_Wire
cls, wire: TopoDS_Wire, spine: TopoDS_Wire, auxSpine: TopoDS_Wire
) -> TopoDS_Shape:
"""
Helper function for extrudeLinearWithRotation
@ -2858,14 +2896,14 @@ class Solid(Shape, Mixin3D):
extrude_builder.MakeSolid()
return extrude_builder.Shape()
@classmethod
@multimethod
def extrudeLinearWithRotation(
cls: Type["Solid"],
cls,
outerWire: Wire,
innerWires: List[Wire],
vecCenter: Vector,
vecNormal: Vector,
angleDegrees: float,
angleDegrees: Real,
) -> "Solid":
"""
Creates a 'twisted prism' by extruding, while simultaneously rotating around the extrusion vector.
@ -2916,12 +2954,22 @@ class Solid(Shape, Mixin3D):
return cls(BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape())
@classmethod
@extrudeLinearWithRotation.register
def extrudeLinearWithRotation(
cls, face: Face, vecCenter: Vector, vecNormal: Vector, angleDegrees: Real,
) -> "Solid":
return cls.extrudeLinearWithRotation(
face.outerWire(), face.innerWires(), vecCenter, vecNormal, angleDegrees
)
@multimethod
def extrudeLinear(
cls: Type["Solid"],
cls,
outerWire: Wire,
innerWires: List[Wire],
vecNormal: Vector,
taper: float = 0,
taper: Real = 0,
) -> "Solid":
"""
Attempt to extrude the list of wires into a prismatic solid in the provided direction
@ -2948,11 +2996,20 @@ class Solid(Shape, Mixin3D):
if taper == 0:
face = Face.makeFromWires(outerWire, innerWires)
else:
face = Face.makeFromWires(outerWire)
return cls.extrudeLinear(face, vecNormal, taper)
@classmethod
@extrudeLinear.register
def extrudeLinear(cls, face: Face, vecNormal: Vector, taper: Real = 0,) -> "Solid":
if taper == 0:
prism_builder: Any = BRepPrimAPI_MakePrism(
face.wrapped, vecNormal.wrapped, True
)
else:
face = Face.makeFromWires(outerWire)
faceNormal = face.normalAt()
d = 1 if vecNormal.getAngle(faceNormal) < 90 * DEG2RAD else -1
prism_builder = LocOpe_DPrism(
@ -2961,12 +3018,12 @@ class Solid(Shape, Mixin3D):
return cls(prism_builder.Shape())
@classmethod
@multimethod
def revolve(
cls: Type["Solid"],
cls,
outerWire: Wire,
innerWires: List[Wire],
angleDegrees: float,
angleDegrees: Real,
axisStart: Vector,
axisEnd: Vector,
) -> "Solid":
@ -2996,6 +3053,14 @@ class Solid(Shape, Mixin3D):
"""
face = Face.makeFromWires(outerWire, innerWires)
return cls.revolve(face, angleDegrees, axisStart, axisEnd)
@classmethod
@revolve.register
def revolve(
cls, face: Face, angleDegrees: Real, axisStart: Vector, axisEnd: Vector,
) -> "Solid":
v1 = Vector(axisStart)
v2 = Vector(axisEnd)
v2 = v2 - v1
@ -3042,9 +3107,9 @@ class Solid(Shape, Mixin3D):
return rv
@classmethod
@multimethod
def sweep(
cls: Type["Solid"],
cls,
outerWire: Wire,
innerWires: List[Wire],
path: Union[Wire, Edge],
@ -3099,10 +3164,32 @@ class Solid(Shape, Mixin3D):
return rv
@classmethod
@sweep.register
def sweep(
cls,
face: Face,
path: Union[Wire, Edge],
makeSolid: bool = True,
isFrenet: bool = False,
mode: Union[Vector, Wire, Edge, None] = None,
transitionMode: Literal["transformed", "round", "right"] = "transformed",
) -> "Shape":
return cls.sweep(
face.outerWire(),
face.innerWires(),
path,
makeSolid,
isFrenet,
mode,
transitionMode,
)
@classmethod
def sweep_multi(
cls: Type["Solid"],
profiles: List[Wire],
cls,
profiles: Iterable[Union[Wire, Face]],
path: Union[Wire, Edge],
makeSolid: bool = True,
isFrenet: bool = False,
@ -3132,7 +3219,8 @@ class Solid(Shape, Mixin3D):
builder.SetMode(isFrenet)
for p in profiles:
builder.Add(p.wrapped, translate, rotate)
w = p.wrapped if isinstance(p, Wire) else p.outerWire().wrapped
builder.Add(w, translate, rotate)
builder.Build()
@ -3169,10 +3257,16 @@ class Compound(Shape, Mixin3D):
return comp
def remove(self, shape: Shape):
"""
Remove the specified shape.
"""
comp_builder = TopoDS_Builder()
comp_builder.Remove(self.wrapped, shape.wrapped)
@classmethod
def makeCompound(
cls: Type["Compound"], listOfShapes: Iterable[Shape]
) -> "Compound":
def makeCompound(cls, listOfShapes: Iterable[Shape]) -> "Compound":
"""
Create a compound out of a list of shapes
"""
@ -3181,7 +3275,7 @@ class Compound(Shape, Mixin3D):
@classmethod
def makeText(
cls: Type["Compound"],
cls,
text: str,
size: float,
height: float,
@ -3254,18 +3348,25 @@ class Compound(Shape, Mixin3D):
yield Shape.cast(it.Value())
it.Next()
def cut(self, *toCut: Shape) -> "Shape":
def __bool__(self) -> bool:
"""
Check if empty.
"""
return TopoDS_Iterator(self.wrapped).More()
def cut(self, *toCut: Shape) -> "Compound":
"""
Remove a shape from another one
"""
cut_op = BRepAlgoAPI_Cut()
return self._bool_op(self, toCut, cut_op)
return tcast(Compound, self._bool_op(self, toCut, cut_op))
def fuse(
self, *toFuse: Shape, glue: bool = False, tol: Optional[float] = None
) -> "Shape":
) -> "Compound":
"""
Fuse shapes together
"""
@ -3279,23 +3380,23 @@ class Compound(Shape, Mixin3D):
args = tuple(self) + toFuse
if len(args) <= 1:
rv: Shape = self
rv: Shape = args[0]
else:
rv = self._bool_op(args[:1], args[1:], fuse_op)
# fuse_op.RefineEdges()
# fuse_op.FuseEdges()
return rv
return tcast(Compound, rv)
def intersect(self, *toIntersect: Shape) -> "Shape":
def intersect(self, *toIntersect: Shape) -> "Compound":
"""
Construct shape intersection
"""
intersect_op = BRepAlgoAPI_Common()
return self._bool_op(self, toIntersect, intersect_op)
return tcast(Compound, self._bool_op(self, toIntersect, intersect_op))
def sortWiresByBuildOrder(wireList: List[Wire]) -> List[List[Wire]]:
@ -3329,3 +3430,26 @@ def sortWiresByBuildOrder(wireList: List[Wire]) -> List[List[Wire]]:
rv.append([face.outerWire(),] + face.innerWires())
return rv
def wiresToFaces(wireList: List[Wire]) -> List[Face]:
"""
Convert wires to a list of faces.
"""
return Face.makeFromWires(wireList[0], wireList[1:]).Faces()
def edgesToWires(edges: Iterable[Edge], tol: float = 1e-6) -> List[Wire]:
"""
Convert edges to a list of wires.
"""
edges_in = TopTools_HSequenceOfShape()
wires_out = TopTools_HSequenceOfShape()
for e in edges:
edges_in.Append(e.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
return [Wire(el) for el in wires_out]

View File

@ -0,0 +1,378 @@
from typing import Tuple, Union, Any, Callable, List, Optional, Iterable, Dict, Sequence
from typing_extensions import Literal
from nptyping import NDArray as Array
from itertools import accumulate, chain
from math import sin, cos, radians
from numpy import array, full, inf, sign
from numpy.linalg import norm
import nlopt
from OCP.gp import gp_Vec2d
from .shapes import Geoms
from ..types import Real
NoneType = type(None)
SegmentDOF = Tuple[float, float, float, float] # p1 p2
ArcDOF = Tuple[float, float, float, float, float] # p r a da
DOF = Union[SegmentDOF, ArcDOF]
ConstraintKind = Literal[
"Fixed",
"FixedPoint",
"Coincident",
"Angle",
"Length",
"Distance",
"Radius",
"Orientation",
"ArcAngle",
]
ConstraintInvariants = { # (arity, geometry types, param type, conversion func)
"Fixed": (1, ("CIRCLE", "LINE"), NoneType, None),
"FixedPoint": (1, ("CIRCLE", "LINE"), Optional[Real], None),
"Coincident": (2, ("CIRCLE", "LINE"), NoneType, None),
"Angle": (2, ("CIRCLE", "LINE"), Real, radians),
"Length": (1, ("CIRCLE", "LINE"), Real, None),
"Distance": (
2,
("CIRCLE", "LINE"),
Tuple[Optional[Real], Optional[Real], Real],
None,
),
"Radius": (1, ("CIRCLE",), Real, None),
"Orientation": (1, ("LINE",), Tuple[Real, Real], None),
"ArcAngle": (1, ("CIRCLE",), Real, radians),
}
Constraint = Tuple[Tuple[int, Optional[int]], ConstraintKind, Optional[Any]]
DIFF_EPS = 1e-10
TOL = 1e-9
MAXITER = 0
def invalid_args(*t):
return ValueError("Invalid argument types {t}")
def arc_first(x):
return array((x[0] + x[2] * sin(x[3]), x[1] + x[2] * cos(x[3])))
def arc_last(x):
return array((x[0] + x[2] * sin(x[3] + x[4]), x[1] + x[2] * cos(x[3] + x[4])))
def arc_point(x, val):
if val is None:
rv = x[:2]
else:
a = x[3] + val * x[4]
rv = array((x[0] + x[2] * sin(a), x[1] + x[2] * cos(a)))
return rv
def line_point(x, val):
return x[:2] + val * x[2:]
def arc_first_tangent(x):
return gp_Vec2d(sign(x[4]) * cos(x[3]), -sign(x[4]) * sin(x[3]))
def arc_last_tangent(x):
return gp_Vec2d(sign(x[4]) * cos(x[3] + x[4]), -sign(x[4]) * sin(x[3] + x[4]))
def fixed_cost(x, t, x0, val):
return norm(x - x0)
def fixed_point_cost(x, t, x0, val):
if t == "LINE":
rv = norm(line_point(x, val) - line_point(x0, val))
elif t == "CIRCLE":
rv = norm(arc_point(x, val) - arc_point(x0, val))
else:
raise invalid_args(t)
return rv
def coincident_cost(x1, t1, x10, x2, t2, x20, val):
if t1 == "LINE" and t2 == "LINE":
v1 = x1[2:]
v2 = x2[:2]
elif t1 == "LINE" and t2 == "CIRCLE":
v1 = x1[2:]
v2 = arc_first(x2)
elif t1 == "CIRCLE" and t2 == "LINE":
v1 = arc_last(x1)
v2 = x2[:2]
elif t1 == "CIRCLE" and t2 == "CIRCLE":
v1 = arc_last(x1)
v2 = arc_first(x2)
else:
raise invalid_args(t1, t2)
return norm(v1 - v2)
def angle_cost(x1, t1, x10, x2, t2, x20, val):
if t1 == "LINE" and t2 == "LINE":
v1 = gp_Vec2d(*(x1[2:] - x1[:2]))
v2 = gp_Vec2d(*(x2[2:] - x2[:2]))
elif t1 == "LINE" and t2 == "CIRCLE":
v1 = gp_Vec2d(*(x1[2:] - x1[:2]))
v2 = arc_first_tangent(x2)
elif t1 == "CIRCLE" and t2 == "LINE":
v1 = arc_last_tangent(x1)
v2 = gp_Vec2d(*(x2[2:] - x2[:2]))
elif t1 == "CIRCLE" and t2 == "CIRCLE":
v1 = arc_last_tangent(x1)
v2 = arc_first_tangent(x2)
else:
raise invalid_args(t1, t2)
return v2.Angle(v1) - val
def length_cost(x, t, x0, val):
rv = 0
if t == "LINE":
rv = norm(x[2:] - x[:2]) - val
elif t == "CIRCLE":
rv = norm(x[2] * (x[4] - x[3])) - val
else:
raise invalid_args(t)
return rv
def distance_cost(x1, t1, x10, x2, t2, x20, val):
val1, val2, d = val
if t1 == "LINE" and t2 == "LINE":
v1 = line_point(x1, val1)
v2 = line_point(x2, val2)
elif t1 == "LINE" and t2 == "CIRCLE":
v1 = line_point(x1, val1)
v2 = arc_point(x2, val2)
elif t1 == "CIRCLE" and t2 == "LINE":
v1 = arc_point(x1, val1)
v2 = line_point(x2, val2)
elif t1 == "CIRCLE" and t2 == "CIRCLE":
v1 = arc_point(x1, val1)
v2 = arc_point(x2, val2)
else:
raise invalid_args(t1, t2)
return norm(v1 - v2) - d
def radius_cost(x, t, x0, val):
if t == "CIRCLE":
rv = x[2] - val
else:
raise invalid_args(t)
return rv
def orientation_cost(x, t, x0, val):
if t == "LINE":
rv = gp_Vec2d(*(x[2:] - x[:2])).Angle(gp_Vec2d(*val))
else:
raise invalid_args(t)
return rv
def arc_angle_cost(x, t, x0, val):
if t == "CIRCLE":
rv = norm(x[4] - x[3]) - val
else:
raise invalid_args(t)
return rv
# dictionary of individual constraint cost functions
costs: Dict[str, Callable[..., float]] = dict(
Fixed=fixed_cost,
FixedPoint=fixed_point_cost,
Coincident=coincident_cost,
Angle=angle_cost,
Length=length_cost,
Distance=distance_cost,
Radius=radius_cost,
Orientation=orientation_cost,
ArcAngle=arc_angle_cost,
)
class SketchConstraintSolver(object):
entities: List[DOF]
constraints: List[Constraint]
geoms: List[Geoms]
ne: int
nc: int
ixs: List[int]
def __init__(
self,
entities: Iterable[DOF],
constraints: Iterable[Constraint],
geoms: Iterable[Geoms],
):
self.entities = list(entities)
self.constraints = list(constraints)
self.geoms = list(geoms)
self.ne = len(self.entities)
self.nc = len(self.constraints)
# validate and transform constraints
# indices of x corresponding to the entities
self.ixs = [0] + list(accumulate(len(e) for e in self.entities))
def _cost(
self, x0: Array[(Any,), float]
) -> Tuple[
Callable[[Array[(Any,), float]], float],
Callable[[Array[(Any,), float], Array[(Any,), float]], None],
Array[(Any,), float],
Array[(Any,), float],
]:
ixs = self.ixs
constraints = self.constraints
geoms = self.geoms
# split initial values per entity
x0s = [x0[ixs[e] : ixs[e + 1]] for e in range(self.ne)]
def f(x) -> float:
"""
Cost function to be minimized
"""
rv = 0.0
for i, ((e1, e2), kind, val) in enumerate(constraints):
cost = costs[kind]
# build arguments for the specific constraint
args = [x[ixs[e1] : ixs[e1 + 1]], geoms[e1], x0s[e1]]
if e2 is not None:
args += [x[ixs[e2] : ixs[e2 + 1]], geoms[e2], x0s[e2]]
# evaluate
rv += cost(*args, val) ** 2
return rv
def grad(x, rv) -> None:
"""
Gradient of the cost function
"""
rv[:] = 0
for i, ((e1, e2), kind, val) in enumerate(constraints):
cost = costs[kind]
# build arguments for the specific constraint
x1 = x[ixs[e1] : ixs[e1 + 1]]
args = [x1.copy(), geoms[e1], x0s[e1]]
if e2 is not None:
x2 = x[ixs[e2] : ixs[e2 + 1]]
args += [x2.copy(), geoms[e2], x0s[e2]]
# evaluate
tmp = cost(*args, val)
for j, k in enumerate(range(ixs[e1], ixs[e1 + 1])):
args[0][j] += DIFF_EPS
tmp1 = cost(*args, val)
rv[k] += 2 * tmp * (tmp1 - tmp) / DIFF_EPS
args[0][j] = x1[j]
if e2 is not None:
for j, k in enumerate(range(ixs[e2], ixs[e2 + 1])):
args[3][j] += DIFF_EPS
tmp2 = cost(*args, val)
rv[k] += 2 * tmp * (tmp2 - tmp) / DIFF_EPS
args[3][j] = x2[j]
# generate lower and upper bounds for optimization
lb = full(ixs[-1], -inf)
ub = full(ixs[-1], +inf)
for i, g in enumerate(geoms):
if g == "CIRCLE":
lb[ixs[i] + 2] = 0 # lower bound for radius
return f, grad, lb, ub
def solve(self) -> Tuple[Sequence[Sequence[float]], Dict[str, Any]]:
x0 = array(list(chain.from_iterable(self.entities))).ravel()
f, grad, lb, ub = self._cost(x0)
def func(x, g):
if g.size > 0:
grad(x, g)
return f(x)
opt = nlopt.opt(nlopt.LD_SLSQP, len(x0))
opt.set_min_objective(func)
opt.set_lower_bounds(lb)
opt.set_upper_bounds(ub)
opt.set_ftol_abs(0)
opt.set_ftol_rel(0)
opt.set_xtol_rel(TOL)
opt.set_xtol_abs(TOL * 1e-3)
opt.set_maxeval(MAXITER)
x = opt.optimize(x0)
status = {
"entities": self.entities,
"cost": opt.last_optimum_value(),
"iters": opt.get_numevals(),
"status": opt.last_optimize_result(),
}
ixs = self.ixs
return [x[i1:i2] for i1, i2 in zip(ixs, ixs[1:])], status

View File

@ -89,7 +89,7 @@ class ConstraintSolver(object):
self,
) -> Tuple[
Callable[[Array[(Any,), float]], float],
Callable[[Array[(Any,), float]], Array[(Any,), float]],
Callable[[Array[(Any,), float], Array[(Any,), float]], None],
]:
def pt_cost(
m1: gp_Pnt,
@ -160,14 +160,14 @@ class ConstraintSolver(object):
return rv
def jac(x):
def grad(x, rv):
constraints = self.constraints
ne = self.ne
delta = DIFF_EPS * eye(NDOF)
rv = zeros(NDOF * ne)
rv[:] = 0
transforms = [
self._build_transform(*x[NDOF * i : NDOF * (i + 1)]) for i in range(ne)
@ -234,19 +234,17 @@ class ConstraintSolver(object):
else:
raise NotImplementedError(f"{m1,m2}")
return rv
return f, jac
return f, grad
def solve(self) -> Tuple[List[Location], Dict[str, Any]]:
x0 = array([el for el in self.entities]).ravel()
f, jac = self._cost()
f, grad = self._cost()
def func(x, grad):
def func(x, g):
if grad.size > 0:
grad[:] = jac(x)
if g.size > 0:
grad(x, g)
return f(x)

998
cadquery/sketch.py Normal file
View File

@ -0,0 +1,998 @@
from typing import (
Union,
Optional,
List,
Dict,
Callable,
Tuple,
Iterable,
Iterator,
Any,
Sequence,
TypeVar,
cast as tcast,
)
from typing_extensions import Literal
from math import tan, sin, cos, pi, radians
from itertools import product, chain
from multimethod import multimethod
from typish import instance_of, get_type
from .hull import find_hull
from .selectors import StringSyntaxSelector, Selector
from .types import Real
from .occ_impl.shapes import Shape, Face, Edge, Wire, Compound, Vertex, edgesToWires
from .occ_impl.geom import Location, Vector
from .occ_impl.importers.dxf import _importDXF
from .occ_impl.sketch_solver import (
SketchConstraintSolver,
ConstraintKind,
ConstraintInvariants,
DOF,
arc_first,
arc_last,
arc_point,
)
Modes = Literal["a", "s", "i"]
Point = Union[Vector, Tuple[Real, Real]]
T = TypeVar("T", bound="Sketch")
SketchVal = Union[Shape, Location]
class Constraint(object):
tags: Tuple[str, ...]
args: Tuple[Edge, ...]
kind: ConstraintKind
param: Any
def __init__(
self,
tags: Tuple[str, ...],
args: Tuple[Edge, ...],
kind: ConstraintKind,
param: Any = None,
):
# validate based on the solver provided spec
if kind not in ConstraintInvariants:
raise ValueError(f"Unknown constraint {kind}.")
arity, types, param_type, converter = ConstraintInvariants[kind]
if arity != len(tags):
raise ValueError(
f"Invalid number of entities for constraint {kind}. Provided {len(tags)}, required {arity}."
)
if any(e.geomType() not in types for e in args):
raise ValueError(
f"Unsupported geometry types {[e.geomType() for e in args]} for constraint {kind}."
)
if not instance_of(param, param_type):
raise ValueError(
f"Unsupported argument types {get_type(param)}, required {param_type}."
)
# if all is fine store everything and possibly convert the params
self.tags = tags
self.args = args
self.kind = kind
self.param = tcast(Any, converter)(param) if converter else param
class Sketch(object):
"""
2D sketch. Supports faces, edges and edges with constraints based construction.
"""
parent: Any
locs: List[Location]
_faces: Compound
_wires: List[Wire]
_edges: List[Edge]
_selection: List[SketchVal]
_constraints: List[Constraint]
_tags: Dict[str, Sequence[SketchVal]]
_solve_status: Optional[Dict[str, Any]]
def __init__(self: T, parent: Any = None, locs: Iterable[Location] = (Location(),)):
"""
Construct an empty sketch.
"""
self.parent = parent
self.locs = list(locs)
self._faces = Compound.makeCompound(())
self._wires = []
self._edges = []
self._selection = []
self._constraints = []
self._tags = {}
self._solve_status = None
def __iter__(self) -> Iterator[Face]:
"""
Iterate over faces-locations combinations.
"""
return iter(f for l in self.locs for f in self._faces.moved(l).Faces())
def _tag(self: T, val: Sequence[Union[Shape, Location]], tag: str):
self._tags[tag] = val
# face construction
def face(
self: T,
b: Union[Wire, Iterable[Edge], Compound, T],
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
ignore_selection: bool = False,
) -> T:
"""
Construct a face from a wire or edges.
"""
res: Union[Face, Sketch, Compound]
if isinstance(b, Wire):
res = Face.makeFromWires(b)
elif isinstance(b, (Sketch, Compound)):
res = b
elif isinstance(b, Iterable):
wires = edgesToWires(tcast(Iterable[Edge], b))
res = Face.makeFromWires(*(wires[0], wires[1:]))
else:
raise ValueError(f"Unsupported argument {b}")
if angle != 0:
res = res.moved(Location(Vector(), Vector(0, 0, 1), angle))
return self.each(lambda l: res.moved(l), mode, tag, ignore_selection)
def importDXF(
self: T,
filename: str,
tol: float = 1e-6,
exclude: List[str] = [],
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Import a DXF file and construct face(s)
"""
res = Compound.makeCompound(_importDXF(filename, tol, exclude))
return self.face(res, angle, mode, tag)
def rect(
self: T,
w: Real,
h: Real,
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Construct a rectangular face.
"""
res = Face.makePlane(w, h).rotate(Vector(), Vector(0, 0, 1), angle)
return self.each(lambda l: res.located(l), mode, tag)
def circle(self: T, r: Real, mode: Modes = "a", tag: Optional[str] = None) -> T:
"""
Construct a circular face.
"""
res = Face.makeFromWires(Wire.makeCircle(r, Vector(), Vector(0, 0, 1)))
return self.each(lambda l: res.located(l), mode, tag)
def ellipse(
self: T,
a1: Real,
a2: Real,
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Construct an elliptical face.
"""
res = Face.makeFromWires(
Wire.makeEllipse(
a1, a2, Vector(), Vector(0, 0, 1), Vector(1, 0, 0), rotation_angle=angle
)
)
return self.each(lambda l: res.located(l), mode, tag)
def trapezoid(
self: T,
w: Real,
h: Real,
a1: Real,
a2: Optional[float] = None,
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Construct a trapezoidal face.
"""
v1 = Vector(-w / 2, -h / 2)
v2 = Vector(w / 2, -h / 2)
v3 = Vector(-w / 2 + h / tan(radians(a1)), h / 2)
v4 = Vector(w / 2 - h / tan(radians(a2) if a2 else radians(a1)), h / 2)
return self.polygon((v1, v2, v4, v3, v1), angle, mode, tag)
def slot(
self: T,
w: Real,
h: Real,
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Construct a slot-shaped face.
"""
p1 = Vector(-w / 2, h / 2)
p2 = Vector(w / 2, h / 2)
p3 = Vector(-w / 2, -h / 2)
p4 = Vector(w / 2, -h / 2)
p5 = Vector(-w / 2 - h / 2, 0)
p6 = Vector(w / 2 + h / 2, 0)
e1 = Edge.makeLine(p1, p2)
e2 = Edge.makeThreePointArc(p2, p6, p4)
e3 = Edge.makeLine(p4, p3)
e4 = Edge.makeThreePointArc(p3, p5, p1)
wire = Wire.assembleEdges((e1, e2, e3, e4))
return self.face(wire, angle, mode, tag)
def regularPolygon(
self: T,
r: Real,
n: int,
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Construct a regular polygonal face.
"""
pts = [
Vector(r * sin(i * 2 * pi / n), r * cos(i * 2 * pi / n))
for i in range(n + 1)
]
return self.polygon(pts, angle, mode, tag)
def polygon(
self: T,
pts: Iterable[Point],
angle: Real = 0,
mode: Modes = "a",
tag: Optional[str] = None,
) -> T:
"""
Construct a polygonal face.
"""
w = Wire.makePolygon(p if isinstance(p, Vector) else Vector(*p) for p in pts)
return self.face(w, angle, mode, tag)
# distribute locations
def rarray(self: T, xs: Real, ys: Real, nx: int, ny: int) -> T:
"""
Generate a rectangular array of locations.
"""
if nx < 1 or ny < 1:
raise ValueError(f"At least 1 elements required, requested {nx}, {ny}")
locs = []
offset = Vector((nx - 1) * xs, (ny - 1) * ys) * 0.5
for i, j in product(range(nx), range(ny)):
locs.append(Location(Vector(i * xs, j * ys) - offset))
if self._selection:
selection: Sequence[Union[Shape, Location, Vector]] = self._selection
else:
selection = [Vector()]
return self.push(
(el * l if isinstance(el, Location) else Location(el.Center())) * l
for l in locs
for el in selection
)
def parray(self: T, r: Real, a1: Real, a2: Real, n: int, rotate: bool = True) -> T:
"""
Generate a polar array of locations.
"""
if n < 1:
raise ValueError(f"At least 1 elements required, requested {n}")
x = r * sin(radians(a1))
y = r * cos(radians(a1))
if rotate:
loc = Location(Vector(x, y), Vector(0, 0, 1), -a1)
else:
loc = Location(Vector(x, y))
locs = [loc]
angle = (a2 - a1) / (n - 1)
for i in range(1, n):
phi = a1 + (angle * i)
x = r * sin(radians(phi))
y = r * cos(radians(phi))
if rotate:
loc = Location(Vector(x, y), Vector(0, 0, 1), -phi)
else:
loc = Location(Vector(x, y))
locs.append(loc)
if self._selection:
selection: Sequence[Union[Shape, Location, Vector]] = self._selection
else:
selection = [Vector()]
return self.push(
(l * el if isinstance(el, Location) else l * Location(el.Center()))
for l in locs
for el in selection
)
def distribute(
self: T, n: int, start: Real = 0, stop: Real = 1, rotate: bool = True
) -> T:
"""
Distribute locations along selected edges or wires.
"""
if not self._selection:
raise ValueError("Nothing selected to distirbute over")
params = [start + i * (stop - start) / n for i in range(n + 1)]
locs = []
for el in self._selection:
if isinstance(el, (Wire, Edge)):
if rotate:
locs.extend(el.locations(params, planar=True))
else:
locs.extend(Location(v) for v in el.positions(params))
else:
raise ValueError(f"Unsupported selection: {el}")
return self.push(locs)
def push(
self: T, locs: Iterable[Union[Location, Point]], tag: Optional[str] = None,
) -> T:
"""
Set current selection to given locations or points.
"""
self._selection = [
l if isinstance(l, Location) else Location(Vector(l)) for l in locs
]
if tag:
self._tag(self._selection[:], tag)
return self
def each(
self: T,
callback: Callable[[Location], Union[Face, "Sketch", Compound]],
mode: Modes = "a",
tag: Optional[str] = None,
ignore_selection: bool = False,
) -> T:
"""
Apply a callback on all applicable entities.
"""
res: List[Face] = []
locs: List[Location] = []
if self._selection and not ignore_selection:
for el in self._selection:
if isinstance(el, Location):
loc = el
else:
loc = Location(el.Center())
locs.append(loc)
else:
locs.append(Location())
for loc in locs:
tmp = callback(loc)
if isinstance(tmp, Sketch):
res.extend(tmp._faces.Faces())
elif isinstance(tmp, Compound):
res.extend(tmp.Faces())
else:
res.append(tmp)
if tag:
self._tag(res, tag)
if mode == "a":
self._faces = self._faces.fuse(*res)
elif mode == "s":
self._faces = self._faces.cut(*res)
elif mode == "i":
self._faces = self._faces.intersect(*res)
elif mode == "c":
if not tag:
raise ValueError("No tag specified - the geometry will be unreachable")
else:
raise ValueError(f"Invalid mode: {mode}")
return self
# modifiers
def hull(self: T, mode: Modes = "a", tag: Optional[str] = None) -> T:
"""
Generate a convex hull from current selection or all objects.
"""
if self._selection:
rv = find_hull(el for el in self._selection if isinstance(el, Edge))
elif self._faces:
rv = find_hull(el for el in self._faces.Edges())
elif self._edges or self._wires:
rv = find_hull(
chain(self._edges, chain.from_iterable(w.Edges() for w in self._wires))
)
else:
raise ValueError("No objects available for hull construction")
self.face(rv, mode=mode, tag=tag, ignore_selection=bool(self._selection))
return self
def offset(self: T, d: Real, mode: Modes = "a", tag: Optional[str] = None) -> T:
"""
Offset selected wires or edges.
"""
rv = (el.offset2D(d) for el in self._selection if isinstance(el, Wire))
for el in chain.from_iterable(rv):
self.face(el, mode=mode, tag=tag, ignore_selection=bool(self._selection))
return self
def _matchFacesToVertices(self) -> Dict[Face, List[Vertex]]:
rv = {}
for f in self._faces.Faces():
f_vertices = f.Vertices()
rv[f] = [
v for v in self._selection if isinstance(v, Vertex) and v in f_vertices
]
return rv
def fillet(self: T, d: Real) -> T:
"""
Add a fillet based on current selection.
"""
f2v = self._matchFacesToVertices()
self._faces = Compound.makeCompound(
k.fillet2D(d, v) if v else k for k, v in f2v.items()
)
return self
def chamfer(self: T, d: Real) -> T:
"""
Add a chamfer based on current selection.
"""
f2v = self._matchFacesToVertices()
self._faces = Compound.makeCompound(
k.chamfer2D(d, v) if v else k for k, v in f2v.items()
)
return self
def clean(self: T) -> T:
"""
Remove internal wires.
"""
self._faces = self._faces.clean()
return self
# selection
def _unique(self: T, vals: List[SketchVal]) -> List[SketchVal]:
tmp = {hash(v): v for v in vals}
return list(tmp.values())
def _select(
self: T,
s: Optional[Union[str, Selector]],
kind: Literal["Faces", "Wires", "Edges", "Vertices"],
tag: Optional[str] = None,
) -> T:
rv = []
if tag:
for el in self._tags[tag]:
rv.extend(getattr(el, kind)())
elif self._selection:
for el in self._selection:
if not isinstance(el, Location):
rv.extend(getattr(el, kind)())
else:
rv.extend(getattr(self._faces, kind)())
for el in self._edges:
rv.extend(getattr(el, kind)())
if s and isinstance(s, Selector):
filtered = s.filter(rv)
elif s and isinstance(s, str):
filtered = StringSyntaxSelector(s).filter(rv)
else:
filtered = rv
self._selection = self._unique(filtered)
return self
def tag(self: T, tag: str) -> T:
"""
Tag current selection.
"""
self._tags[tag] = list(self._selection)
return self
def select(self: T, *tags: str) -> T:
"""
Select based on tags.
"""
self._selection = []
for tag in tags:
self._selection.extend(self._tags[tag])
return self
def faces(
self: T, s: Optional[Union[str, Selector]] = None, tag: Optional[str] = None
) -> T:
"""
Select faces.
"""
return self._select(s, "Faces", tag)
def wires(
self: T, s: Optional[Union[str, Selector]] = None, tag: Optional[str] = None
) -> T:
"""
Select wires.
"""
return self._select(s, "Wires", tag)
def edges(
self: T, s: Optional[Union[str, Selector]] = None, tag: Optional[str] = None
) -> T:
"""
Select edges.
"""
return self._select(s, "Edges", tag)
def vertices(
self: T, s: Optional[Union[str, Selector]] = None, tag: Optional[str] = None
) -> T:
"""
Select vertices.
"""
return self._select(s, "Vertices", tag)
def reset(self: T) -> T:
"""
Reset current selection.
"""
self._selection = []
return self
def delete(self: T) -> T:
"""
Delete selected object.
"""
for obj in self._selection:
if isinstance(obj, Face):
self._faces.remove(obj)
elif isinstance(obj, Wire):
self._wires.remove(obj)
elif isinstance(obj, Edge):
self._edges.remove(obj)
self._selection = []
return self
# edge based interface
def _startPoint(self) -> Vector:
if not self._edges:
raise ValueError("No free edges available")
e = self._edges[0]
return e.startPoint()
def _endPoint(self) -> Vector:
if not self._edges:
raise ValueError("No free edges available")
e = self._edges[-1]
return e.endPoint()
def edge(
self: T, val: Edge, tag: Optional[str] = None, forConstruction: bool = False
) -> T:
"""
Add an edge to the sketch.
"""
val.forConstruction = forConstruction
self._edges.append(val)
if tag:
self._tag([val], tag)
return self
@multimethod
def segment(
self: T,
p1: Point,
p2: Point,
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
"""
Construct a segment.
"""
val = Edge.makeLine(Vector(p1), Vector(p2))
return self.edge(val, tag, forConstruction)
@segment.register
def segment(
self: T, p2: Point, tag: Optional[str] = None, forConstruction: bool = False
) -> T:
p1 = self._endPoint()
val = Edge.makeLine(p1, Vector(p2))
return self.edge(val, tag, forConstruction)
@segment.register
def segment(
self: T,
l: Real,
a: Real,
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
p1 = self._endPoint()
d = Vector(l * cos(radians(a)), l * sin(radians(a)))
val = Edge.makeLine(p1, p1 + d)
return self.edge(val, tag, forConstruction)
@multimethod
def arc(
self: T,
p1: Point,
p2: Point,
p3: Point,
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
"""
Construct an arc.
"""
val = Edge.makeThreePointArc(Vector(p1), Vector(p2), Vector(p3))
return self.edge(val, tag, forConstruction)
@arc.register
def arc(
self: T,
p2: Point,
p3: Point,
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
p1 = self._endPoint()
val = Edge.makeThreePointArc(Vector(p1), Vector(p2), Vector(p3))
return self.edge(val, tag, forConstruction)
@arc.register
def arc(
self: T,
c: Point,
r: Real,
a: Real,
da: Real,
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
if abs(da) >= 360:
val = Edge.makeCircle(r, Vector(c), angle1=a, angle2=a, orientation=da > 0)
else:
p0 = Vector(c)
p1 = p0 + r * Vector(cos(radians(a)), sin(radians(a)))
p2 = p0 + r * Vector(cos(radians(a + da / 2)), sin(radians(a + da / 2)))
p3 = p0 + r * Vector(cos(radians(a + da)), sin(radians(a + da)))
val = Edge.makeThreePointArc(p1, p2, p3)
return self.edge(val, tag, forConstruction)
@multimethod
def spline(
self: T,
pts: Iterable[Point],
tangents: Optional[Iterable[Point]],
periodic: bool,
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
"""
Construct a spline edge.
"""
val = Edge.makeSpline(
[Vector(*p) for p in pts],
[Vector(*t) for t in tangents] if tangents else None,
periodic,
)
return self.edge(val, tag, forConstruction)
@spline.register
def spline(
self: T,
pts: Iterable[Point],
tag: Optional[str] = None,
forConstruction: bool = False,
) -> T:
return self.spline(pts, None, False, tag, forConstruction)
def close(self: T, tag: Optional[str] = None) -> T:
"""
Connect last edge to the first one.
"""
self.segment(self._endPoint(), self._startPoint(), tag)
return self
def assemble(self: T, mode: Modes = "a", tag: Optional[str] = None) -> T:
"""
Assemble edges into faces.
"""
return self.face(
(e for e in self._edges if not e.forConstruction), 0, mode, tag
)
# constraints
@multimethod
def constrain(self: T, tag: str, constraint: ConstraintKind, arg: Any) -> T:
"""
Add a constraint.
"""
self._constraints.append(
Constraint((tag,), (self._tags[tag][0],), constraint, arg)
)
return self
@constrain.register
def constrain(
self: T, tag1: str, tag2: str, constraint: ConstraintKind, arg: Any
) -> T:
self._constraints.append(
Constraint(
(tag1, tag2),
(self._tags[tag1][0], self._tags[tag2][0]),
constraint,
arg,
)
)
return self
def solve(self: T) -> T:
"""
Solve current constraints and update edge positions.
"""
entities = [] # list with all degrees of freedom
e2i = {} # mapping from tags to indices of entities
geoms = [] # geometry types
# fill entities, e2i and geoms
for i, (k, v) in enumerate(
filter(lambda kv: isinstance(kv[1][0], Edge), self._tags.items())
):
v0 = tcast(Edge, v[0])
# dispatch on geom type
if v0.geomType() == "LINE":
p1 = v0.startPoint()
p2 = v0.endPoint()
ent: DOF = (p1.x, p1.y, p2.x, p2.y)
elif v0.geomType() == "CIRCLE":
p = v0.arcCenter()
p1 = v0.startPoint() - p
p2 = v0.endPoint() - p
pm = v0.positionAt(0.5) - p
a1 = Vector(0, 1).getSignedAngle(p1)
a2 = p1.getSignedAngle(p2)
a3 = p1.getSignedAngle(pm)
if a3 > 0 and a2 < 0:
a2 += 2 * pi
elif a3 < 0 and a2 > 0:
a2 -= 2 * pi
radius = v0.radius()
ent = (p.x, p.y, radius, a1, a2)
else:
continue
entities.append(ent)
e2i[k] = i
geoms.append(v0.geomType())
# build the POD constraint list
constraints = []
for c in self._constraints:
ix = (e2i[c.tags[0]], e2i[c.tags[1]] if len(c.tags) == 2 else None)
constraints.append((ix, c.kind, c.param))
# optimize
solver = SketchConstraintSolver(entities, constraints, geoms)
res, self._solve_status = solver.solve()
self._solve_status["x"] = res
# translate back the solution - update edges
for g, (k, i) in zip(geoms, e2i.items()):
el = res[i]
# dispatch on geom type
if g == "LINE":
p1 = Vector(el[0], el[1])
p2 = Vector(el[2], el[3])
e = Edge.makeLine(p1, p2)
elif g == "CIRCLE":
p1 = Vector(*arc_first(el))
p2 = Vector(*arc_point(el, 0.5))
p3 = Vector(*arc_last(el))
e = Edge.makeThreePointArc(p1, p2, p3)
# overwrite the low level object
self._tags[k][0].wrapped = e.wrapped
return self
# misc
def copy(self: T) -> T:
"""
Create a partial copy of the sketch.
"""
rv = self.__class__()
rv._faces = self._faces.copy()
return rv
def moved(self: T, loc: Location) -> T:
"""
Create a partial copy of the sketch with moved _faces.
"""
rv = self.__class__()
rv._faces = self._faces.moved(loc)
return rv
def located(self: T, loc: Location) -> T:
"""
Create a partial copy of the sketch with a new location.
"""
rv = self.__class__(locs=(loc,))
rv._faces = self._faces.copy()
return rv
def finalize(self) -> Any:
"""
Finish sketch construction and return the parent
"""
return self.parent

3
cadquery/types.py Normal file
View File

@ -0,0 +1,3 @@
from typing import Union
Real = Union[int, float]

View File

@ -23,6 +23,7 @@ requirements:
- typing_extensions
- nptyping
- nlopt
- multimethod 1.6
test:
requires:

View File

@ -4,10 +4,12 @@
CadQuery API Reference
***********************
The CadQuery API is made up of 2 main objects:
The CadQuery API is made up of 4 main objects:
* **Sketch** -- Construct 2D sketches
* **Workplane** -- Wraps a topological entity and provides a 2D modelling context.
* **Selector** -- Filter and select things
* **Assembly** -- Combine objects into assemblies.
This page lists methods of these objects grouped by **functional area**
@ -16,11 +18,88 @@ This page lists methods of these objects grouped by **functional area**
Use :ref:`classreference` to see methods alphabetically by class.
Initialization
Sketch initialization
---------------------
.. currentmodule:: cadquery
Creating new sketches.
.. autosummary::
Sketch
Sketch.importDXF
Workplane.sketch
Sketch.finalize
Sketch.copy
Sketch.located
Sketch.moved
Sketch selection
----------------
.. currentmodule:: cadquery
Selecting, tagging and manipulating elements.
.. autosummary::
Sketch.tag
Sketch.select
Sketch.reset
Sketch.delete
Sketch.faces
Sketch.edges
Sketch.vertices
Sketching with faces
--------------------
.. currentmodule:: cadquery
Sketching using the face-based API.
.. autosummary::
Sketch.face
Sketch.rect
Sketch.circle
Sketch.ellipse
Sketch.trapezoid
Sketch.slot
Sketch.regularPolygon
Sketch.polygon
Sketch.rarray
Sketch.parray
Sketch.distribute
Sketch.each
Sketch.push
Sketch.hull
Sketch.offset
Sketch.fillet
Sketch.chamfer
Sketch.clean
Sketching with edges and constraints
------------------------------------
.. currentmodule:: cadquery
Sketching using the edge-based API.
.. autosummary::
Sketch.edge
Sketch.segment
Sketch.arc
Sketch.spline
Sketch.close
Sketch.assemble
Sketch.constrain
Sketch.solve
Initialization
--------------
.. currentmodule:: cadquery
Creating new workplanes and object chains
.. autosummary::
@ -30,7 +109,7 @@ Creating new workplanes and object chains
.. _2dOperations:
2D Operations
-----------------
-------------
Creating 2D constructs that can be used to create 3D features.
@ -173,25 +252,25 @@ as a basis for further operations.
.. currentmodule:: cadquery.selectors
.. autosummary::
NearestToPointSelector
BoxSelector
BaseDirSelector
ParallelDirSelector
DirectionSelector
DirectionNthSelector
LengthNthSelector
AreaNthSelector
RadiusNthSelector
PerpendicularDirSelector
TypeSelector
DirectionMinMaxSelector
CenterNthSelector
BinarySelector
AndSelector
SumSelector
SubtractSelector
InverseSelector
StringSyntaxSelector
NearestToPointSelector
BoxSelector
BaseDirSelector
ParallelDirSelector
DirectionSelector
DirectionNthSelector
LengthNthSelector
AreaNthSelector
RadiusNthSelector
PerpendicularDirSelector
TypeSelector
DirectionMinMaxSelector
CenterNthSelector
BinarySelector
AndSelector
SumSelector
SubtractSelector
InverseSelector
StringSyntaxSelector
.. _assembly:
@ -203,10 +282,10 @@ Workplane and Shape objects can be connected together into assemblies
.. currentmodule:: cadquery
.. autosummary::
Assembly
Assembly.add
Assembly.save
Assembly.constrain
Assembly.solve
Constraint
Color
Assembly
Assembly.add
Assembly.save
Assembly.constrain
Assembly.solve
Constraint
Color

View File

@ -17,10 +17,10 @@ Core Classes
.. autosummary::
CQ
Workplane
Assembly
Constraint
Sketch
Workplane
Assembly
Constraint
Topological Classes
----------------------

View File

@ -38,6 +38,7 @@ Table Of Contents
quickstart.rst
designprinciples.rst
primer.rst
sketch.rst
assy.rst
fileformat.rst
examples.rst

272
doc/sketch.rst Normal file
View File

@ -0,0 +1,272 @@
.. _sketchtutorial:
******
Sketch
******
Sketch tutorial
---------------
The purpose of this section is to demonstrate how to construct sketches using different
approaches.
Face-based API
==============
The main approach for constructing sketches is based on constructing faces and
combining them using boolean operations.
.. cadquery::
:height: 600px
import cadquery as cq
result = (
cq.Sketch()
.trapezoid(4,3,90)
.vertices()
.circle(.5,mode='s')
.reset()
.vertices()
.fillet(.25)
.reset()
.rarray(.6,1,5,1).slot(1.5,0.4,mode='s',angle=90)
)
Note that selectors are implemented, but selection has to be explicitly reset. Sketch
class does not implement history and all modifications happen in-place.
Edge-based API
==============
If needed, one can construct sketches by placing individual edges.
.. cadquery::
:height: 600px
import cadquery as cq
result = (
cq.Sketch()
.segment((0.,0),(0.,2.))
.segment((2.,0))
.close()
.arc((.6,.6),0.4,0.,360.)
.assemble(tag='face')
.edges('%LINE',tag='face')
.vertices()
.chamfer(0.2)
)
Once the construction is finished it has to be converted to the face-based representation
using :meth:`~cadquery.Sketch.assemble`. Afterwards, face based operations can be applied.
Convex hull
===========
For certain special use-cases convex hull can be constructed from straight segments
and circles.
.. cadquery::
:height: 600px
result = (
cq.Sketch()
.arc((0,0),1.,0.,360.)
.arc((1,1.5),0.5,0.,360.)
.segment((0.,2),(-1,3.))
.hull()
)
Constraint-based sketches
=========================
Finally, if desired, geometric constraints can be used to construct sketches. So
far only line segments and arcs can be used in such a use case.
.. cadquery::
:height: 600px
import cadquery as cq
result = (
cq.Sketch()
.segment((0,0), (0,3.),"s1")
.arc((0.,3.), (1.5,1.5), (0.,0.),"a1")
.constrain("s1","Fixed",None)
.constrain("s1", "a1","Coincident",None)
.constrain("a1", "s1","Coincident",None)
.constrain("s1",'a1', "Angle", 45)
.solve()
.assemble()
)
Following constraints are implemented. Arguments are passed in as one tuple in :meth:`~cadquery.Sketch.constrain`. In this table, `0..1` refers to a float between 0 and 1 where 0 would create a constraint relative to the start of the element, and 1 the end.
.. list-table::
:widths: 15 10 15 30 30
:header-rows: 1
* - Name
- Arity
- Entities
- Arguments
- Description
* - FixedPoint
- 1
- All
- `None` for arc center or `0..1` for point on segment/arc
- Specified point is fixed
* - Coincident
- 2
- All
- None
- Specified points coincide
* - Angle
- 2
- All
- `angle`
- Angle between the tangents of the two entities is fixed
* - Length
- 1
- All
- `length`
- Specified entity has fixed length
* - Distance
- 2
- All
- `None or 0..1, None or 0..1, distance`
- Distance between two points is fixed
* - Radius
- 1
- Arc
- `radius`
- Specified entity has a fixed radius
* - Orientation
- 1
- Segment
- `x,y`
- Specified entity is parallel to `(x,y)`
* - ArcAngle
- 1
- Arc
- `angle`
- Specified entity is fixed angular span
Workplane integration
---------------------
Once created, a sketch can be used to construct various features on a workplane.
Supported operations include :meth:`~cadquery.Workplane.extrude`,
:meth:`~cadquery.Workplane.twistExtrude`, :meth:`~cadquery.Workplane.revolve`,
:meth:`~cadquery.Workplane.sweep`, :meth:`~cadquery.Workplane.cutBlind`, :meth:`~cadquery.Workplane.cutThruAll` and :meth:`~cadquery.Workplane.loft`.
Sketches can be created as separate entities and reused, but also created ad-hoc
in one fluent chain of calls as shown below.
Note that the sketch is placed on all locations that are on the top of the stack.
Constructing sketches in-place can be accomplished as follows.
.. cadquery::
:height: 600px
import cadquery as cq
result = (
cq.Workplane()
.box(5,5,1)
.faces('>Z')
.sketch()
.regularPolygon(2,3,tag='outer')
.regularPolygon(1.5,3,mode='s')
.vertices(tag='outer')
.fillet(.2)
.finalize()
.extrude(.5)
)
Sketch API is available after the :meth:`~cadquery.Workplane.sketch` call and original `workplane`.
When multiple elements are selected before constructing the sketch, multiple sketches will be created.
.. cadquery::
:height: 600px
import cadquery as cq
result = (
cq.Workplane()
.box(5,5,1)
.faces('>Z')
.workplane()
.rarray(2,2,2,2)
.rect(1.5,1.5)
.extrude(.5)
.faces('>Z')
.sketch()
.circle(0.4)
.wires()
.distribute(6)
.circle(0.1,mode='a')
.clean()
.finalize()
.cutBlind(-0.5,taper=10)
)
Sometimes it is desired to reuse existing sketches and place them as-is on a workplane.
.. cadquery::
:height: 600px
import cadquery as cq
s = (
cq.Sketch()
.trapezoid(3,1,110)
.vertices()
.fillet(0.2)
)
result = (
cq.Workplane()
.box(5,5,5)
.faces('>X')
.workplane()
.transformed((0,0,-90))
.placeSketch(s)
.cutThruAll()
)
Reusing of existing sketches is needed when using :meth:`~cadquery.Workplane.loft`.
.. cadquery::
:height: 600px
from cadquery import Workplane, Sketch, Vector, Location
s1 = (
Sketch()
.trapezoid(3,1,110)
.vertices()
.fillet(0.2)
)
s2 = (
Sketch()
.rect(2,1)
.vertices()
.fillet(0.2)
)
result = (
Workplane()
.placeSketch(s1, s2.moved(Location(Vector(0, 0, 3))))
.loft()
)
When lofting only outer wires are taken into account.

View File

@ -26,3 +26,4 @@ dependencies:
- pip:
- "--editable=."
- sphinxcadquery
- multimethod

View File

@ -1,5 +1,6 @@
[mypy]
ignore_missing_imports = False
disable_error_code = no-redef
[mypy-ezdxf.*]
ignore_missing_imports = True
@ -28,3 +29,5 @@ ignore_missing_imports = True
[mypy-docutils.*]
ignore_missing_imports = True
[mypy-typish.*]
ignore_missing_imports = True

View File

@ -105,7 +105,7 @@ class TestCadQuery(BaseTest):
import OCP
self.assertEqual(type(r), OCP.TopoDS.TopoDS_Compound)
self.assertEqual(type(r), OCP.TopoDS.TopoDS_Solid)
def testToSVG(self):
"""
@ -3443,6 +3443,10 @@ class TestCadQuery(BaseTest):
with self.assertRaises(ValueError):
Workplane().box(1, 1, 1).faces().circle(0.1).extrude(0.1)
# check that extruding nested geometry raises
with self.assertRaises(ValueError):
Workplane().rect(2, 2).rect(1, 1).extrude(2, taper=4)
def testTaperedExtrudeCutBlind(self):
h = 1.0
@ -4942,6 +4946,80 @@ class TestCadQuery(BaseTest):
self.assertTrue(p2.Contains(f2.Center().toPnt(), 0.1))
self.assertTrue(Vector(p2.Axis().Direction()) == f2.normalAt())
def testEachpoint(self):
r1 = (
Workplane(origin=(0, 0, 1))
.add(
[
Vector(),
Location(Vector(0, 0, -1,)),
Sketch().rect(1, 1),
Face.makePlane(1, 1),
]
)
.eachpoint(lambda l: Face.makePlane(1, 1).locate(l))
)
self.assertTrue(len(r1.objects) == 4)
for v in r1.vals():
self.assertTupleAlmostEquals(v.Center().toTuple(), (0, 0, 0), 6)
def testSketch(self):
r1 = (
Workplane()
.box(10, 10, 1)
.faces(">Z")
.sketch()
.slot(2, 1)
.slot(2, 1, angle=90)
.clean()
.finalize()
.extrude(1)
)
self.assertTrue(r1.val().isValid())
self.assertEqual(len(r1.faces().vals()), 19)
r2 = (
Workplane()
.sketch()
.circle(2)
.wires()
.offset(0.1, mode="s")
.finalize()
.sketch()
.rect(1, 1)
.finalize()
.extrude(1, taper=5)
)
self.assertTrue(r2.val().isValid())
self.assertEqual(len(r2.faces().vals()), 6)
r3 = (
Workplane()
.pushPoints((Location(Vector(1, 1, 0)),))
.sketch()
.circle(2)
.wires()
.offset(-0.1, mode="s")
.finalize()
.extrude(1)
)
self.assertTrue(r3.val().isValid())
self.assertEqual(len(r3.faces().vals()), 4)
self.assertTupleAlmostEquals(r3.val().Center().toTuple(), (1, 1, 0.5), 6)
s = Sketch().trapezoid(3, 1, 120)
r4 = Workplane().placeSketch(s, s.moved(Location(Vector(0, 0, 3)))).loft()
self.assertEqual(len(r4.solids().vals()), 1)
def testCircumscribedPolygon(self):
"""
Test that circumscribed polygons result in the correct shapes

32
tests/test_hull.py Normal file
View File

@ -0,0 +1,32 @@
import pytest
import cadquery as cq
from cadquery import hull
def test_hull():
c1 = cq.Edge.makeCircle(0.5, (-1.5, 0.5, 0))
c2 = cq.Edge.makeCircle(0.5, (1.9, 0.0, 0))
c3 = cq.Edge.makeCircle(0.2, (0.3, 1.5, 0))
c4 = cq.Edge.makeCircle(0.2, (1.0, 1.5, 0))
c5 = cq.Edge.makeCircle(0.1, (0.0, 0.0, 0.0))
e1 = cq.Edge.makeLine(cq.Vector(0, -0.5), cq.Vector(-0.5, 1.5))
e2 = cq.Edge.makeLine(cq.Vector(2.1, 1.5), cq.Vector(2.6, 1.5))
edges = [c1, c2, c3, c4, c5, e1, e2]
h = hull.find_hull(edges)
assert len(h.Vertices()) == 11
assert h.IsClosed()
assert h.isValid()
def test_validation():
with pytest.raises(ValueError):
e1 = cq.Edge.makeEllipse(2, 1)
c1 = cq.Edge.makeCircle(0.5, (-1.5, 0.5, 0))
hull.find_hull([c1, e1])

588
tests/test_sketch.py Normal file
View File

@ -0,0 +1,588 @@
import os
from cadquery.sketch import Sketch, Vector, Location
from cadquery.selectors import LengthNthSelector
from pytest import approx, raises
from math import pi, sqrt
testdataDir = os.path.join(os.path.dirname(__file__), "testdata")
def test_face_interface():
s1 = Sketch().rect(1, 2, 45)
assert s1._faces.Area() == approx(2)
assert s1.vertices(">X")._selection[0].toTuple()[0] == approx(1.5 / sqrt(2))
s2 = Sketch().circle(1)
assert s2._faces.Area() == approx(pi)
s3 = Sketch().ellipse(2, 0.5)
assert s3._faces.Area() == approx(pi)
s4 = Sketch().trapezoid(2, 0.5, 45)
assert s4._faces.Area() == approx(0.75)
s4 = Sketch().trapezoid(2, 0.5, 45)
assert s4._faces.Area() == approx(0.75)
s5 = Sketch().slot(3, 2)
assert s5._faces.Area() == approx(6 + pi)
assert s5.edges(">Y")._selection[0].Length() == approx(3)
s6 = Sketch().regularPolygon(1, 5)
assert len(s6.vertices()._selection) == 5
assert s6.vertices(">Y")._selection[0].toTuple()[1] == approx(1)
s7 = Sketch().polygon([(0, 0), (0, 1), (1, 0)])
assert len(s7.vertices()._selection) == 3
assert s7._faces.Area() == approx(0.5)
with raises(ValueError):
Sketch().face(Sketch().rect(1, 1)._faces)
def test_modes():
s1 = Sketch().rect(2, 2).rect(1, 1, mode="a")
assert s1._faces.Area() == approx(4)
assert len(s1._faces.Faces()) == 2
s2 = Sketch().rect(2, 2).rect(1, 1, mode="s")
assert s2._faces.Area() == approx(4 - 1)
assert len(s2._faces.Faces()) == 1
s3 = Sketch().rect(2, 2).rect(1, 1, mode="i")
assert s3._faces.Area() == approx(1)
assert len(s3._faces.Faces()) == 1
s4 = Sketch().rect(2, 2).rect(1, 1, mode="c", tag="t")
assert s4._faces.Area() == approx(4)
assert len(s4._faces.Faces()) == 1
assert s4._tags["t"][0].Area() == approx(1)
with raises(ValueError):
Sketch().rect(2, 2).rect(1, 1, mode="c")
with raises(ValueError):
Sketch().rect(2, 2).rect(1, 1, mode="dummy")
def test_distribute():
s1 = Sketch().rarray(2, 2, 3, 3).rect(1, 1)
assert s1._faces.Area() == approx(9)
assert len(s1._faces.Faces()) == 9
s2 = Sketch().parray(2, 0, 90, 3).rect(1, 1)
assert s2._faces.Area() == approx(3)
assert len(s2._faces.Faces()) == 3
with raises(ValueError):
Sketch().rarray(2, 2, 3, 0).rect(1, 1)
with raises(ValueError):
Sketch().parray(2, 0, 90, 0).rect(1, 1)
s3 = Sketch().circle(4, mode="c", tag="c").edges(tag="c").distribute(3).rect(1, 1)
assert s2._faces.Area() == approx(3)
assert len(s3._faces.Faces()) == 3
assert len(s3.reset().vertices("<X")._selection) == 2
for f in s3._faces.Faces():
assert f.Center().Length == approx(4)
s4 = (
Sketch()
.circle(4, mode="c", tag="c")
.edges(tag="c")
.distribute(3, rotate=False)
.rect(1, 1)
)
assert s4._faces.Area() == approx(3)
assert len(s4._faces.Faces()) == 3
assert len(s4.reset().vertices("<X")._selection) == 4
for f in s4._faces.Faces():
assert f.Center().Length == approx(4)
with raises(ValueError):
Sketch().rect(2, 2).faces().distribute(5)
with raises(ValueError):
Sketch().rect(2, 2).distribute(5)
s5 = Sketch().push([(0, 0), (1, 1)]).rarray(2, 2, 3, 3).rect(0.5, 0.5)
assert s5._faces.Area() == approx(18 * 0.25)
assert len(s5._faces.Faces()) == 18
s6 = Sketch().push([(0, 0), (1, 1)]).parray(2, 0, 90, 3).rect(0.5, 0.5)
assert s6._faces.Area() == approx(6 * 0.25)
assert len(s6._faces.Faces()) == 6
s7 = Sketch().parray(2, 0, 90, 3, False).rect(0.5, 0.5).reset().vertices(">(1,1,0)")
assert len(s7._selection) == 1
s8 = Sketch().push([(0, 0), (0, 1)]).parray(2, 0, 90, 3).rect(0.5, 0.5)
s8.reset().faces(">(1,1,0)")
assert s8._selection[0].Center().Length == approx(3)
s9 = Sketch().push([(0, 1)], tag="loc")
assert len(s9._tags["loc"]) == 1
def test_each():
s1 = Sketch().each(lambda l: Sketch().push([l]).rect(1, 1))
assert len(s1._faces.Faces()) == 1
s2 = (
Sketch()
.push([(0, 0), (2, 2)])
.each(lambda l: Sketch().push([l]).rect(1, 1), ignore_selection=True)
)
assert len(s2._faces.Faces()) == 1
def test_modifiers():
s1 = Sketch().push([(-2, 0), (2, 0)]).rect(1, 1).reset().vertices("<X").fillet(0.1)
assert len(s1._faces.Faces()) == 2
assert len(s1._faces.Edges()) == 10
s2 = Sketch().push([(-2, 0), (2, 0)]).rect(1, 1).reset().vertices(">X").chamfer(0.1)
assert len(s2._faces.Faces()) == 2
assert len(s2._faces.Edges()) == 10
s3 = Sketch().push([(-2, 0), (2, 0)]).rect(1, 1).reset().hull()
assert len(s3._faces.Faces()) == 3
assert s3._faces.Area() == approx(5)
s4 = Sketch().push([(-2, 0), (2, 0)]).rect(1, 1).reset().hull()
assert len(s4._faces.Faces()) == 3
assert s4._faces.Area() == approx(5)
s5 = (
Sketch()
.push([(-2, 0), (0, 0), (2, 0)])
.rect(1, 1)
.reset()
.faces("not >X")
.edges()
.hull()
)
assert len(s5._faces.Faces()) == 4
assert s5._faces.Area() == approx(4)
s6 = Sketch().segment((0, 0), (0, 1)).segment((1, 0), (2, 0)).hull()
assert len(s6._faces.Faces()) == 1
assert s6._faces.Area() == approx(1)
with raises(ValueError):
Sketch().rect(1, 1).vertices().hull()
with raises(ValueError):
Sketch().hull()
s7 = Sketch().rect(2, 2).wires().offset(1)
assert len(s7._faces.Faces()) == 2
assert len(s7._faces.Edges()) == 4 + 4 + 4
s7.clean()
assert len(s7._faces.Faces()) == 1
assert len(s7._faces.Edges()) == 4 + 4
s8 = Sketch().rect(2, 2).wires().offset(-0.5, mode="s")
assert len(s8._faces.Faces()) == 1
assert len(s8._faces.Edges()) == 4 + 4
def test_delete():
s1 = Sketch().push([(-2, 0), (2, 0)]).rect(1, 1).reset()
assert len(s1._faces.Faces()) == 2
s1.faces("<X").delete()
assert len(s1._faces.Faces()) == 1
s2 = Sketch().segment((0, 0), (1, 0)).segment((0, 1), tag="e").close()
assert len(s2._edges) == 3
s2.edges("<X").delete()
assert len(s2._edges) == 2
def test_selectors():
s = Sketch().push([(-2, 0), (2, 0)]).rect(1, 1).rect(0.5, 0.5, mode="s").reset()
assert len(s._selection) == 0
s.vertices()
assert len(s._selection) == 16
s.reset()
assert len(s._selection) == 0
s.edges()
assert len(s._selection) == 16
s.reset().wires()
assert len(s._selection) == 4
s.reset().faces()
assert len(s._selection) == 2
s.reset().vertices("<Y")
assert len(s._selection) == 4
s.reset().edges("<X or >X")
assert len(s._selection) == 2
s.tag("test").reset()
assert len(s._selection) == 0
s.select("test")
assert len(s._selection) == 2
s.reset().wires()
assert len(s._selection) == 4
s.reset().wires(LengthNthSelector(1))
assert len(s._selection) == 2
def test_edge_interface():
s1 = (
Sketch()
.segment((0, 0), (1, 0))
.segment((1, 1))
.segment(1, 180)
.close()
.assemble()
)
assert len(s1._faces.Faces()) == 1
assert s1._faces.Area() == approx(1)
s2 = Sketch().arc((0, 0), (1, 1), (0, 2)).close().assemble()
assert len(s2._faces.Faces()) == 1
assert s2._faces.Area() == approx(pi / 2)
s3 = Sketch().arc((0, 0), (1, 1), (0, 2)).arc((-1, 1), (0, 0)).assemble()
assert len(s3._faces.Faces()) == 1
assert s3._faces.Area() == approx(pi)
s4 = Sketch().arc((0, 0), 1, 0, 90)
assert len(s4.vertices()._selection) == 2
assert s4.vertices(">Y")._selection[0].Center().y == approx(1)
s5 = Sketch().arc((0, 0), 1, 0, -90)
assert len(s5.vertices()._selection) == 2
assert s5.vertices(">Y")._selection[0].Center().y == approx(0)
s6 = Sketch().arc((0, 0), 1, 90, 360)
assert len(s6.vertices()._selection) == 1
def test_assemble():
s1 = Sketch()
s1.segment((0.0, 0), (0.0, 2.0))
s1.segment(Vector(4.0, -1)).close().arc((0.7, 0.6), 0.4, 0.0, 360.0).assemble()
def test_finalize():
parent = object()
s = Sketch(parent).rect(2, 2).circle(0.5, mode="s")
assert s.finalize() is parent
def test_misc():
with raises(ValueError):
Sketch()._startPoint()
with raises(ValueError):
Sketch()._endPoint()
def test_located():
s1 = Sketch().segment((0, 0), (1, 0)).segment((1, 1)).close().assemble()
assert len(s1._edges) == 3
assert len(s1._faces.Faces()) == 1
s2 = s1.located(loc=Location())
assert len(s2._edges) == 0
assert len(s2._faces.Faces()) == 1
def test_constraint_validation():
with raises(ValueError):
Sketch().segment(1.0, 1.0, "s").constrain("s", "Dummy", None)
with raises(ValueError):
Sketch().segment(1.0, 1.0, "s").constrain("s", "s", "Fixed", None)
with raises(ValueError):
Sketch().spline([(1.0, 1.0), (2.0, 1.0), (0.0, 0.0)], "s").constrain(
"s", "Fixed", None
)
with raises(ValueError):
Sketch().segment(1.0, 1.0, "s").constrain("s", "Fixed", 1)
def test_constraint_solver():
s1 = (
Sketch()
.segment((0.0, 0), (0.0, 2.0), "s1")
.segment((0.5, 2.5), (1.0, 1), "s2")
.close("s3")
)
s1.constrain("s1", "Fixed", None)
s1.constrain("s1", "s2", "Coincident", None)
s1.constrain("s2", "s3", "Coincident", None)
s1.constrain("s3", "s1", "Coincident", None)
s1.constrain("s3", "s1", "Angle", 90)
s1.constrain("s2", "s3", "Angle", 180 - 45)
s1.solve()
assert s1._solve_status["status"] == 4
s1.assemble()
assert s1._faces.isValid()
s2 = (
Sketch()
.arc((0.0, 0.0), (-0.5, 0.5), (0.0, 1.0), "a1")
.arc((0.0, 1.0), (0.5, 1.5), (1.0, 1.0), "a2")
.segment((1.0, 0.0), "s1")
.close("s2")
)
s2.constrain("s2", "Fixed", None)
s2.constrain("s1", "s2", "Coincident", None)
s2.constrain("a2", "s1", "Coincident", None)
s2.constrain("s2", "a1", "Coincident", None)
s2.constrain("a1", "a2", "Coincident", None)
s2.constrain("s1", "s2", "Angle", 90)
s2.constrain("s2", "a1", "Angle", 90)
s2.constrain("a1", "a2", "Angle", -90)
s2.constrain("a2", "s1", "Angle", 90)
s2.constrain("s1", "Length", 0.5)
s2.constrain("a1", "Length", 1.0)
s2.solve()
assert s2._solve_status["status"] == 4
s2.assemble()
assert s2._faces.isValid()
s2._tags["s1"][0].Length() == approx(0.5)
s2._tags["a1"][0].Length() == approx(1.0)
s3 = (
Sketch()
.arc((0.0, 0.0), (-0.5, 0.5), (0.0, 1.0), "a1")
.segment((1.0, 0.0), "s1")
.close("s2")
)
s3.constrain("s2", "Fixed", None)
s3.constrain("a1", "ArcAngle", 60)
s3.constrain("a1", "Radius", 1.0)
s3.constrain("s2", "a1", "Coincident", None)
s3.constrain("a1", "s1", "Coincident", None)
s3.constrain("s1", "s2", "Coincident", None)
s3.solve()
assert s3._solve_status["status"] == 4
s3.assemble()
assert s3._faces.isValid()
s3._tags["a1"][0].radius() == approx(1)
s3._tags["a1"][0].Length() == approx(pi / 3)
s4 = (
Sketch()
.arc((0.0, 0.0), (-0.5, 0.5), (0.0, 1.0), "a1")
.segment((1.0, 0.0), "s1")
.close("s2")
)
s4.constrain("s2", "Fixed", None)
s4.constrain("s1", "Orientation", (-1.0, -1))
s4.constrain("s1", "s2", "Distance", (0.0, 0.5, 2.0))
s4.constrain("s2", "a1", "Coincident", None)
s4.constrain("a1", "s1", "Coincident", None)
s4.constrain("s1", "s2", "Coincident", None)
s4.solve()
assert s4._solve_status["status"] == 4
s4.assemble()
assert s4._faces.isValid()
seg1 = s4._tags["s1"][0]
seg2 = s4._tags["s2"][0]
assert (seg1.endPoint() - seg1.startPoint()).getAngle(Vector(-1, -1)) == approx(
0, abs=1e-9
)
midpoint = (seg2.startPoint() + seg2.endPoint()) / 2
(midpoint - seg1.startPoint()).Length == approx(2)
s5 = (
Sketch()
.segment((0, 0), (0, 3.0), "s1")
.arc((0.0, 0), (1.5, 1.5), (0.0, 3), "a1")
.arc((0.0, 0), (-1.0, 1.5), (0.0, 3), "a2")
)
s5.constrain("s1", "Fixed", None)
s5.constrain("s1", "a1", "Distance", (0.5, 0.5, 3))
s5.constrain("s1", "a1", "Distance", (0.0, 1.0, 0.0))
s5.constrain("a1", "s1", "Distance", (0.0, 1.0, 0.0))
s5.constrain("s1", "a2", "Coincident", None)
s5.constrain("a2", "s1", "Coincident", None)
s5.constrain("a1", "a2", "Distance", (0.5, 0.5, 10.5))
s5.solve()
assert s5._solve_status["status"] == 4
mid0 = s5._edges[0].positionAt(0.5)
mid1 = s5._edges[1].positionAt(0.5)
mid2 = s5._edges[2].positionAt(0.5)
assert (mid1 - mid0).Length == approx(3)
assert (mid1 - mid2).Length == approx(10.5)
s6 = (
Sketch()
.segment((0, 0), (0, 3.0), "s1")
.arc((0.0, 0), (5.5, 5.5), (0.0, 3), "a1")
)
s6.constrain("s1", "Fixed", None)
s6.constrain("s1", "a1", "Coincident", None)
s6.constrain("a1", "s1", "Coincident", None)
s6.constrain("a1", "s1", "Distance", (None, 0.5, 0))
s6.solve()
assert s6._solve_status["status"] == 4
mid0 = s6._edges[0].positionAt(0.5)
mid1 = s6._edges[1].positionAt(0.5)
assert (mid1 - mid0).Length == approx(1.5)
s7 = (
Sketch()
.segment((0, 0), (0, 3.0), "s1")
.arc((0.0, 0), (5.5, 5.5), (0.0, 4), "a1")
)
s7.constrain("s1", "FixedPoint", 0)
s7.constrain("a1", "FixedPoint", None)
s7.constrain("a1", "FixedPoint", 1)
s7.constrain("a1", "s1", "Distance", (0, 0, 0))
s7.constrain("a1", "s1", "Distance", (1, 1, 0))
s7.solve()
assert s7._solve_status["status"] == 4
s7.assemble()
assert s7._faces.isValid()
def test_dxf_import():
filename = os.path.join(testdataDir, "gear.dxf")
s1 = Sketch().importDXF(filename, tol=1e-3)
assert s1._faces.isValid()
s2 = Sketch().importDXF(filename, tol=1e-3).circle(5, mode="s")
assert s2._faces.isValid()
s3 = Sketch().circle(20).importDXF(filename, tol=1e-3, mode="s")
assert s3._faces.isValid()