diff --git a/src/build123d/build_part.py b/src/build123d/build_part.py index dfb2b00..1f4d028 100644 --- a/src/build123d/build_part.py +++ b/src/build123d/build_part.py @@ -85,12 +85,18 @@ class BuildPart(Builder): @property def pending_faces_count(self) -> int: """Number of pending faces""" - return len(self.pending_faces.values()) + count = 0 + for face_list in self.pending_faces.values(): + count += len(face_list) + return count @property def pending_edges_count(self) -> int: """Number of pending edges""" - return len(self.pending_edges.values()) + count = 0 + for edge_list in self.pending_edges.values(): + count += len(edge_list) + return count @property def pending_location_count(self) -> int: @@ -161,7 +167,7 @@ class BuildPart(Builder): if select == Select.ALL: face_list = self.part.Faces() elif select == Select.LAST: - face_list = self.last_edges + face_list = self.last_faces return ShapeList(face_list) def solids(self, select: Select = Select.ALL) -> ShapeList[Solid]: @@ -280,16 +286,14 @@ class BuildPart(Builder): self.part = self.part.fuse(*new_solids).clean() elif mode == Mode.SUBTRACT: if self.part is None: - raise ValueError("Nothing to subtract from") + raise RuntimeError("Nothing to subtract from") self.part = self.part.cut(*new_solids).clean() elif mode == Mode.INTERSECT: if self.part is None: - raise ValueError("Nothing to intersect with") + raise RuntimeError("Nothing to intersect with") self.part = self.part.intersect(*new_solids).clean() elif mode == Mode.REPLACE: self.part = Compound.makeCompound(new_solids).clean() - else: - raise ValueError(f"Invalid mode: {mode}") post_vertices = set() if self.part is None else set(self.part.Vertices()) post_edges = set() if self.part is None else set(self.part.Edges()) @@ -335,12 +339,12 @@ class CounterBoreHole(Compound): depth: float = None, mode: Mode = Mode.SUBTRACT, ): + context: BuildPart = BuildPart._get_context() + hole_depth = ( - BuildPart._get_context().part.BoundingBox().DiagonalLength - if depth is None - else depth + context.part.BoundingBox().DiagonalLength if depth is None else depth ) - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() new_solids = [ Solid.makeCylinder( radius, hole_depth, loc.position(), plane.zDir * -1.0 @@ -354,7 +358,7 @@ class CounterBoreHole(Compound): ) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -379,13 +383,13 @@ class CounterSinkHole(Compound): counter_sink_angle: float = 82, # Common tip angle mode: Mode = Mode.SUBTRACT, ): + context: BuildPart = BuildPart._get_context() + hole_depth = ( - BuildPart._get_context().part.BoundingBox().DiagonalLength - if depth is None - else depth + context.part.BoundingBox().DiagonalLength if depth is None else depth ) cone_height = counter_sink_radius / tan(radians(counter_sink_angle / 2.0)) - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() new_solids = [ Solid.makeCylinder( radius, hole_depth, loc.position(), plane.zDir * -1.0 @@ -394,13 +398,13 @@ class CounterSinkHole(Compound): counter_sink_radius, 0.0, cone_height, - loc, + loc.position(), plane.zDir * -1.0, ) ) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -424,12 +428,14 @@ class Extrude(Compound): mode: Mode = Mode.ADD, ): new_solids: list[Solid] = [] - for plane_index, faces in BuildPart._get_context().pending_faces.items(): + context: BuildPart = BuildPart._get_context() + + for plane_index, faces in context.pending_faces.items(): for face in faces: new_solids.append( Solid.extrudeLinear( face, - BuildPart._get_context().workplanes[plane_index].zDir * until, + context.workplanes[plane_index].zDir * until, 0, ) ) @@ -437,15 +443,13 @@ class Extrude(Compound): new_solids.append( Solid.extrudeLinear( face, - BuildPart._get_context().workplanes[plane_index].zDir - * until - * -1.0, + context.workplanes[plane_index].zDir * until * -1.0, 0, ) ) - BuildPart._get_context().pending_faces = {0: []} - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context.pending_faces = {0: []} + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -466,19 +470,19 @@ class Hole(Compound): depth: float = None, mode: Mode = Mode.SUBTRACT, ): + context: BuildPart = BuildPart._get_context() + hole_depth = ( - BuildPart._get_context().part.BoundingBox().DiagonalLength - if depth is None - else depth + context.part.BoundingBox().DiagonalLength if depth is None else depth ) - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() new_solids = [ Solid.makeCylinder( radius, hole_depth, loc.position(), plane.zDir * -1.0, 360 ) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -494,14 +498,16 @@ class Loft(Solid): def __init__(self, ruled: bool = False, mode: Mode = Mode.ADD): + context: BuildPart = BuildPart._get_context() + loft_wires = [] - for i in range(len(BuildPart._get_context().workplanes)): - for face in BuildPart._get_context().pending_faces[i]: + for i in range(len(context.workplanes)): + for face in context.pending_faces[i]: loft_wires.append(face.outerWire()) new_solid = Solid.makeLoft(loft_wires, ruled) - BuildPart._get_context().pending_faces = {0: []} - BuildPart._get_context()._add_to_context(new_solid, mode=mode) + context.pending_faces = {0: []} + context._add_to_context(new_solid, mode=mode) super().__init__(new_solid.wrapped) @@ -524,13 +530,16 @@ class Revolve(Compound): axis_end: VectorLike = None, mode: Mode = Mode.ADD, ): + + context: BuildPart = BuildPart._get_context() + # Make sure we account for users specifying angles larger than 360 degrees, and # for OCCT not assuming that a 0 degree revolve means a 360 degree revolve angle = revolution_arc % 360.0 angle = 360.0 if angle == 0 else angle new_solids = [] - for i, workplane in enumerate(BuildPart._get_context().workplanes): + for i, workplane in enumerate(context.workplanes): axis = [] if axis_start is None: axis.append(workplane.fromLocalCoords(Vector(0, 0, 0))) @@ -542,31 +551,52 @@ class Revolve(Compound): else: axis.append(workplane.fromLocalCoords(Vector(axis_end))) - for face in BuildPart._get_context().pending_faces[i]: + for face in context.pending_faces[i]: new_solids.append(Solid.revolve(face, angle, *axis)) - BuildPart._get_context().pending_faces = {0: []} - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context.pending_faces = {0: []} + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) class Section(Compound): """Part Operation: Section - Slices current part at the given height from current workplane(s). + Slices current part at the given height by section_by or current workplane(s). Args: + section_by (Plane, optional): sequence of planes to section object. + Defaults to None. height (float, optional): workplane offset. Defaults to 0.0. mode (Mode, optional): combination mode. Defaults to Mode.INTERSECT. """ - def __init__(self, height: float = 0.0, mode: Mode = Mode.INTERSECT): + def __init__( + self, + *section_by: Plane, + height: float = 0.0, + mode: Mode = Mode.INTERSECT, + ): + context: BuildPart = BuildPart._get_context() + + max_size = context.part.BoundingBox().DiagonalLength + + section_planes = section_by if section_by else context.workplanes + section_planes = ( + section_planes if isinstance(section_planes, Iterable) else [section_planes] + ) + planes = [ - Face.makePlane(basePnt=plane.origin + plane.zDir * height, dir=plane.zDir) - for plane in BuildPart._get_context().workplanes + Face.makePlane( + 2 * max_size, + 2 * max_size, + basePnt=plane.origin + plane.zDir * height, + dir=plane.zDir, + ) + for plane in section_planes ] - BuildPart._get_context()._add_to_context(planes, mode=mode) + context._add_to_context(*planes, mode=mode) super().__init__(Compound.makeCompound(planes).wrapped) @@ -589,10 +619,10 @@ class Shell(Compound): kind: Kind = Kind.ARC, mode: Mode = Mode.REPLACE, ): - new_part = BuildPart._get_context().part.shell( - faces, thickness, kind=kind.name.lower() - ) - BuildPart._get_context()._add_to_context(new_part, mode=mode) + context: BuildPart = BuildPart._get_context() + + new_part = context.part.shell(faces, thickness, kind=kind.name.lower()) + context._add_to_context(new_part, mode=mode) super().__init__(new_part.wrapped) @@ -613,7 +643,9 @@ class Split(Compound): keep: Keep = Keep.TOP, mode: Mode = Mode.INTERSECT, ): - max_size = BuildPart._get_context().BoundingBox().DiagonalLength + context: BuildPart = BuildPart._get_context() + + max_size = context.part.BoundingBox().DiagonalLength def build_cutter(keep: Keep) -> Solid: cutter_center = ( @@ -634,8 +666,8 @@ class Split(Compound): else: cutters.append(build_cutter(keep)) - BuildPart._get_context()._add_to_context(*cutters, mode=mode) - super().__init__(BuildPart._get_context().part.wrapped) + context._add_to_context(*cutters, mode=mode) + super().__init__(context.part.wrapped) class Sweep(Compound): @@ -666,6 +698,8 @@ class Sweep(Compound): binormal: Union[Edge, Wire] = None, mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + path_wire = Wire.assembleEdges([path]) if isinstance(path, Edge) else path if binormal is None: binormal_mode = Vector(normal) @@ -675,9 +709,9 @@ class Sweep(Compound): binormal_mode = binormal new_solids = [] - for i in range(BuildPart._get_context().workplane_count): + for i in range(context.workplane_count): if not multisection: - for face in BuildPart._get_context().pending_faces[i]: + for face in context.pending_faces[i]: new_solids.append( Solid.sweep( face, @@ -689,18 +723,15 @@ class Sweep(Compound): ) ) else: - sections = [ - face.outerWire() - for face in BuildPart._get_context().pending_faces[i] - ] + sections = [face.outerWire() for face in context.pending_faces[i]] new_solids.append( Solid.sweep_multi( sections, path_wire, make_solid, is_frenet, binormal_mode ) ) - BuildPart._get_context().pending_faces = {0: []} - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context.pending_faces = {0: []} + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -767,8 +798,10 @@ class Box(Compound): centered: tuple[bool, bool, bool] = (True, True, True), mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() center_offset = Vector( -length / 2 if centered[0] else 0, -width / 2 if centered[1] else 0, @@ -784,7 +817,7 @@ class Box(Compound): ).moved(rotate) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -814,8 +847,10 @@ class Cone(Compound): centered: tuple[bool, bool, bool] = (True, True, True), mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() center_offset = Vector( 0 if centered[0] else max(bottom_radius, top_radius), 0 if centered[1] else max(bottom_radius, top_radius), @@ -832,7 +867,7 @@ class Cone(Compound): ).moved(rotate) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -860,8 +895,10 @@ class Cylinder(Compound): centered: tuple[bool, bool, bool] = (True, True, True), mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() center_offset = Vector( 0 if centered[0] else radius, 0 if centered[1] else radius, @@ -877,7 +914,7 @@ class Cylinder(Compound): ).moved(rotate) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -907,8 +944,10 @@ class Sphere(Compound): centered: tuple[bool, bool, bool] = (True, True, True), mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() center_offset = Vector( 0 if centered[0] else radius, 0 if centered[1] else radius, @@ -925,7 +964,7 @@ class Sphere(Compound): ).moved(rotate) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -956,8 +995,10 @@ class Torus(Compound): centered: tuple[bool, bool, bool] = (True, True, True), mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() center_offset = Vector( 0 if centered[0] else major_radius, 0 if centered[1] else major_radius, @@ -974,7 +1015,7 @@ class Torus(Compound): ).moved(rotate) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) @@ -1007,13 +1048,15 @@ class Wedge(Compound): rotation: RotationLike = (0, 0, 0), mode: Mode = Mode.ADD, ): + context: BuildPart = BuildPart._get_context() + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - location_planes = BuildPart._get_context()._get_and_clear_locations() + location_planes = context._get_and_clear_locations() new_solids = [ Solid.makeWedge( dx, dy, dz, xmin, zmin, xmax, zmax, loc.position(), plane.zDir ).moved(rotate) for loc, plane in location_planes ] - BuildPart._get_context()._add_to_context(*new_solids, mode=mode) + context._add_to_context(*new_solids, mode=mode) super().__init__(Compound.makeCompound(new_solids).wrapped) diff --git a/tests/build_part_tests.py b/tests/build_part_tests.py index ffe29fe..d0fb527 100644 --- a/tests/build_part_tests.py +++ b/tests/build_part_tests.py @@ -26,7 +26,9 @@ license: """ import unittest +from math import pi, sin from build123d import * +from cadquery import Compound, Plane, Vector def _assertTupleAlmostEquals(self, expected, actual, places, msg=None): @@ -47,7 +49,7 @@ class BuildPartTests(unittest.TestCase): Box(10, 10, 10) self.assertEqual(len(test.vertices()), 8) Box(5, 5, 20, centered=(True, True, False)) - self.assertEqual(len(test.vertices(Select.LAST)), 8) + self.assertEqual(len(test.vertices(Select.LAST)), 8) def test_select_edges(self): """Test edges()""" @@ -55,7 +57,7 @@ class BuildPartTests(unittest.TestCase): Box(10, 10, 10) self.assertEqual(len(test.edges()), 12) Box(5, 5, 20, centered=(True, True, False)) - self.assertEqual(len(test.edges(Select.LAST)), 12) + self.assertEqual(len(test.edges(Select.LAST)), 12) def test_select_faces(self): """Test faces()""" @@ -66,8 +68,242 @@ class BuildPartTests(unittest.TestCase): with BuildSketch(): Rectangle(5, 5) Extrude(5) - self.assertEqual(len(test.faces()), 11) - self.assertEqual(len(test.faces(Select.LAST)), 6) + 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]: + PushPoints((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: + PolarArray(30, 0, 360, 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) + WorkplanesFromFaces(*test.faces()) + with BuildSketch(): + PolarArray(10, 0, 360, 5) + Circle(2) + self.assertEqual(test.workplane_count, 6) + self.assertEqual(test.pending_faces_count, 30) + + def test_add_pending_edges(self): + with BuildPart() as test: + Box(100, 100, 100) + WorkplanesFromFaces(*test.faces()) + with BuildLine(): + CenterArc((0, 0), 5, 0, 180) + self.assertEqual(test.pending_edges_count, 6) + + def test_add_pending_location_count(self): + with BuildPart() as test: + PolarArray(30, 0, 360, 5) + self.assertEqual(test.pending_location_count, 5) + + +class BuildPartExceptions(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) + PushPoints(test.faces().filter_by_axis(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) + PushPoints(test.faces().filter_by_axis(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) + PushPoints(test.faces().filter_by_axis(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) + PushPoints(test.faces().filter_by_axis(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 TestExtrude(unittest.TestCase): + def test_extrude_both(self): + with BuildPart() as test: + with BuildSketch(): + Rectangle(5, 5) + Extrude(2.5, both=True) + self.assertAlmostEqual(test.part.Volume(), 125, 5) + + +class TestHole(unittest.TestCase): + def test_fixed_depth(self): + with BuildPart() as test: + Box(10, 10, 10) + PushPoints(test.faces().filter_by_axis(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) + PushPoints(test.faces().filter_by_axis(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): + Workplanes(Plane(origin=(0, 0, i * 3), normal=(0, 0, 1))) + with BuildSketch() as slice: + 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) + + +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, + ) + BuildFace() + Revolve() + 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) + BuildFace() + Revolve(axis_start=(0, 0, 0), axis_end=(1, 0, 0)) + self.assertLess(test.part.Volume(), 244 * pi * 20, 5) + self.assertGreater(test.part.Volume(), 100 * pi * 20, 5) + + +class TestSection(unittest.TestCase): + def test_circle(self): + with BuildPart() as test: + Sphere(10) + Section() + self.assertAlmostEqual(test.faces()[-1].Area(), 100 * pi, 5) + + # def test_custom_plane(self): + # with BuildPart() as test: + # Sphere(10) + # Section(Plane.named("XZ")) + # self.assertAlmostEqual( + # test.faces().filter_by_axis(Axis.Y)[-1].Area(), 100 * pi, 5 + # ) + + +class TestShell(unittest.TestCase): + def test_box_shell(self): + with BuildPart() as test: + Cylinder(10, 10) + Shell(thickness=1, kind=Kind.INTERSECTION) + self.assertAlmostEqual( + test.part.Volume(), 11**2 * pi * 12 - 10**2 * pi * 10, 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) + + +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) if __name__ == "__main__":