""" build123d BuildPart tests name: build_part_tests.py by: Gumyr date: July 28th 2022 desc: Unit tests for the build123d build_part module license: Copyright 2022 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 math import pi, sin from unittest.mock import MagicMock, patch, PropertyMock from build123d import * from build123d import LocationList, WorkplaneList def _assertTupleAlmostEquals(self, expected, actual, places, msg=None): """Check Tuples""" for i, j in zip(actual, expected): self.assertAlmostEqual(i, j, places, msg=msg) unittest.TestCase.assertTupleAlmostEquals = _assertTupleAlmostEquals class TestAlign(unittest.TestCase): def test_align(self): with BuildPart() as max: Box(1, 1, 1, align=(Align.MIN, Align.CENTER, Align.MAX)) bbox = max.part.bounding_box() self.assertGreaterEqual(bbox.min.X, 0) self.assertLessEqual(bbox.max.X, 1) self.assertGreaterEqual(bbox.min.Y, -0.5) self.assertLessEqual(bbox.max.Y, 0.5) self.assertGreaterEqual(bbox.min.Z, -1) self.assertLessEqual(bbox.max.Z, 0) class TestMakeBrakeFormed(unittest.TestCase): def test_make_brake_formed(self): with BuildPart() as bp: with BuildLine() as bl: Polyline((0, 0), (5, 6), (10, 1)) fillet(bl.vertices(), 1) make_brake_formed(thickness=0.5, station_widths=[1, 2, 3, 4]) self.assertTrue(bp.part.volume > 0) self.assertAlmostEqual(bp.part.bounding_box().max.Z, 4, 2) self.assertEqual(len(bp.faces().filter_by(GeomType.PLANE, reverse=True)), 3) outline = FilletPolyline((0, 0), (5, 6), (10, 1), radius=1) sheet_metal = make_brake_formed(thickness=0.5, station_widths=1, line=outline) self.assertAlmostEqual(sheet_metal.bounding_box().max.Z, 1, 2) class TestPartOperationDraft(unittest.TestCase): def setUp(self): self.box = Box(10, 10, 10).solid() self.sides = self.box.faces().filter_by(Axis.Z, reverse=True) self.bottom_face = self.box.faces().sort_by(Axis.Z)[0] self.neutral_plane = Plane(self.bottom_face) def test_successful_draft(self): """Test that a draft operation completes successfully""" result = draft(self.sides, self.neutral_plane, 5) self.assertIsInstance(result, Part) self.assertLess(self.box.volume, result.volume) with BuildPart() as draft_box: Box(10, 10, 10) draft( draft_box.faces().filter_by(Axis.Z, reverse=True), Plane.XY.offset(-5), 5, ) self.assertLess(draft_box.part.volume, 1000) def test_invalid_face_type(self): """Test that a ValueError is raised for unsupported face types""" torus = Torus(5, 1).solid() with self.assertRaises(ValueError) as cm: draft([torus.faces()[0]], self.neutral_plane, 5) def test_faces_from_multiple_solids(self): """Test that using faces from different solids raises an error""" box2 = Box(5, 5, 5).solid() mixed = [self.sides[0], box2.faces()[0]] with self.assertRaises(ValueError) as cm: draft(mixed, self.neutral_plane, 5) self.assertIn("same topological parent", str(cm.exception)) def test_faces_from_multiple_parts(self): """Test that using faces from different solids raises an error""" box2 = Box(5, 5, 5).solid() part: Part = Part() + [self.box, Pos(X=10) * box2] mixed = [part.faces().sort_by(Axis.X)[0], part.faces().sort_by(Axis.X)[-1]] with self.assertRaises(ValueError) as cm: draft(mixed, self.neutral_plane, 5) def test_bad_draft_faces(self): with self.assertRaises(DraftAngleError): draft(self.bottom_face, self.neutral_plane, 10) @patch("build123d.topology.three_d.BRepOffsetAPI_DraftAngle") def test_draftangleerror_from_solid_draft(self, mock_draft_angle): """Simulate a failure in AddDone and catch DraftAngleError""" mock_builder = MagicMock() mock_builder.AddDone.return_value = False mock_builder.ProblematicShape.return_value = "ShapeX" mock_draft_angle.return_value = mock_builder with self.assertRaises(DraftAngleError) as cm: draft(self.sides, self.neutral_plane, 5) class TestBuildPart(unittest.TestCase): """Test the BuildPart Builder derived class""" def test_obj_name(self): with BuildPart() as test: pass self.assertEqual(test._obj_name, "part") def test_invalid_add_to_context_input(self): with self.assertRaises(ValueError): with BuildPart() as test: test._add_to_context(*[4, 4]) def test_select_vertices(self): """Test vertices()""" with BuildPart() as test: Box(10, 10, 10) self.assertEqual(len(test.vertices()), 8) Box(5, 5, 20, align=(Align.CENTER, Align.CENTER, Align.MIN)) self.assertEqual(len(test.vertices(Select.LAST)), 8) def test_select_edges(self): """Test edges()""" with BuildPart() as test: Box(10, 10, 10) self.assertEqual(len(test.edges()), 12) Box(5, 5, 20, align=(Align.CENTER, Align.CENTER, Align.MIN)) self.assertEqual(len(test.edges(Select.LAST)), 12) def test_select_faces(self): """Test faces()""" with BuildPart() as test: Box(10, 10, 10) self.assertEqual(len(test.faces()), 6) with BuildSketch(test.faces().filter_by(Axis.Z)[-1]): Rectangle(5, 5) extrude(amount=5) self.assertEqual(len(test.faces()), 11) self.assertEqual(len(test.faces(Select.LAST)), 6) def test_select_solids(self): """Test faces()""" with BuildPart() as test: for i in [5, 10]: with Locations((3 * i, 0, 0)): Box(10, 10, i) Box(20, 5, 5) self.assertEqual(len(test.solids()), 2) self.assertEqual(len(test.solids(Select.LAST)), 1) def test_mode_add_multiple(self): with BuildPart() as test: with PolarLocations(30, 5): Box(20, 20, 20) self.assertAlmostEqual(len(test.solids()), 5) def test_mode_subtract(self): with BuildPart() as test: Box(20, 20, 20) Sphere(10, mode=Mode.SUBTRACT) self.assertTrue(isinstance(test._obj, Compound)) self.assertAlmostEqual(test.part.volume, 8000 - (4000 / 3) * pi, 5) def test_mode_intersect(self): """Note that a negative volume is created""" with BuildPart() as test: Box(20, 20, 20) Sphere(10, mode=Mode.INTERSECT) self.assertAlmostEqual(abs(test.part.volume), (4000 / 3) * pi, 5) def test_mode_replace(self): with BuildPart() as test: Box(10, 10, 10) Sphere(10, mode=Mode.REPLACE) self.assertAlmostEqual(test.part.volume, (4000 / 3) * pi, 5) def test_add_pending_faces(self): with BuildPart() as test: Box(100, 100, 100) with BuildSketch(*test.faces()): with PolarLocations(10, 5): Circle(2) self.assertEqual(len(test.pending_faces), 30) # self.assertEqual(sum([len(s.faces()) for s in test.pending_faces]), 30) def test_add_pending_edges(self): with BuildPart() as test: Box(100, 100, 100) with BuildLine(): CenterArc((0, 0), 5, 0, 180) self.assertEqual(len(test.pending_edges), 1) def test_add_pending_location_count(self): with BuildPart() as test: with PolarLocations(30, 5): self.assertEqual(len(LocationList._get_context().locations), 5) def test_named_plane(self): with BuildPart(Plane.YZ) as test: self.assertTupleAlmostEquals( WorkplaneList._get_context().workplanes[0].z_dir, (1, 0, 0), 5, ) def test_part_transfer_on_exit(self): with BuildPart(Plane.XY) as test: Box(1, 1, 1) with BuildPart(Plane.XY.offset(1)): Box(1, 1, 1) self.assertAlmostEqual(test.part.volume, 2, 5) class TestBuildPartExceptions(unittest.TestCase): """Test exception handling""" def test_invalid_subtract(self): with self.assertRaises(RuntimeError): with BuildPart(): Sphere(10, mode=Mode.SUBTRACT) def test_invalid_intersect(self): with self.assertRaises(RuntimeError): with BuildPart(): Sphere(10, mode=Mode.INTERSECT) class TestCounterBoreHole(unittest.TestCase): def test_fixed_depth(self): with BuildPart() as test: Box(10, 10, 10) with Locations(test.faces().filter_by(Axis.Z)[-1].center()): CounterBoreHole(2, 3, 1, 5) self.assertAlmostEqual(test.part.volume, 1000 - 4 * 4 * pi - 9 * pi, 5) def test_through_hole(self): with BuildPart() as test: Box(10, 10, 10) with Locations(test.faces().filter_by(Axis.Z)[-1].center()): CounterBoreHole(2, 3, 1) self.assertAlmostEqual(test.part.volume, 1000 - 4 * 9 * pi - 9 * pi, 5) class TestCounterSinkHole(unittest.TestCase): def test_fixed_depth(self): with BuildPart() as test: Box(10, 10, 10) with Locations(test.faces().filter_by(Axis.Z)[-1].center()): CounterSinkHole(2, 4, 5) self.assertLess(test.part.volume, 1000, 5) self.assertGreater(test.part.volume, 1000 - 16 * 5 * pi, 5) def test_through_hole(self): with BuildPart() as test: Box(10, 10, 10) with Locations(test.faces().filter_by(Axis.Z)[-1].center()): CounterSinkHole(2, 4) self.assertLess(test.part.volume, 1000, 5) self.assertGreater(test.part.volume, 1000 - 16 * 10 * pi, 5) class TestCylinder(unittest.TestCase): def test_simple_torus(self): with BuildPart() as test: Cylinder(2, 10) self.assertAlmostEqual(test.part.volume, pi * 2**2 * 10, 5) class TestExtrude(unittest.TestCase): def test_no_faces(self): with self.assertRaises(ValueError): with BuildPart(): extrude(amount=1) def test_extrude_with_face_input(self): with BuildPart() as test: with BuildSketch() as f: Rectangle(5, 5) extrude(*f.sketch.faces(), amount=2.5, both=True) self.assertAlmostEqual(test.part.volume, 125, 5) def test_extrude_both(self): with BuildPart() as test: with BuildSketch(): Rectangle(5, 5) extrude(amount=2.5, both=True) self.assertAlmostEqual(test.part.volume, 125, 5) def test_extrude_until(self): with BuildPart() as test: Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN)) scale(by=(0.8, 0.8, 0.8), mode=Mode.SUBTRACT) with BuildSketch(): Rectangle(1, 1) extrude(until=Until.NEXT) self.assertAlmostEqual(test.part.volume, 10**3 - 8**3 + 1**2 * 8, 5) def test_extrude_face(self): with BuildPart(Plane.XZ) as box: with BuildSketch(Plane.XZ, mode=Mode.PRIVATE) as square: Rectangle(10, 10, align=(Align.CENTER, Align.MIN)) extrude(square.sketch, amount=10) self.assertAlmostEqual(box.part.volume, 10**3, 5) def test_extrude_non_planar_face(self): cyl = Cylinder(1, 2) npf = cyl.split(Plane.XZ).faces().filter_by(GeomType.PLANE, reverse=True)[0] test_solid = extrude(npf, amount=3, dir=(0, 1, 0)) self.assertAlmostEqual(test_solid.volume, 2 * 2 * 3, 5) class TestHole(unittest.TestCase): def test_fixed_depth(self): with BuildPart() as test: Box(10, 10, 10) with Locations(test.faces().filter_by(Axis.Z)[-1].center()): Hole(2, 5) self.assertAlmostEqual(test.part.volume, 1000 - 4 * 5 * pi, 5) def test_through_hole(self): with BuildPart() as test: Box(10, 10, 10) with Locations(test.faces().filter_by(Axis.Z)[-1].center()): Hole(2) self.assertAlmostEqual(test.part.volume, 1000 - 4 * 10 * pi, 5) class TestLoft(unittest.TestCase): def test_simple_loft(self): with BuildPart() as test: slice_count = 10 for i in range(slice_count + 1): with BuildSketch(Plane(origin=(0, 0, i * 3), z_dir=(0, 0, 1))): Circle(10 * sin(i * pi / slice_count) + 5) loft() self.assertLess(test.part.volume, 225 * pi * 30, 5) self.assertGreater(test.part.volume, 25 * pi * 30, 5) sections = [ Face( Wire( [ Edge.make_circle(10 * sin(i * pi / slice_count) + 5).moved( Location(Vector(0, 0, i * 3)) ) ] ) ) for i in range(slice_count + 1) ] with BuildPart() as test: loft(sections) self.assertLess(test.part.volume, 225 * pi * 30, 5) self.assertGreater(test.part.volume, 25 * pi * 30, 5) def test_loft_vertex(self): with BuildPart() as test: v1 = Vertex(0, 0, 3) with BuildSketch() as s: Rectangle(1, 1) loft(sections=[s.sketch, v1], ruled=True) self.assertAlmostEqual(test.part.volume, 1, 5) def test_loft_vertices(self): with BuildPart() as test: v1 = Vertex(0, 0, 3) v2 = Vertex(0, 0, -3) with BuildSketch() as s: Rectangle(1, 1) loft(sections=[v2, s.sketch, v1], ruled=True) self.assertAlmostEqual(test.part.volume, 2, 5) def test_loft_vertex_face(self): v1 = Vertex(0, 0, 3) r = Rectangle(1, 1) test = loft(sections=[r.face(), v1], ruled=True) self.assertAlmostEqual(test.volume, 1, 5) def test_loft_invalid_vertex(self): lower_section = Face.make_rect(10, 10) - Face.make_rect(8, 8) upper_section = Pos(Z=5) * lower_section with self.assertRaises(ValueError): loft([lower_section, Vertex(0, 0, 2.5), upper_section]) def test_loft_no_sections_assert(self): with BuildPart() as test: with self.assertRaises(ValueError): loft(sections=[None]) def test_loft_all_vertices_assert(self): with BuildPart() as test: v1 = Vertex(0, 0, -1) v2 = Vertex(0, 0, 2) with self.assertRaises(ValueError): loft(sections=[v1, v2]) def test_loft_vertex_middle_assert(self): with BuildPart() as test: v1 = Vertex(0, 0, -1) v2 = Vertex(0, 0, 2) with BuildSketch() as s: Circle(1) with self.assertRaises(ValueError): loft(sections=[v1, v2, s.sketch]) def test_loft_with_hole(self): lower_section = Face.make_rect(10, 10) - Face.make_rect(8, 8) upper_section = Pos(Z=5) * lower_section loft_with_hole = loft([lower_section, upper_section]) self.assertAlmostEqual(loft_with_hole.volume, 10 * 10 * 5 - 8 * 8 * 5, 5) def test_loft_with_two_holes(self): lower_section = Text("B", font_size=10) upper_section = Pos(Z=5) * lower_section with self.assertRaises(ValueError): loft([lower_section, upper_section]) def test_loft_with_inconsistent_holes(self): lower_section = Text("B", font_size=10) upper_section = Pos(Z=5) * Face.make_rect(10, 10) with self.assertRaises(ValueError): loft([lower_section, upper_section]) class TestRevolve(unittest.TestCase): def test_simple_revolve(self): with BuildPart() as test: with BuildSketch(): with BuildLine(): l1 = Line((0, 0), (12, 0)) l2 = RadiusArc(l1 @ 1, (15, 20), 50) l3 = Spline( l2 @ 1, (22, 40), (20, 50), tangents=(l2 % 1, (-0.75, 1)) ) l4 = RadiusArc(l3 @ 1, l3 @ 1 + Vector(0, 5), 5) l5 = Spline( l4 @ 1, l4 @ 1 + Vector(2.5, 2.5), l4 @ 1 + Vector(0, 5), tangents=(l4 % 1, (-1, 0)), ) Polyline( l5 @ 1, l5 @ 1 + Vector(0, 1), (0, (l5 @ 1).Y + 1), l1 @ 0, ) make_face() revolve(axis=Axis.Y) self.assertLess(test.part.volume, 22**2 * pi * 50, 5) self.assertGreater(test.part.volume, 144 * pi * 50, 5) def test_revolve_with_axis(self): with BuildPart() as test: with BuildSketch(): with BuildLine(): l1 = Line((0, 0), (0, 12)) l2 = RadiusArc(l1 @ 1, (20, 10), 50) l3 = Line(l2 @ 1, (20, 0)) l4 = Line(l3 @ 1, l1 @ 0) make_face() revolve(axis=Axis.X) self.assertLess(test.part.volume, 244 * pi * 20, 5) self.assertGreater(test.part.volume, 100 * pi * 20, 5) def test_revolve_size(self): """Verify revolution result matches revolution_arc size and direction""" ax = Axis.X profile = RegularPolygon(10, 4, align=(Align.CENTER, Align.MIN)) full_volume = revolve(profile, ax, 360).volume sizes = [30, 90, 150, 180, 200, 360, 500, 720, 750] sizes = [x * -1 for x in sizes[::-1]] + [0] + sizes for size in sizes: solid = revolve(profile, axis=ax, revolution_arc=size) # Create a rotation edge and and the start tangent normal to the profile edge = Edge.make_circle( 1, Plane.YZ, 0, size % 360, ( AngularDirection.COUNTER_CLOCKWISE if size > 0 else AngularDirection.CLOCKWISE ), ) sign = (edge % 0).Z expected = size % (sign * 360) expected = sign * 360 if expected == 0 else expected result = edge.length / edge.radius / pi * 180 * sign self.assertAlmostEqual(solid.volume, full_volume * abs(expected) / 360) self.assertAlmostEqual(expected, result) # Invalid test # def test_invalid_axis_origin(self): # with BuildPart(): # with BuildSketch(): # Rectangle(1, 1, align=(Align.MIN, Align.MIN)) # with self.assertRaises(ValueError): # revolve(axis=Axis((1, 1, 1), (0, 1, 0))) # Invalid test # def test_invalid_axis_direction(self): # with BuildPart(): # with BuildSketch(): # Rectangle(1, 1, align=(Align.MIN, Align.MIN)) # with self.assertRaises(ValueError): # revolve(axis=Axis.Z) class TestSection(unittest.TestCase): def test_circle(self): with BuildPart() as test: Sphere(10) s = section() self.assertAlmostEqual(s.area, 100 * pi, 5) def test_custom_plane(self): with BuildPart() as test: Sphere(10) s = section(section_by=Plane.XZ) self.assertAlmostEqual(s.area, 100 * pi, 5) def test_moved_object(self): sec = section(Pos(-100, 100) * Sphere(10), Plane.XY) self.assertEqual(len(sec.faces()), 1) self.assertAlmostEqual(sec.face().edge().radius, 10, 5) self.assertAlmostEqual(sec.face().center(), (-100, 100, 0), 5) class TestSplit(unittest.TestCase): def test_split(self): with BuildPart() as test: Sphere(10) split(keep=Keep.TOP) self.assertAlmostEqual(test.part.volume, (2 / 3) * 1000 * pi, 5) def test_split_both(self): with BuildPart() as test: Sphere(10) split(keep=Keep.BOTH) self.assertEqual(len(test.solids()), 2) def test_custom_plane(self): with BuildPart() as test: Sphere(10) split(bisect_by=Plane.YZ, keep=Keep.TOP) self.assertAlmostEqual(test.part.volume, (2 / 3) * 1000 * pi, 5) def test_wrapped_object(self): obj = Box(1, 1, 1) obj = fillet(obj.edges().group_by(Axis.Z)[-1], 0.1) right = split(obj, bisect_by=Plane.YZ, keep=Keep.TOP) self.assertLess(right.volume, obj.volume) class TestThicken(unittest.TestCase): def test_thicken(self): with BuildPart() as bp: with BuildSketch(): RectangleRounded(10, 10, 1) thicken(amount=1) self.assertAlmostEqual(bp.part.bounding_box().max.Z, 1, 5) non_planar = Sphere(1).faces()[0] outer_sphere = thicken(non_planar, amount=0.1) self.assertAlmostEqual(outer_sphere.volume, (4 / 3) * pi * (1.1**3 - 1**3), 5) wire = JernArc((0, -2), (-1, 0), 1, -180) + JernArc((0, 0), (1, 0), 2, -90) surface = sweep((wire ^ 0) * RadiusArc((0, 0), (0, -1), 1), wire) part = thicken(surface, 0.4) self.assertAlmostEqual(part.volume, 2.241583787221904, 5) part = thicken( sweep((wire ^ 0) * RadiusArc((0, 0), (0, -1), 1), wire), 0.4, both=True ) self.assertAlmostEqual(part.volume, 4.711747154435256, 5) class TestTorus(unittest.TestCase): def test_simple_torus(self): with BuildPart() as test: Torus(100, 10) self.assertAlmostEqual(test.part.volume, pi * 100 * 2 * pi * 100, 5) class TestWedge(unittest.TestCase): def test_simple_wedge(self): wedge = Wedge(1, 1, 1, 0, 0, 2, 5) self.assertAlmostEqual(wedge.volume, 4.833333333333334, 5) def test_invalid_wedge(self): with self.assertRaises(ValueError): Wedge(0, 1, 1, 0, 0, 2, 5) with self.assertRaises(ValueError): Wedge(1, 0, 1, 0, 0, 2, 5) with self.assertRaises(ValueError): Wedge(1, 1, 0, 0, 0, 2, 5) if __name__ == "__main__": unittest.main()