diff --git a/cadquery/occ_impl/geom.py b/cadquery/occ_impl/geom.py index e95e4f44..325d5002 100644 --- a/cadquery/occ_impl/geom.py +++ b/cadquery/occ_impl/geom.py @@ -1,4 +1,4 @@ -import math +from math import pi, radians, degrees from typing import overload, Sequence, Union, Tuple, Type, Optional @@ -14,6 +14,8 @@ from OCP.gp import ( gp_XYZ, gp_EulerSequence, gp, + gp_Quaternion, + gp_Extrinsic_XYZ, ) from OCP.Bnd import Bnd_Box from OCP.BRepBndLib import BRepBndLib @@ -22,6 +24,7 @@ from OCP.TopoDS import TopoDS_Shape from OCP.TopLoc import TopLoc_Location from ..types import Real +from ..utils import multimethod TOL = 1e-2 @@ -682,7 +685,7 @@ class Plane(object): # NB: this is not a geometric Vector rotate = Vector(rotate) # Convert to radians. - rotate = rotate.multiply(math.pi / 180.0) + rotate = rotate.multiply(pi / 180.0) # Compute rotation matrix. T1 = gp_Trsf() @@ -848,11 +851,11 @@ class BoundBox(object): def enlarge(self, tol: float) -> "BoundBox": """Returns a modified (expanded) bounding box, expanded in all - directions by the tolerance value. + directions by the tolerance value. This means that the minimum values of its X, Y and Z intervals - of the bounding box are reduced by the absolute value of tol, while - the maximum values are increased by the same amount. + of the bounding box are reduced by the absolute value of tol, while + the maximum values are increased by the same amount. """ tmp = Bnd_Box() tmp.Add(self.wrapped) @@ -942,75 +945,91 @@ class Location(object): wrapped: TopLoc_Location - @overload - def __init__(self) -> None: - """Empty location with not rotation or translation with respect to the original location.""" - ... - - @overload + @multimethod def __init__(self, t: VectorLike) -> None: """Location with translation t with respect to the original location.""" - ... - @overload - def __init__(self, t: Plane) -> None: - """Location corresponding to the location of the Plane t.""" - ... + T = gp_Trsf() + T.SetTranslationPart(Vector(t).wrapped) - @overload - def __init__(self, t: Plane, v: VectorLike) -> None: - """Location corresponding to the angular location of the Plane t with translation v.""" - ... + self.wrapped = TopLoc_Location(T) - @overload - def __init__(self, t: TopLoc_Location) -> None: - """Location wrapping the low-level TopLoc_Location object t""" - ... - - @overload - def __init__(self, t: gp_Trsf) -> None: - """Location wrapping the low-level gp_Trsf object t""" - ... - - @overload - def __init__(self, t: VectorLike, ax: VectorLike, angle: float) -> None: - """Location with translation t and rotation around ax by angle - with respect to the original location.""" - ... - - def __init__(self, *args): + @__init__.register + def __init__( + self, + x: Real = 0, + y: Real = 0, + z: Real = 0, + rx: Real = 0, + ry: Real = 0, + rz: Real = 0, + ) -> None: + """Location with translation (x,y,z) and 3 rotation angles.""" T = gp_Trsf() - if len(args) == 0: - pass - elif len(args) == 1: - t = args[0] + q = gp_Quaternion() + q.SetEulerAngles(gp_Extrinsic_XYZ, radians(rx), radians(ry), radians(rz)) - if isinstance(t, (Vector, tuple)): - T.SetTranslationPart(Vector(t).wrapped) - elif isinstance(t, Plane): - cs = gp_Ax3(t.origin.toPnt(), t.zDir.toDir(), t.xDir.toDir()) - T.SetTransformation(cs) - T.Invert() - elif isinstance(t, TopLoc_Location): - self.wrapped = t - return - elif isinstance(t, gp_Trsf): - T = t - else: - raise TypeError("Unexpected parameters") - elif len(args) == 2: - t, v = args - cs = gp_Ax3(Vector(v).toPnt(), t.zDir.toDir(), t.xDir.toDir()) - T.SetTransformation(cs) - T.Invert() - else: - t, ax, angle = args - T.SetRotation( - gp_Ax1(Vector().toPnt(), Vector(ax).toDir()), angle * math.pi / 180.0 - ) - T.SetTranslationPart(Vector(t).wrapped) + T.SetRotation(q) + T.SetTranslationPart(Vector(x, y, z).wrapped) + + self.wrapped = TopLoc_Location(T) + + @__init__.register + def __init__(self, t: Plane) -> None: + """Location corresponding to the location of the Plane t.""" + + T = gp_Trsf() + T.SetTransformation(gp_Ax3(t.origin.toPnt(), t.zDir.toDir(), t.xDir.toDir())) + T.Invert() + + self.wrapped = TopLoc_Location(T) + + @__init__.register + def __init__(self, t: Plane, v: VectorLike) -> None: + """Location corresponding to the angular location of the Plane t with translation v.""" + + T = gp_Trsf() + T.SetTransformation(gp_Ax3(Vector(v).toPnt(), t.zDir.toDir(), t.xDir.toDir())) + T.Invert() + + self.wrapped = TopLoc_Location(T) + + @__init__.register + def __init__(self, T: TopLoc_Location) -> None: + """Location wrapping the low-level TopLoc_Location object t""" + + self.wrapped = T + + @__init__.register + def __init__(self, T: gp_Trsf) -> None: + """Location wrapping the low-level gp_Trsf object t""" + + self.wrapped = TopLoc_Location(T) + + @__init__.register + def __init__(self, t: VectorLike, ax: VectorLike, angle: Real) -> None: + """Location with translation t and rotation around ax by angle + with respect to the original location.""" + + T = gp_Trsf() + T.SetRotation(gp_Ax1(Vector().toPnt(), Vector(ax).toDir()), radians(angle)) + T.SetTranslationPart(Vector(t).wrapped) + + self.wrapped = TopLoc_Location(T) + + @__init__.register + def __init__(self, t: VectorLike, angles: Tuple[Real, Real, Real]) -> None: + """Location with translation t and 3 rotation angles.""" + + T = gp_Trsf() + + q = gp_Quaternion() + q.SetEulerAngles(gp_Extrinsic_XYZ, *map(radians, angles)) + + T.SetRotation(q) + T.SetTranslationPart(Vector(t).wrapped) self.wrapped = TopLoc_Location(T) @@ -1035,6 +1054,6 @@ class Location(object): rot = T.GetRotation() rv_trans = (trans.X(), trans.Y(), trans.Z()) - rv_rot = rot.GetEulerAngles(gp_EulerSequence.gp_Extrinsic_XYZ) + rx, ry, rz = rot.GetEulerAngles(gp_EulerSequence.gp_Extrinsic_XYZ) - return rv_trans, rv_rot + return rv_trans, (degrees(rx), degrees(ry), degrees(rz)) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index ec9a760c..216ea634 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -27,7 +27,7 @@ from ..selectors import ( StringSyntaxSelector, ) -from ..utils import cqmultimethod as multimethod +from ..utils import multimethod # change default OCCT logging level from OCP.Message import Message, Message_Gravity @@ -210,6 +210,15 @@ from OCP.Graphic3d import ( Graphic3d_VTA_TOP, ) +from OCP.Graphic3d import ( + Graphic3d_HTA_LEFT, + Graphic3d_HTA_CENTER, + Graphic3d_HTA_RIGHT, + Graphic3d_VTA_BOTTOM, + Graphic3d_VTA_CENTER, + Graphic3d_VTA_TOP, +) + from OCP.NCollection import NCollection_Utf8String from OCP.BRepFeat import BRepFeat_MakeDPrism @@ -223,6 +232,10 @@ from OCP.TopLoc import TopLoc_Location from OCP.GeomAbs import ( GeomAbs_Shape, GeomAbs_C0, + GeomAbs_C1, + GeomAbs_C2, + GeomAbs_G2, + GeomAbs_G1, GeomAbs_Intersection, GeomAbs_JoinType, ) @@ -1047,6 +1060,7 @@ class Shape(object): return r + @multimethod def move(self: T, loc: Location) -> T: """ Apply a location in relative sense (i.e. update current location) to self @@ -1056,6 +1070,29 @@ class Shape(object): return self + @move.register + def move( + self: T, + x: Real = 0, + y: Real = 0, + z: Real = 0, + rx: Real = 0, + ry: Real = 0, + rz: Real = 0, + ) -> T: + + self.wrapped.Move(Location(x, y, z, rx, ry, rz).wrapped) + + return self + + @move.register + def move(self: T, loc: VectorLike) -> T: + + self.wrapped.Move(Location(loc).wrapped) + + return self + + @multimethod def moved(self: T, loc: Location) -> T: """ Apply a location in relative sense (i.e. update current location) to a copy of self @@ -1066,6 +1103,51 @@ class Shape(object): return r + @moved.register + def moved(self: T, loc1: Location, loc2: Location, *locs: Location) -> T: + + return self.moved((loc1, loc2) + locs) + + @moved.register + def moved(self: T, locs: Sequence[Location]) -> T: + + rv = [] + + for l in locs: + rv.append(self.wrapped.Moved(l.wrapped)) + + return _compound_or_shape(rv) + + @moved.register + def moved( + self: T, + x: Real = 0, + y: Real = 0, + z: Real = 0, + rx: Real = 0, + ry: Real = 0, + rz: Real = 0, + ) -> T: + + return self.moved(Location(x, y, z, rx, ry, rz)) + + @moved.register + def moved(self: T, loc: VectorLike) -> T: + + return self.moved(Location(loc)) + + @moved.register + def moved(self: T, loc1: VectorLike, loc2: VectorLike, *locs: VectorLike) -> T: + + return self.moved( + (Location(loc1), Location(loc2)) + tuple(Location(loc) for loc in locs) + ) + + @moved.register + def moved(self: T, loc: Sequence[VectorLike]) -> T: + + return self.moved(tuple(Location(l) for l in loc)) + def __hash__(self) -> int: return self.hashCode() @@ -1463,6 +1545,34 @@ class Shape(object): return Compound.makeCompound(_siblings([self], level)) + def __add__(self, other: "Shape") -> "Shape": + """ + Fuse self and other. + """ + + return fuse(self, other) + + def __sub__(self, other: "Shape") -> "Shape": + """ + Subtract other from self. + """ + + return cut(self, other) + + def __mul__(self, other: "Shape") -> "Shape": + """ + Intersect self and other. + """ + + return intersect(self, other) + + def __truediv__(self, other: "Shape") -> "Shape": + """ + Split self with other. + """ + + return split(self, other) + class ShapeProtocol(Protocol): @property @@ -3985,3 +4095,882 @@ def edgesToWires(edges: Iterable[Edge], tol: float = 1e-6) -> List[Wire]: ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out) return [Wire(el) for el in wires_out] + + +#%% utilities + + +def _get(s: Shape, ts: Union[Shapes, Tuple[Shapes, ...]]) -> Iterable[Shape]: + """ + Get desired shapes or raise an error. + """ + + # convert input into tuple + if isinstance(ts, tuple): + types = ts + else: + types = (ts,) + + # validate the underlying shape, compounds are unpacked + t = s.ShapeType() + + if t in types: + yield s + elif t == "Compound": + for el in s: + if el.ShapeType() in ts: + yield el + else: + raise ValueError( + f"Required type(s): {types}; encountered {el.ShapeType()}" + ) + else: + raise ValueError(f"Required type(s): {types}; encountered {t}") + + +def _get_one(s: Shape, ts: Union[Shapes, Tuple[Shapes, ...]]) -> Shape: + """ + Get one shape or raise an error. + """ + + # convert input into tuple + if isinstance(ts, tuple): + types = ts + else: + types = (ts,) + + # validate the underlying shape, compounds are unpacked + t = s.ShapeType() + + if t in types: + rv = s + elif t == "Compound": + for el in s: + if el.ShapeType() in ts: + rv = el + break + else: + raise ValueError( + f"Required type(s): {types}, encountered {el.ShapeType()}" + ) + else: + raise ValueError(f"Required type(s): {types}; encountered {t}") + + return rv + + +def _get_one_wire(s: Shape) -> Wire: + """ + Get one wire or edge and convert to wire. + """ + + rv = _get_one(s, ("Wire", "Edge")) + + if isinstance(rv, Wire): + return rv + else: + return Wire.assembleEdges((rv,)) + + +def _get_wires(s: Shape) -> Iterable[Shape]: + """ + Get wires or wires from edges. + """ + + t = s.ShapeType() + + if t == "Wire": + yield s + elif t == "Edge": + yield Wire.assembleEdges((tcast(Edge, s),)) + elif t == "Compound": + for el in s: + yield from _get_wires(el) + else: + raise ValueError(f"Required type(s): Edge, Wire; encountered {t}") + + +def _get_edges(s: Shape) -> Iterable[Shape]: + """ + Get wires or wires from edges. + """ + + t = s.ShapeType() + + if t == "Edge": + yield s + elif t == "Wire": + yield from _get_edges(s.edges()) + elif t == "Compound": + for el in s: + yield from _get_edges(el) + else: + raise ValueError(f"Required type(s): Edge, Wire; encountered {t}") + + +def _get_wire_lists(s: Sequence[Shape]) -> List[List[Wire]]: + """ + Get lists or wires for sweeping or lofting. + """ + + wire_lists: List[List[Wire]] = [] + + for el in s: + if not wire_lists: + wire_lists = [[w] for w in _get_wires(el)] + else: + for wire_list, w in zip(wire_lists, _get_wires(el)): + wire_list.append(w) + + return wire_lists + + +def _normalize(s: Shape) -> Shape: + """ + Apply some normalizations: + - Shell with only one Face -> Face. + """ + + t = s.ShapeType() + rv = s + + if t == "Shell": + faces = s.Faces() + if len(faces) == 1: + rv = faces[0] + elif t == "Compound": + objs = list(s) + if len(objs) == 1: + rv = objs[0] + + return rv + + +def _compound_or_shape(s: Union[TopoDS_Shape, List[TopoDS_Shape]]) -> Shape: + """ + Convert a list of TopoDS_Shape to a Shape or a Compound. + """ + + if isinstance(s, TopoDS_Shape): + rv = _normalize(Shape.cast(s)) + elif len(s) == 1: + rv = _normalize(Shape.cast(s[0])) + else: + rv = Compound.makeCompound([_normalize(Shape.cast(el)) for el in s]) + + return rv + + +def _pts_to_harray(ps: Sequence[VectorLike]) -> TColgp_HArray1OfPnt: + """ + Convert a sequence of Vecotor to a TColgp harray (OCCT specific). + """ + + rv = TColgp_HArray1OfPnt(1, len(ps)) + + for i, p in enumerate(ps): + rv.SetValue(i + 1, Vector(p).toPnt()) + + return rv + + +def _shapes_to_toptools_list(s: Iterable[Shape]) -> TopTools_ListOfShape: + """ + Convert an iterable of Shape to a TopTools list (OCCT specific). + """ + + rv = TopTools_ListOfShape() + + for el in s: + rv.Append(el.wrapped) + + return rv + + +#%% alternative constructors + + +@multimethod +def wire(*s: Shape) -> Shape: + """ + Build wire from edges. + """ + + builder = BRepBuilderAPI_MakeWire() + + edges = _shapes_to_toptools_list(e for el in s for e in _get_edges(el)) + builder.Add(edges) + + return _compound_or_shape(builder.Shape()) + + +@wire.register +def wire(s: Sequence[Shape]) -> Shape: + + return wire(*s) + + +@multimethod +def face(*s: Shape) -> Shape: + """ + Build face from edges or wires. + """ + + from OCP.BOPAlgo import BOPAlgo_Tools + + ws = Compound.makeCompound(w for el in s for w in _get_wires(el)).wrapped + rv = TopoDS_Compound() + + status = BOPAlgo_Tools.WiresToFaces_s(ws, rv) + + if not status: + raise ValueError("Face construction failed") + + return _get_one(_compound_or_shape(rv), "Face") + + +@face.register +def face(s: Sequence[Shape]) -> Shape: + + return face(*s) + + +@multimethod +def shell(*s: Shape) -> Shape: + """ + Build shell from faces. + """ + + builder = BRepBuilderAPI_Sewing() + + for el in s: + for f in _get(el, "Face"): + builder.Add(f.wrapped) + + builder.Perform() + + return _compound_or_shape(builder.SewedShape()) + + +@shell.register +def shell(s: Sequence[Shape]) -> Shape: + + return shell(*s) + + +@multimethod +def solid(*s: Shape) -> Shape: + """ + Build solid from faces. + """ + + builder = ShapeFix_Solid() + + faces = [f for el in s for f in _get(el, "Face")] + rv = builder.SolidFromShell(shell(*faces).wrapped) + + return _compound_or_shape(rv) + + +@solid.register +def solid(s: Sequence[Shape]) -> Shape: + + return solid(*s) + + +@multimethod +def compound(*s: Shape) -> Shape: + """ + Build compound from shapes. + """ + + rv = TopoDS_Compound() + + builder = TopoDS_Builder() + builder.MakeCompound(rv) + + for el in s: + builder.Add(rv, el.wrapped) + + return Compound(rv) + + +@compound.register +def compound(s: Sequence[Shape]) -> Shape: + + return compound(*s) + + +#%% primitives + + +@multimethod +def vertex(x: Real, y: Real, z: Real) -> Shape: + """ + Construct a vertex from coordinates. + """ + + return _compound_or_shape(BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex()) + + +@vertex.register +def vertex(p: VectorLike): + + return _compound_or_shape(BRepBuilderAPI_MakeVertex(Vector(p).toPnt()).Vertex()) + + +def segment(p1: VectorLike, p2: VectorLike) -> Shape: + """ + Construct a segment from two points. + """ + + return _compound_or_shape( + BRepBuilderAPI_MakeEdge(Vector(p1).toPnt(), Vector(p2).toPnt()).Edge() + ) + + +def polyline(*pts: VectorLike) -> Shape: + """ + Construct a polyline from points. + """ + + builder = BRepBuilderAPI_MakePolygon() + + for p in pts: + builder.Add(Vector(p).toPnt()) + + return _compound_or_shape(builder.Wire()) + + +def polygon(*pts: VectorLike) -> Shape: + """ + Construct a polygon (closed polyline) from points. + """ + + builder = BRepBuilderAPI_MakePolygon() + + for p in pts: + builder.Add(Vector(p).toPnt()) + + builder.Close() + + return _compound_or_shape(builder.Wire()) + + +def rect(w: float, h: float) -> Shape: + """ + Construct a rectangle. + """ + + return polygon( + (-w / 2, -h / 2, 0), (w / 2, -h / 2, 0), (w / 2, h / 2, 0), (-w / 2, h / 2, 0) + ) + + +@multimethod +def spline(*pts: VectorLike, tol: float = 1e-6, periodic: bool = False) -> Shape: + """ + Construct a polygon (closed polyline) from points. + """ + + data = _pts_to_harray(pts) + + builder = GeomAPI_Interpolate(data, periodic, tol) + builder.Perform() + + return _compound_or_shape(BRepBuilderAPI_MakeEdge(builder.Curve()).Edge()) + + +@spline.register +def spline( + pts: Sequence[VectorLike], + tgts: Sequence[VectorLike] = (), + tol: float = 1e-6, + periodic: bool = False, + scale: bool = True, +) -> Shape: + + data = _pts_to_harray(pts) + + builder = GeomAPI_Interpolate(data, periodic, tol) + + if tgts: + builder.Load(Vector(tgts[0]).wrapped, Vector(tgts[1]).wrapped, scale) + + builder.Perform() + + return _compound_or_shape(BRepBuilderAPI_MakeEdge(builder.Curve()).Edge()) + + +def circle(r: float) -> Shape: + """ + Construct a circle. + """ + + return _compound_or_shape( + BRepBuilderAPI_MakeEdge( + gp_Circ(gp_Ax2(Vector().toPnt(), Vector(0, 0, 1).toDir()), r) + ).Edge() + ) + + +def ellipse(r1: float, r2: float) -> Shape: + """ + Construct an ellipse. + """ + + return _compound_or_shape( + BRepBuilderAPI_MakeEdge( + gp_Elips(gp_Ax2(Vector().toPnt(), Vector(0, 0, 1).toDir()), r1, r2) + ).Edge() + ) + + +def plane(w: float, l: float) -> Shape: + """ + Construct a planar face. + """ + + pln_geom = gp_Pln(Vector(0, 0, 0).toPnt(), Vector(0, 0, 1).toDir()) + + return _compound_or_shape( + BRepBuilderAPI_MakeFace(pln_geom, -w / 2, w / 2, -l / 2, l / 2).Face() + ) + + +def box(w: float, l: float, h: float) -> Shape: + """ + Construct a solid box. + """ + + return _compound_or_shape( + BRepPrimAPI_MakeBox( + gp_Ax2(Vector(-w / 2, -l / 2, 0).toPnt(), Vector(0, 0, 1).toDir()), w, l, h + ).Shape() + ) + + +def cylinder(d: float, h: float) -> Shape: + """ + Construct a solid cylinder. + """ + + return _compound_or_shape( + BRepPrimAPI_MakeCylinder( + gp_Ax2(Vector(0, 0, 0).toPnt(), Vector(0, 0, 1).toDir()), d / 2, h, 2 * pi + ).Shape() + ) + + +def sphere(d: float) -> Shape: + """ + Construct a solid sphere. + """ + + return _compound_or_shape( + BRepPrimAPI_MakeSphere( + gp_Ax2(Vector(0, 0, 0).toPnt(), Vector(0, 0, 1).toDir()), d / 2, + ).Shape() + ) + + +def torus(d1: float, d2: float) -> Shape: + """ + Construct a solid torus. + """ + + return _compound_or_shape( + BRepPrimAPI_MakeTorus( + gp_Ax2(Vector(0, 0, 0).toPnt(), Vector(0, 0, 1).toDir()), + d1 / 2, + d2 / 2, + 0, + 2 * pi, + ).Shape() + ) + + +@multimethod +def cone(d1: Real, d2: Real, h: Real) -> Shape: + """ + Construct a solid cone. + """ + + return _compound_or_shape( + BRepPrimAPI_MakeCone( + gp_Ax2(Vector(0, 0, 0).toPnt(), Vector(0, 0, 1).toDir()), + d1 / 2, + d2 / 2, + h, + 2 * pi, + ).Shape() + ) + + +@cone.register +def cone(d: Real, h: Real) -> Shape: + + return cone(d, 0, h) + + +def text( + txt: str, + size: float, + font: str = "Arial", + path: Optional[str] = None, + kind: Literal["regular", "bold", "italic"] = "regular", + halign: Literal["center", "left", "right"] = "center", + valign: Literal["center", "top", "bottom"] = "center", +) -> Shape: + """ + Create a flat text. + """ + + builder = Font_BRepTextBuilder() + + font_kind = { + "regular": Font_FA_Regular, + "bold": Font_FA_Bold, + "italic": Font_FA_Italic, + }[kind] + + mgr = Font_FontMgr.GetInstance_s() + + if path and mgr.CheckFont(TCollection_AsciiString(path).ToCString()): + font_t = Font_SystemFont(TCollection_AsciiString(path)) + font_t.SetFontPath(font_kind, TCollection_AsciiString(path)) + mgr.RegisterFont(font_t, True) + + else: + font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind) + + font_i = StdPrs_BRepFont( + NCollection_Utf8String(font_t.FontName().ToCString()), font_kind, float(size), + ) + + if halign == "left": + theHAlign = Graphic3d_HTA_LEFT + elif halign == "center": + theHAlign = Graphic3d_HTA_CENTER + else: + theHAlign = Graphic3d_HTA_RIGHT + + if valign == "bottom": + theVAlign = Graphic3d_VTA_BOTTOM + elif valign == "center": + theVAlign = Graphic3d_VTA_CENTER + else: + theVAlign = Graphic3d_VTA_TOP + + rv = builder.Perform( + font_i, NCollection_Utf8String(txt), theHAlign=theHAlign, theVAlign=theVAlign + ) + + return _compound_or_shape(rv) + + +#%% ops + + +def _bool_op( + s1: Shape, + s2: Shape, + builder: Union[BRepAlgoAPI_BooleanOperation, BRepAlgoAPI_Splitter], + tol: float = 0.0, + parallel: bool = True, +): + + arg = TopTools_ListOfShape() + arg.Append(s1.wrapped) + + tool = TopTools_ListOfShape() + tool.Append(s2.wrapped) + + builder.SetArguments(arg) + builder.SetTools(tool) + + builder.SetRunParallel(parallel) + + if tol: + builder.SetFuzzyValue(tol) + + builder.Build() + + +def fuse(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: + """ + Fuse two shapes. + """ + + builder = BRepAlgoAPI_Fuse() + _bool_op(s1, s2, builder, tol) + + return _compound_or_shape(builder.Shape()) + + +def cut(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: + """ + Subtract two shapes. + """ + + builder = BRepAlgoAPI_Cut() + _bool_op(s1, s2, builder, tol) + + return _compound_or_shape(builder.Shape()) + + +def intersect(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: + """ + Intersect two shapes. + """ + + builder = BRepAlgoAPI_Common() + _bool_op(s1, s2, builder, tol) + + return _compound_or_shape(builder.Shape()) + + +def split(s1: Shape, s2: Shape) -> Shape: + """ + Split one shape with another. + """ + + builder = BRepAlgoAPI_Splitter() + _bool_op(s1, s2, builder) + + return _compound_or_shape(builder.Shape()) + + +def clean(s: Shape) -> Shape: + """ + Clean superfluous edges and faces. + """ + + builder = ShapeUpgrade_UnifySameDomain(s.wrapped, True, True, True) + builder.AllowInternalEdges(False) + builder.Build() + + return _compound_or_shape(builder.Shape()) + + +def fill(s: Shape, constraints: Sequence[Union[Shape, VectorLike]] = ()) -> Shape: + """ + Fill edges/wire possibly obeying constraints. + """ + + builder = BRepOffsetAPI_MakeFilling() + + for e in _get_edges(s): + builder.Add(e.wrapped, GeomAbs_C0) + + for c in constraints: + if isinstance(c, Shape): + for e in _get_edges(c): + builder.Add(e.wrapped, GeomAbs_C0, False) + else: + builder.Add(Vector(c).toPnt()) + + builder.Build() + + return _compound_or_shape(builder.Shape()) + + +def cap( + s: Shape, ctx: Shape, constraints: Sequence[Union[Shape, VectorLike]] = () +) -> Shape: + """ + Fill edges/wire possibly obeying constraints and try to connect smoothly to the context shape. + """ + + builder = BRepOffsetAPI_MakeFilling() + builder.SetResolParam(2, 15, 5) + + for e in _get_edges(s): + f = _get_one(e.ancestors(ctx, "Face"), "Face") + builder.Add(e.wrapped, f.wrapped, GeomAbs_G2, True) + + for c in constraints: + if isinstance(c, Shape): + for e in _get_edges(c): + builder.Add(e.wrapped, GeomAbs_C0, False) + else: + builder.Add(Vector(c).toPnt()) + + builder.Build() + + return _compound_or_shape(builder.Shape()) + + +def fillet(s: Shape, e: Shape, r: float) -> Shape: + """ + Fillet selected edges in a given shell or solid. + """ + + builder = BRepFilletAPI_MakeFillet(_get_one(s, ("Shell", "Solid")).wrapped,) + + for el in _get_edges(e.edges()): + builder.Add(r, el.wrapped) + + builder.Build() + + return _compound_or_shape(builder.Shape()) + + +def chamfer(s: Shape, e: Shape, d: float) -> Shape: + """ + Chamfer selected edges in a given shell or solid. + """ + + builder = BRepFilletAPI_MakeChamfer(_get_one(s, ("Shell", "Solid")).wrapped,) + + for el in _get_edges(e.edges()): + builder.Add(d, el.wrapped) + + builder.Build() + + return _compound_or_shape(builder.Shape()) + + +def extrude(s: Shape, d: VectorLike) -> Shape: + """ + Extrude a shape. + """ + + results = [] + + for el in _get(s, ("Vertex", "Edge", "Wire", "Face")): + + builder = BRepPrimAPI_MakePrism(el.wrapped, Vector(d).wrapped) + builder.Build() + + results.append(builder.Shape()) + + return _compound_or_shape(results) + + +def revolve(s: Shape, p: VectorLike, d: VectorLike, a: float = 360): + """ + Revolve a shape. + """ + + results = [] + ax = gp_Ax1(Vector(p).toPnt(), Vector(d).toDir()) + + for el in _get(s, ("Vertex", "Edge", "Wire", "Face")): + + builder = BRepPrimAPI_MakeRevol(el.wrapped, ax, radians(a)) + builder.Build() + + results.append(builder.Shape()) + + return _compound_or_shape(results) + + +def offset(s: Shape, t: float, cap=True, tol: float = 1e-6) -> Shape: + """ + Offset or thicken faces or shells. + """ + + builder = BRepOffset_MakeOffset() + + results = [] + + for el in _get(s, ("Face", "Shell")): + + builder.Initialize( + el.wrapped, + t, + tol, + BRepOffset_Mode.BRepOffset_Skin, + False, + False, + GeomAbs_Intersection, + cap, + ) + + builder.MakeOffsetShape() + + results.append(builder.Shape()) + + return _compound_or_shape(results) + + +@multimethod +def sweep(s: Shape, path: Shape, cap: bool = False) -> Shape: + """ + Sweep edge or wire along a path. + """ + + spine = _get_one_wire(path) + + results = [] + + for w in _get_wires(s): + builder = BRepOffsetAPI_MakePipeShell(spine.wrapped) + builder.Add(w.wrapped, False, False) + builder.Build() + + if cap: + builder.MakeSolid() + + results.append(builder.Shape()) + + return _compound_or_shape(results) + + +@sweep.register +def sweep(s: Sequence[Shape], path: Shape, cap: bool = False) -> Shape: + """ + Sweep edges or wires along a path, chaining sections are supported. + """ + + spine = _get_one_wire(path) + + results = [] + + # construct sweeps + for el in _get_wire_lists(s): + builder = BRepOffsetAPI_MakePipeShell(spine.wrapped) + + for w in el: + builder.Add(w.wrapped, False, False) + + builder.Build() + + if cap: + builder.MakeSolid() + + results.append(builder.Shape()) + + return _compound_or_shape(results) + + +@multimethod +def loft(s: Sequence[Shape], cap: bool = False, ruled: bool = False) -> Shape: + """ + Loft edges or wires. + """ + + results = [] + + # construct lofts + builder = BRepOffsetAPI_ThruSections() + + for el in _get_wire_lists(s): + builder.Init(cap, ruled) + + for w in el: + builder.AddWire(w.wrapped) + + builder.Build() + builder.Check() + + results.append(builder.Shape()) + + return _compound_or_shape(results) + + +@loft.register +def loft(*s: Shape, cap: bool = False, ruled: bool = False) -> Shape: + + return loft(s, cap, ruled) diff --git a/conda/meta.yaml b/conda/meta.yaml index 7198c3dd..ea4df37e 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -24,7 +24,7 @@ requirements: - typing_extensions - nptyping >=2.0.1 - nlopt - - multimethod ==1.9.1 + - multimethod >=1.11,<2.0 - casadi test: diff --git a/doc/classreference.rst b/doc/classreference.rst index 5a700ac5..543750a8 100644 --- a/doc/classreference.rst +++ b/doc/classreference.rst @@ -84,6 +84,10 @@ Class Details :members: :special-members: +.. automodule:: cadquery.occ_impl.shapes + :show-inheritance: + :members: + .. autoclass:: cadquery.occ_impl.shapes.Mixin1D :show-inheritance: :members: diff --git a/doc/free-func.rst b/doc/free-func.rst new file mode 100644 index 00000000..1229eeb7 --- /dev/null +++ b/doc/free-func.rst @@ -0,0 +1,238 @@ +.. _freefuncapi: + +***************** +Free function API +***************** + +.. warning:: The free function API is experimental and may change. + +For situations when more freedom in crafting individual objects is required, a free function API is provided. +This API has no hidden state, but may result in more verbose code. One can still use selectors as methods, but all other operations are implemented as free functions. +Placement of objects and creation of patterns can be achieved using the various overloads of the moved method. + +Currently this documentation is incomplete, more examples can be found in the tests. + +Tutorial +-------- + +The purpose of this section is to demonstrate how to construct Shape objects using the free function API. + + +.. cadquery:: + :height: 600px + + from cadquery.occ_impl.shapes import * + + dh = 2 + r = 1 + + # construct edges + edge1 = circle(r) + edge2 = circle(1.5*r).moved(z=dh) + edge3 = circle(r).moved(z=1.5*dh) + + # loft the side face + side = loft(edge1, edge2, edge3) + + # bottom face + bottom = fill(side.edges('Z'), side, [(0,0,1.6*dh)]) + + # assemble into a solid + s = solid(side, bottom, top) + + # construct the final result + result = s.moved((-3*r, 0, 0), (3*r, 0, 0)) + + +The code above builds a non-trivial object by sequentially constructing individual faces, assembling them into a solid and finally generating a pattern. + +It begins with defining few edges. + +.. code-block:: python + + edge1 = circle(r) + edge2 = circle(2*r).moved(z=dh) + edge3 = circle(r).moved(z=1.5*dh) + + +Those edges are used to create the side faces of the final solid using :meth:`~cadquery.occ_impl.shapes.loft`. + +.. code-block:: python + + side = loft(edge1, edge2, edge3) + +Once the side is there, :meth:`~cadquery.occ_impl.shapes.cap` and :meth:`~cadquery.occ_impl.shapes.fill` are used to define the top and bottom faces. +Note that :meth:`~cadquery.occ_impl.shapes.cap` tries to maintain curvature continuity with respect to the context shape. This is not the case for :meth:`~cadquery.occ_impl.shapes.fill`. + +.. code-block:: python + + # bottom face + bottom = fill(side.edges('Z'), side, [(0,0,1.75*dh)]) + +Next, all the faces are assembled into a solid. + +.. code-block:: python + + s = solid(side, bottom, top) + +Finally, the solid is duplicated and placed in the desired locations creating the final compound object. Note various usages of :meth:`~cadquery.Shape.moved`. + +.. code-block:: python + + result = s.moved((-3*r, 0, 0), (3*r, 0, 0)) + +In general all the operations are implemented as free functions, with the exception of placement and selection which are strictly related to a specific shape. + + +Primitives +---------- + +Various 1D, 2D and 3D primitives are supported. + +.. cadquery:: + + from cadquery.occ_impl.shapes import * + + e = segment((0,0), (0,1)) + + c = circle(1) + + f = plane(1, 1.5) + + b = box(1, 1, 1) + + result = compound(e, c.move(2), f.move(4), b.move(6)) + + +Boolean operations +------------------ + +Boolean operations are supported and implemented as operators and free functions. +In general boolean operations are slow and it is advised to avoid them and not to perform the in a loop. +One can for example union multiple solids at once by first combining them into a compound. + +.. cadquery:: + + from cadquery.occ_impl.shapes import * + + c1 = cylinder(1, 2) + c2 = cylinder(0.5, 3) + + f1 = plane(2, 2).move(z=1) + f2 = plane(1, 1).move(z=1) + + e1 = segment((0,-2.5, 1), (0,2.5,1)) + + # union + r1 = c2 + c1 + r2 = fuse(f1, f2) + + # difference + r3 = c1 - c2 + r4 = cut(f1, f2) + + # intersection + r5 = c1*c2 + r6 = intersect(f1, f2) + + # splitting + r7 = (c1 / f1).solids('=1.11,<2.0 - typed-ast - regex - pathspec diff --git a/mypy.ini b/mypy.ini index 8d4739ef..97bbf2b5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,7 @@ [mypy] ignore_missing_imports = False disable_error_code = no-redef +plugins = mypy/cadquery-plugin.py [mypy-ezdxf.*] ignore_missing_imports = True diff --git a/mypy/cadquery-plugin.py b/mypy/cadquery-plugin.py new file mode 100644 index 00000000..787ab061 --- /dev/null +++ b/mypy/cadquery-plugin.py @@ -0,0 +1,81 @@ +from mypy.plugin import Plugin, FunctionContext +from mypy.types import Type, UnionType + + +class CadqueryPlugin(Plugin): + def get_function_hook(self, fullname: str): + + if fullname == "cadquery.occ_impl.shapes._get": + + return hook__get + + elif fullname == "cadquery.occ_impl.shapes._get_one": + + return hook__get_one + + elif fullname == "cadquery.occ_impl.shapes._get_edges": + + return hook__get_edges + + elif fullname == "cadquery.occ_impl.shapes._get_wires": + + return hook__get_wires + + return None + + +def hook__get(ctx: FunctionContext) -> Type: + """ + Hook for cq.occ_impl.shapes._get + + Based on the second argument values it adjusts return type to an Iterator of specific subclasses of Shape. + """ + + if hasattr(ctx.args[1][0], "items"): + return_type_names = [el.value for el in ctx.args[1][0].items] + else: + return_type_names = [ctx.args[1][0].value] + + return_types = UnionType([ctx.api.named_type(n) for n in return_type_names]) + + return ctx.api.named_generic_type("typing.Iterable", [return_types]) + + +def hook__get_one(ctx: FunctionContext) -> Type: + """ + Hook for cq.occ_impl.shapes._get_one + + Based on the second argument values it adjusts return type to a Union of specific subclasses of Shape. + """ + + if hasattr(ctx.args[1][0], "items"): + return_type_names = [el.value for el in ctx.args[1][0].items] + else: + return_type_names = [ctx.args[1][0].value] + + return UnionType([ctx.api.named_type(n) for n in return_type_names]) + + +def hook__get_wires(ctx: FunctionContext) -> Type: + """ + Hook for cq.occ_impl.shapes._get_wires + """ + + return_type = ctx.api.named_type("Wire") + + return ctx.api.named_generic_type("typing.Iterable", [return_type]) + + +def hook__get_edges(ctx: FunctionContext) -> Type: + """ + Hook for cq.occ_impl.shapes._get_edges + """ + + return_type = ctx.api.named_type("Edge") + + return ctx.api.named_generic_type("typing.Iterable", [return_type]) + + +def plugin(version: str): + + return CadqueryPlugin diff --git a/setup.py b/setup.py index e9ce5609..3b3abf28 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ if not is_rtd and not is_appveyor and not is_azure and not is_conda: reqs = [ "cadquery-ocp>=7.7.0a0,<7.8", "ezdxf", - "multimethod==1.9.1", + "multimethod>=1.11,<2.0", "nlopt", "nptyping==2.0.1", "typish", diff --git a/tests/test_cad_objects.py b/tests/test_cad_objects.py index 3ced2314..f71252d1 100644 --- a/tests/test_cad_objects.py +++ b/tests/test_cad_objects.py @@ -616,6 +616,12 @@ class TestCadObjects(BaseTest): def testLocation(self): + # empty + loc = Location() + + T = loc.wrapped.Transformation().TranslationPart() + self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 0), 6) + # Tuple loc0 = Location((0, 0, 1)) @@ -680,6 +686,14 @@ class TestCadObjects(BaseTest): with self.assertRaises(TypeError): Location("xy_plane") + # test to tuple + loc8 = Location(z=2, ry=15) + + trans, rot = loc8.toTuple() + + self.assertTupleAlmostEquals(trans, (0, 0, 2), 6) + self.assertTupleAlmostEquals(rot, (0, 15, 0), 6) + def testEdgeWrapperRadius(self): # get a radius from a simple circle diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py new file mode 100644 index 00000000..c583cfc6 --- /dev/null +++ b/tests/test_free_functions.py @@ -0,0 +1,531 @@ +from cadquery.occ_impl.shapes import ( + vertex, + segment, + polyline, + polygon, + rect, + circle, + ellipse, + plane, + box, + cylinder, + sphere, + torus, + cone, + spline, + text, + clean, + fill, + cap, + extrude, + fillet, + chamfer, + revolve, + offset, + loft, + sweep, + cut, + fuse, + intersect, + wire, + face, + shell, + solid, + compound, + Location, + Shape, + _get_one_wire, + _get_wires, + _get, + _get_one, + _get_edges, +) + +from pytest import approx, raises +from math import pi + +#%% test utils + + +def assert_all_valid(*objs: Shape): + + for o in objs: + assert o.isValid() + + +def vector_equal(v1, v2): + + return v1.toTuple() == approx(v2.toTuple()) + + +#%% utils + + +def test_utils(): + + r1 = _get_one_wire(rect(1, 1)) + + assert r1.ShapeType() == "Wire" + + r2 = list(_get_wires(compound(r1, r1.moved(Location(0, 0, 1))))) + + assert len(r2) == 2 + assert all(el.ShapeType() == "Wire" for el in r2) + + with raises(ValueError): + list(_get_wires(box(1, 1, 1))) + + r3 = list(_get(box(1, 1, 1).moved(Location(), Location(2, 0, 0)), "Solid")) + + assert (len(r3)) == 2 + assert all(el.ShapeType() == "Solid" for el in r3) + + with raises(ValueError): + list(_get(box(1, 1, 1), "Shell")) + + r4 = _get_one(compound(box(1, 1, 1), box(2, 2, 2)), "Solid") + + assert r4.ShapeType() == "Solid" + + with raises(ValueError): + _get_one(rect(1, 1), ("Solid", "Shell")) + + with raises(ValueError): + list(_get_edges(fill(circle(1)))) + + +#%% constructors + + +def test_constructors(): + + # wire + e1 = segment((0, 0), (0, 1)) + e2 = segment((0, 1), (1, 1)) + e3 = segment((1, 1), (1, 0)) + e4 = segment((1, 0), (0, 0)) + + w1 = wire(e1, e2, e3, e4) + w2 = wire((e1, e2, e3, e4)) + + assert w1.Length() == approx(4) + assert w2.Length() == approx(4) + + # face + f1 = face(w1, circle(0.1).moved(Location(0.5, 0.5, 0))) + f2 = face((w1,)) + + assert f1.Area() < 1 + assert len(f1.Wires()) == 2 + assert f2.Area() == approx(1) + assert len(f2.Wires()) == 1 + + with raises(ValueError): + face(e1) + + # shell + b = box(1, 1, 1) + + sh1 = shell(b.Faces()) + sh2 = shell(*b.Faces()) + + assert sh1.Area() == approx(6) + assert sh2.Area() == approx(6) + + # solid + s1 = solid(b.Faces()) + s2 = solid(*b.Faces()) + + assert s1.Volume() == approx(1) + assert s2.Volume() == approx(1) + + # compound + c1 = compound(b.Faces()) + c2 = compound(*b.Faces()) + + assert len(list(c1)) == 6 + assert len(list(c2)) == 6 + + for f in list(c1) + list(c2): + assert f.ShapeType() == "Face" + + +#%% primitives + + +def test_vertex(): + + v = vertex((1, 2,)) + + assert v.isValid() + assert v.Center().toTuple() == approx((1, 2, 0)) + + v = vertex(1, 2, 3) + + assert v.isValid() + assert v.Center().toTuple() == approx((1, 2, 3)) + + +def test_segment(): + + s = segment((0, 0, 0), (0, 0, 1)) + + assert s.isValid() + assert s.Length() == approx(1) + + +def test_polyline(): + + s = polyline((0, 0), (0, 1), (1, 1)) + + assert s.isValid() + assert s.Length() == approx(2) + + +def test_polygon(): + + s = polygon((0, 0), (0, 1), (1, 1), (1, 0)) + + assert s.isValid() + assert s.IsClosed() + assert s.Length() == approx(4) + + +def test_rect(): + + s = rect(2, 1) + + assert s.isValid() + assert s.IsClosed() + assert s.Length() == approx(6) + + +def test_circle(): + + s = circle(1) + + assert s.isValid() + assert s.IsClosed() + assert s.Length() == approx(2 * pi) + + +def test_ellipse(): + + s = ellipse(3, 2) + + assert s.isValid() + assert s.IsClosed() + assert face(s).Area() == approx(6 * pi) + + +def test_plane(): + + s = plane(1, 2) + + assert s.isValid() + assert s.Area() == approx(2) + + +def test_box(): + + s = box(1, 1, 1) + + assert s.isValid() + assert s.Volume() == approx(1) + + +def test_cylinder(): + + s = cylinder(2, 1) + + assert s.isValid() + assert s.Volume() == approx(pi) + + +def test_sphere(): + + s = sphere(2) + + assert s.isValid() + assert s.Volume() == approx(4 / 3 * pi) + + +def test_torus(): + + s = torus(10, 2) + + assert s.isValid() + assert s.Volume() == approx(2 * pi ** 2 * 5) + + +def test_cone(): + + s = cone(2, 1) + + assert s.isValid() + assert s.Volume() == approx(1 / 3 * pi) + + s = cone(2, 1, 1) + + assert s.isValid() + assert s.Volume() == approx(1 / 3 * pi * (1 + 0.25 + 0.5)) + + +def test_spline(): + + s1 = spline((0, 0), (0, 1)) + s2 = spline([(0, 0), (0, 1)]) + s3 = spline([(0, 0), (0, 1)], [(1, 0), (-1, 0)]) + + assert s1.Length() == approx(1) + assert s2.Length() == approx(1) + assert s3.Length() > 0 + assert s3.tangentAt(0).toTuple() == approx((1, 0, 0)) + assert s3.tangentAt(1).toTuple() == approx((-1, 0, 0)) + + +def test_text(): + + r1 = text("CQ", 10) + + assert len(r1.Faces()) == 2 + assert len(r1.Wires()) == 3 + assert r1.Area() > 0.0 + + # test alignemnt + r2 = text("CQ", 10, halign="left") + r3 = text("CQ", 10, halign="right") + r4 = text("CQ", 10, valign="bottom") + r5 = text("CQ", 10, valign="top") + + assert r2.faces(" r1.faces(" r3.faces(" r1.faces(" r5.faces(" 0 + assert (b1 * b3).Volume() < 1 + + assert len(fuse(b1, b3, 1e-3).Faces()) == 6 + assert len(cut(b1, b3, 1e-3).Faces()) == 0 + assert len(intersect(b1, b3, 1e-3).Faces()) == 6 + + +#%% moved +def test_moved(): + + b = box(1, 1, 1) + l1 = Location((-1, 0, 0)) + l2 = Location((1, 0, 0)) + l3 = Location((0, 1, 0), (45, 0, 0)) + l4 = Location((0, -1, 0), (-45, 0, 0)) + + bs1 = b.moved(l1, l2) + bs2 = b.moved((l1, l2)) + + assert bs1.Volume() == approx(2) + assert len(bs1.Solids()) == 2 + + assert bs2.Volume() == approx(2) + assert len(bs2.Solids()) == 2 + + # nested move + bs3 = bs1.moved(l3, l4) + + assert bs3.Volume() == approx(4) + assert len(bs3.Solids()) == 4 + + # move with VectorLike + bs4 = b.moved((0, 0, 1), (0, 0, -1)) + bs5 = bs4.moved((1, 0, 0)).move((-1, 0, 0)) + + assert bs4.Volume() == approx(2) + assert vector_equal(bs5.Center(), bs4.Center()) + + # move with direct params + bs6 = b.moved((0, 0, 1)).moved(0, 0, -1) + bs7 = b.moved((0, 0, 1)).moved(z=-1) + bs8 = b.moved(Location((0, 0, 0), (-45, 0, 0))).moved(rx=45) + bs9 = b.moved().move(Location((0, 0, 0), (-45, 0, 0))).move(rx=45) + + assert vector_equal(bs6.Center(), b.Center()) + assert vector_equal(bs7.Center(), b.Center()) + assert vector_equal(bs8.edges(">Z").Center(), b.edges(">Z").Center()) + assert vector_equal(bs9.edges(">Z").Center(), b.edges(">Z").Center()) + + +#%% ops +def test_clean(): + + b1 = box(1, 1, 1) + b2 = b1.moved(Location(1, 0, 0)) + + len((b1 + b2).Faces()) == 10 + len(clean(b1 + b2).Faces()) == 6 + + +def test_fill(): + + w1 = rect(1, 1) + w2 = rect(0.5, 0.5).moved(Location(0, 0, 1)) + + f1 = fill(w1) + f2 = fill(w1, [(0, 0, 1)]) + f3 = fill(w1, [w2]) + + assert f1.isValid() + assert f1.Area() == approx(1) + + assert f2.isValid() + assert f2.Area() > 1 + + assert f3.isValid() + assert f3.Area() > 1 + assert len(f3.Edges()) == 4 + assert len(f3.Wires()) == 1 + + +def test_cap(): + + s = extrude(circle(1), (0, 0, 1)) + + f1 = cap(s.edges(">Z"), s, [(0, 0, 1.5)]) + f2 = cap(s.edges(">Z"), s, [circle(0.5).moved(Location(0, 0, 2))]) + + assert_all_valid(f1, f2) + assert f1.Area() > pi + assert f2.Area() > pi + + +def test_fillet(): + + b = box(1, 1, 1) + + r = fillet(b, b.edges(">Z"), 0.1) + + assert r.isValid() + assert len(r.Edges()) == 20 + assert r.faces(">Z").Area() < 1 + + +def test_chamfer(): + + b = box(1, 1, 1) + + r = chamfer(b, b.edges(">Z"), 0.1) + + assert r.isValid() + assert len(r.Edges()) == 20 + assert r.faces(">Z").Area() < 1 + + +def test_extrude(): + + v = vertex(0, 0, 0) + e = segment((0, 0), (0, 1)) + w = rect(1, 1) + f = fill(w) + + d = (0, 0, 1) + + r1 = extrude(v, d) + r2 = extrude(e, d) + r3 = extrude(w, d) + r4 = extrude(f, d) + + assert r1.Length() == approx(1) + assert r2.Area() == approx(1) + assert r3.Area() == approx(4) + assert r4.Volume() == approx(1) + + +def test_revolve(): + + w = rect(1, 1) + + r = revolve(w, (0.5, 0, 0), (0, 1, 0)) + + assert r.Volume() == approx(4 * pi) + + +def test_offset(): + + f = plane(1, 1) + s = box(1, 1, 1).shells() + + r1 = offset(f, 1) + r2 = offset(s, -0.25) + + assert r1.Volume() == approx(1) + assert r2.Volume() == approx(1 - 0.5 ** 3) + + +def test_sweep(): + + w1 = rect(1, 1) + w2 = w1.moved(Location(0, 0, 1)) + + p1 = segment((0, 0, 0), (0, 0, 1)) + p2 = spline((w1.Center(), w2.Center()), ((-0.5, 0, 1), (0.5, 0, 1))) + + r1 = sweep(w1, p1) + r2 = sweep((w1, w2), p1) + r3 = sweep(w1, p1, cap=True) + r4 = sweep((w1, w2), p1, cap=True) + r5 = sweep((w1, w2), p2, cap=True) + + assert_all_valid(r1, r2, r3, r4, r5) + + assert r1.Area() == approx(4) + assert r2.Area() == approx(4) + assert r3.Volume() == approx(1) + assert r4.Volume() == approx(1) + assert r5.Volume() > 0 + assert len(r5.Faces()) == 6 + + +def test_loft(): + + w1 = circle(1) + w2 = ellipse(1.5, 1).move(0, y=1) + w3 = circle(1).moved(z=4, rx=15) + + w4 = segment((0, 0), (1, 0)) + w5 = w4.moved(0, 0, 1) + + r1 = loft(w1, w2, w3) # loft + r2 = loft(w1, w2, w3, ruled=True) # ruled loft + r3 = loft([w1, w2, w3]) # overload + r4 = loft(w1, w2, w3, cap=True) # capped loft + r5 = loft(w4, w5) # loft with open edges + + assert_all_valid(r1, r2, r3, r4, r5) + + assert len(r1.Faces()) == 1 + assert len(r2.Faces()) == 2 + assert len((r1 - r3).Faces()) == 0 + assert r4.Volume() > 0 + assert r5.Area() == approx(1)