diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 5e0f15161..61fb167b4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,7 +6,7 @@ sphinx: build: - os: "ubuntu-20.04" + os: "ubuntu-22.04" tools: python: "mambaforge-4.10" diff --git a/appveyor.yml b/appveyor.yml index 5138fafaf..6c49e2d84 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -46,6 +46,9 @@ test_script: on_success: - mamba run -n cadquery codecov +# on_finish: +# - sh: export APPVEYOR_VNC_BLOCK=true +# - sh: curl -sflL 'https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-vnc.sh' | sed 's#https://www.appveyor.com/tools/my-ip.aspx#https://api.ipify.org#' | bash -e - #on_finish: # - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) # - sh: export APPVEYOR_SSH_BLOCK=true diff --git a/cadquery/fig.py b/cadquery/fig.py index d5b67f9b5..cd9adda78 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -23,6 +23,7 @@ vtkRenderWindow, vtkRenderWindowInteractor, vtkProp3D, + vtkMapper, ) @@ -37,6 +38,9 @@ class Figure: + """ + Non-blocking visualization class. + """ server: Server win: vtkRenderWindow @@ -102,6 +106,11 @@ def __init__(self, port: int = 18081): orient_widget.EnabledOn() orient_widget.InteractiveOff() + # rendering related settings + vtkMapper.SetResolveCoincidentTopologyToPolygonOffset() + vtkMapper.SetResolveCoincidentTopologyPolygonOffsetParameters(1, 0) + vtkMapper.SetResolveCoincidentTopologyLineOffsetParameters(-1, 0) + self.axes = axes self.orient_widget = orient_widget self.win = win @@ -488,12 +497,18 @@ def onSelection(self, event: list[str]): def show( *args: Showable | vtkProp3D | list[vtkProp3D], name: Optional[str] = None, **kwargs ): + """ + Show objects without blocking. + """ fig = Figure() fig.show(*args, name=name, **kwargs) def clear(*args: Shape | vtkProp3D, **kwargs): + """ + Clear objects from the current figure. + """ fig = Figure() fig.clear(*args, **kwargs) diff --git a/cadquery/func.py b/cadquery/func.py index ef65d674d..949bc809b 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -53,4 +53,75 @@ project, faceOn, isSubshape, + prism, + hollow, + fillet2D, + chamfer2D, + draft, + History, ) + +__all__ = [ + "Vector", + "Plane", + "Location", + "Shape", + "Vertex", + "Edge", + "Wire", + "Face", + "Shell", + "Solid", + "CompSolid", + "Compound", + "edgeOn", + "wireOn", + "wire", + "face", + "shell", + "solid", + "compound", + "vertex", + "segment", + "polyline", + "polygon", + "rect", + "spline", + "circle", + "ellipse", + "plane", + "box", + "cylinder", + "sphere", + "torus", + "cone", + "text", + "fuse", + "cut", + "intersect", + "imprint", + "split", + "fill", + "clean", + "cap", + "fillet", + "chamfer", + "extrude", + "revolve", + "offset", + "offset2D", + "sweep", + "loft", + "hollow", + "check", + "closest", + "setThreads", + "project", + "faceOn", + "isSubshape", + "prism", + "chamfer2D", + "fillet2D", + "draft", + "History", +] diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 38b641372..bc03818af 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -1,6 +1,7 @@ from typing import ( Optional, Tuple, + TypeAlias, Union, Iterable, List, @@ -89,6 +90,7 @@ ) from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeShape, BRepBuilderAPI_MakeVertex, BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, @@ -179,6 +181,7 @@ from OCP.BRepLib import BRepLib, BRepLib_FindSurface from OCP.BRepOffsetAPI import ( + BRepOffsetAPI_DraftAngle, BRepOffsetAPI_ThruSections, BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, @@ -210,6 +213,7 @@ from OCP.BRepTools import ( BRepTools, + BRepTools_History, BRepTools_WireExplorer, BRepTools_ReShape, ) @@ -238,7 +242,7 @@ from OCP.NCollection import NCollection_Utf8String -from OCP.BRepFeat import BRepFeat_MakeDPrism +from OCP.BRepFeat import BRepFeat_MakeDPrism, BRepFeat_MakePrism from OCP.BRepClass3d import BRepClass3d_SolidClassifier, BRepClass3d @@ -1959,6 +1963,36 @@ def reverse(self) -> "Shape": return self.cast(self.wrapped.Reversed()) + def __and__(self, other: "Shape") -> "Compound": + """ + Set intersection for combining selection results. + """ + + LHS = set(self) if isinstance(self, Compound) else {self} + RHS = set(other) if isinstance(other, Compound) else {other} + + return compound(*(LHS & RHS)) + + def __or__(self, other: "Shape") -> "Compound": + """ + Set sum for combining selection results. + """ + + LHS = set(self) if isinstance(self, Compound) else {self} + RHS = set(other) if isinstance(other, Compound) else {other} + + return compound(*(LHS | RHS)) + + def __mod__(self, other: "Shape") -> "Compound": + """ + Set difference for combining selection results. + """ + + LHS = set(self) if isinstance(self, Compound) else {self} + RHS = set(other) if isinstance(other, Compound) else {other} + + return compound(*(LHS - RHS)) + class ShapeProtocol(Protocol): @property @@ -5022,7 +5056,7 @@ def edgesToWires(edges: Iterable[Edge], tol: float = 1e-6) -> List[Wire]: return [Wire(el) for el in wires_out] -#%% utilities +# %% utilities def _get(s: Shape, ts: Union[Shapes, Tuple[Shapes, ...]]) -> Iterable[Shape]: @@ -5134,6 +5168,26 @@ def _get_edges(*shapes: Shape) -> Iterable[Shape]: raise ValueError(f"Required type(s): Edge, Wire; encountered {t}") +def _get_faces(*shapes: Shape) -> Iterable[Face]: + """ + Get faces or faces from wires or edges. + """ + + for s in shapes: + t = s.ShapeType() + + if t == "Face": + yield s.face() + elif t == "Edge": + yield face(s) + elif t == "Wire": + yield face(s) + elif t == "Compound": + yield from _get_faces(*s) + else: + raise ValueError(f"Required type(s): Edge, Wire, Face; encountered {t}") + + def _get_wire_lists(s: Sequence[Shape]) -> List[List[Union[Wire, Vertex]]]: """ Get lists of wires for sweeping or lofting. @@ -5299,7 +5353,7 @@ def _compound_or_shape(s: Union[TopoDS_Shape, Sequence[TopoDS_Shape]]) -> Shape: if isinstance(s, TopoDS_Shape): rv = _normalize(Shape.cast(s)) elif len(s) == 1: - rv = _normalize(Shape.cast(s[0])) + rv = _normalize(Shape.cast(list(s)[0])) else: rv = Compound.makeCompound([_normalize(Shape.cast(el)) for el in s]) @@ -5474,9 +5528,344 @@ def _adaptor_curve_to_edge(crv: Adaptor3d_Curve, p1: float, p2: float) -> TopoDS return bldr.Edge() -#%% alternative constructors +# %% history related helpers + + +class Op: + """ + Operation history element. + """ + + _name: str | None + _tracked: set[Shape] + _deleted: list[Shape] + _modified: dict[Shape, Shape] + _generated: dict[Shape, Shape] + _images: dict[Shape, Shape] + _first: dict[Shape, Shape] + _last: dict[Shape, Shape] + _first_shape: Shape + _last_shape: Shape + + def __init__(self, name: str | None = None): + + self._name = name if name else None + + self._tracked = set() + self._deleted = [] + self._modified = {} + self._generated = {} + self._images = {} + self._first = {} + self._last = {} + self._first_shape = compound() + self._last_shape = compound() + + def _get(self, d: dict[Shape, Shape], k: Shape): + + if k.ShapeType() == "Compound": + tmp: list[Shape] = [] + + for el in k: + val = d[el] + if val.ShapeType() == "Compound": + tmp.extend(val) + else: + tmp.append(val) + + return _normalize(compound(tmp)) + else: + return _normalize(d[k]) + + def modified(self, s: Shape | None = None) -> Shape: + """ + Shapes modified from s. + """ + + if s: + return self._get(self._modified, s) + + return _normalize(compound(*self._modified.values())) + + def generated(self, s: Shape | None = None) -> Shape: + """ + Shapes generated from s. + """ + + if s: + return self._get(self._generated, s) + + return _normalize(compound(*self._generated.values())) + + def deleted(self) -> Shape: + """ + Deleted shapes. + """ + + return _normalize(compound(self._deleted)) + + def images(self, s: Shape) -> Shape: + """ + Images of s. + """ + + return self._get(self._images, s) + + def first(self, s: Shape | None = None) -> Shape: + """ + First shape (e.g. bottom face) or first shape generated from s. + """ + + if s: + return self._get(self._first, s) + + return _normalize(self._first_shape) + + def last(self, s: Shape | None = None) -> Shape: + """ + Last shape (e.g. top face) or last shape generated from s. + """ + + if s: + return self._get(self._last, s) + + return _normalize(self._last_shape) + + +def _combine_hist_dict(d1: dict[Shape, Shape], *ds: dict[Shape, Shape]): + """ + Helper for combining of history dicts. + If a key occurs twice, both values are added to a compound. + """ + + for d in ds: + common_keys = d1.keys() & d.keys() + new_keys = d.keys() - d1.keys() + + for k in common_keys: + d1[k] |= d[k] + + for k in new_keys: + d1[k] = d[k] + + +def _combine_ops(op: Op, *ops: Op) -> Op: + """ + Combine multiple history steps into one. Modifies first step in-place. + """ + + for el in ops: + + op._tracked.update(el._tracked) + op._deleted.extend(el._deleted) + _combine_hist_dict(op._modified, el._modified) + _combine_hist_dict(op._generated, el._generated) + _combine_hist_dict(op._images, el._images) + _combine_hist_dict(op._first, el._first) + _combine_hist_dict(op._last, el._last) + op._first_shape |= el._first_shape + op._last_shape |= el._last_shape + + return op + + +class History: + """ + Operation history. + """ + + ops: list[Op] + opDict: dict[str, Op] + + def __init__(self): + + self.ops = [] + self.opDict = dict() + + def __getitem__(self, ix: int | str) -> Op: + + if isinstance(ix, str): + return self.opDict[ix] + else: + return self.ops[ix] + + def pop(self) -> Op: + + return self.ops.pop() + + def append(self, op: Op, name: str | None = None): + + self.ops.append(op) + if name: + self.opDict[name] = op + + +BuilderType: TypeAlias = BOPAlgo_Builder | BRepBuilderAPI_MakeShape | BRepPrimAPI_MakePrism | BRepPrimAPI_MakeRevol | BRepTools_History + + +def _update_history( + history: History | None, + name: str | None, + shapes: Sequence[Shape], + *builders: BuilderType, +): + """ + Update history based on specified shapes and builders. + """ -ShapeHistory = Dict[Union[Shape, str], Shape] + if history: + # construct the history step + op = Op() + + history.append(op, name) + + # track all subshapes + for shape in shapes: + op._tracked.update(shape.Faces()) + op._tracked.update(shape.Edges()) + op._tracked.update(shape.Vertices()) + + # iterate over all builders and collect history information + builder: Any + for builder in builders: + has_first_last = isinstance( + builder, (BRepPrimAPI_MakeRevol, BRepPrimAPI_MakePrism,) + ) + has_first_last_shape = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + BRepFeat_MakePrism, + BRepFeat_MakeDPrism, + ), + ) + has_generated = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + BRepTools_History, + BRepBuilderAPI_MakeShape, + BOPAlgo_Builder, + BRepOffset_MakeOffset, + ), + ) + has_modifidied = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + BRepTools_History, + BRepBuilderAPI_MakeShape, + BOPAlgo_Builder, + BRepOffset_MakeOffset, + ), + ) + has_deleted = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + BOPAlgo_Builder, + BRepOffset_MakeOffset, + ), + ) + + for el in op._tracked: + wrapped = el.wrapped + + if has_deleted: + if builder.IsDeleted(wrapped): + op._deleted.append(el) + + if has_generated: + gen = _compound_or_shape(list(builder.Generated(wrapped))) + if gen: + if el in op._generated: + op._generated[el] |= gen + else: + op._generated[el] = gen + + if has_modifidied: + mod = _compound_or_shape(list(builder.Modified(wrapped))) + if mod: + if el in op._modified: + op._modified[el] |= mod + else: + op._modified[el] = mod + + if has_first_last: + op._first[el] = _compound_or_shape(builder.FirstShape(el.wrapped)) + op._last[el] = _compound_or_shape(builder.LastShape(el.wrapped)) + + if has_first_last_shape: + op._first_shape |= _compound_or_shape(builder.FirstShape()) + op._last_shape |= _compound_or_shape(builder.LastShape()) + + +def _remap_history_values( + history: History | None, aux: History, +): + """ + Remap generated and modified in history using aux. Used when solid/shell is called inside a function. + """ + + if history: + last_op = history[-1] + last_aux = aux[-1] + + # handle generated + for k, v in last_op._generated.items(): + last_op._generated[k] = last_aux._modified.get(v, v) + + # handle modified + for k, v in last_op._modified.items(): + last_op._modified[k] = last_aux._modified.get(v, v) + + # handle last shape + last_op._last_shape = compound( + [last_aux._modified.get(el, el) for el in last_op._last_shape] + ) + + # handle first shape + last_op._first_shape = compound( + [last_aux._modified.get(el, el) for el in last_op._first_shape] + ) + + +def _update_images(history: History | None, *builders: BuilderType): + + if history is not None: + op = history.ops[-1] + + builder: Any + for builder in builders: + images = builder.Images() + + # store all subshape relations, assume subshapes not present in Images are mapped onto themselves + for s in op._tracked: + try: + op._images[s] = _compound_or_shape(list(images.Find(s.wrapped))) + except Standard_NoSuchObject: + op._images[s] = s + + +def _update_removed(history: History | None, shapes: Sequence[Shape]): + """ + Add shapes to the removed field of the last operation. + """ + + if history: + last_op = history[-1] + last_op._deleted.extend(shapes) + + +# %% alternative constructors @multidispatch @@ -5646,7 +6035,7 @@ def faceOn(base: Shape, *fcs: Shape, tol=1e-6, N=20) -> Face | Compound: def _process_sewing_history( - builder: BRepBuilderAPI_Sewing, faces: List[Face], history: Optional[ShapeHistory], + history: History | None, faces: List[Face], builder: BRepBuilderAPI_Sewing, ): """ Reusable helper for processing sewing history. @@ -5654,14 +6043,10 @@ def _process_sewing_history( # fill history if provided if history is not None: - # collect shapes present in the history dict - for k, v in history.items(): - if isinstance(k, str): - history[k] = Face(builder.Modified(v.wrapped)) - # store all top-level shape relations + op = history[-1] for f in faces: - history[f] = Face(builder.Modified(f.wrapped)) + op._images[f] = Face(builder.Modified(f.wrapped)) @multidispatch @@ -5670,7 +6055,8 @@ def shell( tol: float = 1e-6, manifold: bool = True, ctx: Optional[Sequence[Shape] | Shape] = None, - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Build shell from faces. If ctx is specified, local sewing is performed. @@ -5692,7 +6078,17 @@ def shell( builder.Perform() sewed = builder.SewedShape() - _process_sewing_history(builder, faces, history) + + # if specified, use context for history mapping + if ctx: + if isinstance(ctx, Shape): + faces.extend(ctx.Faces()) + else: + for el in ctx: + faces.extend(el.Faces()) + + _update_history(history, name, faces, builder.GetContext().History()) + _process_sewing_history(history, faces, builder) rv = [] @@ -5720,18 +6116,23 @@ def shell( tol: float = 1e-6, manifold: bool = True, ctx: Optional[Sequence[Shape] | Shape] = None, - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Build shell from a sequence of faces. If ctx is specified, local sewing is performed. """ - return shell(*s, tol=tol, manifold=manifold, ctx=ctx, history=history) + return shell(*s, tol=tol, manifold=manifold, ctx=ctx, history=history, name=name) @multidispatch def solid( - s1: Shape, *sn: Shape, tol: float = 1e-6, history: Optional[ShapeHistory] = None, + s1: Shape, + *sn: Shape, + tol: float = 1e-6, + history: History | None = None, + name: str | None = None, ) -> Compound | Solid: """ Build solid from faces or shells. @@ -5747,7 +6148,11 @@ def solid( shells = [el.wrapped for el in shells_faces if isinstance(el, Shell)] if not shells: faces = [el for el in shells_faces if isinstance(el, Face)] - shells = [tcast(TopoDS_Shell, shell(*faces, tol=tol, history=history).wrapped)] + shells = [ + tcast( + TopoDS_Shell, shell(*faces, tol=tol, history=history, name=name).wrapped + ) + ] rvs = [builder.SolidFromShell(sh) for sh in shells] @@ -5759,14 +6164,19 @@ def solid( s: Sequence[Shape], inner: Optional[Sequence[Shape]] = None, tol: float = 1e-6, - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Solid: """ Build solid from a sequence of faces. """ builder = BRepBuilderAPI_MakeSolid() - builder.Add(_get_one(shell(*s, tol=tol, history=history), "Shell").wrapped) + builder.Add( + _get_one(shell(*s, tol=tol, history=history, name=name), "Shell").wrapped + ) + + n_inner = 0 if inner: for sh in _get(shell(*inner, tol=tol, history=history), "Shell"): @@ -5779,10 +6189,10 @@ def solid( sf.SetContext(ctx) sf.Perform() - # update history if applicable - if history is not None: - for k, v in history.items(): - history[k] = Shape.cast(ctx.Apply(v.wrapped)) + # combine histories of all shell operations if needed + if history and inner: + inner_op = history.pop() + _combine_ops(history.ops[-1], inner_op) return _shape(sf.Solid(), Solid) @@ -5813,7 +6223,7 @@ def compound(s: Sequence[Shape] | Generator[Shape, None, None]) -> Compound: return compound(*s) -#%% primitives +# %% primitives @multimethod @@ -6189,7 +6599,7 @@ def text( return _normalize(compound(rv)) -#%% ops +# %% ops def _bool_op( @@ -6248,7 +6658,13 @@ def setThreads(n: int): def fuse( - s1: Shape, s2: Shape, *shapes: Shape, tol: float = 0.0, glue: GlueLiteral = None, + s1: Shape, + s2: Shape, + *shapes: Shape, + tol: float = 0.0, + glue: GlueLiteral = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Fuse at least two shapes. @@ -6268,10 +6684,19 @@ def fuse( builder.Perform() + _update_history(history, name, [s1, s2, *shapes], builder) + return _compound_or_shape(builder.Shape()) -def cut(s1: Shape, s2: Shape, tol: float = 0.0, glue: GlueLiteral = None) -> Shape: +def cut( + s1: Shape, + s2: Shape, + tol: float = 0.0, + glue: GlueLiteral = None, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Subtract two shapes. """ @@ -6287,11 +6712,18 @@ def cut(s1: Shape, s2: Shape, tol: float = 0.0, glue: GlueLiteral = None) -> Sha builder.Perform() + _update_history(history, name, [s1, s2], builder) + return _compound_or_shape(builder.Shape()) def intersect( - s1: Shape, s2: Shape, tol: float = 0.0, glue: GlueLiteral = None + s1: Shape, + s2: Shape, + tol: float = 0.0, + glue: GlueLiteral = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Intersect two shapes. @@ -6308,10 +6740,18 @@ def intersect( builder.Perform() + _update_history(history, name, [s1, s2], builder) + return _compound_or_shape(builder.Shape()) -def split(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: +def split( + s1: Shape, + s2: Shape, + tol: float = 0.0, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Split one shape with another. """ @@ -6319,6 +6759,8 @@ def split(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: builder = BRepAlgoAPI_Splitter() _bool_op(s1, s2, builder, tol) + _update_history(history, name, [s1, s2], builder) + return _compound_or_shape(builder.Shape()) @@ -6326,7 +6768,8 @@ def imprint( *shapes: Shape, tol: float = 0.0, glue: GlueLiteral = "full", - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Imprint arbitrary number of shapes. @@ -6343,23 +6786,8 @@ def imprint( builder.Perform() # fill history if provided - if history is not None: - images = builder.Images() - - # collect shapes present in the history dict - for k, v in history.items(): - if isinstance(k, str): - try: - history[k] = _compound_or_shape(list(images.Find(v.wrapped))) - except Standard_NoSuchObject: - pass - - # store all top-level shape relations - for s in shapes: - try: - history[s] = _compound_or_shape(list(images.Find(s.wrapped))) - except Standard_NoSuchObject: - pass + _update_history(history, name, [*shapes], builder) + _update_images(history, builder) return _compound_or_shape(builder.Shape()) @@ -6424,42 +6852,65 @@ def cap( return _compound_or_shape(builder.Shape()) -def fillet(s: Shape, e: Shape, r: float) -> Shape: +def fillet( + s: Shape, + edges: Shape, + r: float, + history: History | None = None, + name: str | None = None, +) -> 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()): + for el in _get_edges(edges.edges()): builder.Add(r, el.wrapped) builder.Build() + _update_history(history, name, [s, edges], builder) + return _compound_or_shape(builder.Shape()) -def chamfer(s: Shape, e: Shape, d: float) -> Shape: +def chamfer( + s: Shape, + edges: Shape, + d: float, + history: History | None = None, + name: str | None = None, +) -> 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()): + for el in _get_edges(edges.edges()): builder.Add(d, el.wrapped) builder.Build() + _update_history(history, name, [s, edges], builder) + return _compound_or_shape(builder.Shape()) -def extrude(s: Shape, d: VectorLike, both: bool = False) -> Shape: +def extrude( + s: Shape, + d: VectorLike, + both: bool = False, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Extrude a shape. """ results = [] + builders = [] for el in _get(s, ("Vertex", "Edge", "Wire", "Face")): @@ -6473,16 +6924,28 @@ def extrude(s: Shape, d: VectorLike, both: bool = False) -> Shape: builder.Build() results.append(builder.Shape()) + builders.append(builder) + + _update_history(history, name, [s], *builders) return _compound_or_shape(results) -def revolve(s: Shape, p: VectorLike, d: VectorLike, a: float = 360): +def revolve( + s: Shape, + p: VectorLike, + d: VectorLike, + a: float = 360, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Revolve a shape. """ results = [] + builders = [] + ax = gp_Ax1(Vector(p).toPnt(), Vector(d).toDir()) for el in _get(s, ("Vertex", "Edge", "Wire", "Face")): @@ -6491,12 +6954,21 @@ def revolve(s: Shape, p: VectorLike, d: VectorLike, a: float = 360): builder.Build() results.append(builder.Shape()) + builders.append(builder) + + _update_history(history, name, [s], *builders) return _compound_or_shape(results) def offset( - s: Shape, t: float, cap=True, both: bool = False, tol: float = 1e-6 + s: Shape, + t: float, + cap=True, + both: bool = False, + tol: float = 1e-6, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Offset or thicken faces or shells. @@ -6505,10 +6977,12 @@ def offset( def _offset(t): results = [] + builders = [] for el in _get(s, ("Face", "Shell")): builder = BRepOffset_MakeOffset() + builders.append(builder) builder.Initialize( el.wrapped, @@ -6525,23 +6999,28 @@ def _offset(t): results.append(builder.Shape()) - return results + return results, builders if both: - results_pos = _offset(t) - results_neg = _offset(-t) + results_pos, builders1 = _offset(t) + results_neg, builders2 = _offset(-t) results_both = [ Shape(el1) + Shape(el2) for el1, el2 in zip(results_pos, results_neg) ] + _update_history(history, name, [s], *builders1, *builders2) + _update_removed(history, s.Faces()) + if len(results_both) == 1: rv = results_both[0] else: rv = Compound.makeCompound(results_both) else: - results = _offset(t) + results, builders = _offset(t) + _update_history(history, name, [s], *builders) + rv = _compound_or_shape(results) return rv @@ -6589,9 +7068,65 @@ def offset2D( return _compound_or_shape(bldr.Shape()) -@multimethod +def chamfer2D(s: Shape, verts: Shape, d: float): + """ + Apply a 2D chamfer to a planar face. + """ + + f = _get_one(s, "Face") + + bldr = BRepFilletAPI_MakeFillet2d(tcast(TopoDS_Face, f.wrapped)) + edge_map = s._entitiesFrom("Vertex", "Edge") + + for v in verts.vertices(): + edges = edge_map[v] + if len(edges) < 2: + raise ValueError("Cannot chamfer at this location") + + e1, e2 = edges + + bldr.AddChamfer( + tcast(TopoDS_Edge, e1.wrapped), tcast(TopoDS_Edge, e2.wrapped), d, d + ) + + bldr.Build() + + return _compound_or_shape(bldr.Shape()) + + +def fillet2D(s: Shape, verts: Shape, r: float): + """ + Apply a 2D fillet to a planar face. + """ + + f = _get_one(s, "Face") + + bldr = BRepFilletAPI_MakeFillet2d(tcast(TopoDS_Face, f.wrapped)) + + for v in verts.vertices(): + bldr.AddFillet(tcast(TopoDS_Vertex, v.wrapped), r) + + bldr.Build() + + return _compound_or_shape(bldr.Shape()) + + +_trans_mode_dict = { + "transformed": BRepBuilderAPI_Transformed, + "round": BRepBuilderAPI_RoundCorner, + "right": BRepBuilderAPI_RightCorner, +} + + +@multidispatch def sweep( - s: Shape, path: Shape, aux: Optional[Shape] = None, cap: bool = False + s: Shape, + path: Shape, + aux: Optional[Shape] = None, + cap: bool = False, + transition: Literal["transformed", "round", "right"] = "transformed", + history: History | None = None, + name: str | None = None, ) -> Shape: """ Sweep edge, wire or face along a path. For faces cap has no effect. @@ -6601,6 +7136,7 @@ def sweep( spine = _get_one_wire(path) results = [] + builders = [] def _make_builder(): @@ -6610,6 +7146,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to get faces @@ -6617,20 +7155,75 @@ def _make_builder(): # if faces were supplied if faces: + # for history handling + tops_hist = [] + bots_hist = [] + solid_hist = History() + for f in faces: - tmp = sweep(f.outerWire(), path, aux, True) + builder = _make_builder() + builders.append(builder) + + builder.Add(f.outerWire().wrapped, False, False) + builder.Build() + builder.MakeSolid() - # if needed subtract two sweeps - inner_wires = f.innerWires() - if inner_wires: - tmp -= sweep(compound(inner_wires), path, aux, True) + # for bookkeeping of inner sweeps and cap construction + builders_inner = [] + tops = [] + bots = [] + sides = [] + + # extract the outer side and initial cap + bot = Shape(builder.FirstShape()) + top = Shape(builder.LastShape()) + side = compound() + for el in f.outerWire(): + side |= _compound_or_shape(list(builder.Generated(el.wrapped))) + + for w in f.innerWires(): + builder_inner = _make_builder() + builders_inner.append(builder_inner) - results.append(tmp.wrapped) + builder_inner.Add(w.wrapped, False, False) + builder_inner.Build() + builder_inner.MakeSolid() + + bots.append(Shape(builder_inner.FirstShape())) + tops.append(Shape(builder_inner.LastShape())) + + side_inner = compound() + for el in w: + side_inner |= _compound_or_shape( + list(builder_inner.Generated(el.wrapped)) + ) + + sides.append(side_inner) + + top -= compound(tops) + bot -= compound(bots) + + results.append(solid(side, *sides, top, bot, history=solid_hist).wrapped) + tops_hist.append(top) + bots_hist.append(bot) + + builders.extend(builders_inner) + + rv = _compound_or_shape(results) + + _update_history(history, name, faces + [spine], *builders) + + if history: + history[-1]._last_shape = compound(tops_hist) + history[-1]._first_shape = compound(bots_hist) + # remapping is needed because of the additional solid call + _remap_history_values(history, solid_hist) # otherwise sweep wires else: for w in _get_wires(s): builder = _make_builder() + builders.append(builder) builder.Add(w.wrapped, False, False) builder.Build() @@ -6640,12 +7233,22 @@ def _make_builder(): results.append(builder.Shape()) - return _compound_or_shape(results) + rv = _compound_or_shape(results) + _update_history(history, name, [s, path], *builders) -@multimethod + return rv + + +@multidispatch def sweep( - s: Sequence[Shape], path: Shape, aux: Optional[Shape] = None, cap: bool = False + s: Sequence[Shape], + path: Shape, + aux: Optional[Shape] = None, + cap: bool = False, + transition: Literal["transformed", "round", "right"] = "transformed", + history: History | None = None, + name: str | None = None, ) -> Shape: """ Sweep edges, wires or faces along a path, multiple sections are supported. @@ -6655,6 +7258,11 @@ def sweep( spine = _get_one_wire(path) results = [] + builders = [] + # for history handling + tops_hist = [] + bots_hist = [] + solid_hist = History() def _make_builder(): @@ -6665,12 +7273,16 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to construct sweeps using faces for el in _get_face_lists_strict(s): + # build outer part builder = _make_builder() + builders.append(builder) for f in el: builder.Add(f.outerWire().wrapped, False, False) @@ -6680,11 +7292,20 @@ def _make_builder(): # build inner parts builders_inner = [] + tops = [] + bots = [] + sides = [] + + # extract the outer side and initial cap + bot = Shape(builder.FirstShape()) + top = Shape(builder.LastShape()) + side = Shape(builder.Shape()).faces() % bot % top # initialize builders for w in el[0].innerWires(): builder_inner = _make_builder() builder_inner.Add(w.wrapped, False, False) + builders_inner.append(builder_inner) # add remaining sections @@ -6693,20 +7314,42 @@ def _make_builder(): builder_inner.Add(w.wrapped, False, False) # actually build - inner_parts = [] - for builder_inner in builders_inner: builder_inner.Build() builder_inner.MakeSolid() - inner_parts.append(Shape(builder_inner.Shape())) - results.append((Shape(builder.Shape()) - compound(inner_parts)).wrapped) + bots.append(Shape(builder_inner.FirstShape())) + tops.append(Shape(builder_inner.LastShape())) + + side_inner = Shape(builder_inner.Shape()).faces() % bots[-1] % tops[-1] + sides.append(side_inner) + + # assemble final result using sewing + top -= compound(tops) + bot -= compound(bots) + + results.append(solid(side, *sides, top, bot, history=solid_hist).wrapped) + tops_hist.append(top) + bots_hist.append(bot) + + builders.extend(builders_inner) + + # update history if there is a result + if results: + _update_history(history, name, [*s, path], *builders) + + if history: + history[-1]._last_shape = compound(tops_hist) + history[-1]._first_shape = compound(bots_hist) + # remapping is needed because of the additional solid call + _remap_history_values(history, solid_hist) # if no faces were provided try with wires - if not results: + else: # construct sweeps for el2 in _get_wire_lists_strict(s): builder = _make_builder() + builders.append(builder) for w in el2: builder.Add(w.wrapped, False, False) @@ -6718,10 +7361,12 @@ def _make_builder(): results.append(builder.Shape()) + _update_history(history, name, [*s, path], *builders) + return _compound_or_shape(results) -@multimethod +@multidispatch def loft( s: Sequence[Shape], cap: bool = False, @@ -6732,12 +7377,19 @@ def loft( compat: bool = True, smoothing: bool = False, weights: Tuple[float, float, float] = (1, 1, 1), + history: History | None = None, + name: str | None = None, ) -> Shape: """ Loft edges, wires or faces. For faces cap has no effect. Do not mix faces with other types. """ results = [] + builders = [] + # for history handling + tops_hist = [] + bots_hist = [] + solid_hist = History() def _make_builder(cap): rv = BRepOffsetAPI_ThruSections(cap, ruled) @@ -6754,6 +7406,7 @@ def _make_builder(cap): for el in _get_face_lists(s): # build outer part builder = _make_builder(True) + builders.append(builder) # used to check if building inner parts makes sense has_vertex = False @@ -6768,7 +7421,16 @@ def _make_builder(cap): builder.Build() builder.Check() + # build inner parts builders_inner = [] + tops = [] + bots = [] + sides = [] + + # extract the outer side and initial cap + bot = Shape(builder.FirstShape()) if builder.FirstShape() else compound() + top = Shape(builder.LastShape()) if builder.LastShape() else compound() + side = Shape(builder.Shape()).faces() % bot % top # only initialize inner builders if no vertex was encountered if not has_vertex: @@ -6787,19 +7449,38 @@ def _make_builder(cap): builder_inner.AddWire(w.wrapped) # actually build - inner_parts = [] - for builder_inner in builders_inner: builder_inner.Build() builder_inner.Check() - inner_parts.append(Shape(builder_inner.Shape())) - results.append((Shape(builder.Shape()) - compound(inner_parts)).wrapped) + bots.append(Shape(builder_inner.FirstShape())) + tops.append(Shape(builder_inner.LastShape())) + + side_inner = Shape(builder_inner.Shape()).faces() % bots[-1] % tops[-1] + sides.append(side_inner) + + # assemble final result using sewing + top -= compound(tops) + bot -= compound(bots) + + results.append(solid(side, *sides, top, bot, history=solid_hist).wrapped) + tops_hist.append(top) + bots_hist.append(bot) + + if results: + _update_history(history, name, s, *builders, *builders_inner) + + if history: + history[-1]._last_shape = compound(tops_hist) + history[-1]._first_shape = compound(bots_hist) + # remapping is needed because of the additional solid call + _remap_history_values(history, solid_hist) # otherwise construct using wires - if not results: + else: for el2 in _get_wire_lists(s): builder = _make_builder(cap) + builders.append(builder) for w2 in el2: if isinstance(w2, Wire): @@ -6812,11 +7493,15 @@ def _make_builder(cap): results.append(builder.Shape()) + _update_history(history, name, list(s), *builders) + return _compound_or_shape(results) -@multimethod +@multidispatch def loft( + s1: Shape, + s2: Shape, *s: Shape, cap: bool = False, ruled: bool = False, @@ -6826,12 +7511,26 @@ def loft( compat: bool = True, smoothing: bool = False, weights: Tuple[float, float, float] = (1, 1, 1), + history: History | None = None, + name: str | None = None, ) -> Shape: """ Variadic loft overload. """ - return loft(s, cap, ruled, continuity, parametrization, degree, compat) + return loft( + [s1, s2, *s], + cap, + ruled, + continuity, + parametrization, + degree, + compat, + smoothing, + weights, + history, + name, + ) @multidispatch @@ -6878,7 +7577,273 @@ def project( return _normalize(compound(results)) -#%% diagnostics +_offset_kind_dict = { + "arc": GeomAbs_JoinType.GeomAbs_Arc, + "intersection": GeomAbs_JoinType.GeomAbs_Intersection, +} + + +@multidispatch +def hollow( + s: Shape, + faces: Optional[Shape], + t: float, + tol: float = 1e-3, + kind: Literal["arc", "intersection"] = "intersection", + history: History | None = None, + name: str | None = None, +): + """ + Make a hollow solid by removing faces and applying thickness t. + """ + + bldr = BRepOffsetAPI_MakeThickSolid() + _faces = ( + _shapes_to_toptools_list(faces.Faces()) if faces else TopTools_ListOfShape() + ) + + bldr.MakeThickSolidByJoin( + s.solid().wrapped, + _faces, + t, + tol, + Intersection=True, + Join=_offset_kind_dict[kind], + ) + bldr.Build() + + rv = _compound_or_shape(bldr.Shape()) + + # if no faces provided a watertight solid will be constructed + if faces is None: + sh1 = rv.shell().wrapped + sh2 = s.shell().wrapped + + # sh1 can be outer or inner shell depending on the thickness sign + if t > 0: + sol = BRepBuilderAPI_MakeSolid(sh1, sh2) + else: + sol = BRepBuilderAPI_MakeSolid(sh2, sh1) + + # fix needed for the orientations + rv = _compound_or_shape(sol.Shape()).fix() + + _update_history(history, name, [s], bldr) + + return rv + + +@multidispatch +def hollow( + s: Shape, + t: float, + tol: float = 1e-3, + kind: Literal["arc", "intersection"] = "intersection", + history: History | None = None, + name: str | None = None, +) -> Solid: + + return hollow(s, None, t, tol, kind, history, name) + + +def _update_prism_history(ctx, base, faces, t, s_tmp, history, name, builders) -> Shape: + """ + Helper for prism history handling. + """ + + _tracked = [ctx, faces] + if isinstance(t, Shape): + _tracked.append(t) + elif isinstance(t, tuple): + _tracked.extend(t) + + if base is not None: + _tracked.append(base) + + _update_history(history, name, _tracked, *builders) + + rv = _compound_or_shape(s_tmp) + + # add last if extruding upto + if isinstance(t, Shape) and history is not None: + op = history[-1] + if op._last_shape.size() == 0: + op._last_shape = op.modified(t) + + return rv + + +@multidispatch +def prism( + ctx: Shape, + base: Optional[Shape], + faces: Shape, + t: Optional[Real | Shape | tuple[Shape, Shape]], + angle: Real = 0.0, + additive: bool = True, + history: History | None = None, + name: str | None = None, +) -> Shape: + """ + Build a drafted prismatic feature that can be additive or subtractive. + """ + + builders = [] + + s_tmp = ctx.wrapped + + for f in _get_faces(faces): + bldr: BRepFeat_MakePrism | BRepFeat_MakeDPrism + # if taper is requested, use the dprism builder + if angle != 0: + bldr = BRepFeat_MakeDPrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + radians(angle), + additive, + False, + ) + # otherwise use the prism builder to get cleaner topologies + else: + bldr = BRepFeat_MakePrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + f.normalAt().toDir(), + additive, + False, + ) + + builders.append(bldr) + + # dispatch on thickens type + if isinstance(t, Shape): + bldr.Perform(t.face().wrapped) + elif isinstance(t, tuple): + bldr.Perform(t[0].face().wrapped, t[1].face().wrapped) + elif t is None: + bldr.PerformThruAll() + else: + bldr.Perform(t) + + s_tmp = bldr.Shape() + + return _update_prism_history(ctx, base, faces, t, s_tmp, history, name, builders) + + +@multidispatch +def prism( + ctx: Shape, + base: Optional[Shape], + faces: Shape, + t: Optional[Real | Shape | tuple[Shape, Shape]], + dir: VectorLike, + additive: bool = True, + history: History | None = None, + name: str | None = None, +) -> Shape: + """ + Build a (potentially tilted) prismatic feature that can be additive or subtractive. + """ + + builders = [] + + s_tmp = ctx.wrapped + + for f in _get_faces(faces): + bldr = BRepFeat_MakePrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + Vector(dir).toDir(), + additive, + False, + ) + + builders.append(bldr) + + # dispatch on thickens type + if isinstance(t, Shape): + bldr.Perform(t.face().wrapped) + elif isinstance(t, tuple): + bldr.Perform(t[0].face().wrapped, t[1].face().wrapped) + elif t is None: + bldr.PerformThruAll() + else: + bldr.Perform(t) + + s_tmp = bldr.Shape() + + return _update_prism_history(ctx, base, faces, t, s_tmp, history, name, builders) + + +@multidispatch +def draft( + ctx: Shape, + base: Shape, + faces: Shape, + angle: Real, + history: History | None = None, + name: str | None = None, +) -> Shape: + """ + Add a draft angle to the specified faces. + """ + + base_face = base.face() + n_dir = base_face.normalAt().toDir() + base_pln = base_face.toPln() + + bldr = BRepOffsetAPI_DraftAngle(ctx.wrapped) + + for f in _get_faces(faces): + bldr.Add(f.wrapped, n_dir, radians(angle), base_pln) + + if not bldr.AddDone(): + raise ValueError(f"Face {f} cannot be used in a draft operation.") + + bldr.Build() + + _update_history(history, name, [ctx], bldr) + + return _compound_or_shape(bldr.Shape()) + + +@multidispatch +def draft( + ctx: Shape, + base: Shape, + faces: Shape, + dir: VectorLike, + angle: Real, + history: History | None = None, + name: str | None = None, +) -> Shape: + """ + Add a draft angle to the specified faces. + """ + + base_face = base.face() + n_dir = Vector(dir).toDir() + base_pln = base_face.toPln() + + bldr = BRepOffsetAPI_DraftAngle(ctx.wrapped) + + for f in _get_faces(faces): + bldr.Add(f.wrapped, n_dir, radians(angle), base_pln) + + if not bldr.AddDone(): + raise ValueError(f"Face {f} cannot be used in a draft operation.") + + bldr.Build() + + _update_history(history, name, [ctx], bldr) + + return _compound_or_shape(bldr.Shape()) + + +# %% diagnostics def check( @@ -6933,7 +7898,7 @@ def isSubshape(s1: Shape, s2: Shape) -> bool: return shape_map.Contains(s1.wrapped) -#%% properties +# %% properties def closest(s1: Shape, s2: Shape) -> Tuple[Vector, Vector]: diff --git a/doc/_static/fig.png b/doc/_static/fig.png new file mode 100644 index 000000000..5db201913 Binary files /dev/null and b/doc/_static/fig.png differ diff --git a/doc/classreference.rst b/doc/classreference.rst index 156112372..f00da9914 100644 --- a/doc/classreference.rst +++ b/doc/classreference.rst @@ -37,6 +37,8 @@ Topological Classes occ_impl.shapes.Mixin3D Solid Compound + occ_impl.shapes.History + occ_impl.shapes.Op Geometry Classes ------------------ @@ -104,6 +106,14 @@ Class Details :show-inheritance: :members: +.. automodule:: cadquery.fig + :show-inheritance: + :members: + +.. automodule:: cadquery.vis + :show-inheritance: + :members: + .. autofunction:: cadquery.occ_impl.assembly.toJSON .. autoclass:: cadquery.occ_impl.exporters.dxf.DxfDocument diff --git a/doc/free-func.rst b/doc/free-func.rst index 12977dc51..7a4d9c86c 100644 --- a/doc/free-func.rst +++ b/doc/free-func.rst @@ -5,7 +5,6 @@ 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. @@ -152,6 +151,40 @@ One can for example union multiple solids at once by first combining them into a Note that bool operations work on 2D shapes as well. +Boolean operations support optionally history that allows to refer to generated, modified or deleted shapes. In order to use it one has to instantiate the +:py:class:`cadquery.occ_impl.shapes.History` class and pass it to the operations of interest. The individual boolean operations entries (i.e. instances of :py:class:`cadquery.occ_impl.shapes.Op`) +can be accessed using indexing or name-based queries. In order to support names, one need to specify the `name` parameter when invoking an operation. + +.. warning:: The history tracking feature of the free function API is experimental and may change. + + +Shape selection +--------------- + +Shape selection can be performed using the usual +:meth:`~cadquery.occ_impl.shapes.Shape.vertices`, +:meth:`~cadquery.occ_impl.shapes.Shape.edges`, +:meth:`~cadquery.occ_impl.shapes.Shape.wires`, +:meth:`~cadquery.occ_impl.shapes.Shape.faces`, +:meth:`~cadquery.occ_impl.shapes.Shape.shells`, +:meth:`~cadquery.occ_impl.shapes.Shape.solids` methods. Those methods return a shape or a compound. +Alternatively, one can use +:meth:`~cadquery.occ_impl.shapes.Shape.vertex`, +:meth:`~cadquery.occ_impl.shapes.Shape.edge`, +:meth:`~cadquery.occ_impl.shapes.Shape.wire`, +:meth:`~cadquery.occ_impl.shapes.Shape.face`, +:meth:`~cadquery.occ_impl.shapes.Shape.shell`, +:meth:`~cadquery.occ_impl.shapes.Shape.solid` methods. Those methods either return a single shape or throw. +Additionally, selection can be performed using +:meth:`~cadquery.occ_impl.shapes.Shape.siblings`, +:meth:`~cadquery.occ_impl.shapes.Shape.ancestors`, +special selectors. +Last but not least, selections can be combined using set operations implemented via convenient operator syntax +:meth:`~cadquery.occ_impl.shapes.Shape.__and__`, +:meth:`~cadquery.occ_impl.shapes.Shape.__or__`, +:meth:`~cadquery.occ_impl.shapes.Shape.__mod__`. The last operator implements a set difference operation. + + Shape construction ------------------ @@ -193,7 +226,9 @@ Constructing complex shapes from simple shapes is possible in various contexts. Operations ---------- -Free function API currently supports :meth:`~cadquery.occ_impl.shapes.extrude`, :meth:`~cadquery.occ_impl.shapes.loft`, :meth:`~cadquery.occ_impl.shapes.revolve` and :meth:`~cadquery.occ_impl.shapes.sweep` operations. +Free function API currently supports :meth:`~cadquery.occ_impl.shapes.extrude`, :meth:`~cadquery.occ_impl.shapes.loft`, :meth:`~cadquery.occ_impl.shapes.revolve`, +:meth:`~cadquery.occ_impl.shapes.sweep`, :meth:`~cadquery.occ_impl.shapes.hollow`, :meth:`~cadquery.occ_impl.shapes.draft`, :meth:`~cadquery.occ_impl.shapes.prism`, +:meth:`~cadquery.occ_impl.shapes.chamfer` and :meth:`~cadquery.occ_impl.shapes.fillet` operations. .. cadquery:: @@ -223,6 +258,72 @@ Free function API currently supports :meth:`~cadquery.occ_impl.shapes.extrude`, result = compound([el.moved(2*i) for i,el in enumerate(results)]) +Most operations support optionally history that allows to refer to generated, modified or deleted shapes. In order to use it one has to instantiate the +:py:class:`cadquery.occ_impl.shapes.History` class and pass it to the operations of interest. The individual modeling steps (i.e. instances of :py:class:`cadquery.occ_impl.shapes.Op`) +can be accessed using indexing or name-based queries. In order to support names, one need to specify the `name` parameter when invoking an operation. +Here is an usage example of this feature. + +.. cadquery:: + + from cadquery.func import * + + dx = 5 + dy = 3 + dz = 1.5 + + hist = History() + + # make a hollow base + base_face = plane(dx, dy) + base = extrude(base_face, (0, 0, dz)) + res = fillet(base, base.edges("|Z"), 0.5) + ftop = res.face(">Z") + resh = hollow(res, ftop, -0.2, history=hist, name="hollow") + + # add mounting points + mid = resh.face(">Z[-2]") + f = ( + face(circle(0.1), circle(0.05)) + .moved(offset2D(base_face.wire(), -0.5).vertices()) + .moved(mid) + .moved(z=0) + ) + res = prism(resh, mid, f, base.face(">Z"), history=hist, name="mounts") + + # add fillet + res = fillet( + res, + hist["hollow"].generated().edges("Z") + top_ow = top.outerWire() + + res = prism( + res, + top, + face(top_ow, offset2D(top_ow, -0.1)), + 0.2, + additive=False, + history=hist, + name="lip", + ) + + # apply chamfers + res = chamfer(res, hist["lip"].modified(top).face().outerWire(), 0.05) + result = chamfer( + res, compound([f.face().outerWire() for f in hist["mounts"].last()]), 0.02 + ) + + +Some operations like e.g. :meth:`~cadquery.occ_impl.shapes.extrude` or :meth:`~cadquery.occ_impl.shapes.prism` support referring to the +:meth:`~cadquery.occ_impl.shapes.Op.first` and :meth:`~cadquery.occ_impl.shapes.Op.last` shape as well. + +.. warning:: The history tracking feature of the free function API is experimental and may change. + + Placement --------- diff --git a/doc/primer.rst b/doc/primer.rst index e107f61c1..6d27b6ab2 100644 --- a/doc/primer.rst +++ b/doc/primer.rst @@ -39,8 +39,9 @@ CadQuery is composed of 4 different API, which are implemented on top of each ot #. :class:`~cadquery.Workplane` #. :class:`~cadquery.Sketch` #. :class:`~cadquery.Assembly` -2. The Direct API +2. The Free Function API #. :class:`~cadquery.Shape` + #. :ref:`freefuncapi` 3. The Geometry API #. :class:`~cadquery.Vector` #. :class:`~cadquery.Plane` @@ -77,13 +78,13 @@ Or like this : :: While the first code style is what people default to, it's important to note that when you write your code like this it's equivalent as writting it on a single line. It's then more difficult to debug as you cannot visualize each operation step by step, which is a functionality that is provided by the CQ-Editor debugger for example. -The Direct API -~~~~~~~~~~~~~~ +The Free Function API +~~~~~~~~~~~~~~~~~~~~~ While the fluent API exposes much functionality, you may find scenarios that require extra flexibility or require working with lower level objects. -The direct API is the API that is called by the fluent API under the hood. The 9 topological classes and their methods compose the direct API. -These classes actually wrap the equivalent Open CASCADE Technology (OCCT) classes. +The free function API is the API that is called by the fluent API under the hood. The 9 topological classes, their methods and additional free functionsi +compose the free function API. These classes actually wrap the equivalent Open CASCADE Technology (OCCT) classes. The 9 topological classes are : 1. :class:`~cadquery.Shape` @@ -96,21 +97,23 @@ The 9 topological classes are : 8. :class:`~cadquery.Edge` 9. :class:`~cadquery.Vertex` -Each class has its own methods to create and/or edit shapes of their respective type. One can also use the :ref:`freefuncapi` to create and modify shapes. As already explained in :ref:`cadquery_concepts` there is also some kind of hierarchy in the -topological classes. A Wire is made of several edges which are themselves made of several vertices. This means you can create geometry from the bottom up and have a lot of control over it. +Each class has its own methods to query, select and position shapes. On the other hand, one can use free function to create and modify shapes. +As already explained in :ref:`cadquery_concepts` there is also some kind of hierarchy in the +topological classes. A Wire is made of several edges which are themselves made of several vertices. +This means you can create geometry from the bottom up and have a lot of control over it. For example we can create a circular face like so :: - circle_wire = Wire.makeCircle(10, Vector(0, 0, 0), Vector(0, 0, 1)) - circular_face = Face.makeFromWires(circle_wire, []) +.. code-block:: python + + circle_wire = wire(circle(1)) + circular_face = face(circle_wire) + .. note:: In CadQuery (and OCCT) all the topological classes are shapes, the :class:`~cadquery.Shape` class is the most abstract topological class. - The topological class inherits :class:`~cadquery.Mixin3D` or :class:`~cadquery.Mixin1D` which provide aditional methods that are shared between the classes that inherits them. + The topological class inherits :class:`~cadquery.Mixin3D` or :class:`~cadquery.Mixin1D` which provide additional methods that are shared between the classes that inherits them. -The direct API as its name suggests doesn't provide a parent/children data structure, instead each method call directly returns an object of the specified topological type. -It is more verbose than the fluent API and more tedious to work with, but as it offers more flexibility (you can work with faces, which is something you can't do in the fluent API) -it is sometimes more convenient than the fluent API. The OCCT API ~~~~~~~~~~~~~ @@ -141,13 +144,13 @@ Going back and forth between the APIs While the 3 APIs provide 3 different layer of complexity and functionality you can mix the 3 layers as you wish. Below is presented the different ways you can interact with the different API layers. -------------------------- -Fluent API <=> Direct API -------------------------- +--------------------------------- +Fluent API <=> Free Function API +--------------------------------- .. currentmodule:: cadquery -Here are all the possibilities you have to get an object from the Direct API (i.e a topological object). +Here are all the possibilities you have to get an object from the free function API (i.e a topological object). You can end the Fluent API call chain and get the last object on the stack with :py:meth:`Workplane.val` alternatively you can get all the objects with :py:meth:`Workplane.vals` @@ -179,7 +182,7 @@ If you want to go the other way around i.e using objects from the topological AP You can pass a topological object as a base object to the :class:`~cadquery.Workplane` object. :: - solid_box = Solid.makeBox(10, 10, 10) + solid_box = box(10, 10, 10) part = Workplane(obj=solid_box) # And you can continue your modelling in the fluent API part = part.faces(">Z").circle(1).extrude(10) @@ -187,31 +190,31 @@ You can pass a topological object as a base object to the :class:`~cadquery.Work You can add a topological object as a new operation/step in the Fluent API call chain with :py:meth:`Workplane.newObject` :: - circle_wire = Wire.makeCircle(1, Vector(0, 0, 0), Vector(0, 0, 1)) + circle_wire = wire(circle(1.0)) box = Workplane().box(10, 10, 10).newObject([circle_wire]) # And you can continue modelling box = ( box.toPending().cutThruAll() ) # notice the call to `toPending` that is needed if you want to use it in a subsequent operation -------------------------- -Direct API <=> OCCT API -------------------------- +------------------------------ +Free Function API <=> OCCT API +------------------------------ -Every object of the Direct API stores its OCCT equivalent object in its :attr:`wrapped` attribute.: +Every object of the free function API stores its OCCT equivalent object in its :attr:`wrapped` attribute.: .. code-block:: - >>> box = Solid.makeBox(10,5,5) + >>> box = box(10,5,5) >>> print(type(box)) - >>> box = Solid.makeBox(10,5,5).wrapped + >>> box = box(10,5,5).wrapped >>> print(type(box)) -If you want to cast an OCCT object into a Direct API one you can just pass it as a parameter of the intended class: +If you want to cast an OCCT object into a free function API one you can just pass it as a parameter of the intended class: .. code-block:: diff --git a/doc/quickstart.rst b/doc/quickstart.rst index 3a57e6b5a..3097a2f76 100644 --- a/doc/quickstart.rst +++ b/doc/quickstart.rst @@ -312,6 +312,46 @@ Done! You just made a parametric, model that can generate pretty much any bearing pillow block with <30 lines of code. +Free function equivalent +======================== + +If the fluent style of modeling does not appeal to you, note that exactly the same object can be constructed using :ref:`freefuncapi`. + +.. code-block:: python + :linenos: + + from cadquery.func import * + + height = 60.0 + width = 80.0 + thickness = 10.0 + diameter = 22.0 + padding = 12.0 + r_hole = 1.2 + r_cbore = 2.2 + d_cbore = thickness / 3 + + # construct hole locations + hole_locs = rect(height - padding, width - padding).vertices() + + # make the base + basef = face( + rect(height, width), + circle(diameter / 2), + ) + + # extrude + base = extrude(basef, (0, 0, thickness)) + + # fillet + base = fillet(base, base.edges("|Z"), height / 10) + + # add a cbore + top = base.face(">Z") + base = prism(base, None, circle(r_cbore).moved(hole_locs).moved(top), -d_cbore, additive=False) + base = prism(base, None, circle(r_hole).moved(hole_locs).moved(top), None, additive=False) + + Want to learn more? ==================== diff --git a/doc/vis.rst b/doc/vis.rst index 06382c079..58e20fba9 100644 --- a/doc/vis.rst +++ b/doc/vis.rst @@ -75,7 +75,7 @@ Additionally it is possible to integrate with other libraries using VTK and disp .. image:: _static/show_vtk.PNG -Note that currently the show function is blocking. +Note that the :meth:`~cadquery.vis.show` function is blocking. Screenshots =========== @@ -172,6 +172,20 @@ Fine-grained control of the appearance of every item can be achieved using :meth .. image:: _static/show_styling.png +Non-blocking visualization +========================== + +For non-blocking visualization, one can use the :meth:`cadquery.fig.show` function from the :mod:`~cadquery.fig` module. +It relies on VTK/Trame and opens a web browser window. +To programmatically remove previously added shapes one can use the :meth:`cadquery.fig.clear` function. + + +.. image:: _static/fig.png + +This function is very handy for interactive work and debugging. Alternatively one can use the :class:`~cadquery.fig.Figure` +class for more fine-grained control. + + Jupyter/JupterLab ================= diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index d63711419..a56245f0c 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -47,9 +47,16 @@ edgeOn, faceOn, offset2D, + prism, + hollow, + chamfer2D, + fillet2D, + draft, + isSubshape, ) from cadquery.occ_impl.shapes import ( + History, _get_one_wire, _get_wires, _get, @@ -57,6 +64,8 @@ _get_edges, _adaptor_curve_to_edge, _shape_to_faces_shells, + _get_faces, + _combine_hist_dict, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -72,6 +81,12 @@ def tmpdir(tmp_path_factory): return tmp_path_factory.mktemp("free_functions") +@pytest.fixture +def box_shape(): + + return box(1, 1, 1) + + # %% test utils @@ -121,6 +136,13 @@ def test_utils(): with raises(ValueError): list(_get_edges(fill(circle(1)))) + r5 = _get_faces(plane(1, 1), rect(1, 1), circle(1.0), compound(circle(1.0))) + + assert len(list(r5)) == 4 + + with raises(ValueError): + list(_get_faces(vertex(0, 0, 0))) + def test_adaptor_curve_to_edge(): @@ -213,20 +235,20 @@ def test_sewing(): sh = b.remove(ftop) # regular local sewing - history1 = dict(ftop=ftop) + history1 = History() res1 = shell(sh.faces("not Z"), -0.1) + + # offset outwards + res2 = hollow(box_shape, box_shape.faces(">Z"), 0.1) + + assert res1.isValid() + assert res1.faces().size() == 6 + 5 + + assert res2.isValid() + assert res2.faces().size() == 6 + 5 + + +def test_prism(box_shape): + + ftop = box_shape.faces(">Z") + c = circle(0.2).moved(ftop) + + # additive prism + res1 = prism(box_shape, ftop, c, 0.1, (0, 0, 1)) + + assert res1.isValid() + assert res1.Volume() > box_shape.Volume() + assert res1.faces().size() == 6 + 2 + + # subtractive prism + res2 = prism(box_shape, ftop, c, -0.1, (0, 0, 1), False) + + assert res2.isValid() + assert res2.Volume() < box_shape.Volume() + assert res2.faces().size() == 6 + 2 + + # subtractive prism with tilt + res3 = prism(box_shape, None, c, box_shape.face("Z").innerWires()) == 1 + assert len(res4.face(">X").Center(), + ftop.Center() + Vector(0, 0, 1), + ) + ) + + res5 = prism( + box_shape, + None, + tri, + (box_shape.face("Y").extend(10)), + ) + + assert res5.isValid() + assert res5.faces("|Z").size() == 1 + + # additive prism from/to face using different overload + res6 = prism( + box_shape, + None, + tri, + (box_shape.face("Y").extend(10)), + (0, 1, 0), + ) + + assert res6.isValid() + assert res6.faces("|Z").size() == 1 + + +def test_prism_taper(box_shape): + + ftop = box_shape.faces(">Z") + c = circle(0.2).moved(ftop) + + # additive prism + res1 = prism(box_shape, ftop, c, 0.1) + + assert res1.isValid() + assert res1.Volume() > box_shape.Volume() + assert res1.faces().size() == 6 + 2 + + # additive prism with a taper + res2 = prism(box_shape, ftop, c, 0.1, 15) + + assert res2.isValid() + assert res2.faces().size() == 6 + 4 # NB: side face is split into 3 + assert res2.wire(">Z").Length() < c.Length() + + # subtractive prism + res3 = prism(box_shape / c, ftop, c, box_shape.face("Z")), + 5, + False, + ) + + assert res5.isValid() + assert res5.faces().size() == 6 + 2 * 3 + + +def test_draft(box_shape): + + fbot = box_shape.face("Z").Area() > fbot.Area() + + # direction specified explicitly + res2 = draft(box_shape, fbot, fside, (0, 0, 1), 5) + assert res2.face(">Z").Area() < fbot.Area() + + # raise on unsupported face type + s = extrude(face(ellipse(2, 1)), (0, 0, 1)) + + with raises(ValueError): + draft(s, s.face(">Z[-2]"), 5) + + with raises(ValueError): + draft(s, s.face(">Z[-2]"), (0, 0, 1), 5) + + def test_clean(): b1 = box(1, 1, 1) @@ -857,6 +1038,27 @@ def test_offset2D(): assert r3.edge().Length() == approx(seg.Length()) +def test_fillet2D(): + + f = plane(1, 1) + + res = fillet2D(f, f.vertices(), 0.1) + + assert res.isValid() + assert res.edges().size() == 8 + assert res.edges("%CIRCLE").size() == 4 + + +def test_chamfer2D(): + + f = plane(1, 1) + + res = chamfer2D(f, f.vertices(), 0.1) + + assert res.isValid() + assert res.edges().size() == 8 + + def test_sweep(): w1 = rect(1, 1) @@ -927,8 +1129,7 @@ def test_loft(): r4 = loft(w1, w2, w3, cap=True) # capped loft r5 = loft(w4, w5) # loft with open edges r6 = loft(f1, f2) # loft with faces - r7 = loft() # returns an empty compound - r8 = loft(compound(), compound()) # returns an empty compound + r7 = loft(compound(), compound()) # returns an empty compound assert_all_valid(r1, r2, r3, r4, r5, r6) @@ -940,7 +1141,6 @@ def test_loft(): assert len(r6.Faces()) == 16 assert len(r6.Faces()) == 16 assert not bool(r7) and isinstance(r7, Compound) - assert not bool(r8) and isinstance(r8, Compound) def test_loft_vertex(): @@ -1029,3 +1229,186 @@ def test_closest(): p1, p2 = closest(s1, s2) assert (p1 - p2).Length == approx(4) + + +# %% history +def test_history_bool(): + + b1 = box(1, 1, 1) + b2 = box(1, 0.5, 0.1) + + hist = History() + res = cut(b1, b2, history=hist, name="cut") + + assert hist[0] == hist["cut"] + assert b2.face("X")).size() == 2 + + with pytest.raises(KeyError): + hist["cut"].generated(b1.face(">Z")) + + res2 = imprint(res, b2, history=hist, name="imprint") + + op = hist["imprint"] + + assert isSubshape(op.images(b1.face(">Z")), res2.solid(">Z")) + + +def test_history_extrude(): + + hist = History() + f = plane(1, 1) + res = extrude(f, (0, 0, 1), history=hist) + + op = hist[-1] + + sides = op.generated(f.edges()) + top = op.last() + bot = op.first() + + assert isSubshape(top, res) + assert isSubshape(bot, res) + + assert top == res.face(">Z") + assert bot == res.face("Z") + assert bot == res.face("Z") + assert bot == res.face("Z") + + for el in side: + assert isSubshape(el, res) + + +def test_history_offset(): + + h = History() + f = plane(1, 1) + + offset(f, 0.1, both=True, history=h) + + op = h[-1] + + fs_offset = op.generated(f) + sides = op.generated(f.edges()) + + assert fs_offset.faces().size() == 2 + assert sides.edges().size() == 2 * 4 + + offset(f, 0.1, both=False, history=h) + + op = h[-1] + + fs_offset = op.generated(f) + sides = op.generated(f.edges()) + + assert fs_offset.faces().size() == 1 + assert sides.edges().size() == 4 + + +def test_comibine_hist_dict(): + + f = plane(1, 1) + v = vertex(0, 0, 0) + e = segment((0, 0), (0, 1)) + + d1 = {f: v} + d2 = {f: e} + + _combine_hist_dict(d1, d2) + + assert f in d1 + assert isinstance(d1[f], Compound) + assert v in d1[f] + assert e in d1[f] diff --git a/tests/test_shapes.py b/tests/test_shapes.py index 4fae3f107..bb34fdce5 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -468,3 +468,10 @@ def test_siblings(simple_box): assert level_1.size() + level_2.size() + level_3.size() == level_123.size() assert set(level_1) | set(level_2) | set(level_3) == set(level_123) + + +def test_set_ops(simple_box): + + assert (simple_box.faces(">Z") | simple_box.faces("Z") & simple_box.faces("