""" build123d direct api tests name: test_shape.py by: Gumyr date: January 21, 2025 desc: This python module contains tests for the build123d project. license: Copyright 2025 Gumyr Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ import unittest from random import uniform from unittest.mock import patch from build123d.build_enums import CenterOf, Keep from build123d.geometry import ( Axis, Color, Location, Matrix, Plane, Pos, Rotation, Vector, ) from build123d.objects_part import Box, Cylinder from build123d.objects_sketch import Circle from build123d.operations_part import extrude from build123d.topology import ( Compound, Edge, Face, Shape, ShapeList, Shell, Solid, Vertex, Wire, ) from tests.base_test import DirectApiTestCase, AlwaysEqual class TestShape(DirectApiTestCase): """Misc Shape tests""" def test_mirror(self): box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box() self.assertAlmostEqual(box_bb.min.X, 0, 5) self.assertAlmostEqual(box_bb.max.X, 1, 5) self.assertAlmostEqual(box_bb.min.Y, -1, 5) self.assertAlmostEqual(box_bb.max.Y, 0, 5) box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box() self.assertAlmostEqual(box_bb.min.Z, -1, 5) self.assertAlmostEqual(box_bb.max.Z, 0, 5) def test_compute_mass(self): with self.assertRaises(NotImplementedError): Shape.compute_mass(Vertex()) def test_combined_center(self): objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]] self.assertVectorAlmostEquals( Shape.combined_center(objs, center_of=CenterOf.MASS), (0, 0.5, 0.5), 5, ) objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]] self.assertVectorAlmostEquals( Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX), (-0.5, 0, 0), 5, ) with self.assertRaises(ValueError): Shape.combined_center(objs, center_of=CenterOf.GEOMETRY) def test_shape_type(self): self.assertEqual(Vertex().shape_type(), "Vertex") def test_scale(self): self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5) def test_fuse(self): 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.assertAlmostEqual(combined.volume, 2, 5) fuzzy = box1.fuse(box2, tol=1e-6) self.assertTrue(fuzzy.is_valid()) self.assertAlmostEqual(fuzzy.volume, 2, 5) def test_faces_intersected_by_axis(self): box = Solid.make_box(1, 1, 1, Plane((0, 0, 1))) intersected_faces = box.faces_intersected_by_axis(Axis.Z) self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces) self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces) def test_split(self): shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5) split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM) self.assertTrue(isinstance(split_shape, list)) self.assertEqual(len(split_shape), 2) self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5) split_shape = shape.split(Plane.XY, keep=Keep.TOP) self.assertEqual(len(split_shape.solids()), 1) self.assertTrue(isinstance(split_shape, Solid)) self.assertAlmostEqual(split_shape.volume, 0.5, 5) s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10)) tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face() s2 = s.split(tool, keep=Keep.TOP) self.assertLess(s2.volume, s.volume) self.assertGreater(s2.volume, 0.0) def test_split_by_non_planar_face(self): box = Solid.make_box(1, 1, 1) tool = Circle(1).wire() tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1)) top, bottom = box.split(tool_shell, keep=Keep.BOTH) self.assertFalse(top is None) self.assertFalse(bottom is None) self.assertGreater(top.volume, bottom.volume) def test_split_by_shell(self): box = Solid.make_box(5, 5, 1) tool = Wire.make_rect(4, 4) tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1)) split = box.split(tool_shell, keep=Keep.TOP) inner_vol = 2 * 2 outer_vol = 5 * 5 self.assertAlmostEqual(split.volume, outer_vol - inner_vol) def test_split_keep_all(self): shape = Box(1, 1, 1) split_shape = shape.split(Plane.XY, keep=Keep.ALL) self.assertTrue(isinstance(split_shape, ShapeList)) self.assertEqual(len(split_shape), 2) def test_split_edge_by_shell(self): edge = Edge.make_line((-5, 0, 0), (5, 0, 0)) tool = Wire.make_rect(4, 4) tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1)) top = edge.split(tool_shell, keep=Keep.TOP) self.assertEqual(len(top), 2) self.assertAlmostEqual(top[0].length, 3, 5) def test_split_return_none(self): shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5) split_shape = shape.split(Plane.XY, keep=Keep.INSIDE) self.assertIsNone(split_shape) def test_split_by_perimeter(self): # Test 0 - extract a spherical cap target0 = Solid.make_sphere(10).rotate(Axis.Z, 90) circle = Plane.YZ.offset(15) * Circle(5).face() circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0] circle_outerwire = circle_projected.edge() inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH) self.assertLess(inside0.area, outside0.area) # Test 1 - extract ring of a sphere ring = Pos(Z=15) * (Circle(5) - Circle(3)).face() ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0] ring_outerwire = ring_projected.outer_wire() inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH) if isinstance(inside1, list): inside1 = Compound(inside1) if isinstance(outside1, list): outside1 = Compound(outside1) self.assertLess(inside1.area, outside1.area) self.assertEqual(len(outside1.faces()), 2) # Test 2 - extract multiple faces target2 = Box(1, 10, 10) square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0))) square_projected = square.project_to_shape(target2, (-1, 0, 0))[0] outside2 = target2.split_by_perimeter( square_projected.outer_wire(), Keep.OUTSIDE ) self.assertTrue(isinstance(outside2, Shell)) inside2 = target2.split_by_perimeter(square_projected.outer_wire(), Keep.INSIDE) self.assertTrue(isinstance(inside2, Face)) # Test 4 - invalid inputs with self.assertRaises(ValueError): _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH) with self.assertRaises(ValueError): _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP) def test_distance(self): sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) sphere2 = Solid.make_sphere(1, Plane((5, 0, 0))) self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5) def test_distances(self): sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) sphere2 = Solid.make_sphere(1, Plane((5, 0, 0))) sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5))) distances = [8, 3] for i, distance in enumerate(sphere1.distances(sphere2, sphere3)): self.assertAlmostEqual(distances[i], distance, 5) def test_max_fillet(self): test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)] max_values = [0.96, 3.84] for i, test_object in enumerate(test_solids): with self.subTest("solids" + str(i)): max = test_object.max_fillet(test_object.edges()) self.assertAlmostEqual(max, max_values[i], 2) with self.assertRaises(RuntimeError): test_solids[0].max_fillet( test_solids[0].edges(), tolerance=1e-6, max_iterations=1 ) with self.assertRaises(ValueError): box = Solid.make_box(1, 1, 1) box.fillet(0.75, box.edges()) # invalid_object = box.fillet(0.75, box.edges()) # invalid_object.max_fillet(invalid_object.edges()) @patch.object(Shape, "is_valid", return_value=False) def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid): box = Solid.make_box(1, 1, 1) # Assert that ValueError is raised with self.assertRaises(ValueError) as max_fillet_context: max = box.max_fillet(box.edges()) # Check the error message self.assertEqual(str(max_fillet_context.exception), "Invalid Shape") # Verify is_valid was called mock_is_valid.assert_called_once() def test_locate_bb(self): bounding_box = Solid.make_cone(1, 2, 1).bounding_box() relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box) self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5) self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5) self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5) self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5) self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5) self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5) def test_is_equal(self): box = Solid.make_box(1, 1, 1) self.assertTrue(box.is_equal(box)) def test_equal(self): box = Solid.make_box(1, 1, 1) self.assertEqual(box, box) self.assertEqual(box, AlwaysEqual()) def test_not_equal(self): box = Solid.make_box(1, 1, 1) diff = Solid.make_box(1, 2, 3) self.assertNotEqual(box, diff) self.assertNotEqual(box, object()) def test_tessellate(self): box123 = Solid.make_box(1, 2, 3) verts, triangles = box123.tessellate(1e-6) self.assertEqual(len(verts), 24) self.assertEqual(len(triangles), 12) def test_transformed(self): """Validate that transformed works the same as changing location""" rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360)) offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50)) shape = Solid.make_box(1, 1, 1).transformed(rotation, offset) predicted_location = Location(offset) * Rotation(*rotation) located_shape = Solid.make_box(1, 1, 1).locate(predicted_location) intersect = shape.intersect(located_shape) self.assertAlmostEqual(intersect.volume, 1, 5) def test_position_and_orientation(self): box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30))) self.assertVectorAlmostEquals(box.position, (1, 2, 3), 5) self.assertVectorAlmostEquals(box.orientation, (10, 20, 30), 5) def test_distance_to_with_closest_points(self): s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0))) s1 = Solid.make_sphere(1) distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1) self.assertAlmostEqual(distance, 0.1, 5) self.assertVectorAlmostEquals(pnt0, (0, 1.1, 0), 5) self.assertVectorAlmostEquals(pnt1, (0, 1, 0), 5) def test_closest_points(self): c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0))) c1 = Edge.make_circle(1) closest = c0.closest_points(c1) self.assertVectorAlmostEquals(closest[0], c0.position_at(0.75).to_tuple(), 5) self.assertVectorAlmostEquals(closest[1], c1.position_at(0.25).to_tuple(), 5) def test_distance_to(self): c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0))) c1 = Edge.make_circle(1) distance = c0.distance_to(c1) self.assertAlmostEqual(distance, 0.1, 5) def test_intersection(self): box = Solid.make_box(1, 1, 1) intersections = ( box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z) ) self.assertVectorAlmostEquals(intersections[0], (0.5, 0.5, 0), 5) self.assertVectorAlmostEquals(intersections[1], (0.5, 0.5, 1), 5) def test_clean_error(self): """Note that this test is here to alert build123d to changes in bad OCCT clean behavior with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this. """ sphere = Solid.make_sphere(1) divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5))) positive_half, negative_half = (s.clean() for s in sphere.cut(divider).solids()) self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1) def test_clean_empty(self): obj = Solid() self.assertIs(obj, obj.clean()) def test_relocate(self): box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5))) cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0))) box_with_hole = box.cut(cylinder) box_with_hole.relocate(box.location) self.assertEqual(box.location, box_with_hole.location) bbox1 = box.bounding_box() bbox2 = box_with_hole.bounding_box() self.assertVectorAlmostEquals(bbox1.min, bbox2.min, 5) self.assertVectorAlmostEquals(bbox1.max, bbox2.max, 5) def test_project_to_viewport(self): # Basic test box = Solid.make_box(10, 10, 10) visible, hidden = box.project_to_viewport((-20, 20, 20)) self.assertEqual(len(visible), 9) self.assertEqual(len(hidden), 3) # Contour edges cyl = Solid.make_cylinder(2, 10) visible, hidden = cyl.project_to_viewport((-20, 20, 20)) # Note that some edges are broken into two self.assertEqual(len(visible), 6) self.assertEqual(len(hidden), 2) # Hidden contour edges hole = box - cyl visible, hidden = hole.project_to_viewport((-20, 20, 20)) self.assertEqual(len(visible), 13) self.assertEqual(len(hidden), 6) # Outline edges sphere = Solid.make_sphere(5) visible, hidden = sphere.project_to_viewport((-20, 20, 20)) self.assertEqual(len(visible), 1) self.assertEqual(len(hidden), 0) def test_vertex(self): v = Edge.make_circle(1).vertex() self.assertTrue(isinstance(v, Vertex)) with self.assertWarns(UserWarning): 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): 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): 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): Compound(children=[c, c2]).compound() def test_face(self): f = Face.make_rect(1, 1) self.assertTrue(isinstance(f, Face)) with self.assertWarns(UserWarning): 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): 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): Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid() def test_manifold(self): self.assertTrue(Solid.make_box(1, 1, 1).is_manifold) self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold) self.assertFalse( Solid.make_box(1, 1, 1) .shell() .cut(Solid.make_box(0.5, 0.5, 0.5)) .is_manifold ) self.assertTrue( Compound( children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)] ).is_manifold ) def test_inherit_color(self): # Create some objects and assign colors to them b = Box(1, 1, 1).locate(Pos(2, 2, 0)) b.color = Color("blue") # Blue c = Cylinder(1, 1).locate(Pos(-2, 2, 0)) a = Compound(children=[b, c]) a.color = Color(0, 1, 0) # Check that assigned colors stay and iheritance works self.assertTupleAlmostEquals(tuple(a.color), (0, 1, 0, 1), 5) self.assertTupleAlmostEquals(tuple(b.color), (0, 0, 1, 1), 5) def test_ocp_section(self): # Vertex verts, edges = Vertex(1, 2, 0)._ocp_section(Vertex(1, 2, 0)) self.assertListEqual(verts, []) # ? self.assertListEqual(edges, []) verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4))) self.assertListEqual(verts, []) # ? self.assertListEqual(edges, []) verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5)) self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY)) self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5) self.assertListEqual(edges, []) # spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5)) # cylinder = Pos(Z=-10) * extrude(Circle(5), 20) # cylinder2 = (Rot((0, 90, 0)) * cylinder).face() # pln = Plane.XY # box1 = Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN)) # box2 = Pos(Z=-10) * box1 # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln)) # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln)) # print(vertices1, edges1) # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln)) # print(vertices2, edges2) # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln)) # print(vertices3, edges3) # # vertices4, edges4 = cylinder2.ocp_section(cylinder) # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln)) # print(vertices5, edges5) # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1]) def test_copy_attributes_to(self): box = Box(1, 1, 1) box2 = Box(10, 10, 10) box.label = "box" box.color = Color("Red") box.children = [Box(1, 1, 1), Box(2, 2, 2)] box.topo_parent = box2 blank = Compound() box.copy_attributes_to(blank) self.assertEqual(blank.label, "box") self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red")))) self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children))) self.assertEqual(blank.topo_parent, box2) def test_empty_shape(self): empty = Solid() box = Solid.make_box(1, 1, 1) self.assertIsNone(empty.location) self.assertIsNone(empty.position) self.assertIsNone(empty.orientation) self.assertFalse(empty.is_manifold) with self.assertRaises(ValueError): empty.geom_type self.assertIs(empty, empty.fix()) self.assertEqual(hash(empty), 0) self.assertFalse(empty.is_same(Solid())) self.assertFalse(empty.is_equal(Solid())) 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)) self.assertEqual(Shape.compute_mass(empty), 0) self.assertEqual(empty.entities("Face"), []) self.assertEqual(empty.area, 0) self.assertIs(empty, empty.rotate(Axis.Z, 90)) translate_matrix = [ [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 2.0], [0.0, 0.0, 1.0, 3.0], [0.0, 0.0, 0.0, 1.0], ] self.assertIs(empty, empty.transform_shape(Matrix(translate_matrix))) self.assertIs(empty, empty.transform_geometry(Matrix(translate_matrix))) with self.assertRaises(ValueError): empty.locate(Location()) empty_loc = Location() empty_loc.wrapped = None with self.assertRaises(ValueError): box.locate(empty_loc) with self.assertRaises(ValueError): empty.located(Location()) with self.assertRaises(ValueError): box.located(empty_loc) with self.assertRaises(ValueError): empty.move(Location()) with self.assertRaises(ValueError): box.move(empty_loc) with self.assertRaises(ValueError): empty.moved(Location()) with self.assertRaises(ValueError): box.moved(empty_loc) with self.assertRaises(ValueError): empty.relocate(Location()) with self.assertRaises(ValueError): box.relocate(empty_loc) with self.assertRaises(ValueError): empty.distance_to(Vector(1, 1, 1)) with self.assertRaises(ValueError): empty.distance_to_with_closest_points(Vector(1, 1, 1)) with self.assertRaises(ValueError): empty.distance_to(Vector(1, 1, 1)) with self.assertRaises(ValueError): box.intersect(empty_loc) self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], [])) self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList()) with self.assertRaises(ValueError): empty.split_by_perimeter(Circle(1).wire()) with self.assertRaises(ValueError): empty.distance(Vertex(1, 1, 1)) with self.assertRaises(ValueError): list(empty.distances(Vertex(0, 0, 0), Vertex(1, 1, 1))) with self.assertRaises(ValueError): list(box.distances(empty, Vertex(1, 1, 1))) with self.assertRaises(ValueError): empty.mesh(0.001) with self.assertRaises(ValueError): empty.tessellate(0.001) with self.assertRaises(ValueError): empty.to_splines() empty_axis = Axis((0, 0, 0), (1, 0, 0)) empty_axis.wrapped = None with self.assertRaises(ValueError): box.vertices().group_by(empty_axis) empty_wire = Wire() with self.assertRaises(ValueError): box.vertices().group_by(empty_wire) with self.assertRaises(ValueError): box.vertices().sort_by(empty_axis) with self.assertRaises(ValueError): box.vertices().sort_by(empty_wire) def test_empty_selectors(self): self.assertEqual(Vertex(1, 1, 1).edges(), ShapeList()) self.assertEqual(Vertex(1, 1, 1).wires(), ShapeList()) self.assertEqual(Vertex(1, 1, 1).faces(), ShapeList()) 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()) if __name__ == "__main__": import unittest unittest.main()