diff --git a/cadquery/cq.py b/cadquery/cq.py index 3a762a0a7..f6ec428a5 100644 --- a/cadquery/cq.py +++ b/cadquery/cq.py @@ -47,6 +47,7 @@ Solid, Compound, wiresToFaces, + Shapes, ) from .occ_impl.exporters.svg import getSVG, exportSVG @@ -238,15 +239,13 @@ def _collectProperty(self, propName: str) -> List[CQObject]: """ Collects all of the values for propName, for all items on the stack. - OCCT objects do not implement id correctly, - so hashCode is used to ensure we don't add the same - object multiple times. One weird use case is that the stack could have a solid reference object on it. This is meant to be a reference to the most recently modified version of the context solid, whatever it is. """ - all = {} + rv: Dict[CQObject, Any] = {} # used as an ordered set + for o in self.objects: # tricky-- if an object is a compound of solids, @@ -257,14 +256,14 @@ def _collectProperty(self, propName: str) -> List[CQObject]: and isinstance(o, Solid) and o.ShapeType() == "Compound" ): - for i in getattr(o, "Compounds")(): - all[i.hashCode()] = i + for k in getattr(o, "Compounds")(): + rv[k] = None else: if hasattr(o, propName): - for i in getattr(o, propName)(): - all[i.hashCode()] = i + for k in getattr(o, propName)(): + rv[k] = None - return list(all.values()) + return list(rv.keys()) @overload def split(self: T, keepTop: bool = False, keepBottom: bool = False) -> T: @@ -471,7 +470,7 @@ def val(self) -> CQObject: """ return self.objects[0] if self.objects else self.plane.origin - def _getTagged(self, name: str) -> "Workplane": + def _getTagged(self: T, name: str) -> T: """ Search the parent chain for an object with tag == name. @@ -484,7 +483,7 @@ def _getTagged(self, name: str) -> "Workplane": if rv is None: raise ValueError(f"No Workplane object named {name} in chain") - return rv + return cast(T, rv) def _mergeTags(self: T, obj: "Workplane") -> T: """ @@ -829,20 +828,26 @@ def _selectObjects( solids,shells, and other similar selector methods. It is a useful extension point for plugin developers to make other selector methods. """ - self_as_workplane: Workplane = self - cq_obj = self._getTagged(tag) if tag else self_as_workplane + cq_obj = self._getTagged(tag) if tag else self + # A single list of all faces from all objects on the stack toReturn = cq_obj._collectProperty(objType) + return self.newObject(self._filter(toReturn, selector)) + + def _filter(self, objs, selector: Optional[Union[Selector, str]]): + selectorObj: Selector if selector: if isinstance(selector, str): selectorObj = StringSyntaxSelector(selector) else: selectorObj = selector - toReturn = selectorObj.filter(toReturn) + toReturn = selectorObj.filter(objs) + else: + toReturn = objs - return self.newObject(toReturn) + return toReturn def vertices( self: T, @@ -857,7 +862,7 @@ def vertices( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains the *distinct* vertices of *all* objects on the + :return: a CQ object whose stack contains the *distinct* vertices of *all* objects on the current stack, after being filtered by the selector, if provided If there are no vertices for any objects on the current stack, an empty CQ object @@ -891,7 +896,7 @@ def faces( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains all of the *distinct* faces of *all* objects on + :return: a CQ object whose stack contains all of the *distinct* faces of *all* objects on the current stack, filtered by the provided selector. If there are no faces for any objects on the current stack, an empty CQ object @@ -926,7 +931,7 @@ def edges( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains all of the *distinct* edges of *all* objects on + :return: a CQ object whose stack contains all of the *distinct* edges of *all* objects on the current stack, filtered by the provided selector. If there are no edges for any objects on the current stack, an empty CQ object is returned @@ -960,7 +965,7 @@ def wires( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains all of the *distinct* wires of *all* objects on + :return: a CQ object whose stack contains all of the *distinct* wires of *all* objects on the current stack, filtered by the provided selector. If there are no wires for any objects on the current stack, an empty CQ object is returned @@ -986,7 +991,7 @@ def solids( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains all of the *distinct* solids of *all* objects on + :return: a CQ object whose stack contains all of the *distinct* solids of *all* objects on the current stack, filtered by the provided selector. If there are no solids for any objects on the current stack, an empty CQ object is returned @@ -1015,7 +1020,7 @@ def shells( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains all of the *distinct* shells of *all* objects on + :return: a CQ object whose stack contains all of the *distinct* shells of *all* objects on the current stack, filtered by the provided selector. If there are no shells for any objects on the current stack, an empty CQ object is returned @@ -1038,7 +1043,7 @@ def compounds( :param selector: optional Selector object, or string selector expression (see :class:`StringSyntaxSelector`) :param tag: if set, search the tagged object instead of self - :return: a CQ object who's stack contains all of the *distinct* compounds of *all* objects on + :return: a CQ object whose stack contains all of the *distinct* compounds of *all* objects on the current stack, filtered by the provided selector. A compound contains multiple CAD primitives that resulted from a single operation, such as @@ -1046,6 +1051,43 @@ def compounds( """ return self._selectObjects("Compounds", selector, tag) + def ancestors(self: T, kind: Shapes, tag: Optional[str] = None) -> T: + """ + Select topological ancestors. + + :param kind: kind of ancestor, e.g. "Face" or "Edge" + :param tag: if set, search the tagged object instead of self + :return: a Workplane object whose stack contains selected ancestors. + + + """ + ctx_solid = self.findSolid() + objects = self._getTagged(tag).objects if tag else self.objects + + results = [ + el.ancestors(ctx_solid, kind) for el in objects if isinstance(el, Shape) + ] + + return self.newObject(set(el for res in results for el in res)) + + def siblings(self: T, kind: Shapes, level: int = 1, tag: Optional[str] = None) -> T: + """ + Select topological siblings. + + :param kind: kind of linking element, e.g. "Vertex" or "Edge" + :param level: level of relation - how many elements of kind are in the link + :param tag: if set, search the tagged object instead of self + :return: a Workplane object whose stack contains selected siblings. + + """ + ctx_solid = self.findSolid() + objects = self._getTagged(tag).objects if tag else self.objects + shapes = [el for el in objects if isinstance(el, Shape)] + + results = [el.siblings(ctx_solid, kind, level) for el in shapes] + + return self.newObject(set(el for res in results for el in res) - set(shapes)) + def toSvg(self, opts: Any = None) -> str: """ Returns svg text that represents the first item on the stack. diff --git a/cadquery/occ_impl/exporters/dxf.py b/cadquery/occ_impl/exporters/dxf.py index b8dbaa3b4..8300d3fc2 100644 --- a/cadquery/occ_impl/exporters/dxf.py +++ b/cadquery/occ_impl/exporters/dxf.py @@ -12,7 +12,7 @@ from ...cq import Face, Plane, Workplane from ...units import RAD2DEG -from ..shapes import Edge +from ..shapes import Edge, Shape, Compound from .utils import toCompound ApproxOptions = Literal["spline", "arc"] @@ -140,7 +140,8 @@ def add_shape(self, workplane: Workplane, layer: str = "") -> Self: if self.approx == "spline": edges = [ - e.toSplines() if e.geomType() == "BSPLINE" else e for e in shape.Edges() + e.toSplines() if e.geomType() == "BSPLINE" else e + for e in self._ordered_edges(shape) ] elif self.approx == "arc": @@ -148,10 +149,12 @@ def add_shape(self, workplane: Workplane, layer: str = "") -> Self: # this is needed to handle free wires for el in shape.Wires(): - edges.extend(Face.makeFromWires(el).toArcs(self.tolerance).Edges()) + edges.extend( + self._ordered_edges(Face.makeFromWires(el).toArcs(self.tolerance)) + ) else: - edges = shape.Edges() + edges = self._ordered_edges(shape) for edge in edges: converter = self._DISPATCH_MAP.get(edge.geomType(), None) @@ -173,6 +176,21 @@ def add_shape(self, workplane: Workplane, layer: str = "") -> Self: return self + @staticmethod + def _ordered_edges(s: Shape) -> List[Edge]: + + rv: List[Edge] = [] + + # iterate over wires and then edges + for w in s.Wires(): + rv.extend(w) + + # add free edges + if isinstance(s, Compound): + rv.extend(e for e in s if isinstance(e, Edge)) + + return rv + @staticmethod def _dxf_line(edge: Edge) -> DxfEntityAttributes: """Convert a Line to DXF entity attributes. diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index c2c2dc197..99085f998 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -93,7 +93,7 @@ ) from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter -from OCP.TopExp import TopExp_Explorer # Topology explorer +from OCP.TopExp import TopExp_Explorer, TopExp # Topology explorer # used for getting underlying geometry -- is this equivalent to brep adaptor? from OCP.BRep import BRep_Tool, BRep_Builder @@ -162,7 +162,6 @@ TopTools_MapOfShape, ) -from OCP.TopExp import TopExp from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Solid, ShapeFix_Face @@ -173,7 +172,7 @@ from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain -from OCP.BRepTools import BRepTools +from OCP.BRepTools import BRepTools, BRepTools_WireExplorer from OCP.LocOpe import LocOpe_DPrism @@ -300,6 +299,14 @@ ta.TopAbs_COMPOUND: "Compound", } +ancestors_LUT = { + "Vertex": ta.TopAbs_EDGE, + "Edge": ta.TopAbs_WIRE, + "Wire": ta.TopAbs_FACE, + "Face": ta.TopAbs_SHELL, + "Shell": ta.TopAbs_SOLID, +} + geom_LUT_FACE = { ga.GeomAbs_Plane: "PLANE", ga.GeomAbs_Cylinder: "CYLINDER", @@ -329,6 +336,7 @@ Shapes = Literal[ "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "CompSolid", "Compound" ] + Geoms = Literal[ "Vertex", "Wire", @@ -544,9 +552,9 @@ def geomType(self) -> Geoms: The return values depend on the type of the shape: | Vertex: always 'Vertex' - | Edge: LINE, CIRCLE, ELLIPSE, HYPERBOLA, PARABOLA, BEZIER, + | Edge: LINE, CIRCLE, ELLIPSE, HYPERBOLA, PARABOLA, BEZIER, | BSPLINE, OFFSET, OTHER - | Face: PLANE, CYLINDER, CONE, SPHERE, TORUS, BEZIER, BSPLINE, + | Face: PLANE, CYLINDER, CONE, SPHERE, TORUS, BEZIER, BSPLINE, | REVOLUTION, EXTRUSION, OFFSET, OTHER | Solid: 'Solid' | Shell: 'Shell' @@ -1058,7 +1066,7 @@ def _bool_op( def cut(self, *toCut: "Shape", tol: Optional[float] = None) -> "Shape": """ Remove the positional arguments from this Shape. - + :param tol: Fuzzy mode tolerance """ @@ -1093,7 +1101,7 @@ def fuse( def intersect(self, *toIntersect: "Shape", tol: Optional[float] = None) -> "Shape": """ Intersection of the positional arguments and this Shape. - + :param tol: Fuzzy mode tolerance """ @@ -1355,6 +1363,66 @@ def _repr_javascript_(self): return display(self)._repr_javascript_() + def __iter__(self) -> Iterator["Shape"]: + """ + Iterate over subshapes. + + """ + + it = TopoDS_Iterator(self.wrapped) + + while it.More(): + yield Shape.cast(it.Value()) + it.Next() + + def ancestors(self, shape: "Shape", kind: Shapes) -> "Compound": + """ + Iterate over ancestors, i.e. shapes of same kind within shape that contain self. + + """ + + shape_map = TopTools_IndexedDataMapOfShapeListOfShape() + + TopExp.MapShapesAndAncestors_s( + shape.wrapped, shapetype(self.wrapped), inverse_shape_LUT[kind], shape_map + ) + + return Compound.makeCompound( + Shape.cast(s) for s in shape_map.FindFromKey(self.wrapped) + ) + + def siblings(self, shape: "Shape", kind: Shapes, level: int = 1) -> "Compound": + """ + Iterate over siblings, i.e. shapes within shape that share subshapes of kind with self. + + """ + + shape_map = TopTools_IndexedDataMapOfShapeListOfShape() + TopExp.MapShapesAndAncestors_s( + shape.wrapped, inverse_shape_LUT[kind], shapetype(self.wrapped), shape_map, + ) + exclude = TopTools_MapOfShape() + + def _siblings(shapes, level): + + rv = set() + + for s in shapes: + exclude.Add(s.wrapped) + + for s in shapes: + + rv.update( + Shape.cast(el) + for child in s._entities(kind) + for el in shape_map.FindFromKey(child) + if not exclude.Contains(el) + ) + + return rv if level == 1 else _siblings(rv, level - 1) + + return Compound.makeCompound(_siblings([self], level)) + class ShapeProtocol(Protocol): @property @@ -2254,6 +2322,18 @@ def chamfer2D(self, d: float, vertices: Iterable[Vertex]) -> "Wire": return f.chamfer2D(d, vertices).outerWire() + def __iter__(self) -> Iterator[Edge]: + """ + Iterate over edges in an ordered way. + + """ + + exp = BRepTools_WireExplorer(self.wrapped) + + while exp.Current(): + yield Edge(exp.Current()) + exp.Next() + class Face(Shape): """ @@ -3579,18 +3659,6 @@ def makeText( return rv - def __iter__(self) -> Iterator[Shape]: - """ - Iterate over subshapes. - - """ - - it = TopoDS_Iterator(self.wrapped) - - while it.More(): - yield Shape.cast(it.Value()) - it.Next() - def __bool__(self) -> bool: """ Check if empty. @@ -3601,7 +3669,7 @@ def __bool__(self) -> bool: def cut(self, *toCut: "Shape", tol: Optional[float] = None) -> "Compound": """ Remove the positional arguments from this Shape. - + :param tol: Fuzzy mode tolerance """ @@ -3642,7 +3710,7 @@ def intersect( ) -> "Compound": """ Intersection of the positional arguments and this Shape. - + :param tol: Fuzzy mode tolerance """ @@ -3653,6 +3721,59 @@ def intersect( return tcast(Compound, self._bool_op(self, toIntersect, intersect_op)) + def ancestors(self, shape: "Shape", kind: Shapes) -> "Compound": + """ + Iterate over ancestors, i.e. shapes of same kind within shape that contain elements of self. + + """ + + shape_map = TopTools_IndexedDataMapOfShapeListOfShape() + shapetypes = set(shapetype(ch.wrapped) for ch in self) + + for t in shapetypes: + TopExp.MapShapesAndAncestors_s( + shape.wrapped, t, inverse_shape_LUT[kind], shape_map + ) + + return Compound.makeCompound( + Shape.cast(a) for s in self for a in shape_map.FindFromKey(s.wrapped) + ) + + def siblings(self, shape: "Shape", kind: Shapes, level: int = 1) -> "Compound": + """ + Iterate over siblings, i.e. shapes within shape that share subshapes of kind with the elements of self. + + """ + + shape_map = TopTools_IndexedDataMapOfShapeListOfShape() + shapetypes = set(shapetype(ch.wrapped) for ch in self) + + for t in shapetypes: + TopExp.MapShapesAndAncestors_s( + shape.wrapped, inverse_shape_LUT[kind], t, shape_map, + ) + + exclude = TopTools_MapOfShape() + + def _siblings(shapes, level): + + rv = set() + + for s in shapes: + exclude.Add(s.wrapped) + + for s in shapes: + rv.update( + Shape.cast(el) + for child in s._entities(kind) + for el in shape_map.FindFromKey(child) + if not exclude.Contains(el) + ) + + return rv if level == 1 else _siblings(rv, level - 1) + + return Compound.makeCompound(_siblings(self, level)) + def sortWiresByBuildOrder(wireList: List[Wire]) -> List[List[Wire]]: """Tries to determine how wires should be combined into faces. diff --git a/cadquery/sketch.py b/cadquery/sketch.py index adc874a06..1b0bd2888 100644 --- a/cadquery/sketch.py +++ b/cadquery/sketch.py @@ -154,7 +154,7 @@ def face( res = Face.makeFromWires(b) elif isinstance(b, (Sketch, Compound)): res = b - elif isinstance(b, Iterable): + elif isinstance(b, Iterable) and not isinstance(b, Shape): wires = edgesToWires(tcast(Iterable[Edge], b)) res = Face.makeFromWires(*(wires[0], wires[1:])) else: diff --git a/doc/selectors.rst b/doc/selectors.rst index 44cc187a5..ae51cee1a 100644 --- a/doc/selectors.rst +++ b/doc/selectors.rst @@ -1,7 +1,7 @@ .. _selector_reference: -String Selectors Reference -============================= +Selectors Reference +=================== CadQuery selector strings allow filtering various types of object lists. Most commonly, Edges, Faces, and Vertices are @@ -143,3 +143,30 @@ It is possible to use user defined vectors as a basis for the selectors. For exa # chamfer only one edge result = result.edges(">(-1, 1, 0)").chamfer(1) + + +Topological Selectors +--------------------- + +Is is also possible to use topological relations to select objects. Currently +the following methods are supported: + + * :py:meth:`cadquery.Workplane.ancestors` + * :py:meth:`cadquery.Workplane.siblings` + +Ancestors allows to select all objects containing currently selected object. + +.. cadquery:: + + result = cq.Workplane("XY").box(10, 10, 10).faces(">Z").edges("Z") + + result = result.siblings("Edge") diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index 6ffe35426..428c764d0 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -877,14 +877,14 @@ def ellipsePoints(r1, r2, a): r1, r2, startAtCurrent=False, angle1=a1, angle2=a2, rotation_angle=ra ) ) - start = ellipseArc1.vertices().objects[0] - end = ellipseArc1.vertices().objects[1] + start = ellipseArc1.val().startPoint() + end = ellipseArc1.val().endPoint() self.assertTupleAlmostEquals( - (start.X, start.Y), (p0[0] + sx_rot, p0[1] + sy_rot), 3 + (start.x, start.y), (p0[0] + sx_rot, p0[1] + sy_rot), 3 ) self.assertTupleAlmostEquals( - (end.X, end.Y), (p0[0] + ex_rot, p0[1] + ey_rot), 3 + (end.x, end.y), (p0[0] + ex_rot, p0[1] + ey_rot), 3 ) # startAtCurrent=True, sense = 1 @@ -895,14 +895,14 @@ def ellipsePoints(r1, r2, a): r1, r2, startAtCurrent=True, angle1=a1, angle2=a2, rotation_angle=ra ) ) - start = ellipseArc2.vertices().objects[0] - end = ellipseArc2.vertices().objects[1] + start = ellipseArc2.val().startPoint() + end = ellipseArc2.val().endPoint() self.assertTupleAlmostEquals( - (start.X, start.Y), (p0[0] + sx_rot - sx_rot, p0[1] + sy_rot - sy_rot), 3 + (start.x, start.y), (p0[0] + sx_rot - sx_rot, p0[1] + sy_rot - sy_rot), 3 ) self.assertTupleAlmostEquals( - (end.X, end.Y), (p0[0] + ex_rot - sx_rot, p0[1] + ey_rot - sy_rot), 3 + (end.x, end.y), (p0[0] + ex_rot - sx_rot, p0[1] + ey_rot - sy_rot), 3 ) # startAtCurrent=False, sense = -1 @@ -919,15 +919,15 @@ def ellipsePoints(r1, r2, a): sense=-1, ) ) - start = ellipseArc3.vertices().objects[0] - end = ellipseArc3.vertices().objects[1] + start = ellipseArc3.val().startPoint() + end = ellipseArc3.val().endPoint() # swap start and end points for comparison due to different sense self.assertTupleAlmostEquals( - (start.X, start.Y), (p0[0] + ex_rot, p0[1] + ey_rot), 3 + (start.x, start.y), (p0[0] + ex_rot, p0[1] + ey_rot), 3 ) self.assertTupleAlmostEquals( - (end.X, end.Y), (p0[0] + sx_rot, p0[1] + sy_rot), 3 + (end.x, end.y), (p0[0] + sx_rot, p0[1] + sy_rot), 3 ) # startAtCurrent=True, sense = -1 @@ -948,15 +948,15 @@ def ellipsePoints(r1, r2, a): self.assertEqual(len(ellipseArc4.ctx.pendingWires), 1) - start = ellipseArc4.vertices().objects[0] - end = ellipseArc4.vertices().objects[1] + start = ellipseArc4.val().startPoint() + end = ellipseArc4.val().endPoint() # swap start and end points for comparison due to different sense self.assertTupleAlmostEquals( - (start.X, start.Y), (p0[0] + ex_rot - ex_rot, p0[1] + ey_rot - ey_rot), 3 + (start.x, start.y), (p0[0] + ex_rot - ex_rot, p0[1] + ey_rot - ey_rot), 3 ) self.assertTupleAlmostEquals( - (end.X, end.Y), (p0[0] + sx_rot - ex_rot, p0[1] + sy_rot - ey_rot), 3 + (end.x, end.y), (p0[0] + sx_rot - ex_rot, p0[1] + sy_rot - ey_rot), 3 ) def testEllipseArcsClockwise(self): @@ -5352,33 +5352,40 @@ def circumradius(n, a): a = 1 # Test triangle - vs = Workplane("XY").polygon(3, 2 * a, circumscribed=True).vertices().vals() + w = Workplane("XY").polygon(3, 2 * a, circumscribed=True) + vs = w.vertices().vals() + self.assertEqual(3, len(vs)) + R = circumradius(3, a) - self.assertEqual( - vs[0].toTuple(), approx((a, a * math.tan(math.radians(60)), 0)) - ) - self.assertEqual(vs[1].toTuple(), approx((-R, 0, 0))) - self.assertEqual( - vs[2].toTuple(), approx((a, -a * math.tan(math.radians(60)), 0)) - ) + + vs0 = w.vertices(">X").vertices(">Y").val() + vs1 = w.vertices("X").vertices("X").vertices(">Y").val() + vs1 = w.vertices("Y").val() + vs2 = w.vertices("X").vertices("Z").val() + + # check ancestors + res1 = list(s.Edges()[0].ancestors(s, "Face")) + assert len(res1) == 2 + assert w.faces(">Z").edges(">X").ancestors("Face").size() == 2 + assert w.faces(">Z").edges(">X or Z").siblings("Edge").size() == 4 + + res3 = list(f.siblings(s, "Edge", 2)) + assert len(res3) == 1 + assert w.faces(">Z").siblings("Edge", 2).size() == 1 + + res4 = list(f.siblings(s, "Edge").siblings(s, "Edge")) + assert len(res4) == 2 + assert w.faces(">Z").siblings("Edge").siblings("Edge").size() == 2 + + # check regular iterator + res5 = list(s) + assert len(res5) == 1 + assert isinstance(res5[0], Shell) + + # check ordered iteration for wires + w = Workplane().polygon(5, 1).val() + edges = list(w) + + for e1, e2 in zip(edges, edges[1:]): + assert (e2.startPoint() - e1.endPoint()).Length == approx(0.0) + + # check ancestors on a compound + w = Workplane().pushPoints([(0, 0), (2, 0)]).box(1, 1, 1) + c = w.val() + fs = w.faces(">Z").combine().val() + + res6 = list(fs.ancestors(c, "Solid")) + assert len(res6) == 2 + + res7 = list(fs.siblings(c, "Edge", 2)) + assert len(res7) == 2