mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -08:00
Added draft operation Issue #807
This commit is contained in:
parent
30d26904ff
commit
44c3bac548
4 changed files with 264 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue