Added draft operation Issue #807

This commit is contained in:
gumyr 2025-05-25 16:11:35 -04:00
parent 30d26904ff
commit 44c3bac548
4 changed files with 264 additions and 16 deletions

View file

@ -30,12 +30,13 @@ from __future__ import annotations
from typing import cast
from collections.abc import Iterable
from build123d.build_enums import Mode, Until, Kind, Side
from build123d.build_enums import GeomType, Mode, Until, Kind, Side
from build123d.build_part import BuildPart
from build123d.geometry import Axis, Plane, Vector, VectorLike
from build123d.topology import (
Compound,
Curve,
DraftAngleError,
Edge,
Face,
Shell,
@ -55,6 +56,59 @@ from build123d.build_common import (
)
def draft(
faces: Face | Iterable[Face],
neutral_plane: Plane,
angle: float,
) -> Part:
"""Part Operation: draft
Apply a draft angle to the given faces of the part
Args:
faces: Faces to which the draft should be applied.
neutral_plane: Plane defining the neutral direction and position.
angle: Draft angle in degrees.
"""
context: BuildPart | None = BuildPart._get_context("draft")
face_list: ShapeList[Face] = flatten_sequence(faces)
assert all(isinstance(f, Face) for f in face_list), "all faces must be of type Face"
validate_inputs(context, "draft", face_list)
valid_geom_types = {GeomType.PLANE, GeomType.CYLINDER, GeomType.CONE}
unsupported = [f for f in face_list if f.geom_type not in valid_geom_types]
if unsupported:
raise ValueError(
f"Draft not supported on face(s) with geometry: "
f"{', '.join(set(f.geom_type.name for f in unsupported))}"
)
# Check that all the faces are associated with the same Solid
topo_parents = set(f.topo_parent for f in face_list)
if len(topo_parents) != 1:
raise ValueError("All faces must share the same topological parent (a Solid)")
parent_solids = next(iter(topo_parents)).solids()
if len(parent_solids) != 1:
raise ValueError("Topological parent must be a single Solid")
# Create the drafted solid
try:
new_solid = parent_solids[0].draft(face_list, neutral_plane, angle)
except DraftAngleError as err:
raise DraftAngleError(
f"Draft operation failed. "
f"Use `err.face` and `err.problematic_shape` for more information.",
face=err.face,
problematic_shape=err.problematic_shape,
) from err
if context is not None:
context._add_to_context(new_solid, clean=False, mode=Mode.REPLACE)
return Part(Compound([new_solid]).wrapped)
def extrude(
to_extrude: Face | Sketch | None = None,
amount: float | None = None,

View file

@ -68,7 +68,11 @@ from OCP.BRepClass3d import BRepClass3d_SolidClassifier
from OCP.BRepFeat import BRepFeat_MakeDPrism
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCP.BRepOffset import BRepOffset_MakeOffset, BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid
from OCP.BRepOffsetAPI import (
BRepOffsetAPI_DraftAngle,
BRepOffsetAPI_MakePipeShell,
BRepOffsetAPI_MakeThickSolid,
)
from OCP.BRepPrimAPI import (
BRepPrimAPI_MakeBox,
BRepPrimAPI_MakeCone,
@ -88,7 +92,7 @@ from OCP.TopExp import TopExp
from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Solid, TopoDS_Wire
from OCP.gp import gp_Ax2, gp_Pnt
from build123d.build_enums import CenterOf, Kind, Transition, Until
from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until
from build123d.geometry import (
DEG2RAD,
Axis,
@ -431,7 +435,7 @@ class Mixin3D(Shape):
"""
solid_classifier = BRepClass3d_SolidClassifier(self.wrapped)
solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance)
solid_classifier.Perform(gp_Pnt(*Vector(point)), tolerance)
return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace()
@ -1421,3 +1425,62 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]):
raise RuntimeError("Error applying thicken to given surface") from err
return result
def draft(self, faces: Iterable[Face], neutral_plane: Plane, angle: float) -> Solid:
"""Apply a draft angle to the given faces of the solid.
Args:
faces: Faces to which the draft should be applied.
neutral_plane: Plane defining the neutral direction and position.
angle: Draft angle in degrees.
Returns:
Solid with the specified draft angles applied.
Raises:
RuntimeError: If draft application fails on any face or during build.
"""
valid_geom_types = {GeomType.PLANE, GeomType.CYLINDER, GeomType.CONE}
for face in faces:
if face.geom_type not in valid_geom_types:
raise ValueError(
f"Face {face} has unsupported geometry type {face.geom_type.name}. "
"Only PLANAR, CYLINDRICAL, and CONICAL faces are supported."
)
draft_angle_builder = BRepOffsetAPI_DraftAngle(self.wrapped)
for face in faces:
draft_angle_builder.Add(
face.wrapped,
neutral_plane.z_dir.to_dir(),
radians(angle),
neutral_plane.wrapped,
Flag=True,
)
if not draft_angle_builder.AddDone():
raise DraftAngleError(
"Draft could not be added to a face.",
face=face,
problematic_shape=draft_angle_builder.ProblematicShape(),
)
try:
draft_angle_builder.Build()
result = Solid(draft_angle_builder.Shape())
except StdFail_NotDone as err:
raise DraftAngleError(
"Draft build failed on the given solid.",
face=None,
problematic_shape=draft_angle_builder.ProblematicShape(),
) from err
return result
class DraftAngleError(RuntimeError):
"""Solid.draft custom exception"""
def __init__(self, message, face=None, problematic_shape=None):
super().__init__(message)
self.face = face
self.problematic_shape = problematic_shape

View file

@ -28,6 +28,8 @@ license:
import unittest
from math import pi, sin
from unittest.mock import MagicMock, patch
from build123d import *
from build123d import LocationList, WorkplaneList
@ -56,7 +58,6 @@ class TestAlign(unittest.TestCase):
class TestMakeBrakeFormed(unittest.TestCase):
def test_make_brake_formed(self):
# TODO: Fix so this test doesn't raise a DeprecationWarning from NumPy
with BuildPart() as bp:
with BuildLine() as bl:
Polyline((0, 0), (5, 6), (10, 1))
@ -71,6 +72,67 @@ class TestMakeBrakeFormed(unittest.TestCase):
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"""
@ -171,7 +233,7 @@ class TestBuildPart(unittest.TestCase):
def test_named_plane(self):
with BuildPart(Plane.YZ) as test:
self.assertTupleAlmostEquals(
WorkplaneList._get_context().workplanes[0].z_dir.to_tuple(),
WorkplaneList._get_context().workplanes[0].z_dir,
(1, 0, 0),
5,
)

View file

@ -29,19 +29,27 @@ license:
import math
import unittest
# Mocks for testing failure cases
from unittest.mock import MagicMock, patch
from build123d.build_enums import GeomType, Kind, Until
from build123d.geometry import (
Axis,
BoundBox,
Location,
OrientedBoundBox,
Plane,
Pos,
Vector,
)
from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.objects_curve import Spline
from build123d.objects_part import Box, Torus
from build123d.objects_sketch import Circle, Rectangle
from build123d.topology import Compound, Edge, Face, Shell, Solid, Vertex, Wire
from build123d.topology import (
Compound,
DraftAngleError,
Edge,
Face,
Shell,
Solid,
Vertex,
Wire,
)
import build123d
from OCP.BRepOffsetAPI import BRepOffsetAPI_DraftAngle
from OCP.StdFail import StdFail_NotDone
class TestSolid(unittest.TestCase):
@ -254,5 +262,66 @@ class TestSolid(unittest.TestCase):
self.assertAlmostEqual(obb2.volume, 40, 4)
class TestSolidDraft(unittest.TestCase):
def setUp(self):
# Create a simple box to test draft
self.box: Solid = Box(10, 10, 10).solid()
self.sides = self.box.faces().filter_by(Axis.Z, reverse=True)
self.bottom_face: 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 on a planar face"""
drafted = self.box.draft(self.sides, self.neutral_plane, 5)
self.assertIsInstance(drafted, Solid)
self.assertNotEqual(drafted.volume, self.box.volume)
def test_unsupported_geometry(self):
"""Test that a ValueError is raised on unsupported face geometry"""
# Create toroidal face to simulate unsupported geometry
torus = Torus(5, 1).solid()
with self.assertRaises(ValueError) as cm:
torus.draft([torus.faces()[0]], self.neutral_plane, 5)
self.assertIn("unsupported geometry type", str(cm.exception))
@patch("build123d.topology.three_d.BRepOffsetAPI_DraftAngle")
def test_adddone_failure_raises_draftangleerror(self, mock_draft_api):
"""Test that failure of AddDone() raises DraftAngleError"""
mock_builder = MagicMock()
mock_builder.AddDone.return_value = False
mock_builder.ProblematicShape.return_value = "BadShape"
mock_draft_api.return_value = mock_builder
with self.assertRaises(DraftAngleError) as cm:
self.box.draft(self.sides, self.neutral_plane, 5)
self.assertEqual(cm.exception.face, self.sides[0])
self.assertEqual(cm.exception.problematic_shape, "BadShape")
self.assertIn("Draft could not be added", str(cm.exception))
@patch.object(
build123d.topology.three_d.BRepOffsetAPI_DraftAngle,
"Build",
side_effect=StdFail_NotDone,
)
def test_build_failure_raises_draftangleerror(self, mock_draft_api):
"""Test that Build() failure raises DraftAngleError"""
with self.assertRaises(DraftAngleError) as cm:
self.box.draft(self.sides, self.neutral_plane, 5)
self.assertIsNone(cm.exception.face)
self.assertEqual(
cm.exception.problematic_shape, cm.exception.problematic_shape
) # Not None
self.assertIn("Draft build failed", str(cm.exception))
def test_draftangleerror_contents(self):
"""Test that DraftAngleError stores face and problematic shape"""
err = DraftAngleError("msg", face="face123", problematic_shape="shape456")
self.assertEqual(str(err), "msg")
self.assertEqual(err.face, "face123")
self.assertEqual(err.problematic_shape, "shape456")
if __name__ == "__main__":
unittest.main()