diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 7eb28caca..843eeb0ec 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -208,7 +208,7 @@ from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.StlAPI import StlAPI_Writer -from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCP.ShapeUpgrade import ShapeUpgrade_RemoveLocations, ShapeUpgrade_UnifySameDomain from OCP.BRepTools import ( BRepTools, @@ -4106,12 +4106,18 @@ def hollow( occ_faces_list = TopTools_ListOfShape() shell_builder = BRepOffsetAPI_MakeThickSolid() + location_remover = ShapeUpgrade_RemoveLocations() + # Workplane.findSolid() wraps located solids in an unlocated compound. + location_remover.SetRemoveLevel(ta.TopAbs_COMPOUND) + location_remover.Remove(self.wrapped) + normalized_shape = self.__class__(location_remover.GetResult()) + if faceList: for f in faceList: - occ_faces_list.Append(f.wrapped) + occ_faces_list.Append(location_remover.ModifiedShape(f.wrapped)) shell_builder.MakeThickSolidByJoin( - self.wrapped, + normalized_shape.wrapped, occ_faces_list, thickness, tolerance, @@ -4125,7 +4131,7 @@ def hollow( else: # if no faces provided a watertight solid will be constructed s1 = self.__class__(shell_builder.Shape()).Shells()[0].wrapped - s2 = self.Shells()[0].wrapped + s2 = normalized_shape.Shells()[0].wrapped # s1 can be outer or inner shell depending on the thickness sign if thickness > 0: diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index a1de3c957..fd4a57d15 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -2441,6 +2441,48 @@ def testClosedShell(self): self.assertEqual(len(s4_shell_1.Faces()), s1.faces().size()) self.assertEqual(len(s4_shell_2.Faces()), s1.faces().size()) + def testLocatedSphereShell(self): + """Keep the world geometry when hollowing spheres on rotated workplanes.""" + radius = 13 + for plane in ("XY", "XZ", "YZ", "ZX", "ZY"): + source = Workplane(plane).sphere(radius) + source_volume = source.val().Volume() + for thickness in (-1, 1): + result = source.shell(thickness).val() + inner_radius, outer_radius = ( + (radius + thickness, radius) + if thickness < 0 + else (radius, radius + thickness) + ) + expected_volume = ( + 4 * math.pi / 3 * (outer_radius ** 3 - inner_radius ** 3) + ) + self.assertTrue(result.isValid()) + self.assertAlmostEqual(result.Volume(), expected_volume, places=5) + self.assertTupleAlmostEquals(result.Center().toTuple(), (0, 0, 0), 5) + self.assertAlmostEqual(source.val().Volume(), source_volume, places=5) + + self.assertTrue(Workplane("XZ").sphere(radius).val().hollow([], -1).isValid()) + + def testLocatedPartialSphereAndOpenBoxShell(self): + """Keep translated partial spheres and selected box faces in world space.""" + partial = Workplane("XZ").sphere(13, angle1=-90, angle2=45).translate((5, 6, 7)) + source_volume = partial.val().Volume() + result = partial.shell(-1).val() + self.assertTrue(result.isValid()) + self.assertGreater(result.Volume(), 0) + self.assertAlmostEqual(result.Center().x, 5, places=5) + self.assertGreater(result.Center().y, 6) + self.assertAlmostEqual(result.Center().z, 7, places=5) + self.assertAlmostEqual(partial.val().Volume(), source_volume, places=5) + + box = Workplane("XZ").box(10, 10, 10).translate((5, 6, 7)) + open_shell = box.faces(">Z").shell(-1).val() + self.assertTrue(open_shell.isValid()) + self.assertAlmostEqual(open_shell.Volume(), 424, places=5) + self.assertAlmostEqual(open_shell.Center().x, 5, places=5) + self.assertAlmostEqual(open_shell.Center().y, 6, places=5) + def testOpenCornerShell(self): s = Workplane("XY").box(1, 1, 1) s1 = s.faces("+Z")