From 560a5369b718242d7b8d547e1a00e4519e40aac1 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 27 May 2025 14:38:21 -0400 Subject: [PATCH] Convert Shape methods to properties: is_null, is_valid, shape_type --- src/build123d/operations_part.py | 4 +- src/build123d/topology/shape_core.py | 61 +++++++++------------ src/build123d/topology/three_d.py | 8 +-- src/build123d/topology/two_d.py | 8 +-- tests/test_build_generic.py | 4 +- tests/test_direct_api/test_compound.py | 4 +- tests/test_direct_api/test_face.py | 10 ++-- tests/test_direct_api/test_import_export.py | 4 +- tests/test_direct_api/test_mixin3_d.py | 12 ++-- tests/test_direct_api/test_shape.py | 12 ++-- tests/test_direct_api/test_shells.py | 8 +-- tests/test_direct_api/test_solid.py | 2 +- tests/test_direct_api/test_wire.py | 16 +++--- tests/test_mesher.py | 4 +- 14 files changed, 75 insertions(+), 82 deletions(-) diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index 2fc2126..7a196f7 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -304,11 +304,11 @@ def loft( new_solid = Solid.make_loft(loft_wires, ruled) # Try to recover an invalid loft - if not new_solid.is_valid(): + if not new_solid.is_valid: new_solid = Solid(Shell(new_solid.faces() + section_list)) if clean: new_solid = new_solid.clean() - if not new_solid.is_valid(): + if not new_solid.is_valid: raise RuntimeError("Failed to create valid loft") if context is not None: diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index fda2700..52377b6 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -422,6 +422,14 @@ class Shape(NodeMixin, Generic[TOPODS]): return True + @property + def is_null(self) -> bool: + """Returns true if this shape is null. In other words, it references no + underlying shape with the potential to be given a location and an + orientation. + """ + return self.wrapped is None or self.wrapped.IsNull() + @property def is_planar_face(self) -> bool: """Is the shape a planar face even though its geom_type may not be PLANE""" @@ -431,6 +439,18 @@ class Shape(NodeMixin, Generic[TOPODS]): is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE) return is_face_planar.IsPlanar() + @property + def is_valid(self) -> bool: + """Returns True if no defect is detected on the shape S or any of its + subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full + description of what is checked. + """ + if self.wrapped is None: + return True + chk = BRepCheck_Analyzer(self.wrapped) + chk.SetParallel(True) + return chk.IsValid() + @property def location(self) -> Location | None: """Get this Shape's Location""" @@ -543,6 +563,11 @@ class Shape(NodeMixin, Generic[TOPODS]): (Vector(principal_props.ThirdAxisOfInertia()), principal_moments[2]), ] + @property + def shape_type(self) -> Shapes: + """Return the shape type string for this class""" + return tcast(Shapes, Shape.shape_LUT[shapetype(self.wrapped)]) + @property def static_moments(self) -> tuple[float, float, float]: """ @@ -1188,7 +1213,7 @@ class Shape(NodeMixin, Generic[TOPODS]): """fix - try to fix shape if not valid""" if self.wrapped is None: return self - if not self.is_valid(): + if not self.is_valid: shape_copy: Shape = copy.deepcopy(self, None) shape_copy.wrapped = tcast(TOPODS, fix(self.wrapped)) @@ -1332,7 +1357,7 @@ class Shape(NodeMixin, Generic[TOPODS]): return None if ( not isinstance(shape_intersections, ShapeList) - and shape_intersections.is_null() + and shape_intersections.is_null ): return None return shape_intersections @@ -1352,18 +1377,6 @@ class Shape(NodeMixin, Generic[TOPODS]): return False return self.wrapped.IsEqual(other.wrapped) - def is_null(self) -> bool: - """Returns true if this shape is null. In other words, it references no - underlying shape with the potential to be given a location and an - orientation. - - Args: - - Returns: - - """ - return self.wrapped is None or self.wrapped.IsNull() - def is_same(self, other: Shape) -> bool: """Returns True if other and this shape are same, i.e. if they share the same TShape with the same Locations. Orientations may differ. Also see @@ -1379,22 +1392,6 @@ class Shape(NodeMixin, Generic[TOPODS]): return False return self.wrapped.IsSame(other.wrapped) - def is_valid(self) -> bool: - """Returns True if no defect is detected on the shape S or any of its - subshapes. See the OCCT docs on BRepCheck_Analyzer::IsValid for a full - description of what is checked. - - Args: - - Returns: - - """ - if self.wrapped is None: - return True - chk = BRepCheck_Analyzer(self.wrapped) - chk.SetParallel(True) - return chk.IsValid() - def locate(self, loc: Location) -> Self: """Apply a location in absolute sense to self @@ -1677,10 +1674,6 @@ class Shape(NodeMixin, Generic[TOPODS]): return self._apply_transform(transformation) - def shape_type(self) -> Shapes: - """Return the shape type string for this class""" - return tcast(Shapes, Shape.shape_LUT[shapetype(self.wrapped)]) - def shell(self) -> Shell | None: """Return the Shell""" return None diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index b0d86f4..e4131ce 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -259,7 +259,7 @@ class Mixin3D(Shape): try: new_shape = self.__class__(chamfer_builder.Shape()) - if not new_shape.is_valid(): + if not new_shape.is_valid: raise Standard_Failure except (StdFail_NotDone, Standard_Failure) as err: raise ValueError( @@ -343,7 +343,7 @@ class Mixin3D(Shape): try: new_shape = self.__class__(fillet_builder.Shape()) - if not new_shape.is_valid(): + if not new_shape.is_valid: raise Standard_Failure except (StdFail_NotDone, Standard_Failure) as err: raise ValueError( @@ -485,7 +485,7 @@ class Mixin3D(Shape): # Do these numbers work? - if not try with the smaller window try: new_shape = self.__class__(fillet_builder.Shape()) - if not new_shape.is_valid(): + if not new_shape.is_valid: raise fillet_exception except fillet_exception: return __max_fillet(window_min, window_mid, current_iteration + 1) @@ -499,7 +499,7 @@ class Mixin3D(Shape): ) return return_value - if not self.is_valid(): + if not self.is_valid: raise ValueError("Invalid Shape") native_edges = [e.wrapped for e in edge_list] diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 4bfb5e0..9aad804 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -374,7 +374,7 @@ class Mixin2D(Shape): raise RuntimeError( f"Length error of {length_error:.6f} exceeds tolerance {tolerance}" ) - if not wrapped_edge.is_valid(): + if not wrapped_edge.is_valid: raise RuntimeError("Wrapped edge is invalid") if not snap_to_face: @@ -1016,7 +1016,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) from err surface_face = surface_face.fix() - if not surface_face.is_valid(): + if not surface_face.is_valid: raise RuntimeError("non planar face is invalid") return surface_face @@ -1443,7 +1443,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): ) from err surface_face = surface_face.fix() - # if not surface_face.is_valid(): + # if not surface_face.is_valid: # raise RuntimeError("non planar face is invalid") return surface_face @@ -2021,7 +2021,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]): # # Part 5: Validate # - if not wrapped_wire.is_valid(): + if not wrapped_wire.is_valid: raise RuntimeError("wrapped wire is not valid") return wrapped_wire diff --git a/tests/test_build_generic.py b/tests/test_build_generic.py index d3248e0..ac02ca5 100644 --- a/tests/test_build_generic.py +++ b/tests/test_build_generic.py @@ -522,7 +522,7 @@ class OffsetTests(unittest.TestCase): def test_face_offset_with_holes(self): sk = Rectangle(100, 100) - GridLocations(80, 80, 2, 2) * Circle(5) sk2 = offset(sk, -5) - self.assertTrue(sk2.face().is_valid()) + self.assertTrue(sk2.face().is_valid) self.assertLess(sk2.area, sk.area) self.assertEqual(len(sk2), 1) @@ -881,7 +881,7 @@ class TestSweep(unittest.TestCase): Rectangle(2 * lip, 2 * lip, align=(Align.CENTER, Align.CENTER)) sweep(sections=sk2.sketch, path=topedgs, mode=Mode.SUBTRACT) - self.assertTrue(p.part.is_valid()) + self.assertTrue(p.part.is_valid) def test_path_error(self): e1 = Edge.make_line((0, 0), (1, 0)) diff --git a/tests/test_direct_api/test_compound.py b/tests/test_direct_api/test_compound.py index e4eb6f2..9f93460 100644 --- a/tests/test_direct_api/test_compound.py +++ b/tests/test_direct_api/test_compound.py @@ -51,10 +51,10 @@ class TestCompound(unittest.TestCase): box1 = Solid.make_box(1, 1, 1) box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0))) combined = Compound([box1]).fuse(box2, glue=True) - self.assertTrue(combined.is_valid()) + self.assertTrue(combined.is_valid) self.assertAlmostEqual(combined.volume, 2, 5) fuzzy = Compound([box1]).fuse(box2, tol=1e-6) - self.assertTrue(fuzzy.is_valid()) + self.assertTrue(fuzzy.is_valid) self.assertAlmostEqual(fuzzy.volume, 2, 5) def test_remove(self): diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py index 162eda2..6e57a55 100644 --- a/tests/test_direct_api/test_face.py +++ b/tests/test_direct_api/test_face.py @@ -65,7 +65,7 @@ class TestFace(unittest.TestCase): bottom_edge = Edge.make_circle(radius=1, end_angle=90) top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90) curved = Face.make_surface_from_curves(bottom_edge, top_edge) - self.assertTrue(curved.is_valid()) + self.assertTrue(curved.is_valid) self.assertAlmostEqual(curved.area, math.pi / 2, 5) self.assertAlmostEqual( curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 @@ -74,7 +74,7 @@ class TestFace(unittest.TestCase): bottom_wire = Wire.make_circle(1) top_wire = Wire.make_circle(1, Plane((0, 0, 1))) curved = Face.make_surface_from_curves(bottom_wire, top_wire) - self.assertTrue(curved.is_valid()) + self.assertTrue(curved.is_valid) self.assertAlmostEqual(curved.area, 2 * math.pi, 5) def test_center(self): @@ -303,7 +303,7 @@ class TestFace(unittest.TestCase): for j in range(4 - i % 2) ] cylinder_walls_with_holes = cylinder_wall.make_holes(projected_wires) - self.assertTrue(cylinder_walls_with_holes.is_valid()) + self.assertTrue(cylinder_walls_with_holes.is_valid) self.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area) def test_is_inside(self): @@ -377,7 +377,7 @@ class TestFace(unittest.TestCase): surface_points=[Vector(0, 0, -5)], interior_wires=[hole], ) - self.assertTrue(surface.is_valid()) + self.assertTrue(surface.is_valid) self.assertEqual(surface.geom_type, GeomType.BSPLINE) bbox = surface.bounding_box() self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -5.113393280136395), 5) @@ -877,7 +877,7 @@ class TestFace(unittest.TestCase): with self.assertRaises(RuntimeError): surface.wrap(star.outer_wire(), target) - @patch.object(Wire, "is_valid", return_value=False) + @patch.object(Wire, "is_valid", new_callable=PropertyMock, return_value=False) def test_wrap_invalid_wire(self, mock_is_valid): surface = Cone(5, 2, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0] target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0)) diff --git a/tests/test_direct_api/test_import_export.py b/tests/test_direct_api/test_import_export.py index 9b22dd5..8f9f29e 100644 --- a/tests/test_direct_api/test_import_export.py +++ b/tests/test_direct_api/test_import_export.py @@ -40,11 +40,11 @@ class TestImportExport(unittest.TestCase): original_box = Solid.make_box(1, 1, 1) export_step(original_box, "test_box.step") step_box = import_step("test_box.step") - self.assertTrue(step_box.is_valid()) + self.assertTrue(step_box.is_valid) self.assertAlmostEqual(step_box.volume, 1, 5) export_brep(step_box, "test_box.brep") brep_box = import_brep("test_box.brep") - self.assertTrue(brep_box.is_valid()) + self.assertTrue(brep_box.is_valid) self.assertAlmostEqual(brep_box.volume, 1, 5) os.remove("test_box.step") os.remove("test_box.brep") diff --git a/tests/test_direct_api/test_mixin3_d.py b/tests/test_direct_api/test_mixin3_d.py index 5e04e7b..1bee8fc 100644 --- a/tests/test_direct_api/test_mixin3_d.py +++ b/tests/test_direct_api/test_mixin3_d.py @@ -27,7 +27,7 @@ license: """ import unittest -from unittest.mock import patch +from unittest.mock import patch, PropertyMock from build123d.build_enums import CenterOf, Kind from build123d.geometry import Axis, Plane @@ -67,7 +67,7 @@ class TestMixin3D(unittest.TestCase): face = box.faces().sort_by(Axis.Z)[0] self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face) - @patch.object(Shape, "is_valid", return_value=False) + @patch.object(Shape, "is_valid", new_callable=PropertyMock, return_value=False) def test_chamfer_invalid_shape_raises_error(self, mock_is_valid): box = Solid.make_box(1, 1, 1) @@ -111,7 +111,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [f], additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) # face with depth @@ -119,7 +119,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [f], depth=0.5, thru_all=False, additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) # face until @@ -128,7 +128,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [f], up_to_face=limit, thru_all=False, additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5) # wire @@ -136,7 +136,7 @@ class TestMixin3D(unittest.TestCase): d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism( None, [w], additive=False ) - self.assertTrue(d.is_valid()) + self.assertTrue(d.is_valid) self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5) def test_center(self): diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py index 76622ac..f159fbb 100644 --- a/tests/test_direct_api/test_shape.py +++ b/tests/test_direct_api/test_shape.py @@ -29,7 +29,7 @@ license: # Always equal to any other object, to test that __eq__ cooperation is working import unittest from random import uniform -from unittest.mock import patch +from unittest.mock import patch, PropertyMock import numpy as np from build123d.build_enums import CenterOf, Keep @@ -100,7 +100,7 @@ class TestShape(unittest.TestCase): Shape.combined_center(objs, center_of=CenterOf.GEOMETRY) def test_shape_type(self): - self.assertEqual(Vertex().shape_type(), "Vertex") + self.assertEqual(Vertex().shape_type, "Vertex") def test_scale(self): self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5) @@ -109,10 +109,10 @@ class TestShape(unittest.TestCase): box1 = Solid.make_box(1, 1, 1) box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0))) combined = box1.fuse(box2, glue=True) - self.assertTrue(combined.is_valid()) + self.assertTrue(combined.is_valid) self.assertAlmostEqual(combined.volume, 2, 5) fuzzy = box1.fuse(box2, tol=1e-6) - self.assertTrue(fuzzy.is_valid()) + self.assertTrue(fuzzy.is_valid) self.assertAlmostEqual(fuzzy.volume, 2, 5) def test_faces_intersected_by_axis(self): @@ -245,7 +245,7 @@ class TestShape(unittest.TestCase): # invalid_object = box.fillet(0.75, box.edges()) # invalid_object.max_fillet(invalid_object.edges()) - @patch.object(Shape, "is_valid", return_value=False) + @patch.object(Shape, "is_valid", new_callable=PropertyMock, return_value=False) def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid): box = Solid.make_box(1, 1, 1) @@ -526,7 +526,7 @@ class TestShape(unittest.TestCase): self.assertEqual(hash(empty), 0) self.assertFalse(empty.is_same(Solid())) self.assertFalse(empty.is_equal(Solid())) - self.assertTrue(empty.is_valid()) + self.assertTrue(empty.is_valid) empty_bbox = empty.bounding_box() self.assertEqual(tuple(empty_bbox.size), (0, 0, 0)) self.assertIs(empty, empty.mirror(Plane.XY)) diff --git a/tests/test_direct_api/test_shells.py b/tests/test_direct_api/test_shells.py index 6c9023b..d465433 100644 --- a/tests/test_direct_api/test_shells.py +++ b/tests/test_direct_api/test_shells.py @@ -41,12 +41,12 @@ class TestShells(unittest.TestCase): def test_shell_init(self): box_faces = Solid.make_box(1, 1, 1).faces() box_shell = Shell(box_faces) - self.assertTrue(box_shell.is_valid()) + self.assertTrue(box_shell.is_valid) def test_shell_init_single_face(self): face = Solid.make_cone(1, 0, 2).faces().filter_by(GeomType.CONE).first shell = Shell(face) - self.assertTrue(shell.is_valid()) + self.assertTrue(shell.is_valid) def test_center(self): box_faces = Solid.make_box(1, 1, 1).faces() @@ -71,9 +71,9 @@ class TestShells(unittest.TestCase): x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5)) surface = sweep(x_section, Circle(5).wire()) single_face = Shell(surface.face()) - self.assertTrue(single_face.is_valid()) + self.assertTrue(single_face.is_valid) single_face = Shell(surface.faces()) - self.assertTrue(single_face.is_valid()) + self.assertTrue(single_face.is_valid) def test_sweep(self): path_c1 = JernArc((0, 0), (-1, 0), 1, 180) diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py index 640bf31..a0fa0f3 100644 --- a/tests/test_direct_api/test_solid.py +++ b/tests/test_direct_api/test_solid.py @@ -59,7 +59,7 @@ class TestSolid(unittest.TestCase): box = Solid(box_shell) self.assertAlmostEqual(box.area, 6, 5) self.assertAlmostEqual(box.volume, 1, 5) - self.assertTrue(box.is_valid()) + self.assertTrue(box.is_valid) def test_extrude(self): v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1)) diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py index 3391b88..51e398e 100644 --- a/tests/test_direct_api/test_wire.py +++ b/tests/test_direct_api/test_wire.py @@ -191,26 +191,26 @@ class TestWire(unittest.TestCase): e1 = Edge.make_line((1, 0), (1, 1)) w0 = Wire.make_circle(1) w1 = Wire(e0) - self.assertTrue(w1.is_valid()) + self.assertTrue(w1.is_valid) w2 = Wire([e0]) self.assertAlmostEqual(w2.length, 1, 5) - self.assertTrue(w2.is_valid()) + self.assertTrue(w2.is_valid) w3 = Wire([e0, e1]) - self.assertTrue(w3.is_valid()) + self.assertTrue(w3.is_valid) self.assertAlmostEqual(w3.length, 2, 5) w4 = Wire(w0.wrapped) - self.assertTrue(w4.is_valid()) + self.assertTrue(w4.is_valid) w5 = Wire(obj=w0.wrapped) - self.assertTrue(w5.is_valid()) + self.assertTrue(w5.is_valid) w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red")) - self.assertTrue(w6.is_valid()) + self.assertTrue(w6.is_valid) self.assertEqual(w6.label, "w6") np.testing.assert_allclose(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 1e-5) w7 = Wire(w6) - self.assertTrue(w7.is_valid()) + self.assertTrue(w7.is_valid) c0 = Polyline((0, 0), (1, 0), (1, 1)) w8 = Wire(c0) - self.assertTrue(w8.is_valid()) + self.assertTrue(w8.is_valid) with self.assertRaises(ValueError): Wire(bob="fred") diff --git a/tests/test_mesher.py b/tests/test_mesher.py index 65edaff..0be4ccb 100644 --- a/tests/test_mesher.py +++ b/tests/test_mesher.py @@ -208,7 +208,7 @@ class TestHollowImport(unittest.TestCase): export_stl(test_shape, "test.stl") importer = Mesher() stl = importer.read("test.stl") - self.assertTrue(stl[0].is_valid()) + self.assertTrue(stl[0].is_valid) class TestImportDegenerateTriangles(unittest.TestCase): @@ -221,7 +221,7 @@ class TestImportDegenerateTriangles(unittest.TestCase): stl = importer.read("cyl_w_rect_hole.stl")[0] self.assertEqual(type(stl), Solid) self.assertTrue(stl.is_manifold) - self.assertTrue(stl.is_valid()) + self.assertTrue(stl.is_valid) self.assertEqual(sum(f.area == 0 for f in stl.faces()), 0)