Tighten single-shape topo selectors to raise on non-unique results Issue #1278
Some checks are pending
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-24.04-arm, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-14, 3.14) (push) Waiting to run
tests / tests (macos-15-intel, 3.14) (push) Waiting to run
tests / tests (ubuntu-24.04-arm, 3.14) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.14) (push) Waiting to run
tests / tests (windows-latest, 3.14) (push) Waiting to run
Run type checking / typecheck (3.10) (push) Waiting to run
Run type checking / typecheck (3.14) (push) Waiting to run

This commit is contained in:
Roger Maitland 2026-05-04 20:40:52 -04:00
parent 4536d3d37a
commit fdb2e70f90
6 changed files with 126 additions and 81 deletions

View file

@ -569,10 +569,7 @@ class Builder(ABC, Generic[ShapeT]):
all_vertices = self.vertices(select)
vertex_count = len(all_vertices)
if vertex_count != 1:
warnings.warn(
f"Found {vertex_count} vertices, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one vertex, found {vertex_count}")
return all_vertices[0]
def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]:
@ -612,10 +609,7 @@ class Builder(ABC, Generic[ShapeT]):
all_edges = self.edges(select)
edge_count = len(all_edges)
if edge_count != 1:
warnings.warn(
f"Found {edge_count} edges, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one edge, found {edge_count}")
return all_edges[0]
def wires(self, select: Select = Select.ALL) -> ShapeList[Wire]:
@ -655,10 +649,7 @@ class Builder(ABC, Generic[ShapeT]):
all_wires = self.wires(select)
wire_count = len(all_wires)
if wire_count != 1:
warnings.warn(
f"Found {wire_count} wires, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one wire, found {wire_count}")
return all_wires[0]
def faces(self, select: Select = Select.ALL) -> ShapeList[Face]:
@ -698,10 +689,7 @@ class Builder(ABC, Generic[ShapeT]):
all_faces = self.faces(select)
face_count = len(all_faces)
if face_count != 1:
warnings.warn(
f"Found {face_count} faces, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one face, found {face_count}")
return all_faces[0]
def solids(self, select: Select = Select.ALL) -> ShapeList[Solid]:
@ -741,10 +729,7 @@ class Builder(ABC, Generic[ShapeT]):
all_solids = self.solids(select)
solid_count = len(all_solids)
if solid_count != 1:
warnings.warn(
f"Found {solid_count} solids, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one solid, found {solid_count}")
return all_solids[0]
def _shapes(

View file

@ -593,16 +593,13 @@ class Compound(Mixin3D[TopoDS_Compound]):
middle = self.bounding_box().center()
return middle
def compound(self) -> Compound | None:
def compound(self) -> Compound:
"""Return the Compound"""
shape_list = self.compounds()
entity_count = len(shape_list)
if entity_count > 1:
warnings.warn(
f"Found {entity_count} compounds, returning first",
stacklevel=2,
)
return shape_list[0] if shape_list else None
if entity_count != 1:
raise ValueError(f"Expected exactly one compound, found {entity_count}")
return shape_list[0]
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""

View file

@ -818,6 +818,36 @@ class Shape(NodeMixin, Generic[TOPODS]):
calc_function(obj.wrapped, properties)
return properties.Mass()
@overload
@staticmethod
def get_shape_list(shape: Shape, entity_type: Literal["Vertex"]) -> ShapeList[Vertex]: ...
@overload
@staticmethod
def get_shape_list(shape: Shape, entity_type: Literal["Edge"]) -> ShapeList[Edge]: ...
@overload
@staticmethod
def get_shape_list(shape: Shape, entity_type: Literal["Wire"]) -> ShapeList[Wire]: ...
@overload
@staticmethod
def get_shape_list(shape: Shape, entity_type: Literal["Face"]) -> ShapeList[Face]: ...
@overload
@staticmethod
def get_shape_list(shape: Shape, entity_type: Literal["Shell"]) -> ShapeList[Shell]: ...
@overload
@staticmethod
def get_shape_list(shape: Shape, entity_type: Literal["Solid"]) -> ShapeList[Solid]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Compound"]
) -> ShapeList[Compound]: ...
@staticmethod
def get_shape_list(
shape: Shape,
@ -835,25 +865,55 @@ class Shape(NodeMixin, Generic[TOPODS]):
item.topo_parent = shape if shape.topo_parent is None else shape.topo_parent
return shape_list
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Vertex"]) -> Vertex: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Edge"]) -> Edge: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Wire"]) -> Wire: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Face"]) -> Face: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Shell"]) -> Shell: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Solid"]) -> Solid: ...
@overload
@staticmethod
def get_single_shape(
shape: Shape, entity_type: Literal["Compound"]
) -> Compound: ...
@staticmethod
def get_single_shape(
shape: Shape,
entity_type: Literal[
"Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"
],
) -> Shape | None:
"""Helper to extract a single entity of a specific type from a shape,
with a warning if count != 1."""
) -> Shape:
"""Return the single entity of the requested type.
Raises:
ValueError: if the number of matching entities is not exactly one.
"""
shape_list = Shape.get_shape_list(shape, entity_type)
entity_count = len(shape_list)
if entity_count == 0:
return None
if entity_count > 1:
warnings.warn(
f"Found {entity_count} {entity_type.lower()}s, returning first",
stacklevel=3,
if entity_count != 1:
raise ValueError(
f"Expected exactly one {entity_type.lower()}, found {entity_count}"
)
return shape_list[0] if shape_list else None
return shape_list[0]
# ---- Instance Methods ----
@ -1104,9 +1164,9 @@ class Shape(NodeMixin, Generic[TOPODS]):
"""Points on two shapes where the distance between them is minimal"""
return self.distance_to_with_closest_points(other)[1:3]
def compound(self) -> Compound | None:
def compound(self) -> Compound:
"""Return the Compound"""
return None
return Shape.get_single_shape(self, "Compound")
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""
@ -1223,7 +1283,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
yield dist_calc.Value()
def edge(self) -> Edge | None:
def edge(self) -> Edge:
"""Return the Edge"""
return Shape.get_single_shape(self, "Edge")
@ -1240,7 +1300,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return []
return _topods_entities(self.wrapped, topo_type)
def face(self) -> Face | None:
def face(self) -> Face:
"""Return the Face"""
return Shape.get_single_shape(self, "Face")
@ -1788,7 +1848,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return self._apply_transform(transformation)
def shell(self) -> Shell | None:
def shell(self) -> Shell:
"""Return the Shell"""
return Shape.get_single_shape(self, "Shell")
@ -1846,7 +1906,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
result = Shape._show_tree(tree[0], show_center)
return result
def solid(self) -> Solid | None:
def solid(self) -> Solid:
"""Return the Solid"""
return Shape.get_single_shape(self, "Solid")
@ -2307,7 +2367,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
self_translated = self.moved(Location(TopLoc_Location(transformation)))
return self_translated
def wire(self) -> Wire | None:
def wire(self) -> Wire:
"""Return the Wire"""
return Shape.get_single_shape(self, "Wire")
@ -2501,7 +2561,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return shape_to_html(self)._repr_html_()
return repr(self)
def vertex(self) -> Vertex | None:
def vertex(self) -> Vertex:
"""Return the Vertex"""
return Shape.get_single_shape(self, "Vertex")
@ -2749,8 +2809,8 @@ class ShapeList(list[T]):
compounds = self.compounds()
compound_count = len(compounds)
if compound_count != 1:
warnings.warn(
f"Found {compound_count} compounds, returning first", stacklevel=2
raise ValueError(
f"Expected exactly one compound, found {compound_count}"
)
return compounds[0]
@ -2763,7 +2823,7 @@ class ShapeList(list[T]):
edges = self.edges()
edge_count = len(edges)
if edge_count != 1:
warnings.warn(f"Found {edge_count} edges, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one edge, found {edge_count}")
return edges[0]
def edges(self) -> ShapeList[Edge]:
@ -2775,8 +2835,7 @@ class ShapeList(list[T]):
faces = self.faces()
face_count = len(faces)
if face_count != 1:
msg = f"Found {face_count} faces, returning first"
warnings.warn(msg, stacklevel=2)
raise ValueError(f"Expected exactly one face, found {face_count}")
return faces[0]
def faces(self) -> ShapeList[Face]:
@ -3073,7 +3132,7 @@ class ShapeList(list[T]):
shells = self.shells()
shell_count = len(shells)
if shell_count != 1:
warnings.warn(f"Found {shell_count} shells, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one shell, found {shell_count}")
return shells[0]
def shells(self) -> ShapeList[Shell]:
@ -3085,7 +3144,7 @@ class ShapeList(list[T]):
solids = self.solids()
solid_count = len(solids)
if solid_count != 1:
warnings.warn(f"Found {solid_count} solids, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one solid, found {solid_count}")
return solids[0]
def solids(self) -> ShapeList[Solid]:
@ -3217,9 +3276,7 @@ class ShapeList(list[T]):
vertices = self.vertices()
vertex_count = len(vertices)
if vertex_count != 1:
warnings.warn(
f"Found {vertex_count} vertices, returning first", stacklevel=2
)
raise ValueError(f"Expected exactly one vertex, found {vertex_count}")
return vertices[0]
def vertices(self) -> ShapeList[Vertex]:
@ -3231,7 +3288,7 @@ class ShapeList(list[T]):
wires = self.wires()
wire_count = len(wires)
if wire_count != 1:
warnings.warn(f"Found {wire_count} wires, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one wire, found {wire_count}")
return wires[0]
def wires(self) -> ShapeList[Wire]:

View file

@ -127,7 +127,7 @@ class TestBuilder(unittest.TestCase):
with BuildLine() as l:
CenterArc((0, 0), 1, 0, 90)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one vertex"):
l.vertex()
def test_edge(self):
@ -138,7 +138,7 @@ class TestBuilder(unittest.TestCase):
with BuildSketch() as s:
Rectangle(1, 1)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
s.edge()
def test_wire(self):
@ -149,7 +149,7 @@ class TestBuilder(unittest.TestCase):
with BuildPart() as p:
Box(1, 1, 1)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
p.wire()
def test_face(self):
@ -160,7 +160,7 @@ class TestBuilder(unittest.TestCase):
with BuildPart() as p:
Box(1, 1, 1)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
p.face()
def test_solid(self):
@ -171,7 +171,7 @@ class TestBuilder(unittest.TestCase):
with BuildSketch():
Text("Two", 10)
extrude(amount=5)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
p.solid()
def test_workplanes_as_list(self):

View file

@ -395,44 +395,44 @@ class TestShape(unittest.TestCase):
def test_vertex(self):
v = Edge.make_circle(1).vertex()
self.assertTrue(isinstance(v, Vertex))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one vertex"):
Wire.make_rect(1, 1).vertex()
def test_edge(self):
e = Edge.make_circle(1).edge()
self.assertTrue(isinstance(e, Edge))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
Wire.make_rect(1, 1).edge()
def test_wire(self):
w = Wire.make_circle(1).wire()
self.assertTrue(isinstance(w, Wire))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
Solid.make_box(1, 1, 1).wire()
def test_compound(self):
c = Compound.make_text("hello", 10)
self.assertTrue(isinstance(c, Compound))
c2 = Compound.make_text("world", 10)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one compound"):
Compound(children=[c, c2]).compound()
def test_face(self):
f = Face.make_rect(1, 1)
self.assertTrue(isinstance(f, Face))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
Solid.make_box(1, 1, 1).face()
def test_shell(self):
s = Solid.make_sphere(1).shell()
self.assertTrue(isinstance(s, Shell))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one shell"):
extrude(Compound.make_text("two", 10), amount=5).shell()
def test_solid(self):
s = Solid.make_sphere(1).solid()
self.assertTrue(isinstance(s, Solid))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
def test_manifold(self):
@ -625,12 +625,18 @@ class TestShape(unittest.TestCase):
self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList())
self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList())
self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList())
self.assertIsNone(Vertex(1, 1, 1).edge())
self.assertIsNone(Vertex(1, 1, 1).wire())
self.assertIsNone(Vertex(1, 1, 1).face())
self.assertIsNone(Vertex(1, 1, 1).shell())
self.assertIsNone(Vertex(1, 1, 1).solid())
self.assertIsNone(Vertex(1, 1, 1).compound())
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
Vertex(1, 1, 1).edge()
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
Vertex(1, 1, 1).wire()
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
Vertex(1, 1, 1).face()
with self.assertRaisesRegex(ValueError, "Expected exactly one shell"):
Vertex(1, 1, 1).shell()
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
Vertex(1, 1, 1).solid()
with self.assertRaisesRegex(ValueError, "Expected exactly one compound"):
Vertex(1, 1, 1).compound()
def test_rotate(self):
line = Edge.make_line((0, 0), (1, 0))

View file

@ -323,7 +323,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Edge.make_circle(1)])
self.assertAlmostEqual(tuple(sl.vertex()), (1, 0, 0), 5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one vertex"):
sl.vertex()
self.assertEqual(len(Edge().vertices()), 0)
@ -336,7 +336,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Edge.make_circle(1)])
self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
sl.edge()
def test_wires(self):
@ -348,7 +348,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Wire.make_circle(1)])
self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
sl.wire()
def test_faces(self):
@ -362,7 +362,7 @@ class TestShapeList(unittest.TestCase):
)
self.assertAlmostEqual(sl.face().area, 2 * 1, 5)
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
sl.face()
def test_shells(self):
@ -374,7 +374,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5)
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one shell"):
sl.shell()
def test_solids(self):
@ -384,7 +384,7 @@ class TestShapeList(unittest.TestCase):
def test_solid(self):
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
sl.solid()
sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)])
self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5)
@ -396,7 +396,7 @@ class TestShapeList(unittest.TestCase):
def test_compound(self):
sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one compound"):
sl.compound()
sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)])
self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5)