From 931cf4c15fc71eddb543cafa70dd2a7f95092ce5 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Mon, 11 Nov 2024 21:59:51 -0600
Subject: [PATCH 001/518] fix: correct type on to_align_offset
---
src/build123d/geometry.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 9f563e7..f07a97c 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -1019,7 +1019,7 @@ class BoundBox:
and second_box.max.Z < self.max.Z
)
- def to_align_offset(self, align: Tuple[float, float]) -> Tuple[float, float]:
+ def to_align_offset(self, align: Tuple[Align, Align]) -> List[float]:
"""Amount to move object to achieve the desired alignment"""
align_offset = []
for i in range(2):
From f2095d64cfe5a4d5daa86732dd53e73f34b82a93 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Mon, 11 Nov 2024 22:24:15 -0600
Subject: [PATCH 002/518] cleanup: remove duplicated aligning logic
---
src/build123d/build_common.py | 45 ++++++++++++++-------------------
src/build123d/geometry.py | 32 ++++++++++++++---------
src/build123d/objects_part.py | 13 ++--------
src/build123d/objects_sketch.py | 24 +++++++++---------
src/build123d/topology.py | 4 +--
tests/test_build_sketch.py | 7 ++++-
6 files changed, 60 insertions(+), 65 deletions(-)
diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py
index 28bada2..1dd5ddb 100644
--- a/src/build123d/build_common.py
+++ b/src/build123d/build_common.py
@@ -54,7 +54,14 @@ from typing import Any, Callable, Iterable, Optional, Union, TypeVar
from typing_extensions import Self, ParamSpec, Concatenate
from build123d.build_enums import Align, Mode, Select, Unit
-from build123d.geometry import Axis, Location, Plane, Vector, VectorLike
+from build123d.geometry import (
+ Axis,
+ Location,
+ Plane,
+ Vector,
+ VectorLike,
+ to_align_offset,
+)
from build123d.topology import (
Compound,
Curve,
@@ -963,14 +970,7 @@ class HexLocations(LocationList):
min_corner = Vector(sorted_points[0][0].X, sorted_points[1][0].Y)
# Calculate the amount to offset the array to align it
- align_offset = []
- for i in range(2):
- if self.align[i] == Align.MIN:
- align_offset.append(0)
- elif self.align[i] == Align.CENTER:
- align_offset.append(-size[i] / 2)
- elif self.align[i] == Align.MAX:
- align_offset.append(-size[i])
+ align_offset = to_align_offset((0, 0), size, align)
# Align the points
points = ShapeList(
@@ -1163,29 +1163,22 @@ class GridLocations(LocationList):
size = [x_spacing * (x_count - 1), y_spacing * (y_count - 1)]
self.size = Vector(*size) #: size of the grid
- align_offset = []
- for i in range(2):
- if self.align[i] == Align.MIN:
- align_offset.append(0.0)
- elif self.align[i] == Align.CENTER:
- align_offset.append(-size[i] / 2)
- elif self.align[i] == Align.MAX:
- align_offset.append(-size[i])
+ align_offset = to_align_offset((0, 0), size, align)
- self.min = Vector(*align_offset) #: bottom left corner
+ self.min = align_offset #: bottom left corner
self.max = self.min + self.size #: top right corner
# Create the list of local locations
- local_locations = []
- for i, j in product(range(x_count), range(y_count)):
- local_locations.append(
- Location(
- Vector(
- i * x_spacing + align_offset[0],
- j * y_spacing + align_offset[1],
- )
+ local_locations = [
+ Location(
+ align_offset
+ + Vector(
+ i * x_spacing,
+ j * y_spacing,
)
)
+ for i, j in product(range(x_count), range(y_count))
+ ]
self.local_locations = Locations._move_to_existing(
local_locations
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index f07a97c..9610f01 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -1019,19 +1019,9 @@ class BoundBox:
and second_box.max.Z < self.max.Z
)
- def to_align_offset(self, align: Tuple[Align, Align]) -> List[float]:
+ def to_align_offset(self, align: Sequence[Align]) -> Vector:
"""Amount to move object to achieve the desired alignment"""
- align_offset = []
- for i in range(2):
- if align[i] == Align.MIN:
- align_offset.append(-self.min.to_tuple()[i])
- elif align[i] == Align.CENTER:
- align_offset.append(
- -(self.min.to_tuple()[i] + self.max.to_tuple()[i]) / 2
- )
- elif align[i] == Align.MAX:
- align_offset.append(-self.max.to_tuple()[i])
- return align_offset
+ return to_align_offset(self.min.to_tuple(), self.max.to_tuple(), align)
class Color:
@@ -2534,3 +2524,21 @@ class Plane(metaclass=PlaneMeta):
elif shape is not None:
return shape.intersect(self)
+
+
+def to_align_offset(
+ min_point: Sequence[float],
+ max_point: Sequence[float],
+ align: Sequence[Align],
+) -> Vector:
+ """Amount to move object to achieve the desired alignment"""
+ align_offset = []
+
+ for alignment, min_coord, max_coord in zip(align, min_point, max_point):
+ if alignment == Align.MIN:
+ align_offset.append(-min_coord)
+ elif alignment == Align.CENTER:
+ align_offset.append(-(min_coord + max_coord) / 2)
+ elif alignment == Align.MAX:
+ align_offset.append(-max_coord)
+ return Vector(*align_offset)
diff --git a/src/build123d/objects_part.py b/src/build123d/objects_part.py
index 365683a..42b4e21 100644
--- a/src/build123d/objects_part.py
+++ b/src/build123d/objects_part.py
@@ -63,17 +63,8 @@ class BasePartObject(Part):
if align is not None:
align = tuplify(align, 3)
bbox = part.bounding_box()
- align_offset = []
- for i in range(3):
- if align[i] == Align.MIN:
- align_offset.append(-bbox.min.to_tuple()[i])
- elif align[i] == Align.CENTER:
- align_offset.append(
- -(bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2
- )
- elif align[i] == Align.MAX:
- align_offset.append(-bbox.max.to_tuple()[i])
- part.move(Location(Vector(*align_offset)))
+ offset = bbox.to_align_offset(align)
+ part.move(Location(offset))
context: BuildPart = BuildPart._get_context(self, log=False)
rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index 7958cec..3d6b2a7 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -36,7 +36,14 @@ from typing import Iterable, Union
from build123d.build_common import LocationList, flatten_sequence, validate_inputs
from build123d.build_enums import Align, FontStyle, Mode
from build123d.build_sketch import BuildSketch
-from build123d.geometry import Axis, Location, Rotation, Vector, VectorLike
+from build123d.geometry import (
+ Axis,
+ Location,
+ Rotation,
+ Vector,
+ VectorLike,
+ to_align_offset,
+)
from build123d.topology import (
Compound,
Edge,
@@ -74,7 +81,7 @@ class BaseSketchObject(Sketch):
):
if align is not None:
align = tuplify(align, 2)
- obj.move(Location(Vector(*obj.bounding_box().to_align_offset(align))))
+ obj.move(Location(obj.bounding_box().to_align_offset(align)))
context: BuildSketch = BuildSketch._get_context(self, log=False)
if context is None:
@@ -346,17 +353,10 @@ class RegularPolygon(BaseSketchObject):
if align is not None:
align = tuplify(align, 2)
- align_offset = []
- for i in range(2):
- if align[i] == Align.MIN:
- align_offset.append(-mins[i])
- elif align[i] == Align.CENTER:
- align_offset.append(0)
- elif align[i] == Align.MAX:
- align_offset.append(-maxs[i])
+ align_offset = to_align_offset(mins, maxs, align)
else:
- align_offset = [0, 0]
- pts = [point + Vector(*align_offset) for point in pts]
+ align_offset = Vector(0, 0)
+ pts = [point + align_offset for point in pts]
face = Face(Wire.make_polygon(pts))
super().__init__(face, rotation=0, align=None, mode=mode)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 955b2cd..40f6882 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -4415,9 +4415,7 @@ class Compound(Mixin3D, Shape):
# Align the text from the bounding box
align = tuplify(align, 2)
- text_flat = text_flat.translate(
- Vector(*text_flat.bounding_box().to_align_offset(align))
- )
+ text_flat = text_flat.translate(text_flat.bounding_box().to_align_offset(align))
if text_path is not None:
path_length = text_path.length
diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py
index b4f8fbe..23707c2 100644
--- a/tests/test_build_sketch.py
+++ b/tests/test_build_sketch.py
@@ -27,7 +27,10 @@ license:
"""
import unittest
-from math import pi, sqrt, atan2, degrees
+from math import atan2, degrees, pi, sqrt
+
+import pytest
+
from build123d import *
@@ -278,6 +281,7 @@ class TestBuildSketchObjects(unittest.TestCase):
test.sketch.faces()[0].normal_at().to_tuple(), (0, 0, 1), 5
)
+ @pytest.mark.skip(reason="Conflicts with test_regular_polygon_matches_polar")
def test_regular_polygon_align(self):
with BuildSketch() as align:
RegularPolygon(2, 5, align=(Align.MIN, Align.MAX))
@@ -293,6 +297,7 @@ class TestBuildSketchObjects(unittest.TestCase):
Vector(align.vertices().sort_by_distance(other=(0, 0, 0))[-1]).length, 2
)
+ @pytest.mark.skip(reason="Conflicts with test_regular_polygon_align")
def test_regular_polygon_matches_polar(self):
for side_count in range(3, 10):
with BuildSketch():
From cf11f91f2dff653e723c68dc289cfac8355da756 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Tue, 12 Nov 2024 17:59:00 -0600
Subject: [PATCH 003/518] feat: add align.NONE
---
src/build123d/build_enums.py | 1 +
src/build123d/geometry.py | 2 ++
src/build123d/objects_sketch.py | 2 +-
tests/test_build_sketch.py | 8 ++------
4 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py
index fe8ba0e..9802821 100644
--- a/src/build123d/build_enums.py
+++ b/src/build123d/build_enums.py
@@ -36,6 +36,7 @@ class Align(Enum):
MIN = auto()
CENTER = auto()
MAX = auto()
+ NONE = None
def __repr__(self):
return f"<{self.__class__.__name__}.{self.name}>"
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 9610f01..4b0c1fc 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -2541,4 +2541,6 @@ def to_align_offset(
align_offset.append(-(min_coord + max_coord) / 2)
elif alignment == Align.MAX:
align_offset.append(-max_coord)
+ elif alignment == Align.NONE:
+ align_offset.append(0)
return Vector(*align_offset)
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index 3d6b2a7..04c296b 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -311,7 +311,7 @@ class RegularPolygon(BaseSketchObject):
side_count: int,
major_radius: bool = True,
rotation: float = 0,
- align: tuple[Align, Align] = (Align.CENTER, Align.CENTER),
+ align: tuple[Align, Align] = (Align.NONE, Align.NONE),
mode: Mode = Mode.ADD,
):
# pylint: disable=too-many-locals
diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py
index 23707c2..03fb388 100644
--- a/tests/test_build_sketch.py
+++ b/tests/test_build_sketch.py
@@ -29,8 +29,6 @@ license:
import unittest
from math import atan2, degrees, pi, sqrt
-import pytest
-
from build123d import *
@@ -260,7 +258,7 @@ class TestBuildSketchObjects(unittest.TestCase):
self.assertEqual(r.radius, 2)
self.assertEqual(r.side_count, 6)
self.assertEqual(r.rotation, 0)
- self.assertEqual(r.align, (Align.CENTER, Align.CENTER))
+ self.assertEqual(r.align, (Align.NONE, Align.NONE))
self.assertEqual(r.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 2) * 2**2, 5)
self.assertTupleAlmostEquals(
@@ -274,14 +272,13 @@ class TestBuildSketchObjects(unittest.TestCase):
self.assertAlmostEqual(r.radius, 1, 5)
self.assertEqual(r.side_count, 3)
self.assertEqual(r.rotation, 0)
- self.assertEqual(r.align, (Align.CENTER, Align.CENTER))
+ self.assertEqual(r.align, (Align.NONE, Align.NONE))
self.assertEqual(r.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 4) * (0.5 * 2) ** 2, 5)
self.assertTupleAlmostEquals(
test.sketch.faces()[0].normal_at().to_tuple(), (0, 0, 1), 5
)
- @pytest.mark.skip(reason="Conflicts with test_regular_polygon_matches_polar")
def test_regular_polygon_align(self):
with BuildSketch() as align:
RegularPolygon(2, 5, align=(Align.MIN, Align.MAX))
@@ -297,7 +294,6 @@ class TestBuildSketchObjects(unittest.TestCase):
Vector(align.vertices().sort_by_distance(other=(0, 0, 0))[-1]).length, 2
)
- @pytest.mark.skip(reason="Conflicts with test_regular_polygon_align")
def test_regular_polygon_matches_polar(self):
for side_count in range(3, 10):
with BuildSketch():
From 54bc6a468151d14f29c8e3863185b26855c043bd Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Tue, 12 Nov 2024 21:43:41 -0600
Subject: [PATCH 004/518] feat: optional center argument for to_align_offset
---
src/build123d/geometry.py | 16 ++++++++++++++--
src/build123d/objects_sketch.py | 4 ++--
tests/test_build_sketch.py | 4 ++--
3 files changed, 18 insertions(+), 6 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 4b0c1fc..026e88e 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -2530,15 +2530,27 @@ def to_align_offset(
min_point: Sequence[float],
max_point: Sequence[float],
align: Sequence[Align],
+ center: Optional[Sequence[float]] = None,
) -> Vector:
"""Amount to move object to achieve the desired alignment"""
align_offset = []
- for alignment, min_coord, max_coord in zip(align, min_point, max_point):
+ if center is None:
+ center = [
+ (min_coord + max_coord) / 2
+ for min_coord, max_coord in zip(min_point, max_point)
+ ]
+
+ for alignment, min_coord, max_coord, center_coord in zip(
+ align,
+ min_point,
+ max_point,
+ center,
+ ):
if alignment == Align.MIN:
align_offset.append(-min_coord)
elif alignment == Align.CENTER:
- align_offset.append(-(min_coord + max_coord) / 2)
+ align_offset.append(-center_coord)
elif alignment == Align.MAX:
align_offset.append(-max_coord)
elif alignment == Align.NONE:
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index 04c296b..6d68533 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -311,7 +311,7 @@ class RegularPolygon(BaseSketchObject):
side_count: int,
major_radius: bool = True,
rotation: float = 0,
- align: tuple[Align, Align] = (Align.NONE, Align.NONE),
+ align: tuple[Align, Align] = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD,
):
# pylint: disable=too-many-locals
@@ -353,7 +353,7 @@ class RegularPolygon(BaseSketchObject):
if align is not None:
align = tuplify(align, 2)
- align_offset = to_align_offset(mins, maxs, align)
+ align_offset = to_align_offset(mins, maxs, align, center=(0, 0))
else:
align_offset = Vector(0, 0)
pts = [point + align_offset for point in pts]
diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py
index 03fb388..fc890bd 100644
--- a/tests/test_build_sketch.py
+++ b/tests/test_build_sketch.py
@@ -258,7 +258,7 @@ class TestBuildSketchObjects(unittest.TestCase):
self.assertEqual(r.radius, 2)
self.assertEqual(r.side_count, 6)
self.assertEqual(r.rotation, 0)
- self.assertEqual(r.align, (Align.NONE, Align.NONE))
+ self.assertEqual(r.align, (Align.CENTER, Align.CENTER))
self.assertEqual(r.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 2) * 2**2, 5)
self.assertTupleAlmostEquals(
@@ -272,7 +272,7 @@ class TestBuildSketchObjects(unittest.TestCase):
self.assertAlmostEqual(r.radius, 1, 5)
self.assertEqual(r.side_count, 3)
self.assertEqual(r.rotation, 0)
- self.assertEqual(r.align, (Align.NONE, Align.NONE))
+ self.assertEqual(r.align, (Align.CENTER, Align.CENTER))
self.assertEqual(r.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 4) * (0.5 * 2) ** 2, 5)
self.assertTupleAlmostEquals(
From d86811d2dc862f7aa04e523bf0515f4a700dab61 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Wed, 13 Nov 2024 21:05:58 -0600
Subject: [PATCH 005/518] feat: add Align2DType and Align3DType
---
src/build123d/build_enums.py | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py
index 9802821..627d0b2 100644
--- a/src/build123d/build_enums.py
+++ b/src/build123d/build_enums.py
@@ -28,6 +28,7 @@ license:
from __future__ import annotations
from enum import Enum, auto
+from typing import Union, TypeAlias
class Align(Enum):
@@ -42,6 +43,17 @@ class Align(Enum):
return f"<{self.__class__.__name__}.{self.name}>"
+Align2DType: TypeAlias = Union[
+ Union[Align, None],
+ tuple[Union[Align, None], Union[Align, None]],
+]
+
+Align3DType: TypeAlias = Union[
+ Union[Align, None],
+ tuple[Union[Align, None], Union[Align, None], Union[Align, None]],
+]
+
+
class ApproxOption(Enum):
"""DXF export spline approximation strategy"""
From b43deebbcc8b0e9de87db57092e0169619682482 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Wed, 13 Nov 2024 21:10:33 -0600
Subject: [PATCH 006/518] fix: make typesignature on to_align_offset more
precise
---
src/build123d/geometry.py | 28 +++++++++++++++++-----------
src/build123d/objects_sketch.py | 6 +-----
2 files changed, 18 insertions(+), 16 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 026e88e..da7b10f 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -82,7 +82,7 @@ from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Face, TopoDS_Shape, TopoDS_Vertex
-from build123d.build_enums import Align, Intrinsic, Extrinsic
+from build123d.build_enums import Align, Align2DType, Align3DType, Intrinsic, Extrinsic
# Create a build123d logger to distinguish these logs from application logs.
# If the user doesn't configure logging, all build123d logs will be discarded.
@@ -1019,7 +1019,7 @@ class BoundBox:
and second_box.max.Z < self.max.Z
)
- def to_align_offset(self, align: Sequence[Align]) -> Vector:
+ def to_align_offset(self, align: Union[Align2DType, Align3DType]) -> Vector:
"""Amount to move object to achieve the desired alignment"""
return to_align_offset(self.min.to_tuple(), self.max.to_tuple(), align)
@@ -2527,22 +2527,28 @@ class Plane(metaclass=PlaneMeta):
def to_align_offset(
- min_point: Sequence[float],
- max_point: Sequence[float],
- align: Sequence[Align],
- center: Optional[Sequence[float]] = None,
+ min_point: VectorLike,
+ max_point: VectorLike,
+ align: Union[Align2DType, Align3DType],
+ center: Optional[VectorLike] = None,
) -> Vector:
"""Amount to move object to achieve the desired alignment"""
align_offset = []
if center is None:
- center = [
- (min_coord + max_coord) / 2
- for min_coord, max_coord in zip(min_point, max_point)
- ]
+ center = (Vector(min_point) + Vector(max_point)) / 2
+
+ if align is None or align is Align.NONE:
+ return Vector(0, 0, 0)
+ if align is Align.MIN:
+ return Vector(min_point)
+ if align is Align.MAX:
+ return Vector(max_point)
+ if align is Align.CENTER:
+ return Vector(center)
for alignment, min_coord, max_coord, center_coord in zip(
- align,
+ map(Align, align),
min_point,
max_point,
center,
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index 6d68533..984c9b5 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -351,11 +351,7 @@ class RegularPolygon(BaseSketchObject):
mins = [pts_sorted[0][0].X, pts_sorted[1][0].Y]
maxs = [pts_sorted[0][-1].X, pts_sorted[1][-1].Y]
- if align is not None:
- align = tuplify(align, 2)
- align_offset = to_align_offset(mins, maxs, align, center=(0, 0))
- else:
- align_offset = Vector(0, 0)
+ align_offset = to_align_offset(mins, maxs, align, center=(0, 0))
pts = [point + align_offset for point in pts]
face = Face(Wire.make_polygon(pts))
From 8e5cd102cdd4151003e7f63bd3349d5d3442d753 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Wed, 13 Nov 2024 21:17:09 -0600
Subject: [PATCH 007/518] fix: import type alias from typing_extensions
---
src/build123d/build_enums.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py
index 627d0b2..ab98131 100644
--- a/src/build123d/build_enums.py
+++ b/src/build123d/build_enums.py
@@ -27,8 +27,11 @@ license:
"""
from __future__ import annotations
+
from enum import Enum, auto
-from typing import Union, TypeAlias
+from typing import Union
+
+from typing_extensions import TypeAlias
class Align(Enum):
From e8365566f70a26b273c387d07ec4dd2b4392a28d Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sat, 16 Nov 2024 19:44:11 -0600
Subject: [PATCH 008/518] test: `to_align_offset` behaves as expected
---
tests/test_align.py | 79 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 79 insertions(+)
create mode 100644 tests/test_align.py
diff --git a/tests/test_align.py b/tests/test_align.py
new file mode 100644
index 0000000..423a982
--- /dev/null
+++ b/tests/test_align.py
@@ -0,0 +1,79 @@
+import pytest
+
+from build123d.build_enums import Align
+from build123d.geometry import Vector, to_align_offset
+
+
+@pytest.mark.parametrize(
+ "x_align,x_expect",
+ [
+ (Align.MAX, -0.5),
+ (Align.CENTER, 0.25),
+ (Align.MIN, 1),
+ (Align.NONE, 0),
+ ],
+)
+@pytest.mark.parametrize(
+ "y_align,y_expect",
+ [
+ (Align.MAX, -1),
+ (Align.CENTER, 0.25),
+ (Align.MIN, 1.5),
+ (Align.NONE, 0),
+ ],
+)
+@pytest.mark.parametrize(
+ "z_align,z_expect",
+ [
+ (Align.MAX, -1),
+ (Align.CENTER, -0.75),
+ (Align.MIN, -0.5),
+ (Align.NONE, 0),
+ ],
+)
+def test_align(
+ x_align,
+ x_expect,
+ y_align,
+ y_expect,
+ z_align,
+ z_expect,
+):
+ offset = to_align_offset(
+ min_point=(-1, -1.5, 0.5),
+ max_point=(0.5, 1.0, 1.0),
+ align=(x_align, y_align, z_align),
+ )
+ assert offset.X == x_expect
+ assert offset.Y == y_expect
+ assert offset.Z == z_expect
+
+
+@pytest.mark.parametrize("alignment", Align)
+def test_align_single(alignment):
+ min_point = (-1, -1.5, 0.5)
+ max_point = (0.5, 1, 1)
+ expected = to_align_offset(
+ min_point=min_point,
+ max_point=max_point,
+ align=(alignment, alignment, alignment),
+ )
+ offset = to_align_offset(
+ min_point=min_point,
+ max_point=max_point,
+ align=alignment,
+ )
+ assert expected == offset
+
+
+def test_align_center():
+ min_point = (-1, -1.5, 0.5)
+ max_point = (0.5, 1, 1)
+ center = (4, 2, 6)
+ offset = to_align_offset(
+ min_point=min_point,
+ max_point=max_point,
+ center=center,
+ align=Align.CENTER,
+ )
+ assert offset == -Vector(center)
From 2ceef0307ac69247ac9b158e2d5153041fcbbf04 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sat, 16 Nov 2024 19:45:12 -0600
Subject: [PATCH 009/518] fix: return -Vector if a single alignment
---
src/build123d/geometry.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index da7b10f..94ad4b0 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -2541,11 +2541,11 @@ def to_align_offset(
if align is None or align is Align.NONE:
return Vector(0, 0, 0)
if align is Align.MIN:
- return Vector(min_point)
+ return -Vector(min_point)
if align is Align.MAX:
- return Vector(max_point)
+ return -Vector(max_point)
if align is Align.CENTER:
- return Vector(center)
+ return -Vector(center)
for alignment, min_coord, max_coord, center_coord in zip(
map(Align, align),
From 1c4b8ff0458269cc59b98e770965f3ab4c6c7721 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sun, 17 Nov 2024 08:20:03 -0600
Subject: [PATCH 010/518] test: check for invalid slots
---
tests/test_build_sketch.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py
index fc890bd..b561409 100644
--- a/tests/test_build_sketch.py
+++ b/tests/test_build_sketch.py
@@ -29,6 +29,8 @@ license:
import unittest
from math import atan2, degrees, pi, sqrt
+import pytest
+
from build123d import *
@@ -509,5 +511,18 @@ class TestBuildSketchObjects(unittest.TestCase):
self.assertTupleAlmostEquals(tuple(c1), tuple(c2), 2)
+@pytest.mark.parametrize(
+ "slot,args",
+ [
+ (SlotOverall, (5, 10)),
+ (SlotCenterToCenter, (-1, 10)),
+ (SlotCenterPoint, ((0, 0, 0), (2, 0, 0), 10)),
+ ],
+)
+def test_invalid_slots(slot, args):
+ with pytest.raises(ValueError):
+ slot(*args)
+
+
if __name__ == "__main__":
unittest.main(failfast=True)
From a5aa8cbc9934c53bc47bc220ee15de7cb1cfb681 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sun, 17 Nov 2024 08:34:39 -0600
Subject: [PATCH 011/518] fix: SlotOverall errors if width <= height
---
src/build123d/objects_sketch.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index 984c9b5..79ef520 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -499,6 +499,11 @@ class SlotOverall(BaseSketchObject):
align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD,
):
+ if width <= height:
+ raise ValueError(
+ f"Slot requires that width > height. Got: {width=}, {height=}"
+ )
+
context = BuildSketch._get_context(self)
validate_inputs(context, self)
From fa5e8deb3e80a13094b9f6b29c6be67a1903df95 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sun, 17 Nov 2024 08:35:27 -0600
Subject: [PATCH 012/518] fix: SlotCenterToCenter errors if center_separation
<= 0
---
src/build123d/objects_sketch.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index 79ef520..abb8b5d 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -458,6 +458,11 @@ class SlotCenterToCenter(BaseSketchObject):
rotation: float = 0,
mode: Mode = Mode.ADD,
):
+ if center_separation <= 0:
+ raise ValueError(
+ f"Requires center_separation > 0. Got: {center_separation=}"
+ )
+
context = BuildSketch._get_context(self)
validate_inputs(context, self)
From 55345a4bc5e0fda3275931bcce9a03bf7e94120d Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sun, 17 Nov 2024 08:37:28 -0600
Subject: [PATCH 013/518] fix: SlotCenterPoint errors on width <= height
---
src/build123d/objects_sketch.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index abb8b5d..ca85a60 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -425,6 +425,13 @@ class SlotCenterPoint(BaseSketchObject):
self.slot_height = height
half_line = point_v - center_v
+
+ if half_line.length * 2 <= height:
+ raise ValueError(
+ f"Slots must have width > height. "
+ "Got: {height=} width={half_line.length * 2} (computed)"
+ )
+
face = Face(
Wire.combine(
[
From c31e92a165258b7b4ce99aefa6be8758aac540b2 Mon Sep 17 00:00:00 2001
From: Ethan Rooke
Date: Sun, 17 Nov 2024 09:02:07 -0600
Subject: [PATCH 014/518] test: fix test_random_slots
---
tests/test_pack.py | 15 ++++++---------
1 file changed, 6 insertions(+), 9 deletions(-)
diff --git a/tests/test_pack.py b/tests/test_pack.py
index 736db50..56f64f7 100644
--- a/tests/test_pack.py
+++ b/tests/test_pack.py
@@ -8,10 +8,8 @@ date: November 9th 2023
desc: Unit tests for the build123d pack module
"""
-import operator
import random
import unittest
-from functools import reduce
from build123d import *
@@ -53,15 +51,14 @@ class TestPack(unittest.TestCase):
random.seed(123456)
# 50 is an arbitrary number that is large enough to exercise
# different aspects of the packer while still completing quickly.
- inputs = [
- SlotOverall(random.randint(1, 20), random.randint(1, 20)) for _ in range(50)
- ]
+ widths = [random.randint(2, 20) for _ in range(50)]
+ heights = [random.randint(1, width - 1) for width in widths]
+ inputs = [SlotOverall(width, height) for width, height in zip(widths, heights)]
# Not raising in this call shows successfull non-overlap.
packed = pack(inputs, 1)
- self.assertEqual(
- "bbox: 0.0 <= x <= 124.0, 0.0 <= y <= 105.0, 0.0 <= z <= 0.0",
- str((Sketch() + packed).bounding_box()),
- )
+ bb = (Sketch() + packed).bounding_box()
+ self.assertEqual(bb.min, Vector(0, 0, 0))
+ self.assertEqual(bb.max, Vector(70, 63, 0))
if __name__ == "__main__":
From 3b0fcb017a8f629af5636780b18bf1becb5fb4d4 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 18 Nov 2024 14:18:37 -0500
Subject: [PATCH 015/518] Replaced unwrap with unwrap_topods_compound in
topology.py Issue #788
---
src/build123d/topology.py | 73 +++++++++++++++++++++++++--------------
tests/test_direct_api.py | 25 +++++++++++++-
2 files changed, 72 insertions(+), 26 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 40f6882..8653c43 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -1732,13 +1732,6 @@ class Shape(NodeMixin):
else:
sum_shape = self.fuse(*summands)
- # Simplify Compounds if possible
- sum_shape = (
- sum_shape.unwrap(fully=True)
- if isinstance(sum_shape, Compound)
- else sum_shape
- )
-
if SkipClean.clean:
sum_shape = sum_shape.clean()
@@ -1782,12 +1775,6 @@ class Shape(NodeMixin):
# Do the actual cut operation
difference = self.cut(*subtrahends)
- # Simplify Compounds if possible
- difference = (
- difference.unwrap(fully=True)
- if isinstance(difference, Compound)
- else difference
- )
# To allow the @, % and ^ operators to work 1D objects must be type Curve
if minuend_dim == 1:
difference = Curve(Compound(difference.edges()).wrapped)
@@ -1805,13 +1792,6 @@ class Shape(NodeMixin):
if new_shape.wrapped is not None and SkipClean.clean:
new_shape = new_shape.clean()
- # Simplify Compounds if possible
- new_shape = (
- new_shape.unwrap(fully=True)
- if isinstance(new_shape, Compound)
- else new_shape
- )
-
# To allow the @, % and ^ operators to work 1D objects must be type Curve
if self._dim == 1:
new_shape = Curve(Compound(new_shape.edges()).wrapped)
@@ -2621,7 +2601,11 @@ class Shape(NodeMixin):
operation.SetRunParallel(True)
operation.Build()
- result = Shape.cast(operation.Shape())
+ result = downcast(operation.Shape())
+ # Remove unnecessary TopoDS_Compound around single shape
+ if isinstance(result, TopoDS_Compound):
+ result = unwrap_topods_compound(result, True)
+ result = Shape.cast(result)
base = args[0] if isinstance(args, tuple) else args
base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
@@ -2829,7 +2813,12 @@ class Shape(NodeMixin):
# Perform the splitting operation
splitter.Build()
- result = Compound(downcast(splitter.Shape())).unwrap(fully=False)
+ result = downcast(splitter.Shape())
+ # Remove unnecessary TopoDS_Compound around single shape
+ if isinstance(result, TopoDS_Compound):
+ result = unwrap_topods_compound(result, False)
+ result = Shape.cast(result)
+
if keep != Keep.BOTH:
if not isinstance(tool, Plane):
# Create solids from the surfaces for sorting
@@ -2843,7 +2832,8 @@ class Shape(NodeMixin):
(tops if is_up else bottoms).append(part)
result = Compound(tops) if keep == Keep.TOP else Compound(bottoms)
- return result.unwrap(fully=True)
+ result_wrapped = unwrap_topods_compound(result.wrapped, fully=True)
+ return Shape.cast(result_wrapped)
@overload
def split_by_perimeter(
@@ -4415,7 +4405,9 @@ class Compound(Mixin3D, Shape):
# Align the text from the bounding box
align = tuplify(align, 2)
- text_flat = text_flat.translate(text_flat.bounding_box().to_align_offset(align))
+ text_flat = text_flat.translate(
+ Vector(*text_flat.bounding_box().to_align_offset(align))
+ )
if text_path is not None:
path_length = text_path.length
@@ -8464,7 +8456,10 @@ class Wire(Mixin1D, Shape):
wire_builder.Build()
if not wire_builder.IsDone():
if wire_builder.Error() == BRepBuilderAPI_NonManifoldWire:
- warnings.warn("Wire is non manifold", stacklevel=2)
+ warnings.warn(
+ "Wire is non manifold (e.g. branching, self intersecting)",
+ stacklevel=2,
+ )
elif wire_builder.Error() == BRepBuilderAPI_EmptyWire:
raise RuntimeError("Wire is empty")
elif wire_builder.Error() == BRepBuilderAPI_DisconnectedWire:
@@ -9232,6 +9227,34 @@ def topo_explore_common_vertex(
return None # No common vertex found
+def unwrap_topods_compound(
+ compound: TopoDS_Compound, fully: bool = True
+) -> Union[TopoDS_Compound, TopoDS_Shape]:
+ """Strip unnecessary Compound wrappers
+
+ Args:
+ compound (TopoDS_Compound): The TopoDS_Compound to unwrap.
+ fully (bool, optional): return base shape without any TopoDS_Compound
+ wrappers (otherwise one TopoDS_Compound is left). Defaults to True.
+
+ Returns:
+ Union[TopoDS_Compound, TopoDS_Shape]: base shape
+ """
+
+ if compound.NbChildren() == 1:
+ iterator = TopoDS_Iterator(compound)
+ single_element = downcast(iterator.Value())
+
+ # If the single element is another TopoDS_Compound, unwrap it recursively
+ if isinstance(single_element, TopoDS_Compound):
+ return unwrap_topods_compound(single_element, fully)
+
+ return single_element if fully else compound
+
+ # If there are no elements or more than one element, return TopoDS_Compound
+ return compound
+
+
class SkipClean:
"""Skip clean context for use in operator driven code where clean=False wouldn't work"""
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 8f5e62d..307dad0 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -90,6 +90,7 @@ from build123d.topology import (
polar,
new_edges,
delta,
+ unwrap_topods_compound,
)
from build123d.jupyter_tools import display
@@ -1577,6 +1578,28 @@ class TestFunctions(unittest.TestCase):
with self.assertRaises(TypeError):
Vector(1, 1, 1) & ("x", "y", "z")
+ def test_unwrap_topods_compound(self):
+ # Complex Compound
+ b1 = Box(1, 1, 1).solid()
+ b2 = Box(2, 2, 2).solid()
+ c1 = Compound([b1, b2])
+ c2 = Compound([b1, c1])
+ c3 = Compound([c2])
+ c4 = Compound([c3])
+ self.assertEqual(c4.wrapped.NbChildren(), 1)
+ c5 = Compound(unwrap_topods_compound(c4.wrapped, False))
+ self.assertEqual(c5.wrapped.NbChildren(), 2)
+
+ # unwrap fully
+ c0 = Compound([b1])
+ c1 = Compound([c0])
+ result = Shape.cast(unwrap_topods_compound(c1.wrapped, True))
+ self.assertTrue(isinstance(result, Solid))
+
+ # unwrap not fully
+ result = Shape.cast(unwrap_topods_compound(c1.wrapped, False))
+ self.assertTrue(isinstance(result, Compound))
+
class TestImportExport(DirectApiTestCase):
def test_import_export(self):
@@ -2579,7 +2602,7 @@ class TestPlane(DirectApiTestCase):
(
Axis.X.direction, # plane x_dir
Axis.Z.direction, # plane y_dir
- -Axis.Y.direction, # plane z_dir
+ -Axis.Y.direction, # plane z_dir
),
# Trapezoid face, positive y coordinate
(
From e82b20a575acf3de228b48024cc65aaaad0d285a Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 19 Nov 2024 13:46:54 -0500
Subject: [PATCH 016/518] Curve object become Wire/Edge, replace
Compound.first_level_shapes with Shape.get_top_level_shapes Issue #788
---
src/build123d/topology.py | 257 ++++++++++++++++++++++----------------
tests/test_algebra.py | 64 +++++++++-
tests/test_direct_api.py | 14 ++-
3 files changed, 218 insertions(+), 117 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 8653c43..ea8cfad 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -382,6 +382,46 @@ def tuplify(obj: Any, dim: int) -> tuple:
class Mixin1D:
"""Methods to add to the Edge and Wire classes"""
+ def __add__(self, other: Union[list[Shape], Shape]) -> Self:
+ """fuse shape to wire/edge operator +"""
+
+ # Convert `other` to list of base objects and filter out None values
+ summands = [
+ shape
+ for o in (other if isinstance(other, (list, tuple)) else [other])
+ if o is not None
+ for shape in o.get_top_level_shapes()
+ ]
+ # If there is nothing to add return the original object
+ if not summands:
+ return self
+
+ if not all(summand._dim == 1 for summand in summands):
+ raise ValueError("Only shapes with the same dimension can be added")
+
+ summand_edges = [e for summand in summands for e in summand.edges()]
+ if self.wrapped is None: # an empty object
+ if len(summands) == 1:
+ sum_shape = summands[0]
+ else:
+ try:
+ sum_shape = Wire(summand_edges)
+ except Exception:
+ sum_shape = summands[0].fuse(*summands[1:])
+ else:
+ try:
+ sum_shape = Wire(self.edges() + summand_edges)
+ except Exception:
+ sum_shape = self.fuse(*summands)
+
+ if SkipClean.clean:
+ sum_shape = sum_shape.clean()
+
+ # If there is only one Edge, return that
+ sum_shape = sum_shape.edge() if len(sum_shape.edges()) == 1 else sum_shape
+
+ return sum_shape
+
def start_point(self) -> Vector:
"""The start point of this edge
@@ -1710,7 +1750,7 @@ class Shape(NodeMixin):
shape
for o in (other if isinstance(other, (list, tuple)) else [other])
if o is not None
- for shape in (o.first_level_shapes() if isinstance(o, Compound) else [o])
+ for shape in o.get_top_level_shapes()
]
# If there is nothing to add return the original object
if not summands:
@@ -1735,10 +1775,6 @@ class Shape(NodeMixin):
if SkipClean.clean:
sum_shape = sum_shape.clean()
- # To allow the @, % and ^ operators to work 1D objects must be type Curve
- if addend_dim == 1:
- sum_shape = Curve(Compound(sum_shape.edges()).wrapped)
-
return sum_shape
def __sub__(self, other: Union[Shape, Iterable[Shape]]) -> Self:
@@ -1752,7 +1788,7 @@ class Shape(NodeMixin):
shape
for o in (other if isinstance(other, (list, tuple)) else [other])
if o is not None
- for shape in (o.first_level_shapes() if isinstance(o, Compound) else [o])
+ for shape in o.get_top_level_shapes()
]
# If there is nothing to subtract return the original object
if not subtrahends:
@@ -1775,10 +1811,6 @@ class Shape(NodeMixin):
# Do the actual cut operation
difference = self.cut(*subtrahends)
- # To allow the @, % and ^ operators to work 1D objects must be type Curve
- if minuend_dim == 1:
- difference = Curve(Compound(difference.edges()).wrapped)
-
return difference
def __and__(self, other: Shape) -> Self:
@@ -1792,10 +1824,6 @@ class Shape(NodeMixin):
if new_shape.wrapped is not None and SkipClean.clean:
new_shape = new_shape.clean()
- # To allow the @, % and ^ operators to work 1D objects must be type Curve
- if self._dim == 1:
- new_shape = Curve(Compound(new_shape.edges()).wrapped)
-
return new_shape
def __rmul__(self, other):
@@ -1828,10 +1856,7 @@ class Shape(NodeMixin):
upgrader.Build()
self.wrapped = downcast(upgrader.Shape())
except Exception:
- warnings.warn(
- f"Unable to clean {self}",
- stacklevel=2,
- )
+ warnings.warn(f"Unable to clean {self}", stacklevel=2)
return self
def fix(self) -> Self:
@@ -2175,8 +2200,53 @@ class Shape(NodeMixin):
return out
+ def get_top_level_shapes(self) -> ShapeList[Shape]:
+ """
+ Retrieve the first level of child shapes from the shape.
+
+ This method collects all the non-compound shapes directly contained in the
+ current shape. If the wrapped shape is a `TopoDS_Compound`, it traverses
+ its immediate children and collects all shapes that are not further nested
+ compounds. Nested compounds are traversed to gather their non-compound elements
+ without returning the nested compound itself.
+
+ Returns:
+ ShapeList[Shape]: A list of all first-level non-compound child shapes.
+
+ Example:
+ If the current shape is a compound containing both simple shapes
+ (e.g., edges, vertices) and other compounds, the method returns a list
+ of only the simple shapes directly contained at the top level.
+ """
+ if self.wrapped is None:
+ return ShapeList()
+
+ first_level_shapes = []
+ stack = [self]
+
+ while stack:
+ current_shape = stack.pop()
+ if isinstance(current_shape.wrapped, TopoDS_Compound):
+ iterator = TopoDS_Iterator()
+ iterator.Initialize(current_shape.wrapped)
+ while iterator.More():
+ child_shape = Shape.cast(iterator.Value())
+ if isinstance(child_shape.wrapped, TopoDS_Compound):
+ # Traverse further into the compound
+ stack.append(child_shape)
+ else:
+ # Add non-compound shape
+ first_level_shapes.append(child_shape)
+ iterator.Next()
+ else:
+ first_level_shapes.append(current_shape)
+
+ return ShapeList(first_level_shapes)
+
def vertices(self) -> ShapeList[Vertex]:
"""vertices - all the vertices in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
vertex_list = ShapeList(
[Vertex(downcast(i)) for i in self._entities(Vertex.__name__)]
)
@@ -2190,13 +2260,14 @@ class Shape(NodeMixin):
vertex_count = len(vertices)
if vertex_count != 1:
warnings.warn(
- f"Found {vertex_count} vertices, returning first",
- stacklevel=2,
+ f"Found {vertex_count} vertices, returning first", stacklevel=2
)
return vertices[0]
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
edge_list = ShapeList(
[
Edge(i)
@@ -2221,6 +2292,8 @@ class Shape(NodeMixin):
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
if isinstance(self, Compound):
# pylint: disable=not-an-iterable
sub_compounds = [c for c in self if isinstance(c, Compound)]
@@ -2242,6 +2315,8 @@ class Shape(NodeMixin):
def wires(self) -> ShapeList[Wire]:
"""wires - all the wires in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
return ShapeList([Wire(i) for i in self._entities(Wire.__name__)])
def wire(self) -> Wire:
@@ -2257,6 +2332,8 @@ class Shape(NodeMixin):
def faces(self) -> ShapeList[Face]:
"""faces - all the faces in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
face_list = ShapeList([Face(i) for i in self._entities(Face.__name__)])
for face in face_list:
face.topo_parent = self
@@ -2273,6 +2350,8 @@ class Shape(NodeMixin):
def shells(self) -> ShapeList[Shell]:
"""shells - all the shells in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
return ShapeList([Shell(i) for i in self._entities(Shell.__name__)])
def shell(self) -> Shell:
@@ -2288,6 +2367,8 @@ class Shape(NodeMixin):
def solids(self) -> ShapeList[Solid]:
"""solids - all the solids in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
return ShapeList([Solid(i) for i in self._entities(Solid.__name__)])
def solid(self) -> Solid:
@@ -2607,7 +2688,7 @@ class Shape(NodeMixin):
result = unwrap_topods_compound(result, True)
result = Shape.cast(result)
- base = args[0] if isinstance(args, tuple) else args
+ base = args[0] if isinstance(args, (list, tuple)) else args
base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
return result
@@ -4015,7 +4096,7 @@ class Compound(Mixin3D, Shape):
@property
def _dim(self) -> Union[int, None]:
"""The dimension of the shapes within the Compound - None if inconsistent"""
- sub_dims = {s._dim for s in self.first_level_shapes()}
+ sub_dims = {s._dim for s in self.get_top_level_shapes()}
return sub_dims.pop() if len(sub_dims) == 1 else None
@overload
@@ -4252,6 +4333,47 @@ class Compound(Mixin3D, Shape):
# else:
# logger.debug("Adding no children to %s", self.label)
+ def __add__(self, other: Union[list[Shape], Shape]) -> Shape:
+ """Combine other to self `+` operator
+
+ Note that if all of the objects are connected Edges/Wires the result
+ will be a Wire, otherwise a Shape.
+ """
+ if self._dim == 1:
+ curve = Curve() if self.wrapped is None else Curve(self.wrapped)
+ self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
+ return curve + other
+ else:
+ summands = [
+ shape
+ for o in (other if isinstance(other, (list, tuple)) else [other])
+ if o is not None
+ for shape in o.get_top_level_shapes()
+ ]
+ # If there is nothing to add return the original object
+ if not summands:
+ return self
+
+ summands = [
+ s for s in self.get_top_level_shapes() + summands if s is not None
+ ]
+
+ # Only fuse the parts if necessary
+ if len(summands) <= 1:
+ result: Shape = summands[0]
+ else:
+ fuse_op = BRepAlgoAPI_Fuse()
+ fuse_op.SetFuzzyValue(TOLERANCE)
+ self.copy_attributes_to(
+ summands[0], ["wrapped", "_NodeMixin__children"]
+ )
+ result = self._bool_op(summands[:1], summands[1:], fuse_op)
+
+ if SkipClean.clean:
+ result = result.clean()
+
+ return result
+
def do_children_intersect(
self, include_parent: bool = False, tolerance: float = 1e-5
) -> tuple[bool, tuple[Shape, Shape], float]:
@@ -4490,64 +4612,6 @@ class Compound(Mixin3D, Shape):
return TopoDS_Iterator(self.wrapped).More()
- def cut(self, *to_cut: Shape) -> Compound:
- """Remove a shape from another one
-
- Args:
- *to_cut: Shape:
-
- Returns:
-
- """
-
- cut_op = BRepAlgoAPI_Cut()
-
- return tcast(Compound, self._bool_op(self, to_cut, cut_op))
-
- def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Compound:
- """Fuse shapes together
-
- Args:
- *to_fuse: Shape:
- glue: bool: (Default value = False)
- tol: float: (Default value = None)
-
- Returns:
-
- """
-
- fuse_op = BRepAlgoAPI_Fuse()
- if glue:
- fuse_op.SetGlue(BOPAlgo_GlueEnum.BOPAlgo_GlueShift)
- if tol:
- fuse_op.SetFuzzyValue(tol)
-
- args = tuple(self) + to_fuse
-
- if len(args) <= 1:
- return_value: Shape = args[0]
- else:
- return_value = self._bool_op(args[:1], args[1:], fuse_op)
-
- # fuse_op.RefineEdges()
- # fuse_op.FuseEdges()
-
- return tcast(Compound, return_value)
-
- def intersect(self, *to_intersect: Shape) -> Compound:
- """Construct shape intersection
-
- Args:
- *to_intersect: Shape:
-
- Returns:
-
- """
-
- intersect_op = BRepAlgoAPI_Common()
-
- return tcast(Compound, self._bool_op(self, to_intersect, intersect_op))
-
def get_type(
self,
obj_type: Union[
@@ -4587,36 +4651,6 @@ class Compound(Mixin3D, Shape):
return results
- def first_level_shapes(
- self, _shapes: list[TopoDS_Shape] = None
- ) -> ShapeList[Shape]:
- """first_level_shapes
-
- This method iterates through the immediate children of the compound and
- collects all non-compound shapes (e.g., vertices, edges, faces, solids).
- If a child shape is itself a compound, the method recursively explores it,
- retrieving all first-level shapes within any nested compounds.
-
- Note: the _shapes parameter is not to be assigned by the user.
-
- Returns:
- ShapeList[Shape]: Shapes contained within the Compound
- """
- if self.wrapped is None:
- return ShapeList()
- if _shapes is None:
- _shapes = []
- iterator = TopoDS_Iterator()
- iterator.Initialize(self.wrapped)
- while iterator.More():
- child = Shape.cast(iterator.Value())
- if isinstance(child, Compound):
- child.first_level_shapes(_shapes)
- else:
- _shapes.append(child)
- iterator.Next()
- return ShapeList(_shapes)
-
def unwrap(self, fully: bool = True) -> Union[Self, Shape]:
"""Strip unnecessary Compound wrappers
@@ -4675,6 +4709,8 @@ class Curve(Compound):
def _dim(self) -> int:
return 1
+ __add__ = Mixin1D.__add__
+
def __matmul__(self, position: float) -> Vector:
"""Position on curve operator @ - only works if continuous"""
return Wire(self.edges()).position_at(position)
@@ -9240,7 +9276,6 @@ def unwrap_topods_compound(
Returns:
Union[TopoDS_Compound, TopoDS_Shape]: base shape
"""
-
if compound.NbChildren() == 1:
iterator = TopoDS_Iterator(compound)
single_element = downcast(iterator.Value())
diff --git a/tests/test_algebra.py b/tests/test_algebra.py
index e99b496..6b4bbbc 100644
--- a/tests/test_algebra.py
+++ b/tests/test_algebra.py
@@ -339,6 +339,31 @@ class ObjectTests(unittest.TestCase):
class AlgebraTests(unittest.TestCase):
+
+ # Shapes
+
+ def test_shape_plus(self):
+ f1 = Face.make_rect(1, 3)
+ f2 = Face.make_rect(3, 1)
+ f3 = f1 + f2
+ self.assertTupleAlmostEquals(f3.bounding_box().size, (3, 3, 0), 6)
+
+ f4 = f1 + []
+ self.assertTupleAlmostEquals(f4.bounding_box().size, (1, 3, 0), 6)
+
+ e1 = Edge.make_line((0, 0), (1, 1))
+ with self.assertRaises(ValueError):
+ _ = f1 + e1
+
+ with self.assertRaises(ValueError):
+ _ = Shape() + f2
+
+ f5 = Face() + f1
+ self.assertTupleAlmostEquals(f5.bounding_box().size, (1, 3, 0), 6)
+
+ f6 = Face() + [f1, f2]
+ self.assertTupleAlmostEquals(f6.bounding_box().size, (3, 3, 0), 6)
+
# Part
def test_part_plus(self):
@@ -507,16 +532,47 @@ class AlgebraTests(unittest.TestCase):
self.assertTupleAlmostEquals(result.bounding_box().max, (0.4, 0.4, 0.0), 3)
# Curve
- def test_curve_plus(self):
+ def test_curve_plus_continuous(self):
l1 = Polyline((0, 0), (1, 0), (1, 1))
l2 = Line((1, 1), (0, 0))
l = l1 + l2
- w = Wire(l)
- self.assertTrue(w.is_closed)
+ self.assertTrue(isinstance(l, Wire))
+ self.assertTrue(l.is_closed)
self.assertTupleAlmostEquals(
- w.center(CenterOf.MASS), (0.6464466094067263, 0.35355339059327373, 0.0), 6
+ l.center(CenterOf.MASS), (0.6464466094067263, 0.35355339059327373, 0.0), 6
)
+ def test_curve_plus_noncontinuous(self):
+ e1 = Edge.make_line((0, 1), (1, 1))
+ e2 = Edge.make_line((1, 1), (2, 1))
+ e3 = Edge.make_line((2, 1), (3, 1))
+ l = Curve() + [e1, e3]
+ self.assertTrue(isinstance(l, Compound))
+ l += e2 # fills the hole and makes a single edge
+ self.assertTrue(isinstance(l, Edge))
+ self.assertAlmostEqual(l.length, 3, 5)
+
+ l2 = e1 + e3
+ self.assertTrue(isinstance(l2, Compound))
+
+ def test_curve_plus_nothing(self):
+ e1 = Edge.make_line((0, 1), (1, 1))
+ l = e1 + Curve()
+ self.assertTrue(isinstance(l, Edge))
+ self.assertAlmostEqual(l.length, 1, 5)
+
+ def test_nothing_plus_curve(self):
+ e1 = Edge.make_line((0, 1), (1, 1))
+ l = Curve() + e1
+ self.assertTrue(isinstance(l, Edge))
+ self.assertAlmostEqual(l.length, 1, 5)
+
+ def test_bad_dims(self):
+ e1 = Edge.make_line((0, 1), (1, 1))
+ f1 = Face.make_rect(1, 1)
+ with self.assertRaises(ValueError):
+ _ = e1 + f1
+
def test_curve_minus(self):
l1 = Line((0, 0), (1, 1))
l2 = Line((0.25, 0.25), (0.75, 0.75))
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 307dad0..9eba3db 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -878,13 +878,16 @@ class TestCompound(DirectApiTestCase):
comp4 = comp3.unwrap(fully=True)
self.assertTrue(isinstance(comp4, Face))
- def test_first_level_shapes(self):
+ def test_get_top_level_shapes(self):
base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4))
- fls = base_shapes.first_level_shapes()
+ fls = base_shapes.get_top_level_shapes()
self.assertTrue(isinstance(fls, ShapeList))
self.assertEqual(len(fls), 20)
self.assertTrue(all(isinstance(s, Solid) for s in fls))
+ b1 = Box(1, 1, 1).solid()
+ self.assertEqual(b1.get_top_level_shapes()[0], b1)
+
class TestEdge(DirectApiTestCase):
def test_close(self):
@@ -3615,10 +3618,12 @@ class TestShapeList(DirectApiTestCase):
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
sl.vertex()
+ self.assertEqual(len(Edge().vertices()), 0)
def test_edges(self):
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
self.assertEqual(len(sl.edges()), 8)
+ self.assertEqual(len(Edge().edges()), 0)
def test_edge(self):
sl = ShapeList([Edge.make_circle(1)])
@@ -3630,6 +3635,7 @@ class TestShapeList(DirectApiTestCase):
def test_wires(self):
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
self.assertEqual(len(sl.wires()), 2)
+ self.assertEqual(len(Wire().wires()), 0)
def test_wire(self):
sl = ShapeList([Wire.make_circle(1)])
@@ -3641,6 +3647,7 @@ class TestShapeList(DirectApiTestCase):
def test_faces(self):
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
self.assertEqual(len(sl.faces()), 9)
+ self.assertEqual(len(Face().faces()), 0)
def test_face(self):
sl = ShapeList(
@@ -3654,6 +3661,7 @@ class TestShapeList(DirectApiTestCase):
def test_shells(self):
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
self.assertEqual(len(sl.shells()), 2)
+ self.assertEqual(len(Shell().shells()), 0)
def test_shell(self):
sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
@@ -3665,6 +3673,7 @@ class TestShapeList(DirectApiTestCase):
def test_solids(self):
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
self.assertEqual(len(sl.solids()), 2)
+ self.assertEqual(len(Solid().solids()), 0)
def test_solid(self):
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
@@ -3676,6 +3685,7 @@ class TestShapeList(DirectApiTestCase):
def test_compounds(self):
sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
self.assertEqual(len(sl.compounds()), 2)
+ self.assertEqual(len(Compound().compounds()), 0)
def test_compound(self):
sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
From 73f5f6cd2861eb9db7dd7a967b701694b2e0440e Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 19 Nov 2024 14:43:24 -0600
Subject: [PATCH 017/518] test.yml -> test on py310 and py311 and not py39
---
.github/workflows/test.yml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b0827ce..cd53c9a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -8,9 +8,8 @@ jobs:
fail-fast: false
matrix:
python-version: [
- "3.9",
"3.10",
- #"3.11"
+ "3.11"
]
os: [macos-13, ubuntu-latest, windows-latest]
@@ -29,7 +28,6 @@ jobs:
fail-fast: false
matrix:
python-version: [
- #"3.9",
"3.10",
#"3.11"
]
From a61912fbe3585e60bcea9991f02d3078282a1f5d Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 19 Nov 2024 14:44:56 -0600
Subject: [PATCH 018/518] publish.yml -> print python3 version during wheel
build
---
.github/workflows/publish.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a5387e5..8f173b0 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -20,6 +20,7 @@ jobs:
run: |
pwd
ls -lR
+ python3 -V
python3 -m pip install --upgrade pip
python3 -m pip -V
python3 -m pip install build
From 1022b88ca9daeea35d435051cab2d56207291671 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 19 Nov 2024 14:46:05 -0600
Subject: [PATCH 019/518] mypy.yml -> use py310 and py311
mypy is not in active use, but still keeping this up to date
---
.github/workflows/mypy.yml | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
index f056e70..43b2c0d 100644
--- a/.github/workflows/mypy.yml
+++ b/.github/workflows/mypy.yml
@@ -7,9 +7,8 @@ jobs:
fail-fast: false
matrix:
python-version: [
- "3.9",
"3.10",
- #"3.11"
+ "3.11"
]
runs-on: ubuntu-latest
@@ -21,4 +20,4 @@ jobs:
- name: typecheck
run: |
- mypy --config-file mypy.ini src/build123d
\ No newline at end of file
+ mypy --config-file mypy.ini src/build123d
From 4adb26ebf0aa403eff176df6fbc30c07221d2472 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 19 Nov 2024 14:48:19 -0600
Subject: [PATCH 020/518] pyproject.toml -> no py39 support
---
pyproject.toml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 037194e..6ef699d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,7 @@ authors = [
]
description = "A python CAD programming library"
readme = "README.md"
-requires-python = ">= 3.9, < 3.13"
+requires-python = ">= 3.10, < 3.13"
keywords = [
"3d models",
"3d printing",
@@ -61,5 +61,5 @@ exclude = ["build123d._dev"]
write_to = "src/build123d/_version.py"
[tool.black]
-target-version = ["py39", "py310", "py311", "py312"]
+target-version = ["py310", "py311", "py312"]
line-length = 88
From e772388dda3aaabc0b1a53e9721f1d4a1096692c Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 19 Nov 2024 15:01:26 -0600
Subject: [PATCH 021/518] .readthedocs.yaml -> py39 to py310
---
.readthedocs.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 33266c4..3c263a9 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -7,7 +7,7 @@ formats:
build:
os: "ubuntu-22.04"
tools:
- python: "3.9"
+ python: "3.10"
apt_packages:
- graphviz
From 52e43d51e52d5fb113414f30bad3f10e98e082be Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 19 Nov 2024 19:39:57 -0500
Subject: [PATCH 022/518] Refactored shape extractors to avoid class references
Issue #788
---
src/build123d/topology.py | 178 +++++++++++++++-----------------------
1 file changed, 68 insertions(+), 110 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index ea8cfad..be15a1b 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -2243,60 +2243,87 @@ class Shape(NodeMixin):
return ShapeList(first_level_shapes)
+ @staticmethod
+ def _get_shape_list(shape: Shape, entity_type: str) -> ShapeList:
+ """Helper to extract entities of a specific type from a shape."""
+ if shape.wrapped is None:
+ return ShapeList()
+ shape_list = ShapeList([Shape.cast(i) for i in shape._entities(entity_type)])
+ for item in shape_list:
+ item.topo_parent = shape
+ return shape_list
+
+ @staticmethod
+ def _get_single_shape(shape: Shape, entity_type: str) -> Shape:
+ """Helper to extract a single entity of a specific type from a shape,
+ with a warning if count != 1."""
+ shape_list = Shape._get_shape_list(shape, entity_type)
+ entity_count = len(shape_list)
+ if entity_count != 1:
+ warnings.warn(
+ f"Found {entity_count} {entity_type.lower()}s, returning first",
+ stacklevel=2,
+ )
+ return shape_list[0] if shape_list else None
+
def vertices(self) -> ShapeList[Vertex]:
"""vertices - all the vertices in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- vertex_list = ShapeList(
- [Vertex(downcast(i)) for i in self._entities(Vertex.__name__)]
- )
- for vertex in vertex_list:
- vertex.topo_parent = self
- return vertex_list
+ return Shape._get_shape_list(self, "Vertex")
def vertex(self) -> Vertex:
"""Return the Vertex"""
- vertices = self.vertices()
- vertex_count = len(vertices)
- if vertex_count != 1:
- warnings.warn(
- f"Found {vertex_count} vertices, returning first", stacklevel=2
- )
- return vertices[0]
+ return Shape._get_single_shape(self, "Vertex")
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- edge_list = ShapeList(
- [
- Edge(i)
- for i in self._entities(Edge.__name__)
- if not BRep_Tool.Degenerated_s(TopoDS.Edge_s(i))
- ]
+ edge_list = Shape._get_shape_list(self, "Edge")
+ return edge_list.filter_by(
+ lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
)
- for edge in edge_list:
- edge.topo_parent = self
- return edge_list
def edge(self) -> Edge:
"""Return the Edge"""
- edges = self.edges()
- edge_count = len(edges)
- if edge_count != 1:
- warnings.warn(
- f"Found {edge_count} edges, returning first",
- stacklevel=2,
- )
- return edges[0]
+ return Shape._get_single_shape(self, "Edge")
+
+ def wires(self) -> ShapeList[Wire]:
+ """wires - all the wires in this Shape"""
+ return Shape._get_shape_list(self, "Wire")
+
+ def wire(self) -> Wire:
+ """Return the Wire"""
+ return Shape._get_single_shape(self, "Wire")
+
+ def faces(self) -> ShapeList[Face]:
+ """faces - all the faces in this Shape"""
+ return Shape._get_shape_list(self, "Face")
+
+ def face(self) -> Face:
+ """Return the Face"""
+ return Shape._get_single_shape(self, "Face")
+
+ def shells(self) -> ShapeList[Shell]:
+ """shells - all the shells in this Shape"""
+ return Shape._get_shape_list(self, "Shell")
+
+ def shell(self) -> Shell:
+ """Return the Shell"""
+ return Shape._get_single_shape(self, "Shell")
+
+ def solids(self) -> ShapeList[Solid]:
+ """solids - all the solids in this Shape"""
+ return Shape._get_shape_list(self, "Solid")
+
+ def solid(self) -> Solid:
+ """Return the Solid"""
+ return Shape._get_single_shape(self, "Solid")
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""
if self.wrapped is None:
return ShapeList()
- if isinstance(self, Compound):
+ if isinstance(self.wrapped, TopoDS_Compound):
# pylint: disable=not-an-iterable
- sub_compounds = [c for c in self if isinstance(c, Compound)]
+ sub_compounds = [c for c in self if isinstance(c.wrapped, TopoDS_Compound)]
sub_compounds.append(self)
else:
sub_compounds = []
@@ -2304,83 +2331,14 @@ class Shape(NodeMixin):
def compound(self) -> Compound:
"""Return the Compound"""
- compounds = self.compounds()
- compound_count = len(compounds)
- if compound_count != 1:
+ shape_list = self.compounds()
+ entity_count = len(shape_list)
+ if entity_count != 1:
warnings.warn(
- f"Found {compound_count} compounds, returning first",
+ f"Found {entity_count} compounds, returning first",
stacklevel=2,
)
- return compounds[0]
-
- def wires(self) -> ShapeList[Wire]:
- """wires - all the wires in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- return ShapeList([Wire(i) for i in self._entities(Wire.__name__)])
-
- def wire(self) -> Wire:
- """Return the Wire"""
- wires = self.wires()
- wire_count = len(wires)
- if wire_count != 1:
- warnings.warn(
- f"Found {wire_count} wires, returning first",
- stacklevel=2,
- )
- return wires[0]
-
- def faces(self) -> ShapeList[Face]:
- """faces - all the faces in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- face_list = ShapeList([Face(i) for i in self._entities(Face.__name__)])
- for face in face_list:
- face.topo_parent = self
- return face_list
-
- def face(self) -> Face:
- """Return the Face"""
- faces = self.faces()
- face_count = len(faces)
- if face_count != 1:
- msg = f"Found {face_count} faces, returning first"
- warnings.warn(msg, stacklevel=2)
- return faces[0]
-
- def shells(self) -> ShapeList[Shell]:
- """shells - all the shells in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- return ShapeList([Shell(i) for i in self._entities(Shell.__name__)])
-
- def shell(self) -> Shell:
- """Return the Shell"""
- shells = self.shells()
- shell_count = len(shells)
- if shell_count != 1:
- warnings.warn(
- f"Found {shell_count} shells, returning first",
- stacklevel=2,
- )
- return shells[0]
-
- def solids(self) -> ShapeList[Solid]:
- """solids - all the solids in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- return ShapeList([Solid(i) for i in self._entities(Solid.__name__)])
-
- def solid(self) -> Solid:
- """Return the Solid"""
- solids = self.solids()
- solid_count = len(solids)
- if solid_count != 1:
- warnings.warn(
- f"Found {solid_count} solids, returning first",
- stacklevel=2,
- )
- return solids[0]
+ return shape_list[0] if shape_list else None
@property
def area(self) -> float:
From 01691bcb4e4d92a7a33fec3acd091386ea7bf9e3 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Wed, 20 Nov 2024 08:09:31 -0600
Subject: [PATCH 023/518] mypy.yml -> use py310 and py312 and not py311
---
.github/workflows/mypy.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
index 43b2c0d..ebf3b61 100644
--- a/.github/workflows/mypy.yml
+++ b/.github/workflows/mypy.yml
@@ -8,7 +8,8 @@ jobs:
matrix:
python-version: [
"3.10",
- "3.11"
+ # "3.11",
+ "3.12"
]
runs-on: ubuntu-latest
From ae5448e10970d36176595b29142e9b06bf647139 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Wed, 20 Nov 2024 08:10:24 -0600
Subject: [PATCH 024/518] test.yml -> use py310 and py312 and not py311
---
.github/workflows/test.yml | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cd53c9a..5ca3ca1 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,7 +9,8 @@ jobs:
matrix:
python-version: [
"3.10",
- "3.11"
+ # "3.11",
+ "3.12",
]
os: [macos-13, ubuntu-latest, windows-latest]
@@ -29,7 +30,8 @@ jobs:
matrix:
python-version: [
"3.10",
- #"3.11"
+ #"3.11",
+ #"3.12"
]
os: [macos-14]
From 875f33507fcc3e3ab35908aac8718eb847be0e89 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Fri, 22 Nov 2024 15:05:04 -0600
Subject: [PATCH 025/518] test.yml -> use cadquery-ocp from pypi on macos-arm64
---
.github/workflows/test.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5ca3ca1..c350447 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,15 +30,15 @@ jobs:
matrix:
python-version: [
"3.10",
- #"3.11",
- #"3.12"
+ "3.11",
+ "3.12"
]
os: [macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- - uses: ./.github/actions/setup-macos-arm64/
+ - uses: ./.github/actions/setup/
with:
python-version: ${{ matrix.python-version }}
- name: test
From e9bef21e305a7dda95d64932524b9e8dcea037e5 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Fri, 22 Nov 2024 15:10:01 -0600
Subject: [PATCH 026/518] test.yml -> combine x86_64 and macos-arm64 into a
single job
---
.github/workflows/test.yml | 25 ++-----------------------
1 file changed, 2 insertions(+), 23 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c350447..f4efd4e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -3,7 +3,7 @@ name: tests
on: [push, pull_request, workflow_dispatch]
jobs:
- tests_x86_64:
+ tests:
strategy:
fail-fast: false
matrix:
@@ -12,28 +12,7 @@ jobs:
# "3.11",
"3.12",
]
- os: [macos-13, ubuntu-latest, windows-latest]
-
- runs-on: ${{ matrix.os }}
- steps:
- - uses: actions/checkout@v4
- - uses: ./.github/actions/setup/
- with:
- python-version: ${{ matrix.python-version }}
- - name: test
- run: |
- python -m pytest
-
- tests_arm64:
- strategy:
- fail-fast: false
- matrix:
- python-version: [
- "3.10",
- "3.11",
- "3.12"
- ]
- os: [macos-14]
+ os: [macos-13, macos-14, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
From 1755d29b663a86b3b2de893204f006e42a2cf411 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Fri, 22 Nov 2024 15:11:44 -0600
Subject: [PATCH 027/518] Delete .github/actions/setup-macos-arm64 directory
and action.yml
---
.github/actions/setup-macos-arm64/action.yml | 22 --------------------
1 file changed, 22 deletions(-)
delete mode 100644 .github/actions/setup-macos-arm64/action.yml
diff --git a/.github/actions/setup-macos-arm64/action.yml b/.github/actions/setup-macos-arm64/action.yml
deleted file mode 100644
index 48c0c8a..0000000
--- a/.github/actions/setup-macos-arm64/action.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-name: 'Setup'
-inputs:
- python-version: # id of input
- description: 'Python version'
- required: true
-
-runs:
- using: "composite"
- steps:
- - name: python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
- - name: install requirements
- shell: bash
- run: |
- pip install wheel
- pip install mypy
- pip install pytest
- pip install pylint
- pip install https://github.com/jdegenstein/ocp-build-system/releases/download/7.7.2_macos_arm64_cp310/cadquery_ocp-7.7.2-cp310-cp310-macosx_11_0_arm64.whl
- pip install .
From eab54d054f1da58023ffa3af42074b01782f7bc7 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Fri, 22 Nov 2024 15:33:57 -0600
Subject: [PATCH 028/518] README.md -> don't mention Apple Silicon
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 0b8e7be..1bf4995 100644
--- a/README.md
+++ b/README.md
@@ -43,4 +43,4 @@ cd build123d
python3 -m pip install -e .
```
-Further installation instructions are available (e.g. Poetry, Apple Silicon) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html).
+Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html).
From dba9831ae39c72dffe465bdfb68f101f2dde336d Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Fri, 22 Nov 2024 15:37:13 -0600
Subject: [PATCH 029/518] installation.rst -> remove entire obsolete Apple
Silicon workaround section
---
docs/installation.rst | 31 +------------------------------
1 file changed, 1 insertion(+), 30 deletions(-)
diff --git a/docs/installation.rst b/docs/installation.rst
index 3a54c1d..c794857 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -2,7 +2,7 @@
Installation
############
-The recommended method for most users is to install **build123d** is:
+The recommended method for most users to install **build123d** is:
.. doctest::
@@ -110,35 +110,6 @@ Which should return something similar to:
├── Face at 0x165e88218f0, Center(0.5, 1.0, 0.0)
└── Face at 0x165eb21ee70, Center(0.5, 1.0, 3.0)
-Special notes on Apple Silicon installs
-----------------------------------------------
-
-Due to some dependencies not being available via pip, there is a bit of a hacky work around for Apple Silicon installs (M1 or M2 ARM64 architecture machines - if you aren't sure, try `uname -p` in a terminal and see if it returns arm). Specifically the cadquery-ocp dependency fails to resolve at install time. The error looks something like this:
-
-.. doctest::
-
- └[~]> python3 -m pip install build123d
- Collecting build123d
- ...
- INFO: pip is looking at multiple versions of build123d to determine which version is compatible with other requirements. This could take a while.
- ERROR: Could not find a version that satisfies the requirement cadquery-ocp~=7.7.1 (from build123d) (from versions: none)
- ERROR: No matching distribution found for cadquery-ocp~=7.7.1
-
-A procedure for avoiding this issue is to install in a conda environment, which does have the missing dependency (substituting for the environment name you want to use for this install):
-
-.. doctest::
-
- conda create -n python=3.10
- conda activate
- conda install -c cadquery -c conda-forge cadquery=master
- pip install svgwrite svgpathtools anytree scipy ipython trianglesolver \
- ocp_tessellate webcolors==1.12 "numpy>=2,<3" cachetools==5.2.0 \
- ocp_vscode requests orjson urllib3 certifi py-lib3mf \
- "svgpathtools>=1.5.1,<2" "svgelements>=1.9.1,<2" "ezdxf>=1.1.0,<2"
- pip install --no-deps build123d ocpsvg
-
-`You can track the issue here `_
-
Adding a nicer GUI
----------------------------------------------
From 12f5ec091061acb2aac2a1842a2e1d8fa4f5b814 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 5 Dec 2024 08:57:25 -0500
Subject: [PATCH 030/518] Creates valid subfiles
---
tools/refactor_topo.py | 384 ++++++++++++++++++++++++++++++-----------
1 file changed, 284 insertions(+), 100 deletions(-)
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index e1fcb4e..3b1a02d 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -1,9 +1,11 @@
from pathlib import Path
import libcst as cst
-from typing import List, Set, Dict, Union
-from pprint import pprint
+import libcst.matchers as m
+from typing import List, Set, Dict
from rope.base.project import Project
from rope.refactor.importutils import ImportOrganizer
+import subprocess
+from datetime import datetime
class ImportCollector(cst.CSTVisitor):
@@ -73,46 +75,147 @@ class GlobalVariableExtractor(cst.CSTVisitor):
self.global_variables.append(assign)
+class ClassMethodExtractor(cst.CSTVisitor):
+ def __init__(self, methods_to_convert: List[str]):
+ self.methods_to_convert = methods_to_convert
+ self.extracted_methods: List[cst.FunctionDef] = []
+
+ def visit_ClassDef(self, node: cst.ClassDef) -> None:
+ # Extract the class name to append it to the function name
+ self.current_class_name = node.name.value
+ self.generic_visit(node) # Continue to visit child nodes
+
+ def leave_ClassDef(self, original_node: cst.ClassDef) -> None:
+ # Clear the current class name after leaving the class
+ self.current_class_name = None
+
+ def visit_FunctionDef(self, node: cst.FunctionDef) -> None:
+ # Check if the function should be converted
+ if node.name.value in self.methods_to_convert and self.current_class_name:
+ # Rename the method by appending the class name to avoid conflicts
+ new_name = f"{node.name.value}_{self.current_class_name.lower()}"
+ renamed_node = node.with_changes(name=cst.Name(new_name))
+ # Remove `self` from parameters since it's now a standalone function
+ if renamed_node.params.params:
+ renamed_node = renamed_node.with_changes(
+ params=renamed_node.params.with_changes(
+ params=renamed_node.params.params[1:]
+ )
+ )
+ self.extracted_methods.append(renamed_node)
+
+
def write_topo_class_files(
+ source_tree: cst.Module,
extracted_classes: Dict[str, cst.ClassDef],
imports: Set[str],
output_dir: Path,
) -> None:
- """
- Write files for each group of classes:
- 1. Separate modules for "Shape", "Compound", "Solid", "Face" + "Shell", "Edge" + "Wire", and "Vertex"
- 2. "ShapeList" is extracted into its own module and imported by all modules except "Shape"
- """
+ """Write files for each group of classes:"""
# Create output directory if it doesn't exist
output_dir.mkdir(parents=True, exist_ok=True)
# Sort imports for consistency
imports_code = "\n".join(imports)
- # Define class groupings based on layers
- class_groups = {
- "shape": ["Shape"],
- "vertex": ["Vertex"],
- "edge_wire": ["Mixin1D", "Edge", "Wire"],
- "face_shell": ["Face", "Shell"],
- "solid": ["Mixin3D", "Solid"],
- "compound": ["Compound"],
- "shape_list": ["ShapeList"],
+ # Describe where the functions should go
+ function_source = {
+ "shape_core": [
+ "downcast",
+ "fix",
+ "get_top_level_topods_shapes",
+ "_sew_topods_faces",
+ "shapetype",
+ "_topods_compound_dim",
+ "_topods_entities",
+ "_topods_face_normal_at",
+ "apply_ocp_monkey_patches",
+ "unwrap_topods_compound",
+ ],
+ "utils": [
+ "delta",
+ "edges_to_wires",
+ "_extrude_topods_shape",
+ "find_max_dimension",
+ "isclose_b",
+ "_make_loft",
+ "_make_topods_compound_from_shapes",
+ "_make_topods_face_from_wires",
+ "new_edges",
+ "parse_arguments",
+ "parse_kwargs",
+ "polar",
+ "_topods_bool_op",
+ "tuplify",
+ "unwrapped_shapetype",
+ ],
+ "zero_d": [
+ "topo_explore_common_vertex",
+ ],
+ "one_d": [
+ "topo_explore_connected_edges",
+ ],
+ "two_d": ["sort_wires_by_build_order"],
}
- # Write ShapeList class separately
- if "ShapeList" in extracted_classes:
- class_file = output_dir / "shape_list.py"
- shape_list_class = extracted_classes["ShapeList"]
- shape_list_module = cst.Module(
- body=[*cst.parse_module(imports_code).body, shape_list_class]
- )
- class_file.write_text(shape_list_module.code)
- print(f"Created {class_file}")
+ # Define class groupings based on layers
+ class_groups = {
+ "utils": ["_ClassMethodProxy"],
+ "shape_core": [
+ "Shape",
+ "Comparable",
+ "ShapePredicate",
+ "GroupBy",
+ "ShapeList",
+ "Joint",
+ "SkipClean",
+ ],
+ "zero_d": ["Vertex"],
+ "one_d": ["Mixin1D", "Edge", "Wire"],
+ "two_d": ["Mixin2D", "Face", "Shell"],
+ "three_d": ["Mixin3D", "Solid"],
+ "composite": ["Compound", "Curve", "Sketch", "Part"],
+ }
for group_name, class_names in class_groups.items():
- if group_name == "shape_list":
- continue
+ module_docstring = f"""
+build123d topology
+
+name: {group_name}.py
+by: Gumyr
+date: {datetime.now().strftime('%B %d, %Y')}
+
+desc:
+
+license:
+
+ Copyright {datetime.now().strftime('%Y')} 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.
+
+"""
+ header = [
+ cst.SimpleStatementLine(
+ [cst.Expr(cst.SimpleString(f'"""{module_docstring}"""'))]
+ )
+ ]
+
+ if group_name in ["utils", "shape_core"]:
+ function_collector = StandaloneFunctionAndVariableCollector()
+ source_tree.visit(function_collector)
+
+ variable_collector = GlobalVariableExtractor()
+ source_tree.visit(variable_collector)
group_classes = [
extracted_classes[name] for name in class_names if name in extracted_classes
@@ -121,87 +224,134 @@ def write_topo_class_files(
continue
# Add imports for base classes based on layer dependencies
- additional_imports = ["from .utils import *"]
- if group_name != "shape":
- additional_imports.append("from .shape import Shape")
- additional_imports.append("from .shape_list import ShapeList")
- if group_name in ["edge_wire", "face_shell", "solid", "compound"]:
- additional_imports.append("from .vertex import Vertex")
- if group_name in ["face_shell", "solid", "compound"]:
- additional_imports.append("from .edge_wire import Edge, Wire")
- if group_name in ["solid", "compound"]:
- additional_imports.append("from .face_shell import Face, Shell")
- if group_name == "compound":
- additional_imports.append("from .solid import Solid")
+ additional_imports = []
+ if group_name != "shape_core":
+ additional_imports.append(
+ "from .shape_core import Shape, ShapeList, SkipClean, TrimmingTool, Joint"
+ )
+ additional_imports.append("from .utils import _ClassMethodProxy")
+ if group_name not in ["shape_core", "vertex"]:
+ for sub_group_name in function_source.keys():
+ additional_imports.append(
+ f"from .{sub_group_name} import "
+ + ",".join(function_source[sub_group_name])
+ )
+ if group_name not in ["shape_core", "utils", "vertex"]:
+ additional_imports.append("from .zero_d import Vertex")
+ if group_name in ["two_d"]:
+ additional_imports.append("from .one_d import Mixin1D")
- # Create class file (e.g., face_shell.py)
+ if group_name in ["two_d", "three_d", "composite"]:
+ additional_imports.append("from .one_d import Edge, Wire")
+ if group_name in ["three_d", "composite"]:
+ additional_imports.append("from .one_d import Mixin1D")
+
+ additional_imports.append("from .two_d import Mixin2D, Face, Shell")
+ if group_name == "composite":
+ additional_imports.append("from .one_d import Mixin1D")
+ additional_imports.append("from .three_d import Mixin3D, Solid")
+
+ # Add TYPE_CHECKING imports
+ if group_name not in ["composite"]:
+ additional_imports.append("if TYPE_CHECKING:")
+ if group_name in ["shape_core", "utils"]:
+ additional_imports.append(" from .zero_d import Vertex")
+ if group_name in ["shape_core", "utils", "zero_d"]:
+ additional_imports.append(" from .one_d import Edge, Wire")
+ if group_name in ["shape_core", "utils", "one_d"]:
+ additional_imports.append(" from .two_d import Face, Shell")
+ if group_name in ["shape_core", "utils", "one_d", "two_d"]:
+ additional_imports.append(" from .three_d import Solid")
+ if group_name in ["shape_core", "utils", "one_d", "two_d", "three_d"]:
+ additional_imports.append(
+ " from .composite import Compound, Curve, Sketch, Part"
+ )
+ # Create class file (e.g., two_d.py)
class_file = output_dir / f"{group_name}.py"
all_imports_code = "\n".join([imports_code, *additional_imports])
- class_module = cst.Module(
- body=[*cst.parse_module(all_imports_code).body, *group_classes]
- )
+
+ # if group_name in ["shape_core", "utils"]:
+ if group_name in function_source.keys():
+ body = [*cst.parse_module(all_imports_code).body]
+ for func in function_collector.functions:
+ if group_name == "shape_core" and func.name.value in [
+ "_topods_compound_dim",
+ "_topods_face_normal_at",
+ "apply_ocp_monkey_patches",
+ ]:
+ body.append(func)
+
+ # If this is the "apply_ocp_monkey_patches" function, add a call to it
+ if func.name.value == "apply_ocp_monkey_patches":
+ apply_patches_call = cst.Expr(
+ value=cst.Call(func=cst.Name("apply_ocp_monkey_patches"))
+ )
+ body.append(apply_patches_call)
+ body.append(cst.EmptyLine(indent=False))
+ body.append(cst.EmptyLine(indent=False))
+
+ if group_name == "shape_core":
+ for var in variable_collector.global_variables:
+ # Check the name of the assigned variable(s)
+ for target in var.targets:
+ if isinstance(target.target, cst.Name):
+ var_name = target.target.value
+ # Check if the variable name is in the exclusion list
+ if var_name not in ["T", "K"]:
+ body.append(var)
+ body.append(cst.EmptyLine(indent=False))
+
+ # Add classes and inject variables after a specific class
+ for class_def in group_classes:
+ body.append(class_def)
+
+ # Inject variables after the specified class
+ if class_def.name.value == "Comparable":
+ body.append(
+ cst.Comment(
+ "# This TypeVar allows IDEs to see the type of objects within the ShapeList"
+ )
+ )
+ body.append(cst.EmptyLine(indent=False))
+ for var in variable_collector.global_variables:
+ # Check the name of the assigned variable(s)
+ for target in var.targets:
+ if isinstance(target.target, cst.Name):
+ var_name = target.target.value
+ # Check if the variable name is in the inclusion list
+ if var_name in ["T", "K"]:
+ body.append(var)
+ body.append(cst.EmptyLine(indent=False))
+
+ for func in function_collector.functions:
+ if func.name.value in function_source[
+ group_name
+ ] and func.name.value not in [
+ "_topods_compound_dim",
+ "_topods_face_normal_at",
+ "apply_ocp_monkey_patches",
+ ]:
+ body.append(func)
+ class_module = cst.Module(body=body, header=header)
+ else:
+ class_module = cst.Module(
+ body=[*cst.parse_module(all_imports_code).body, *group_classes],
+ header=header,
+ )
class_file.write_text(class_module.code)
+
print(f"Created {class_file}")
# Create __init__.py to make it a proper package
init_file = output_dir / "__init__.py"
init_content = []
for group_name in class_groups.keys():
- if group_name != "shape_list":
- init_content.append(f"from .{group_name} import *")
+ init_content.append(f"from .{group_name} import *")
init_file.write_text("\n".join(init_content))
print(f"Created {init_file}")
-def write_utils_file(
- source_tree: cst.Module, imports: Set[str], output_dir: Path
-) -> None:
- """
- Extract and write standalone functions and global variables to a utils.py file.
-
- Args:
- source_tree: The parsed source tree
- imports: Set of import statements
- output_dir: Directory to write the utils file
- """
- # Collect standalone functions and global variables
- function_collector = StandaloneFunctionAndVariableCollector()
- source_tree.visit(function_collector)
-
- variable_collector = GlobalVariableExtractor()
- source_tree.visit(variable_collector)
-
- # Create utils file
- utils_file = output_dir / "utils.py"
-
- # Prepare the module body
- module_body = []
-
- # Add imports
- imports_tree = cst.parse_module("\n".join(sorted(imports)))
- module_body.extend(imports_tree.body)
-
- # Add global variables with newlines
- for var in variable_collector.global_variables:
- module_body.append(var)
- module_body.append(cst.EmptyLine(indent=False))
-
- # Add a newline between variables and functions
- if variable_collector.global_variables and function_collector.functions:
- module_body.append(cst.EmptyLine(indent=False))
-
- # Add functions
- module_body.extend(function_collector.functions)
-
- # Create the module
- utils_module = cst.Module(body=module_body)
-
- # Write the file
- utils_file.write_text(utils_module.code)
- print(f"Created {utils_file}")
-
-
def remove_unused_imports(file_path: Path, project: Project) -> None:
"""Remove unused imports from a Python file using rope.
@@ -226,10 +376,31 @@ def remove_unused_imports(file_path: Path, project: Project) -> None:
if changes:
changes.do()
print(f"Cleaned imports in {file_path}")
+ subprocess.run(["black", file_path])
+
else:
print(f"No unused imports found in {file_path}")
+class UnionToPipeTransformer(cst.CSTTransformer):
+ def leave_Annotation(
+ self, original_node: cst.Annotation, updated_node: cst.Annotation
+ ) -> cst.Annotation:
+ # Check if the annotation is using a Union
+ if m.matches(updated_node.annotation, m.Subscript(value=m.Name("Union"))):
+ subscript = updated_node.annotation
+ if isinstance(subscript, cst.Subscript):
+ elements = [elt.slice.value for elt in subscript.slice]
+ # Build new binary operator nodes using | for each type in the Union
+ new_annotation = elements[0]
+ for element in elements[1:]:
+ new_annotation = cst.BinaryOperation(
+ left=new_annotation, operator=cst.BitOr(), right=element
+ )
+ return updated_node.with_changes(annotation=new_annotation)
+ return updated_node
+
+
def main():
# Define paths
script_dir = Path(__file__).parent
@@ -238,6 +409,7 @@ def main():
# Define classes to extract
class_names = [
+ "_ClassMethodProxy",
"Shape",
"Compound",
"Solid",
@@ -246,16 +418,26 @@ def main():
"Wire",
"Edge",
"Vertex",
- "Mixin0D",
+ "Curve",
+ "Sketch",
+ "Part",
"Mixin1D",
"Mixin2D",
"Mixin3D",
- "MixinCompound",
+ "Comparable",
+ "ShapePredicate",
+ "SkipClean",
"ShapeList",
+ "GroupBy",
+ "Joint",
]
# Parse source file and collect imports
source_tree = cst.parse_module(topo_file.read_text())
+ source_tree = source_tree.visit(UnionToPipeTransformer())
+ # transformed_module = source_tree.visit(UnionToPipeTransformer())
+ # print(transformed_module.code)
+
collector = ImportCollector()
source_tree.visit(collector)
@@ -267,18 +449,20 @@ def main():
mixin_extractor = MixinClassExtractor()
source_tree.visit(mixin_extractor)
+ # Extract functions
+ function_collector = StandaloneFunctionAndVariableCollector()
+ source_tree.visit(function_collector)
+ # for f in function_collector.functions:
+ # print(f.name.value)
+
# Write the class files
write_topo_class_files(
+ source_tree=source_tree,
extracted_classes=extractor.extracted_classes,
imports=collector.imports,
output_dir=output_dir,
)
- # Write the utils file
- write_utils_file(
- source_tree=source_tree, imports=collector.imports, output_dir=output_dir
- )
-
# Create a Rope project instance
project = Project(str(script_dir))
From b90d0979e2128271291ab43de3087ee0b8c1631f Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 6 Dec 2024 10:56:14 -0500
Subject: [PATCH 031/518] latest update
---
tools/refactor_topo.py | 69 ++++++++++++++++++++++++++++++++++--------
1 file changed, 57 insertions(+), 12 deletions(-)
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 3b1a02d..0a1c395 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -1,3 +1,41 @@
+"""
+refactor topology
+
+name: refactor_topology.py
+by: Gumyr
+date: Dec 05, 2024
+
+desc:
+ This python script refactors the very large topology.py module into several
+ files based on the topological heirarchical order:
+ + shape_core.py - base classes Shape, ShapeList
+ + utils.py - utility classes & functions
+ + zero_d.py - Vertex
+ + one_d.py - Mixin1D, Edge, Wire
+ + two_d.py - Mixin2D, Face, Shell
+ + three_d.py - Mixin3D, Solid
+ + composite.py - Compound
+ Each of these modules import lower order modules to avoid import loops. They
+ also may contain functions used both by end users and higher order modules.
+
+license:
+
+ Copyright 2024 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.
+
+"""
+
from pathlib import Path
import libcst as cst
import libcst.matchers as m
@@ -134,7 +172,6 @@ def write_topo_class_files(
],
"utils": [
"delta",
- "edges_to_wires",
"_extrude_topods_shape",
"find_max_dimension",
"isclose_b",
@@ -153,6 +190,7 @@ def write_topo_class_files(
"topo_explore_common_vertex",
],
"one_d": [
+ "edges_to_wires",
"topo_explore_connected_edges",
],
"two_d": ["sort_wires_by_build_order"],
@@ -169,6 +207,7 @@ def write_topo_class_files(
"ShapeList",
"Joint",
"SkipClean",
+ "BoundBox",
],
"zero_d": ["Vertex"],
"one_d": ["Mixin1D", "Edge", "Wire"],
@@ -227,7 +266,7 @@ license:
additional_imports = []
if group_name != "shape_core":
additional_imports.append(
- "from .shape_core import Shape, ShapeList, SkipClean, TrimmingTool, Joint"
+ "from .shape_core import Shape, ShapeList, BoundBox, SkipClean, TrimmingTool, Joint"
)
additional_imports.append("from .utils import _ClassMethodProxy")
if group_name not in ["shape_core", "vertex"]:
@@ -253,7 +292,7 @@ license:
# Add TYPE_CHECKING imports
if group_name not in ["composite"]:
- additional_imports.append("if TYPE_CHECKING:")
+ additional_imports.append("if TYPE_CHECKING: # pragma: no cover")
if group_name in ["shape_core", "utils"]:
additional_imports.append(" from .zero_d import Vertex")
if group_name in ["shape_core", "utils", "zero_d"]:
@@ -343,13 +382,13 @@ license:
print(f"Created {class_file}")
# Create __init__.py to make it a proper package
- init_file = output_dir / "__init__.py"
- init_content = []
- for group_name in class_groups.keys():
- init_content.append(f"from .{group_name} import *")
+ # init_file = output_dir / "__init__.py"
+ # init_content = []
+ # for group_name in class_groups.keys():
+ # init_content.append(f"from .{group_name} import *")
- init_file.write_text("\n".join(init_content))
- print(f"Created {init_file}")
+ # init_file.write_text("\n".join(init_content))
+ # print(f"Created {init_file}")
def remove_unused_imports(file_path: Path, project: Project) -> None:
@@ -404,12 +443,15 @@ class UnionToPipeTransformer(cst.CSTTransformer):
def main():
# Define paths
script_dir = Path(__file__).parent
- topo_file = script_dir / "topology.py"
- output_dir = script_dir / "topology"
+ topo_file = script_dir / ".." / "src" / "build123d" / "topology_old.py"
+ output_dir = script_dir / ".." / "src" / "build123d" / "topology"
+ topo_file = topo_file.resolve()
+ output_dir = output_dir.resolve()
# Define classes to extract
class_names = [
"_ClassMethodProxy",
+ "BoundBox",
"Shape",
"Compound",
"Solid",
@@ -464,10 +506,13 @@ def main():
)
# Create a Rope project instance
- project = Project(str(script_dir))
+ # project = Project(str(script_dir))
+ project = Project(str(output_dir))
# Clean up imports
for file in output_dir.glob("*.py"):
+ if file.name == "__init__.py":
+ continue
remove_unused_imports(file, project)
From 36a89eafad9800d53b4b6e7bf97acca78e69b1df Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sat, 7 Dec 2024 11:31:42 -0500
Subject: [PATCH 032/518] Added __init__.py generation
---
tools/refactor_topo.py | 318 +++++++++++++++++++++++++++++++++++++++--
1 file changed, 303 insertions(+), 15 deletions(-)
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 0a1c395..4adbc0b 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -45,6 +45,196 @@ from rope.refactor.importutils import ImportOrganizer
import subprocess
from datetime import datetime
+module_descriptions = {
+ "shape_core": """
+This module defines the foundational classes and methods for the build123d CAD library, enabling
+detailed geometric operations and 3D modeling capabilities. It provides a hierarchy of classes
+representing various geometric entities like vertices, edges, wires, faces, shells, solids, and
+compounds. These classes are designed to work seamlessly with the OpenCascade Python bindings,
+leveraging its robust CAD kernel.
+
+Key Features:
+- **Shape Base Class:** Implements core functionalities such as transformations (rotation,
+ translation, scaling), geometric queries, and boolean operations (cut, fuse, intersect).
+- **Custom Utilities:** Includes helper classes like `ShapeList` for advanced filtering, sorting,
+ and grouping of shapes, and `GroupBy` for organizing shapes by specific criteria.
+- **Type Safety:** Extensive use of Python typing features ensures clarity and correctness in type
+ handling.
+- **Advanced Geometry:** Supports operations like finding intersections, computing bounding boxes,
+ projecting faces, and generating triangulated meshes.
+
+The module is designed for extensibility, enabling developers to build complex 3D assemblies and
+perform detailed CAD operations programmatically while maintaining a clean and structured API.
+""",
+ "utils": """
+This module provides utility functions and helper classes for the build123d CAD library, enabling
+advanced geometric operations and facilitating the use of the OpenCascade CAD kernel. It complements
+the core library by offering reusable and modular tools for manipulating shapes, performing Boolean
+operations, and validating geometry.
+
+Key Features:
+- **Geometric Utilities**:
+ - `polar`: Converts polar coordinates to Cartesian.
+ - `tuplify`: Normalizes inputs into consistent tuples.
+ - `find_max_dimension`: Computes the maximum bounding dimension of shapes.
+
+- **Shape Creation**:
+ - `_make_loft`: Creates lofted shapes from wires and vertices.
+ - `_make_topods_compound_from_shapes`: Constructs compounds from multiple shapes.
+ - `_make_topods_face_from_wires`: Generates planar faces with optional holes.
+
+- **Boolean Operations**:
+ - `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes.
+ - `new_edges`: Identifies newly created edges from combined shapes.
+
+- **Utility Classes**:
+ - `_ClassMethodProxy`: Dynamically binds methods across classes.
+
+- **Enhanced Math**:
+ - `isclose_b`: Overrides `math.isclose` with a stricter absolute tolerance.
+
+This module is a critical component of build123d, supporting complex CAD workflows and geometric
+transformations while maintaining a clean, extensible API.
+""",
+ "zero_d": """
+This module provides the foundational implementation for zero-dimensional geometry in the build123d
+CAD system, focusing on the `Vertex` class and its related operations. A `Vertex` represents a
+single point in 3D space, serving as the cornerstone for more complex geometric structures such as
+edges, wires, and faces. It is directly integrated with the OpenCascade kernel, enabling precise
+modeling and manipulation of 3D objects.
+
+Key Features:
+- **Vertex Class**:
+ - Supports multiple constructors, including Cartesian coordinates, iterable inputs, and
+ OpenCascade `TopoDS_Vertex` objects.
+ - Offers robust arithmetic operations such as addition and subtraction with other vertices,
+ vectors, or tuples.
+ - Provides utility methods for transforming vertices, converting to tuples, and iterating over
+ coordinate components.
+
+- **Intersection Utilities**:
+ - Includes `topo_explore_common_vertex`, a utility to identify shared vertices between edges,
+ facilitating advanced topological queries.
+
+- **Integration with Shape Hierarchy**:
+ - Extends the `Shape` base class, inheriting essential features such as transformation matrices
+ and bounding box computations.
+
+This module plays a critical role in defining precise geometric points and their interactions,
+serving as the building block for complex 3D models in the build123d library.
+""",
+ "one_d": """
+This module defines the classes and methods for one-dimensional geometric entities in the build123d
+CAD library. It focuses on `Edge` and `Wire`, representing essential topological elements like
+curves and connected sequences of curves within a 3D model. These entities are pivotal for
+constructing complex shapes, boundaries, and paths in CAD applications.
+
+Key Features:
+- **Edge Class**:
+ - Represents curves such as lines, arcs, splines, and circles.
+ - Supports advanced operations like trimming, offsetting, splitting, and projecting onto shapes.
+ - Includes methods for geometric queries like finding tangent angles, normals, and intersection
+ points.
+
+- **Wire Class**:
+ - Represents a connected sequence of edges forming a continuous path.
+ - Supports operations such as closure, projection, and edge manipulation.
+
+- **Mixin1D**:
+ - Shared functionality for both `Edge` and `Wire` classes, enabling splitting, extrusion, and
+ 1D-specific operations.
+
+This module integrates deeply with OpenCascade, leveraging its robust geometric and topological
+operations. It provides utility functions to create, manipulate, and query 1D geometric entities,
+ensuring precise and efficient workflows in 3D modeling tasks.
+""",
+ "two_d": """
+This module provides classes and methods for two-dimensional geometric entities in the build123d CAD
+library, focusing on the `Face` and `Shell` classes. These entities form the building blocks for
+creating and manipulating complex 2D surfaces and 3D shells, enabling precise modeling for CAD
+applications.
+
+Key Features:
+- **Mixin2D**:
+ - Adds shared functionality to `Face` and `Shell` classes, such as splitting, extrusion, and
+ projection operations.
+
+- **Face Class**:
+ - Represents a 3D bounded surface with advanced features like trimming, offsetting, and Boolean
+ operations.
+ - Provides utilities for creating faces from wires, arrays of points, Bézier surfaces, and ruled
+ surfaces.
+ - Enables geometry queries like normal vectors, surface centers, and planarity checks.
+
+- **Shell Class**:
+ - Represents a collection of connected faces forming a closed surface.
+ - Supports operations like lofting and sweeping profiles along paths.
+
+- **Utilities**:
+ - Includes methods for sorting wires into buildable faces and creating holes within faces
+ efficiently.
+
+The module integrates deeply with OpenCascade to leverage its powerful CAD kernel, offering robust
+and extensible tools for surface and shell creation, manipulation, and analysis.
+""",
+ "three_d": """
+This module defines the `Solid` class and associated methods for creating, manipulating, and
+querying three-dimensional solid geometries in the build123d CAD system. It provides powerful tools
+for constructing complex 3D models, including operations such as extrusion, sweeping, filleting,
+chamfering, and Boolean operations. The module integrates with OpenCascade to leverage its robust
+geometric kernel for precise 3D modeling.
+
+Key Features:
+- **Solid Class**:
+ - Represents closed, bounded 3D shapes with methods for volume calculation, bounding box
+ computation, and validity checks.
+ - Includes constructors for primitive solids (e.g., box, cylinder, cone, torus) and advanced
+ operations like lofting, revolving, and sweeping profiles along paths.
+
+- **Mixin3D**:
+ - Adds shared methods for operations like filleting, chamfering, splitting, and hollowing solids.
+ - Supports advanced workflows such as finding maximum fillet radii and extruding with rotation or
+ taper.
+
+- **Boolean Operations**:
+ - Provides utilities for union, subtraction, and intersection of solids.
+
+- **Thickening and Offsetting**:
+ - Allows transformation of faces or shells into solids through thickening.
+
+This module is essential for generating and manipulating complex 3D geometries in the build123d
+library, offering a comprehensive API for CAD modeling.
+""",
+ "composite": """
+This module defines advanced composite geometric entities for the build123d CAD system. It
+introduces the `Compound` class as a central concept for managing groups of shapes, alongside
+specialized subclasses such as `Curve`, `Sketch`, and `Part` for 1D, 2D, and 3D objects,
+respectively. These classes streamline the construction and manipulation of complex geometric
+assemblies.
+
+Key Features:
+- **Compound Class**:
+ - Represents a collection of geometric shapes (e.g., vertices, edges, faces, solids) grouped
+ hierarchically.
+ - Supports operations like adding, removing, and combining shapes, as well as querying volumes,
+ centers, and intersections.
+ - Provides utility methods for unwrapping nested compounds and generating 3D text or coordinate
+ system triads.
+
+- **Specialized Subclasses**:
+ - `Curve`: Handles 1D objects like edges and wires.
+ - `Sketch`: Focused on 2D objects, such as faces.
+ - `Part`: Manages 3D solids and assemblies.
+
+- **Advanced Features**:
+ - Includes Boolean operations, hierarchy traversal, and bounding box-based intersection detection.
+ - Supports transformations, child-parent relationships, and dynamic updates.
+
+This module leverages OpenCascade for robust geometric operations while offering a Pythonic
+interface for efficient and extensible CAD modeling workflows.
+""",
+}
+
class ImportCollector(cst.CSTVisitor):
def __init__(self):
@@ -179,8 +369,6 @@ def write_topo_class_files(
"_make_topods_compound_from_shapes",
"_make_topods_face_from_wires",
"new_edges",
- "parse_arguments",
- "parse_kwargs",
"polar",
"_topods_bool_op",
"tuplify",
@@ -225,7 +413,7 @@ by: Gumyr
date: {datetime.now().strftime('%B %d, %Y')}
desc:
-
+{module_descriptions[group_name]}
license:
Copyright {datetime.now().strftime('%Y')} Gumyr
@@ -294,16 +482,24 @@ license:
if group_name not in ["composite"]:
additional_imports.append("if TYPE_CHECKING: # pragma: no cover")
if group_name in ["shape_core", "utils"]:
- additional_imports.append(" from .zero_d import Vertex")
+ additional_imports.append(
+ " from .zero_d import Vertex # pylint: disable=R0801"
+ )
if group_name in ["shape_core", "utils", "zero_d"]:
- additional_imports.append(" from .one_d import Edge, Wire")
+ additional_imports.append(
+ " from .one_d import Edge, Wire # pylint: disable=R0801"
+ )
if group_name in ["shape_core", "utils", "one_d"]:
- additional_imports.append(" from .two_d import Face, Shell")
+ additional_imports.append(
+ " from .two_d import Face, Shell # pylint: disable=R0801"
+ )
if group_name in ["shape_core", "utils", "one_d", "two_d"]:
- additional_imports.append(" from .three_d import Solid")
+ additional_imports.append(
+ " from .three_d import Solid # pylint: disable=R0801"
+ )
if group_name in ["shape_core", "utils", "one_d", "two_d", "three_d"]:
additional_imports.append(
- " from .composite import Compound, Curve, Sketch, Part"
+ " from .composite import Compound, Curve, Sketch, Part # pylint: disable=R0801"
)
# Create class file (e.g., two_d.py)
class_file = output_dir / f"{group_name}.py"
@@ -321,7 +517,10 @@ license:
body.append(func)
# If this is the "apply_ocp_monkey_patches" function, add a call to it
- if func.name.value == "apply_ocp_monkey_patches":
+ if (
+ group_name == "shape_core"
+ and func.name.value == "apply_ocp_monkey_patches"
+ ):
apply_patches_call = cst.Expr(
value=cst.Call(func=cst.Name("apply_ocp_monkey_patches"))
)
@@ -382,13 +581,102 @@ license:
print(f"Created {class_file}")
# Create __init__.py to make it a proper package
- # init_file = output_dir / "__init__.py"
- # init_content = []
- # for group_name in class_groups.keys():
- # init_content.append(f"from .{group_name} import *")
+ init_file = output_dir / "__init__.py"
+ init_content = f'''
+"""
+build123d.topology package
- # init_file.write_text("\n".join(init_content))
- # print(f"Created {init_file}")
+name: __init__.py
+by: Gumyr
+date: {datetime.now().strftime('%B %d, %Y')}
+
+desc:
+ This package contains modules for representing and manipulating 3D geometric shapes,
+ including operations on vertices, edges, faces, solids, and composites.
+ The package provides foundational classes to work with 3D objects, and methods to
+ manipulate and analyze those objects.
+
+license:
+
+ Copyright {datetime.now().strftime('%Y')} 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.
+
+"""
+
+from .shape_core import (
+ Shape,
+ Comparable,
+ ShapePredicate,
+ GroupBy,
+ ShapeList,
+ Joint,
+ SkipClean,
+ BoundBox,
+ downcast,
+ fix,
+ unwrap_topods_compound,
+)
+from .utils import (
+ tuplify,
+ isclose_b,
+ polar,
+ delta,
+ new_edges,
+ find_max_dimension,
+)
+from .zero_d import Vertex, topo_explore_common_vertex
+from .one_d import Edge, Wire, edges_to_wires, topo_explore_connected_edges
+from .two_d import Face, Shell, sort_wires_by_build_order
+from .three_d import Solid
+from .composite import Compound, Curve, Sketch, Part
+
+__all__ = [
+ "Shape",
+ "Comparable",
+ "ShapePredicate",
+ "GroupBy",
+ "ShapeList",
+ "Joint",
+ "SkipClean",
+ "BoundBox",
+ "downcast",
+ "fix",
+ "unwrap_topods_compound",
+ "tuplify",
+ "isclose_b",
+ "polar",
+ "delta",
+ "new_edges",
+ "find_max_dimension",
+ "Vertex",
+ "topo_explore_common_vertex",
+ "Edge",
+ "Wire",
+ "edges_to_wires",
+ "topo_explore_connected_edges",
+ "Face",
+ "Shell",
+ "sort_wires_by_build_order",
+ "Solid",
+ "Compound",
+ "Curve",
+ "Sketch",
+ "Part",
+]
+'''
+ init_file.write_text(init_content)
+ print(f"Created {init_file}")
def remove_unused_imports(file_path: Path, project: Project) -> None:
From d1de2a6da1ed959e8280552402139da802078b40 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 9 Dec 2024 10:09:38 -0500
Subject: [PATCH 033/518] Refactored topology.py ready to split into multiple
modules
---
src/build123d/build_common.py | 19 +-
src/build123d/drafting.py | 18 +-
src/build123d/exporters.py | 23 +-
src/build123d/geometry.py | 2 +-
src/build123d/importers.py | 2 +-
src/build123d/objects_sketch.py | 2 +-
src/build123d/operations_generic.py | 13 +-
src/build123d/operations_part.py | 9 +-
src/build123d/operations_sketch.py | 3 +-
src/build123d/topology.py | 3272 ++++++++++++++-------------
tests/test_algebra.py | 10 +-
tests/test_build_sketch.py | 2 -
tests/test_direct_api.py | 195 +-
tests/test_joints.py | 4 +-
14 files changed, 1831 insertions(+), 1743 deletions(-)
diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py
index 1dd5ddb..f2f42c5 100644
--- a/src/build123d/build_common.py
+++ b/src/build123d/build_common.py
@@ -429,23 +429,30 @@ class Builder(ABC):
if mode == Mode.ADD:
if self._obj is None:
if len(typed[self._shape]) == 1:
- self._obj = typed[self._shape][0]
+ combined = typed[self._shape][0]
else:
- self._obj = (
+ combined = (
typed[self._shape].pop().fuse(*typed[self._shape])
)
else:
- self._obj = self._obj.fuse(*typed[self._shape])
+ combined = self._obj.fuse(*typed[self._shape])
elif mode == Mode.SUBTRACT:
if self._obj is None:
raise RuntimeError("Nothing to subtract from")
- self._obj = self._obj.cut(*typed[self._shape])
+ combined = self._obj.cut(*typed[self._shape])
elif mode == Mode.INTERSECT:
if self._obj is None:
raise RuntimeError("Nothing to intersect with")
- self._obj = self._obj.intersect(*typed[self._shape])
+ combined = self._obj.intersect(*typed[self._shape])
elif mode == Mode.REPLACE:
- self._obj = Compound(list(typed[self._shape]))
+ combined = self._sub_class(list(typed[self._shape]))
+
+ # If the boolean operation created a list, convert back
+ self._obj = (
+ self._sub_class(combined)
+ if isinstance(combined, list)
+ else combined
+ )
if self._obj is not None and clean:
self._obj = self._obj.clean()
diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py
index d7c1086..4f824a7 100644
--- a/src/build123d/drafting.py
+++ b/src/build123d/drafting.py
@@ -439,23 +439,25 @@ class DimensionLine(BaseSketchObject):
overage = shaft_length + draft.pad_around_text + label_length / 2
label_u_values = [0.5, -overage / path_length, 1 + overage / path_length]
- # d_lines = Sketch(children=arrows[0])
d_lines = {}
- # for arrow_pair in arrow_shapes:
for u_value in label_u_values:
- d_line = Sketch()
- for add_arrow, arrow_shape in zip(arrows, arrow_shapes):
- if add_arrow:
- d_line += arrow_shape
+ select_arrow_shapes = [
+ arrow_shape
+ for add_arrow, arrow_shape in zip(arrows, arrow_shapes)
+ if add_arrow
+ ]
+ d_line = Sketch(select_arrow_shapes)
flip_label = path_obj.tangent_at(u_value).get_angle(Vector(1, 0, 0)) >= 180
loc = Draft._sketch_location(path_obj, u_value, flip_label)
placed_label = label_shape.located(loc)
- self_intersection = d_line.intersect(placed_label).area
+ self_intersection = Sketch.intersect(d_line, placed_label).area
d_line += placed_label
bbox_size = d_line.bounding_box().size
# Minimize size while avoiding intersections
- common_area = 0.0 if sketch is None else d_line.intersect(sketch).area
+ common_area = (
+ 0.0 if sketch is None else Sketch.intersect(d_line, sketch).area
+ )
common_area += self_intersection
score = (d_line.area - 10 * common_area) / bbox_size.X
d_lines[d_line] = score
diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py
index b56914c..96fcfdb 100644
--- a/src/build123d/exporters.py
+++ b/src/build123d/exporters.py
@@ -55,17 +55,14 @@ from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum # type: ignore
from OCP.TopExp import TopExp_Explorer # type: ignore
from typing_extensions import Self
-from build123d.build_enums import Unit
-from build123d.geometry import TOLERANCE, Color
+from build123d.build_enums import Unit, GeomType
+from build123d.geometry import TOLERANCE, Color, Vector, VectorLike
from build123d.topology import (
BoundBox,
Compound,
Edge,
Wire,
- GeomType,
Shape,
- Vector,
- VectorLike,
)
from build123d.build_common import UNITS_PER_METER
@@ -682,7 +679,7 @@ class ExportDXF(Export2D):
def _convert_circle(self, edge: Edge, attribs: dict):
"""Converts a Circle object into a DXF circle entity."""
- curve = edge._geom_adaptor()
+ curve = edge.geom_adaptor()
circle = curve.Circle()
center = self._convert_point(circle.Location())
radius = circle.Radius()
@@ -710,7 +707,7 @@ class ExportDXF(Export2D):
def _convert_ellipse(self, edge: Edge, attribs: dict):
"""Converts an Ellipse object into a DXF ellipse entity."""
- geom = edge._geom_adaptor()
+ geom = edge.geom_adaptor()
ellipse = geom.Ellipse()
minor_radius = ellipse.MinorRadius()
major_radius = ellipse.MajorRadius()
@@ -743,7 +740,7 @@ class ExportDXF(Export2D):
# This pulls the underlying Geom_BSplineCurve out of the Edge.
# The adaptor also supplies a parameter range for the curve.
- adaptor = edge._geom_adaptor()
+ adaptor = edge.geom_adaptor()
curve = adaptor.Curve().Curve()
u1 = adaptor.FirstParameter()
u2 = adaptor.LastParameter()
@@ -1157,7 +1154,7 @@ class ExportSVG(Export2D):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _line_segment(self, edge: Edge, reverse: bool) -> PT.Line:
- curve = edge._geom_adaptor()
+ curve = edge.geom_adaptor()
fp = curve.FirstParameter()
lp = curve.LastParameter()
(u0, u1) = (lp, fp) if reverse else (fp, lp)
@@ -1187,7 +1184,7 @@ class ExportSVG(Export2D):
def _circle_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]:
# pylint: disable=too-many-locals
- curve = edge._geom_adaptor()
+ curve = edge.geom_adaptor()
circle = curve.Circle()
radius = circle.Radius()
x_axis = circle.XAxis().Direction()
@@ -1215,7 +1212,7 @@ class ExportSVG(Export2D):
def _circle_element(self, edge: Edge) -> ET.Element:
"""Converts a Circle object into an SVG circle element."""
if edge.is_closed:
- curve = edge._geom_adaptor()
+ curve = edge.geom_adaptor()
circle = curve.Circle()
radius = circle.Radius()
center = circle.Location()
@@ -1233,7 +1230,7 @@ class ExportSVG(Export2D):
def _ellipse_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]:
# pylint: disable=too-many-locals
- curve = edge._geom_adaptor()
+ curve = edge.geom_adaptor()
ellipse = curve.Ellipse()
minor_radius = ellipse.MinorRadius()
major_radius = ellipse.MajorRadius()
@@ -1276,7 +1273,7 @@ class ExportSVG(Export2D):
# This pulls the underlying Geom_BSplineCurve out of the Edge.
# The adaptor also supplies a parameter range for the curve.
- adaptor = edge._geom_adaptor()
+ adaptor = edge.geom_adaptor()
spline = adaptor.Curve().Curve()
u1 = adaptor.FirstParameter()
u2 = adaptor.LastParameter()
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 94ad4b0..219e9a2 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -963,7 +963,7 @@ class BoundBox:
return result
@classmethod
- def _from_topo_ds(
+ def from_topo_ds(
cls,
shape: TopoDS_Shape,
tolerance: float = None,
diff --git a/src/build123d/importers.py b/src/build123d/importers.py
index b9f5299..baf4dc0 100644
--- a/src/build123d/importers.py
+++ b/src/build123d/importers.py
@@ -113,7 +113,7 @@ def import_brep(file_name: Union[PathLike, str, bytes]) -> Shape:
if shape.IsNull():
raise ValueError(f"Could not import {file_name}")
- return Shape.cast(shape)
+ return Compound.cast(shape)
def import_step(filename: Union[PathLike, str, bytes]) -> Compound:
diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py
index ca85a60..c91d87c 100644
--- a/src/build123d/objects_sketch.py
+++ b/src/build123d/objects_sketch.py
@@ -43,6 +43,7 @@ from build123d.geometry import (
Vector,
VectorLike,
to_align_offset,
+ TOLERANCE,
)
from build123d.topology import (
Compound,
@@ -52,7 +53,6 @@ from build123d.topology import (
Sketch,
Wire,
tuplify,
- TOLERANCE,
topo_explore_common_vertex,
)
diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py
index 3b809cd..9909cbf 100644
--- a/src/build123d/operations_generic.py
+++ b/src/build123d/operations_generic.py
@@ -904,7 +904,7 @@ SplitType = Union[Edge, Wire, Face, Solid]
def split(
objects: Union[SplitType, Iterable[SplitType]] = None,
- bisect_by: Union[Plane, Face] = Plane.XZ,
+ bisect_by: Union[Plane, Face, Shell] = Plane.XZ,
keep: Keep = Keep.TOP,
mode: Mode = Mode.REPLACE,
):
@@ -937,7 +937,16 @@ def split(
new_objects = []
for obj in object_list:
- new_objects.append(obj.split(bisect_by, keep))
+ bottom = None
+ if keep == Keep.BOTH:
+ top, bottom = obj.split(bisect_by, keep)
+ else:
+ top = obj.split(bisect_by, keep)
+ for subpart in [top, bottom]:
+ if isinstance(subpart, Iterable):
+ new_objects.extend(subpart)
+ elif subpart is not None:
+ new_objects.append(subpart)
if context is not None:
context._add_to_context(*new_objects, mode=mode)
diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py
index 09e6fc3..464da38 100644
--- a/src/build123d/operations_part.py
+++ b/src/build123d/operations_part.py
@@ -173,7 +173,10 @@ def extrude(
context._add_to_context(*new_solids, clean=clean, mode=mode)
else:
if len(new_solids) > 1:
- new_solids = [new_solids.pop().fuse(*new_solids)]
+ fused_solids = new_solids.pop().fuse(*new_solids)
+ new_solids = (
+ fused_solids if isinstance(fused_solids, list) else [fused_solids]
+ )
if clean:
new_solids = [solid.clean() for solid in new_solids]
@@ -597,7 +600,9 @@ def thicken(
)
for direction in [1, -1] if both else [1]:
new_solids.append(
- face.thicken(depth=amount, normal_override=face_normal * direction)
+ Solid.thicken(
+ face, depth=amount, normal_override=face_normal * direction
+ )
)
if context is not None:
diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py
index e5b2d6b..35e65b9 100644
--- a/src/build123d/operations_sketch.py
+++ b/src/build123d/operations_sketch.py
@@ -40,9 +40,8 @@ from build123d.topology import (
Sketch,
topo_explore_connected_edges,
topo_explore_common_vertex,
- TOLERANCE,
)
-from build123d.geometry import Vector
+from build123d.geometry import Vector, TOLERANCE
from build123d.build_common import flatten_sequence, validate_inputs
from build123d.build_sketch import BuildSketch
from scipy.spatial import Voronoi
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index be15a1b..b75fe5e 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -36,10 +36,12 @@ from __future__ import annotations
# too-many-arguments, too-many-locals, too-many-public-methods,
# too-many-statements, too-many-instance-attributes, too-many-branches
import copy
+import inspect
import itertools
import os
import platform
import sys
+import types
import warnings
from abc import ABC, ABCMeta, abstractmethod
from io import BytesIO
@@ -54,20 +56,22 @@ from typing import (
Iterator,
Optional,
Protocol,
+ Sequence,
Tuple,
Type,
TypeVar,
Union,
overload,
+ TYPE_CHECKING,
)
from typing import cast as tcast
from typing_extensions import Self, Literal, deprecated
from anytree import NodeMixin, PreOrderIter, RenderTree
-from IPython.lib.pretty import pretty
+from IPython.lib.pretty import pretty, PrettyPrinter
from numpy import ndarray
from scipy.optimize import minimize
-from scipy.spatial import ConvexHull
+from scipy.spatial import ConvexHull # pylint:disable=no-name-in-module
from vtkmodules.vtkCommonDataModel import vtkPolyData
from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter
@@ -300,68 +304,46 @@ from build123d.geometry import (
)
+@property
+def _topods_compound_dim(self) -> int | None:
+ """The dimension of the shapes within the Compound - None if inconsistent"""
+ sub_dims = {s.dim for s in get_top_level_topods_shapes(self)}
+ return sub_dims.pop() if len(sub_dims) == 1 else None
+
+
+def _topods_face_normal_at(self, surface_point: gp_Pnt) -> Vector:
+ """normal_at point on surface"""
+ surface = BRep_Tool.Surface_s(self)
+
+ # project point on surface
+ projector = GeomAPI_ProjectPointOnSurf(surface_point, surface)
+ u_val, v_val = projector.LowerDistanceParameters()
+
+ gp_pnt = gp_Pnt()
+ normal = gp_Vec()
+ BRepGProp_Face(self).Normal(u_val, v_val, gp_pnt, normal)
+
+ return Vector(normal).normalized()
+
+
+def apply_ocp_monkey_patches() -> None:
+ """Applies monkey patches to TopoDS classes."""
+ TopoDS_Compound.dim = _topods_compound_dim
+ TopoDS_Face.dim = 2
+ TopoDS_Face.normal_at = _topods_face_normal_at
+ TopoDS_Shape.dim = None
+ TopoDS_Shell.dim = 2
+ TopoDS_Solid.dim = 3
+ TopoDS_Vertex.dim = 0
+ TopoDS_Edge.dim = 1
+ TopoDS_Wire.dim = 1
+
+
+apply_ocp_monkey_patches()
+
+
HASH_CODE_MAX = 2147483647 # max 32bit signed int, required by OCC.Core.HashCode
-shape_LUT = {
- ta.TopAbs_VERTEX: "Vertex",
- ta.TopAbs_EDGE: "Edge",
- ta.TopAbs_WIRE: "Wire",
- ta.TopAbs_FACE: "Face",
- ta.TopAbs_SHELL: "Shell",
- ta.TopAbs_SOLID: "Solid",
- ta.TopAbs_COMPOUND: "Compound",
- ta.TopAbs_COMPSOLID: "CompSolid",
-}
-
-shape_properties_LUT = {
- ta.TopAbs_VERTEX: None,
- ta.TopAbs_EDGE: BRepGProp.LinearProperties_s,
- ta.TopAbs_WIRE: BRepGProp.LinearProperties_s,
- ta.TopAbs_FACE: BRepGProp.SurfaceProperties_s,
- ta.TopAbs_SHELL: BRepGProp.SurfaceProperties_s,
- ta.TopAbs_SOLID: BRepGProp.VolumeProperties_s,
- ta.TopAbs_COMPOUND: BRepGProp.VolumeProperties_s,
- ta.TopAbs_COMPSOLID: BRepGProp.VolumeProperties_s,
-}
-
-inverse_shape_LUT = {v: k for k, v in shape_LUT.items()}
-
-downcast_LUT = {
- ta.TopAbs_VERTEX: TopoDS.Vertex_s,
- ta.TopAbs_EDGE: TopoDS.Edge_s,
- ta.TopAbs_WIRE: TopoDS.Wire_s,
- ta.TopAbs_FACE: TopoDS.Face_s,
- ta.TopAbs_SHELL: TopoDS.Shell_s,
- ta.TopAbs_SOLID: TopoDS.Solid_s,
- ta.TopAbs_COMPOUND: TopoDS.Compound_s,
- ta.TopAbs_COMPSOLID: TopoDS.CompSolid_s,
-}
-
-geom_LUT_FACE: Dict[ga.GeomAbs_SurfaceType, GeomType] = {
- ga.GeomAbs_Plane: GeomType.PLANE,
- ga.GeomAbs_Cylinder: GeomType.CYLINDER,
- ga.GeomAbs_Cone: GeomType.CONE,
- ga.GeomAbs_Sphere: GeomType.SPHERE,
- ga.GeomAbs_Torus: GeomType.TORUS,
- ga.GeomAbs_BezierSurface: GeomType.BEZIER,
- ga.GeomAbs_BSplineSurface: GeomType.BSPLINE,
- ga.GeomAbs_SurfaceOfRevolution: GeomType.REVOLUTION,
- ga.GeomAbs_SurfaceOfExtrusion: GeomType.EXTRUSION,
- ga.GeomAbs_OffsetSurface: GeomType.OFFSET,
- ga.GeomAbs_OtherSurface: GeomType.OTHER,
-}
-
-geom_LUT_EDGE: Dict[ga.GeomAbs_CurveType, GeomType] = {
- ga.GeomAbs_Line: GeomType.LINE,
- ga.GeomAbs_Circle: GeomType.CIRCLE,
- ga.GeomAbs_Ellipse: GeomType.ELLIPSE,
- ga.GeomAbs_Hyperbola: GeomType.HYPERBOLA,
- ga.GeomAbs_Parabola: GeomType.PARABOLA,
- ga.GeomAbs_BezierCurve: GeomType.BEZIER,
- ga.GeomAbs_BSplineCurve: GeomType.BSPLINE,
- ga.GeomAbs_OffsetCurve: GeomType.OFFSET,
- ga.GeomAbs_OtherCurve: GeomType.OTHER,
-}
Shapes = Literal["Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"]
@@ -379,26 +361,70 @@ def tuplify(obj: Any, dim: int) -> tuple:
return result
+class _ClassMethodProxy:
+ """
+ A proxy for dynamically binding a class method to different classes.
+
+ This descriptor allows a class method defined in one class to be reused
+ in other classes while ensuring that the `cls` parameter refers to the
+ correct class (the class on which the method is being called). This avoids
+ issues where the method would otherwise always reference the original
+ defining class.
+
+ Attributes:
+ method (classmethod): The class method to be proxied.
+
+ Methods:
+ __get__(instance, owner):
+ Dynamically binds the proxied method to the calling class (`owner`).
+
+ Example:
+ class Mixin1D:
+ @classmethod
+ def extrude(cls, shape, direction):
+ print(f"extrude called on {cls.__name__}")
+
+ class Mixin2D:
+ extrude = ClassMethodProxy(Mixin1D.extrude)
+
+ class Mixin3D:
+ extrude = ClassMethodProxy(Mixin1D.extrude)
+
+ # Usage
+ Mixin2D.extrude(None, None) # Output: extrude called on Mixin2D
+ Mixin3D.extrude(None, None) # Output: extrude called on Mixin3D
+ """
+
+ def __init__(self, method):
+ self.method = method
+
+ def __get__(self, instance, owner):
+ # Bind the method dynamically as a class method of `owner`
+ return types.MethodType(self.method.__func__, owner)
+
+
class Mixin1D:
"""Methods to add to the Edge and Wire classes"""
def __add__(self, other: Union[list[Shape], Shape]) -> Self:
"""fuse shape to wire/edge operator +"""
- # Convert `other` to list of base objects and filter out None values
+ # Convert `other` to list of base topods objects and filter out None values
summands = [
shape
for o in (other if isinstance(other, (list, tuple)) else [other])
if o is not None
- for shape in o.get_top_level_shapes()
+ for shape in get_top_level_topods_shapes(o.wrapped)
]
# If there is nothing to add return the original object
if not summands:
return self
- if not all(summand._dim == 1 for summand in summands):
+ if not all(summand.dim == 1 for summand in summands):
raise ValueError("Only shapes with the same dimension can be added")
+ # Convert back to Edge/Wire objects now that it's safe to do so
+ summands = [Mixin1D.cast(s) for s in summands]
summand_edges = [e for summand in summands for e in summand.edges()]
if self.wrapped is None: # an empty object
if len(summands) == 1:
@@ -408,13 +434,15 @@ class Mixin1D:
sum_shape = Wire(summand_edges)
except Exception:
sum_shape = summands[0].fuse(*summands[1:])
+ if type(self).order == 4:
+ sum_shape = type(self)(sum_shape)
else:
try:
sum_shape = Wire(self.edges() + summand_edges)
except Exception:
sum_shape = self.fuse(*summands)
- if SkipClean.clean:
+ if SkipClean.clean and not isinstance(sum_shape, list):
sum_shape = sum_shape.clean()
# If there is only one Edge, return that
@@ -422,12 +450,164 @@ class Mixin1D:
return sum_shape
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Self:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # Extend the lookup table with additional entries
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
+ @overload
+ def split(
+ self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM]
+ ) -> Optional[Self] | Optional[list[Self]]:
+ """split and keep inside or outside"""
+
+ @overload
+ def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[
+ Optional[Self] | Optional[list[Self]],
+ Optional[Self] | Optional[list[Self]],
+ ]:
+ """split and keep inside and outside"""
+
+ @overload
+ def split(self, tool: TrimmingTool) -> Optional[Self] | Optional[list[Self]]:
+ """split and keep inside (default)"""
+
+ def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP):
+ """split
+
+ Split this shape by the provided plane or face.
+
+ Args:
+ surface (Union[Plane,Face]): surface to segment shape
+ keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP.
+
+ Returns:
+ Shape: result of split
+ Returns:
+ Optional[Self] | Optional[list[Self]],
+ Tuple[Optional[Self] | Optional[list[Self]]]: The result of the split operation.
+
+ - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None`
+ if no top is found.
+ - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None`
+ if no bottom is found.
+ - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is
+ either a `Self` or `list[Self]`, or `None` if no corresponding part is found.
+ """
+ shape_list = TopTools_ListOfShape()
+ shape_list.Append(self.wrapped)
+
+ # Define the splitting tool
+ trim_tool = (
+ BRepBuilderAPI_MakeFace(tool.wrapped).Face() # Plane to Face
+ if isinstance(tool, Plane)
+ else tool.wrapped
+ )
+ tool_list = TopTools_ListOfShape()
+ tool_list.Append(trim_tool)
+
+ # Create the splitter algorithm
+ splitter = BRepAlgoAPI_Splitter()
+
+ # Set the shape to be split and the splitting tool (plane face)
+ splitter.SetArguments(shape_list)
+ splitter.SetTools(tool_list)
+
+ # Perform the splitting operation
+ splitter.Build()
+
+ split_result = downcast(splitter.Shape())
+ # Remove unnecessary TopoDS_Compound around single shape
+ if isinstance(split_result, TopoDS_Compound):
+ split_result = unwrap_topods_compound(split_result, True)
+
+ if not isinstance(tool, Plane):
+ # Create solids from the surfaces for sorting by thickening
+ offset_builder = BRepOffset_MakeOffset()
+ offset_builder.Initialize(
+ tool.wrapped,
+ Offset=0.1,
+ Tol=1.0e-5,
+ Intersection=True,
+ Join=GeomAbs_Intersection,
+ Thickening=True,
+ )
+ offset_builder.MakeOffsetShape()
+ try:
+ tool_thickened = downcast(offset_builder.Shape())
+ except StdFail_NotDone as err:
+ raise RuntimeError("Error determining top/bottom") from err
+
+ tops, bottoms = [], []
+ properties = GProp_GProps()
+ for part in get_top_level_topods_shapes(split_result):
+ sub_shape = self.__class__.cast(part)
+ if isinstance(tool, Plane):
+ is_up = tool.to_local_coords(sub_shape).center().Z >= 0
+ else:
+ # Intersect self and the thickened tool
+ is_up_obj = _topods_bool_op(
+ (part,), (tool_thickened,), BRepAlgoAPI_Common()
+ )
+ # Calculate volume of intersection
+ BRepGProp.VolumeProperties_s(is_up_obj, properties)
+ is_up = properties.Mass() >= TOLERANCE
+ (tops if is_up else bottoms).append(sub_shape)
+
+ top = None if not tops else tops[0] if len(tops) == 1 else tops
+ bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms
+
+ if keep == Keep.BOTH:
+ return (top, bottom)
+ if keep == Keep.TOP:
+ return top
+ if keep == Keep.BOTTOM:
+ return bottom
+ return None
+
+ def vertices(self) -> ShapeList[Vertex]:
+ """vertices - all the vertices in this Shape"""
+ return Shape.get_shape_list(self, "Vertex")
+
+ def vertex(self) -> Vertex:
+ """Return the Vertex"""
+ return Shape.get_single_shape(self, "Vertex")
+
+ def edges(self) -> ShapeList[Edge]:
+ """edges - all the edges in this Shape"""
+ edge_list = Shape.get_shape_list(self, "Edge")
+ return edge_list.filter_by(
+ lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
+ )
+
+ def edge(self) -> Edge:
+ """Return the Edge"""
+ return Shape.get_single_shape(self, "Edge")
+
+ def wires(self) -> ShapeList[Wire]:
+ """wires - all the wires in this Shape"""
+ return Shape.get_shape_list(self, "Wire")
+
+ def wire(self) -> Wire:
+ """Return the Wire"""
+ return Shape.get_single_shape(self, "Wire")
+
def start_point(self) -> Vector:
"""The start point of this edge
Note that circles may have identical start and end points.
"""
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
umin = curve.FirstParameter()
return Vector(curve.Value(umin))
@@ -437,7 +617,7 @@ class Mixin1D:
Note that circles may have identical start and end points.
"""
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
umax = curve.LastParameter()
return Vector(curve.Value(umax))
@@ -453,7 +633,7 @@ class Mixin1D:
Returns:
float: parameter value
"""
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
length = GCPnts_AbscissaPoint.Length_s(curve)
return GCPnts_AbscissaPoint(
@@ -484,7 +664,7 @@ class Mixin1D:
"""
if isinstance(position, (float, int)):
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
if position_mode == PositionMode.PARAMETER:
parameter = self.param_at(position)
else:
@@ -492,8 +672,8 @@ class Mixin1D:
else:
try:
pnt = Vector(position)
- except Exception:
- raise ValueError("position must be a float or a point")
+ except Exception as exc:
+ raise ValueError("position must be a float or a point") from exc
# GeomAPI_ProjectPointOnCurve only works with Edges so find
# the closest Edge if the shape has multiple Edges.
my_edges: list[Edge] = self.edges()
@@ -548,7 +728,7 @@ class Mixin1D:
"""
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
gtype = self.geom_type
if gtype == GeomType.CIRCLE:
@@ -610,15 +790,15 @@ class Mixin1D:
all_lines: list[Edge, Wire] = [
line for line in [self, *lines] if line is not None
]
- if any([not isinstance(line, (Edge, Wire)) for line in all_lines]):
+ if any(not isinstance(line, (Edge, Wire)) for line in all_lines):
raise ValueError("Only Edges or Wires are valid")
result = None
# Are they all co-axial - if so, select one of the infinite planes
all_edges: list[Edge] = [e for l in all_lines for e in l.edges()]
- if all([e.geom_type == GeomType.LINE for e in all_edges]):
+ if all(e.geom_type == GeomType.LINE for e in all_edges):
as_axis = [Axis(e @ 0, e % 0) for e in all_edges]
- if all([a0.is_coaxial(a1) for a0, a1 in combinations(as_axis, 2)]):
+ if all(a0.is_coaxial(a1) for a0, a1 in combinations(as_axis, 2)):
origin = as_axis[0].position
x_dir = as_axis[0].direction
z_dir = as_axis[0].to_plane().x_dir
@@ -643,7 +823,9 @@ class Mixin1D:
points = list(set(points)) # unique points
extreme_areas = {}
for subset in combinations(points, 3):
- area = Face(Wire.make_polygon(subset, close=True)).area
+ vector1 = subset[1] - subset[0]
+ vector2 = subset[2] - subset[0]
+ area = 0.5 * (vector1.cross(vector2).length)
extreme_areas[area] = subset
# The points that create the largest area make the most accurate plane
extremes = extreme_areas[sorted(list(extreme_areas.keys()))[-1]]
@@ -659,7 +841,7 @@ class Mixin1D:
result = None
else:
# Are all of the points on the common plane
- common = all([c_plane.contains(p) for p in points])
+ common = all(c_plane.contains(p) for p in points)
result = c_plane if common else None
return result
@@ -667,7 +849,7 @@ class Mixin1D:
@property
def length(self) -> float:
"""Edge or Wire length"""
- return GCPnts_AbscissaPoint.Length_s(self._geom_adaptor())
+ return GCPnts_AbscissaPoint.Length_s(self.geom_adaptor())
@property
def radius(self) -> float:
@@ -684,7 +866,7 @@ class Mixin1D:
ValueError: if kernel can not reduce the shape to a circular edge
"""
- geom = self._geom_adaptor()
+ geom = self.geom_adaptor()
try:
circ = geom.Circle()
except (Standard_NoSuchObject, Standard_Failure) as err:
@@ -721,7 +903,7 @@ class Mixin1D:
Returns:
Vector: position on the underlying curve
"""
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
if position_mode == PositionMode.PARAMETER:
param = self.param_at(distance)
@@ -772,7 +954,7 @@ class Mixin1D:
Location: A Location object representing local coordinate system
at the specified distance.
"""
- curve = self._geom_adaptor()
+ curve = self.geom_adaptor()
if position_mode == PositionMode.PARAMETER:
param = self.param_at(distance)
@@ -891,16 +1073,10 @@ class Mixin1D:
obj = downcast(offset_builder.Shape())
if isinstance(obj, TopoDS_Compound):
- offset_wire = None
- for i, shape in enumerate(Compound(obj)):
- offset_wire = Wire(shape.wrapped)
- if i >= 1:
- raise RuntimeError("Multiple Wires generated")
- if offset_wire is None:
- raise RuntimeError("No offset generated")
- elif isinstance(obj, TopoDS_Wire):
+ obj = unwrap_topods_compound(obj, fully=True)
+ if isinstance(obj, TopoDS_Wire):
offset_wire = Wire(obj)
- else:
+ else: # Likely multiple Wires were generated
raise RuntimeError("Unexpected result type")
if side != Side.BOTH:
@@ -971,7 +1147,7 @@ class Mixin1D:
def project(
self, face: Face, direction: VectorLike, closest: bool = True
- ) -> Union[Mixin1D, list[Mixin1D]]:
+ ) -> Union[Mixin1D, ShapeList[Mixin1D]]:
"""Project onto a face along the specified direction
Args:
@@ -986,7 +1162,7 @@ class Mixin1D:
bldr = BRepProj_Projection(
self.wrapped, face.wrapped, Vector(direction).to_dir()
)
- shapes = Compound(bldr.Shape())
+ shapes: TopoDS_Compound = bldr.Shape()
# select the closest projection if requested
return_value: Union[Mixin1D, list[Mixin1D]]
@@ -997,24 +1173,240 @@ class Mixin1D:
min_dist = inf
- for shape in shapes:
- dist_calc.LoadS2(shape.wrapped)
+ # for shape in shapes:
+ for shape in get_top_level_topods_shapes(shapes):
+ dist_calc.LoadS2(shape)
dist_calc.Perform()
dist = dist_calc.Value()
if dist < min_dist:
min_dist = dist
- return_value = tcast(Mixin1D, shape)
+ return_value = Mixin1D.cast(shape)
else:
- return_value = [tcast(Mixin1D, shape) for shape in shapes]
+ return_value = ShapeList(
+ Mixin1D.cast(shape) for shape in get_top_level_topods_shapes(shapes)
+ )
return return_value
+ def project_to_viewport(
+ self,
+ viewport_origin: VectorLike,
+ viewport_up: VectorLike = (0, 0, 1),
+ look_at: VectorLike = None,
+ ) -> tuple[ShapeList[Edge], ShapeList[Edge]]:
+ """project_to_viewport
+
+ Project a shape onto a viewport returning visible and hidden Edges.
+
+ Args:
+ viewport_origin (VectorLike): location of viewport
+ viewport_up (VectorLike, optional): direction of the viewport y axis.
+ Defaults to (0, 0, 1).
+ look_at (VectorLike, optional): point to look at.
+ Defaults to None (center of shape).
+
+ Returns:
+ tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges
+ """
+
+ def extract_edges(compound):
+ edges = [] # List to store the extracted edges
+
+ # Create a TopExp_Explorer to traverse the sub-shapes of the compound
+ explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE)
+
+ # Loop through the sub-shapes and extract edges
+ while explorer.More():
+ edge = downcast(explorer.Current())
+ edges.append(edge)
+ explorer.Next()
+
+ return edges
+
+ # Setup the projector
+ hidden_line_removal = HLRBRep_Algo()
+ hidden_line_removal.Add(self.wrapped)
+
+ viewport_origin = Vector(viewport_origin)
+ look_at = Vector(look_at) if look_at else self.center()
+ projection_dir: Vector = (viewport_origin - look_at).normalized()
+ viewport_up = Vector(viewport_up).normalized()
+ camera_coordinate_system = gp_Ax2()
+ camera_coordinate_system.SetAxis(
+ gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir())
+ )
+ camera_coordinate_system.SetYDirection(viewport_up.to_dir())
+ projector = HLRAlgo_Projector(camera_coordinate_system)
+
+ hidden_line_removal.Projector(projector)
+ hidden_line_removal.Update()
+ hidden_line_removal.Hide()
+
+ hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal)
+
+ # Create the visible edges
+ visible_edges = []
+ for edges in [
+ hlr_shapes.VCompound(),
+ hlr_shapes.Rg1LineVCompound(),
+ hlr_shapes.OutLineVCompound(),
+ ]:
+ if not edges.IsNull():
+ visible_edges.extend(extract_edges(downcast(edges)))
+
+ # Create the hidden edges
+ hidden_edges = []
+ for edges in [
+ hlr_shapes.HCompound(),
+ hlr_shapes.OutLineHCompound(),
+ hlr_shapes.Rg1LineHCompound(),
+ ]:
+ if not edges.IsNull():
+ hidden_edges.extend(extract_edges(downcast(edges)))
+
+ # Fix the underlying geometry - otherwise we will get segfaults
+ for edge in visible_edges:
+ BRepLib.BuildCurves3d_s(edge, TOLERANCE)
+ for edge in hidden_edges:
+ BRepLib.BuildCurves3d_s(edge, TOLERANCE)
+
+ # convert to native shape objects
+ visible_edges = ShapeList(Edge(e) for e in visible_edges)
+ hidden_edges = ShapeList(Edge(e) for e in hidden_edges)
+
+ return (visible_edges, hidden_edges)
+
+ @classmethod
+ def extrude(
+ cls, obj: Shape, direction: VectorLike
+ ) -> Edge | Face | Shell | Solid | Compound:
+ """extrude
+
+ Extrude a Shape in the provided direction.
+ * Vertices generate Edges
+ * Edges generate Faces
+ * Wires generate Shells
+ * Faces generate Solids
+ * Shells generate Compounds
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Union[Edge, Face, Shell, Solid, Compound]: extruded shape
+ """
+ return cls.cast(_extrude_topods_shape(obj.wrapped, direction))
+
+
+class Mixin2D:
+ """Additional methods to add to Face and Shell class"""
+
+ project_to_viewport = Mixin1D.project_to_viewport
+ extrude = _ClassMethodProxy(Mixin1D.extrude)
+ split = Mixin1D.split
+
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Self:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # define the shape lookup table for casting
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ ta.TopAbs_FACE: Face,
+ ta.TopAbs_SHELL: Shell,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
+ vertices = Mixin1D.vertices
+ vertex = Mixin1D.vertex
+ edges = Mixin1D.edges
+ edge = Mixin1D.edge
+ wires = Mixin1D.wires
+ wire = Mixin1D.wire
+
+ def faces(self) -> ShapeList[Face]:
+ """faces - all the faces in this Shape"""
+ return Shape.get_shape_list(self, "Face")
+
+ def face(self) -> Face:
+ """Return the Face"""
+ return Shape.get_single_shape(self, "Face")
+
+ def shells(self) -> ShapeList[Shell]:
+ """shells - all the shells in this Shape"""
+ return Shape.get_shape_list(self, "Shell")
+
+ def shell(self) -> Shell:
+ """Return the Shell"""
+ return Shape.get_single_shape(self, "Shell")
+
+ def __neg__(self) -> Self:
+ """Reverse normal operator -"""
+ new_surface = copy.deepcopy(self)
+ new_surface.wrapped = downcast(self.wrapped.Complemented())
+
+ return new_surface
+
+ def offset(self, amount: float) -> Self:
+ """Return a copy of self moved along the normal by amount"""
+ return copy.deepcopy(self).moved(Location(self.normal_at() * amount))
+
class Mixin3D:
"""Additional methods to add to 3D Shape classes"""
+ project_to_viewport = Mixin1D.project_to_viewport
+ extrude = _ClassMethodProxy(Mixin1D.extrude)
+ split = Mixin1D.split
+
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Self:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # define the shape lookup table for casting
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ ta.TopAbs_FACE: Face,
+ ta.TopAbs_SHELL: Shell,
+ ta.TopAbs_SOLID: Solid,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
+ vertices = Mixin1D.vertices
+ vertex = Mixin1D.vertex
+ edges = Mixin1D.edges
+ edge = Mixin1D.edge
+ wires = Mixin1D.wires
+ wire = Mixin1D.wire
+ faces = Mixin2D.faces
+ face = Mixin2D.face
+ shells = Mixin2D.shells
+ shell = Mixin2D.shell
+
+ def solids(self) -> ShapeList[Solid]:
+ """solids - all the solids in this Shape"""
+ return Shape.get_shape_list(self, "Solid")
+
+ def solid(self) -> Solid:
+ """Return the Solid"""
+ return Shape.get_single_shape(self, "Solid")
+
def fillet(self, radius: float, edge_list: Iterable[Edge]) -> Self:
"""Fillet
@@ -1207,7 +1599,7 @@ class Mixin3D:
raise ValueError("Center of GEOMETRY is not supported for this object")
if center_of == CenterOf.MASS:
properties = GProp_GProps()
- calc_function = shape_properties_LUT[shapetype(self.wrapped)]
+ calc_function = Shape.shape_properties_LUT[shapetype(self.wrapped)]
if calc_function:
calc_function(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
@@ -1446,6 +1838,71 @@ class Shape(NodeMixin):
_dim = None
+ shape_LUT = {
+ ta.TopAbs_VERTEX: "Vertex",
+ ta.TopAbs_EDGE: "Edge",
+ ta.TopAbs_WIRE: "Wire",
+ ta.TopAbs_FACE: "Face",
+ ta.TopAbs_SHELL: "Shell",
+ ta.TopAbs_SOLID: "Solid",
+ ta.TopAbs_COMPOUND: "Compound",
+ ta.TopAbs_COMPSOLID: "CompSolid",
+ }
+
+ shape_properties_LUT = {
+ ta.TopAbs_VERTEX: None,
+ ta.TopAbs_EDGE: BRepGProp.LinearProperties_s,
+ ta.TopAbs_WIRE: BRepGProp.LinearProperties_s,
+ ta.TopAbs_FACE: BRepGProp.SurfaceProperties_s,
+ ta.TopAbs_SHELL: BRepGProp.SurfaceProperties_s,
+ ta.TopAbs_SOLID: BRepGProp.VolumeProperties_s,
+ ta.TopAbs_COMPOUND: BRepGProp.VolumeProperties_s,
+ ta.TopAbs_COMPSOLID: BRepGProp.VolumeProperties_s,
+ }
+
+ inverse_shape_LUT = {v: k for k, v in shape_LUT.items()}
+
+ downcast_LUT = {
+ ta.TopAbs_VERTEX: TopoDS.Vertex_s,
+ ta.TopAbs_EDGE: TopoDS.Edge_s,
+ ta.TopAbs_WIRE: TopoDS.Wire_s,
+ ta.TopAbs_FACE: TopoDS.Face_s,
+ ta.TopAbs_SHELL: TopoDS.Shell_s,
+ ta.TopAbs_SOLID: TopoDS.Solid_s,
+ ta.TopAbs_COMPOUND: TopoDS.Compound_s,
+ ta.TopAbs_COMPSOLID: TopoDS.CompSolid_s,
+ }
+
+ geom_LUT_EDGE: Dict[ga.GeomAbs_CurveType, GeomType] = {
+ ga.GeomAbs_Line: GeomType.LINE,
+ ga.GeomAbs_Circle: GeomType.CIRCLE,
+ ga.GeomAbs_Ellipse: GeomType.ELLIPSE,
+ ga.GeomAbs_Hyperbola: GeomType.HYPERBOLA,
+ ga.GeomAbs_Parabola: GeomType.PARABOLA,
+ ga.GeomAbs_BezierCurve: GeomType.BEZIER,
+ ga.GeomAbs_BSplineCurve: GeomType.BSPLINE,
+ ga.GeomAbs_OffsetCurve: GeomType.OFFSET,
+ ga.GeomAbs_OtherCurve: GeomType.OTHER,
+ }
+ geom_LUT_FACE: Dict[ga.GeomAbs_SurfaceType, GeomType] = {
+ ga.GeomAbs_Plane: GeomType.PLANE,
+ ga.GeomAbs_Cylinder: GeomType.CYLINDER,
+ ga.GeomAbs_Cone: GeomType.CONE,
+ ga.GeomAbs_Sphere: GeomType.SPHERE,
+ ga.GeomAbs_Torus: GeomType.TORUS,
+ ga.GeomAbs_BezierSurface: GeomType.BEZIER,
+ ga.GeomAbs_BSplineSurface: GeomType.BSPLINE,
+ ga.GeomAbs_SurfaceOfRevolution: GeomType.REVOLUTION,
+ ga.GeomAbs_SurfaceOfExtrusion: GeomType.EXTRUSION,
+ ga.GeomAbs_OffsetSurface: GeomType.OFFSET,
+ ga.GeomAbs_OtherSurface: GeomType.OTHER,
+ }
+ _transModeDict = {
+ Transition.TRANSFORMED: BRepBuilderAPI_Transformed,
+ Transition.ROUND: BRepBuilderAPI_RoundCorner,
+ Transition.RIGHT: BRepBuilderAPI_RightCorner,
+ }
+
def __init__(
self,
obj: TopoDS_Shape = None,
@@ -1517,6 +1974,15 @@ class Shape(NodeMixin):
self._color = node_color # Set the node's color for next time
return node_color
+ @property
+ def is_planar_face(self) -> bool:
+ """Is the shape a planar face even though its geom_type may not be PLANE"""
+ if self.wrapped is None or not isinstance(self.wrapped, TopoDS_Face):
+ return False
+ surface = BRep_Tool.Surface_s(self.wrapped)
+ is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE)
+ return is_face_planar.IsPlanar()
+
@color.setter
def color(self, value):
"""Set the shape's color"""
@@ -1561,45 +2027,53 @@ class Shape(NodeMixin):
Returns:
bool: is the shape manifold or water tight
"""
- if isinstance(self, Compound):
- # pylint: disable=not-an-iterable
- return all(sub_shape.is_manifold for sub_shape in self)
+ # Extract one or more (if a Compound) shape from self
+ shape_stack = get_top_level_topods_shapes(self.wrapped)
+ results = []
- result = True
- # Create an empty indexed data map to store the edges and their corresponding faces.
- shape_map = TopTools_IndexedDataMapOfShapeListOfShape()
+ while shape_stack:
+ shape = shape_stack.pop(0)
- # Fill the map with edges and their associated faces in the given shape. Each edge in
- # the map is associated with a list of faces that share that edge.
- TopExp.MapShapesAndAncestors_s(
- self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, shape_map
- )
+ result = True
+ # Create an empty indexed data map to store the edges and their corresponding faces.
+ shape_map = TopTools_IndexedDataMapOfShapeListOfShape()
- # Iterate over the edges in the map and checks if each edge is non-degenerate and has
- # exactly two faces associated with it.
- for i in range(shape_map.Extent()):
- # Access each edge in the map sequentially
- edge = downcast(shape_map.FindKey(i + 1))
+ # Fill the map with edges and their associated faces in the given shape. Each edge in
+ # the map is associated with a list of faces that share that edge.
+ TopExp.MapShapesAndAncestors_s(
+ # shape.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, shape_map
+ shape,
+ ta.TopAbs_EDGE,
+ ta.TopAbs_FACE,
+ shape_map,
+ )
- vertex0 = TopoDS_Vertex()
- vertex1 = TopoDS_Vertex()
+ # Iterate over the edges in the map and checks if each edge is non-degenerate and has
+ # exactly two faces associated with it.
+ for i in range(shape_map.Extent()):
+ # Access each edge in the map sequentially
+ edge = downcast(shape_map.FindKey(i + 1))
- # Extract the two vertices of the current edge and stores them in vertex0/1.
- TopExp.Vertices_s(edge, vertex0, vertex1)
+ vertex0 = TopoDS_Vertex()
+ vertex1 = TopoDS_Vertex()
- # Check if both vertices are null and if they are the same vertex. If so, the
- # edge is considered degenerate (i.e., has zero length), and it is skipped.
- if vertex0.IsNull() and vertex1.IsNull() and vertex0.IsSame(vertex1):
- continue
+ # Extract the two vertices of the current edge and stores them in vertex0/1.
+ TopExp.Vertices_s(edge, vertex0, vertex1)
- # Check if the current edge has exactly two faces associated with it. If not,
- # it means the edge is not shared by exactly two faces, indicating that the
- # shape is not manifold.
- if shape_map.FindFromIndex(i + 1).Extent() != 2:
- result = False
- break
+ # Check if both vertices are null and if they are the same vertex. If so, the
+ # edge is considered degenerate (i.e., has zero length), and it is skipped.
+ if vertex0.IsNull() and vertex1.IsNull() and vertex0.IsSame(vertex1):
+ continue
- return result
+ # Check if the current edge has exactly two faces associated with it. If not,
+ # it means the edge is not shared by exactly two faces, indicating that the
+ # shape is not manifold.
+ if shape_map.FindFromIndex(i + 1).Extent() != 2:
+ result = False
+ break
+ results.append(result)
+
+ return all(results)
class _DisplayNode(NodeMixin):
"""Used to create anytree structures from TopoDS_Shapes"""
@@ -1637,7 +2111,7 @@ class Shape(NodeMixin):
) -> list[_DisplayNode]:
"""Create an anytree copy of the TopoDS_Shape structure"""
- obj_type = shape_LUT[shape.ShapeType()]
+ obj_type = Shape.shape_LUT[shape.ShapeType()]
if show_center:
loc = Shape(shape).bounding_box().center()
else:
@@ -1731,13 +2205,17 @@ class Shape(NodeMixin):
Returns:
str: tree representation of internal structure
"""
-
- if isinstance(self, Compound) and self.children:
+ # if isinstance(self, Compound) and self.children:
+ if (
+ self.wrapped is not None
+ and isinstance(self.wrapped, TopoDS_Compound)
+ and self.children
+ ):
show_center = False if show_center is None else show_center
result = Shape._show_tree(self, show_center)
else:
tree = Shape._build_tree(
- self.wrapped, tree=[], limit=inverse_shape_LUT[limit_class]
+ self.wrapped, tree=[], limit=Shape.inverse_shape_LUT[limit_class]
)
show_center = True if show_center is None else show_center
result = Shape._show_tree(tree[0], show_center)
@@ -1772,7 +2250,7 @@ class Shape(NodeMixin):
else:
sum_shape = self.fuse(*summands)
- if SkipClean.clean:
+ if SkipClean.clean and not isinstance(sum_shape, list):
sum_shape = sum_shape.clean()
return sum_shape
@@ -1830,16 +2308,16 @@ class Shape(NodeMixin):
"""right multiply for positioning operator *"""
if not (
isinstance(other, (list, tuple))
- and all([isinstance(o, (Location, Plane)) for o in other])
+ and all(isinstance(o, (Location, Plane)) for o in other)
):
raise ValueError(
"shapes can only be multiplied list of locations or planes"
)
return [loc * self for loc in other]
+ @abstractmethod
def center(self) -> Vector:
"""All of the derived classes from Shape need a center method"""
- raise NotImplementedError
def clean(self) -> Self:
"""clean
@@ -1870,110 +2348,9 @@ class Shape(NodeMixin):
return self
@classmethod
- def cast(cls, obj: TopoDS_Shape, for_construction: bool = False) -> Self:
- "Returns the right type of wrapper, given a OCCT object"
-
- new_shape = None
-
- # define the shape lookup table for casting
- constructor__lut = {
- ta.TopAbs_VERTEX: Vertex,
- ta.TopAbs_EDGE: Edge,
- ta.TopAbs_WIRE: Wire,
- ta.TopAbs_FACE: Face,
- ta.TopAbs_SHELL: Shell,
- ta.TopAbs_SOLID: Solid,
- ta.TopAbs_COMPOUND: Compound,
- }
-
- shape_type = shapetype(obj)
- # NB downcast is needed to handle TopoDS_Shape types
- new_shape = constructor__lut[shape_type](downcast(obj))
- new_shape.for_construction = for_construction
-
- return new_shape
-
- @deprecated("Use the `export_stl` function instead")
- def export_stl(
- self,
- file_name: str,
- tolerance: float = 1e-3,
- angular_tolerance: float = 0.1,
- ascii_format: bool = False,
- ) -> bool:
- """Export STL
-
- Exports a shape to a specified STL file.
-
- Args:
- file_name (str): The path and file name to write the STL output to.
- tolerance (float, optional): A linear deflection setting which limits the distance
- between a curve and its tessellation. Setting this value too low will result in
- large meshes that can consume computing resources. Setting the value too high can
- result in meshes with a level of detail that is too low. The default is a good
- starting point for a range of cases. Defaults to 1e-3.
- angular_tolerance (float, optional): Angular deflection setting which limits the angle
- between subsequent segments in a polyline. Defaults to 0.1.
- ascii_format (bool, optional): Export the file as ASCII (True) or binary (False)
- STL format. Defaults to False (binary).
-
- Returns:
- bool: Success
- """
- mesh = BRepMesh_IncrementalMesh(
- self.wrapped, tolerance, True, angular_tolerance, True
- )
- mesh.Perform()
-
- writer = StlAPI_Writer()
-
- if ascii_format:
- writer.ASCIIMode = True
- else:
- writer.ASCIIMode = False
-
- return writer.Write(self.wrapped, file_name)
-
- @deprecated("Use the `export_step` function instead")
- def export_step(self, file_name: str, **kwargs) -> IFSelect_ReturnStatus:
- """Export this shape to a STEP file.
-
- kwargs is used to provide optional keyword arguments to configure the exporter.
-
- Args:
- file_name (str): Path and filename for writing.
- kwargs: used to provide optional keyword arguments to configure the exporter.
-
- Returns:
- IFSelect_ReturnStatus: OCCT return status
- """
- # Handle the extra settings for the STEP export
- pcurves = 1
- if "write_pcurves" in kwargs and not kwargs["write_pcurves"]:
- pcurves = 0
- precision_mode = kwargs["precision_mode"] if "precision_mode" in kwargs else 0
-
- writer = STEPControl_Writer()
- Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves)
- Interface_Static.SetIVal_s("write.precision.mode", precision_mode)
- writer.Transfer(self.wrapped, STEPControl_AsIs)
-
- return writer.Write(file_name)
-
- @deprecated("Use the `export_brep` function instead")
- def export_brep(self, file: Union[str, BytesIO]) -> bool:
- """Export this shape to a BREP file
-
- Args:
- file: Union[str, BytesIO]:
-
- Returns:
-
- """
-
- return_value = BRepTools.Write_s(self.wrapped, file)
-
- return True if return_value is None else return_value
+ @abstractmethod
+ def cast(cls: Type[Self], obj: TopoDS_Shape) -> Self:
+ """Returns the right type of wrapper, given a OCCT object"""
@property
def geom_type(self) -> GeomType:
@@ -1988,9 +2365,9 @@ class Shape(NodeMixin):
shape: TopAbs_ShapeEnum = shapetype(self.wrapped)
if shape == ta.TopAbs_EDGE:
- geom = geom_LUT_EDGE[BRepAdaptor_Curve(self.wrapped).GetType()]
+ geom = Shape.geom_LUT_EDGE[BRepAdaptor_Curve(self.wrapped).GetType()]
elif shape == ta.TopAbs_FACE:
- geom = geom_LUT_FACE[BRepAdaptor_Surface(self.wrapped).GetType()]
+ geom = Shape.geom_LUT_FACE[BRepAdaptor_Surface(self.wrapped).GetType()]
else:
geom = GeomType.OTHER
@@ -2072,9 +2449,7 @@ class Shape(NodeMixin):
Returns:
BoundBox: A box sized to contain this Shape
"""
- return BoundBox._from_topo_ds(
- self.wrapped, tolerance=tolerance, optimal=optimal
- )
+ return BoundBox.from_topo_ds(self.wrapped, tolerance=tolerance, optimal=optimal)
def mirror(self, mirror_plane: Plane = None) -> Self:
"""
@@ -2153,7 +2528,7 @@ class Shape(NodeMixin):
"""
properties = GProp_GProps()
- calc_function = shape_properties_LUT[shapetype(obj.wrapped)]
+ calc_function = Shape.shape_properties_LUT[shapetype(obj.wrapped)]
if not calc_function:
raise NotImplementedError
@@ -2163,21 +2538,11 @@ class Shape(NodeMixin):
def shape_type(self) -> Shapes:
"""Return the shape type string for this class"""
- return tcast(Shapes, shape_LUT[shapetype(self.wrapped)])
+ return tcast(Shapes, Shape.shape_LUT[shapetype(self.wrapped)])
- def _entities(self, topo_type: Shapes) -> list[TopoDS_Shape]:
- out = {} # using dict to prevent duplicates
-
- explorer = TopExp_Explorer(self.wrapped, inverse_shape_LUT[topo_type])
-
- while explorer.More():
- item = explorer.Current()
- out[item.HashCode(HASH_CODE_MAX)] = (
- item # needed to avoid pseudo-duplicate entities
- )
- explorer.Next()
-
- return list(out.values())
+ def entities(self, topo_type: Shapes) -> list[TopoDS_Shape]:
+ """Return all of the TopoDS sub entities of the given type"""
+ return _topods_entities(self.wrapped, topo_type)
def _entities_from(
self, child_type: Shapes, parent_type: Shapes
@@ -2187,15 +2552,15 @@ class Shape(NodeMixin):
TopExp.MapShapesAndAncestors_s(
self.wrapped,
- inverse_shape_LUT[child_type],
- inverse_shape_LUT[parent_type],
+ Shape.inverse_shape_LUT[child_type],
+ Shape.inverse_shape_LUT[parent_type],
res,
)
out: Dict[Shape, list[Shape]] = {}
for i in range(1, res.Extent() + 1):
- out[Shape.cast(res.FindKey(i))] = [
- Shape.cast(el) for el in res.FindFromIndex(i)
+ out[self.__class__.cast(res.FindKey(i))] = [
+ self.__class__.cast(el) for el in res.FindFromIndex(i)
]
return out
@@ -2218,127 +2583,90 @@ class Shape(NodeMixin):
(e.g., edges, vertices) and other compounds, the method returns a list
of only the simple shapes directly contained at the top level.
"""
- if self.wrapped is None:
- return ShapeList()
-
- first_level_shapes = []
- stack = [self]
-
- while stack:
- current_shape = stack.pop()
- if isinstance(current_shape.wrapped, TopoDS_Compound):
- iterator = TopoDS_Iterator()
- iterator.Initialize(current_shape.wrapped)
- while iterator.More():
- child_shape = Shape.cast(iterator.Value())
- if isinstance(child_shape.wrapped, TopoDS_Compound):
- # Traverse further into the compound
- stack.append(child_shape)
- else:
- # Add non-compound shape
- first_level_shapes.append(child_shape)
- iterator.Next()
- else:
- first_level_shapes.append(current_shape)
-
- return ShapeList(first_level_shapes)
+ return ShapeList(
+ self.__class__.cast(s) for s in get_top_level_topods_shapes(self.wrapped)
+ )
@staticmethod
- def _get_shape_list(shape: Shape, entity_type: str) -> ShapeList:
+ def get_shape_list(shape: Shape, entity_type: str) -> ShapeList:
"""Helper to extract entities of a specific type from a shape."""
if shape.wrapped is None:
return ShapeList()
- shape_list = ShapeList([Shape.cast(i) for i in shape._entities(entity_type)])
+ shape_list = ShapeList(
+ [shape.__class__.cast(i) for i in shape.entities(entity_type)]
+ )
for item in shape_list:
item.topo_parent = shape
return shape_list
@staticmethod
- def _get_single_shape(shape: Shape, entity_type: str) -> Shape:
+ def get_single_shape(shape: Shape, entity_type: str) -> Shape:
"""Helper to extract a single entity of a specific type from a shape,
with a warning if count != 1."""
- shape_list = Shape._get_shape_list(shape, entity_type)
+ shape_list = Shape.get_shape_list(shape, entity_type)
entity_count = len(shape_list)
if entity_count != 1:
warnings.warn(
f"Found {entity_count} {entity_type.lower()}s, returning first",
- stacklevel=2,
+ stacklevel=3,
)
return shape_list[0] if shape_list else None
def vertices(self) -> ShapeList[Vertex]:
- """vertices - all the vertices in this Shape"""
- return Shape._get_shape_list(self, "Vertex")
+ """vertices - all the vertices in this Shape - subclasses may override"""
+ return ShapeList()
def vertex(self) -> Vertex:
"""Return the Vertex"""
- return Shape._get_single_shape(self, "Vertex")
+ return None
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this Shape"""
- edge_list = Shape._get_shape_list(self, "Edge")
- return edge_list.filter_by(
- lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
- )
+ return ShapeList()
def edge(self) -> Edge:
"""Return the Edge"""
- return Shape._get_single_shape(self, "Edge")
+ return None
def wires(self) -> ShapeList[Wire]:
"""wires - all the wires in this Shape"""
- return Shape._get_shape_list(self, "Wire")
+ return ShapeList()
def wire(self) -> Wire:
"""Return the Wire"""
- return Shape._get_single_shape(self, "Wire")
+ return None
def faces(self) -> ShapeList[Face]:
"""faces - all the faces in this Shape"""
- return Shape._get_shape_list(self, "Face")
+ return ShapeList()
def face(self) -> Face:
"""Return the Face"""
- return Shape._get_single_shape(self, "Face")
+ return None
def shells(self) -> ShapeList[Shell]:
"""shells - all the shells in this Shape"""
- return Shape._get_shape_list(self, "Shell")
+ return ShapeList()
def shell(self) -> Shell:
"""Return the Shell"""
- return Shape._get_single_shape(self, "Shell")
+ return None
def solids(self) -> ShapeList[Solid]:
"""solids - all the solids in this Shape"""
- return Shape._get_shape_list(self, "Solid")
+ return ShapeList()
def solid(self) -> Solid:
"""Return the Solid"""
- return Shape._get_single_shape(self, "Solid")
+ return None
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""
- if self.wrapped is None:
- return ShapeList()
- if isinstance(self.wrapped, TopoDS_Compound):
- # pylint: disable=not-an-iterable
- sub_compounds = [c for c in self if isinstance(c.wrapped, TopoDS_Compound)]
- sub_compounds.append(self)
- else:
- sub_compounds = []
- return ShapeList(sub_compounds)
+ return ShapeList()
def compound(self) -> Compound:
"""Return the Compound"""
- shape_list = self.compounds()
- entity_count = len(shape_list)
- if entity_count != 1:
- warnings.warn(
- f"Found {entity_count} compounds, returning first",
- stacklevel=2,
- )
- return shape_list[0] if shape_list else None
+ return None
@property
def area(self) -> float:
@@ -2467,14 +2795,11 @@ class Shape(NodeMixin):
Returns:
Shape: copy of transformed shape with all objects keeping their type
"""
- if isinstance(self, Vertex):
- new_shape = Vertex(*t_matrix.multiply(Vector(self)))
- else:
- transformed = Shape.cast(
- BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape()
- )
- new_shape = copy.deepcopy(self, None)
- new_shape.wrapped = transformed.wrapped
+ transformed = downcast(
+ BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape()
+ )
+ new_shape = copy.deepcopy(self, None)
+ new_shape.wrapped = transformed
return new_shape
@@ -2495,11 +2820,11 @@ class Shape(NodeMixin):
Returns:
Shape: a copy of the object, but with geometry transformed
"""
- transformed = Shape.cast(
+ transformed = downcast(
BRepBuilderAPI_GTransform(self.wrapped, t_matrix.wrapped, True).Shape()
)
new_shape = copy.deepcopy(self, None)
- new_shape.wrapped = transformed.wrapped
+ new_shape.wrapped = transformed
return new_shape
@@ -2585,10 +2910,18 @@ class Shape(NodeMixin):
self, other: Union[Shape, VectorLike]
) -> tuple[float, Vector, Vector]:
"""Minimal distance between two shapes and the points on each shape"""
- other = other if isinstance(other, Shape) else Vertex(other)
+
+ if isinstance(other, Shape):
+ topods_shape = other.wrapped
+ else:
+ vec = Vector(other)
+ topods_shape = downcast(
+ BRepBuilderAPI_MakeVertex(gp_Pnt(vec.X, vec.Y, vec.Z)).Vertex()
+ )
+
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
- dist_calc.LoadS2(other.wrapped)
+ dist_calc.LoadS2(topods_shape)
dist_calc.Perform()
return (
dist_calc.Value(),
@@ -2613,7 +2946,7 @@ class Shape(NodeMixin):
args: Iterable[Shape],
tools: Iterable[Shape],
operation: Union[BRepAlgoAPI_BooleanOperation, BRepAlgoAPI_Splitter],
- ) -> Self:
+ ) -> Self | ShapeList[Self]:
"""Generic boolean operation
Args:
@@ -2625,6 +2958,12 @@ class Shape(NodeMixin):
Returns:
"""
+ # Find the highest order class from all the inputs Solid > Vertex
+ order_dict = {type(s): type(s).order for s in [self] + list(args) + list(tools)}
+ highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1]
+
+ # The base of the operation
+ base = args[0] if isinstance(args, (list, tuple)) else args
arg = TopTools_ListOfShape()
for obj in args:
@@ -2640,32 +2979,54 @@ class Shape(NodeMixin):
operation.SetRunParallel(True)
operation.Build()
- result = downcast(operation.Shape())
- # Remove unnecessary TopoDS_Compound around single shape
- if isinstance(result, TopoDS_Compound):
- result = unwrap_topods_compound(result, True)
- result = Shape.cast(result)
+ topo_result = downcast(operation.Shape())
- base = args[0] if isinstance(args, (list, tuple)) else args
+ # Clean
+ if SkipClean.clean:
+ upgrader = ShapeUpgrade_UnifySameDomain(topo_result, True, True, True)
+ upgrader.AllowInternalEdges(False)
+ try:
+ upgrader.Build()
+ topo_result = downcast(upgrader.Shape())
+ except Exception:
+ warnings.warn("Boolean operation unable to clean", stacklevel=2)
+
+ # Remove unnecessary TopoDS_Compound around single shape
+ if isinstance(topo_result, TopoDS_Compound):
+ topo_result = unwrap_topods_compound(topo_result, True)
+
+ if isinstance(topo_result, TopoDS_Compound) and highest_order[1] != 4:
+ results = ShapeList(
+ highest_order[0].cast(s)
+ for s in get_top_level_topods_shapes(topo_result)
+ )
+ for result in results:
+ base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
+ return results
+
+ result = highest_order[0].cast(topo_result)
base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
return result
- def cut(self, *to_cut: Shape) -> Self:
+ def cut(self, *to_cut: Shape) -> Self | ShapeList[Self]:
"""Remove the positional arguments from this Shape.
Args:
*to_cut: Shape:
Returns:
-
+ Self | ShapeList[Self]: Resulting object may be of a different class than self
+ or a ShapeList if multiple non-Compound object created
"""
cut_op = BRepAlgoAPI_Cut()
return self._bool_op((self,), to_cut, cut_op)
- def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Self:
+ def fuse(
+ self, *to_fuse: Shape, glue: bool = False, tol: float = None
+ ) -> Self | ShapeList[Self]:
"""fuse
Fuse a sequence of shapes into a single shape.
@@ -2676,7 +3037,9 @@ class Shape(NodeMixin):
tol (float, optional): tolerance. Defaults to None.
Returns:
- Shape: fused shape
+ Self | ShapeList[Self]: Resulting object may be of a different class than self
+ or a ShapeList if multiple non-Compound object created
+
"""
fuse_op = BRepAlgoAPI_Fuse()
@@ -2689,15 +3052,9 @@ class Shape(NodeMixin):
return return_value
- def _intersect_with_axis(self, *axes: Axis) -> Shape:
- lines = [Edge(a) for a in axes]
- return self.intersect(*lines)
-
- def _intersect_with_plane(self, *planes: Plane) -> Shape:
- surfaces = [Face.make_plane(p) for p in planes]
- return self.intersect(*surfaces)
-
- def intersect(self, *to_intersect: Union[Shape, Axis, Plane]) -> Shape:
+ def intersect(
+ self, *to_intersect: Union[Shape, Axis, Plane]
+ ) -> Self | ShapeList[Self]:
"""Intersection of the arguments and this shape
Args:
@@ -2705,20 +3062,44 @@ class Shape(NodeMixin):
intersect with
Returns:
- Shape: Resulting object may be of a different class than self
+ Self | ShapeList[Self]: Resulting object may be of a different class than self
+ or a ShapeList if multiple non-Compound object created
"""
+ def _to_vertex(vec: Vector) -> Vertex:
+ """Helper method to convert vector to shape"""
+ return self.__class__.cast(
+ downcast(
+ BRepBuilderAPI_MakeVertex(gp_Pnt(vec.X, vec.Y, vec.Z)).Vertex()
+ )
+ )
+
+ def _to_edge(axis: Axis) -> Edge:
+ """Helper method to convert axis to shape"""
+ return self.__class__.cast(
+ BRepBuilderAPI_MakeEdge(
+ Geom_Line(
+ axis.position.to_pnt(),
+ axis.direction.to_dir(),
+ )
+ ).Edge()
+ )
+
+ def _to_face(plane: Plane) -> Face:
+ """Helper method to convert plane to shape"""
+ return self.__class__.cast(BRepBuilderAPI_MakeFace(plane.wrapped).Face())
+
# Convert any geometry objects into their respective topology objects
objs = []
for obj in to_intersect:
if isinstance(obj, Vector):
- objs.append(Vertex(obj))
+ objs.append(_to_vertex(obj))
elif isinstance(obj, Axis):
- objs.append(Edge(obj))
+ objs.append(_to_edge(obj))
elif isinstance(obj, Plane):
- objs.append(Face.make_plane(obj))
+ objs.append(_to_face(obj))
elif isinstance(obj, Location):
- objs.append(Vertex(obj.position))
+ objs.append(_to_vertex(obj.position))
else:
objs.append(obj)
@@ -2751,10 +3132,10 @@ class Shape(NodeMixin):
tuple[list[Vertex], list[Edge]]: section results
"""
try:
- section = BRepAlgoAPI_Section(other._geom_adaptor(), self.wrapped)
+ section = BRepAlgoAPI_Section(other.geom_adaptor(), self.wrapped)
except (TypeError, AttributeError):
try:
- section = BRepAlgoAPI_Section(self._geom_adaptor(), other.wrapped)
+ section = BRepAlgoAPI_Section(self.geom_adaptor(), other.wrapped)
except (TypeError, AttributeError):
return ([], [])
@@ -2762,18 +3143,18 @@ class Shape(NodeMixin):
section.Build()
# Get the resulting shapes from the intersection
- intersectionShape = section.Shape()
+ intersection_shape = section.Shape()
vertices = []
# Iterate through the intersection shape to find intersection points/edges
- explorer = TopExp_Explorer(intersectionShape, TopAbs_ShapeEnum.TopAbs_VERTEX)
+ explorer = TopExp_Explorer(intersection_shape, TopAbs_ShapeEnum.TopAbs_VERTEX)
while explorer.More():
- vertices.append(Vertex(downcast(explorer.Current())))
+ vertices.append(self.__class__.cast(downcast(explorer.Current())))
explorer.Next()
edges = []
- explorer = TopExp_Explorer(intersectionShape, TopAbs_ShapeEnum.TopAbs_EDGE)
+ explorer = TopExp_Explorer(intersection_shape, TopAbs_ShapeEnum.TopAbs_EDGE)
while explorer.More():
- edges.append(Edge(downcast(explorer.Current())))
+ edges.append(self.__class__.cast(downcast(explorer.Current())))
explorer.Next()
return (vertices, edges)
@@ -2818,78 +3199,29 @@ class Shape(NodeMixin):
faces_dist.sort(key=lambda x: x[1])
faces = [face[0] for face in faces_dist]
- return ShapeList([Face(face) for face in faces])
-
- def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP) -> Self:
- """split
-
- Split this shape by the provided plane or face.
-
- Args:
- surface (Union[Plane,Face]): surface to segment shape
- keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP.
-
- Returns:
- Shape: result of split
- """
- shape_list = TopTools_ListOfShape()
- shape_list.Append(self.wrapped)
-
- # Define the splitting tool
- trim_tool = (
- Face.make_plane(tool).wrapped if isinstance(tool, Plane) else tool.wrapped
- )
- tool_list = TopTools_ListOfShape()
- tool_list.Append(trim_tool)
-
- # Create the splitter algorithm
- splitter = BRepAlgoAPI_Splitter()
-
- # Set the shape to be split and the splitting tool (plane face)
- splitter.SetArguments(shape_list)
- splitter.SetTools(tool_list)
-
- # Perform the splitting operation
- splitter.Build()
-
- result = downcast(splitter.Shape())
- # Remove unnecessary TopoDS_Compound around single shape
- if isinstance(result, TopoDS_Compound):
- result = unwrap_topods_compound(result, False)
- result = Shape.cast(result)
-
- if keep != Keep.BOTH:
- if not isinstance(tool, Plane):
- # Create solids from the surfaces for sorting
- surface_up = tool.thicken(0.1)
- tops, bottoms = [], []
- for part in result:
- if isinstance(tool, Plane):
- is_up = tool.to_local_coords(part).center().Z >= 0
- else:
- is_up = surface_up.intersect(part).volume >= TOLERANCE
- (tops if is_up else bottoms).append(part)
- result = Compound(tops) if keep == Keep.TOP else Compound(bottoms)
-
- result_wrapped = unwrap_topods_compound(result.wrapped, fully=True)
- return Shape.cast(result_wrapped)
+ return ShapeList([self.__class__.cast(face) for face in faces])
@overload
def split_by_perimeter(
self, perimeter: Union[Edge, Wire], keep: Literal[Keep.INSIDE, Keep.OUTSIDE]
- ) -> Union[Optional[Shell], Optional[Face]]: ...
+ ) -> Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]]:
+ """split_by_perimeter and keep inside or outside"""
@overload
def split_by_perimeter(
self, perimeter: Union[Edge, Wire], keep: Literal[Keep.BOTH]
) -> tuple[
- Union[Optional[Shell], Optional[Face]],
- Union[Optional[Shell], Optional[Face]],
- ]: ...
+ Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]],
+ Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]],
+ ]:
+ """split_by_perimeter and keep inside and outside"""
+
@overload
def split_by_perimeter(
self, perimeter: Union[Edge, Wire]
- ) -> Union[Optional[Shell], Optional[Face]]: ...
+ ) -> Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]]:
+ """split_by_perimeter and keep inside (default)"""
+
def split_by_perimeter(
self, perimeter: Union[Edge, Wire], keep: Keep = Keep.INSIDE
):
@@ -2921,13 +3253,29 @@ class Shape(NodeMixin):
"""
- def get(los: TopTools_ListOfShape, shape_cls) -> list:
+ def get(los: TopTools_ListOfShape) -> list:
+ """Return objects from TopTools_ListOfShape as list"""
shapes = []
for _ in range(los.Size()):
- shapes.append(shape_cls(los.First()))
+ first = los.First()
+ if not first.IsNull():
+ shapes.append(self.__class__.cast(first))
los.RemoveFirst()
return shapes
+ def process_sides(sides):
+ """Process sides to determine if it should be None, a single element,
+ a Shell, or a ShapeList."""
+ if not sides:
+ return None
+ if len(sides) == 1:
+ return sides[0]
+ # Attempt to create a shell
+ potential_shell = _sew_topods_faces([s.wrapped for s in sides])
+ if isinstance(potential_shell, TopoDS_Shell):
+ return self.__class__.cast(potential_shell)
+ return ShapeList(sides)
+
if keep not in {Keep.INSIDE, Keep.OUTSIDE, Keep.BOTH}:
raise ValueError(
"keep must be one of Keep.INSIDE, Keep.OUTSIDE, or Keep.BOTH"
@@ -2940,36 +3288,32 @@ class Shape(NodeMixin):
for perimeter_edge in perimeter.edges():
perimeter_edges.Append(perimeter_edge.wrapped)
- # Split the faces by the perimeter edges
- lefts: list[Face] = []
- rights: list[Face] = []
- for target_face in self.faces():
- constructor = BRepFeat_SplitShape(target_face.wrapped)
+ # Split the shells by the perimeter edges
+ lefts: list[Shell] = []
+ rights: list[Shell] = []
+ for target_shell in self.shells():
+ constructor = BRepFeat_SplitShape(target_shell.wrapped)
constructor.Add(perimeter_edges)
constructor.Build()
- lefts.extend(get(constructor.Left(), Face))
- rights.extend(get(constructor.Right(), Face))
+ lefts.extend(get(constructor.Left()))
+ rights.extend(get(constructor.Right()))
- left = None if not lefts else lefts[0] if len(lefts) == 1 else Shell(lefts)
- right = None if not rights else rights[0] if len(rights) == 1 else Shell(rights)
+ left = process_sides(lefts)
+ right = process_sides(rights)
# Is left or right the inside?
perimeter_length = perimeter.length
- left_perimeter_length = (
- sum(e.length for e in left.edges()) if left is not None else 0
- )
- right_perimeter_length = (
- sum(e.length for e in right.edges()) if right is not None else 0
- )
+ left_perimeter_length = sum(e.length for e in left.edges()) if left else 0
+ right_perimeter_length = sum(e.length for e in right.edges()) if right else 0
left_inside = abs(perimeter_length - left_perimeter_length) < abs(
perimeter_length - right_perimeter_length
)
if keep == Keep.BOTH:
return (left, right) if left_inside else (right, left)
- elif keep == Keep.INSIDE:
+ if keep == Keep.INSIDE:
return left if left_inside else right
- else: # keep == Keep.OUTSIDE:
- return right if left_inside else left
+ # keep == Keep.OUTSIDE:
+ return right if left_inside else left
def distance(self, other: Shape) -> float:
"""Minimal distance between two shapes
@@ -3161,12 +3505,12 @@ class Shape(NodeMixin):
"""
return self.__class__(BRepAlgo.ConvertFace_s(self.wrapped, tolerance))
- def _repr_javascript_(self):
- """Jupyter 3D representation support"""
+ # def _repr_javascript_(self):
+ # """Jupyter 3D representation support"""
- from .jupyter_tools import display
+ # from .jupyter_tools import display
- return display(self)._repr_javascript_()
+ # return display(self)._repr_javascript_()
def transformed(
self, rotate: VectorLike = (0, 0, 0), offset: VectorLike = (0, 0, 0)
@@ -3197,7 +3541,7 @@ class Shape(NodeMixin):
t_o.SetTranslation(Vector(offset).wrapped)
return self._apply_transform(t_o * t_rx * t_ry * t_rz)
- def find_intersection_points(self, axis: Axis) -> list[tuple[Vector, Vector]]:
+ def find_intersection_points(self, other: Axis) -> list[tuple[Vector, Vector]]:
"""Find point and normal at intersection
Return both the point(s) and normal(s) of the intersection of the axis and the shape
@@ -3210,7 +3554,7 @@ class Shape(NodeMixin):
"""
oc_shape = self.wrapped
- intersection_line = gce_MakeLin(axis.wrapped).Value()
+ intersection_line = gce_MakeLin(other.wrapped).Value()
intersect_maker = BRepIntCurveSurface_Inter()
intersect_maker.Init(oc_shape, intersection_line, 0.0001)
@@ -3218,10 +3562,10 @@ class Shape(NodeMixin):
while intersect_maker.More():
inter_pt = intersect_maker.Pnt()
# Calculate distance along axis
- distance = axis.to_plane().to_local_coords(Vector(inter_pt)).Z
+ distance = other.to_plane().to_local_coords(Vector(inter_pt)).Z
intersections.append(
(
- Face(intersect_maker.Face()),
+ intersect_maker.Face(), # TopoDS_Face
Vector(inter_pt),
distance,
)
@@ -3232,7 +3576,7 @@ class Shape(NodeMixin):
intersecting_faces = [i[0] for i in intersections]
intersecting_points = [i[1] for i in intersections]
intersecting_normals = [
- f.normal_at(intersecting_points[i]).normalized()
+ f.normal_at(intersecting_points[i].to_pnt())
for i, f in enumerate(intersecting_faces)
]
result = []
@@ -3241,16 +3585,12 @@ class Shape(NodeMixin):
return result
- @deprecated("Use find_intersection_points instead")
- def find_intersection(self, axis: Axis) -> list[tuple[Vector, Vector]]:
- return self.find_intersection_points(axis)
-
def project_faces(
self,
faces: Union[list[Face], Compound],
path: Union[Wire, Edge],
start: float = 0,
- ) -> Compound:
+ ) -> ShapeList[Face]:
"""Projected Faces following the given path on Shape
Project by positioning each face of to the shape along the path and
@@ -3275,7 +3615,11 @@ class Shape(NodeMixin):
# The derived classes of Shape implement center
shape_center = self.center() # pylint: disable=no-member
- if isinstance(faces, Compound):
+ if (
+ not isinstance(faces, (list, tuple))
+ and faces.wrapped is not None
+ and isinstance(faces.wrapped, TopoDS_Compound)
+ ):
faces = faces.faces()
first_face_min_x = faces[0].bounding_box().min.X
@@ -3310,168 +3654,7 @@ class Shape(NodeMixin):
logger.debug("finished projecting '%d' faces", len(faces))
- return Compound(projected_faces)
-
- def _extrude(
- self, direction: VectorLike
- ) -> Union[Edge, Face, Shell, Solid, Compound]:
- """_extrude
-
- Extrude self in the provided direction.
-
- Args:
- direction (VectorLike): direction and magnitude of extrusion
-
- Raises:
- ValueError: Unsupported class
- RuntimeError: Generated invalid result
-
- Returns:
- Union[Edge, Face, Shell, Solid, Compound]: extruded shape
- """
- direction = Vector(direction)
-
- if not isinstance(self, (Vertex, Edge, Wire, Face, Shell)):
- raise ValueError(f"extrude not supported for {type(self)}")
-
- prism_builder = BRepPrimAPI_MakePrism(self.wrapped, direction.wrapped)
- new_shape = downcast(prism_builder.Shape())
- shape_type = new_shape.ShapeType()
-
- if shape_type == TopAbs_ShapeEnum.TopAbs_EDGE:
- result = Edge(new_shape)
- elif shape_type == TopAbs_ShapeEnum.TopAbs_FACE:
- result = Face(new_shape)
- elif shape_type == TopAbs_ShapeEnum.TopAbs_SHELL:
- result = Shell(new_shape)
- elif shape_type == TopAbs_ShapeEnum.TopAbs_SOLID:
- result = Solid(new_shape)
- elif shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID:
- solids = []
- explorer = TopExp_Explorer(new_shape, TopAbs_ShapeEnum.TopAbs_SOLID)
- while explorer.More():
- topods_solid = downcast(explorer.Current())
- solids.append(Solid(topods_solid))
- explorer.Next()
- result = Compound(solids)
- else:
- raise RuntimeError("extrude produced an unexpected result")
- return result
-
- @classmethod
- def extrude(
- cls, obj: Union[Vertex, Edge, Wire, Face, Shell], direction: VectorLike
- ) -> Self:
- """extrude
-
- Extrude a Shape in the provided direction.
- * Vertices generate Edges
- * Edges generate Faces
- * Wires generate Shells
- * Faces generate Solids
- * Shells generate Compounds
-
- Args:
- direction (VectorLike): direction and magnitude of extrusion
-
- Raises:
- ValueError: Unsupported class
- RuntimeError: Generated invalid result
-
- Returns:
- Union[Edge, Face, Shell, Solid, Compound]: extruded shape
- """
- return obj._extrude(direction)
-
- def project_to_viewport(
- self,
- viewport_origin: VectorLike,
- viewport_up: VectorLike = (0, 0, 1),
- look_at: VectorLike = None,
- ) -> tuple[ShapeList[Edge], ShapeList[Edge]]:
- """project_to_viewport
-
- Project a shape onto a viewport returning visible and hidden Edges.
-
- Args:
- viewport_origin (VectorLike): location of viewport
- viewport_up (VectorLike, optional): direction of the viewport y axis.
- Defaults to (0, 0, 1).
- look_at (VectorLike, optional): point to look at.
- Defaults to None (center of shape).
-
- Returns:
- tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges
- """
-
- def extract_edges(compound):
- edges = [] # List to store the extracted edges
-
- # Create a TopExp_Explorer to traverse the sub-shapes of the compound
- explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE)
-
- # Loop through the sub-shapes and extract edges
- while explorer.More():
- edge = downcast(explorer.Current())
- edges.append(edge)
- explorer.Next()
-
- return edges
-
- # Setup the projector
- hidden_line_removal = HLRBRep_Algo()
- hidden_line_removal.Add(self.wrapped)
-
- viewport_origin = Vector(viewport_origin)
- look_at = Vector(look_at) if look_at else self.center()
- projection_dir: Vector = (viewport_origin - look_at).normalized()
- viewport_up = Vector(viewport_up).normalized()
- camera_coordinate_system = gp_Ax2()
- camera_coordinate_system.SetAxis(
- gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir())
- )
- camera_coordinate_system.SetYDirection(viewport_up.to_dir())
- projector = HLRAlgo_Projector(camera_coordinate_system)
-
- hidden_line_removal.Projector(projector)
- hidden_line_removal.Update()
- hidden_line_removal.Hide()
-
- hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal)
-
- # Create the visible edges
- visible_edges = []
- for edges in [
- hlr_shapes.VCompound(),
- hlr_shapes.Rg1LineVCompound(),
- hlr_shapes.OutLineVCompound(),
- ]:
- if not edges.IsNull():
- visible_edges.extend(extract_edges(downcast(edges)))
-
- # Create the hidden edges
- hidden_edges = []
- for edges in [
- hlr_shapes.HCompound(),
- hlr_shapes.OutLineHCompound(),
- hlr_shapes.Rg1LineHCompound(),
- ]:
- if not edges.IsNull():
- hidden_edges.extend(extract_edges(downcast(edges)))
-
- # Fix the underlying geometry - otherwise we will get segfaults
- for edge in visible_edges:
- BRepLib.BuildCurves3d_s(edge, TOLERANCE)
- for edge in hidden_edges:
- BRepLib.BuildCurves3d_s(edge, TOLERANCE)
-
- # convert to native shape objects
- # visible_edges = ShapeList(map(Shape, visible_edges))
- # hidden_edges = ShapeList(map(Shape, hidden_edges))
- visible_edges = ShapeList(map(Edge, visible_edges))
- hidden_edges = ShapeList(map(Edge, hidden_edges))
-
- return (visible_edges, hidden_edges)
+ return ShapeList(projected_faces)
class Comparable(metaclass=ABCMeta):
@@ -3510,6 +3693,10 @@ class ShapeList(list[T]):
"""Last element in the ShapeList"""
return self[-1]
+ def center(self) -> Vector:
+ """The average of the center of objects within the ShapeList"""
+ return sum(o.center() for o in self) / len(self) if self else Vector(0, 0, 0)
+
def filter_by(
self,
filter_by: Union[ShapePredicate, Axis, Plane, GeomType],
@@ -3541,10 +3728,25 @@ class ShapeList(list[T]):
# could be moved out maybe?
def axis_parallel_predicate(axis: Axis, tolerance: float):
def pred(shape: Shape):
- if isinstance(shape, Face) and shape.is_planar:
- shape_axis = Axis(shape.center(), shape.normal_at(None))
- elif isinstance(shape, Edge) and shape.geom_type == GeomType.LINE:
- shape_axis = Axis(shape.position_at(0), shape.tangent_at(0))
+ if shape.is_planar_face:
+ gp_pnt = gp_Pnt()
+ normal = gp_Vec()
+ u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped)
+ BRepGProp_Face(shape.wrapped).Normal(u_val, v_val, gp_pnt, normal)
+ normal = Vector(normal).normalized()
+ shape_axis = Axis(shape.center(), normal)
+ elif (
+ isinstance(shape.wrapped, TopoDS_Edge)
+ and shape.geom_type == GeomType.LINE
+ ):
+ curve = shape.geom_adaptor()
+ umin = curve.FirstParameter()
+ tmp = gp_Pnt()
+ res = gp_Vec()
+ curve.D1(umin, tmp, res)
+ start_pos = Vector(tmp)
+ start_dir = Vector(gp_Dir(res))
+ shape_axis = Axis(start_pos, start_dir)
else:
return False
return axis.is_parallel(shape_axis, tolerance)
@@ -3556,12 +3758,18 @@ class ShapeList(list[T]):
plane_xyz = plane.z_dir.wrapped.XYZ()
def pred(shape: Shape):
- if isinstance(shape, Face) and shape.is_planar:
- shape_axis = Axis(shape.center(), shape.normal_at(None))
+ if shape.is_planar_face:
+ gp_pnt = gp_Pnt()
+ normal = gp_Vec()
+ u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped)
+ BRepGProp_Face(shape.wrapped).Normal(u_val, v_val, gp_pnt, normal)
+ normal = Vector(normal).normalized()
+ shape_axis = Axis(shape.center(), normal)
+ # shape_axis = Axis(shape.center(), shape.normal_at(None))
return plane_axis.is_parallel(shape_axis, tolerance)
- if isinstance(shape, Wire):
+ if isinstance(shape.wrapped, TopoDS_Wire):
return all(pred(e) for e in shape.edges())
- if isinstance(shape, Edge):
+ if isinstance(shape.wrapped, TopoDS_Edge):
for curve in shape.wrapped.TShape().Curves():
if curve.IsCurve3D():
return ShapeAnalysis_Curve.IsPlanar_s(
@@ -3681,13 +3889,13 @@ class ShapeList(list[T]):
tol_digits,
)
- elif isinstance(group_by, (Edge, Wire)):
+ elif hasattr(group_by, "wrapped") and isinstance(
+ group_by.wrapped, (TopoDS_Edge, TopoDS_Wire)
+ ):
def key_f(obj):
- return round(
- group_by.param_at_point(obj.center()),
- tol_digits,
- )
+ pnt1, _pnt2 = group_by.closest_points(obj.center())
+ return round(group_by.param_at_point(pnt1), tol_digits)
elif isinstance(group_by, SortBy):
if group_by == SortBy.LENGTH:
@@ -3745,7 +3953,9 @@ class ShapeList(list[T]):
key=lambda o: (axis_as_location * Location(o.center())).position.Z,
reverse=reverse,
)
- elif isinstance(sort_by, (Edge, Wire)):
+ elif hasattr(sort_by, "wrapped") and isinstance(
+ sort_by.wrapped, (TopoDS_Edge, TopoDS_Wire)
+ ):
def u_of_closest_center(obj) -> float:
"""u-value of closest point between object center and sort_by"""
@@ -3805,9 +4015,8 @@ class ShapeList(list[T]):
Returns:
ShapeList: Sorted shapes
"""
- other = other if isinstance(other, Shape) else Vertex(other)
distances = sorted(
- [(other.distance_to(obj), obj) for obj in self],
+ [(obj.distance_to(other), obj) for obj in self],
key=lambda obj: obj[0],
reverse=reverse,
)
@@ -3823,8 +4032,7 @@ class ShapeList(list[T]):
vertex_count = len(vertices)
if vertex_count != 1:
warnings.warn(
- f"Found {vertex_count} vertices, returning first",
- stacklevel=2,
+ f"Found {vertex_count} vertices, returning first", stacklevel=2
)
return vertices[0]
@@ -3837,10 +4045,7 @@ class ShapeList(list[T]):
edges = self.edges()
edge_count = len(edges)
if edge_count != 1:
- warnings.warn(
- f"Found {edge_count} edges, returning first",
- stacklevel=2,
- )
+ warnings.warn(f"Found {edge_count} edges, returning first", stacklevel=2)
return edges[0]
def wires(self) -> ShapeList[Wire]:
@@ -3852,10 +4057,7 @@ class ShapeList(list[T]):
wires = self.wires()
wire_count = len(wires)
if wire_count != 1:
- warnings.warn(
- f"Found {wire_count} wires, returning first",
- stacklevel=2,
- )
+ warnings.warn(f"Found {wire_count} wires, returning first", stacklevel=2)
return wires[0]
def faces(self) -> ShapeList[Face]:
@@ -3880,10 +4082,7 @@ class ShapeList(list[T]):
shells = self.shells()
shell_count = len(shells)
if shell_count != 1:
- warnings.warn(
- f"Found {shell_count} shells, returning first",
- stacklevel=2,
- )
+ warnings.warn(f"Found {shell_count} shells, returning first", stacklevel=2)
return shells[0]
def solids(self) -> ShapeList[Solid]:
@@ -3895,10 +4094,7 @@ class ShapeList(list[T]):
solids = self.solids()
solid_count = len(solids)
if solid_count != 1:
- warnings.warn(
- f"Found {solid_count} solids, returning first",
- stacklevel=2,
- )
+ warnings.warn(f"Found {solid_count} solids, returning first", stacklevel=2)
return solids[0]
def compounds(self) -> ShapeList[Compound]:
@@ -3911,8 +4107,7 @@ class ShapeList(list[T]):
compound_count = len(compounds)
if compound_count != 1:
warnings.warn(
- f"Found {compound_count} compounds, returning first",
- stacklevel=2,
+ f"Found {compound_count} compounds, returning first", stacklevel=2
)
return compounds[0]
@@ -4016,16 +4211,25 @@ class GroupBy(Generic[T, K]):
def __repr__(self):
return repr(ShapeList(self))
- def _repr_pretty_(self, p, cycle=False):
+ def _repr_pretty_(self, printer: PrettyPrinter, cycle: bool = False) -> None:
+ """
+ Render a formatted representation of the object for pretty-printing in
+ interactive environments.
+
+ Args:
+ printer (PrettyPrinter): The pretty printer instance handling the output.
+ cycle (bool): Indicates if a reference cycle is detected to
+ prevent infinite recursion.
+ """
if cycle:
- p.text("(...)")
+ printer.text("(...)")
else:
- with p.group(1, "[", "]"):
+ with printer.group(1, "[", "]"):
for idx, item in enumerate(self):
if idx:
- p.text(",")
- p.breakable()
- p.pretty(item)
+ printer.text(",")
+ printer.breakable()
+ printer.pretty(item)
def group(self, key: K):
"""Select group by key"""
@@ -4049,106 +4253,64 @@ class Compound(Mixin3D, Shape):
(CAD) applications, allowing engineers and designers to work with assemblies
of shapes as unified entities for efficient modeling and analysis."""
- _dim = None
+ order = 4.0
+
+ project_to_viewport = Mixin1D.project_to_viewport
+ extrude = _ClassMethodProxy(Mixin1D.extrude)
+
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Self:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # define the shape lookup table for casting
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ ta.TopAbs_FACE: Face,
+ ta.TopAbs_SHELL: Shell,
+ ta.TopAbs_SOLID: Solid,
+ ta.TopAbs_COMPOUND: Compound,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
@property
def _dim(self) -> Union[int, None]:
"""The dimension of the shapes within the Compound - None if inconsistent"""
- sub_dims = {s._dim for s in self.get_top_level_shapes()}
+ sub_dims = {s.dim for s in get_top_level_topods_shapes(self.wrapped)}
return sub_dims.pop() if len(sub_dims) == 1 else None
- @overload
def __init__(
self,
- obj: TopoDS_Shape,
+ obj: Optional[TopoDS_Compound | Iterable[Shape]] = None,
label: str = "",
color: Color = None,
material: str = "",
joints: dict[str, Joint] = None,
parent: Compound = None,
- children: Iterable[Shape] = None,
- ):
- """Build a Compound from an OCCT TopoDS_Shape/TopoDS_Compound
-
- Args:
- obj (TopoDS_Shape, optional): OCCT Compound.
- label (str, optional): Defaults to ''.
- color (Color, optional): Defaults to None.
- material (str, optional): tag for external tools. Defaults to ''.
- joints (dict[str, Joint], optional): names joints. Defaults to None.
- parent (Compound, optional): assembly parent. Defaults to None.
- children (Iterable[Shape], optional): assembly children. Defaults to None.
- """
-
- @overload
- def __init__(
- self,
- shapes: Iterable[Shape],
- label: str = "",
- color: Color = None,
- material: str = "",
- joints: dict[str, Joint] = None,
- parent: Compound = None,
- children: Iterable[Shape] = None,
+ children: Sequence[Shape] = None,
):
"""Build a Compound from Shapes
Args:
- shapes (Iterable[Shape]): shapes within the compound
+ obj (TopoDS_Compound | Iterable[Shape], optional): OCCT Compound or shapes
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
material (str, optional): tag for external tools. Defaults to ''.
joints (dict[str, Joint], optional): names joints. Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
- children (Iterable[Shape], optional): assembly children. Defaults to None.
+ children (Sequence[Shape], optional): assembly children. Defaults to None.
"""
- def __init__(self, *args, **kwargs):
- shapes, obj, label, color, material, joints, parent, children = (None,) * 8
-
- if args:
- l_a = len(args)
- if isinstance(args[0], TopoDS_Shape):
- obj, label, color, material, joints, parent, children = args[:7] + (
- None,
- ) * (7 - l_a)
- elif isinstance(args[0], Iterable):
- shapes, label, color, material, joints, parent, children = args[:7] + (
- None,
- ) * (7 - l_a)
-
- unknown_args = ", ".join(
- set(kwargs.keys()).difference(
- [
- "shapes",
- "obj",
- "label",
- "material",
- "color",
- "joints",
- "parent",
- "children",
- ]
- )
- )
- if unknown_args:
- raise ValueError(f"Unexpected argument(s) {unknown_args}")
-
- obj = kwargs.get("obj", obj)
- shapes = kwargs.get("shapes", shapes)
- material = kwargs.get("material", material)
- joints = kwargs.get("joints", joints)
- label = kwargs.get("label", label)
- color = kwargs.get("color", color)
- parent = kwargs.get("parent", parent)
- children = kwargs.get("children", children)
-
- if shapes:
- obj = Compound._make_compound([s.wrapped for s in shapes])
+ if isinstance(obj, Iterable):
+ obj = _make_topods_compound_from_shapes([s.wrapped for s in obj])
super().__init__(
obj=obj,
- label="" if label is None else label,
+ label=label,
color=color,
parent=parent,
)
@@ -4192,7 +4354,7 @@ class Compound(Mixin3D, Shape):
raise ValueError("Center of GEOMETRY is not supported for this object")
if center_of == CenterOf.MASS:
properties = GProp_GProps()
- calc_function = shape_properties_LUT[unwrapped_shapetype(self)]
+ calc_function = Shape.shape_properties_LUT[unwrapped_shapetype(self)]
if calc_function:
calc_function(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
@@ -4202,42 +4364,6 @@ class Compound(Mixin3D, Shape):
middle = self.bounding_box().center()
return middle
- @staticmethod
- def _make_compound(occt_shapes: Iterable[TopoDS_Shape]) -> TopoDS_Compound:
- """Create an OCCT TopoDS_Compound
-
- Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects
-
- Args:
- occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes
-
- Returns:
- TopoDS_Compound: OCCT compound
- """
- comp = TopoDS_Compound()
- comp_builder = TopoDS_Builder()
- comp_builder.MakeCompound(comp)
-
- for shape in occt_shapes:
- comp_builder.Add(comp, shape)
-
- return comp
-
- @classmethod
- def make_compound(cls, shapes: Iterable[Shape]) -> Compound:
- """Create a compound out of a list of shapes
- Args:
- shapes: Iterable[Shape]:
- Returns:
- """
- warnings.warn(
- "make_compound() will be deprecated - use the Compound constructor instead",
- DeprecationWarning,
- stacklevel=2,
- )
-
- return cls(Compound._make_compound([s.wrapped for s in shapes]))
-
def _remove(self, shape: Shape) -> Compound:
"""Return self with the specified shape removed.
@@ -4252,7 +4378,7 @@ class Compound(Mixin3D, Shape):
"""Method call after detaching from `parent`."""
logger.debug("Removing parent of %s (%s)", self.label, parent.label)
if parent.children:
- parent.wrapped = Compound._make_compound(
+ parent.wrapped = _make_topods_compound_from_shapes(
[c.wrapped for c in parent.children]
)
else:
@@ -4266,20 +4392,24 @@ class Compound(Mixin3D, Shape):
def _post_attach(self, parent: Compound):
"""Method call after attaching to `parent`."""
logger.debug("Updated parent of %s to %s", self.label, parent.label)
- parent.wrapped = Compound._make_compound([c.wrapped for c in parent.children])
+ parent.wrapped = _make_topods_compound_from_shapes(
+ [c.wrapped for c in parent.children]
+ )
def _post_detach_children(self, children):
"""Method call before detaching `children`."""
if children:
kids = ",".join([child.label for child in children])
logger.debug("Removing children %s from %s", kids, self.label)
- self.wrapped = Compound._make_compound([c.wrapped for c in self.children])
+ self.wrapped = _make_topods_compound_from_shapes(
+ [c.wrapped for c in self.children]
+ )
# else:
# logger.debug("Removing no children from %s", self.label)
def _pre_attach_children(self, children):
"""Method call before attaching `children`."""
- if not all([isinstance(child, Shape) for child in children]):
+ if not all(isinstance(child, Shape) for child in children):
raise ValueError("Each child must be of type Shape")
def _post_attach_children(self, children: Iterable[Shape]):
@@ -4287,11 +4417,13 @@ class Compound(Mixin3D, Shape):
if children:
kids = ",".join([child.label for child in children])
logger.debug("Adding children %s to %s", kids, self.label)
- self.wrapped = Compound._make_compound([c.wrapped for c in self.children])
+ self.wrapped = _make_topods_compound_from_shapes(
+ [c.wrapped for c in self.children]
+ )
# else:
# logger.debug("Adding no children to %s", self.label)
- def __add__(self, other: Union[list[Shape], Shape]) -> Shape:
+ def __add__(self, other: Shape | Sequence[Shape]) -> Compound:
"""Combine other to self `+` operator
Note that if all of the objects are connected Edges/Wires the result
@@ -4301,36 +4433,77 @@ class Compound(Mixin3D, Shape):
curve = Curve() if self.wrapped is None else Curve(self.wrapped)
self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
return curve + other
+
+ summands = [
+ shape
+ for o in (other if isinstance(other, (list, tuple)) else [other])
+ if o is not None
+ for shape in o.get_top_level_shapes()
+ ]
+ # If there is nothing to add return the original object
+ if not summands:
+ return self
+
+ summands = [s for s in self.get_top_level_shapes() + summands if s is not None]
+
+ # Only fuse the parts if necessary
+ if len(summands) <= 1:
+ result: Shape = Compound(summands[0:1])
else:
- summands = [
- shape
- for o in (other if isinstance(other, (list, tuple)) else [other])
- if o is not None
- for shape in o.get_top_level_shapes()
- ]
- # If there is nothing to add return the original object
- if not summands:
- return self
+ fuse_op = BRepAlgoAPI_Fuse()
+ fuse_op.SetFuzzyValue(TOLERANCE)
+ self.copy_attributes_to(summands[0], ["wrapped", "_NodeMixin__children"])
+ result = self._bool_op(summands[:1], summands[1:], fuse_op)
+ if isinstance(result, list):
+ result = Compound(result)
+ self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
- summands = [
- s for s in self.get_top_level_shapes() + summands if s is not None
- ]
+ if SkipClean.clean:
+ result = result.clean()
- # Only fuse the parts if necessary
- if len(summands) <= 1:
- result: Shape = summands[0]
- else:
- fuse_op = BRepAlgoAPI_Fuse()
- fuse_op.SetFuzzyValue(TOLERANCE)
- self.copy_attributes_to(
- summands[0], ["wrapped", "_NodeMixin__children"]
- )
- result = self._bool_op(summands[:1], summands[1:], fuse_op)
+ return result
- if SkipClean.clean:
- result = result.clean()
+ def __sub__(self, other: Shape | Sequence[Shape]) -> Compound:
+ """Cut other to self `-` operator"""
+ difference = Shape.__sub__(self, other)
+ difference = Compound(
+ difference if isinstance(difference, list) else [difference]
+ )
+ self.copy_attributes_to(difference, ["wrapped", "_NodeMixin__children"])
- return result
+ return difference
+
+ def __and__(self, other: Shape | Sequence[Shape]) -> Compound:
+ """Intersect other to self `&` operator"""
+ intersection = Shape.__and__(self, other)
+ intersection = Compound(
+ intersection if isinstance(intersection, list) else [intersection]
+ )
+ self.copy_attributes_to(intersection, ["wrapped", "_NodeMixin__children"])
+ return intersection
+
+ def compounds(self) -> ShapeList[Compound]:
+ """compounds - all the compounds in this Shape"""
+ if self.wrapped is None:
+ return ShapeList()
+ if isinstance(self.wrapped, TopoDS_Compound):
+ # pylint: disable=not-an-iterable
+ sub_compounds = [c for c in self if isinstance(c.wrapped, TopoDS_Compound)]
+ sub_compounds.append(self)
+ else:
+ sub_compounds = []
+ return ShapeList(sub_compounds)
+
+ def compound(self) -> Compound:
+ """Return the Compound"""
+ shape_list = self.compounds()
+ entity_count = len(shape_list)
+ if entity_count != 1:
+ warnings.warn(
+ f"Found {entity_count} compounds, returning first",
+ stacklevel=2,
+ )
+ return shape_list[0] if shape_list else None
def do_children_intersect(
self, include_parent: bool = False, tolerance: float = 1e-5
@@ -4363,16 +4536,20 @@ class Compound(Mixin3D, Shape):
for child_index_pair in child_index_pairs:
# First check for bounding box intersections ..
# .. then confirm with actual object intersections which could be complex
+ bbox_intersection = children_bbox[child_index_pair[0]].intersect(
+ children_bbox[child_index_pair[1]]
+ )
bbox_common_volume = (
- children_bbox[child_index_pair[0]]
- .intersect(children_bbox[child_index_pair[1]])
- .volume
+ 0.0 if isinstance(bbox_intersection, list) else bbox_intersection.volume
)
if bbox_common_volume > tolerance:
+ obj_intersection = children[child_index_pair[0]].intersect(
+ children[child_index_pair[1]]
+ )
common_volume = (
- children[child_index_pair[0]]
- .intersect(children[child_index_pair[1]])
- .volume
+ 0.0
+ if isinstance(obj_intersection, list)
+ else obj_intersection.volume
)
if common_volume > tolerance:
return (
@@ -4505,7 +4682,7 @@ class Compound(Mixin3D, Shape):
[(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)],
[(-1, 0, 0), (-1, 1.5, 0)],
)
- arrow = arrow_arc.fuse(copy.copy(arrow_arc).mirror(Plane.XZ))
+ arrow = Wire([arrow_arc, copy.copy(arrow_arc).mirror(Plane.XZ)])
x_label = (
Compound.make_text(
"X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
@@ -4530,17 +4707,20 @@ class Compound(Mixin3D, Shape):
.move(Location(z_axis @ 1))
.edges()
)
- triad = Edge.fuse(
- x_axis,
- y_axis,
- z_axis,
- arrow.moved(Location(x_axis @ 1)),
- arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)),
- arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)),
- *x_label,
- *y_label,
- *z_label,
+ triad = Curve(
+ [
+ x_axis,
+ y_axis,
+ z_axis,
+ arrow.moved(Location(x_axis @ 1)),
+ arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)),
+ arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)),
+ *x_label,
+ *y_label,
+ *z_label,
+ ]
)
+
return triad
def __iter__(self) -> Iterator[Shape]:
@@ -4552,7 +4732,7 @@ class Compound(Mixin3D, Shape):
iterator = TopoDS_Iterator(self.wrapped)
while iterator.More():
- yield Shape.cast(iterator.Value())
+ yield Compound.cast(iterator.Value())
iterator.Next()
def __len__(self) -> int:
@@ -4697,16 +4877,15 @@ class Edge(Mixin1D, Shape):
# pylint: disable=too-many-public-methods
- _dim = 1
+ order = 1.0
@property
def _dim(self) -> int:
return 1
- @overload
def __init__(
self,
- obj: TopoDS_Shape,
+ obj: Optional[TopoDS_Shape | Axis] = None,
label: str = "",
color: Color = None,
parent: Compound = None,
@@ -4714,68 +4893,29 @@ class Edge(Mixin1D, Shape):
"""Build an Edge from an OCCT TopoDS_Shape/TopoDS_Edge
Args:
- obj (TopoDS_Shape, optional): OCCT Face.
+ obj (TopoDS_Shape | Axis, optional): OCCT Edge or Axis.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
- @overload
- def __init__(
- self,
- axis: Axis,
- label: str = "",
- color: Color = None,
- parent: Compound = None,
- ):
- """Build an infinite Edge from an Axis
-
- Args:
- axis (Axis): Axis to be converted to an infinite Edge
- label (str, optional): Defaults to ''.
- color (Color, optional): Defaults to None.
- parent (Compound, optional): assembly parent. Defaults to None.
- """
-
- def __init__(self, *args, **kwargs):
- axis, obj, label, color, parent = (None,) * 5
-
- if args:
- l_a = len(args)
- if isinstance(args[0], TopoDS_Shape):
- obj, label, color, parent = args[:4] + (None,) * (4 - l_a)
- elif isinstance(args[0], Axis):
- axis, label, color, parent = args[:4] + (None,) * (4 - l_a)
-
- unknown_args = ", ".join(
- set(kwargs.keys()).difference(["axis", "obj", "label", "color", "parent"])
- )
- if unknown_args:
- raise ValueError(f"Unexpected argument(s) {unknown_args}")
-
- obj = kwargs.get("obj", obj)
- axis = kwargs.get("axis", axis)
- label = kwargs.get("label", label)
- color = kwargs.get("color", color)
- parent = kwargs.get("parent", parent)
-
- if axis is not None:
+ if isinstance(obj, Axis):
obj = BRepBuilderAPI_MakeEdge(
Geom_Line(
- axis.position.to_pnt(),
- axis.direction.to_dir(),
+ obj.position.to_pnt(),
+ obj.direction.to_dir(),
)
).Edge()
super().__init__(
obj=obj,
- label="" if label is None else label,
+ label=label,
color=color,
parent=parent,
)
- def _geom_adaptor(self) -> BRepAdaptor_Curve:
- """ """
+ def geom_adaptor(self) -> BRepAdaptor_Curve:
+ """Return the Geom Curve from this Edge"""
return BRepAdaptor_Curve(self.wrapped)
def close(self) -> Union[Edge, Wire]:
@@ -4796,7 +4936,7 @@ class Edge(Mixin1D, Shape):
"""center of an underlying circle or ellipse geometry."""
geom_type = self.geom_type
- geom_adaptor = self._geom_adaptor()
+ geom_adaptor = self.geom_adaptor()
if geom_type == GeomType.CIRCLE:
return_value = Vector(geom_adaptor.Circle().Position().Location())
@@ -4866,7 +5006,9 @@ class Edge(Mixin1D, Shape):
return u_values
- def _intersect_with_edge(self, edge: Edge) -> Shape:
+ def _intersect_with_edge(self, edge: Edge) -> tuple[list[Vertex], list[Edge]]:
+ """find intersection vertices and edges"""
+
# Find any intersection points
vertex_intersections = [
Vertex(pnt) for pnt in self.find_intersection_points(edge)
@@ -4876,29 +5018,17 @@ class Edge(Mixin1D, Shape):
intersect_op = BRepAlgoAPI_Common()
edge_intersections = self._bool_op((self,), (edge,), intersect_op).edges()
- return Compound(vertex_intersections + edge_intersections)
-
- def _intersect_with_axis(self, axis: Axis) -> Shape:
- # Find any intersection points
- vertex_intersections = [
- Vertex(pnt) for pnt in self.find_intersection_points(axis)
- ]
-
- # Find Edge/Edge overlaps
- intersect_op = BRepAlgoAPI_Common()
- edge_intersections = self._bool_op((self,), (Edge(axis),), intersect_op).edges()
-
- return Compound(vertex_intersections + edge_intersections)
+ return vertex_intersections, edge_intersections
def find_intersection_points(
- self, edge: Union[Axis, Edge] = None, tolerance: float = TOLERANCE
+ self, other: Axis | Edge = None, tolerance: float = TOLERANCE
) -> ShapeList[Vector]:
"""find_intersection_points
Determine the points where a 2D edge crosses itself or another 2D edge
Args:
- edge (Union[Axis, Edge]): curve to compare with
+ other (Axis | Edge): curve to compare with
tolerance (float, optional): the precision of computing the intersection points.
Defaults to TOLERANCE.
@@ -4906,19 +5036,21 @@ class Edge(Mixin1D, Shape):
ShapeList[Vector]: list of intersection points
"""
# Convert an Axis into an edge at least as large as self and Axis start point
- if isinstance(edge, Axis):
+ if isinstance(other, Axis):
self_bbox_w_edge = self.bounding_box().add(
- Vertex(edge.position).bounding_box()
+ Vertex(other.position).bounding_box()
)
- edge = Edge.make_line(
- edge.position + edge.direction * (-1 * self_bbox_w_edge.diagonal),
- edge.position + edge.direction * self_bbox_w_edge.diagonal,
+ other = Edge.make_line(
+ other.position + other.direction * (-1 * self_bbox_w_edge.diagonal),
+ other.position + other.direction * self_bbox_w_edge.diagonal,
)
# To determine the 2D plane to work on
- plane = self.common_plane(edge)
+ plane = self.common_plane(other)
if plane is None:
raise ValueError("All objects must be on the same plane")
- edge_surface: Geom_Surface = Face.make_plane(plane)._geom_adaptor()
+ # Convert the plane into a Geom_Surface
+ pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face()
+ edge_surface = BRep_Tool.Surface_s(pln_shape)
self_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s(
self.wrapped,
@@ -4927,13 +5059,13 @@ class Edge(Mixin1D, Shape):
self.param_at(0),
self.param_at(1),
)
- if edge is not None:
+ if other is not None:
edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s(
- edge.wrapped,
+ other.wrapped,
edge_surface,
TopLoc_Location(),
- edge.param_at(0),
- edge.param_at(1),
+ other.param_at(0),
+ other.param_at(1),
)
intersector = Geom2dAPI_InterCurveCurve(
self_2d_curve, edge_2d_curve, tolerance
@@ -4953,10 +5085,10 @@ class Edge(Mixin1D, Shape):
valid_crosses = []
for pnt in crosses:
try:
- if edge is not None:
+ if other is not None:
if (
self.distance_to(pnt) <= TOLERANCE
- and edge.distance_to(pnt) <= TOLERANCE
+ and other.distance_to(pnt) <= TOLERANCE
):
valid_crosses.append(pnt)
else:
@@ -4967,25 +5099,44 @@ class Edge(Mixin1D, Shape):
return ShapeList(valid_crosses)
- def intersect(self, other: Union[Edge, Axis]) -> Union[Shape, None]:
- intersection: Compound
- if isinstance(other, Edge):
- intersection = self._intersect_with_edge(other)
- elif isinstance(other, Axis):
- intersection = self._intersect_with_axis(other)
- else:
- return NotImplemented
+ def intersect(
+ self, *to_intersect: Edge | Axis
+ ) -> Optional[Shape | ShapeList[Shape]]:
+ """intersect Edge with Edge or Axis
- if intersection is not None:
+ Args:
+ other (Union[Edge, Axis]): other object
+
+ Returns:
+ Union[Shape, None]: Compound of vertices and/or edges
+ """
+ edges = [Edge(obj) if isinstance(obj, Axis) else obj for obj in to_intersect]
+ if not all(isinstance(obj, Edge) for obj in edges):
+ raise TypeError(
+ "Only Edge or Axis instances are supported for intersection"
+ )
+
+ # Find any intersection points
+ points_sets: list[set[Vector]] = []
+ for edge_pair in combinations([self] + edges, 2):
+ intersection_points = edge_pair[0].find_intersection_points(edge_pair[1])
+ points_sets.append(set(intersection_points))
+
+ # Find the intersection of all sets
+ common_points = set.intersection(*points_sets)
+ common_vertices = [Vertex(*pnt) for pnt in common_points]
+
+ # Find Edge/Edge overlaps
+ common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges()
+
+ if common_vertices or common_edges:
# If there is just one vertex or edge return it
- vertices = intersection.get_type(Vertex)
- edges = intersection.get_type(Edge)
- if len(vertices) == 1 and len(edges) == 0:
- return vertices[0]
- elif len(vertices) == 0 and len(edges) == 1:
- return edges[0]
- else:
- return intersection
+ if len(common_vertices) == 1 and len(common_edges) == 0:
+ return common_vertices[0]
+ if len(common_vertices) == 0 and len(common_edges) == 1:
+ return common_edges[0]
+ return ShapeList(common_vertices + common_edges)
+ return None
def reversed(self) -> Edge:
"""Return a copy of self with the opposite orientation"""
@@ -5619,7 +5770,7 @@ class Edge(Mixin1D, Shape):
return Axis(self.position_at(0), self.position_at(1) - self.position_at(0))
-class Face(Shape):
+class Face(Mixin2D, Shape):
"""A Face in build123d represents a 3D bounded surface within the topological data
structure. It encapsulates geometric information, defining a face of a 3D shape.
These faces are integral components of complex structures, such as solids and
@@ -5628,7 +5779,7 @@ class Face(Shape):
# pylint: disable=too-many-public-methods
- _dim = 2
+ order = 2.0
@property
def _dim(self) -> int:
@@ -5705,7 +5856,10 @@ class Face(Shape):
parent = kwargs.get("parent", parent)
if outer_wire is not None:
- obj = Face._make_from_wires(outer_wire, inner_wires)
+ inner_topods_wires = (
+ [w.wrapped for w in inner_wires] if inner_wires is not None else []
+ )
+ obj = _make_topods_face_from_wires(outer_wire.wrapped, inner_topods_wires)
super().__init__(
obj=obj,
@@ -5750,7 +5904,7 @@ class Face(Shape):
if self.is_planar:
flat_face = Plane(self).to_local_coords(self)
flat_face_edges = flat_face.edges()
- if all([e.geom_type == GeomType.LINE for e in flat_face_edges]):
+ if all(e.geom_type == GeomType.LINE for e in flat_face_edges):
flat_face_vertices = flat_face.vertices()
result = "POLYGON"
if len(flat_face_edges) == 4:
@@ -5763,10 +5917,8 @@ class Face(Shape):
[edge.tangent_at(0) for edge in pair] for pair in edge_pairs
]
if all(
- [
- edge_directions[0].get_angle(edge_directions[1]) == 90
- for edge_directions in edge_pair_directions
- ]
+ edge_directions[0].get_angle(edge_directions[1]) == 90
+ for edge_directions in edge_pair_directions
):
result = "RECTANGLE"
if len(flat_face_edges.group_by(SortBy.LENGTH)) == 1:
@@ -5781,29 +5933,18 @@ class Face(Shape):
return Plane(origin, z_dir=self.normal_at(origin)).location
@property
- def is_planar(face: Face) -> bool:
+ def is_planar(self) -> bool:
"""Is the face planar even though its geom_type may not be PLANE"""
- surface = BRep_Tool.Surface_s(face.wrapped)
- is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE)
- return is_face_planar.IsPlanar()
+ return self.is_planar_face
- def _geom_adaptor(self) -> Geom_Surface:
- """ """
+ def geom_adaptor(self) -> Geom_Surface:
+ """Return the Geom Surface for this Face"""
return BRep_Tool.Surface_s(self.wrapped)
def _uv_bounds(self) -> Tuple[float, float, float, float]:
+ """Return the u min, u max, v min, v max values"""
return BRepTools.UVBounds_s(self.wrapped)
- def __neg__(self) -> Face:
- """Reverse normal operator -"""
- new_face = copy.deepcopy(self)
- new_face.wrapped = downcast(self.wrapped.Complemented())
- return new_face
-
- def offset(self, amount: float) -> Face:
- """Return a copy of self moved along the normal by amount"""
- return copy.deepcopy(self).moved(Location(self.normal_at() * amount))
-
@overload
def normal_at(self, surface_point: VectorLike = None) -> Vector:
"""normal_at point on surface
@@ -5849,7 +5990,7 @@ class Face(Shape):
surface_point, u, v = (None,) * 3
if args:
- if isinstance(args[0], Iterable):
+ if isinstance(args[0], Sequence):
surface_point = args[0]
elif isinstance(args[0], (int, float)):
u = args[0]
@@ -5871,7 +6012,7 @@ class Face(Shape):
raise ValueError("Both u & v values must be specified")
# get the geometry
- surface = self._geom_adaptor()
+ surface = self.geom_adaptor()
if surface_point is None:
u_val0, u_val1, v_val0, v_val1 = self._uv_bounds()
@@ -6041,93 +6182,7 @@ class Face(Shape):
return return_value
@classmethod
- def make_from_wires(
- cls, outer_wire: Wire, inner_wires: Iterable[Wire] = None
- ) -> Face:
- """make_from_wires
-
- Makes a planar face from one or more wires
-
- Args:
- outer_wire (Wire): closed perimeter wire
- inner_wires (list[Wire], optional): holes. Defaults to None.
-
- Raises:
- ValueError: outer wire not closed
- ValueError: wires not planar
- ValueError: inner wire not closed
- ValueError: internal error
-
- Returns:
- Face: planar face potentially with holes
- """
- warnings.warn(
- "make_from_wires() will be deprecated - use the Face constructor instead",
- DeprecationWarning,
- stacklevel=2,
- )
-
- return Face(Face._make_from_wires(outer_wire, inner_wires))
-
- @classmethod
- def _make_from_wires(
- cls, outer_wire: Wire, inner_wires: Iterable[Wire] = None
- ) -> TopoDS_Shape:
- """make_from_wires
-
- Makes a planar face from one or more wires
-
- Args:
- outer_wire (Wire): closed perimeter wire
- inner_wires (list[Wire], optional): holes. Defaults to None.
-
- Raises:
- ValueError: outer wire not closed
- ValueError: wires not planar
- ValueError: inner wire not closed
- ValueError: internal error
-
- Returns:
- Face: planar face potentially with holes
- """
- if inner_wires and not outer_wire.is_closed:
- raise ValueError("Cannot build face(s): outer wire is not closed")
- inner_wires = inner_wires if inner_wires else []
-
- # check if wires are coplanar
- verification_compound = Compound([outer_wire] + inner_wires)
- if not BRepLib_FindSurface(
- verification_compound.wrapped, OnlyPlane=True
- ).Found():
- raise ValueError("Cannot build face(s): wires not planar")
-
- # fix outer wire
- sf_s = ShapeFix_Shape(outer_wire.wrapped)
- sf_s.Perform()
- topo_wire = TopoDS.Wire_s(sf_s.Shape())
-
- face_builder = BRepBuilderAPI_MakeFace(topo_wire, True)
-
- for inner_wire in inner_wires:
- if not inner_wire.is_closed:
- raise ValueError("Cannot build face(s): inner wire is not closed")
- face_builder.Add(inner_wire.wrapped)
-
- face_builder.Build()
-
- if not face_builder.IsDone():
- raise ValueError(f"Cannot build face(s): {face_builder.Error()}")
-
- face = face_builder.Face()
-
- sf_f = ShapeFix_Face(face)
- sf_f.FixOrientation()
- sf_f.Perform()
-
- return sf_f.Result()
-
- @classmethod
- def sew_faces(cls, faces: Iterable[Face]) -> list[ShapeList[Face]]:
+ def sew_faces(cls, faces: Iterable[Face]) -> ShapeList[ShapeList[Face]]:
"""sew faces
Group contiguous faces and return them in a list of ShapeList
@@ -6139,35 +6194,29 @@ class Face(Shape):
RuntimeError: OCCT SewedShape generated unexpected output
Returns:
- list[ShapeList[Face]]: grouped contiguous faces
+ ShapeList[ShapeList[Face]]: grouped contiguous faces
"""
- # Create the shell build
- shell_builder = BRepBuilderAPI_Sewing()
- # Add the given faces to it
- for face in faces:
- shell_builder.Add(face.wrapped)
- # Attempt to sew the faces into a contiguous shell
- shell_builder.Perform()
- # Extract the sewed shape - a face, a shell, a solid or a compound
- sewed_shape = downcast(shell_builder.SewedShape())
+ # Sew the faces
+ sewed_shape = _sew_topods_faces([f.wrapped for f in faces])
+ top_level_shapes = get_top_level_topods_shapes(sewed_shape)
+ sewn_faces = ShapeList()
- # Create a list of ShapeList of Faces
- if isinstance(sewed_shape, TopoDS_Face):
- sewn_faces = [ShapeList([Face(sewed_shape)])]
- elif isinstance(sewed_shape, TopoDS_Shell):
- sewn_faces = [Shell(sewed_shape).faces()]
- elif isinstance(sewed_shape, TopoDS_Compound):
- sewn_faces = []
- for face in Compound(sewed_shape).get_type(Face):
- sewn_faces.append(ShapeList([face]))
- for shell in Compound(sewed_shape).get_type(Shell):
- sewn_faces.append(shell.faces())
- elif isinstance(sewed_shape, TopoDS_Solid):
- sewn_faces = [Solid(sewed_shape).faces()]
- else:
- raise RuntimeError(
- f"SewedShape returned a {type(sewed_shape)} which was unexpected"
- )
+ # For each of the top level shapes create a ShapeList of Face
+ for top_level_shape in top_level_shapes:
+ if isinstance(top_level_shape, TopoDS_Face):
+ sewn_faces.append(ShapeList([Face(top_level_shape)]))
+ elif isinstance(top_level_shape, TopoDS_Shell):
+ sewn_faces.append(Shell(top_level_shape).faces())
+ elif isinstance(top_level_shape, TopoDS_Solid):
+ sewn_faces.append(
+ ShapeList(
+ Face(f) for f in _topods_entities(top_level_shape, "Face")
+ )
+ )
+ else:
+ raise RuntimeError(
+ f"SewedShape returned a {type(top_level_shape)} which was unexpected"
+ )
return sewn_faces
@@ -6206,9 +6255,13 @@ class Face(Shape):
path = Wire([path.edge()])
builder = BRepOffsetAPI_MakePipeShell(path.wrapped)
builder.Add(profile.wrapped, False, False)
- builder.SetTransitionMode(Solid._transModeDict[transition])
+ builder.SetTransitionMode(Shape._transModeDict[transition])
builder.Build()
- return Shape.cast(builder.Shape()).clean().face()
+ result = Face(builder.Shape())
+ if SkipClean.clean:
+ result = result.clean()
+
+ return result
@classmethod
def make_surface_from_array_of_points(
@@ -6222,12 +6275,14 @@ class Face(Shape):
"""make_surface_from_array_of_points
Approximate a spline surface through the provided 2d array of points.
- The first dimension correspond to points on the vertical direction in the parameter space of the face.
- The second dimension correspond to points on the horizontal direction in the parameter space of the face.
- The 2 dimensions are U,V dimensions of the parameter space of the face.
+ The first dimension correspond to points on the vertical direction in the parameter
+ space of the face. The second dimension correspond to points on the horizontal
+ direction in the parameter space of the face. The 2 dimensions are U,V dimensions
+ of the parameter space of the face.
Args:
- points (list[list[VectorLike]]): a 2D list of points, first dimension is V parameters second is U parameters.
+ points (list[list[VectorLike]]): a 2D list of points, first dimension is V
+ parameters second is U parameters.
tol (float, optional): tolerance of the algorithm. Defaults to 1e-2.
smoothing (Tuple[float, float, float], optional): optional tuple of
3 weights use for variational smoothing. Defaults to None.
@@ -6370,7 +6425,7 @@ class Face(Shape):
if isinstance(exterior, Wire):
outside_edges = exterior.edges()
elif isinstance(exterior, Iterable) and all(
- [isinstance(o, Edge) for o in exterior]
+ isinstance(o, Edge) for o in exterior
):
outside_edges = exterior
else:
@@ -6483,15 +6538,9 @@ class Face(Shape):
# Need to wrap in b3d objects for comparison to work
# ref.wrapped != edge.wrapped but ref == edge
- edges = [Shape.cast(e) for e in edges]
+ edges = [Mixin2D.cast(e) for e in edges]
- if reference_edge:
- if reference_edge not in edges:
- raise ValueError("One or more vertices are not part of edge")
- edge1 = reference_edge
- edge2 = [x for x in edges if x != reference_edge][0]
- else:
- edge1, edge2 = edges
+ edge1, edge2 = Wire.order_chamfer_edges(reference_edge, edges)
chamfer_builder.AddChamfer(
TopoDS.Edge_s(edge1.wrapped),
@@ -6515,43 +6564,9 @@ class Face(Shape):
and 1 - abs(plane.z_dir.dot(Vector(normal))) < TOLERANCE
)
- def thicken(
- self, depth: float, normal_override: Optional[VectorLike] = None
- ) -> Solid:
- """Thicken Face
-
- Create a solid from a potentially non planar face by thickening along the normals.
-
- .. image:: thickenFace.png
-
- Non-planar faces are thickened both towards and away from the center of the sphere.
-
- Args:
- depth (float): Amount to thicken face(s), can be positive or negative.
- normal_override (Vector, optional): The normal_override vector can be used to
- indicate which way is 'up', potentially flipping the face normal direction
- such that many faces with different normals all go in the same direction
- (direction need only be +/- 90 degrees from the face normal). Defaults to None.
-
- Raises:
- RuntimeError: Opencascade internal failures
-
- Returns:
- Solid: The resulting Solid object
- """
- # Check to see if the normal needs to be flipped
- adjusted_depth = depth
- if normal_override is not None:
- face_center = self.center()
- face_normal = self.normal_at(face_center).normalized()
- if face_normal.dot(Vector(normal_override).normalized()) < 0:
- adjusted_depth = -depth
-
- return _thicken(self.wrapped, adjusted_depth)
-
def project_to_shape(
- self, target_object: Shape, direction: VectorLike, taper: float = 0
- ) -> ShapeList[Face]:
+ self, target_object: Shape, direction: VectorLike
+ ) -> ShapeList[Face | Shell]:
"""Project Face to target Object
Project a Face onto a Shape generating new Face(s) on the surfaces of the object.
@@ -6569,180 +6584,39 @@ class Face(Shape):
Args:
target_object (Shape): Object to project onto
direction (VectorLike): projection direction
- taper (float, optional): taper angle. Defaults to 0.
Returns:
ShapeList[Face]: Face(s) projected on target object ordered by distance
"""
- max_dimension = Compound([self, target_object]).bounding_box().diagonal
- if taper == 0:
- face_extruded = Solid.extrude(self, Vector(direction) * max_dimension)
+ max_dimension = find_max_dimension([self, target_object])
+ extruded_topods_self = _extrude_topods_shape(
+ self.wrapped, Vector(direction) * max_dimension
+ )
+
+ intersected_shapes = ShapeList()
+ if isinstance(target_object, Vertex):
+ raise TypeError("projection to a vertex is not supported")
+ if isinstance(target_object, Face):
+ topods_shape = _topods_bool_op(
+ (extruded_topods_self,), (target_object.wrapped,), BRepAlgoAPI_Common()
+ )
+ if not topods_shape.IsNull():
+ intersected_shapes.append(Face(topods_shape))
else:
- face_extruded = Solid.extrude_taper(
- self, Vector(direction) * max_dimension, taper=taper
- )
+ for target_shell in target_object.shells():
+ topods_shape = _topods_bool_op(
+ (extruded_topods_self,),
+ (target_shell.wrapped,),
+ BRepAlgoAPI_Common(),
+ )
+ for topods_shell in get_top_level_topods_shapes(topods_shape):
+ intersected_shapes.append(Shell(topods_shell))
- intersected_faces = ShapeList()
- for target_face in target_object.faces():
- intersected_faces.extend(face_extruded.intersect(target_face).faces())
-
- # intersected faces may be fragmented so we'll put them back together
- sewed_face_list = Face.sew_faces(intersected_faces)
- sewed_faces = ShapeList()
- for face_group in sewed_face_list:
- if len(face_group) > 1:
- sewed_faces.append(face_group.pop(0).fuse(*face_group).clean())
- else:
- sewed_faces.append(face_group[0])
-
- return sewed_faces.sort_by(Axis(self.center(), direction))
-
- def project_to_shape_alt(
- self, target_object: Shape, direction: VectorLike
- ) -> Union[None, Face, Compound]:
- """project_to_shape_alt
-
- Return the Faces contained within the first projection of self onto
- the target.
-
- Args:
- target_object (Shape): Object to project onto
- direction (VectorLike): projection direction
-
- Returns:
- Union[None, Face, Compound]: projection
- """
-
- perimeter = self.outer_wire()
- direction = Vector(direction)
- projection_axis = Axis((0, 0, 0), direction)
- max_size = target_object.bounding_box().add(self.bounding_box()).diagonal
- projection_faces: list[Face] = []
-
- def get(los: TopTools_ListOfShape, shape_cls) -> list:
- shapes = []
- for _i in range(los.Size()):
- shapes.append(shape_cls(los.First()))
- los.RemoveFirst()
- return shapes
-
- def desired_faces(face_list: list[Face]) -> bool:
- return (
- face_list
- and face_list[0]._extrude(direction * -max_size).intersect(self).area
- > TOLERANCE
- )
-
- #
- # Self projection
- #
- projection_plane = Plane(direction * -max_size, z_dir=-direction)
-
- # Setup the projector
- hidden_line_remover = HLRBRep_Algo()
- hidden_line_remover.Add(target_object.wrapped)
- hlr_projector = HLRAlgo_Projector(projection_plane.to_gp_ax2())
- hidden_line_remover.Projector(hlr_projector)
- hidden_line_remover.Update()
- hidden_line_remover.Hide()
- hlr_shapes = HLRBRep_HLRToShape(hidden_line_remover)
-
- # Find the visible edges
- target_edges_on_xy = []
- for edge_compound in [
- hlr_shapes.VCompound(),
- hlr_shapes.Rg1LineVCompound(),
- hlr_shapes.OutLineVCompound(),
- ]:
- if not edge_compound.IsNull():
- target_edges_on_xy.extend(Compound(edge_compound).edges())
-
- target_edges = [
- projection_plane.from_local_coords(e) for e in target_edges_on_xy
+ intersected_shapes = intersected_shapes.sort_by(Axis(self.center(), direction))
+ intersected_shapes = [
+ s.face() if len(s.faces()) == 1 else s for s in intersected_shapes
]
- target_wires = edges_to_wires(target_edges)
- # return target_wires
-
- # projection_plane = Plane(self.center(), z_dir=direction)
- # projection_plane = Plane((0, 0, 0), z_dir=direction)
- # visible, _hidden = target_object.project_to_viewport(
- # viewport_origin=direction * -max_size,
- # # viewport_up=projection_plane.x_dir,
- # viewport_up=(direction.X, direction.Y, 0),
- # # viewport_up=(direction.Y,direction.X,0),
- # # viewport_up=projection_plane.y_dir.cross(direction),
- # look_at=projection_plane.z_dir,
- # )
- # self_visible_edges = [projection_plane.from_local_coords(e) for e in visible]
- # self_visible_wires = edges_to_wires(self_visible_edges)
-
- # Project the perimeter onto the target object
- hlr_projector = BRepProj_Projection(
- perimeter.wrapped, target_object.wrapped, direction.to_dir()
- )
- # print(len(Compound(hlr_projector.Shape()).wires().sort_by(projection_axis)))
- projected_wires = (
- Compound(hlr_projector.Shape()).wires().sort_by(projection_axis)
- )
-
- # target_projected_wires = []
- # for target_wire in target_wires:
- # hlr_projector = BRepProj_Projection(
- # target_wire.wrapped, target_object.wrapped, direction.to_dir()
- # )
- # target_projected_wires.extend(
- # Compound(hlr_projector.Shape()).wires().sort_by(projection_axis)
- # )
- # return target_projected_wires
- # target_projected_edges = [e for w in target_projected_wires for e in w.edges()]
-
- edge_sequence = TopTools_SequenceOfShape()
- for e in projected_wires.edges():
- edge_sequence.Append(e.wrapped)
-
- # Split the faces by the projection edges & keep the part of
- # these faces bound by the projection
- for target_face in target_object.faces():
- constructor = BRepFeat_SplitShape(target_face.wrapped)
- constructor.Add(edge_sequence)
- constructor.Build()
- lefts = get(constructor.Left(), Face)
- rights = get(constructor.Right(), Face)
- # Keep the faces that correspond to the projection
- if desired_faces(lefts):
- projection_faces.extend(lefts)
- if desired_faces(rights):
- projection_faces.extend(rights)
-
- # # Filter out faces on the back
- # projection_faces = ShapeList(projection_faces).filter_by(
- # lambda f: f._extrude(direction * -1).intersect(target_object).area > 0,
- # reverse=True,
- # )
-
- # Project the targets own edges on the projection_faces
- # trim_wires = []
- # for projection_face in projection_faces:
- # for target_wire in target_wires:
- # hlr_projector = BRepProj_Projection(
- # target_wire.wrapped, projection_face.wrapped, direction.to_dir()
- # )
- # # print(len(Compound(hlr_projector.Shape()).wires().sort_by(projection_axis)))
- # trim_wires.extend(
- # Compound(hlr_projector.Shape()).wires()
- # )
-
- # return trim_wires
-
- # Create the object to return depending on the # projected faces
- if not projection_faces:
- projection = None
- elif len(projection_faces) == 1:
- projection = projection_faces[0]
- else:
- projection = projection_faces.pop(0).fuse(*projection_faces).clean()
-
- return projection
+ return intersected_shapes
def make_holes(self, interior_wires: list[Wire]) -> Face:
"""Make Holes in Face
@@ -6803,10 +6677,16 @@ class Face(Shape):
bool: indicating whether or not point is within Face
"""
- return Compound([self]).is_inside(point, tolerance)
+ solid_classifier = BRepClass3d_SolidClassifier(self.wrapped)
+ solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance)
+ return solid_classifier.IsOnAFace()
+
+ # surface = BRep_Tool.Surface_s(self.wrapped)
+ # projector = GeomAPI_ProjectPointOnSurf(Vector(point).to_pnt(), surface)
+ # return projector.LowerDistance() <= TOLERANCE
-class Shell(Shape):
+class Shell(Mixin2D, Shape):
"""A Shell is a fundamental component in build123d's topological data structure
representing a connected set of faces forming a closed surface in 3D space. As
part of a geometric model, it defines a watertight enclosure, commonly encountered
@@ -6815,16 +6695,15 @@ class Shell(Shape):
allows for efficient handling of surfaces within a model, supporting various
operations and analyses."""
- _dim = 2
+ order = 2.5
@property
def _dim(self) -> int:
return 2
- @overload
def __init__(
self,
- obj: TopoDS_Shape,
+ obj: Optional[TopoDS_Shape | Face | Iterable[Face]] = None,
label: str = "",
color: Color = None,
parent: Compound = None,
@@ -6832,94 +6711,26 @@ class Shell(Shape):
"""Build a shell from an OCCT TopoDS_Shape/TopoDS_Shell
Args:
- obj (TopoDS_Shape, optional): OCCT Shell.
+ obj (TopoDS_Shape | Face | Iterable[Face], optional): OCCT Shell, Face or Faces.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
- @overload
- def __init__(
- self,
- face: Face,
- label: str = "",
- color: Color = None,
- parent: Compound = None,
- ):
- """Build a shell from a single Face
+ if isinstance(obj, Iterable) and len(obj) == 1:
+ obj = obj[0]
- Args:
- face (Face): Face to convert to Shell
- label (str, optional): Defaults to ''.
- color (Color, optional): Defaults to None.
- parent (Compound, optional): assembly parent. Defaults to None.
- """
-
- @overload
- def __init__(
- self,
- faces: Iterable[Face],
- label: str = "",
- color: Color = None,
- parent: Compound = None,
- ):
- """Build a shell from Faces
-
- Args:
- faces (Iterable[Face]): Faces to assemble
- label (str, optional): Defaults to ''.
- color (Color, optional): Defaults to None.
- parent (Compound, optional): assembly parent. Defaults to None.
- """
-
- def __init__(self, *args, **kwargs):
- face, faces, obj, label, color, parent = (None,) * 6
-
- if args:
- l_a = len(args)
- if isinstance(args[0], TopoDS_Shape):
- obj, label, color, parent = args[:4] + (None,) * (4 - l_a)
- elif isinstance(args[0], Face):
- face, label, color, parent = args[:4] + (None,) * (4 - l_a)
- elif isinstance(args[0], Iterable):
- faces, label, color, parent = args[:4] + (None,) * (4 - l_a)
-
- unknown_args = ", ".join(
- set(kwargs.keys()).difference(
- [
- "face",
- "faces",
- "obj",
- "label",
- "color",
- "parent",
- ]
- )
- )
- if unknown_args:
- raise ValueError(f"Unexpected argument(s) {unknown_args}")
-
- obj = kwargs.get("obj", obj)
- face = kwargs.get("face", face)
- faces = kwargs.get("faces", faces)
- label = kwargs.get("label", label)
- color = kwargs.get("color", color)
- parent = kwargs.get("parent", parent)
-
- if faces:
- if len(faces) == 1:
- face = faces[0]
- else:
- obj = Shell._make_shell(faces)
- if face:
+ if isinstance(obj, Face):
builder = BRepBuilderAPI_MakeShell(
- BRepAdaptor_Surface(face.wrapped).Surface().Surface()
+ BRepAdaptor_Surface(obj.wrapped).Surface().Surface()
)
obj = builder.Shape()
+ elif isinstance(obj, Iterable):
+ obj = _sew_topods_faces([f.wrapped for f in obj])
super().__init__(
obj=obj,
- label="" if label is None else label,
+ label=label,
color=color,
parent=parent,
)
@@ -6927,34 +6738,14 @@ class Shell(Shape):
@property
def volume(self) -> float:
"""volume - the volume of this Shell if manifold, otherwise zero"""
- # when density == 1, mass == volume
if self.is_manifold:
- return Solid(self).volume
+ solid_shell = ShapeFix_Solid().SolidFromShell(self.wrapped)
+ properties = GProp_GProps()
+ calc_function = Shape.shape_properties_LUT[shapetype(solid_shell)]
+ calc_function(solid_shell, properties)
+ return properties.Mass()
return 0.0
- @classmethod
- def make_shell(cls, faces: Iterable[Face]) -> Shell:
- """Create a Shell from provided faces"""
- warnings.warn(
- "make_shell() will be deprecated - use the Shell constructor instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return Shell(Shell._make_shell(faces))
-
- @classmethod
- def _make_shell(cls, faces: Iterable[Face]) -> TopoDS_Shape:
- """Create a Shell from provided faces"""
- shell_builder = BRepBuilderAPI_Sewing()
-
- for face in faces:
- shell_builder.Add(face.wrapped)
-
- shell_builder.Perform()
- shape = shell_builder.SewedShape()
-
- return shape
-
def center(self) -> Vector:
"""Center of mass of the shell"""
properties = GProp_GProps()
@@ -6985,9 +6776,13 @@ class Shell(Shape):
path = Wire(Wire(path.edges()).order_edges())
builder = BRepOffsetAPI_MakePipeShell(path.wrapped)
builder.Add(profile.wrapped, False, False)
- builder.SetTransitionMode(Solid._transModeDict[transition])
+ builder.SetTransitionMode(Shape._transModeDict[transition])
builder.Build()
- return Shape.cast(builder.Shape())
+ result = Shell(builder.Shape())
+ if SkipClean.clean:
+ result = result.clean()
+
+ return result
@classmethod
def make_loft(
@@ -6995,10 +6790,9 @@ class Shell(Shape):
) -> Shell:
"""make loft
- Makes a loft from a list of wires and vertices.
- Vertices can appear only at the beginning or end of the list, but cannot appear consecutively within the list
- nor between wires.
- Wires may be closed or opened.
+ Makes a loft from a list of wires and vertices. Vertices can appear only at the
+ beginning or end of the list, but cannot appear consecutively within the list nor
+ between wires. Wires may be closed or opened.
Args:
objs (list[Vertex, Wire]): wire perimeters or vertices
@@ -7012,22 +6806,6 @@ class Shell(Shape):
"""
return cls(_make_loft(objs, False, ruled))
- def thicken(self, depth: float) -> Solid:
- """Thicken Shell
-
- Create a solid from a shell by thickening along the normals.
-
- Args:
- depth (float): Amount to thicken face(s), can be positive or negative.
-
- Raises:
- RuntimeError: Opencascade internal failures
-
- Returns:
- Solid: The resulting Solid object
- """
- return _thicken(self.wrapped, depth)
-
class Solid(Mixin3D, Shape):
"""A Solid in build123d represents a three-dimensional solid geometry
@@ -7037,16 +6815,15 @@ class Solid(Mixin3D, Shape):
operations (union, intersection, and difference), are often performed on
Solid objects to create or modify complex geometries."""
- _dim = 3
+ order = 3.0
@property
def _dim(self) -> int:
return 3
- @overload
def __init__(
self,
- obj: TopoDS_Shape,
+ obj: TopoDS_Shape | Shell = None,
label: str = "",
color: Color = None,
material: str = "",
@@ -7056,7 +6833,7 @@ class Solid(Mixin3D, Shape):
"""Build a solid from an OCCT TopoDS_Shape/TopoDS_Solid
Args:
- obj (TopoDS_Shape, optional): OCCT Solid.
+ obj (TopoDS_Shape | Shell, optional): OCCT Solid or Shell.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
material (str, optional): tag for external tools. Defaults to ''.
@@ -7064,71 +6841,13 @@ class Solid(Mixin3D, Shape):
parent (Compound, optional): assembly parent. Defaults to None.
"""
- @overload
- def __init__(
- self,
- shell: Shell,
- label: str = "",
- color: Color = None,
- material: str = "",
- joints: dict[str, Joint] = None,
- parent: Compound = None,
- ):
- """Build a shell from Faces
-
- Args:
- shell (Shell): manifold shell of the new solid
- label (str, optional): Defaults to ''.
- color (Color, optional): Defaults to None.
- material (str, optional): tag for external tools. Defaults to ''.
- joints (dict[str, Joint], optional): names joints. Defaults to None.
- parent (Compound, optional): assembly parent. Defaults to None.
- """
-
- def __init__(self, *args, **kwargs):
- shell, obj, label, color, material, joints, parent = (None,) * 7
-
- if args:
- l_a = len(args)
- if isinstance(args[0], TopoDS_Shape):
- obj, label, color, material, joints, parent = args[:6] + (None,) * (
- 6 - l_a
- )
- elif isinstance(args[0], Shell):
- shell, label, color, material, joints, parent = args[:6] + (None,) * (
- 6 - l_a
- )
-
- unknown_args = ", ".join(
- set(kwargs.keys()).difference(
- [
- "shell",
- "obj",
- "label",
- "color",
- "material",
- "joints",
- "parent",
- ]
- )
- )
- if unknown_args:
- raise ValueError(f"Unexpected argument(s) {unknown_args}")
-
- obj = kwargs.get("obj", obj)
- shell = kwargs.get("shell", shell)
- label = kwargs.get("label", label)
- color = kwargs.get("color", color)
- material = kwargs.get("material", material)
- joints = kwargs.get("joints", joints)
- parent = kwargs.get("parent", parent)
-
- if shell is not None:
- obj = Solid._make_solid(shell)
+ if isinstance(obj, Shell):
+ obj = Solid._make_solid(obj)
super().__init__(
obj=obj,
- label="" if label is None else label,
+ # label="" if label is None else label,
+ label=label,
color=color,
parent=parent,
)
@@ -7141,16 +6860,6 @@ class Solid(Mixin3D, Shape):
# when density == 1, mass == volume
return Shape.compute_mass(self)
- @classmethod
- def make_solid(cls, shell: Shell) -> Solid:
- """Create a Solid object from the surface shell"""
- warnings.warn(
- "make_compound() will be deprecated - use the Compound constructor instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return Solid(Solid._make_solid(shell))
-
@classmethod
def _make_solid(cls, shell: Shell) -> TopoDS_Solid:
"""Create a Solid object from the surface shell"""
@@ -7291,8 +7000,8 @@ class Solid(Mixin3D, Shape):
) -> Solid:
"""make loft
- Makes a loft from a list of wires and vertices.
- Vertices can appear only at the beginning or end of the list, but cannot appear consecutively within the list
+ Makes a loft from a list of wires and vertices. Vertices can appear only at the
+ beginning or end of the list, but cannot appear consecutively within the list
nor between wires.
Args:
@@ -7526,12 +7235,12 @@ class Solid(Mixin3D, Shape):
# extrude inner wires
inner_solids = [
- Shape(extrude_aux_spine(w.wrapped, straight_spine_w, aux_spine_w))
+ extrude_aux_spine(w.wrapped, straight_spine_w, aux_spine_w)
for w in inner_wires
]
# combine the inner solids into compound
- inner_comp = Compound._make_compound(inner_solids)
+ inner_comp = _make_topods_compound_from_shapes(inner_solids)
# subtract from the outer solid
return Solid(BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape())
@@ -7567,7 +7276,7 @@ class Solid(Mixin3D, Shape):
direction *= -1
until = Until.NEXT if until == Until.PREVIOUS else Until.LAST
- max_dimension = Compound([section, target_object]).bounding_box().diagonal
+ max_dimension = find_max_dimension([section, target_object])
clipping_direction = (
direction * max_dimension
if until == Until.NEXT
@@ -7632,7 +7341,7 @@ class Solid(Mixin3D, Shape):
"clipping error - extrusion may be incorrect",
stacklevel=2,
)
- extrusion = Shape.fuse(*extrusion_parts)
+ extrusion = Solid.fuse(*extrusion_parts)
return extrusion
@@ -7673,12 +7382,6 @@ class Solid(Mixin3D, Shape):
return cls(revol_builder.Shape())
- _transModeDict = {
- Transition.TRANSFORMED: BRepBuilderAPI_Transformed,
- Transition.ROUND: BRepBuilderAPI_RoundCorner,
- Transition.RIGHT: BRepBuilderAPI_RightCorner,
- }
-
@classmethod
def _set_sweep_mode(
cls,
@@ -7747,7 +7450,7 @@ class Solid(Mixin3D, Shape):
else:
builder.SetMode(is_frenet)
- builder.SetTransitionMode(Solid._transModeDict[transition])
+ builder.SetTransitionMode(Shape._transModeDict[transition])
builder.Add(wire.wrapped, False, rotate)
@@ -7755,7 +7458,7 @@ class Solid(Mixin3D, Shape):
if make_solid:
builder.MakeSolid()
- shapes.append(Shape.cast(builder.Shape()))
+ shapes.append(Mixin3D.cast(builder.Shape()))
return_value, inner_shapes = shapes[0], shapes[1:]
@@ -7815,6 +7518,66 @@ class Solid(Mixin3D, Shape):
return cls(builder.Shape())
+ @classmethod
+ def thicken(
+ cls,
+ surface: Face | Shell,
+ depth: float,
+ normal_override: Optional[VectorLike] = None,
+ ) -> Solid:
+ """Thicken Face or Shell
+
+ Create a solid from a potentially non planar face or shell by thickening along
+ the normals.
+
+ .. image:: thickenFace.png
+
+ Non-planar faces are thickened both towards and away from the center of the sphere.
+
+ Args:
+ depth (float): Amount to thicken face(s), can be positive or negative.
+ normal_override (Vector, optional): Face only. The normal_override vector can be
+ used to indicate which way is 'up', potentially flipping the face normal
+ direction such that many faces with different normals all go in the same
+ direction (direction need only be +/- 90 degrees from the face normal).
+ Defaults to None.
+
+ Raises:
+ RuntimeError: Opencascade internal failures
+
+ Returns:
+ Solid: The resulting Solid object
+ """
+ # Check to see if the normal needs to be flipped
+ adjusted_depth = depth
+ if isinstance(surface, Face) and normal_override is not None:
+ surface_center = surface.center()
+ surface_normal = surface.normal_at(surface_center).normalized()
+ if surface_normal.dot(Vector(normal_override).normalized()) < 0:
+ adjusted_depth = -depth
+
+ offset_builder = BRepOffset_MakeOffset()
+ offset_builder.Initialize(
+ surface.wrapped,
+ Offset=adjusted_depth,
+ Tol=1.0e-5,
+ Mode=BRepOffset_Skin,
+ # BRepOffset_RectoVerso - which describes the offset of a given surface shell along both
+ # sides of the surface but doesn't seem to work
+ Intersection=True,
+ SelfInter=False,
+ Join=GeomAbs_Intersection, # Could be GeomAbs_Arc,GeomAbs_Tangent,GeomAbs_Intersection
+ Thickening=True,
+ RemoveIntEdges=True,
+ )
+ offset_builder.MakeOffsetShape()
+ try:
+ result = Solid(offset_builder.Shape())
+ except StdFail_NotDone as err:
+ raise RuntimeError("Error applying thicken to given surface") from err
+
+ return result
+
class Vertex(Shape):
"""A Vertex in build123d represents a zero-dimensional point in the topological
@@ -7824,7 +7587,7 @@ class Vertex(Shape):
manipulation of 3D shapes. They hold coordinate information and are essential
for constructing complex structures like wires, faces, and solids."""
- _dim = 0
+ order = 0.0
@property
def _dim(self) -> int:
@@ -7889,11 +7652,32 @@ class Vertex(Shape):
super().__init__(ocp_vx)
self.X, self.Y, self.Z = self.to_tuple()
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Self:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # define the shape lookup table for casting
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
@property
def volume(self) -> float:
"""volume - the volume of this Vertex, which is always zero"""
return 0.0
+ def vertices(self) -> ShapeList[Vertex]:
+ """vertices - all the vertices in this Shape"""
+ return ShapeList((self,)) # Vertex is an iterable
+
+ def vertex(self) -> Vertex:
+ """Return the Vertex"""
+ return self
+
def to_tuple(self) -> tuple[float, float, float]:
"""Return vertex as three tuple of floats"""
geom_point = BRep_Tool.Pnt_s(self.wrapped)
@@ -7969,6 +7753,7 @@ class Vertex(Shape):
return new_vertex
def __and__(self, *args, **kwargs):
+ """intersect operator +"""
raise NotImplementedError("Vertices can't be intersected")
def __repr__(self) -> str:
@@ -7979,7 +7764,7 @@ class Vertex(Shape):
Returns:
Vertex as String
"""
- return f"Vertex: ({self.X}, {self.Y}, {self.Z})"
+ return f"Vertex({self.X}, {self.Y}, {self.Z})"
def __iter__(self):
"""Initialize to beginning"""
@@ -8001,6 +7786,21 @@ class Vertex(Shape):
raise StopIteration
return value
+ def transform_shape(self, t_matrix: Matrix) -> Vertex:
+ """Apply affine transform without changing type
+
+ Transforms a copy of this Vertex by the provided 3D affine transformation matrix.
+ Note that not all transformation are supported - primarily designed for translation
+ and rotation. See :transform_geometry: for more comprehensive transformations.
+
+ Args:
+ t_matrix (Matrix): affine transformation matrix
+
+ Returns:
+ Vertex: copy of transformed shape with all objects keeping their type
+ """
+ return Vertex(*t_matrix.multiply(Vector(self)))
+
class Wire(Mixin1D, Shape):
"""A Wire in build123d is a topological entity representing a connected sequence
@@ -8009,7 +7809,7 @@ class Wire(Mixin1D, Shape):
solids. They store information about the connectivity and order of edges,
allowing precise definition of paths within a 3D model."""
- _dim = 1
+ order = 1.5
@property
def _dim(self) -> int:
@@ -8118,7 +7918,12 @@ class Wire(Mixin1D, Shape):
edge, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Wire):
wire, label, color, parent = args[:4] + (None,) * (4 - l_a)
- elif isinstance(args[0], Curve):
+ # elif isinstance(args[0], Curve):
+ elif (
+ hasattr(args[0], "wrapped")
+ and isinstance(args[0].wrapped, TopoDS_Compound)
+ and args[0].wrapped.dim == 1
+ ): # Curve
curve, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Iterable):
edges, sequenced, label, color, parent = args[:5] + (None,) * (5 - l_a)
@@ -8167,8 +7972,8 @@ class Wire(Mixin1D, Shape):
parent=parent,
)
- def _geom_adaptor(self) -> BRepAdaptor_CompCurve:
- """ """
+ def geom_adaptor(self) -> BRepAdaptor_CompCurve:
+ """Return the Geom Comp Curve for this Wire"""
return BRepAdaptor_CompCurve(self.wrapped)
def close(self) -> Wire:
@@ -8204,7 +8009,7 @@ class Wire(Mixin1D, Shape):
edges_in = TopTools_HSequenceOfShape()
wires_out = TopTools_HSequenceOfShape()
- for edge in Compound(wires).edges():
+ for edge in [e for w in wires for e in w.edges()]:
edges_in.Append(edge.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
@@ -8306,9 +8111,9 @@ class Wire(Mixin1D, Shape):
edges_uv_values: list[tuple[float, float, Edge]] = []
found_end_of_wire = False # for finding ends of closed wires
- for e in edges:
- u = self.param_at_point(e.position_at(0))
- v = self.param_at_point(e.position_at(1))
+ for edge in edges:
+ u = self.param_at_point(edge.position_at(0))
+ v = self.param_at_point(edge.position_at(1))
if self.is_closed: # Avoid two beginnings or ends
u = (
1 - u
@@ -8331,33 +8136,33 @@ class Wire(Mixin1D, Shape):
# Edge might be reversed and require flipping parms
u, v = (v, u) if u > v else (u, v)
- edges_uv_values.append((u, v, e))
+ edges_uv_values.append((u, v, edge))
new_edges = []
- for u, v, e in edges_uv_values:
+ for u, v, edge in edges_uv_values:
if v < start or u > end: # Edge not needed
continue
- elif start <= u and v <= end: # keep whole Edge
- new_edges.append(e)
+ if start <= u and v <= end: # keep whole Edge
+ new_edges.append(edge)
elif start >= u and end <= v: # Wire trimmed to single Edge
- u_edge = e.param_at_point(self.position_at(start))
- v_edge = e.param_at_point(self.position_at(end))
+ u_edge = edge.param_at_point(self.position_at(start))
+ v_edge = edge.param_at_point(self.position_at(end))
u_edge, v_edge = (
(v_edge, u_edge) if u_edge > v_edge else (u_edge, v_edge)
)
- new_edges.append(e.trim(u_edge, v_edge))
+ new_edges.append(edge.trim(u_edge, v_edge))
elif start <= u: # keep start of Edge
- u_edge = e.param_at_point(self.position_at(end))
+ u_edge = edge.param_at_point(self.position_at(end))
if u_edge != 0:
- new_edges.append(e.trim(0, u_edge))
+ new_edges.append(edge.trim(0, u_edge))
else: # v <= end keep end of Edge
- v_edge = e.param_at_point(self.position_at(start))
+ v_edge = edge.param_at_point(self.position_at(start))
if v_edge != 1:
- new_edges.append(e.trim(v_edge, 1))
+ new_edges.append(edge.trim(v_edge, 1))
return Wire(new_edges)
@@ -8368,33 +8173,6 @@ class Wire(Mixin1D, Shape):
]
return ShapeList(ordered_edges)
- @classmethod
- def make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> Wire:
- """make_wire
-
- Build a Wire from the provided unsorted Edges. If sequenced is True the
- Edges are placed in such that the end of the nth Edge is coincident with
- the n+1th Edge forming an unbroken sequence. Note that sequencing a list
- is relatively slow.
-
- Args:
- edges (Iterable[Edge]): Edges to assemble
- sequenced (bool, optional): arrange in order. Defaults to False.
-
- Raises:
- ValueError: Edges are disconnected and can't be sequenced.
- RuntimeError: Wire is empty
-
- Returns:
- Wire: assembled edges
- """
- warnings.warn(
- "make_wire() will be deprecated - use the Wire constructor instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return Wire(edges, sequenced)
-
@classmethod
def _make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> TopoDS_Wire:
"""_make_wire
@@ -8569,7 +8347,16 @@ class Wire(Mixin1D, Shape):
Returns:
Wire: filleted wire
"""
- return Face(self).fillet_2d(radius, vertices).outer_wire()
+ # Create a face to fillet
+ unfilleted_face = _make_topods_face_from_wires(self.wrapped)
+ # Fillet the face
+ fillet_builder = BRepFilletAPI_MakeFillet2d(unfilleted_face)
+ for vertex in vertices:
+ fillet_builder.AddFillet(vertex.wrapped, radius)
+ fillet_builder.Build()
+ filleted_face = downcast(fillet_builder.Shape())
+ # Return the outer wire
+ return Wire(BRepTools.OuterWire_s(filleted_face))
def chamfer_2d(
self,
@@ -8592,7 +8379,58 @@ class Wire(Mixin1D, Shape):
Returns:
Wire: chamfered wire
"""
- return Face(self).chamfer_2d(distance, distance2, vertices, edge).outer_wire()
+ reference_edge = edge
+ del edge
+
+ # Create a face to chamfer
+ unchamfered_face = _make_topods_face_from_wires(self.wrapped)
+ chamfer_builder = BRepFilletAPI_MakeFillet2d(unchamfered_face)
+
+ vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
+ TopExp.MapShapesAndAncestors_s(
+ unchamfered_face, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map
+ )
+
+ for v in vertices:
+ edges = vertex_edge_map.FindFromKey(v.wrapped)
+
+ # Index or iterator access to OCP.TopTools.TopTools_ListOfShape is slow on M1 macs
+ # Using First() and Last() to omit
+ edges = [edges.First(), edges.Last()]
+
+ # Need to wrap in b3d objects for comparison to work
+ # ref.wrapped != edge.wrapped but ref == edge
+ edges = [Edge(e) for e in edges]
+
+ edge1, edge2 = Wire.order_chamfer_edges(reference_edge, edges)
+
+ chamfer_builder.AddChamfer(
+ TopoDS.Edge_s(edge1.wrapped),
+ TopoDS.Edge_s(edge2.wrapped),
+ distance,
+ distance2,
+ )
+
+ chamfer_builder.Build()
+ chamfered_face = chamfer_builder.Shape()
+ # Fix the shape
+ shape_fix = ShapeFix_Shape(chamfered_face)
+ shape_fix.Perform()
+ chamfered_face = downcast(shape_fix.Shape())
+ # Return the outer wire
+ return Wire(BRepTools.OuterWire_s(chamfered_face))
+
+ @staticmethod
+ def order_chamfer_edges(reference_edge, edges) -> tuple[Edge, Edge]:
+ """Order the edges of a chamfer relative to a reference Edge"""
+ if reference_edge:
+ if reference_edge not in edges:
+ raise ValueError("One or more vertices are not part of edge")
+ edge1 = reference_edge
+ edge2 = [x for x in edges if x != reference_edge][0]
+ else:
+ edge1, edge2 = edges
+ return edge1, edge2
@classmethod
def make_rect(
@@ -8785,13 +8623,13 @@ class Wire(Mixin1D, Shape):
if direction_vector is not None:
projection_object = BRepProj_Projection(
self.wrapped,
- Shape.cast(target_object.wrapped).wrapped,
+ target_object.wrapped,
gp_Dir(*direction_vector.to_tuple()),
)
else:
projection_object = BRepProj_Projection(
self.wrapped,
- Shape.cast(target_object.wrapped).wrapped,
+ target_object.wrapped,
gp_Pnt(*center_point.to_tuple()),
)
@@ -8878,48 +8716,20 @@ class Joint(ABC):
@abstractmethod
def connect_to(self, other: Joint):
"""All derived classes must provide a connect_to method"""
- raise NotImplementedError
@abstractmethod
def relative_to(self, other: Joint) -> Location:
"""Return relative location to another joint"""
- raise NotImplementedError
@property
@abstractmethod
- def location(self) -> Location: # pragma: no cover
+ def location(self) -> Location:
"""Location of joint"""
- raise NotImplementedError
@property
@abstractmethod
- def symbol(self) -> Compound: # pragma: no cover
+ def symbol(self) -> Compound:
"""A CAD object positioned in global space to illustrate the joint"""
- raise NotImplementedError
-
-
-def _thicken(obj: TopoDS_Shape, depth: float):
- solid = BRepOffset_MakeOffset()
- solid.Initialize(
- obj,
- Offset=depth,
- Tol=1.0e-5,
- Mode=BRepOffset_Skin,
- # BRepOffset_RectoVerso - which describes the offset of a given surface shell along both
- # sides of the surface but doesn't seem to work
- Intersection=True,
- SelfInter=False,
- Join=GeomAbs_Intersection, # Could be GeomAbs_Arc,GeomAbs_Tangent,GeomAbs_Intersection
- Thickening=True,
- RemoveIntEdges=True,
- )
- solid.MakeOffsetShape()
- try:
- result = Solid(solid.Shape())
- except StdFail_NotDone as err:
- raise RuntimeError("Error applying thicken to given Face") from err
-
- return result.clean()
def _make_loft(
@@ -8929,8 +8739,8 @@ def _make_loft(
) -> TopoDS_Shape:
"""make loft
- Makes a loft from a list of wires and vertices.
- Vertices can appear only at the beginning or end of the list, but cannot appear consecutively within the list
+ Makes a loft from a list of wires and vertices. Vertices can appear only at the
+ beginning or end of the list, but cannot appear consecutively within the list
nor between wires.
Args:
@@ -8945,14 +8755,15 @@ def _make_loft(
"""
if len(objs) < 2:
raise ValueError("More than one wire is required")
- vertices = [obj for obj in objs if isinstance(obj, Vertex)]
+ vertices = [obj for obj in objs if isinstance(obj.wrapped, TopoDS_Vertex)]
vertex_count = len(vertices)
if vertex_count > 2:
raise ValueError("Only two vertices are allowed")
if vertex_count == 1 and not (
- isinstance(objs[0], Vertex) or isinstance(objs[-1], Vertex)
+ isinstance(objs[0].wrapped, TopoDS_Vertex)
+ or isinstance(objs[-1].wrapped, TopoDS_Vertex)
):
raise ValueError(
"The vertex must be either at the beginning or end of the list"
@@ -8963,7 +8774,10 @@ def _make_loft(
raise ValueError(
"You can't have only 2 vertices to loft; try adding some wires"
)
- if not (isinstance(objs[0], Vertex) and isinstance(objs[-1], Vertex)):
+ if not (
+ isinstance(objs[0].wrapped, TopoDS_Vertex)
+ and isinstance(objs[-1].wrapped, TopoDS_Vertex)
+ ):
raise ValueError(
"The vertices must be at the beginning and end of the list"
)
@@ -8971,9 +8785,9 @@ def _make_loft(
loft_builder = BRepOffsetAPI_ThruSections(filled, ruled)
for obj in objs:
- if isinstance(obj, Vertex):
+ if isinstance(obj.wrapped, TopoDS_Vertex):
loft_builder.AddVertex(obj.wrapped)
- elif isinstance(obj, Wire):
+ elif isinstance(obj.wrapped, TopoDS_Wire):
loft_builder.AddWire(obj.wrapped)
loft_builder.Build()
@@ -8991,7 +8805,7 @@ def downcast(obj: TopoDS_Shape) -> TopoDS_Shape:
"""
- f_downcast: Any = downcast_LUT[shapetype(obj)]
+ f_downcast: Any = Shape.downcast_LUT[shapetype(obj)]
return_value = f_downcast(obj)
return return_value
@@ -9038,21 +8852,22 @@ def fix(obj: TopoDS_Shape) -> TopoDS_Shape:
return downcast(shape_fix.Shape())
-def isclose_b(a: float, b: float, rel_tol=1e-9, abs_tol=1e-14) -> bool:
+def isclose_b(x: float, y: float, rel_tol=1e-9, abs_tol=1e-14) -> bool:
"""Determine whether two floating point numbers are close in value.
Overridden abs_tol default for the math.isclose function.
Args:
- a (float): First value to compare
- b (float): Second value to compare
- rel_tol (float, optional): Maximum difference for being considered "close", relative to the
- magnitude of the input values. Defaults to 1e-9.
- abs_tol (float, optional): Maximum difference for being considered "close", regardless of the
- magnitude of the input values. Defaults to 1e-14 (unlike math.isclose which defaults to zero).
+ x (float): First value to compare
+ y (float): Second value to compare
+ rel_tol (float, optional): Maximum difference for being considered "close",
+ relative to the magnitude of the input values. Defaults to 1e-9.
+ abs_tol (float, optional): Maximum difference for being considered "close",
+ regardless of the magnitude of the input values. Defaults to 1e-14
+ (unlike math.isclose which defaults to zero).
Returns: True if a is close in value to b, and False otherwise.
"""
- return isclose(a, b, rel_tol=rel_tol, abs_tol=abs_tol)
+ return isclose(x, y, rel_tol=rel_tol, abs_tol=abs_tol)
def shapetype(obj: TopoDS_Shape) -> TopAbs_ShapeEnum:
@@ -9065,7 +8880,7 @@ def shapetype(obj: TopoDS_Shape) -> TopAbs_ShapeEnum:
def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum:
"""Return Shape's TopAbs_ShapeEnum"""
- if isinstance(obj, Compound):
+ if isinstance(obj.wrapped, TopoDS_Compound):
shapetypes = set(shapetype(o.wrapped) for o in obj)
if len(shapetypes) == 1:
result = shapetypes.pop()
@@ -9167,9 +8982,14 @@ def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]:
operation.SetRunParallel(True)
operation.Build()
- edges = Shape.cast(operation.Shape()).edges()
- for edge in edges:
- edge.topo_parent = combined
+ edges = []
+ explorer = TopExp_Explorer(operation.Shape(), TopAbs_ShapeEnum.TopAbs_EDGE)
+ while explorer.More():
+ found_edge = combined.__class__.cast(downcast(explorer.Current()))
+ found_edge.topo_parent = combined
+ edges.append(found_edge)
+ explorer.Next()
+
return ShapeList(edges)
@@ -9195,11 +9015,11 @@ def topo_explore_connected_edges(edge: Edge, parent: Shape = None) -> ShapeList[
def topo_explore_common_vertex(
- edge1: Union[Edge, TopoDS_Edge], edge2: Union[Edge, TopoDS_Edge]
-) -> Union[Vertex, None]:
+ edge1: Edge | TopoDS_Edge, edge2: Edge | TopoDS_Edge
+) -> Optional[Vertex]:
"""Given two edges, find the common vertex"""
- topods_edge1 = edge1.wrapped if isinstance(edge1, Edge) else edge1
- topods_edge2 = edge2.wrapped if isinstance(edge2, Edge) else edge2
+ topods_edge1 = edge1 if isinstance(edge1, TopoDS_Edge) else edge1.wrapped
+ topods_edge2 = edge2 if isinstance(edge2, TopoDS_Edge) else edge2.wrapped
# Explore vertices of the first edge
vert_exp = TopExp_Explorer(topods_edge1, ta.TopAbs_VERTEX)
@@ -9223,7 +9043,7 @@ def topo_explore_common_vertex(
def unwrap_topods_compound(
compound: TopoDS_Compound, fully: bool = True
-) -> Union[TopoDS_Compound, TopoDS_Shape]:
+) -> TopoDS_Compound | TopoDS_Shape:
"""Strip unnecessary Compound wrappers
Args:
@@ -9232,7 +9052,7 @@ def unwrap_topods_compound(
wrappers (otherwise one TopoDS_Compound is left). Defaults to True.
Returns:
- Union[TopoDS_Compound, TopoDS_Shape]: base shape
+ TopoDS_Compound | TopoDS_Shape: base shape
"""
if compound.NbChildren() == 1:
iterator = TopoDS_Iterator(compound)
@@ -9248,6 +9068,242 @@ def unwrap_topods_compound(
return compound
+def get_top_level_topods_shapes(topods_shape: TopoDS_Shape) -> ShapeList[TopoDS_Shape]:
+ """
+ Retrieve the first level of child shapes from the shape.
+
+ This method collects all the non-compound shapes directly contained in the
+ current shape. If the wrapped shape is a `TopoDS_Compound`, it traverses
+ its immediate children and collects all shapes that are not further nested
+ compounds. Nested compounds are traversed to gather their non-compound elements
+ without returning the nested compound itself.
+
+ Returns:
+ ShapeList[TopoDS_Shape]: A list of all first-level non-compound child shapes.
+
+ Example:
+ If the current shape is a compound containing both simple shapes
+ (e.g., edges, vertices) and other compounds, the method returns a list
+ of only the simple shapes directly contained at the top level.
+ """
+ if topods_shape is None:
+ return ShapeList()
+
+ first_level_shapes = []
+ stack = [topods_shape]
+
+ while stack:
+ current_shape = stack.pop()
+ if isinstance(current_shape, TopoDS_Compound):
+ iterator = TopoDS_Iterator()
+ iterator.Initialize(current_shape)
+ while iterator.More():
+ child_shape = downcast(iterator.Value())
+ if isinstance(child_shape, TopoDS_Compound):
+ # Traverse further into the compound
+ stack.append(child_shape)
+ else:
+ # Add non-compound shape
+ first_level_shapes.append(child_shape)
+ iterator.Next()
+ else:
+ first_level_shapes.append(current_shape)
+
+ return ShapeList(first_level_shapes)
+
+
+def _topods_bool_op(
+ args: Iterable[TopoDS_Shape],
+ tools: Iterable[TopoDS_Shape],
+ operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter,
+) -> TopoDS_Shape:
+ """Generic boolean operation for TopoDS_Shapes
+
+ Args:
+ args: Iterable[TopoDS_Shape]:
+ tools: Iterable[TopoDS_Shape]:
+ operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter:
+
+ Returns: TopoDS_Shape
+
+ """
+
+ arg = TopTools_ListOfShape()
+ for obj in args:
+ arg.Append(obj)
+
+ tool = TopTools_ListOfShape()
+ for obj in tools:
+ tool.Append(obj)
+
+ operation.SetArguments(arg)
+ operation.SetTools(tool)
+
+ operation.SetRunParallel(True)
+ operation.Build()
+
+ result = downcast(operation.Shape())
+ # Remove unnecessary TopoDS_Compound around single shape
+ if isinstance(result, TopoDS_Compound):
+ result = unwrap_topods_compound(result, True)
+
+ return result
+
+
+def _make_topods_face_from_wires(
+ outer_wire: TopoDS_Wire, inner_wires: Optional[Sequence[TopoDS_Wire]] = None
+) -> TopoDS_Face:
+ """_make_topods_face_from_wires
+
+ Makes a planar face from one or more wires
+
+ Args:
+ outer_wire (TopoDS_Wire): closed perimeter wire
+ inner_wires (Sequence[TopoDS_Wire], optional): holes. Defaults to None.
+
+ Raises:
+ ValueError: outer wire not closed
+ ValueError: wires not planar
+ ValueError: inner wire not closed
+ ValueError: internal error
+
+ Returns:
+ TopoDS_Face: planar face potentially with holes
+ """
+ if inner_wires and not BRep_Tool.IsClosed_s(outer_wire):
+ raise ValueError("Cannot build face(s): outer wire is not closed")
+ inner_wires = inner_wires if inner_wires else []
+
+ # check if wires are coplanar
+ verification_compound = _make_topods_compound_from_shapes(
+ [outer_wire] + inner_wires
+ )
+ if not BRepLib_FindSurface(verification_compound, OnlyPlane=True).Found():
+ raise ValueError("Cannot build face(s): wires not planar")
+
+ # fix outer wire
+ sf_s = ShapeFix_Shape(outer_wire)
+ sf_s.Perform()
+ topo_wire = TopoDS.Wire_s(sf_s.Shape())
+
+ face_builder = BRepBuilderAPI_MakeFace(topo_wire, True)
+
+ for inner_wire in inner_wires:
+ if not BRep_Tool.IsClosed_s(inner_wire):
+ raise ValueError("Cannot build face(s): inner wire is not closed")
+ face_builder.Add(inner_wire)
+
+ face_builder.Build()
+
+ if not face_builder.IsDone():
+ raise ValueError(f"Cannot build face(s): {face_builder.Error()}")
+
+ face = face_builder.Face()
+
+ sf_f = ShapeFix_Face(face)
+ sf_f.FixOrientation()
+ sf_f.Perform()
+
+ return downcast(sf_f.Result())
+
+
+def _sew_topods_faces(faces: Sequence[TopoDS_Face]) -> TopoDS_Shape:
+ """Sew faces into a shell if possible"""
+ shell_builder = BRepBuilderAPI_Sewing()
+ for face in faces:
+ shell_builder.Add(face)
+ shell_builder.Perform()
+ return downcast(shell_builder.SewedShape())
+
+
+def _make_topods_compound_from_shapes(
+ occt_shapes: Sequence[TopoDS_Shape],
+) -> TopoDS_Compound:
+ """Create an OCCT TopoDS_Compound
+
+ Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects
+
+ Args:
+ occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes
+
+ Returns:
+ TopoDS_Compound: OCCT compound
+ """
+ comp = TopoDS_Compound()
+ comp_builder = TopoDS_Builder()
+ comp_builder.MakeCompound(comp)
+
+ for shape in occt_shapes:
+ comp_builder.Add(comp, shape)
+
+ return comp
+
+
+def find_max_dimension(shapes: Shape | Iterable[Shape]) -> float:
+ """Return the maximum dimension of one or more shapes"""
+ shapes = shapes if isinstance(shapes, Iterable) else [shapes]
+ composite = _make_topods_compound_from_shapes([s.wrapped for s in shapes])
+ bbox = BoundBox.from_topo_ds(composite, tolerance=TOLERANCE, optimal=True)
+ return bbox.diagonal
+
+
+def _topods_entities(shape: TopoDS_Shape, topo_type: Shapes) -> list[TopoDS_Shape]:
+ """Return the TopoDS_Shapes of topo_type from this TopoDS_Shape"""
+ out = {} # using dict to prevent duplicates
+
+ explorer = TopExp_Explorer(shape, Shape.inverse_shape_LUT[topo_type])
+
+ while explorer.More():
+ item = explorer.Current()
+ out[item.HashCode(HASH_CODE_MAX)] = (
+ item # needed to avoid pseudo-duplicate entities
+ )
+ explorer.Next()
+
+ return list(out.values())
+
+
+def _extrude_topods_shape(obj: TopoDS_Shape, direction: VectorLike) -> TopoDS_Shape:
+ """extrude
+
+ Extrude a Shape in the provided direction.
+ * Vertices generate Edges
+ * Edges generate Faces
+ * Wires generate Shells
+ * Faces generate Solids
+ * Shells generate Compounds
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ TopoDS_Shape: extruded shape
+ """
+ direction = Vector(direction)
+
+ if obj is None or not isinstance(
+ obj,
+ (TopoDS_Vertex, TopoDS_Edge, TopoDS_Wire, TopoDS_Face, TopoDS_Shell),
+ ):
+ raise ValueError(f"extrude not supported for {type(obj)}")
+
+ prism_builder = BRepPrimAPI_MakePrism(obj, direction.wrapped)
+ extrusion = downcast(prism_builder.Shape())
+ shape_type = extrusion.ShapeType()
+ if shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID:
+ solids = []
+ explorer = TopExp_Explorer(extrusion, TopAbs_ShapeEnum.TopAbs_SOLID)
+ while explorer.More():
+ solids.append(downcast(explorer.Current()))
+ explorer.Next()
+ extrusion = _make_topods_compound_from_shapes(solids)
+ return extrusion
+
+
class SkipClean:
"""Skip clean context for use in operator driven code where clean=False wouldn't work"""
diff --git a/tests/test_algebra.py b/tests/test_algebra.py
index 6b4bbbc..3d354a6 100644
--- a/tests/test_algebra.py
+++ b/tests/test_algebra.py
@@ -1,6 +1,5 @@
import math
import unittest
-import pytest
from build123d import *
from build123d.topology import Shape
@@ -553,7 +552,7 @@ class AlgebraTests(unittest.TestCase):
self.assertAlmostEqual(l.length, 3, 5)
l2 = e1 + e3
- self.assertTrue(isinstance(l2, Compound))
+ self.assertTrue(isinstance(l2, list))
def test_curve_plus_nothing(self):
e1 = Edge.make_line((0, 1), (1, 1))
@@ -626,7 +625,8 @@ class AlgebraTests(unittest.TestCase):
def test_part_minus_empty(self):
b = Box(1, 2, 3)
r = b - Part()
- self.assertEqual(b.wrapped, r.wrapped)
+ self.assertAlmostEqual(b.volume, r.volume, 5)
+ self.assertEqual(r._dim, 3)
def test_empty_and_part(self):
b = Box(1, 2, 3)
@@ -660,7 +660,8 @@ class AlgebraTests(unittest.TestCase):
def test_sketch_minus_empty(self):
b = Rectangle(1, 2)
r = b - Sketch()
- self.assertEqual(b.wrapped, r.wrapped)
+ self.assertAlmostEqual(b.area, r.area, 5)
+ self.assertEqual(r._dim, 2)
def test_empty_and_sketch(self):
b = Rectangle(1, 3)
@@ -823,6 +824,7 @@ class LocationTests(unittest.TestCase):
# on plane, located to grid position, and finally rotated
c_plane = plane * outer_loc * rotations[i]
s += c_plane * Circle(1)
+ s = Sketch(s.faces())
for loc in PolarLocations(0.8, (i + 3) * 2):
# Use polar locations on c_plane
diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py
index b561409..205bebf 100644
--- a/tests/test_build_sketch.py
+++ b/tests/test_build_sketch.py
@@ -480,8 +480,6 @@ class TestBuildSketchObjects(unittest.TestCase):
line = Polyline((0, 0), (10, 10), (20, 10))
test = trace(line, 4)
- self.assertEqual(len(test.faces()), 3)
- test = trace(line, 4).clean()
self.assertEqual(len(test.faces()), 1)
def test_full_round(self):
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 9eba3db..0ae4030 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -66,6 +66,7 @@ from build123d.geometry import (
Location,
LocationEncoder,
Matrix,
+ Plane,
Pos,
Rot,
Rotation,
@@ -78,7 +79,6 @@ from build123d.topology import (
Compound,
Edge,
Face,
- Plane,
Shape,
ShapeList,
Shell,
@@ -419,10 +419,10 @@ class TestBoundBox(DirectApiTestCase):
# Test creation of a bounding box from a shape - note the low accuracy comparison
# as the box is a little larger than the shape
- bb1 = BoundBox._from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
+ bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
self.assertVectorAlmostEquals(bb1.size, (2, 2, 1), 1)
- bb2 = BoundBox._from_topo_ds(
+ bb2 = BoundBox.from_topo_ds(
Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False
)
self.assertTrue(bb2.is_inside(bb1))
@@ -459,11 +459,11 @@ class TestBoundBox(DirectApiTestCase):
class TestCadObjects(DirectApiTestCase):
def _make_circle(self):
circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0)
- return Shape.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
+ return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
def _make_ellipse(self):
ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0)
- return Shape.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge())
+ return Edge.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge())
def test_edge_wrapper_center(self):
e = self._make_circle()
@@ -608,7 +608,7 @@ class TestCadObjects(DirectApiTestCase):
self.assertVectorAlmostEquals(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
def test_vertices(self):
- e = Shape.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
+ e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
self.assertEqual(2, len(e.vertices()))
def test_edge_wrapper_radius(self):
@@ -849,8 +849,8 @@ class TestCompound(DirectApiTestCase):
# N.B. b and bb overlap but still add to Compound volume
def test_constructor(self):
- with self.assertRaises(ValueError):
- Compound(bob="fred")
+ with self.assertRaises(TypeError):
+ Compound(foo="bar")
def test_len(self):
self.assertEqual(len(Compound()), 0)
@@ -1139,7 +1139,7 @@ class TestEdge(DirectApiTestCase):
self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5)
def test_init(self):
- with self.assertRaises(ValueError):
+ with self.assertRaises(TypeError):
Edge(direction=(1, 0, 0))
@@ -1354,11 +1354,11 @@ class TestFace(DirectApiTestCase):
for y in range(11)
]
surface = Face.make_surface_from_array_of_points(pnts)
- solid = surface.thicken(1)
+ solid = Solid.thicken(surface, 1)
self.assertAlmostEqual(solid.volume, 101.59, 2)
square = Face.make_rect(10, 10)
- bbox = square.thicken(1, normal_override=(0, 0, -1)).bounding_box()
+ bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box()
self.assertVectorAlmostEquals(bbox.min, (-5, -5, -1), 5)
self.assertVectorAlmostEquals(bbox.max, (5, 5, 0), 5)
@@ -1596,11 +1596,11 @@ class TestFunctions(unittest.TestCase):
# unwrap fully
c0 = Compound([b1])
c1 = Compound([c0])
- result = Shape.cast(unwrap_topods_compound(c1.wrapped, True))
+ result = Compound.cast(unwrap_topods_compound(c1.wrapped, True))
self.assertTrue(isinstance(result, Solid))
# unwrap not fully
- result = Shape.cast(unwrap_topods_compound(c1.wrapped, False))
+ result = Compound.cast(unwrap_topods_compound(c1.wrapped, False))
self.assertTrue(isinstance(result, Compound))
@@ -1632,18 +1632,18 @@ class TestImportExport(DirectApiTestCase):
self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5)
-class TestJupyter(DirectApiTestCase):
- def test_repr_javascript(self):
- shape = Solid.make_box(1, 1, 1)
+# class TestJupyter(DirectApiTestCase):
+# def test_repr_javascript(self):
+# shape = Solid.make_box(1, 1, 1)
- # Test no exception on rendering to js
- js1 = shape._repr_javascript_()
+# # Test no exception on rendering to js
+# js1 = shape._repr_javascript_()
- assert "function render" in js1
+# assert "function render" in js1
- def test_display_error(self):
- with self.assertRaises(AttributeError):
- display(Vector())
+# def test_display_error(self):
+# with self.assertRaises(AttributeError):
+# display(Vector())
class TestLocation(DirectApiTestCase):
@@ -1930,6 +1930,19 @@ class TestLocation(DirectApiTestCase):
self.assertTrue(isinstance(i, Vertex))
self.assertVectorAlmostEquals(Vector(i), (0.5, 0.5, 0.5), 5)
+ e1 = Edge.make_line((0, -1), (2, 1))
+ e2 = Edge.make_line((0, 1), (2, -1))
+ e3 = Edge.make_line((0, 0), (2, 0))
+
+ i = e1.intersect(e2, e3)
+ self.assertTrue(isinstance(i, Vertex))
+ self.assertVectorAlmostEquals(i, (1, 0, 0), 5)
+
+ e4 = Edge.make_line((1, -1), (1, 1))
+ e5 = Edge.make_line((2, -1), (2, 1))
+ i = e3.intersect(e4, e5)
+ self.assertIsNone(i)
+
class TestMatrix(DirectApiTestCase):
def test_matrix_creation_and_access(self):
@@ -2946,6 +2959,7 @@ class TestProjection(DirectApiTestCase):
projected_text = sphere.project_faces(
faces=Compound.make_text("dog", font_size=14),
path=arch_path,
+ start=0.01, # avoid a character spanning the sphere edge
)
self.assertEqual(len(projected_text.solids()), 0)
self.assertEqual(len(projected_text.faces()), 3)
@@ -3051,9 +3065,9 @@ class TestShape(DirectApiTestCase):
def test_split(self):
shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM)
- self.assertEqual(len(split_shape.solids()), 2)
- self.assertAlmostEqual(split_shape.volume, 0.25, 5)
- self.assertTrue(isinstance(split_shape, Compound))
+ self.assertTrue(isinstance(split_shape, list))
+ self.assertEqual(len(split_shape), 2)
+ self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5)
split_shape = shape.split(Plane.XY, keep=Keep.TOP)
self.assertEqual(len(split_shape.solids()), 1)
self.assertTrue(isinstance(split_shape, Solid))
@@ -3068,16 +3082,17 @@ class TestShape(DirectApiTestCase):
def test_split_by_non_planar_face(self):
box = Solid.make_box(1, 1, 1)
tool = Circle(1).wire()
- tool_shell: Shell = Shape.extrude(tool, Vector(0, 0, 1))
- split = box.split(tool_shell, keep=Keep.BOTH)
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ top, bottom = box.split(tool_shell, keep=Keep.BOTH)
- self.assertEqual(len(split.solids()), 2)
- self.assertGreater(split.solids()[0].volume, split.solids()[1].volume)
+ self.assertFalse(top is None)
+ self.assertFalse(bottom is None)
+ self.assertGreater(top.volume, bottom.volume)
def test_split_by_shell(self):
box = Solid.make_box(5, 5, 1)
tool = Wire.make_rect(4, 4)
- tool_shell: Shell = Shape.extrude(tool, Vector(0, 0, 1))
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
split = box.split(tool_shell, keep=Keep.TOP)
inner_vol = 2 * 2
outer_vol = 5 * 5
@@ -3097,41 +3112,28 @@ class TestShape(DirectApiTestCase):
ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0]
ring_outerwire = ring_projected.outer_wire()
inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH)
+ if isinstance(inside1, list):
+ inside1 = Compound(inside1)
+ if isinstance(outside1, list):
+ outside1 = Compound(outside1)
self.assertLess(inside1.area, outside1.area)
self.assertEqual(len(outside1.faces()), 2)
# Test 2 - extract multiple faces
- with BuildPart() as cross:
- with BuildSketch(Pos(Z=-5) * Rot(Z=-45)) as skt:
- Rectangle(5, 1, align=Align.MIN)
- Rectangle(1, 5, align=Align.MIN)
- fillet(skt.vertices(), 0.3)
- extrude(amount=10)
- target2 = cross.part
+ target2 = Box(1, 10, 10)
square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0)))
- square_projected = square.project_to_shape(cross.part, (-1, 0, 0))[0]
- projected_edges = square_projected.edges().sort_by(SortBy.DISTANCE)[2:]
- projected_perimeter = Wire(projected_edges)
- inside2 = target2.split_by_perimeter(projected_perimeter, Keep.INSIDE)
- self.assertTrue(isinstance(inside2, Shell))
-
- # Test 3 - Invalid, wire on shape edge
- target3 = Solid.make_cylinder(5, 10, Plane((0, 0, -5)))
- square_projected = square.project_to_shape(target3, (-1, 0, 0))[0].unwrap(
- fully=True
+ square_projected = square.project_to_shape(target2, (-1, 0, 0))[0]
+ outside2 = target2.split_by_perimeter(
+ square_projected.outer_wire(), Keep.OUTSIDE
)
- project_perimeter = square_projected.outer_wire()
- inside3 = target3.split_by_perimeter(project_perimeter, Keep.INSIDE)
- self.assertIsNone(inside3)
- outside3 = target3.split_by_perimeter(project_perimeter, Keep.OUTSIDE)
- self.assertAlmostEqual(outside3.area, target3.shell().area, 5)
+ self.assertTrue(isinstance(outside2, Shell))
# Test 4 - invalid inputs
with self.assertRaises(ValueError):
- _, _ = target2.split_by_perimeter(projected_perimeter.edges()[0], Keep.BOTH)
+ _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH)
with self.assertRaises(ValueError):
- _, _ = target3.split_by_perimeter(projected_perimeter, Keep.TOP)
+ _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP)
def test_distance(self):
sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
@@ -3332,7 +3334,7 @@ class TestShape(DirectApiTestCase):
s = Solid.make_sphere(1).solid()
self.assertTrue(isinstance(s, Solid))
with self.assertWarns(UserWarning):
- Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH).solid()
+ Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
def test_manifold(self):
self.assertTrue(Solid.make_box(1, 1, 1).is_manifold)
@@ -3498,6 +3500,17 @@ class TestShapeList(DirectApiTestCase):
self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2)
self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4)
+ line = Edge.make_line((0, 0, 0), (1, 1, 2))
+ vertices_by_line = box.vertices().group_by(line)
+ self.assertEqual(len(vertices_by_line[0]), 1)
+ self.assertEqual(len(vertices_by_line[1]), 2)
+ self.assertEqual(len(vertices_by_line[2]), 1)
+ self.assertEqual(len(vertices_by_line[3]), 1)
+ self.assertEqual(len(vertices_by_line[4]), 2)
+ self.assertEqual(len(vertices_by_line[5]), 1)
+ self.assertVectorAlmostEquals(vertices_by_line[0][0], (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(vertices_by_line[-1][0], (1, 1, 2), 5)
+
with BuildPart() as boxes:
with GridLocations(10, 10, 3, 3):
Box(1, 1, 1)
@@ -3549,34 +3562,34 @@ class TestShapeList(DirectApiTestCase):
def test_group_by_str_repr(self):
nonagon = RegularPolygon(5, 9)
- expected = [
- "[[],",
- " [,",
- " ],",
- " [,",
- " ],",
- " [,",
- " ],",
- " [,",
- " ]]",
- ]
+ # TODO: re-enable this test once the topology refactor complete
+ # expected = [
+ # "[[],",
+ # " [,",
+ # " ],",
+ # " [,",
+ # " ],",
+ # " [,",
+ # " ],",
+ # " [,",
+ # " ]]",
+ # ]
+ # self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
- self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
-
- expected_repr = (
- "[[],"
- " [,"
- " ],"
- " [,"
- " ],"
- " [,"
- " ],"
- " [,"
- " ]]"
- )
- self.assertDunderReprEqual(
- repr(nonagon.edges().group_by(Axis.X)), expected_repr
- )
+ # expected_repr = (
+ # "[[],"
+ # " [,"
+ # " ],"
+ # " [,"
+ # " ],"
+ # " [,"
+ # " ],"
+ # " [,"
+ # " ]]"
+ # )
+ # self.assertDunderReprEqual(
+ # repr(nonagon.edges().group_by(Axis.X)), expected_repr
+ # )
f = io.StringIO()
p = pretty.PrettyPrinter(f)
@@ -3732,8 +3745,8 @@ class TestShells(DirectApiTestCase):
self.assertAlmostEqual(nm_shell.volume, 0, 5)
def test_constructor(self):
- with self.assertRaises(ValueError):
- Shell(bob="fred")
+ with self.assertRaises(TypeError):
+ Shell(foo="bar")
x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5))
surface = sweep(x_section, Circle(5).wire())
@@ -3760,8 +3773,8 @@ class TestShells(DirectApiTestCase):
self.assertEqual(len(sweep_e_w.faces()), 2)
self.assertEqual(len(sweep_w_e.faces()), 2)
self.assertEqual(len(sweep_c2_c1.faces()), 2)
- self.assertEqual(len(sweep_w_w.faces()), 4)
- self.assertEqual(len(sweep_c2_c2.faces()), 4)
+ self.assertEqual(len(sweep_w_w.faces()), 3) # 3 with clean, 4 without
+ self.assertEqual(len(sweep_c2_c2.faces()), 3) # 3 with clean, 4 without
def test_make_loft(self):
r = 3
@@ -3775,8 +3788,8 @@ class TestShells(DirectApiTestCase):
def test_thicken(self):
rect = Wire.make_rect(10, 5)
- shell: Shell = Shape.extrude(rect, Vector(0, 0, 3))
- thick = shell.thicken(1)
+ shell: Shell = Shell.extrude(rect, Vector(0, 0, 3))
+ thick = Solid.thicken(shell, 1)
self.assertEqual(isinstance(thick, Solid), True)
inner_vol = 3 * 10 * 5
@@ -3953,8 +3966,8 @@ class TestSolid(DirectApiTestCase):
self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5)
def test_constructor(self):
- with self.assertRaises(ValueError):
- Solid(bob="fred")
+ with self.assertRaises(TypeError):
+ Solid(foo="bar")
class TestVector(DirectApiTestCase):
@@ -4273,7 +4286,7 @@ class TestVertex(DirectApiTestCase):
test_vertex - [1, 2, 3]
def test_vertex_str(self):
- self.assertEqual(str(Vertex(0, 0, 0)), "Vertex: (0.0, 0.0, 0.0)")
+ self.assertEqual(str(Vertex(0, 0, 0)), "Vertex(0.0, 0.0, 0.0)")
def test_vertex_to_vector(self):
self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector)
diff --git a/tests/test_joints.py b/tests/test_joints.py
index dc61498..f42187f 100644
--- a/tests/test_joints.py
+++ b/tests/test_joints.py
@@ -34,7 +34,7 @@ from build123d.build_enums import Align, CenterOf, GeomType
from build123d.build_common import Mode
from build123d.build_part import BuildPart
from build123d.build_sketch import BuildSketch
-from build123d.geometry import Axis, Location, Rotation, Vector, VectorLike
+from build123d.geometry import Axis, Location, Plane, Rotation, Vector, VectorLike
from build123d.joints import (
BallJoint,
CylindricalJoint,
@@ -45,7 +45,7 @@ from build123d.joints import (
from build123d.objects_part import Box, Cone, Cylinder, Sphere
from build123d.objects_sketch import Circle
from build123d.operations_part import extrude
-from build123d.topology import Edge, Plane, Solid
+from build123d.topology import Edge, Solid
class DirectApiTestCase(unittest.TestCase):
From a34f3403972bed2ee6ad4dcc48750823a5cb8803 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 9 Dec 2024 11:52:20 -0500
Subject: [PATCH 034/518] Fixing drafting problem only tested with pytest
---
src/build123d/drafting.py | 7 +++++--
src/build123d/operations_sketch.py | 7 ++++++-
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py
index 4f824a7..171e669 100644
--- a/src/build123d/drafting.py
+++ b/src/build123d/drafting.py
@@ -50,7 +50,7 @@ from build123d.objects_curve import Line, TangentArc
from build123d.objects_sketch import BaseSketchObject, Polygon, Text
from build123d.operations_generic import fillet, mirror, sweep
from build123d.operations_sketch import make_face, trace
-from build123d.topology import Compound, Edge, Sketch, Vertex, Wire
+from build123d.topology import Compound, Curve, Edge, Sketch, Vertex, Wire
class ArrowHead(BaseSketchObject):
@@ -704,7 +704,10 @@ class TechnicalDrawing(BaseSketchObject):
)
bf_pnt3 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (1 / 3)
bf_pnt4 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (2 / 3)
- box_frame_curve += Edge.make_line(bf_pnt3, (bf_pnt2.X, bf_pnt3.Y))
+ box_frame_curve = Curve() + [
+ box_frame_curve,
+ Edge.make_line(bf_pnt3, (bf_pnt2.X, bf_pnt3.Y)),
+ ]
box_frame_curve += Edge.make_line(bf_pnt4, (bf_pnt2.X, bf_pnt4.Y))
bf_pnt5 = box_frame_curve.edges().sort_by(Axis.Y)[-1] @ (1 / 3)
bf_pnt6 = box_frame_curve.edges().sort_by(Axis.Y)[-1] @ (2 / 3)
diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py
index 35e65b9..78e8936 100644
--- a/src/build123d/operations_sketch.py
+++ b/src/build123d/operations_sketch.py
@@ -305,4 +305,9 @@ def trace(
context.pending_edges = ShapeList()
combined_faces = Face.fuse(*new_faces) if len(new_faces) > 1 else new_faces[0]
- return Sketch(combined_faces.wrapped)
+ result = (
+ Sketch(combined_faces)
+ if isinstance(combined_faces, list)
+ else Sketch(combined_faces.wrapped)
+ )
+ return result
From d7c73e1e81adcf79a4d655b9e50eb90531a2a996 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sun, 15 Dec 2024 11:00:51 -0500
Subject: [PATCH 035/518] Fixed Vector.__hash__ (used by set) by rounding
---
src/build123d/geometry.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 219e9a2..6c86a78 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -450,7 +450,7 @@ class Vector:
def __hash__(self) -> int:
"""Hash of Vector"""
- return hash(self.X) + hash(self.Y) + hash(self.Z)
+ return hash(round(self.X, 6)) + hash(round(self.Y, 6)) + hash(round(self.Z, 6))
def __copy__(self) -> Vector:
"""Return copy of self"""
From 6cb574c772282af9f045a75b4d7f84c9fd85e753 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sun, 15 Dec 2024 12:48:43 -0500
Subject: [PATCH 036/518] Improved Vector.__hash__ algorithm
---
src/build123d/geometry.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 6c86a78..dd4c48b 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -450,7 +450,7 @@ class Vector:
def __hash__(self) -> int:
"""Hash of Vector"""
- return hash(round(self.X, 6)) + hash(round(self.Y, 6)) + hash(round(self.Z, 6))
+ return hash((round(self.X, 6), round(self.Y, 6), round(self.Z, 6)))
def __copy__(self) -> Vector:
"""Return copy of self"""
From 87c046b2400ed48cbdc2cc2be2ba4fef100ab42d Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 16 Dec 2024 19:40:15 -0500
Subject: [PATCH 037/518] Added Optional to input parameters
---
tools/refactor_topo.py | 60 +++++++++++++++++++++++++++++++++++-------
1 file changed, 51 insertions(+), 9 deletions(-)
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 4adbc0b..51a6b6f 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -333,6 +333,51 @@ class ClassMethodExtractor(cst.CSTVisitor):
self.extracted_methods.append(renamed_node)
+class OptionalTransformer(cst.CSTTransformer):
+ def __init__(self):
+ super().__init__()
+ self.requires_optional_import = False # Tracks if `Optional` import is needed
+
+ def leave_AnnAssign(
+ self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign
+ ) -> cst.AnnAssign:
+ # Check if the annotation has a default value of `None`
+ if (
+ isinstance(updated_node.value, cst.Name)
+ and updated_node.value.value == "None"
+ ):
+ # Wrap the annotation type in `Optional`
+ if updated_node.annotation:
+ self.requires_optional_import = True
+ new_annotation = cst.Subscript(
+ value=cst.Name("Optional"),
+ slice=[
+ cst.SubscriptElement(
+ slice=cst.Index(updated_node.annotation.annotation)
+ )
+ ],
+ )
+ return updated_node.with_changes(
+ annotation=cst.Annotation(new_annotation)
+ )
+ return updated_node
+
+ def leave_Module(
+ self, original_node: cst.Module, updated_node: cst.Module
+ ) -> cst.Module:
+ # Add the `Optional` import if required
+ if self.requires_optional_import:
+ import_stmt = cst.ImportFrom(
+ module=cst.Name("typing"),
+ names=[cst.ImportAlias(name=cst.Name("Optional"))],
+ )
+ new_body = [cst.SimpleStatementLine([import_stmt])] + list(
+ updated_node.body
+ )
+ return updated_node.with_changes(body=new_body)
+ return updated_node
+
+
def write_topo_class_files(
source_tree: cst.Module,
extracted_classes: Dict[str, cst.ClassDef],
@@ -764,10 +809,12 @@ def main():
# Parse source file and collect imports
source_tree = cst.parse_module(topo_file.read_text())
- source_tree = source_tree.visit(UnionToPipeTransformer())
- # transformed_module = source_tree.visit(UnionToPipeTransformer())
- # print(transformed_module.code)
+ # Apply transformations
+ source_tree = source_tree.visit(UnionToPipeTransformer()) # Existing transformation
+ source_tree = source_tree.visit(OptionalTransformer()) # New Optional conversion
+
+ # Collect imports
collector = ImportCollector()
source_tree.visit(collector)
@@ -782,8 +829,6 @@ def main():
# Extract functions
function_collector = StandaloneFunctionAndVariableCollector()
source_tree.visit(function_collector)
- # for f in function_collector.functions:
- # print(f.name.value)
# Write the class files
write_topo_class_files(
@@ -793,11 +838,8 @@ def main():
output_dir=output_dir,
)
- # Create a Rope project instance
- # project = Project(str(script_dir))
- project = Project(str(output_dir))
-
# Clean up imports
+ project = Project(str(output_dir))
for file in output_dir.glob("*.py"):
if file.name == "__init__.py":
continue
From 127d04858209ee733d47927063cdd0171db6789e Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 17 Dec 2024 11:33:31 -0500
Subject: [PATCH 038/518] Revert "Added Optional to input parameters"
This reverts commit 87c046b2400ed48cbdc2cc2be2ba4fef100ab42d.
---
tools/refactor_topo.py | 60 +++++++-----------------------------------
1 file changed, 9 insertions(+), 51 deletions(-)
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 51a6b6f..4adbc0b 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -333,51 +333,6 @@ class ClassMethodExtractor(cst.CSTVisitor):
self.extracted_methods.append(renamed_node)
-class OptionalTransformer(cst.CSTTransformer):
- def __init__(self):
- super().__init__()
- self.requires_optional_import = False # Tracks if `Optional` import is needed
-
- def leave_AnnAssign(
- self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign
- ) -> cst.AnnAssign:
- # Check if the annotation has a default value of `None`
- if (
- isinstance(updated_node.value, cst.Name)
- and updated_node.value.value == "None"
- ):
- # Wrap the annotation type in `Optional`
- if updated_node.annotation:
- self.requires_optional_import = True
- new_annotation = cst.Subscript(
- value=cst.Name("Optional"),
- slice=[
- cst.SubscriptElement(
- slice=cst.Index(updated_node.annotation.annotation)
- )
- ],
- )
- return updated_node.with_changes(
- annotation=cst.Annotation(new_annotation)
- )
- return updated_node
-
- def leave_Module(
- self, original_node: cst.Module, updated_node: cst.Module
- ) -> cst.Module:
- # Add the `Optional` import if required
- if self.requires_optional_import:
- import_stmt = cst.ImportFrom(
- module=cst.Name("typing"),
- names=[cst.ImportAlias(name=cst.Name("Optional"))],
- )
- new_body = [cst.SimpleStatementLine([import_stmt])] + list(
- updated_node.body
- )
- return updated_node.with_changes(body=new_body)
- return updated_node
-
-
def write_topo_class_files(
source_tree: cst.Module,
extracted_classes: Dict[str, cst.ClassDef],
@@ -809,12 +764,10 @@ def main():
# Parse source file and collect imports
source_tree = cst.parse_module(topo_file.read_text())
+ source_tree = source_tree.visit(UnionToPipeTransformer())
+ # transformed_module = source_tree.visit(UnionToPipeTransformer())
+ # print(transformed_module.code)
- # Apply transformations
- source_tree = source_tree.visit(UnionToPipeTransformer()) # Existing transformation
- source_tree = source_tree.visit(OptionalTransformer()) # New Optional conversion
-
- # Collect imports
collector = ImportCollector()
source_tree.visit(collector)
@@ -829,6 +782,8 @@ def main():
# Extract functions
function_collector = StandaloneFunctionAndVariableCollector()
source_tree.visit(function_collector)
+ # for f in function_collector.functions:
+ # print(f.name.value)
# Write the class files
write_topo_class_files(
@@ -838,8 +793,11 @@ def main():
output_dir=output_dir,
)
- # Clean up imports
+ # Create a Rope project instance
+ # project = Project(str(script_dir))
project = Project(str(output_dir))
+
+ # Clean up imports
for file in output_dir.glob("*.py"):
if file.name == "__init__.py":
continue
From 5b88e87bad69f26b09c480a55b81c4547214a338 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 17 Dec 2024 11:58:58 -0500
Subject: [PATCH 039/518] Replace Optional with | None
---
src/build123d/topology.py | 24 ++++++++++++------------
tools/refactor_topo.py | 19 +++++++++++++++++++
2 files changed, 31 insertions(+), 12 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index b75fe5e..b218cb0 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -468,18 +468,18 @@ class Mixin1D:
@overload
def split(
self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM]
- ) -> Optional[Self] | Optional[list[Self]]:
+ ) -> Self | list[Self] | None:
"""split and keep inside or outside"""
@overload
def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[
- Optional[Self] | Optional[list[Self]],
- Optional[Self] | Optional[list[Self]],
+ Self | list[Self] | None,
+ Self | list[Self] | None,
]:
"""split and keep inside and outside"""
@overload
- def split(self, tool: TrimmingTool) -> Optional[Self] | Optional[list[Self]]:
+ def split(self, tool: TrimmingTool) -> Self | list[Self] | None:
"""split and keep inside (default)"""
def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP):
@@ -494,8 +494,8 @@ class Mixin1D:
Returns:
Shape: result of split
Returns:
- Optional[Self] | Optional[list[Self]],
- Tuple[Optional[Self] | Optional[list[Self]]]: The result of the split operation.
+ Self | list[Self] | None,
+ Tuple[Self | list[Self] | None]: The result of the split operation.
- **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None`
if no top is found.
@@ -3204,22 +3204,22 @@ class Shape(NodeMixin):
@overload
def split_by_perimeter(
self, perimeter: Union[Edge, Wire], keep: Literal[Keep.INSIDE, Keep.OUTSIDE]
- ) -> Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]]:
+ ) -> Face | Shell | ShapeList[Face] | None:
"""split_by_perimeter and keep inside or outside"""
@overload
def split_by_perimeter(
self, perimeter: Union[Edge, Wire], keep: Literal[Keep.BOTH]
) -> tuple[
- Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]],
- Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]],
+ Face | Shell | ShapeList[Face] | None,
+ Face | Shell | ShapeList[Face] | None,
]:
"""split_by_perimeter and keep inside and outside"""
@overload
def split_by_perimeter(
self, perimeter: Union[Edge, Wire]
- ) -> Optional[Face] | Optional[Shell] | Optional[ShapeList[Face]]:
+ ) -> Face | Shell | ShapeList[Face] | None:
"""split_by_perimeter and keep inside (default)"""
def split_by_perimeter(
@@ -3241,8 +3241,8 @@ class Shape(NodeMixin):
ValueError: keep must be one of Keep.INSIDE|OUTSIDE|BOTH
Returns:
- Union[Optional[Shell], Optional[Face],
- Tuple[Optional[Shell], Optional[Face]]]: The result of the split operation.
+ Union[Face | Shell | ShapeList[Face] | None,
+ Tuple[Face | Shell | ShapeList[Face] | None]: The result of the split operation.
- **Keep.INSIDE**: Returns the inside part as a `Shell` or `Face`, or `None`
if no inside part is found.
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 4adbc0b..6fa5c27 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -728,6 +728,24 @@ class UnionToPipeTransformer(cst.CSTTransformer):
return updated_node
+class OptionalToPipeTransformer(cst.CSTTransformer):
+ def leave_Annotation(
+ self, original_node: cst.Annotation, updated_node: cst.Annotation
+ ) -> cst.Annotation:
+ # Match Optional[...] annotations
+ if m.matches(updated_node.annotation, m.Subscript(value=m.Name("Optional"))):
+ subscript = updated_node.annotation
+ if isinstance(subscript, cst.Subscript) and subscript.slice:
+ # Extract the inner type of Optional
+ inner_type = subscript.slice[0].slice.value
+ # Replace Optional[X] with X | None
+ new_annotation = cst.BinaryOperation(
+ left=inner_type, operator=cst.BitOr(), right=cst.Name("None")
+ )
+ return updated_node.with_changes(annotation=new_annotation)
+ return updated_node
+
+
def main():
# Define paths
script_dir = Path(__file__).parent
@@ -765,6 +783,7 @@ def main():
# Parse source file and collect imports
source_tree = cst.parse_module(topo_file.read_text())
source_tree = source_tree.visit(UnionToPipeTransformer())
+ source_tree = source_tree.visit(OptionalToPipeTransformer())
# transformed_module = source_tree.visit(UnionToPipeTransformer())
# print(transformed_module.code)
From e36ab04cbc0199b4a1eb8ab23f60d37864ba450a Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 17 Dec 2024 11:43:54 -0600
Subject: [PATCH 040/518] action.yml -> add pytest-cov dependency to setup
---
.github/actions/setup/action.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index ae1abc7..ed36938 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -17,5 +17,6 @@ runs:
pip install wheel
pip install mypy
pip install pytest
+ pip install pytest-cov
pip install pylint
pip install .
From dd1aabba784ac162ffd48287dabb61b79971f2f6 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 17 Dec 2024 11:48:33 -0600
Subject: [PATCH 041/518] coverage.yml -> depend on .github/actions/setup like
other workflows
---
.github/workflows/coverage.yml | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index ca7272b..68dfebf 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -6,14 +6,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- - name: Set up Python 3.10
- uses: actions/setup-python@v5
+ - name: Setup
+ uses: ./.github/actions/setup/
with:
python-version: '3.10'
- - name: Install dependencies
- run: pip install -r requirements.txt
- name: Run tests and collect coverage
run: pytest --cov=build123d
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v4
-
+ uses: codecov/codecov-action@v5
From 590deb3937bdff6f1f2369017aa6e45d1038a5f6 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 17 Dec 2024 11:49:20 -0600
Subject: [PATCH 042/518] Delete requirements.txt
no longer needed
---
requirements.txt | 2 --
1 file changed, 2 deletions(-)
delete mode 100644 requirements.txt
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 62c8eab..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-pytest-cov
--e .
From 005f3af80ee1607c3cc6b83252bd51e713eafe67 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 17 Dec 2024 12:06:52 -0600
Subject: [PATCH 043/518] coverage.yml -> try double quotes instead of single
quotes to get python-version to be passed down correctly.
---
.github/workflows/coverage.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 68dfebf..25bc1f2 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -9,7 +9,7 @@ jobs:
- name: Setup
uses: ./.github/actions/setup/
with:
- python-version: '3.10'
+ python-version: "3.10"
- name: Run tests and collect coverage
run: pytest --cov=build123d
- name: Upload coverage to Codecov
From fd5515d27494b8a2a77ac35239ce80f224686eb9 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 17 Dec 2024 13:19:39 -0600
Subject: [PATCH 044/518] action.yml -> point to inputs.python-version instead
of matrix.python-version
---
.github/actions/setup/action.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index ed36938..9a4af02 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -10,7 +10,7 @@ runs:
- name: python
uses: actions/setup-python@v5
with:
- python-version: ${{ matrix.python-version }}
+ python-version: ${{ inputs.python-version }}
- name: install requirements
shell: bash
run: |
From 217b70aa1ec530aa97b8e3d793dedda0b1851c02 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 20 Dec 2024 14:31:42 -0500
Subject: [PATCH 045/518] Implemented review feedback - fixed mypy on
shape_core.py & utils.py
---
mypy.ini | 12 +-
src/build123d/exporters.py | 16 +-
src/build123d/geometry.py | 2 +-
src/build123d/topology.py | 924 +++++++++++++++++++++++--------------
tests/test_direct_api.py | 4 -
tests/test_exporters.py | 2 +
tools/refactor_topo.py | 36 +-
7 files changed, 607 insertions(+), 389 deletions(-)
diff --git a/mypy.ini b/mypy.ini
index 9938434..2180a44 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -1,7 +1,9 @@
# Global options:
[mypy]
-no_implicit_optional=False
+
+[mypy-OCP.*]
+ignore_missing_imports = True
[mypy-anytree.*]
ignore_missing_imports = True
@@ -12,3 +14,11 @@ ignore_missing_imports = True
[mypy-vtkmodules.*]
ignore_missing_imports = True
+[mypy-build123d.topology.jupyter_tools.*]
+ignore_missing_imports = True
+
+[mypy-IPython.lib.pretty.*]
+ignore_missing_imports = True
+
+[mypy-svgpathtools.*]
+ignore_missing_imports = True
diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py
index 96fcfdb..b869ac5 100644
--- a/src/build123d/exporters.py
+++ b/src/build123d/exporters.py
@@ -53,6 +53,7 @@ from OCP.HLRAlgo import HLRAlgo_Projector # type: ignore
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape # type: ignore
from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum # type: ignore
from OCP.TopExp import TopExp_Explorer # type: ignore
+from OCP.TopoDS import TopoDS
from typing_extensions import Self
from build123d.build_enums import Unit, GeomType
@@ -1060,7 +1061,7 @@ class ExportSVG(Export2D):
)
while explorer.More():
topo_wire = explorer.Current()
- loose_wires.append(Wire(topo_wire))
+ loose_wires.append(Wire(TopoDS.Wire_s(topo_wire)))
explorer.Next()
# print(f"{len(loose_wires)} loose wires")
for wire in loose_wires:
@@ -1097,12 +1098,13 @@ class ExportSVG(Export2D):
@staticmethod
def _wire_edges(wire: Wire, reverse: bool) -> List[Edge]:
- edges = []
- explorer = BRepTools_WireExplorer(wire.wrapped)
- while explorer.More():
- topo_edge = explorer.Current()
- edges.append(Edge(topo_edge))
- explorer.Next()
+ # edges = []
+ # explorer = BRepTools_WireExplorer(wire.wrapped)
+ # while explorer.More():
+ # topo_edge = explorer.Current()
+ # edges.append(Edge(topo_edge))
+ # explorer.Next()
+ edges = wire.edges()
if reverse:
edges.reverse()
return edges
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index dd4c48b..fa9a26e 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -542,7 +542,7 @@ class Vector:
#:TypeVar("VectorLike"): Tuple of float or Vector defining a position in space
VectorLike = Union[
- Vector, tuple[float, float], tuple[float, float, float], Iterable[float]
+ Vector, tuple[float, float], tuple[float, float, float], Sequence[float]
]
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index b218cb0..7299d6b 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -57,6 +57,7 @@ from typing import (
Optional,
Protocol,
Sequence,
+ SupportsIndex,
Tuple,
Type,
TypeVar,
@@ -78,6 +79,7 @@ from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter
import OCP.GeomAbs as ga # Geometry type enum
import OCP.TopAbs as ta # Topology type enum
from OCP.Aspect import Aspect_TOL_SOLID
+from OCP.Bnd import Bnd_Box
from OCP.BOPAlgo import BOPAlgo_GlueEnum
from OCP.BRep import BRep_Tool
@@ -304,16 +306,9 @@ from build123d.geometry import (
)
-@property
-def _topods_compound_dim(self) -> int | None:
- """The dimension of the shapes within the Compound - None if inconsistent"""
- sub_dims = {s.dim for s in get_top_level_topods_shapes(self)}
- return sub_dims.pop() if len(sub_dims) == 1 else None
-
-
-def _topods_face_normal_at(self, surface_point: gp_Pnt) -> Vector:
- """normal_at point on surface"""
- surface = BRep_Tool.Surface_s(self)
+def _topods_face_normal_at(face: TopoDS_Face, surface_point: gp_Pnt) -> Vector:
+ """Find the normal at a point on surface"""
+ surface = BRep_Tool.Surface_s(face)
# project point on surface
projector = GeomAPI_ProjectPointOnSurf(surface_point, surface)
@@ -321,25 +316,29 @@ def _topods_face_normal_at(self, surface_point: gp_Pnt) -> Vector:
gp_pnt = gp_Pnt()
normal = gp_Vec()
- BRepGProp_Face(self).Normal(u_val, v_val, gp_pnt, normal)
+ BRepGProp_Face(face).Normal(u_val, v_val, gp_pnt, normal)
return Vector(normal).normalized()
-def apply_ocp_monkey_patches() -> None:
- """Applies monkey patches to TopoDS classes."""
- TopoDS_Compound.dim = _topods_compound_dim
- TopoDS_Face.dim = 2
- TopoDS_Face.normal_at = _topods_face_normal_at
- TopoDS_Shape.dim = None
- TopoDS_Shell.dim = 2
- TopoDS_Solid.dim = 3
- TopoDS_Vertex.dim = 0
- TopoDS_Edge.dim = 1
- TopoDS_Wire.dim = 1
+def topods_dim(topods: TopoDS_Shape) -> int | None:
+ """Return the dimension of this TopoDS_Shape"""
+ shape_dim_map = {
+ (TopoDS_Vertex,): 0,
+ (TopoDS_Edge, TopoDS_Wire): 1,
+ (TopoDS_Face, TopoDS_Shell): 2,
+ (TopoDS_Solid,): 3,
+ }
+ for shape_types, dim in shape_dim_map.items():
+ if isinstance(topods, shape_types):
+ return dim
-apply_ocp_monkey_patches()
+ if isinstance(topods, TopoDS_Compound):
+ sub_dims = {topods_dim(s) for s in get_top_level_topods_shapes(topods)}
+ return sub_dims.pop() if len(sub_dims) == 1 else None
+
+ return None
HASH_CODE_MAX = 2147483647 # max 32bit signed int, required by OCC.Core.HashCode
@@ -350,7 +349,7 @@ Shapes = Literal["Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"]
TrimmingTool = Union[Plane, "Shell", "Face"]
-def tuplify(obj: Any, dim: int) -> tuple:
+def tuplify(obj: Any, dim: int) -> tuple | None:
"""Create a size tuple"""
if obj is None:
result = None
@@ -361,48 +360,6 @@ def tuplify(obj: Any, dim: int) -> tuple:
return result
-class _ClassMethodProxy:
- """
- A proxy for dynamically binding a class method to different classes.
-
- This descriptor allows a class method defined in one class to be reused
- in other classes while ensuring that the `cls` parameter refers to the
- correct class (the class on which the method is being called). This avoids
- issues where the method would otherwise always reference the original
- defining class.
-
- Attributes:
- method (classmethod): The class method to be proxied.
-
- Methods:
- __get__(instance, owner):
- Dynamically binds the proxied method to the calling class (`owner`).
-
- Example:
- class Mixin1D:
- @classmethod
- def extrude(cls, shape, direction):
- print(f"extrude called on {cls.__name__}")
-
- class Mixin2D:
- extrude = ClassMethodProxy(Mixin1D.extrude)
-
- class Mixin3D:
- extrude = ClassMethodProxy(Mixin1D.extrude)
-
- # Usage
- Mixin2D.extrude(None, None) # Output: extrude called on Mixin2D
- Mixin3D.extrude(None, None) # Output: extrude called on Mixin3D
- """
-
- def __init__(self, method):
- self.method = method
-
- def __get__(self, instance, owner):
- # Bind the method dynamically as a class method of `owner`
- return types.MethodType(self.method.__func__, owner)
-
-
class Mixin1D:
"""Methods to add to the Edge and Wire classes"""
@@ -420,7 +377,7 @@ class Mixin1D:
if not summands:
return self
- if not all(summand.dim == 1 for summand in summands):
+ if not all(topods_dim(summand) == 1 for summand in summands):
raise ValueError("Only shapes with the same dimension can be added")
# Convert back to Edge/Wire objects now that it's safe to do so
@@ -1278,37 +1235,11 @@ class Mixin1D:
return (visible_edges, hidden_edges)
- @classmethod
- def extrude(
- cls, obj: Shape, direction: VectorLike
- ) -> Edge | Face | Shell | Solid | Compound:
- """extrude
-
- Extrude a Shape in the provided direction.
- * Vertices generate Edges
- * Edges generate Faces
- * Wires generate Shells
- * Faces generate Solids
- * Shells generate Compounds
-
- Args:
- direction (VectorLike): direction and magnitude of extrusion
-
- Raises:
- ValueError: Unsupported class
- RuntimeError: Generated invalid result
-
- Returns:
- Union[Edge, Face, Shell, Solid, Compound]: extruded shape
- """
- return cls.cast(_extrude_topods_shape(obj.wrapped, direction))
-
class Mixin2D:
"""Additional methods to add to Face and Shell class"""
project_to_viewport = Mixin1D.project_to_viewport
- extrude = _ClassMethodProxy(Mixin1D.extrude)
split = Mixin1D.split
@classmethod
@@ -1353,6 +1284,8 @@ class Mixin2D:
def __neg__(self) -> Self:
"""Reverse normal operator -"""
+ if self.wrapped is None:
+ raise ValueError("Invalid Shape")
new_surface = copy.deepcopy(self)
new_surface.wrapped = downcast(self.wrapped.Complemented())
@@ -1367,7 +1300,6 @@ class Mixin3D:
"""Additional methods to add to 3D Shape classes"""
project_to_viewport = Mixin1D.project_to_viewport
- extrude = _ClassMethodProxy(Mixin1D.extrude)
split = Mixin1D.split
@classmethod
@@ -1813,7 +1745,10 @@ class Mixin3D:
return self.__class__(shape)
-class Shape(NodeMixin):
+TOPODS = TypeVar("TOPODS", bound=TopoDS_Shape)
+
+
+class Shape(NodeMixin, Generic[TOPODS]):
"""Shape
Base class for all CAD objects such as Edge, Face, Solid, etc.
@@ -1905,12 +1840,14 @@ class Shape(NodeMixin):
def __init__(
self,
- obj: TopoDS_Shape = None,
+ obj: TopoDS_Shape | None = None,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
- self.wrapped = downcast(obj) if obj is not None else None
+ self.wrapped: TOPODS | None = (
+ tcast(Optional[TOPODS], downcast(obj)) if obj is not None else None
+ )
self.for_construction = False
self.label = label
self._color = color
@@ -1919,41 +1856,50 @@ class Shape(NodeMixin):
self.parent = parent
# Extracted objects like Vertices and Edges may need to know where they came from
- self.topo_parent: Shape = None
+ self.topo_parent: Shape | None = None
@property
- def location(self) -> Location:
+ def location(self) -> Location | None:
"""Get this Shape's Location"""
+ if self.wrapped is None:
+ return None
return Location(self.wrapped.Location())
@location.setter
def location(self, value: Location):
"""Set Shape's Location to value"""
- self.wrapped.Location(value.wrapped)
+ if self.wrapped is not None:
+ self.wrapped.Location(value.wrapped)
@property
- def position(self) -> Vector:
+ def position(self) -> Vector | None:
"""Get the position component of this Shape's Location"""
+ if self.wrapped is None or self.location is None:
+ return None
return self.location.position
@position.setter
def position(self, value: VectorLike):
"""Set the position component of this Shape's Location to value"""
loc = self.location
- loc.position = value
- self.location = loc
+ if loc is not None:
+ loc.position = Vector(value)
+ self.location = loc
@property
- def orientation(self) -> Vector:
+ def orientation(self) -> Vector | None:
"""Get the orientation component of this Shape's Location"""
+ if self.location is None:
+ return None
return self.location.orientation
@orientation.setter
def orientation(self, rotations: VectorLike):
"""Set the orientation component of this Shape's Location to rotations"""
loc = self.location
- loc.orientation = rotations
- self.location = loc
+ if loc is not None:
+ loc.orientation = Vector(rotations)
+ self.location = loc
@property
def color(self) -> Union[None, Color]:
@@ -1962,7 +1908,7 @@ class Shape(NodeMixin):
# Find the correct color for this node
if self._color is None:
# Find parent color
- current_node = self
+ current_node: Compound | Shape | None = self
while current_node is not None:
parent_color = current_node._color
if parent_color is not None:
@@ -1974,6 +1920,11 @@ class Shape(NodeMixin):
self._color = node_color # Set the node's color for next time
return node_color
+ @color.setter
+ def color(self, value):
+ """Set the shape's color"""
+ self._color = value
+
@property
def is_planar_face(self) -> bool:
"""Is the shape a planar face even though its geom_type may not be PLANE"""
@@ -1983,12 +1934,9 @@ class Shape(NodeMixin):
is_face_planar = GeomLib_IsPlanarSurface(surface, TOLERANCE)
return is_face_planar.IsPlanar()
- @color.setter
- def color(self, value):
- """Set the shape's color"""
- self._color = value
-
- def copy_attributes_to(self, target: Shape, exceptions: Iterable[str] = None):
+ def copy_attributes_to(
+ self, target: Shape, exceptions: Iterable[str] | None = None
+ ):
"""Copy common object attributes to target
Note that preset attributes of target will not be overridden.
@@ -2028,7 +1976,10 @@ class Shape(NodeMixin):
bool: is the shape manifold or water tight
"""
# Extract one or more (if a Compound) shape from self
- shape_stack = get_top_level_topods_shapes(self.wrapped)
+ if self.wrapped is None:
+ shape_stack = []
+ else:
+ shape_stack = get_top_level_topods_shapes(self.wrapped)
results = []
while shape_stack:
@@ -2052,7 +2003,7 @@ class Shape(NodeMixin):
# exactly two faces associated with it.
for i in range(shape_map.Extent()):
# Access each edge in the map sequentially
- edge = downcast(shape_map.FindKey(i + 1))
+ edge = TopoDS.Edge_s(shape_map.FindKey(i + 1))
vertex0 = TopoDS_Vertex()
vertex1 = TopoDS_Vertex()
@@ -2081,15 +2032,15 @@ class Shape(NodeMixin):
def __init__(
self,
label: str = "",
- address: int = None,
- position: Union[Vector, Location] = None,
- parent: Shape._DisplayNode = None,
+ address: int | None = None,
+ position: Union[Vector, Location, None] = None,
+ parent: Shape._DisplayNode | None = None,
):
self.label = label
self.address = address
self.position = position
self.parent = parent
- self.children = []
+ self.children: list[Shape] = []
_ordered_shapes = [
TopAbs_ShapeEnum.TopAbs_COMPOUND,
@@ -2105,13 +2056,14 @@ class Shape(NodeMixin):
def _build_tree(
shape: TopoDS_Shape,
tree: list[_DisplayNode],
- parent: _DisplayNode = None,
+ parent: _DisplayNode | None = None,
limit: TopAbs_ShapeEnum = TopAbs_ShapeEnum.TopAbs_VERTEX,
show_center: bool = True,
) -> list[_DisplayNode]:
"""Create an anytree copy of the TopoDS_Shape structure"""
obj_type = Shape.shape_LUT[shape.ShapeType()]
+ loc: Vector | Location
if show_center:
loc = Shape(shape).bounding_box().center()
else:
@@ -2177,7 +2129,7 @@ class Shape(NodeMixin):
limit_class: Literal[
"Compound", "Edge", "Face", "Shell", "Solid", "Vertex", "Wire"
] = "Vertex",
- show_center: bool = None,
+ show_center: bool | None = None,
) -> str:
"""Display internal topology
@@ -2205,7 +2157,6 @@ class Shape(NodeMixin):
Returns:
str: tree representation of internal structure
"""
- # if isinstance(self, Compound) and self.children:
if (
self.wrapped is not None
and isinstance(self.wrapped, TopoDS_Compound)
@@ -2215,7 +2166,9 @@ class Shape(NodeMixin):
result = Shape._show_tree(self, show_center)
else:
tree = Shape._build_tree(
- self.wrapped, tree=[], limit=Shape.inverse_shape_LUT[limit_class]
+ tcast(TopoDS_Shape, self.wrapped),
+ tree=[],
+ limit=Shape.inverse_shape_LUT[limit_class],
)
show_center = True if show_center is None else show_center
result = Shape._show_tree(tree[0], show_center)
@@ -2291,7 +2244,7 @@ class Shape(NodeMixin):
return difference
- def __and__(self, other: Shape) -> Self:
+ def __and__(self, other: Shape | Iterable[Shape]) -> Self | ShapeList[Self]:
"""intersect shape with self operator &"""
others = other if isinstance(other, (list, tuple)) else [other]
@@ -2299,7 +2252,11 @@ class Shape(NodeMixin):
raise ValueError("Cannot intersect shape with empty compound")
new_shape = self.intersect(*others)
- if new_shape.wrapped is not None and SkipClean.clean:
+ if (
+ not isinstance(new_shape, list)
+ and new_shape.wrapped is not None
+ and SkipClean.clean
+ ):
new_shape = new_shape.clean()
return new_shape
@@ -2316,7 +2273,7 @@ class Shape(NodeMixin):
return [loc * self for loc in other]
@abstractmethod
- def center(self) -> Vector:
+ def center(self, center_of: CenterOf | None = None) -> Vector:
"""All of the derived classes from Shape need a center method"""
def clean(self) -> Self:
@@ -2327,21 +2284,25 @@ class Shape(NodeMixin):
Returns:
Shape: Original object with extraneous internal edges removed
"""
+ if self.wrapped is None:
+ return self
upgrader = ShapeUpgrade_UnifySameDomain(self.wrapped, True, True, True)
upgrader.AllowInternalEdges(False)
# upgrader.SetAngularTolerance(1e-5)
try:
upgrader.Build()
- self.wrapped = downcast(upgrader.Shape())
+ self.wrapped = tcast(TOPODS, downcast(upgrader.Shape()))
except Exception:
warnings.warn(f"Unable to clean {self}", stacklevel=2)
return self
def fix(self) -> Self:
"""fix - try to fix shape if not valid"""
+ if self.wrapped is None:
+ return self
if not self.is_valid():
shape_copy: Shape = copy.deepcopy(self, None)
- shape_copy.wrapped = fix(self.wrapped)
+ shape_copy.wrapped = tcast(TOPODS, fix(self.wrapped))
return shape_copy
@@ -2356,18 +2317,23 @@ class Shape(NodeMixin):
def geom_type(self) -> GeomType:
"""Gets the underlying geometry type.
- Args:
-
Returns:
+ GeomType: The geometry type of the shape
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot determine geometry type of an empty shape")
shape: TopAbs_ShapeEnum = shapetype(self.wrapped)
if shape == ta.TopAbs_EDGE:
- geom = Shape.geom_LUT_EDGE[BRepAdaptor_Curve(self.wrapped).GetType()]
+ geom = Shape.geom_LUT_EDGE[
+ BRepAdaptor_Curve(tcast(TopoDS_Edge, self.wrapped)).GetType()
+ ]
elif shape == ta.TopAbs_FACE:
- geom = Shape.geom_LUT_FACE[BRepAdaptor_Surface(self.wrapped).GetType()]
+ geom = Shape.geom_LUT_FACE[
+ BRepAdaptor_Surface(tcast(TopoDS_Face, self.wrapped)).GetType()
+ ]
else:
geom = GeomType.OTHER
@@ -2382,6 +2348,8 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None:
+ return 0
return self.wrapped.HashCode(HASH_CODE_MAX)
def is_null(self) -> bool:
@@ -2394,7 +2362,7 @@ class Shape(NodeMixin):
Returns:
"""
- return self.wrapped.IsNull()
+ return self.wrapped is None or self.wrapped.IsNull()
def is_same(self, other: Shape) -> bool:
"""Returns True if other and this shape are same, i.e. if they share the
@@ -2407,6 +2375,8 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None or other.wrapped is None:
+ return False
return self.wrapped.IsSame(other.wrapped)
def is_equal(self, other: Shape) -> bool:
@@ -2420,11 +2390,26 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None or other.wrapped is None:
+ return False
return self.wrapped.IsEqual(other.wrapped)
def __eq__(self, other) -> bool:
- """Are shapes same operator =="""
- return self.is_same(other) if isinstance(other, Shape) else NotImplemented
+ """Check if two shapes are the same.
+
+ This method checks if the current shape is the same as the other shape.
+ Two shapes are considered the same if they share the same TShape with
+ the same Locations. Orientations may differ.
+
+ Args:
+ other (Shape): The shape to compare with.
+
+ Returns:
+ bool: True if the shapes are the same, False otherwise.
+ """
+ if isinstance(other, Shape):
+ return self.is_same(other)
+ return NotImplemented
def is_valid(self) -> bool:
"""Returns True if no defect is detected on the shape S or any of its
@@ -2436,11 +2421,15 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None:
+ return True
chk = BRepCheck_Analyzer(self.wrapped)
chk.SetParallel(True)
return chk.IsValid()
- def bounding_box(self, tolerance: float = None, optimal: bool = True) -> BoundBox:
+ def bounding_box(
+ self, tolerance: float | None = None, optimal: bool = True
+ ) -> BoundBox:
"""Create a bounding box for this Shape.
Args:
@@ -2449,9 +2438,12 @@ class Shape(NodeMixin):
Returns:
BoundBox: A box sized to contain this Shape
"""
+ if self.wrapped is None:
+ return BoundBox(Bnd_Box())
+ tolerance = TOLERANCE if tolerance is None else tolerance
return BoundBox.from_topo_ds(self.wrapped, tolerance=tolerance, optimal=optimal)
- def mirror(self, mirror_plane: Plane = None) -> Self:
+ def mirror(self, mirror_plane: Plane | None = None) -> Self:
"""
Applies a mirror transform to this Shape. Does not duplicate objects
about the plane.
@@ -2464,6 +2456,8 @@ class Shape(NodeMixin):
if not mirror_plane:
mirror_plane = Plane.XY
+ if self.wrapped is None:
+ return self
transformation = gp_Trsf()
transformation.SetMirror(
gp_Ax2(mirror_plane.origin.to_pnt(), mirror_plane.z_dir.to_dir())
@@ -2500,7 +2494,7 @@ class Shape(NodeMixin):
sum_wc = sum_wc.add(weighted_center)
middle = Vector(sum_wc.multiply(1.0 / total_mass))
elif center_of == CenterOf.BOUNDING_BOX:
- total_mass = len(objects)
+ total_mass = len(list(objects))
weighted_centers = []
for obj in objects:
@@ -2527,6 +2521,9 @@ class Shape(NodeMixin):
Returns:
"""
+ if obj.wrapped is None:
+ return 0.0
+
properties = GProp_GProps()
calc_function = Shape.shape_properties_LUT[shapetype(obj.wrapped)]
@@ -2542,12 +2539,17 @@ class Shape(NodeMixin):
def entities(self, topo_type: Shapes) -> list[TopoDS_Shape]:
"""Return all of the TopoDS sub entities of the given type"""
+ if self.wrapped is None:
+ return []
return _topods_entities(self.wrapped, topo_type)
def _entities_from(
self, child_type: Shapes, parent_type: Shapes
) -> Dict[Shape, list[Shape]]:
"""This function is very slow on M1 macs and is currently unused"""
+ if self.wrapped is None:
+ return {}
+
res = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(
@@ -2583,12 +2585,19 @@ class Shape(NodeMixin):
(e.g., edges, vertices) and other compounds, the method returns a list
of only the simple shapes directly contained at the top level.
"""
+ if self.wrapped is None:
+ return ShapeList()
return ShapeList(
self.__class__.cast(s) for s in get_top_level_topods_shapes(self.wrapped)
)
@staticmethod
- def get_shape_list(shape: Shape, entity_type: str) -> ShapeList:
+ def get_shape_list(
+ shape: Shape,
+ entity_type: Literal[
+ "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"
+ ],
+ ) -> ShapeList:
"""Helper to extract entities of a specific type from a shape."""
if shape.wrapped is None:
return ShapeList()
@@ -2600,7 +2609,12 @@ class Shape(NodeMixin):
return shape_list
@staticmethod
- def get_single_shape(shape: Shape, entity_type: str) -> Shape:
+ def get_single_shape(
+ shape: Shape,
+ entity_type: Literal[
+ "Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"
+ ],
+ ) -> Shape:
"""Helper to extract a single entity of a specific type from a shape,
with a warning if count != 1."""
shape_list = Shape.get_shape_list(shape, entity_type)
@@ -2616,7 +2630,7 @@ class Shape(NodeMixin):
"""vertices - all the vertices in this Shape - subclasses may override"""
return ShapeList()
- def vertex(self) -> Vertex:
+ def vertex(self) -> Vertex | None:
"""Return the Vertex"""
return None
@@ -2624,7 +2638,7 @@ class Shape(NodeMixin):
"""edges - all the edges in this Shape"""
return ShapeList()
- def edge(self) -> Edge:
+ def edge(self) -> Edge | None:
"""Return the Edge"""
return None
@@ -2632,7 +2646,7 @@ class Shape(NodeMixin):
"""wires - all the wires in this Shape"""
return ShapeList()
- def wire(self) -> Wire:
+ def wire(self) -> Wire | None:
"""Return the Wire"""
return None
@@ -2640,7 +2654,7 @@ class Shape(NodeMixin):
"""faces - all the faces in this Shape"""
return ShapeList()
- def face(self) -> Face:
+ def face(self) -> Face | None:
"""Return the Face"""
return None
@@ -2648,7 +2662,7 @@ class Shape(NodeMixin):
"""shells - all the shells in this Shape"""
return ShapeList()
- def shell(self) -> Shell:
+ def shell(self) -> Shell | None:
"""Return the Shell"""
return None
@@ -2656,7 +2670,7 @@ class Shape(NodeMixin):
"""solids - all the solids in this Shape"""
return ShapeList()
- def solid(self) -> Solid:
+ def solid(self) -> Solid | None:
"""Return the Solid"""
return None
@@ -2664,13 +2678,15 @@ class Shape(NodeMixin):
"""compounds - all the compounds in this Shape"""
return ShapeList()
- def compound(self) -> Compound:
+ def compound(self) -> Compound | None:
"""Return the Compound"""
return None
@property
def area(self) -> float:
"""area -the surface area of all faces in this Shape"""
+ if self.wrapped is None:
+ return 0.0
properties = GProp_GProps()
BRepGProp.SurfaceProperties_s(self.wrapped, properties)
@@ -2687,11 +2703,15 @@ class Shape(NodeMixin):
Returns:
Shape: copy of transformed Shape
"""
+ if self.wrapped is None:
+ return self
shape_copy: Shape = copy.deepcopy(self, None)
transformed_shape = BRepBuilderAPI_Transform(
- shape_copy.wrapped, transformation, True
+ self.wrapped,
+ transformation,
+ True,
).Shape()
- shape_copy.wrapped = downcast(transformed_shape)
+ shape_copy.wrapped = tcast(TOPODS, downcast(transformed_shape))
return shape_copy
def rotate(self, axis: Axis, angle: float) -> Self:
@@ -2770,18 +2790,13 @@ class Shape(NodeMixin):
Changes to the CAD structure of the base object will be reflected in all instances.
"""
reference = copy.deepcopy(self)
- reference.wrapped.TShape(self.wrapped.TShape())
+ if self.wrapped is not None:
+ assert (
+ reference.wrapped is not None
+ ) # Ensure mypy knows reference.wrapped is not None
+ reference.wrapped.TShape(self.wrapped.TShape())
return reference
- def copy(self) -> Self:
- """Here for backwards compatibility with cq-editor"""
- warnings.warn(
- "copy() will be deprecated - use copy.copy() or copy.deepcopy() instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return copy.deepcopy(self, None)
-
def transform_shape(self, t_matrix: Matrix) -> Self:
"""Apply affine transform without changing type
@@ -2795,11 +2810,13 @@ class Shape(NodeMixin):
Returns:
Shape: copy of transformed shape with all objects keeping their type
"""
+ new_shape = copy.deepcopy(self, None)
+ if self.wrapped is None:
+ return new_shape
transformed = downcast(
BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape()
)
- new_shape = copy.deepcopy(self, None)
- new_shape.wrapped = transformed
+ new_shape.wrapped = tcast(TOPODS, transformed)
return new_shape
@@ -2820,11 +2837,13 @@ class Shape(NodeMixin):
Returns:
Shape: a copy of the object, but with geometry transformed
"""
+ new_shape = copy.deepcopy(self, None)
+ if self.wrapped is None:
+ return new_shape
transformed = downcast(
BRepBuilderAPI_GTransform(self.wrapped, t_matrix.wrapped, True).Shape()
)
- new_shape = copy.deepcopy(self, None)
- new_shape.wrapped = transformed
+ new_shape.wrapped = tcast(TOPODS, transformed)
return new_shape
@@ -2837,7 +2856,10 @@ class Shape(NodeMixin):
Returns:
"""
-
+ if self.wrapped is None:
+ raise ValueError("Cannot locate an empty shape")
+ if loc.wrapped is None:
+ raise ValueError("Cannot locate a shape at an empty location")
self.wrapped.Location(loc.wrapped)
return self
@@ -2853,8 +2875,12 @@ class Shape(NodeMixin):
Returns:
Shape: copy of Shape at location
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot locate an empty shape")
+ if loc.wrapped is None:
+ raise ValueError("Cannot locate a shape at an empty location")
shape_copy: Shape = copy.deepcopy(self, None)
- shape_copy.wrapped.Location(loc.wrapped)
+ shape_copy.wrapped.Location(loc.wrapped) # type: ignore
return shape_copy
def move(self, loc: Location) -> Self:
@@ -2866,6 +2892,10 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot move an empty shape")
+ if loc.wrapped is None:
+ raise ValueError("Cannot move a shape at an empty location")
self.wrapped.Move(loc.wrapped)
@@ -2882,8 +2912,12 @@ class Shape(NodeMixin):
Returns:
Shape: copy of Shape moved to relative location
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot move an empty shape")
+ if loc.wrapped is None:
+ raise ValueError("Cannot move a shape at an empty location")
shape_copy: Shape = copy.deepcopy(self, None)
- shape_copy.wrapped = downcast(shape_copy.wrapped.Moved(loc.wrapped))
+ shape_copy.wrapped = tcast(TOPODS, downcast(self.wrapped.Moved(loc.wrapped)))
return shape_copy
def relocate(self, loc: Location):
@@ -2892,9 +2926,14 @@ class Shape(NodeMixin):
Args:
loc (Location): new location to set for self
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot relocate an empty shape")
+ if loc.wrapped is None:
+ raise ValueError("Cannot relocate a shape at an empty location")
+
if self.location != loc:
old_ax = gp_Ax3()
- old_ax.Transform(self.location.wrapped.Transformation())
+ old_ax.Transform(self.location.wrapped.Transformation()) # type: ignore
new_ax = gp_Ax3()
new_ax.Transform(loc.wrapped.Transformation())
@@ -2903,21 +2942,23 @@ class Shape(NodeMixin):
trsf.SetDisplacement(new_ax, old_ax)
builder = BRepBuilderAPI_Transform(self.wrapped, trsf, True, True)
- self.wrapped = downcast(builder.Shape())
+ self.wrapped = tcast(TOPODS, downcast(builder.Shape()))
self.wrapped.Location(loc.wrapped)
def distance_to_with_closest_points(
self, other: Union[Shape, VectorLike]
) -> tuple[float, Vector, Vector]:
"""Minimal distance between two shapes and the points on each shape"""
+ if self.wrapped is None or (isinstance(other, Shape) and other.wrapped is None):
+ raise ValueError("Cannot calculate distance to or from an empty shape")
if isinstance(other, Shape):
- topods_shape = other.wrapped
+ topods_shape = tcast(TopoDS_Shape, other.wrapped)
else:
vec = Vector(other)
- topods_shape = downcast(
- BRepBuilderAPI_MakeVertex(gp_Pnt(vec.X, vec.Y, vec.Z)).Vertex()
- )
+ topods_shape = BRepBuilderAPI_MakeVertex(
+ gp_Pnt(vec.X, vec.Y, vec.Z)
+ ).Vertex()
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
@@ -2935,7 +2976,7 @@ class Shape(NodeMixin):
def closest_points(self, other: Union[Shape, VectorLike]) -> tuple[Vector, Vector]:
"""Points on two shapes where the distance between them is minimal"""
- return tuple(self.distance_to_with_closest_points(other)[1:])
+ return self.distance_to_with_closest_points(other)[1:3]
def __hash__(self) -> int:
"""Return has code"""
@@ -2967,11 +3008,13 @@ class Shape(NodeMixin):
arg = TopTools_ListOfShape()
for obj in args:
- arg.Append(obj.wrapped)
+ if obj.wrapped is not None:
+ arg.Append(obj.wrapped)
tool = TopTools_ListOfShape()
for obj in tools:
- tool.Append(obj.wrapped)
+ if obj.wrapped is not None:
+ tool.Append(obj.wrapped)
operation.SetArguments(arg)
operation.SetTools(tool)
@@ -3025,7 +3068,7 @@ class Shape(NodeMixin):
return self._bool_op((self,), to_cut, cut_op)
def fuse(
- self, *to_fuse: Shape, glue: bool = False, tol: float = None
+ self, *to_fuse: Shape, glue: bool = False, tol: float | None = None
) -> Self | ShapeList[Self]:
"""fuse
@@ -3099,7 +3142,9 @@ class Shape(NodeMixin):
elif isinstance(obj, Plane):
objs.append(_to_face(obj))
elif isinstance(obj, Location):
- objs.append(_to_vertex(obj.position))
+ if obj.wrapped is None:
+ raise ValueError("Cannot intersect with an empty location")
+ objs.append(_to_vertex(tcast(Vector, obj.position)))
else:
objs.append(obj)
@@ -3109,6 +3154,31 @@ class Shape(NodeMixin):
return shape_intersections
+ @classmethod
+ @abstractmethod
+ def extrude(
+ cls, obj: Shape, direction: VectorLike
+ ) -> Edge | Face | Shell | Solid | Compound:
+ """extrude
+
+ Extrude a Shape in the provided direction.
+ * Vertices generate Edges
+ * Edges generate Faces
+ * Wires generate Shells
+ * Faces generate Solids
+ * Shells generate Compounds
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Edge | Face | Shell | Solid | Compound: extruded shape
+ """
+
def _ocp_section(
self: Shape, other: Union[Vertex, Edge, Wire, Face]
) -> tuple[list[Vertex], list[Edge]]:
@@ -3131,6 +3201,9 @@ class Shape(NodeMixin):
Returns:
tuple[list[Vertex], list[Edge]]: section results
"""
+ if self.wrapped is None or other.wrapped is None:
+ return ([], [])
+
try:
section = BRepAlgoAPI_Section(other.geom_adaptor(), self.wrapped)
except (TypeError, AttributeError):
@@ -3175,11 +3248,13 @@ class Shape(NodeMixin):
Returns:
list[Face]: A list of intersected faces sorted by distance from axis.position
"""
+ if self.wrapped is None:
+ return ShapeList()
+
line = gce_MakeLin(axis.wrapped).Value()
- shape = self.wrapped
intersect_maker = BRepIntCurveSurface_Inter()
- intersect_maker.Init(shape, line, tol)
+ intersect_maker.Init(self.wrapped, line, tol)
faces_dist = [] # using a list instead of a dictionary to be able to sort it
while intersect_maker.More():
@@ -3281,6 +3356,9 @@ class Shape(NodeMixin):
"keep must be one of Keep.INSIDE, Keep.OUTSIDE, or Keep.BOTH"
)
+ if self.wrapped is None:
+ raise ValueError("Cannot split an empty shape")
+
# Process the perimeter
if not perimeter.is_closed:
raise ValueError("perimeter must be a closed Wire or Edge")
@@ -3324,6 +3402,8 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None or other.wrapped is None:
+ raise ValueError("Cannot calculate distance to or from an empty shape")
return BRepExtrema_DistShapeShape(self.wrapped, other.wrapped).Value()
@@ -3336,11 +3416,15 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot calculate distance to or from an empty shape")
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
for other_shape in others:
+ if other_shape.wrapped is None:
+ raise ValueError("Cannot calculate distance to or from an empty shape")
dist_calc.LoadS2(other_shape.wrapped)
dist_calc.Perform()
@@ -3356,6 +3440,8 @@ class Shape(NodeMixin):
Returns:
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot mesh an empty shape")
if not BRepTools.Triangulation_s(self.wrapped, tolerance):
BRepMesh_IncrementalMesh(
@@ -3366,6 +3452,9 @@ class Shape(NodeMixin):
self, tolerance: float, angular_tolerance: float = 0.1
) -> Tuple[list[Vector], list[Tuple[int, int, int]]]:
"""General triangulated approximation"""
+ if self.wrapped is None:
+ raise ValueError("Cannot tessellate an empty shape")
+
self.mesh(tolerance, angular_tolerance)
vertices: list[Vector] = []
@@ -3373,6 +3462,7 @@ class Shape(NodeMixin):
offset = 0
for face in self.faces():
+ assert face.wrapped is not None
loc = TopLoc_Location()
poly = BRep_Tool.Triangulation_s(face.wrapped, loc)
trsf = loc.Transformation()
@@ -3409,7 +3499,7 @@ class Shape(NodeMixin):
def to_splines(
self, degree: int = 3, tolerance: float = 1e-3, nurbs: bool = False
- ) -> T:
+ ) -> Self:
"""to_splines
Approximate shape with b-splines of the specified degree.
@@ -3420,8 +3510,11 @@ class Shape(NodeMixin):
nurbs (bool, optional): Use rational splines. Defaults to False.
Returns:
- T: _description_
+ Self: Approximated shape
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot approximate an empty shape")
+
params = ShapeCustom_RestrictionParameters()
result = ShapeCustom.BSplineRestriction_s(
@@ -3441,8 +3534,8 @@ class Shape(NodeMixin):
def to_vtk_poly_data(
self,
- tolerance: float = None,
- angular_tolerance: float = None,
+ tolerance: float | None = None,
+ angular_tolerance: float | None = None,
normals: bool = False,
) -> vtkPolyData:
"""Convert shape to vtkPolyData
@@ -3454,6 +3547,9 @@ class Shape(NodeMixin):
Returns: data object in VTK consisting of points, vertices, lines, and polygons
"""
+ if self.wrapped is None:
+ raise ValueError("Cannot convert an empty shape")
+
vtk_shape = IVtkOCC_Shape(self.wrapped)
shape_data = IVtkVTK_ShapeData()
shape_mesher = IVtkOCC_ShapeMesher()
@@ -3492,25 +3588,12 @@ class Shape(NodeMixin):
return return_value
- def to_arcs(self, tolerance: float = 1e-3) -> Face:
- """to_arcs
+ def _repr_javascript_(self):
+ """Jupyter 3D representation support"""
- Approximate planar face with arcs and straight line segments.
+ from .jupyter_tools import display
- Args:
- tolerance (float, optional): Approximation tolerance. Defaults to 1e-3.
-
- Returns:
- Face: approximated face
- """
- return self.__class__(BRepAlgo.ConvertFace_s(self.wrapped, tolerance))
-
- # def _repr_javascript_(self):
- # """Jupyter 3D representation support"""
-
- # from .jupyter_tools import display
-
- # return display(self)._repr_javascript_()
+ return display(self)._repr_javascript_()
def transformed(
self, rotate: VectorLike = (0, 0, 0), offset: VectorLike = (0, 0, 0)
@@ -3552,11 +3635,12 @@ class Shape(NodeMixin):
Returns:
list[tuple[Vector, Vector]]: Point and normal of intersection
"""
- oc_shape = self.wrapped
+ if self.wrapped is None:
+ return []
intersection_line = gce_MakeLin(other.wrapped).Value()
intersect_maker = BRepIntCurveSurface_Inter()
- intersect_maker.Init(oc_shape, intersection_line, 0.0001)
+ intersect_maker.Init(self.wrapped, intersection_line, 0.0001)
intersections = []
while intersect_maker.More():
@@ -3576,7 +3660,7 @@ class Shape(NodeMixin):
intersecting_faces = [i[0] for i in intersections]
intersecting_points = [i[1] for i in intersections]
intersecting_normals = [
- f.normal_at(intersecting_points[i].to_pnt())
+ _topods_face_normal_at(f, intersecting_points[i].to_pnt())
for i, f in enumerate(intersecting_faces)
]
result = []
@@ -3657,7 +3741,7 @@ class Shape(NodeMixin):
return ShapeList(projected_faces)
-class Comparable(metaclass=ABCMeta):
+class Comparable(ABC):
"""Abstract base class that requires comparison methods"""
@abstractmethod
@@ -3695,7 +3779,11 @@ class ShapeList(list[T]):
def center(self) -> Vector:
"""The average of the center of objects within the ShapeList"""
- return sum(o.center() for o in self) / len(self) if self else Vector(0, 0, 0)
+ if not self:
+ return Vector(0, 0, 0)
+
+ total_center = sum((o.center() for o in self), Vector(0, 0, 0))
+ return total_center / len(self)
def filter_by(
self,
@@ -3729,12 +3817,17 @@ class ShapeList(list[T]):
def axis_parallel_predicate(axis: Axis, tolerance: float):
def pred(shape: Shape):
if shape.is_planar_face:
+ assert shape.wrapped is not None and isinstance(shape, Face)
gp_pnt = gp_Pnt()
- normal = gp_Vec()
+ surface_normal = gp_Vec()
u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped)
- BRepGProp_Face(shape.wrapped).Normal(u_val, v_val, gp_pnt, normal)
- normal = Vector(normal).normalized()
- shape_axis = Axis(shape.center(), normal)
+ BRepGProp_Face(shape.wrapped).Normal(
+ u_val, v_val, gp_pnt, surface_normal
+ )
+ normalized_surface_normal = Vector(
+ surface_normal.X(), surface_normal.Y(), surface_normal.Z()
+ ).normalized()
+ shape_axis = Axis(shape.center(), normalized_surface_normal)
elif (
isinstance(shape.wrapped, TopoDS_Edge)
and shape.geom_type == GeomType.LINE
@@ -3759,13 +3852,15 @@ class ShapeList(list[T]):
def pred(shape: Shape):
if shape.is_planar_face:
- gp_pnt = gp_Pnt()
- normal = gp_Vec()
+ assert shape.wrapped is not None and isinstance(shape, Face)
+ gp_pnt: gp_Pnt = gp_Pnt()
+ surface_normal: gp_Vec = gp_Vec()
u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped)
- BRepGProp_Face(shape.wrapped).Normal(u_val, v_val, gp_pnt, normal)
- normal = Vector(normal).normalized()
- shape_axis = Axis(shape.center(), normal)
- # shape_axis = Axis(shape.center(), shape.normal_at(None))
+ BRepGProp_Face(shape.wrapped).Normal(
+ u_val, v_val, gp_pnt, surface_normal
+ )
+ normalized_surface_normal = Vector(surface_normal).normalized()
+ shape_axis = Axis(shape.center(), normalized_surface_normal)
return plane_axis.is_parallel(shape_axis, tolerance)
if isinstance(shape.wrapped, TopoDS_Wire):
return all(pred(e) for e in shape.edges())
@@ -3881,6 +3976,9 @@ class ShapeList(list[T]):
"""
if isinstance(group_by, Axis):
+ if group_by.wrapped is None:
+ raise ValueError("Cannot group by an empty axis")
+ assert group_by.location is not None
axis_as_location = group_by.location.inverse()
def key_f(obj):
@@ -3892,6 +3990,8 @@ class ShapeList(list[T]):
elif hasattr(group_by, "wrapped") and isinstance(
group_by.wrapped, (TopoDS_Edge, TopoDS_Wire)
):
+ if group_by.wrapped is None:
+ raise ValueError("Cannot group by an empty Edge or Wire")
def key_f(obj):
pnt1, _pnt2 = group_by.closest_points(obj.center())
@@ -3946,7 +4046,11 @@ class ShapeList(list[T]):
Returns:
ShapeList: sorted list of objects
"""
+
if isinstance(sort_by, Axis):
+ if sort_by.wrapped is None:
+ raise ValueError("Cannot sort by an empty axis")
+ assert sort_by.location is not None
axis_as_location = sort_by.location.inverse()
objects = sorted(
self,
@@ -3956,9 +4060,12 @@ class ShapeList(list[T]):
elif hasattr(sort_by, "wrapped") and isinstance(
sort_by.wrapped, (TopoDS_Edge, TopoDS_Wire)
):
+ if sort_by.wrapped is None:
+ raise ValueError("Cannot sort by an empty Edge or Wire")
def u_of_closest_center(obj) -> float:
"""u-value of closest point between object center and sort_by"""
+ assert not isinstance(sort_by, SortBy)
pnt1, _pnt2 = sort_by.closest_points(obj.center())
return sort_by.param_at_point(pnt1)
@@ -3975,9 +4082,10 @@ class ShapeList(list[T]):
reverse=reverse,
)
elif sort_by == SortBy.RADIUS:
+ with_radius = [obj for obj in self if hasattr(obj, "radius")]
objects = sorted(
- self,
- key=lambda obj: obj.radius,
+ with_radius,
+ key=lambda obj: obj.radius, # type: ignore
reverse=reverse,
)
elif sort_by == SortBy.DISTANCE:
@@ -3987,15 +4095,17 @@ class ShapeList(list[T]):
reverse=reverse,
)
elif sort_by == SortBy.AREA:
+ with_area = [obj for obj in self if hasattr(obj, "area")]
objects = sorted(
- self,
- key=lambda obj: obj.area,
+ with_area,
+ key=lambda obj: obj.area, # type: ignore
reverse=reverse,
)
elif sort_by == SortBy.VOLUME:
+ with_volume = [obj for obj in self if hasattr(obj, "volume")]
objects = sorted(
- self,
- key=lambda obj: obj.volume,
+ with_volume,
+ key=lambda obj: obj.volume, # type: ignore
reverse=reverse,
)
@@ -4016,7 +4126,7 @@ class ShapeList(list[T]):
ShapeList: Sorted shapes
"""
distances = sorted(
- [(obj.distance_to(other), obj) for obj in self],
+ [(obj.distance_to(other), obj) for obj in self], # type: ignore
key=lambda obj: obj[0],
reverse=reverse,
)
@@ -4024,7 +4134,7 @@ class ShapeList(list[T]):
def vertices(self) -> ShapeList[Vertex]:
"""vertices - all the vertices in this ShapeList"""
- return ShapeList([v for shape in self for v in shape.vertices()])
+ return ShapeList([v for shape in self for v in shape.vertices()]) # type: ignore
def vertex(self) -> Vertex:
"""Return the Vertex"""
@@ -4038,7 +4148,7 @@ class ShapeList(list[T]):
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this ShapeList"""
- return ShapeList([e for shape in self for e in shape.edges()])
+ return ShapeList([e for shape in self for e in shape.edges()]) # type: ignore
def edge(self) -> Edge:
"""Return the Edge"""
@@ -4050,7 +4160,7 @@ class ShapeList(list[T]):
def wires(self) -> ShapeList[Wire]:
"""wires - all the wires in this ShapeList"""
- return ShapeList([w for shape in self for w in shape.wires()])
+ return ShapeList([w for shape in self for w in shape.wires()]) # type: ignore
def wire(self) -> Wire:
"""Return the Wire"""
@@ -4062,7 +4172,7 @@ class ShapeList(list[T]):
def faces(self) -> ShapeList[Face]:
"""faces - all the faces in this ShapeList"""
- return ShapeList([f for shape in self for f in shape.faces()])
+ return ShapeList([f for shape in self for f in shape.faces()]) # type: ignore
def face(self) -> Face:
"""Return the Face"""
@@ -4075,7 +4185,7 @@ class ShapeList(list[T]):
def shells(self) -> ShapeList[Shell]:
"""shells - all the shells in this ShapeList"""
- return ShapeList([s for shape in self for s in shape.shells()])
+ return ShapeList([s for shape in self for s in shape.shells()]) # type: ignore
def shell(self) -> Shell:
"""Return the Shell"""
@@ -4087,7 +4197,7 @@ class ShapeList(list[T]):
def solids(self) -> ShapeList[Solid]:
"""solids - all the solids in this ShapeList"""
- return ShapeList([s for shape in self for s in shape.solids()])
+ return ShapeList([s for shape in self for s in shape.solids()]) # type: ignore
def solid(self) -> Solid:
"""Return the Solid"""
@@ -4099,7 +4209,7 @@ class ShapeList(list[T]):
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this ShapeList"""
- return ShapeList([c for shape in self for c in shape.compounds()])
+ return ShapeList([c for shape in self for c in shape.compounds()]) # type: ignore
def compound(self) -> Compound:
"""Return the Compound"""
@@ -4111,68 +4221,63 @@ class ShapeList(list[T]):
)
return compounds[0]
- def __gt__(self, sort_by: Union[Axis, SortBy] = Axis.Z):
+ def __gt__(self, sort_by: Union[Axis, SortBy] = Axis.Z) -> ShapeList[T]: # type: ignore
"""Sort operator >"""
return self.sort_by(sort_by)
- def __lt__(self, sort_by: Union[Axis, SortBy] = Axis.Z):
+ def __lt__(self, sort_by: Union[Axis, SortBy] = Axis.Z) -> ShapeList[T]: # type: ignore
"""Reverse sort operator <"""
return self.sort_by(sort_by, reverse=True)
- def __rshift__(self, group_by: Union[Axis, SortBy] = Axis.Z):
+ def __rshift__(self, group_by: Union[Axis, SortBy] = Axis.Z) -> ShapeList[T]:
"""Group and select largest group operator >>"""
return self.group_by(group_by)[-1]
- def __lshift__(self, group_by: Union[Axis, SortBy] = Axis.Z):
+ def __lshift__(self, group_by: Union[Axis, SortBy] = Axis.Z) -> ShapeList[T]:
"""Group and select smallest group operator <<"""
return self.group_by(group_by)[0]
- def __or__(self, filter_by: Union[Axis, GeomType] = Axis.Z):
+ def __or__(self, filter_by: Union[Axis, GeomType] = Axis.Z) -> ShapeList[T]:
"""Filter by axis or geomtype operator |"""
return self.filter_by(filter_by)
- def __eq__(self, other: object):
+ def __eq__(self, other: object) -> bool:
"""ShapeLists equality operator =="""
return (
- set(self) == set(other) if isinstance(other, ShapeList) else NotImplemented
+ set(self) == set(other) if isinstance(other, ShapeList) else NotImplemented # type: ignore
)
# Normally implementing __eq__ is enough, but ShapeList subclasses list,
# which already implements __ne__, so we need to override it, too
- def __ne__(self, other: ShapeList):
+ def __ne__(self, other: ShapeList) -> bool: # type: ignore
"""ShapeLists inequality operator !="""
return (
set(self) != set(other) if isinstance(other, ShapeList) else NotImplemented
)
- def __add__(self, other: ShapeList):
+ def __add__(self, other: ShapeList) -> ShapeList[T]: # type: ignore
"""Combine two ShapeLists together operator +"""
return ShapeList(list(self) + list(other))
- def __sub__(self, other: ShapeList) -> ShapeList:
+ def __sub__(self, other: ShapeList) -> ShapeList[T]:
"""Differences between two ShapeLists operator -"""
- # hash_other = [hash(o) for o in other]
- # hash_set = {hash(o): o for o in self if hash(o) not in hash_other}
- # return ShapeList(hash_set.values())
return ShapeList(set(self) - set(other))
- def __and__(self, other: ShapeList):
+ def __and__(self, other: ShapeList) -> ShapeList[T]:
"""Intersect two ShapeLists operator &"""
return ShapeList(set(self) & set(other))
@overload
- def __getitem__(self, key: int) -> T: ...
+ def __getitem__(self, key: SupportsIndex) -> T: ...
@overload
def __getitem__(self, key: slice) -> ShapeList[T]: ...
- def __getitem__(self, key: Union[int, slice]) -> Union[T, ShapeList[T]]:
+ def __getitem__(self, key: Union[SupportsIndex, slice]) -> Union[T, ShapeList[T]]:
"""Return slices of ShapeList as ShapeList"""
if isinstance(key, slice):
- return_value = ShapeList(list(self).__getitem__(key))
- else:
- return_value = list(self).__getitem__(key)
- return return_value
+ return ShapeList(list(self).__getitem__(key))
+ return list(self).__getitem__(key)
class GroupBy(Generic[T, K]):
@@ -4243,7 +4348,7 @@ class GroupBy(Generic[T, K]):
return self.group(self.key_f(shape))
-class Compound(Mixin3D, Shape):
+class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""A Compound in build123d is a topological entity representing a collection of
geometric shapes grouped together within a single structure. It serves as a
container for organizing diverse shapes like edges, faces, or solids. This
@@ -4256,7 +4361,6 @@ class Compound(Mixin3D, Shape):
order = 4.0
project_to_viewport = Mixin1D.project_to_viewport
- extrude = _ClassMethodProxy(Mixin1D.extrude)
@classmethod
def cast(cls, obj: TopoDS_Shape) -> Self:
@@ -4280,18 +4384,17 @@ class Compound(Mixin3D, Shape):
@property
def _dim(self) -> Union[int, None]:
"""The dimension of the shapes within the Compound - None if inconsistent"""
- sub_dims = {s.dim for s in get_top_level_topods_shapes(self.wrapped)}
- return sub_dims.pop() if len(sub_dims) == 1 else None
+ return topods_dim(self.wrapped)
def __init__(
self,
obj: Optional[TopoDS_Compound | Iterable[Shape]] = None,
label: str = "",
- color: Color = None,
+ color: Color | None = None,
material: str = "",
- joints: dict[str, Joint] = None,
- parent: Compound = None,
- children: Sequence[Shape] = None,
+ joints: dict[str, Joint] | None = None,
+ parent: Compound | None = None,
+ children: Sequence[Shape] | None = None,
):
"""Build a Compound from Shapes
@@ -4505,6 +4608,26 @@ class Compound(Mixin3D, Shape):
)
return shape_list[0] if shape_list else None
+ @classmethod
+ def extrude(cls, obj: Shell, direction: VectorLike) -> Compound:
+ """extrude
+
+ Extrude a Shell into a Compound.
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Edge: extruded shape
+ """
+ return Compound(
+ TopoDS.Compound_s(_extrude_topods_shape(obj.wrapped, direction))
+ )
+
def do_children_intersect(
self, include_parent: bool = False, tolerance: float = 1e-5
) -> tuple[bool, tuple[Shape, Shape], float]:
@@ -4569,7 +4692,7 @@ class Compound(Mixin3D, Shape):
font_style: FontStyle = FontStyle.REGULAR,
align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER),
position_on_path: float = 0.0,
- text_path: Union[Edge, Wire] = None,
+ text_path: Union[Edge, Wire, None] = None,
) -> "Compound":
"""2D Text that optionally follows a path.
@@ -4866,7 +4989,7 @@ class Curve(Compound):
return Wire.combine(self.edges())
-class Edge(Mixin1D, Shape):
+class Edge(Mixin1D, Shape[TopoDS_Edge]):
"""An Edge in build123d is a fundamental element in the topological data structure
representing a one-dimensional geometric entity within a 3D model. It encapsulates
information about a curve, which could be a line, arc, or other parametrically
@@ -4885,15 +5008,15 @@ class Edge(Mixin1D, Shape):
def __init__(
self,
- obj: Optional[TopoDS_Shape | Axis] = None,
+ obj: Optional[TopoDS_Edge | Axis | None] = None,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build an Edge from an OCCT TopoDS_Shape/TopoDS_Edge
Args:
- obj (TopoDS_Shape | Axis, optional): OCCT Edge or Axis.
+ obj (TopoDS_Edge | Axis, optional): OCCT Edge or Axis.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
@@ -5021,7 +5144,7 @@ class Edge(Mixin1D, Shape):
return vertex_intersections, edge_intersections
def find_intersection_points(
- self, other: Axis | Edge = None, tolerance: float = TOLERANCE
+ self, other: Axis | Edge | None = None, tolerance: float = TOLERANCE
) -> ShapeList[Vector]:
"""find_intersection_points
@@ -5138,6 +5261,24 @@ class Edge(Mixin1D, Shape):
return ShapeList(common_vertices + common_edges)
return None
+ @classmethod
+ def extrude(cls, obj: Vertex, direction: VectorLike) -> Edge:
+ """extrude
+
+ Extrude a Vertex into an Edge.
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Edge: extruded shape
+ """
+ return Edge(TopoDS.Edge_s(_extrude_topods_shape(obj.wrapped, direction)))
+
def reversed(self) -> Edge:
"""Return a copy of self with the opposite orientation"""
reversed_edge = copy.deepcopy(self)
@@ -5254,7 +5395,9 @@ class Edge(Mixin1D, Shape):
return u_value
@classmethod
- def make_bezier(cls, *cntl_pnts: VectorLike, weights: list[float] = None) -> Edge:
+ def make_bezier(
+ cls, *cntl_pnts: VectorLike, weights: list[float] | None = None
+ ) -> Edge:
"""make_bezier
Create a rational (with weights) or non-rational bezier curve. The first and last
@@ -5422,9 +5565,9 @@ class Edge(Mixin1D, Shape):
def make_spline(
cls,
points: list[VectorLike],
- tangents: list[VectorLike] = None,
+ tangents: list[VectorLike] | None = None,
periodic: bool = False,
- parameters: list[float] = None,
+ parameters: list[float] | None = None,
scale: bool = True,
tol: float = 1e-6,
) -> Edge:
@@ -5518,7 +5661,7 @@ class Edge(Mixin1D, Shape):
cls,
points: list[VectorLike],
tol: float = 1e-3,
- smoothing: Tuple[float, float, float] = None,
+ smoothing: Tuple[float, float, float] | None = None,
min_deg: int = 1,
max_deg: int = 6,
) -> Edge:
@@ -5728,8 +5871,8 @@ class Edge(Mixin1D, Shape):
def project_to_shape(
self,
target_object: Shape,
- direction: VectorLike = None,
- center: VectorLike = None,
+ direction: VectorLike | None = None,
+ center: VectorLike | None = None,
) -> list[Edge]:
"""Project Edge
@@ -5770,7 +5913,7 @@ class Edge(Mixin1D, Shape):
return Axis(self.position_at(0), self.position_at(1) - self.position_at(0))
-class Face(Mixin2D, Shape):
+class Face(Mixin2D, Shape[TopoDS_Face]):
"""A Face in build123d represents a 3D bounded surface within the topological data
structure. It encapsulates geometric information, defining a face of a 3D shape.
These faces are integral components of complex structures, such as solids and
@@ -5788,10 +5931,10 @@ class Face(Mixin2D, Shape):
@overload
def __init__(
self,
- obj: TopoDS_Shape,
+ obj: TopoDS_Face,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a Face from an OCCT TopoDS_Shape/TopoDS_Face
@@ -5806,10 +5949,10 @@ class Face(Mixin2D, Shape):
def __init__(
self,
outer_wire: Wire,
- inner_wires: Iterable[Wire] = None,
+ inner_wires: Iterable[Wire] | None = None,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a planar Face from a boundary Wire with optional hole Wires.
@@ -5868,7 +6011,7 @@ class Face(Mixin2D, Shape):
parent=parent,
)
# Faces can optionally record the plane it was created on for later extrusion
- self.created_on: Plane = None
+ self.created_on: Plane | None = None
@property
def length(self) -> float:
@@ -5946,7 +6089,7 @@ class Face(Mixin2D, Shape):
return BRepTools.UVBounds_s(self.wrapped)
@overload
- def normal_at(self, surface_point: VectorLike = None) -> Vector:
+ def normal_at(self, surface_point: VectorLike | None = None) -> Vector:
"""normal_at point on surface
Args:
@@ -6056,7 +6199,9 @@ class Face(Mixin2D, Shape):
return Vector(gp_pnt)
- def location_at(self, u: float, v: float, x_dir: VectorLike = None) -> Location:
+ def location_at(
+ self, u: float, v: float, x_dir: VectorLike | None = None
+ ) -> Location:
"""Location at the u/v position of face"""
origin = self.position_at(u, v)
if x_dir is None:
@@ -6116,6 +6261,24 @@ class Face(Mixin2D, Shape):
)
return self.outer_wire()
+ @classmethod
+ def extrude(cls, obj: Edge, direction: VectorLike) -> Face:
+ """extrude
+
+ Extrude an Edge into a Face.
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Face: extruded shape
+ """
+ return Face(TopoDS.Face_s(_extrude_topods_shape(obj.wrapped, direction)))
+
@classmethod
def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face:
"""make_rect
@@ -6268,7 +6431,7 @@ class Face(Mixin2D, Shape):
cls,
points: list[list[VectorLike]],
tol: float = 1e-2,
- smoothing: Tuple[float, float, float] = None,
+ smoothing: Tuple[float, float, float] | None = None,
min_deg: int = 1,
max_deg: int = 3,
) -> Face:
@@ -6322,7 +6485,7 @@ class Face(Mixin2D, Shape):
def make_bezier_surface(
cls,
points: list[list[VectorLike]],
- weights: list[list[float]] = None,
+ weights: list[list[float]] | None = None,
) -> Face:
"""make_bezier_surface
@@ -6371,8 +6534,8 @@ class Face(Mixin2D, Shape):
def make_surface(
cls,
exterior: Union[Wire, Iterable[Edge]],
- surface_points: Iterable[VectorLike] = None,
- interior_wires: Iterable[Wire] = None,
+ surface_points: Iterable[VectorLike] | None = None,
+ interior_wires: Iterable[Wire] | None = None,
) -> Face:
"""Create Non-Planar Face
@@ -6500,7 +6663,7 @@ class Face(Mixin2D, Shape):
distance: float,
distance2: float,
vertices: Iterable[Vertex],
- edge: Edge = None,
+ edge: Edge | None = None,
) -> Face:
"""Apply 2D chamfer to a face
@@ -6520,7 +6683,6 @@ class Face(Mixin2D, Shape):
"""
reference_edge = edge
- del edge
chamfer_builder = BRepFilletAPI_MakeFillet2d(self.wrapped)
@@ -6685,8 +6847,24 @@ class Face(Mixin2D, Shape):
# projector = GeomAPI_ProjectPointOnSurf(Vector(point).to_pnt(), surface)
# return projector.LowerDistance() <= TOLERANCE
+ def to_arcs(self, tolerance: float = 1e-3) -> Face:
+ """to_arcs
-class Shell(Mixin2D, Shape):
+ Approximate planar face with arcs and straight line segments.
+
+ Args:
+ tolerance (float, optional): Approximation tolerance. Defaults to 1e-3.
+
+ Returns:
+ Face: approximated face
+ """
+ if self.wrapped is None:
+ raise ValueError("Cannot approximate an empty shape")
+
+ return self.__class__(BRepAlgo.ConvertFace_s(self.wrapped, tolerance))
+
+
+class Shell(Mixin2D, Shape[TopoDS_Shell]):
"""A Shell is a fundamental component in build123d's topological data structure
representing a connected set of faces forming a closed surface in 3D space. As
part of a geometric model, it defines a watertight enclosure, commonly encountered
@@ -6703,10 +6881,10 @@ class Shell(Mixin2D, Shape):
def __init__(
self,
- obj: Optional[TopoDS_Shape | Face | Iterable[Face]] = None,
+ obj: Optional[TopoDS_Shell | Face | Iterable[Face]] = None,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a shell from an OCCT TopoDS_Shape/TopoDS_Shell
@@ -6752,6 +6930,24 @@ class Shell(Mixin2D, Shape):
BRepGProp.LinearProperties_s(self.wrapped, properties)
return Vector(properties.CentreOfMass())
+ @classmethod
+ def extrude(cls, obj: Wire, direction: VectorLike) -> Shell:
+ """extrude
+
+ Extrude a Wire into a Shell.
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Edge: extruded shape
+ """
+ return Shell(TopoDS.Shell_s(_extrude_topods_shape(obj.wrapped, direction)))
+
@classmethod
def sweep(
cls,
@@ -6807,7 +7003,7 @@ class Shell(Mixin2D, Shape):
return cls(_make_loft(objs, False, ruled))
-class Solid(Mixin3D, Shape):
+class Solid(Mixin3D, Shape[TopoDS_Solid]):
"""A Solid in build123d represents a three-dimensional solid geometry
in a topological structure. A solid is a closed and bounded volume, enclosing
a region in 3D space. It comprises faces, edges, and vertices connected in a
@@ -6823,12 +7019,12 @@ class Solid(Mixin3D, Shape):
def __init__(
self,
- obj: TopoDS_Shape | Shell = None,
+ obj: TopoDS_Solid | Shell | None = None,
label: str = "",
- color: Color = None,
+ color: Color | None = None,
material: str = "",
- joints: dict[str, Joint] = None,
- parent: Compound = None,
+ joints: dict[str, Joint] | None = None,
+ parent: Compound | None = None,
):
"""Build a solid from an OCCT TopoDS_Shape/TopoDS_Solid
@@ -6865,6 +7061,24 @@ class Solid(Mixin3D, Shape):
"""Create a Solid object from the surface shell"""
return ShapeFix_Solid().SolidFromShell(shell.wrapped)
+ @classmethod
+ def extrude(cls, obj: Face, direction: VectorLike) -> Solid:
+ """extrude
+
+ Extrude a Face into a Solid.
+
+ Args:
+ direction (VectorLike): direction and magnitude of extrusion
+
+ Raises:
+ ValueError: Unsupported class
+ RuntimeError: Generated invalid result
+
+ Returns:
+ Edge: extruded shape
+ """
+ return Solid(TopoDS.Solid_s(_extrude_topods_shape(obj.wrapped, direction)))
+
@classmethod
def from_bounding_box(cls, bbox: BoundBox) -> Solid:
"""A box of the same dimensions and location"""
@@ -7169,7 +7383,7 @@ class Solid(Mixin3D, Shape):
center: VectorLike,
normal: VectorLike,
angle: float,
- inner_wires: list[Wire] = None,
+ inner_wires: list[Wire] | None = None,
) -> Solid:
"""Extrude with Rotation
@@ -7351,7 +7565,7 @@ class Solid(Mixin3D, Shape):
section: Union[Face, Wire],
angle: float,
axis: Axis,
- inner_wires: list[Wire] = None,
+ inner_wires: list[Wire] | None = None,
) -> Solid:
"""Revolve
@@ -7407,7 +7621,7 @@ class Solid(Mixin3D, Shape):
cls,
section: Union[Face, Wire],
path: Union[Wire, Edge],
- inner_wires: list[Wire] = None,
+ inner_wires: list[Wire] | None = None,
make_solid: bool = True,
is_frenet: bool = False,
mode: Union[Vector, Wire, Edge, None] = None,
@@ -7579,7 +7793,7 @@ class Solid(Mixin3D, Shape):
return result
-class Vertex(Shape):
+class Vertex(Shape[TopoDS_Vertex]):
"""A Vertex in build123d represents a zero-dimensional point in the topological
data structure. It marks the endpoints of edges within a 3D model, defining precise
locations in space. Vertices play a crucial role in defining the geometry of objects
@@ -7801,8 +8015,13 @@ class Vertex(Shape):
"""
return Vertex(*t_matrix.multiply(Vector(self)))
+ @classmethod
+ def extrude(cls, obj: Shape, direction: VectorLike) -> Vertex:
+ """extrude - invalid operation for Vertex"""
+ raise NotImplementedError("Vertices can't be created by extrusion")
-class Wire(Mixin1D, Shape):
+
+class Wire(Mixin1D, Shape[TopoDS_Wire]):
"""A Wire in build123d is a topological entity representing a connected sequence
of edges forming a continuous curve or path in 3D space. Wires are essential
components in modeling complex objects, defining boundaries for surfaces or
@@ -7818,15 +8037,15 @@ class Wire(Mixin1D, Shape):
@overload
def __init__(
self,
- obj: TopoDS_Shape,
+ obj: TopoDS_Wire,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a wire from an OCCT TopoDS_Wire
Args:
- obj (TopoDS_Shape, optional): OCCT Wire.
+ obj (TopoDS_Wire, optional): OCCT Wire.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
@@ -7837,8 +8056,8 @@ class Wire(Mixin1D, Shape):
self,
edge: Edge,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a Wire from an Edge
@@ -7854,8 +8073,8 @@ class Wire(Mixin1D, Shape):
self,
wire: Wire,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a Wire from an Wire - used when the input could be an Edge or Wire.
@@ -7871,8 +8090,8 @@ class Wire(Mixin1D, Shape):
self,
wire: Curve,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a Wire from an Curve.
@@ -7889,8 +8108,8 @@ class Wire(Mixin1D, Shape):
edges: Iterable[Edge],
sequenced: bool = False,
label: str = "",
- color: Color = None,
- parent: Compound = None,
+ color: Color | None = None,
+ parent: Compound | None = None,
):
"""Build a wire from Edges
@@ -7912,7 +8131,7 @@ class Wire(Mixin1D, Shape):
if args:
l_a = len(args)
- if isinstance(args[0], TopoDS_Shape):
+ if isinstance(args[0], TopoDS_Wire):
obj, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Edge):
edge, label, color, parent = args[:4] + (None,) * (4 - l_a)
@@ -7922,7 +8141,7 @@ class Wire(Mixin1D, Shape):
elif (
hasattr(args[0], "wrapped")
and isinstance(args[0].wrapped, TopoDS_Compound)
- and args[0].wrapped.dim == 1
+ and topods_dim(args[0].wrapped) == 1
): # Curve
curve, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Iterable):
@@ -8020,6 +8239,11 @@ class Wire(Mixin1D, Shape):
return wires
+ @classmethod
+ def extrude(cls, obj: Shape, direction: VectorLike) -> Wire:
+ """extrude - invalid operation for Wire"""
+ raise NotImplementedError("Wires can't be created by extrusion")
+
def fix_degenerate_edges(self, precision: float) -> Wire:
"""fix_degenerate_edges
@@ -8363,7 +8587,7 @@ class Wire(Mixin1D, Shape):
distance: float,
distance2: float,
vertices: Iterable[Vertex],
- edge: Edge = None,
+ edge: Edge | None = None,
) -> Wire:
"""chamfer_2d
@@ -8581,8 +8805,8 @@ class Wire(Mixin1D, Shape):
def project_to_shape(
self,
target_object: Shape,
- direction: VectorLike = None,
- center: VectorLike = None,
+ direction: VectorLike | None = None,
+ center: VectorLike | None = None,
) -> list[Wire]:
"""Project Wire
@@ -8701,14 +8925,15 @@ class Joint(ABC):
def __init__(self, label: str, parent: Union[Solid, Compound]):
self.label = label
self.parent = parent
- self.connected_to: Joint = None
+ self.connected_to: Joint | None = None
def _connect_to(self, other: Joint, **kwargs): # pragma: no cover
"""Connect Joint self by repositioning other"""
if not isinstance(other, Joint):
raise TypeError(f"other must of type Joint not {type(other)}")
-
+ if self.parent.location is None:
+ raise ValueError("Parent location is not set")
relative_location = self.relative_to(other, **kwargs)
other.parent.locate(self.parent.location * relative_location)
self.connected_to = other
@@ -8753,6 +8978,7 @@ def _make_loft(
Returns:
TopoDS_Shape: Lofted object
"""
+ objs = list(objs) # To determine its length
if len(objs) < 2:
raise ValueError("More than one wire is required")
vertices = [obj for obj in objs if isinstance(obj.wrapped, TopoDS_Vertex)]
@@ -8811,7 +9037,7 @@ def downcast(obj: TopoDS_Shape) -> TopoDS_Shape:
return return_value
-def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> list[Wire]:
+def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> ShapeList[Wire]:
"""Convert edges to a list of wires.
Args:
@@ -8826,12 +9052,14 @@ def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> list[Wire]:
wires_out = TopTools_HSequenceOfShape()
for edge in edges:
- edges_in.Append(edge.wrapped)
+ if edge.wrapped is not None:
+ edges_in.Append(edge.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
- wires = ShapeList()
+ wires: ShapeList[Wire] = ShapeList()
for i in range(wires_out.Length()):
- wires.append(Wire(downcast(wires_out.Value(i + 1))))
+ # wires.append(Wire(downcast(wires_out.Value(i + 1))))
+ wires.append(Wire(TopoDS.Wire_s(wires_out.Value(i + 1))))
return wires
@@ -8870,9 +9098,9 @@ def isclose_b(x: float, y: float, rel_tol=1e-9, abs_tol=1e-14) -> bool:
return isclose(x, y, rel_tol=rel_tol, abs_tol=abs_tol)
-def shapetype(obj: TopoDS_Shape) -> TopAbs_ShapeEnum:
+def shapetype(obj: TopoDS_Shape | None) -> TopAbs_ShapeEnum:
"""Return TopoDS_Shape's TopAbs_ShapeEnum"""
- if obj.IsNull():
+ if obj is None or obj.IsNull():
raise ValueError("Null TopoDS_Shape object")
return obj.ShapeType()
@@ -8968,12 +9196,14 @@ def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]:
# Create a list of combined object edges
combined_topo_edges = TopTools_ListOfShape()
for edge in combined.edges():
- combined_topo_edges.Append(edge.wrapped)
+ if edge.wrapped is not None:
+ combined_topo_edges.Append(edge.wrapped)
# Create a list of original object edges
original_topo_edges = TopTools_ListOfShape()
for edge in [e for obj in objects for e in obj.edges()]:
- original_topo_edges.Append(edge.wrapped)
+ if edge.wrapped is not None:
+ original_topo_edges.Append(edge.wrapped)
# Cut the original edges from the combined edges
operation = BRepAlgoAPI_Cut()
@@ -8993,15 +9223,21 @@ def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]:
return ShapeList(edges)
-def topo_explore_connected_edges(edge: Edge, parent: Shape = None) -> ShapeList[Edge]:
+def topo_explore_connected_edges(
+ edge: Edge, parent: Shape | None = None
+) -> ShapeList[Edge]:
"""Given an edge extracted from a Shape, return the edges connected to it"""
parent = parent if parent is not None else edge.topo_parent
+ if parent is None:
+ raise ValueError("edge has no valid parent")
given_topods_edge = edge.wrapped
+ if given_topods_edge is None:
+ raise ValueError("edge is empty")
connected_edges = set()
# Find all the TopoDS_Edges for this Shape
- topods_edges = ShapeList([e.wrapped for e in parent.edges()])
+ topods_edges = [e.wrapped for e in parent.edges() if e.wrapped is not None]
for topods_edge in topods_edges:
# # Don't match with the given edge
@@ -9011,7 +9247,7 @@ def topo_explore_connected_edges(edge: Edge, parent: Shape = None) -> ShapeList[
if topo_explore_common_vertex(given_topods_edge, topods_edge) is not None:
connected_edges.add(topods_edge)
- return ShapeList([Edge(e) for e in connected_edges])
+ return ShapeList(Edge(e) for e in connected_edges)
def topo_explore_common_vertex(
@@ -9021,6 +9257,9 @@ def topo_explore_common_vertex(
topods_edge1 = edge1 if isinstance(edge1, TopoDS_Edge) else edge1.wrapped
topods_edge2 = edge2 if isinstance(edge2, TopoDS_Edge) else edge2.wrapped
+ if topods_edge1 is None or topods_edge2 is None:
+ raise ValueError("edge is empty")
+
# Explore vertices of the first edge
vert_exp = TopExp_Explorer(topods_edge1, ta.TopAbs_VERTEX)
while vert_exp.More():
@@ -9033,7 +9272,7 @@ def topo_explore_common_vertex(
# Check if the vertices are the same
if vertex1.IsSame(vertex2):
- return Vertex(downcast(vertex1)) # Common vertex found
+ return Vertex(TopoDS.Vertex_s(vertex1)) # Common vertex found
explorer2.Next()
vert_exp.Next()
@@ -9068,7 +9307,7 @@ def unwrap_topods_compound(
return compound
-def get_top_level_topods_shapes(topods_shape: TopoDS_Shape) -> ShapeList[TopoDS_Shape]:
+def get_top_level_topods_shapes(topods_shape: TopoDS_Shape) -> list[TopoDS_Shape]:
"""
Retrieve the first level of child shapes from the shape.
@@ -9079,7 +9318,7 @@ def get_top_level_topods_shapes(topods_shape: TopoDS_Shape) -> ShapeList[TopoDS_
without returning the nested compound itself.
Returns:
- ShapeList[TopoDS_Shape]: A list of all first-level non-compound child shapes.
+ list[TopoDS_Shape]: A list of all first-level non-compound child shapes.
Example:
If the current shape is a compound containing both simple shapes
@@ -9109,7 +9348,7 @@ def get_top_level_topods_shapes(topods_shape: TopoDS_Shape) -> ShapeList[TopoDS_
else:
first_level_shapes.append(current_shape)
- return ShapeList(first_level_shapes)
+ return first_level_shapes
def _topods_bool_op(
@@ -9151,7 +9390,7 @@ def _topods_bool_op(
def _make_topods_face_from_wires(
- outer_wire: TopoDS_Wire, inner_wires: Optional[Sequence[TopoDS_Wire]] = None
+ outer_wire: TopoDS_Wire, inner_wires: Optional[Iterable[TopoDS_Wire]] = None
) -> TopoDS_Face:
"""_make_topods_face_from_wires
@@ -9159,7 +9398,7 @@ def _make_topods_face_from_wires(
Args:
outer_wire (TopoDS_Wire): closed perimeter wire
- inner_wires (Sequence[TopoDS_Wire], optional): holes. Defaults to None.
+ inner_wires (Iterable[TopoDS_Wire], optional): holes. Defaults to None.
Raises:
ValueError: outer wire not closed
@@ -9172,7 +9411,7 @@ def _make_topods_face_from_wires(
"""
if inner_wires and not BRep_Tool.IsClosed_s(outer_wire):
raise ValueError("Cannot build face(s): outer wire is not closed")
- inner_wires = inner_wires if inner_wires else []
+ inner_wires = list(inner_wires) if inner_wires else []
# check if wires are coplanar
verification_compound = _make_topods_compound_from_shapes(
@@ -9204,7 +9443,7 @@ def _make_topods_face_from_wires(
sf_f.FixOrientation()
sf_f.Perform()
- return downcast(sf_f.Result())
+ return TopoDS.Face_s(sf_f.Result())
def _sew_topods_faces(faces: Sequence[TopoDS_Face]) -> TopoDS_Shape:
@@ -9217,7 +9456,7 @@ def _sew_topods_faces(faces: Sequence[TopoDS_Face]) -> TopoDS_Shape:
def _make_topods_compound_from_shapes(
- occt_shapes: Sequence[TopoDS_Shape],
+ occt_shapes: Sequence[TopoDS_Shape | None],
) -> TopoDS_Compound:
"""Create an OCCT TopoDS_Compound
@@ -9234,7 +9473,8 @@ def _make_topods_compound_from_shapes(
comp_builder.MakeCompound(comp)
for shape in occt_shapes:
- comp_builder.Add(comp, shape)
+ if shape is not None:
+ comp_builder.Add(comp, shape)
return comp
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 0ae4030..1892bcd 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -3211,10 +3211,6 @@ class TestShape(DirectApiTestCase):
self.assertVectorAlmostEquals(box.position, (1, 2, 3), 5)
self.assertVectorAlmostEquals(box.orientation, (10, 20, 30), 5)
- def test_copy(self):
- with self.assertWarns(DeprecationWarning):
- Solid.make_box(1, 1, 1).copy()
-
def test_distance_to_with_closest_points(self):
s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0)))
s1 = Solid.make_sphere(1)
diff --git a/tests/test_exporters.py b/tests/test_exporters.py
index a038057..6e90d85 100644
--- a/tests/test_exporters.py
+++ b/tests/test_exporters.py
@@ -32,6 +32,8 @@ from build123d import (
)
from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType
+from ocp_vscode import show
+
class ExportersTestCase(unittest.TestCase):
@staticmethod
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 6fa5c27..9bccdfd 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -87,9 +87,6 @@ Key Features:
- `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes.
- `new_edges`: Identifies newly created edges from combined shapes.
-- **Utility Classes**:
- - `_ClassMethodProxy`: Dynamically binds methods across classes.
-
- **Enhanced Math**:
- `isclose_b`: Overrides `math.isclose` with a stricter absolute tolerance.
@@ -354,7 +351,7 @@ def write_topo_class_files(
"get_top_level_topods_shapes",
"_sew_topods_faces",
"shapetype",
- "_topods_compound_dim",
+ "topods_dim",
"_topods_entities",
"_topods_face_normal_at",
"apply_ocp_monkey_patches",
@@ -386,7 +383,6 @@ def write_topo_class_files(
# Define class groupings based on layers
class_groups = {
- "utils": ["_ClassMethodProxy"],
"shape_core": [
"Shape",
"Comparable",
@@ -456,7 +452,6 @@ license:
additional_imports.append(
"from .shape_core import Shape, ShapeList, BoundBox, SkipClean, TrimmingTool, Joint"
)
- additional_imports.append("from .utils import _ClassMethodProxy")
if group_name not in ["shape_core", "vertex"]:
for sub_group_name in function_source.keys():
additional_imports.append(
@@ -508,26 +503,6 @@ license:
# if group_name in ["shape_core", "utils"]:
if group_name in function_source.keys():
body = [*cst.parse_module(all_imports_code).body]
- for func in function_collector.functions:
- if group_name == "shape_core" and func.name.value in [
- "_topods_compound_dim",
- "_topods_face_normal_at",
- "apply_ocp_monkey_patches",
- ]:
- body.append(func)
-
- # If this is the "apply_ocp_monkey_patches" function, add a call to it
- if (
- group_name == "shape_core"
- and func.name.value == "apply_ocp_monkey_patches"
- ):
- apply_patches_call = cst.Expr(
- value=cst.Call(func=cst.Name("apply_ocp_monkey_patches"))
- )
- body.append(apply_patches_call)
- body.append(cst.EmptyLine(indent=False))
- body.append(cst.EmptyLine(indent=False))
-
if group_name == "shape_core":
for var in variable_collector.global_variables:
# Check the name of the assigned variable(s)
@@ -562,13 +537,7 @@ license:
body.append(cst.EmptyLine(indent=False))
for func in function_collector.functions:
- if func.name.value in function_source[
- group_name
- ] and func.name.value not in [
- "_topods_compound_dim",
- "_topods_face_normal_at",
- "apply_ocp_monkey_patches",
- ]:
+ if func.name.value in function_source[group_name]:
body.append(func)
class_module = cst.Module(body=body, header=header)
else:
@@ -756,7 +725,6 @@ def main():
# Define classes to extract
class_names = [
- "_ClassMethodProxy",
"BoundBox",
"Shape",
"Compound",
From 59bb0373a87e833469b44660ef5e57e3677a7235 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 20 Dec 2024 14:39:48 -0500
Subject: [PATCH 046/518] Review viewer import
---
tests/test_exporters.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/tests/test_exporters.py b/tests/test_exporters.py
index 6e90d85..a038057 100644
--- a/tests/test_exporters.py
+++ b/tests/test_exporters.py
@@ -32,8 +32,6 @@ from build123d import (
)
from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType
-from ocp_vscode import show
-
class ExportersTestCase(unittest.TestCase):
@staticmethod
From 5be9a270431619d0eb0b1bb0331df34cb20a6d41 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sat, 21 Dec 2024 11:06:49 -0500
Subject: [PATCH 047/518] mypy fixes for zero_d.py & latest code review changes
---
src/build123d/geometry.py | 15 ++++--
src/build123d/topology.py | 110 +++++++++++++++++++-------------------
2 files changed, 67 insertions(+), 58 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index fa9a26e..209ff8f 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -861,8 +861,12 @@ class BoundBox:
"""A BoundingBox for a Shape"""
def __init__(self, bounding_box: Bnd_Box) -> None:
- self.wrapped: Bnd_Box = bounding_box
- x_min, y_min, z_min, x_max, y_max, z_max = bounding_box.Get()
+ if bounding_box.IsVoid():
+ self.wrapped = None
+ x_min, y_min, z_min, x_max, y_max, z_max = (0,) * 6
+ else:
+ self.wrapped: Bnd_Box = bounding_box
+ x_min, y_min, z_min, x_max, y_max, z_max = bounding_box.Get()
self.min = Vector(x_min, y_min, z_min) #: location of minimum corner
self.max = Vector(x_max, y_max, z_max) #: location of maximum corner
self.size = Vector(x_max - x_min, y_max - y_min, z_max - z_min) #: overall size
@@ -870,6 +874,8 @@ class BoundBox:
@property
def diagonal(self) -> float:
"""body diagonal length (i.e. object maximum size)"""
+ if self.wrapped is None:
+ return 0.0
return self.wrapped.SquareExtent() ** 0.5
def __repr__(self):
@@ -914,13 +920,14 @@ class BoundBox:
tmp = Bnd_Box()
tmp.SetGap(tol)
- tmp.Add(self.wrapped)
+ if self.wrapped is not None:
+ tmp.Add(self.wrapped)
if isinstance(obj, tuple):
tmp.Update(*obj)
elif isinstance(obj, Vector):
tmp.Update(*obj.to_tuple())
- elif isinstance(obj, BoundBox):
+ elif isinstance(obj, BoundBox) and obj.wrapped is not None:
tmp.Add(obj.wrapped)
return BoundBox(tmp)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 7299d6b..3e07edc 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -1469,6 +1469,7 @@ class Mixin3D:
Returns:
Self: Chamfered solid
"""
+ edge_list = list(edge_list)
if face:
if any((edge for edge in edge_list if edge not in face.edges())):
raise ValueError("Some edges are not part of the face")
@@ -1566,6 +1567,7 @@ class Mixin3D:
Returns:
Solid: A hollow solid.
"""
+ faces = list(faces) if faces else []
if kind == Kind.TANGENT:
raise ValueError("Kind.TANGENT not supported")
@@ -1631,6 +1633,7 @@ class Mixin3D:
Returns:
Solid: A shelled solid.
"""
+ openings = list(openings) if openings else []
if kind == Kind.TANGENT:
raise ValueError("Kind.TANGENT not supported")
@@ -1771,7 +1774,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
# pylint: disable=too-many-instance-attributes, too-many-public-methods
- _dim = None
+ @property
+ @abstractmethod
+ def _dim(self) -> int | None:
+ """Dimension of the object"""
shape_LUT = {
ta.TopAbs_VERTEX: "Vertex",
@@ -2174,7 +2180,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
result = Shape._show_tree(tree[0], show_center)
return result
- def __add__(self, other: Union[list[Shape], Shape]) -> Self:
+ def __add__(self, other: Union[list[Shape], Shape]) -> Self | ShapeList[Self]:
"""fuse shape to self operator +"""
# Convert `other` to list of base objects and filter out None values
summands = [
@@ -2208,7 +2214,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return sum_shape
- def __sub__(self, other: Union[Shape, Iterable[Shape]]) -> Self:
+ def __sub__(self, other: Union[Shape, Iterable[Shape]]) -> Self | ShapeList[Self]:
"""cut shape from self operator -"""
if self.wrapped is None:
@@ -2483,6 +2489,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
Returns:
Vector: center of multiple objects
"""
+ objects = list(objects)
if center_of == CenterOf.MASS:
total_mass = sum(Shape.compute_mass(o) for o in objects)
weighted_centers = [
@@ -2999,8 +3006,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
Returns:
"""
+ args = list(args)
+ tools = list(tools)
# Find the highest order class from all the inputs Solid > Vertex
- order_dict = {type(s): type(s).order for s in [self] + list(args) + list(tools)}
+ order_dict = {type(s): type(s).order for s in [self] + args + tools}
highest_order = sorted(order_dict.items(), key=lambda item: item[1])[-1]
# The base of the operation
@@ -4257,7 +4266,7 @@ class ShapeList(list[T]):
def __add__(self, other: ShapeList) -> ShapeList[T]: # type: ignore
"""Combine two ShapeLists together operator +"""
- return ShapeList(list(self) + list(other))
+ return ShapeList(itertools.chain(self, other))
def __sub__(self, other: ShapeList) -> ShapeList[T]:
"""Differences between two ShapeLists operator -"""
@@ -4409,7 +4418,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""
if isinstance(obj, Iterable):
- obj = _make_topods_compound_from_shapes([s.wrapped for s in obj])
+ obj = _make_topods_compound_from_shapes(s.wrapped for s in obj)
super().__init__(
obj=obj,
@@ -4482,7 +4491,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
logger.debug("Removing parent of %s (%s)", self.label, parent.label)
if parent.children:
parent.wrapped = _make_topods_compound_from_shapes(
- [c.wrapped for c in parent.children]
+ c.wrapped for c in parent.children
)
else:
parent.wrapped = None
@@ -4496,7 +4505,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""Method call after attaching to `parent`."""
logger.debug("Updated parent of %s to %s", self.label, parent.label)
parent.wrapped = _make_topods_compound_from_shapes(
- [c.wrapped for c in parent.children]
+ c.wrapped for c in parent.children
)
def _post_detach_children(self, children):
@@ -4505,7 +4514,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
kids = ",".join([child.label for child in children])
logger.debug("Removing children %s from %s", kids, self.label)
self.wrapped = _make_topods_compound_from_shapes(
- [c.wrapped for c in self.children]
+ c.wrapped for c in self.children
)
# else:
# logger.debug("Removing no children from %s", self.label)
@@ -4521,12 +4530,12 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
kids = ",".join([child.label for child in children])
logger.debug("Adding children %s to %s", kids, self.label)
self.wrapped = _make_topods_compound_from_shapes(
- [c.wrapped for c in self.children]
+ c.wrapped for c in self.children
)
# else:
# logger.debug("Adding no children to %s", self.label)
- def __add__(self, other: Shape | Sequence[Shape]) -> Compound:
+ def __add__(self, other: Shape | Iterable[Shape]) -> Compound:
"""Combine other to self `+` operator
Note that if all of the objects are connected Edges/Wires the result
@@ -4566,7 +4575,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
return result
- def __sub__(self, other: Shape | Sequence[Shape]) -> Compound:
+ def __sub__(self, other: Shape | Iterable[Shape]) -> Compound:
"""Cut other to self `-` operator"""
difference = Shape.__sub__(self, other)
difference = Compound(
@@ -4576,7 +4585,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
return difference
- def __and__(self, other: Shape | Sequence[Shape]) -> Compound:
+ def __and__(self, other: Shape | Iterable[Shape]) -> Compound:
"""Intersect other to self `&` operator"""
intersection = Shape.__and__(self, other)
intersection = Compound(
@@ -6558,6 +6567,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
Returns:
Face: Potentially non-planar face
"""
+ exterior = list(exterior) if isinstance(exterior, Iterable) else exterior
# pylint: disable=too-many-branches
if surface_points:
surface_points = [Vector(p) for p in surface_points]
@@ -6894,7 +6904,7 @@ class Shell(Mixin2D, Shape[TopoDS_Shell]):
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
-
+ obj = list(obj) if isinstance(obj, Iterable) else obj
if isinstance(obj, Iterable) and len(obj) == 1:
obj = obj[0]
@@ -7812,7 +7822,7 @@ class Vertex(Shape[TopoDS_Vertex]):
"""Default Vertext at the origin"""
@overload
- def __init__(self, v: TopoDS_Vertex): # pragma: no cover
+ def __init__(self, ocp_vx: TopoDS_Vertex): # pragma: no cover
"""Vertex from OCCT TopoDS_Vertex object"""
@overload
@@ -7823,40 +7833,30 @@ class Vertex(Shape[TopoDS_Vertex]):
def __init__(self, v: Iterable[float]):
"""Vertex from Vector or other iterators"""
- @overload
- def __init__(self, v: tuple[float]):
- """Vertex from tuple of floats"""
-
def __init__(self, *args, **kwargs):
self.vertex_index = 0
- x, y, z, ocp_vx = 0, 0, 0, None
- unknown_args = ", ".join(set(kwargs.keys()).difference(["v", "X", "Y", "Z"]))
- if unknown_args:
- raise ValueError(f"Unexpected argument(s) {unknown_args}")
+ ocp_vx = kwargs.pop("ocp_vx", None)
+ v = kwargs.pop("v", None)
+ x = kwargs.pop("X", 0)
+ y = kwargs.pop("Y", 0)
+ z = kwargs.pop("Z", 0)
- if args and all(isinstance(args[i], (int, float)) for i in range(len(args))):
- values = list(args)
- values += [0.0] * max(0, (3 - len(args)))
- x, y, z = values[0:3]
- elif len(args) == 1 or "v" in kwargs:
- first_arg = args[0] if args else None
- first_arg = kwargs.get("v", first_arg) # override with kwarg
- if isinstance(first_arg, (tuple, Iterable)):
- try:
- values = [float(value) for value in first_arg]
- except (TypeError, ValueError) as exc:
- raise TypeError("Expected floats") from exc
- if len(values) < 3:
- values += [0.0] * (3 - len(values))
- x, y, z = values
- elif isinstance(first_arg, TopoDS_Vertex):
- ocp_vx = first_arg
+ # Handle unexpected kwargs
+ if kwargs:
+ raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
+
+ if args:
+ if isinstance(args[0], TopoDS_Vertex):
+ ocp_vx = args[0]
+ elif isinstance(args[0], Iterable):
+ v = args[0]
else:
- raise TypeError("Expected floats, TopoDS_Vertex, or iterable")
- x = kwargs.get("X", x)
- y = kwargs.get("Y", y)
- z = kwargs.get("Z", z)
+ x, y, z = args[:3] + (0,) * (3 - len(args))
+
+ if v is not None:
+ x, y, z = itertools.islice(itertools.chain(v, [0, 0, 0]), 3)
+
ocp_vx = (
downcast(BRepBuilderAPI_MakeVertex(gp_Pnt(x, y, z)).Vertex())
if ocp_vx is None
@@ -7897,12 +7897,12 @@ class Vertex(Shape[TopoDS_Vertex]):
geom_point = BRep_Tool.Pnt_s(self.wrapped)
return (geom_point.X(), geom_point.Y(), geom_point.Z())
- def center(self) -> Vector:
+ def center(self, *args, **kwargs) -> Vector:
"""The center of a vertex is itself!"""
return Vector(self)
- def __add__(
- self, other: Union[Vertex, Vector, Tuple[float, float, float]]
+ def __add__( # type: ignore
+ self, other: Vertex | Vector | tuple[float, float, float]
) -> Vertex:
"""Add
@@ -7936,7 +7936,7 @@ class Vertex(Shape[TopoDS_Vertex]):
)
return new_vertex
- def __sub__(self, other: Union[Vertex, Vector, tuple]) -> Vertex:
+ def __sub__(self, other: Union[Vertex, Vector, tuple]) -> Vertex: # type: ignore
"""Subtract
Substract a Vertex with a Vertex, Vector or Tuple from self
@@ -8604,7 +8604,6 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
Wire: chamfered wire
"""
reference_edge = edge
- del edge
# Create a face to chamfer
unchamfered_face = _make_topods_face_from_wires(self.wrapped)
@@ -8726,7 +8725,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
# ]
# ):
# raise ValueError("edges overlap")
-
+ edges = list(edges)
fragments_per_edge = int(2 / tolerance)
points_lookup = {} # lookup from point index to edge/position on edge
points = [] # convex hull point cloud
@@ -9170,6 +9169,8 @@ def polar(length: float, angle: float) -> tuple[float, float]:
def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]:
"""Compare the OCCT objects of each list and return the differences"""
+ shapes_one = list(shapes_one)
+ shapes_two = list(shapes_two)
occt_one = set(shape.wrapped for shape in shapes_one)
occt_two = set(shape.wrapped for shape in shapes_two)
occt_delta = list(occt_one - occt_two)
@@ -9366,7 +9367,8 @@ def _topods_bool_op(
Returns: TopoDS_Shape
"""
-
+ args = list(args)
+ tools = list(tools)
arg = TopTools_ListOfShape()
for obj in args:
arg.Append(obj)
@@ -9446,7 +9448,7 @@ def _make_topods_face_from_wires(
return TopoDS.Face_s(sf_f.Result())
-def _sew_topods_faces(faces: Sequence[TopoDS_Face]) -> TopoDS_Shape:
+def _sew_topods_faces(faces: Iterable[TopoDS_Face]) -> TopoDS_Shape:
"""Sew faces into a shell if possible"""
shell_builder = BRepBuilderAPI_Sewing()
for face in faces:
@@ -9456,7 +9458,7 @@ def _sew_topods_faces(faces: Sequence[TopoDS_Face]) -> TopoDS_Shape:
def _make_topods_compound_from_shapes(
- occt_shapes: Sequence[TopoDS_Shape | None],
+ occt_shapes: Iterable[TopoDS_Shape | None],
) -> TopoDS_Compound:
"""Create an OCCT TopoDS_Compound
@@ -9482,7 +9484,7 @@ def _make_topods_compound_from_shapes(
def find_max_dimension(shapes: Shape | Iterable[Shape]) -> float:
"""Return the maximum dimension of one or more shapes"""
shapes = shapes if isinstance(shapes, Iterable) else [shapes]
- composite = _make_topods_compound_from_shapes([s.wrapped for s in shapes])
+ composite = _make_topods_compound_from_shapes(s.wrapped for s in shapes)
bbox = BoundBox.from_topo_ds(composite, tolerance=TOLERANCE, optimal=True)
return bbox.diagonal
From 4c3d1544a90e0c3e81bb486b7e3066e8507bf56a Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 23 Dec 2024 11:46:20 -0500
Subject: [PATCH 048/518] Attempting to isolate mac specific issue
---
src/build123d/topology.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 3e07edc..1bd0ed6 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -4266,7 +4266,8 @@ class ShapeList(list[T]):
def __add__(self, other: ShapeList) -> ShapeList[T]: # type: ignore
"""Combine two ShapeLists together operator +"""
- return ShapeList(itertools.chain(self, other))
+ # return ShapeList(itertools.chain(self, other))
+ return ShapeList(list(self) + list(other))
def __sub__(self, other: ShapeList) -> ShapeList[T]:
"""Differences between two ShapeLists operator -"""
From 48e4b5e0577d1f4ba9c90a62269d26d67afa91e2 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sat, 28 Dec 2024 15:46:38 -0500
Subject: [PATCH 049/518] Resolved one_d.py mypy errors
---
src/build123d/topology.py | 346 ++++++++++++++++++++++++--------------
tests/test_direct_api.py | 4 +-
2 files changed, 217 insertions(+), 133 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 1bd0ed6..20e71a8 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -36,15 +36,12 @@ from __future__ import annotations
# too-many-arguments, too-many-locals, too-many-public-methods,
# too-many-statements, too-many-instance-attributes, too-many-branches
import copy
-import inspect
import itertools
import os
import platform
import sys
-import types
import warnings
-from abc import ABC, ABCMeta, abstractmethod
-from io import BytesIO
+from abc import ABC, abstractmethod
from itertools import combinations
from math import radians, inf, pi, sin, cos, tan, copysign, ceil, floor, isclose
from typing import (
@@ -66,8 +63,7 @@ from typing import (
TYPE_CHECKING,
)
from typing import cast as tcast
-from typing_extensions import Self, Literal, deprecated
-
+from typing_extensions import Self, Literal
from anytree import NodeMixin, PreOrderIter, RenderTree
from IPython.lib.pretty import pretty, PrettyPrinter
from numpy import ndarray
@@ -171,6 +167,7 @@ from OCP.Geom import (
Geom_Line,
)
from OCP.GeomAdaptor import GeomAdaptor_Curve
+from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf, GeomAPI_IntCS
from OCP.Geom2d import Geom2d_Curve, Geom2d_Line, Geom2d_TrimmedCurve
from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve
from OCP.GeomAbs import GeomAbs_C0, GeomAbs_Intersection, GeomAbs_JoinType
@@ -205,8 +202,6 @@ from OCP.gp import (
from OCP.GProp import GProp_GProps
from OCP.HLRAlgo import HLRAlgo_Projector
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
-from OCP.IFSelect import IFSelect_ReturnStatus
-from OCP.Interface import Interface_Static
from OCP.IVtkOCC import IVtkOCC_Shape, IVtkOCC_ShapeMesher
from OCP.IVtkVTK import IVtkVTK_ShapeData
from OCP.LocOpe import LocOpe_DPrism
@@ -233,8 +228,6 @@ from OCP.Standard import (
from OCP.StdFail import StdFail_NotDone
from OCP.StdPrs import StdPrs_BRepFont
from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder
-from OCP.STEPControl import STEPControl_AsIs, STEPControl_Writer
-from OCP.StlAPI import StlAPI_Writer
# Array of vectors (used for B-spline interpolation):
# Array of points (used for B-spline construction):
@@ -363,9 +356,11 @@ def tuplify(obj: Any, dim: int) -> tuple | None:
class Mixin1D:
"""Methods to add to the Edge and Wire classes"""
- def __add__(self, other: Union[list[Shape], Shape]) -> Self:
+ def __add__(self, other: Shape | Iterable[Shape]) -> Edge | Wire | ShapeList[Edge]:
"""fuse shape to wire/edge operator +"""
+ assert isinstance(self, Shape) # for typing
+
# Convert `other` to list of base topods objects and filter out None values
summands = [
shape
@@ -383,6 +378,9 @@ class Mixin1D:
# Convert back to Edge/Wire objects now that it's safe to do so
summands = [Mixin1D.cast(s) for s in summands]
summand_edges = [e for summand in summands for e in summand.edges()]
+
+ assert isinstance(self, Shape) # for typing
+
if self.wrapped is None: # an empty object
if len(summands) == 1:
sum_shape = summands[0]
@@ -395,7 +393,7 @@ class Mixin1D:
sum_shape = type(self)(sum_shape)
else:
try:
- sum_shape = Wire(self.edges() + summand_edges)
+ sum_shape = Wire(self.edges() + ShapeList(summand_edges))
except Exception:
sum_shape = self.fuse(*summands)
@@ -408,7 +406,7 @@ class Mixin1D:
return sum_shape
@classmethod
- def cast(cls, obj: TopoDS_Shape) -> Self:
+ def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire:
"Returns the right type of wrapper, given a OCCT object"
# Extend the lookup table with additional entries
@@ -461,6 +459,8 @@ class Mixin1D:
- **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is
either a `Self` or `list[Self]`, or `None` if no corresponding part is found.
"""
+ assert isinstance(self, Shape) # for typing
+
shape_list = TopTools_ListOfShape()
shape_list.Append(self.wrapped)
@@ -505,7 +505,8 @@ class Mixin1D:
except StdFail_NotDone as err:
raise RuntimeError("Error determining top/bottom") from err
- tops, bottoms = [], []
+ tops: list[Shape] = []
+ bottoms: list[Shape] = []
properties = GProp_GProps()
for part in get_top_level_topods_shapes(split_result):
sub_shape = self.__class__.cast(part)
@@ -534,14 +535,17 @@ class Mixin1D:
def vertices(self) -> ShapeList[Vertex]:
"""vertices - all the vertices in this Shape"""
+ assert isinstance(self, Shape) # for typing
return Shape.get_shape_list(self, "Vertex")
def vertex(self) -> Vertex:
"""Return the Vertex"""
+ assert isinstance(self, Shape) # for typing
return Shape.get_single_shape(self, "Vertex")
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this Shape"""
+ assert isinstance(self, Shape) # for typing
edge_list = Shape.get_shape_list(self, "Edge")
return edge_list.filter_by(
lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
@@ -549,14 +553,17 @@ class Mixin1D:
def edge(self) -> Edge:
"""Return the Edge"""
+ assert isinstance(self, Shape) # for typing
return Shape.get_single_shape(self, "Edge")
def wires(self) -> ShapeList[Wire]:
"""wires - all the wires in this Shape"""
+ assert isinstance(self, Shape) # for typing
return Shape.get_shape_list(self, "Wire")
def wire(self) -> Wire:
"""Return the Wire"""
+ assert isinstance(self, Shape) # for typing
return Shape.get_single_shape(self, "Wire")
def start_point(self) -> Vector:
@@ -564,6 +571,7 @@ class Mixin1D:
Note that circles may have identical start and end points.
"""
+ assert isinstance(self, Shape) # for typing
curve = self.geom_adaptor()
umin = curve.FirstParameter()
@@ -574,6 +582,7 @@ class Mixin1D:
Note that circles may have identical start and end points.
"""
+ assert isinstance(self, Shape) # for typing
curve = self.geom_adaptor()
umax = curve.LastParameter()
@@ -590,6 +599,7 @@ class Mixin1D:
Returns:
float: parameter value
"""
+ assert isinstance(self, Shape) # for typing
curve = self.geom_adaptor()
length = GCPnts_AbscissaPoint.Length_s(curve)
@@ -619,6 +629,7 @@ class Mixin1D:
Returns:
Vector: tangent value
"""
+ assert isinstance(self, Shape) # for typing
if isinstance(position, (float, int)):
curve = self.geom_adaptor()
@@ -684,6 +695,7 @@ class Mixin1D:
Returns:
"""
+ assert isinstance(self, Shape) # for typing
curve = self.geom_adaptor()
gtype = self.geom_type
@@ -717,6 +729,8 @@ class Mixin1D:
Returns:
Vector: center
"""
+ assert isinstance(self, Shape) # for typing
+
if center_of == CenterOf.GEOMETRY:
middle = self.position_at(0.5)
elif center_of == CenterOf.MASS:
@@ -727,7 +741,7 @@ class Mixin1D:
middle = self.bounding_box().center()
return middle
- def common_plane(self, *lines: Union[Edge, Wire]) -> Union[None, Plane]:
+ def common_plane(self, *lines: Edge | Wire | None) -> Union[None, Plane]:
"""common_plane
Find the plane containing all the edges/wires (including self). If there
@@ -743,8 +757,10 @@ class Mixin1D:
# pylint: disable=too-many-locals
# Note: BRepLib_FindSurface is not helpful as it requires the
# Edges to form a surface perimeter.
+ assert isinstance(self, Shape) # for typing
+
points: list[Vector] = []
- all_lines: list[Edge, Wire] = [
+ all_lines: list[Edge | Wire] = [
line for line in [self, *lines] if line is not None
]
if any(not isinstance(line, (Edge, Wire)) for line in all_lines):
@@ -766,9 +782,6 @@ class Mixin1D:
# Shorten any infinite lines (from converted Axis)
normal_lines = list(filter(lambda line: line.length <= 1e50, all_lines))
infinite_lines = filter(lambda line: line.length > 1e50, all_lines)
- # shortened_lines = [
- # l.trim(0.4999999999, 0.5000000001) for l in infinite_lines
- # ]
shortened_lines = [l.trim_to_length(0.5, 10) for l in infinite_lines]
all_lines = normal_lines + shortened_lines
@@ -791,7 +804,9 @@ class Mixin1D:
x_dir = (extremes[1] - extremes[0]).normalized()
z_dir = (extremes[2] - extremes[0]).cross(x_dir)
try:
- c_plane = Plane(origin=(sum(extremes) / 3), z_dir=z_dir)
+ c_plane = Plane(
+ origin=(sum(extremes, Vector(0, 0, 0)) / 3), z_dir=z_dir
+ )
c_plane = c_plane.shift_origin((0, 0))
except ValueError:
# There is no valid common plane
@@ -806,6 +821,7 @@ class Mixin1D:
@property
def length(self) -> float:
"""Edge or Wire length"""
+ assert isinstance(self, Shape) # for typing
return GCPnts_AbscissaPoint.Length_s(self.geom_adaptor())
@property
@@ -823,6 +839,7 @@ class Mixin1D:
ValueError: if kernel can not reduce the shape to a circular edge
"""
+ assert isinstance(self, Shape) # for typing
geom = self.geom_adaptor()
try:
circ = geom.Circle()
@@ -833,11 +850,17 @@ class Mixin1D:
@property
def is_forward(self) -> bool:
"""Does the Edge/Wire loop forward or reverse"""
+ assert isinstance(self, Shape) # for typing
+ if self.wrapped is None:
+ raise ValueError("Can't determine direction of empty Edge or Wire")
return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD
@property
def is_closed(self) -> bool:
"""Are the start and end points equal?"""
+ assert isinstance(self, Shape) # for typing
+ if self.wrapped is None:
+ raise ValueError("Can't determine if empty Edge or Wire is closed")
return BRep_Tool.IsClosed_s(self.wrapped)
@property
@@ -860,6 +883,7 @@ class Mixin1D:
Returns:
Vector: position on the underlying curve
"""
+ assert isinstance(self, Shape) # for typing
curve = self.geom_adaptor()
if position_mode == PositionMode.PARAMETER:
@@ -911,6 +935,7 @@ class Mixin1D:
Location: A Location object representing local coordinate system
at the specified distance.
"""
+ assert isinstance(self, Shape) # for typing
curve = self.geom_adaptor()
if position_mode == PositionMode.PARAMETER:
@@ -970,15 +995,15 @@ class Mixin1D:
self.location_at(d, position_mode, frame_method, planar) for d in distances
]
- def __matmul__(self: Union[Edge, Wire], position: float) -> Vector:
+ def __matmul__(self, position: float) -> Vector:
"""Position on wire operator @"""
return self.position_at(position)
- def __mod__(self: Union[Edge, Wire], position: float) -> Vector:
+ def __mod__(self, position: float) -> Vector:
"""Tangent on wire operator %"""
return self.tangent_at(position)
- def __xor__(self: Union[Edge, Wire], position: float) -> Location:
+ def __xor__(self, position: float) -> Location:
"""Location on wire operator ^"""
return self.location_at(position)
@@ -1039,7 +1064,7 @@ class Mixin1D:
if side != Side.BOTH:
# Find and remove the end arcs
offset_edges = offset_wire.edges()
- edges_to_keep = [[], [], []]
+ edges_to_keep: list[list[int]] = [[], [], []]
i = 0
for edge in offset_edges:
if edge.geom_type == GeomType.CIRCLE and (
@@ -1072,7 +1097,9 @@ class Mixin1D:
else:
edge0 = Edge.make_line(self0, end1)
edge1 = Edge.make_line(self1, end0)
- offset_wire = Wire(line.edges() + offset_wire.edges() + [edge0, edge1])
+ offset_wire = Wire(
+ line.edges() + offset_wire.edges() + ShapeList([edge0, edge1])
+ )
offset_edges = offset_wire.edges()
return offset_edges[0] if len(offset_edges) == 1 else offset_wire
@@ -1104,7 +1131,7 @@ class Mixin1D:
def project(
self, face: Face, direction: VectorLike, closest: bool = True
- ) -> Union[Mixin1D, ShapeList[Mixin1D]]:
+ ) -> Edge | Wire | ShapeList[Edge | Wire]:
"""Project onto a face along the specified direction
Args:
@@ -1115,6 +1142,9 @@ class Mixin1D:
Returns:
"""
+ assert isinstance(self, Shape) # for typing
+ if self.wrapped is None:
+ raise ValueError("Can't project an empty Edge or Wire")
bldr = BRepProj_Projection(
self.wrapped, face.wrapped, Vector(direction).to_dir()
@@ -1122,7 +1152,7 @@ class Mixin1D:
shapes: TopoDS_Compound = bldr.Shape()
# select the closest projection if requested
- return_value: Union[Mixin1D, list[Mixin1D]]
+ return_value: Edge | Wire | ShapeList[Edge | Wire]
if closest:
dist_calc = BRepExtrema_DistShapeShape()
@@ -1151,7 +1181,7 @@ class Mixin1D:
self,
viewport_origin: VectorLike,
viewport_up: VectorLike = (0, 0, 1),
- look_at: VectorLike = None,
+ look_at: VectorLike | None = None,
) -> tuple[ShapeList[Edge], ShapeList[Edge]]:
"""project_to_viewport
@@ -1167,6 +1197,7 @@ class Mixin1D:
Returns:
tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges
"""
+ assert isinstance(self, Shape) # for typing
def extract_edges(compound):
edges = [] # List to store the extracted edges
@@ -1295,12 +1326,62 @@ class Mixin2D:
"""Return a copy of self moved along the normal by amount"""
return copy.deepcopy(self).moved(Location(self.normal_at() * amount))
+ def find_intersection_points(
+ self, other: Axis, tolerance: float = TOLERANCE
+ ) -> list[tuple[Vector, Vector]]:
+ """Find point and normal at intersection
+
+ Return both the point(s) and normal(s) of the intersection of the axis and the shape
+
+ Args:
+ axis (Axis): axis defining the intersection line
+
+ Returns:
+ list[tuple[Vector, Vector]]: Point and normal of intersection
+ """
+ assert isinstance(self, Shape) # for typing
+
+ if self.wrapped is None:
+ return []
+
+ intersection_line = gce_MakeLin(other.wrapped).Value()
+ intersect_maker = BRepIntCurveSurface_Inter()
+ intersect_maker.Init(self.wrapped, intersection_line, tolerance)
+
+ intersections = []
+ while intersect_maker.More():
+ inter_pt = intersect_maker.Pnt()
+ # Calculate distance along axis
+ distance = other.to_plane().to_local_coords(Vector(inter_pt)).Z
+ intersections.append(
+ (
+ intersect_maker.Face(), # TopoDS_Face
+ Vector(inter_pt),
+ distance,
+ )
+ )
+ intersect_maker.Next()
+
+ intersections.sort(key=lambda x: x[2])
+ intersecting_faces = [i[0] for i in intersections]
+ intersecting_points = [i[1] for i in intersections]
+ intersecting_normals = [
+ _topods_face_normal_at(f, intersecting_points[i].to_pnt())
+ for i, f in enumerate(intersecting_faces)
+ ]
+ result = []
+ for pnt, normal in zip(intersecting_points, intersecting_normals):
+ result.append((pnt, normal))
+
+ return result
+
class Mixin3D:
"""Additional methods to add to 3D Shape classes"""
project_to_viewport = Mixin1D.project_to_viewport
split = Mixin1D.split
+ find_intersection_points = Mixin2D.find_intersection_points
@classmethod
def cast(cls, obj: TopoDS_Shape) -> Self:
@@ -2180,7 +2261,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
result = Shape._show_tree(tree[0], show_center)
return result
- def __add__(self, other: Union[list[Shape], Shape]) -> Self | ShapeList[Self]:
+ def __add__(self, other: Shape | Iterable[Shape]) -> Self | ShapeList[Self]:
"""fuse shape to self operator +"""
# Convert `other` to list of base objects and filter out None values
summands = [
@@ -2250,7 +2331,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return difference
- def __and__(self, other: Shape | Iterable[Shape]) -> Self | ShapeList[Self]:
+ def __and__(self, other: Shape | Iterable[Shape]) -> None | Self | ShapeList[Self]:
"""intersect shape with self operator &"""
others = other if isinstance(other, (list, tuple)) else [other]
@@ -2260,6 +2341,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
if (
not isinstance(new_shape, list)
+ and new_shape is not None
and new_shape.wrapped is not None
and SkipClean.clean
):
@@ -2279,7 +2361,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return [loc * self for loc in other]
@abstractmethod
- def center(self, center_of: CenterOf | None = None) -> Vector:
+ def center(self, *args, **kwargs) -> Vector:
"""All of the derived classes from Shape need a center method"""
def clean(self) -> Self:
@@ -3106,7 +3188,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
def intersect(
self, *to_intersect: Union[Shape, Axis, Plane]
- ) -> Self | ShapeList[Self]:
+ ) -> None | Self | ShapeList[Self]:
"""Intersection of the arguments and this shape
Args:
@@ -3160,7 +3242,13 @@ class Shape(NodeMixin, Generic[TOPODS]):
# Find the shape intersections
intersect_op = BRepAlgoAPI_Common()
shape_intersections = self._bool_op((self,), objs, intersect_op)
-
+ if isinstance(shape_intersections, ShapeList) and not shape_intersections:
+ return None
+ elif (
+ not isinstance(shape_intersections, ShapeList)
+ and shape_intersections.is_null()
+ ):
+ return None
return shape_intersections
@classmethod
@@ -3600,7 +3688,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
def _repr_javascript_(self):
"""Jupyter 3D representation support"""
- from .jupyter_tools import display
+ from build123d.jupyter_tools import display
return display(self)._repr_javascript_()
@@ -3633,51 +3721,6 @@ class Shape(NodeMixin, Generic[TOPODS]):
t_o.SetTranslation(Vector(offset).wrapped)
return self._apply_transform(t_o * t_rx * t_ry * t_rz)
- def find_intersection_points(self, other: Axis) -> list[tuple[Vector, Vector]]:
- """Find point and normal at intersection
-
- Return both the point(s) and normal(s) of the intersection of the axis and the shape
-
- Args:
- axis (Axis): axis defining the intersection line
-
- Returns:
- list[tuple[Vector, Vector]]: Point and normal of intersection
- """
- if self.wrapped is None:
- return []
-
- intersection_line = gce_MakeLin(other.wrapped).Value()
- intersect_maker = BRepIntCurveSurface_Inter()
- intersect_maker.Init(self.wrapped, intersection_line, 0.0001)
-
- intersections = []
- while intersect_maker.More():
- inter_pt = intersect_maker.Pnt()
- # Calculate distance along axis
- distance = other.to_plane().to_local_coords(Vector(inter_pt)).Z
- intersections.append(
- (
- intersect_maker.Face(), # TopoDS_Face
- Vector(inter_pt),
- distance,
- )
- )
- intersect_maker.Next()
-
- intersections.sort(key=lambda x: x[2])
- intersecting_faces = [i[0] for i in intersections]
- intersecting_points = [i[1] for i in intersections]
- intersecting_normals = [
- _topods_face_normal_at(f, intersecting_points[i].to_pnt())
- for i, f in enumerate(intersecting_faces)
- ]
- result = []
- for pnt, normal in zip(intersecting_points, intersecting_normals):
- result.append((pnt, normal))
-
- return result
-
def project_faces(
self,
faces: Union[list[Face], Compound],
@@ -4266,7 +4309,7 @@ class ShapeList(list[T]):
def __add__(self, other: ShapeList) -> ShapeList[T]: # type: ignore
"""Combine two ShapeLists together operator +"""
- # return ShapeList(itertools.chain(self, other))
+ # return ShapeList(itertools.chain(self, other)) # breaks MacOS-13
return ShapeList(list(self) + list(other))
def __sub__(self, other: ShapeList) -> ShapeList[T]:
@@ -4672,24 +4715,25 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
bbox_intersection = children_bbox[child_index_pair[0]].intersect(
children_bbox[child_index_pair[1]]
)
- bbox_common_volume = (
- 0.0 if isinstance(bbox_intersection, list) else bbox_intersection.volume
- )
- if bbox_common_volume > tolerance:
+ if bbox_intersection is not None:
obj_intersection = children[child_index_pair[0]].intersect(
children[child_index_pair[1]]
)
- common_volume = (
- 0.0
- if isinstance(obj_intersection, list)
- else obj_intersection.volume
- )
- if common_volume > tolerance:
- return (
- True,
- (children[child_index_pair[0]], children[child_index_pair[1]]),
- common_volume,
+ if obj_intersection is not None:
+ common_volume = (
+ 0.0
+ if isinstance(obj_intersection, list)
+ else obj_intersection.volume
)
+ if common_volume > tolerance:
+ return (
+ True,
+ (
+ children[child_index_pair[0]],
+ children[child_index_pair[1]],
+ ),
+ common_volume,
+ )
return (False, (), 0.0)
@classmethod
@@ -5095,6 +5139,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
list[float]: u values between 0.0 and 1.0
"""
angle = angle % 360 # angle needs to always be positive 0..360
+ u_values: list[float]
if self.geom_type == GeomType.LINE:
if self.tangent_angle_at(0) == angle:
@@ -5104,7 +5149,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
else:
# Solve this problem geometrically by creating a tangent curve and finding intercepts
periodic = int(self.is_closed) # if closed don't include end point
- tan_pnts = []
+ tan_pnts: list[VectorLike] = []
previous_tangent = None
# When angles go from 360 to 0 a discontinuity is created so add 360 to these
@@ -5130,7 +5175,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
max_range = 360 * (ceil(tan_curve_bbox.max.Y / 360))
# Create a horizontal line for each 360 cycle and intercept it
- intercept_pnts = []
+ intercept_pnts: list[Vector] = []
for i in range(min_range, max_range + 1, 360):
line = Edge.make_line((0, angle + i, 0), (100, angle + i, 0))
intercept_pnts.extend(tan_curve.find_intersection_points(line))
@@ -5233,8 +5278,8 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
return ShapeList(valid_crosses)
def intersect(
- self, *to_intersect: Edge | Axis
- ) -> Optional[Shape | ShapeList[Shape]]:
+ self, *to_intersect: Edge | Axis | Plane
+ ) -> None | Vertex | Edge | ShapeList[Vertex | Edge]:
"""intersect Edge with Edge or Axis
Args:
@@ -5243,24 +5288,58 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
Returns:
Union[Shape, None]: Compound of vertices and/or edges
"""
- edges = [Edge(obj) if isinstance(obj, Axis) else obj for obj in to_intersect]
- if not all(isinstance(obj, Edge) for obj in edges):
- raise TypeError(
- "Only Edge or Axis instances are supported for intersection"
- )
+ edges: list[Edge] = []
+ planes: list[Plane] = []
+ edges_common_to_planes: list[Edge] = []
- # Find any intersection points
+ for obj in to_intersect:
+ match obj:
+ case Axis():
+ edges.append(Edge(obj))
+ case Edge():
+ edges.append(obj)
+ case Plane():
+ planes.append(obj)
+ case _:
+ raise ValueError(f"Unknown object type: {type(obj)}")
+
+ # Find any edge / edge intersection points
points_sets: list[set[Vector]] = []
for edge_pair in combinations([self] + edges, 2):
intersection_points = edge_pair[0].find_intersection_points(edge_pair[1])
points_sets.append(set(intersection_points))
+ # Find any edge / plane intersection points & edges
+ for edge, plane in itertools.product([self] + edges, planes):
+ # Find point intersections
+ geom_line = BRep_Tool.Curve_s(
+ edge.wrapped, edge.param_at(0), edge.param_at(1)
+ )
+ geom_plane = Geom_Plane(plane.local_coord_system)
+ intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
+ plane_intersection_points: list[Vector] = []
+ if intersection_calculator.IsDone():
+ plane_intersection_points = [
+ Vector(intersection_calculator.Point(i + 1))
+ for i in range(intersection_calculator.NbPoints())
+ ]
+ points_sets.append(set(plane_intersection_points))
+
+ # Find edge intersections
+ if (edge_plane := edge.common_plane()) is not None: # is a 2D edge
+ if edge_plane.z_dir == plane.z_dir or -edge_plane.z_dir == plane.z_dir:
+ edges_common_to_planes.append(edge)
+
+ edges.extend(edges_common_to_planes)
+
# Find the intersection of all sets
common_points = set.intersection(*points_sets)
common_vertices = [Vertex(*pnt) for pnt in common_points]
# Find Edge/Edge overlaps
- common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges()
+ common_edges: list[Edge] = []
+ if edges:
+ common_edges = self._bool_op((self,), edges, BRepAlgoAPI_Common()).edges()
if common_vertices or common_edges:
# If there is just one vertex or edge return it
@@ -5614,21 +5693,21 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
Edge: the spline
"""
# pylint: disable=too-many-locals
- points = [Vector(point) for point in points]
+ point_vectors = [Vector(point) for point in points]
if tangents:
- tangents = tuple(Vector(v) for v in tangents)
- pnts = TColgp_HArray1OfPnt(1, len(points))
- for i, point in enumerate(points):
+ tangent_vectors = tuple(Vector(v) for v in tangents)
+ pnts = TColgp_HArray1OfPnt(1, len(point_vectors))
+ for i, point in enumerate(point_vectors):
pnts.SetValue(i + 1, point.to_pnt())
if parameters is None:
spline_builder = GeomAPI_Interpolate(pnts, periodic, tol)
else:
- if len(parameters) != (len(points) + periodic):
+ if len(parameters) != (len(point_vectors) + periodic):
raise ValueError(
"There must be one parameter for each interpolation point "
"(plus one if periodic), or none specified. Parameter count: "
- f"{len(parameters)}, point count: {len(points)}"
+ f"{len(parameters)}, point count: {len(point_vectors)}"
)
parameters_array = TColStd_HArray1OfReal(1, len(parameters))
for p_index, p_value in enumerate(parameters):
@@ -5637,21 +5716,25 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
spline_builder = GeomAPI_Interpolate(pnts, parameters_array, periodic, tol)
if tangents:
- if len(tangents) == 2 and len(points) != 2:
+ if len(tangent_vectors) == 2 and len(point_vectors) != 2:
# Specify only initial and final tangent:
- spline_builder.Load(tangents[0].wrapped, tangents[1].wrapped, scale)
+ spline_builder.Load(
+ tangent_vectors[0].wrapped, tangent_vectors[1].wrapped, scale
+ )
else:
- if len(tangents) != len(points):
+ if len(tangent_vectors) != len(point_vectors):
raise ValueError(
f"There must be one tangent for each interpolation point, "
f"or just two end point tangents. Tangent count: "
- f"{len(tangents)}, point count: {len(points)}"
+ f"{len(tangent_vectors)}, point count: {len(point_vectors)}"
)
# Specify a tangent for each interpolation point:
- tangents_array = TColgp_Array1OfVec(1, len(tangents))
- tangent_enabled_array = TColStd_HArray1OfBoolean(1, len(tangents))
- for t_index, t_value in enumerate(tangents):
+ tangents_array = TColgp_Array1OfVec(1, len(tangent_vectors))
+ tangent_enabled_array = TColStd_HArray1OfBoolean(
+ 1, len(tangent_vectors)
+ )
+ for t_index, t_value in enumerate(tangent_vectors):
tangent_enabled_array.SetValue(t_index + 1, t_value is not None)
tangent_vec = t_value if t_value is not None else Vector()
tangents_array.SetValue(t_index + 1, tangent_vec.wrapped)
@@ -5874,7 +5957,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
locations = self.locations(t_values)
if positions_only:
for loc in locations:
- loc.orientation = (0, 0, 0)
+ loc.orientation = Vector(0, 0, 0)
return locations
@@ -8533,12 +8616,12 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
Returns:
Wire: an irregular polygon
"""
- vertices = [Vector(v) for v in vertices]
- if (vertices[0] - vertices[-1]).length > TOLERANCE and close:
- vertices.append(vertices[0])
+ vectors = [Vector(v) for v in vertices]
+ if (vectors[0] - vectors[-1]).length > TOLERANCE and close:
+ vectors.append(vectors[0])
wire_builder = BRepBuilderAPI_MakePolygon()
- for vertex in vertices:
+ for vertex in vectors:
wire_builder.Add(vertex.to_pnt())
return cls(wire_builder.Wire())
@@ -8742,7 +8825,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
# Filter the fragments
connecting_edge_data = []
- trim_points = {}
+ trim_points: dict[int, list[int]] = {}
for simplice in convex_hull.simplices:
edge0 = points_lookup[simplice[0]][0]
edge1 = points_lookup[simplice[1]][0]
@@ -8778,13 +8861,13 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
)
trim_data = {}
- for edge, points in trim_points.items():
- s_points = sorted(points)
+ for edge_index, start_end_pnts in trim_points.items():
+ s_points = sorted(start_end_pnts)
f_points = []
for i in range(0, len(s_points) - 1, 2):
if s_points[i] != s_points[i + 1]:
f_points.append(tuple(s_points[i : i + 2]))
- trim_data[edge] = f_points
+ trim_data[edge_index] = f_points
connecting_edges = [
Edge.make_line(
@@ -8793,10 +8876,10 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
for line in connecting_edge_data
]
trimmed_edges = [
- edges[edge].trim(
+ edges[edge_index].trim(
points_lookup[trim_pair[0]][1], points_lookup[trim_pair[1]][1]
)
- for edge, trim_pairs in trim_data.items()
+ for edge_index, trim_pairs in trim_data.items()
for trim_pair in trim_pairs
]
hull_wire = Wire(connecting_edges + trimmed_edges, sequenced=True)
@@ -8834,11 +8917,14 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
"""
# pylint: disable=too-many-branches
+ if self.wrapped is None or target_object.wrapped is None:
+ raise ValueError("Can't project empty Wires or to empty Shapes")
+
if not (direction is None) ^ (center is None):
raise ValueError("One of either direction or center must be provided")
if direction is not None:
direction_vector = Vector(direction).normalized()
- center_point = None
+ center_point = Vector() # for typing, never used
else:
direction_vector = None
center_point = Vector(center)
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 1892bcd..a34f2f2 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -4205,9 +4205,7 @@ class TestVector(DirectApiTestCase):
self.assertVectorAlmostEquals(
(v1 & Solid.make_box(2, 4, 5)).vertex(), (1, 2, 3), 5
)
- self.assertTrue(
- len(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)).vertices()) == 0
- )
+ self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)))
class TestVectorLike(DirectApiTestCase):
From 5ecea4bb91139a5e07938155810515d35670dbc0 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Mon, 30 Dec 2024 20:47:22 +0400
Subject: [PATCH 050/518] remove `is_inkscape_label` parameter
---
src/build123d/importers.py | 15 ++++++---------
tests/test_importers.py | 8 ++------
2 files changed, 8 insertions(+), 15 deletions(-)
diff --git a/src/build123d/importers.py b/src/build123d/importers.py
index b9f5299..1bb1693 100644
--- a/src/build123d/importers.py
+++ b/src/build123d/importers.py
@@ -31,6 +31,7 @@ license:
import os
from os import PathLike, fsdecode
+import re
import unicodedata
from math import degrees
from pathlib import Path
@@ -337,7 +338,6 @@ def import_svg(
flip_y: bool = True,
ignore_visibility: bool = False,
label_by: str = "id",
- is_inkscape_label: bool = False,
) -> ShapeList[Union[Wire, Face]]:
"""import_svg
@@ -345,10 +345,9 @@ def import_svg(
svg_file (Union[str, Path, TextIO]): svg file
flip_y (bool, optional): flip objects to compensate for svg orientation. Defaults to True.
ignore_visibility (bool, optional): Defaults to False.
- label_by (str, optional): xml attribute. Defaults to "id".
- is_inkscape_label (bool, optional): flag to indicate that the attribute
- is an Inkscape label like `inkscape:label` - label_by would be set to
- `label` in this case. Defaults to False.
+ label_by (str, optional): XML attribute to use for imported shapes' `label` property.
+ Defaults to "id".
+ Use `inkscape:label` to read labels set from Inkscape's "Layers and Objects" panel.
Raises:
ValueError: unexpected shape type
@@ -357,10 +356,8 @@ def import_svg(
ShapeList[Union[Wire, Face]]: objects contained in svg
"""
shapes = []
- label_by = (
- "{http://www.inkscape.org/namespaces/inkscape}" + label_by
- if is_inkscape_label
- else label_by
+ label_by = re.sub(
+ r"^inkscape:(.+)", r"{http://www.inkscape.org/namespaces/inkscape}\1", label_by
)
for face_or_wire, color_and_label in import_svg_document(
svg_file,
diff --git a/tests/test_importers.py b/tests/test_importers.py
index 7c7e4a1..3e1155b 100644
--- a/tests/test_importers.py
+++ b/tests/test_importers.py
@@ -71,13 +71,9 @@ class ImportSVG(unittest.TestCase):
def test_import_svg(self):
svg_file = Path(__file__).parent / "../tests/svg_import_test.svg"
- for tag in ["id", "label"]:
+ for tag in ["id", "inkscape:label"]:
# Import the svg object as a ShapeList
- svg = import_svg(
- svg_file,
- label_by=tag,
- is_inkscape_label=tag == "label",
- )
+ svg = import_svg(svg_file, label_by=tag)
# Exact the shape of the plate & holes
base_faces = svg.filter_by(lambda f: "base" in f.label)
From 93bcaa1e118cdda874f9293a45d72d6d87d4243d Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 30 Dec 2024 13:43:45 -0500
Subject: [PATCH 051/518] Fixed two_d.py typing errors
---
src/build123d/topology.py | 2996 ++++++++++++++++++-------------------
1 file changed, 1491 insertions(+), 1505 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 20e71a8..f5cc68b 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -353,1482 +353,6 @@ def tuplify(obj: Any, dim: int) -> tuple | None:
return result
-class Mixin1D:
- """Methods to add to the Edge and Wire classes"""
-
- def __add__(self, other: Shape | Iterable[Shape]) -> Edge | Wire | ShapeList[Edge]:
- """fuse shape to wire/edge operator +"""
-
- assert isinstance(self, Shape) # for typing
-
- # Convert `other` to list of base topods objects and filter out None values
- summands = [
- shape
- for o in (other if isinstance(other, (list, tuple)) else [other])
- if o is not None
- for shape in get_top_level_topods_shapes(o.wrapped)
- ]
- # If there is nothing to add return the original object
- if not summands:
- return self
-
- if not all(topods_dim(summand) == 1 for summand in summands):
- raise ValueError("Only shapes with the same dimension can be added")
-
- # Convert back to Edge/Wire objects now that it's safe to do so
- summands = [Mixin1D.cast(s) for s in summands]
- summand_edges = [e for summand in summands for e in summand.edges()]
-
- assert isinstance(self, Shape) # for typing
-
- if self.wrapped is None: # an empty object
- if len(summands) == 1:
- sum_shape = summands[0]
- else:
- try:
- sum_shape = Wire(summand_edges)
- except Exception:
- sum_shape = summands[0].fuse(*summands[1:])
- if type(self).order == 4:
- sum_shape = type(self)(sum_shape)
- else:
- try:
- sum_shape = Wire(self.edges() + ShapeList(summand_edges))
- except Exception:
- sum_shape = self.fuse(*summands)
-
- if SkipClean.clean and not isinstance(sum_shape, list):
- sum_shape = sum_shape.clean()
-
- # If there is only one Edge, return that
- sum_shape = sum_shape.edge() if len(sum_shape.edges()) == 1 else sum_shape
-
- return sum_shape
-
- @classmethod
- def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire:
- "Returns the right type of wrapper, given a OCCT object"
-
- # Extend the lookup table with additional entries
- constructor_lut = {
- ta.TopAbs_VERTEX: Vertex,
- ta.TopAbs_EDGE: Edge,
- ta.TopAbs_WIRE: Wire,
- }
-
- shape_type = shapetype(obj)
- # NB downcast is needed to handle TopoDS_Shape types
- return constructor_lut[shape_type](downcast(obj))
-
- @overload
- def split(
- self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM]
- ) -> Self | list[Self] | None:
- """split and keep inside or outside"""
-
- @overload
- def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[
- Self | list[Self] | None,
- Self | list[Self] | None,
- ]:
- """split and keep inside and outside"""
-
- @overload
- def split(self, tool: TrimmingTool) -> Self | list[Self] | None:
- """split and keep inside (default)"""
-
- def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP):
- """split
-
- Split this shape by the provided plane or face.
-
- Args:
- surface (Union[Plane,Face]): surface to segment shape
- keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP.
-
- Returns:
- Shape: result of split
- Returns:
- Self | list[Self] | None,
- Tuple[Self | list[Self] | None]: The result of the split operation.
-
- - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None`
- if no top is found.
- - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None`
- if no bottom is found.
- - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is
- either a `Self` or `list[Self]`, or `None` if no corresponding part is found.
- """
- assert isinstance(self, Shape) # for typing
-
- shape_list = TopTools_ListOfShape()
- shape_list.Append(self.wrapped)
-
- # Define the splitting tool
- trim_tool = (
- BRepBuilderAPI_MakeFace(tool.wrapped).Face() # Plane to Face
- if isinstance(tool, Plane)
- else tool.wrapped
- )
- tool_list = TopTools_ListOfShape()
- tool_list.Append(trim_tool)
-
- # Create the splitter algorithm
- splitter = BRepAlgoAPI_Splitter()
-
- # Set the shape to be split and the splitting tool (plane face)
- splitter.SetArguments(shape_list)
- splitter.SetTools(tool_list)
-
- # Perform the splitting operation
- splitter.Build()
-
- split_result = downcast(splitter.Shape())
- # Remove unnecessary TopoDS_Compound around single shape
- if isinstance(split_result, TopoDS_Compound):
- split_result = unwrap_topods_compound(split_result, True)
-
- if not isinstance(tool, Plane):
- # Create solids from the surfaces for sorting by thickening
- offset_builder = BRepOffset_MakeOffset()
- offset_builder.Initialize(
- tool.wrapped,
- Offset=0.1,
- Tol=1.0e-5,
- Intersection=True,
- Join=GeomAbs_Intersection,
- Thickening=True,
- )
- offset_builder.MakeOffsetShape()
- try:
- tool_thickened = downcast(offset_builder.Shape())
- except StdFail_NotDone as err:
- raise RuntimeError("Error determining top/bottom") from err
-
- tops: list[Shape] = []
- bottoms: list[Shape] = []
- properties = GProp_GProps()
- for part in get_top_level_topods_shapes(split_result):
- sub_shape = self.__class__.cast(part)
- if isinstance(tool, Plane):
- is_up = tool.to_local_coords(sub_shape).center().Z >= 0
- else:
- # Intersect self and the thickened tool
- is_up_obj = _topods_bool_op(
- (part,), (tool_thickened,), BRepAlgoAPI_Common()
- )
- # Calculate volume of intersection
- BRepGProp.VolumeProperties_s(is_up_obj, properties)
- is_up = properties.Mass() >= TOLERANCE
- (tops if is_up else bottoms).append(sub_shape)
-
- top = None if not tops else tops[0] if len(tops) == 1 else tops
- bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms
-
- if keep == Keep.BOTH:
- return (top, bottom)
- if keep == Keep.TOP:
- return top
- if keep == Keep.BOTTOM:
- return bottom
- return None
-
- def vertices(self) -> ShapeList[Vertex]:
- """vertices - all the vertices in this Shape"""
- assert isinstance(self, Shape) # for typing
- return Shape.get_shape_list(self, "Vertex")
-
- def vertex(self) -> Vertex:
- """Return the Vertex"""
- assert isinstance(self, Shape) # for typing
- return Shape.get_single_shape(self, "Vertex")
-
- def edges(self) -> ShapeList[Edge]:
- """edges - all the edges in this Shape"""
- assert isinstance(self, Shape) # for typing
- edge_list = Shape.get_shape_list(self, "Edge")
- return edge_list.filter_by(
- lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
- )
-
- def edge(self) -> Edge:
- """Return the Edge"""
- assert isinstance(self, Shape) # for typing
- return Shape.get_single_shape(self, "Edge")
-
- def wires(self) -> ShapeList[Wire]:
- """wires - all the wires in this Shape"""
- assert isinstance(self, Shape) # for typing
- return Shape.get_shape_list(self, "Wire")
-
- def wire(self) -> Wire:
- """Return the Wire"""
- assert isinstance(self, Shape) # for typing
- return Shape.get_single_shape(self, "Wire")
-
- def start_point(self) -> Vector:
- """The start point of this edge
-
- Note that circles may have identical start and end points.
- """
- assert isinstance(self, Shape) # for typing
- curve = self.geom_adaptor()
- umin = curve.FirstParameter()
-
- return Vector(curve.Value(umin))
-
- def end_point(self) -> Vector:
- """The end point of this edge.
-
- Note that circles may have identical start and end points.
- """
- assert isinstance(self, Shape) # for typing
- curve = self.geom_adaptor()
- umax = curve.LastParameter()
-
- return Vector(curve.Value(umax))
-
- def param_at(self, distance: float) -> float:
- """Parameter along a curve
-
- Compute parameter value at the specified normalized distance.
-
- Args:
- d (float): normalized distance (0.0 >= d >= 1.0)
-
- Returns:
- float: parameter value
- """
- assert isinstance(self, Shape) # for typing
- curve = self.geom_adaptor()
-
- length = GCPnts_AbscissaPoint.Length_s(curve)
- return GCPnts_AbscissaPoint(
- curve, length * distance, curve.FirstParameter()
- ).Parameter()
-
- def tangent_at(
- self,
- position: Union[float, VectorLike] = 0.5,
- position_mode: PositionMode = PositionMode.PARAMETER,
- ) -> Vector:
- """tangent_at
-
- Find the tangent at a given position on the 1D shape where the position
- is either a float (or int) parameter or a point that lies on the shape.
-
- Args:
- position (Union[float, VectorLike]): distance, parameter value, or
- point on shape. Defaults to 0.5.
- position_mode (PositionMode, optional): position calculation mode.
- Defaults to PositionMode.PARAMETER.
-
- Raises:
- ValueError: invalid position
-
- Returns:
- Vector: tangent value
- """
- assert isinstance(self, Shape) # for typing
-
- if isinstance(position, (float, int)):
- curve = self.geom_adaptor()
- if position_mode == PositionMode.PARAMETER:
- parameter = self.param_at(position)
- else:
- parameter = self.param_at(position / self.length)
- else:
- try:
- pnt = Vector(position)
- except Exception as exc:
- raise ValueError("position must be a float or a point") from exc
- # GeomAPI_ProjectPointOnCurve only works with Edges so find
- # the closest Edge if the shape has multiple Edges.
- my_edges: list[Edge] = self.edges()
- distances = [(e.distance_to(pnt), i) for i, e in enumerate(my_edges)]
- sorted_distances = sorted(distances, key=lambda x: x[0])
- closest_edge = my_edges[sorted_distances[0][1]]
- # Get the extreme of the parameter values for this Edge
- first: float = closest_edge.param_at(0)
- last: float = closest_edge.param_at(1)
- # Extract the Geom_Curve from the Shape
- curve = BRep_Tool.Curve_s(closest_edge.wrapped, first, last)
- projector = GeomAPI_ProjectPointOnCurve(pnt.to_pnt(), curve)
- parameter = projector.LowerDistanceParameter()
-
- tmp = gp_Pnt()
- res = gp_Vec()
- curve.D1(parameter, tmp, res)
-
- return Vector(gp_Dir(res))
-
- def tangent_angle_at(
- self,
- location_param: float = 0.5,
- position_mode: PositionMode = PositionMode.PARAMETER,
- plane: Plane = Plane.XY,
- ) -> float:
- """tangent_angle_at
-
- Compute the tangent angle at the specified location
-
- Args:
- location_param (float, optional): distance or parameter value. Defaults to 0.5.
- position_mode (PositionMode, optional): position calculation mode.
- Defaults to PositionMode.PARAMETER.
- plane (Plane, optional): plane line was constructed on. Defaults to Plane.XY.
-
- Returns:
- float: angle in degrees between 0 and 360
- """
- tan_vector = self.tangent_at(location_param, position_mode)
- angle = (plane.x_dir.get_signed_angle(tan_vector, plane.z_dir) + 360) % 360.0
- return angle
-
- def normal(self) -> Vector:
- """Calculate the normal Vector. Only possible for planar curves.
-
- :return: normal vector
-
- Args:
-
- Returns:
-
- """
- assert isinstance(self, Shape) # for typing
-
- curve = self.geom_adaptor()
- gtype = self.geom_type
-
- if gtype == GeomType.CIRCLE:
- circ = curve.Circle()
- return_value = Vector(circ.Axis().Direction())
- elif gtype == GeomType.ELLIPSE:
- ell = curve.Ellipse()
- return_value = Vector(ell.Axis().Direction())
- else:
- find_surface = BRepLib_FindSurface(self.wrapped, OnlyPlane=True)
- surf = find_surface.Surface()
-
- if isinstance(surf, Geom_Plane):
- pln = surf.Pln()
- return_value = Vector(pln.Axis().Direction())
- else:
- raise ValueError("Normal not defined")
-
- return return_value
-
- def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector:
- """Center of object
-
- Return the center based on center_of
-
- Args:
- center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY.
-
- Returns:
- Vector: center
- """
- assert isinstance(self, Shape) # for typing
-
- if center_of == CenterOf.GEOMETRY:
- middle = self.position_at(0.5)
- elif center_of == CenterOf.MASS:
- properties = GProp_GProps()
- BRepGProp.LinearProperties_s(self.wrapped, properties)
- middle = Vector(properties.CentreOfMass())
- elif center_of == CenterOf.BOUNDING_BOX:
- middle = self.bounding_box().center()
- return middle
-
- def common_plane(self, *lines: Edge | Wire | None) -> Union[None, Plane]:
- """common_plane
-
- Find the plane containing all the edges/wires (including self). If there
- is no common plane return None. If the edges are coaxial, select one
- of the infinite number of valid planes.
-
- Args:
- lines (sequence of Union[Edge,Wire]): edges in common with self
-
- Returns:
- Union[None, Plane]: Either the common plane or None
- """
- # pylint: disable=too-many-locals
- # Note: BRepLib_FindSurface is not helpful as it requires the
- # Edges to form a surface perimeter.
- assert isinstance(self, Shape) # for typing
-
- points: list[Vector] = []
- all_lines: list[Edge | Wire] = [
- line for line in [self, *lines] if line is not None
- ]
- if any(not isinstance(line, (Edge, Wire)) for line in all_lines):
- raise ValueError("Only Edges or Wires are valid")
-
- result = None
- # Are they all co-axial - if so, select one of the infinite planes
- all_edges: list[Edge] = [e for l in all_lines for e in l.edges()]
- if all(e.geom_type == GeomType.LINE for e in all_edges):
- as_axis = [Axis(e @ 0, e % 0) for e in all_edges]
- if all(a0.is_coaxial(a1) for a0, a1 in combinations(as_axis, 2)):
- origin = as_axis[0].position
- x_dir = as_axis[0].direction
- z_dir = as_axis[0].to_plane().x_dir
- c_plane = Plane(origin, z_dir=z_dir)
- result = c_plane.shift_origin((0, 0))
-
- if result is None: # not coaxial
- # Shorten any infinite lines (from converted Axis)
- normal_lines = list(filter(lambda line: line.length <= 1e50, all_lines))
- infinite_lines = filter(lambda line: line.length > 1e50, all_lines)
- shortened_lines = [l.trim_to_length(0.5, 10) for l in infinite_lines]
- all_lines = normal_lines + shortened_lines
-
- for line in all_lines:
- num_points = 2 if line.geom_type == GeomType.LINE else 8
- points.extend(
- [line.position_at(i / (num_points - 1)) for i in range(num_points)]
- )
- points = list(set(points)) # unique points
- extreme_areas = {}
- for subset in combinations(points, 3):
- vector1 = subset[1] - subset[0]
- vector2 = subset[2] - subset[0]
- area = 0.5 * (vector1.cross(vector2).length)
- extreme_areas[area] = subset
- # The points that create the largest area make the most accurate plane
- extremes = extreme_areas[sorted(list(extreme_areas.keys()))[-1]]
-
- # Create a plane from these points
- x_dir = (extremes[1] - extremes[0]).normalized()
- z_dir = (extremes[2] - extremes[0]).cross(x_dir)
- try:
- c_plane = Plane(
- origin=(sum(extremes, Vector(0, 0, 0)) / 3), z_dir=z_dir
- )
- c_plane = c_plane.shift_origin((0, 0))
- except ValueError:
- # There is no valid common plane
- result = None
- else:
- # Are all of the points on the common plane
- common = all(c_plane.contains(p) for p in points)
- result = c_plane if common else None
-
- return result
-
- @property
- def length(self) -> float:
- """Edge or Wire length"""
- assert isinstance(self, Shape) # for typing
- return GCPnts_AbscissaPoint.Length_s(self.geom_adaptor())
-
- @property
- def radius(self) -> float:
- """Calculate the radius.
-
- Note that when applied to a Wire, the radius is simply the radius of the first edge.
-
- Args:
-
- Returns:
- radius
-
- Raises:
- ValueError: if kernel can not reduce the shape to a circular edge
-
- """
- assert isinstance(self, Shape) # for typing
- geom = self.geom_adaptor()
- try:
- circ = geom.Circle()
- except (Standard_NoSuchObject, Standard_Failure) as err:
- raise ValueError("Shape could not be reduced to a circle") from err
- return circ.Radius()
-
- @property
- def is_forward(self) -> bool:
- """Does the Edge/Wire loop forward or reverse"""
- assert isinstance(self, Shape) # for typing
- if self.wrapped is None:
- raise ValueError("Can't determine direction of empty Edge or Wire")
- return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD
-
- @property
- def is_closed(self) -> bool:
- """Are the start and end points equal?"""
- assert isinstance(self, Shape) # for typing
- if self.wrapped is None:
- raise ValueError("Can't determine if empty Edge or Wire is closed")
- return BRep_Tool.IsClosed_s(self.wrapped)
-
- @property
- def volume(self) -> float:
- """volume - the volume of this Edge or Wire, which is always zero"""
- return 0.0
-
- def position_at(
- self, distance: float, position_mode: PositionMode = PositionMode.PARAMETER
- ) -> Vector:
- """Position At
-
- Generate a position along the underlying curve.
-
- Args:
- distance (float): distance or parameter value
- position_mode (PositionMode, optional): position calculation mode. Defaults to
- PositionMode.PARAMETER.
-
- Returns:
- Vector: position on the underlying curve
- """
- assert isinstance(self, Shape) # for typing
- curve = self.geom_adaptor()
-
- if position_mode == PositionMode.PARAMETER:
- param = self.param_at(distance)
- else:
- param = self.param_at(distance / self.length)
-
- return Vector(curve.Value(param))
-
- def positions(
- self,
- distances: Iterable[float],
- position_mode: PositionMode = PositionMode.PARAMETER,
- ) -> list[Vector]:
- """Positions along curve
-
- Generate positions along the underlying curve
-
- Args:
- distances (Iterable[float]): distance or parameter values
- position_mode (PositionMode, optional): position calculation mode.
- Defaults to PositionMode.PARAMETER.
-
- Returns:
- list[Vector]: positions along curve
- """
- return [self.position_at(d, position_mode) for d in distances]
-
- def location_at(
- self,
- distance: float,
- position_mode: PositionMode = PositionMode.PARAMETER,
- frame_method: FrameMethod = FrameMethod.FRENET,
- planar: bool = False,
- ) -> Location:
- """Locations along curve
-
- Generate a location along the underlying curve.
-
- Args:
- distance (float): distance or parameter value
- position_mode (PositionMode, optional): position calculation mode.
- Defaults to PositionMode.PARAMETER.
- frame_method (FrameMethod, optional): moving frame calculation method.
- Defaults to FrameMethod.FRENET.
- planar (bool, optional): planar mode. Defaults to False.
-
- Returns:
- Location: A Location object representing local coordinate system
- at the specified distance.
- """
- assert isinstance(self, Shape) # for typing
- curve = self.geom_adaptor()
-
- if position_mode == PositionMode.PARAMETER:
- param = self.param_at(distance)
- else:
- param = self.param_at(distance / self.length)
-
- law: GeomFill_TrihedronLaw
- if frame_method == FrameMethod.FRENET:
- law = GeomFill_Frenet()
- else:
- law = GeomFill_CorrectedFrenet()
-
- law.SetCurve(curve)
-
- tangent, normal, binormal = gp_Vec(), gp_Vec(), gp_Vec()
-
- law.D0(param, tangent, normal, binormal)
- pnt = curve.Value(param)
-
- transformation = gp_Trsf()
- if planar:
- transformation.SetTransformation(
- gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3()
- )
- else:
- transformation.SetTransformation(
- gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3()
- )
-
- return Location(TopLoc_Location(transformation))
-
- def locations(
- self,
- distances: Iterable[float],
- position_mode: PositionMode = PositionMode.PARAMETER,
- frame_method: FrameMethod = FrameMethod.FRENET,
- planar: bool = False,
- ) -> list[Location]:
- """Locations along curve
-
- Generate location along the curve
-
- Args:
- distances (Iterable[float]): distance or parameter values
- position_mode (PositionMode, optional): position calculation mode.
- Defaults to PositionMode.PARAMETER.
- frame_method (FrameMethod, optional): moving frame calculation method.
- Defaults to FrameMethod.FRENET.
- planar (bool, optional): planar mode. Defaults to False.
-
- Returns:
- list[Location]: A list of Location objects representing local coordinate
- systems at the specified distances.
- """
- return [
- self.location_at(d, position_mode, frame_method, planar) for d in distances
- ]
-
- def __matmul__(self, position: float) -> Vector:
- """Position on wire operator @"""
- return self.position_at(position)
-
- def __mod__(self, position: float) -> Vector:
- """Tangent on wire operator %"""
- return self.tangent_at(position)
-
- def __xor__(self, position: float) -> Location:
- """Location on wire operator ^"""
- return self.location_at(position)
-
- def offset_2d(
- self,
- distance: float,
- kind: Kind = Kind.ARC,
- side: Side = Side.BOTH,
- closed: bool = True,
- ) -> Union[Edge, Wire]:
- """2d Offset
-
- Offsets a planar edge/wire
-
- Args:
- distance (float): distance from edge/wire to offset
- kind (Kind, optional): offset corner transition. Defaults to Kind.ARC.
- side (Side, optional): side to place offset. Defaults to Side.BOTH.
- closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT
- offset. Defaults to True.
- Raises:
- RuntimeError: Multiple Wires generated
- RuntimeError: Unexpected result type
-
- Returns:
- Wire: offset wire
- """
- # pylint: disable=too-many-branches, too-many-locals, too-many-statements
- kind_dict = {
- Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
- Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
- Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
- }
- line = self if isinstance(self, Wire) else Wire([self])
-
- # Avoiding a bug when the wire contains a single Edge
- if len(line.edges()) == 1:
- edge = line.edges()[0]
- edges = [edge.trim(0.0, 0.5), edge.trim(0.5, 1.0)]
- topods_wire = Wire(edges).wrapped
- else:
- topods_wire = line.wrapped
-
- offset_builder = BRepOffsetAPI_MakeOffset()
- offset_builder.Init(kind_dict[kind])
- # offset_builder.SetApprox(True)
- offset_builder.AddWire(topods_wire)
- offset_builder.Perform(distance)
-
- obj = downcast(offset_builder.Shape())
- if isinstance(obj, TopoDS_Compound):
- obj = unwrap_topods_compound(obj, fully=True)
- if isinstance(obj, TopoDS_Wire):
- offset_wire = Wire(obj)
- else: # Likely multiple Wires were generated
- raise RuntimeError("Unexpected result type")
-
- if side != Side.BOTH:
- # Find and remove the end arcs
- offset_edges = offset_wire.edges()
- edges_to_keep: list[list[int]] = [[], [], []]
- i = 0
- for edge in offset_edges:
- if edge.geom_type == GeomType.CIRCLE and (
- edge.arc_center == line.position_at(0)
- or edge.arc_center == line.position_at(1)
- ):
- i += 1
- else:
- edges_to_keep[i].append(edge)
- edges_to_keep[0] += edges_to_keep[2]
- wires = [Wire(edges) for edges in edges_to_keep[0:2]]
- centers = [w.position_at(0.5) for w in wires]
- angles = [
- line.tangent_at(0).get_signed_angle(c - line.position_at(0))
- for c in centers
- ]
- if side == Side.LEFT:
- offset_wire = wires[int(angles[0] > angles[1])]
- else:
- offset_wire = wires[int(angles[0] <= angles[1])]
-
- if closed:
- self0 = line.position_at(0)
- self1 = line.position_at(1)
- end0 = offset_wire.position_at(0)
- end1 = offset_wire.position_at(1)
- if (self0 - end0).length - abs(distance) <= TOLERANCE:
- edge0 = Edge.make_line(self0, end0)
- edge1 = Edge.make_line(self1, end1)
- else:
- edge0 = Edge.make_line(self0, end1)
- edge1 = Edge.make_line(self1, end0)
- offset_wire = Wire(
- line.edges() + offset_wire.edges() + ShapeList([edge0, edge1])
- )
-
- offset_edges = offset_wire.edges()
- return offset_edges[0] if len(offset_edges) == 1 else offset_wire
-
- def perpendicular_line(
- self, length: float, u_value: float, plane: Plane = Plane.XY
- ) -> Edge:
- """perpendicular_line
-
- Create a line on the given plane perpendicular to and centered on beginning of self
-
- Args:
- length (float): line length
- u_value (float): position along line between 0.0 and 1.0
- plane (Plane, optional): plane containing perpendicular line. Defaults to Plane.XY.
-
- Returns:
- Edge: perpendicular line
- """
- start = self.position_at(u_value)
- local_plane = Plane(
- origin=start, x_dir=self.tangent_at(u_value), z_dir=plane.z_dir
- )
- line = Edge.make_line(
- start + local_plane.y_dir * length / 2,
- start - local_plane.y_dir * length / 2,
- )
- return line
-
- def project(
- self, face: Face, direction: VectorLike, closest: bool = True
- ) -> Edge | Wire | ShapeList[Edge | Wire]:
- """Project onto a face along the specified direction
-
- Args:
- face: Face:
- direction: VectorLike:
- closest: bool: (Default value = True)
-
- Returns:
-
- """
- assert isinstance(self, Shape) # for typing
- if self.wrapped is None:
- raise ValueError("Can't project an empty Edge or Wire")
-
- bldr = BRepProj_Projection(
- self.wrapped, face.wrapped, Vector(direction).to_dir()
- )
- shapes: TopoDS_Compound = bldr.Shape()
-
- # select the closest projection if requested
- return_value: Edge | Wire | ShapeList[Edge | Wire]
-
- if closest:
- dist_calc = BRepExtrema_DistShapeShape()
- dist_calc.LoadS1(self.wrapped)
-
- min_dist = inf
-
- # for shape in shapes:
- for shape in get_top_level_topods_shapes(shapes):
- dist_calc.LoadS2(shape)
- dist_calc.Perform()
- dist = dist_calc.Value()
-
- if dist < min_dist:
- min_dist = dist
- return_value = Mixin1D.cast(shape)
-
- else:
- return_value = ShapeList(
- Mixin1D.cast(shape) for shape in get_top_level_topods_shapes(shapes)
- )
-
- return return_value
-
- def project_to_viewport(
- self,
- viewport_origin: VectorLike,
- viewport_up: VectorLike = (0, 0, 1),
- look_at: VectorLike | None = None,
- ) -> tuple[ShapeList[Edge], ShapeList[Edge]]:
- """project_to_viewport
-
- Project a shape onto a viewport returning visible and hidden Edges.
-
- Args:
- viewport_origin (VectorLike): location of viewport
- viewport_up (VectorLike, optional): direction of the viewport y axis.
- Defaults to (0, 0, 1).
- look_at (VectorLike, optional): point to look at.
- Defaults to None (center of shape).
-
- Returns:
- tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges
- """
- assert isinstance(self, Shape) # for typing
-
- def extract_edges(compound):
- edges = [] # List to store the extracted edges
-
- # Create a TopExp_Explorer to traverse the sub-shapes of the compound
- explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE)
-
- # Loop through the sub-shapes and extract edges
- while explorer.More():
- edge = downcast(explorer.Current())
- edges.append(edge)
- explorer.Next()
-
- return edges
-
- # Setup the projector
- hidden_line_removal = HLRBRep_Algo()
- hidden_line_removal.Add(self.wrapped)
-
- viewport_origin = Vector(viewport_origin)
- look_at = Vector(look_at) if look_at else self.center()
- projection_dir: Vector = (viewport_origin - look_at).normalized()
- viewport_up = Vector(viewport_up).normalized()
- camera_coordinate_system = gp_Ax2()
- camera_coordinate_system.SetAxis(
- gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir())
- )
- camera_coordinate_system.SetYDirection(viewport_up.to_dir())
- projector = HLRAlgo_Projector(camera_coordinate_system)
-
- hidden_line_removal.Projector(projector)
- hidden_line_removal.Update()
- hidden_line_removal.Hide()
-
- hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal)
-
- # Create the visible edges
- visible_edges = []
- for edges in [
- hlr_shapes.VCompound(),
- hlr_shapes.Rg1LineVCompound(),
- hlr_shapes.OutLineVCompound(),
- ]:
- if not edges.IsNull():
- visible_edges.extend(extract_edges(downcast(edges)))
-
- # Create the hidden edges
- hidden_edges = []
- for edges in [
- hlr_shapes.HCompound(),
- hlr_shapes.OutLineHCompound(),
- hlr_shapes.Rg1LineHCompound(),
- ]:
- if not edges.IsNull():
- hidden_edges.extend(extract_edges(downcast(edges)))
-
- # Fix the underlying geometry - otherwise we will get segfaults
- for edge in visible_edges:
- BRepLib.BuildCurves3d_s(edge, TOLERANCE)
- for edge in hidden_edges:
- BRepLib.BuildCurves3d_s(edge, TOLERANCE)
-
- # convert to native shape objects
- visible_edges = ShapeList(Edge(e) for e in visible_edges)
- hidden_edges = ShapeList(Edge(e) for e in hidden_edges)
-
- return (visible_edges, hidden_edges)
-
-
-class Mixin2D:
- """Additional methods to add to Face and Shell class"""
-
- project_to_viewport = Mixin1D.project_to_viewport
- split = Mixin1D.split
-
- @classmethod
- def cast(cls, obj: TopoDS_Shape) -> Self:
- "Returns the right type of wrapper, given a OCCT object"
-
- # define the shape lookup table for casting
- constructor_lut = {
- ta.TopAbs_VERTEX: Vertex,
- ta.TopAbs_EDGE: Edge,
- ta.TopAbs_WIRE: Wire,
- ta.TopAbs_FACE: Face,
- ta.TopAbs_SHELL: Shell,
- }
-
- shape_type = shapetype(obj)
- # NB downcast is needed to handle TopoDS_Shape types
- return constructor_lut[shape_type](downcast(obj))
-
- vertices = Mixin1D.vertices
- vertex = Mixin1D.vertex
- edges = Mixin1D.edges
- edge = Mixin1D.edge
- wires = Mixin1D.wires
- wire = Mixin1D.wire
-
- def faces(self) -> ShapeList[Face]:
- """faces - all the faces in this Shape"""
- return Shape.get_shape_list(self, "Face")
-
- def face(self) -> Face:
- """Return the Face"""
- return Shape.get_single_shape(self, "Face")
-
- def shells(self) -> ShapeList[Shell]:
- """shells - all the shells in this Shape"""
- return Shape.get_shape_list(self, "Shell")
-
- def shell(self) -> Shell:
- """Return the Shell"""
- return Shape.get_single_shape(self, "Shell")
-
- def __neg__(self) -> Self:
- """Reverse normal operator -"""
- if self.wrapped is None:
- raise ValueError("Invalid Shape")
- new_surface = copy.deepcopy(self)
- new_surface.wrapped = downcast(self.wrapped.Complemented())
-
- return new_surface
-
- def offset(self, amount: float) -> Self:
- """Return a copy of self moved along the normal by amount"""
- return copy.deepcopy(self).moved(Location(self.normal_at() * amount))
-
- def find_intersection_points(
- self, other: Axis, tolerance: float = TOLERANCE
- ) -> list[tuple[Vector, Vector]]:
- """Find point and normal at intersection
-
- Return both the point(s) and normal(s) of the intersection of the axis and the shape
-
- Args:
- axis (Axis): axis defining the intersection line
-
- Returns:
- list[tuple[Vector, Vector]]: Point and normal of intersection
- """
- assert isinstance(self, Shape) # for typing
-
- if self.wrapped is None:
- return []
-
- intersection_line = gce_MakeLin(other.wrapped).Value()
- intersect_maker = BRepIntCurveSurface_Inter()
- intersect_maker.Init(self.wrapped, intersection_line, tolerance)
-
- intersections = []
- while intersect_maker.More():
- inter_pt = intersect_maker.Pnt()
- # Calculate distance along axis
- distance = other.to_plane().to_local_coords(Vector(inter_pt)).Z
- intersections.append(
- (
- intersect_maker.Face(), # TopoDS_Face
- Vector(inter_pt),
- distance,
- )
- )
- intersect_maker.Next()
-
- intersections.sort(key=lambda x: x[2])
- intersecting_faces = [i[0] for i in intersections]
- intersecting_points = [i[1] for i in intersections]
- intersecting_normals = [
- _topods_face_normal_at(f, intersecting_points[i].to_pnt())
- for i, f in enumerate(intersecting_faces)
- ]
- result = []
- for pnt, normal in zip(intersecting_points, intersecting_normals):
- result.append((pnt, normal))
-
- return result
-
-
-class Mixin3D:
- """Additional methods to add to 3D Shape classes"""
-
- project_to_viewport = Mixin1D.project_to_viewport
- split = Mixin1D.split
- find_intersection_points = Mixin2D.find_intersection_points
-
- @classmethod
- def cast(cls, obj: TopoDS_Shape) -> Self:
- "Returns the right type of wrapper, given a OCCT object"
-
- # define the shape lookup table for casting
- constructor_lut = {
- ta.TopAbs_VERTEX: Vertex,
- ta.TopAbs_EDGE: Edge,
- ta.TopAbs_WIRE: Wire,
- ta.TopAbs_FACE: Face,
- ta.TopAbs_SHELL: Shell,
- ta.TopAbs_SOLID: Solid,
- }
-
- shape_type = shapetype(obj)
- # NB downcast is needed to handle TopoDS_Shape types
- return constructor_lut[shape_type](downcast(obj))
-
- vertices = Mixin1D.vertices
- vertex = Mixin1D.vertex
- edges = Mixin1D.edges
- edge = Mixin1D.edge
- wires = Mixin1D.wires
- wire = Mixin1D.wire
- faces = Mixin2D.faces
- face = Mixin2D.face
- shells = Mixin2D.shells
- shell = Mixin2D.shell
-
- def solids(self) -> ShapeList[Solid]:
- """solids - all the solids in this Shape"""
- return Shape.get_shape_list(self, "Solid")
-
- def solid(self) -> Solid:
- """Return the Solid"""
- return Shape.get_single_shape(self, "Solid")
-
- def fillet(self, radius: float, edge_list: Iterable[Edge]) -> Self:
- """Fillet
-
- Fillets the specified edges of this solid.
-
- Args:
- radius (float): float > 0, the radius of the fillet
- edge_list (Iterable[Edge]): a list of Edge objects, which must belong to this solid
-
- Returns:
- Any: Filleted solid
- """
- native_edges = [e.wrapped for e in edge_list]
-
- fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped)
-
- for native_edge in native_edges:
- fillet_builder.Add(radius, native_edge)
-
- try:
- new_shape = self.__class__(fillet_builder.Shape())
- if not new_shape.is_valid():
- raise Standard_Failure
- except (StdFail_NotDone, Standard_Failure) as err:
- raise ValueError(
- f"Failed creating a fillet with radius of {radius}, try a smaller value"
- f" or use max_fillet() to find the largest valid fillet radius"
- ) from err
-
- return new_shape
-
- def max_fillet(
- self,
- edge_list: Iterable[Edge],
- tolerance=0.1,
- max_iterations: int = 10,
- ) -> float:
- """Find Maximum Fillet Size
-
- Find the largest fillet radius for the given Shape and edges with a
- recursive binary search.
-
- Example:
-
- max_fillet_radius = my_shape.max_fillet(shape_edges)
- max_fillet_radius = my_shape.max_fillet(shape_edges, tolerance=0.5, max_iterations=8)
-
-
- Args:
- edge_list (Iterable[Edge]): a sequence of Edge objects, which must belong to this solid
- tolerance (float, optional): maximum error from actual value. Defaults to 0.1.
- max_iterations (int, optional): maximum number of recursive iterations. Defaults to 10.
-
- Raises:
- RuntimeError: failed to find the max value
- ValueError: the provided Shape is invalid
-
- Returns:
- float: maximum fillet radius
- """
-
- def __max_fillet(window_min: float, window_max: float, current_iteration: int):
- window_mid = (window_min + window_max) / 2
-
- if current_iteration == max_iterations:
- raise RuntimeError(
- f"Failed to find the max value within {tolerance} in {max_iterations}"
- )
-
- fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped)
-
- for native_edge in native_edges:
- fillet_builder.Add(window_mid, native_edge)
-
- # Do these numbers work? - if not try with the smaller window
- try:
- new_shape = self.__class__(fillet_builder.Shape())
- if not new_shape.is_valid():
- raise fillet_exception
- except fillet_exception:
- return __max_fillet(window_min, window_mid, current_iteration + 1)
-
- # These numbers work, are they close enough? - if not try larger window
- if window_mid - window_min <= tolerance:
- return_value = window_mid
- else:
- return_value = __max_fillet(
- window_mid, window_max, current_iteration + 1
- )
- return return_value
-
- if not self.is_valid():
- raise ValueError("Invalid Shape")
-
- native_edges = [e.wrapped for e in edge_list]
-
- # Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform
- # specific exceptions are required.
- if platform.system() == "Darwin":
- fillet_exception = Standard_Failure
- else:
- fillet_exception = StdFail_NotDone
-
- max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0)
-
- return max_radius
-
- def chamfer(
- self,
- length: float,
- length2: Optional[float],
- edge_list: Iterable[Edge],
- face: Face = None,
- ) -> Self:
- """Chamfer
-
- Chamfers the specified edges of this solid.
-
- Args:
- length (float): length > 0, the length (length) of the chamfer
- length2 (Optional[float]): length2 > 0, optional parameter for asymmetrical
- chamfer. Should be `None` if not required.
- edge_list (Iterable[Edge]): a list of Edge objects, which must belong to
- this solid
- face (Face): identifies the side where length is measured. The edge(s) must be
- part of the face
-
- Returns:
- Self: Chamfered solid
- """
- edge_list = list(edge_list)
- if face:
- if any((edge for edge in edge_list if edge not in face.edges())):
- raise ValueError("Some edges are not part of the face")
-
- native_edges = [e.wrapped for e in edge_list]
-
- # make a edge --> faces mapping
- edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
- TopExp.MapShapesAndAncestors_s(
- self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map
- )
-
- # note: we prefer 'length' word to 'radius' as opposed to FreeCAD's API
- chamfer_builder = BRepFilletAPI_MakeChamfer(self.wrapped)
-
- if length2:
- distance1 = length
- distance2 = length2
- else:
- distance1 = length
- distance2 = length
-
- for native_edge in native_edges:
- if face:
- topo_face = face.wrapped
- else:
- topo_face = edge_face_map.FindFromKey(native_edge).First()
-
- chamfer_builder.Add(
- distance1, distance2, native_edge, TopoDS.Face_s(topo_face)
- ) # NB: edge_face_map return a generic TopoDS_Shape
-
- try:
- new_shape = self.__class__(chamfer_builder.Shape())
- if not new_shape.is_valid():
- raise Standard_Failure
- except (StdFail_NotDone, Standard_Failure) as err:
- raise ValueError(
- "Failed creating a chamfer, try a smaller length value(s)"
- ) from err
-
- return new_shape
-
- def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector:
- """Return center of object
-
- Find center of object
-
- Args:
- center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS.
-
- Raises:
- ValueError: Center of GEOMETRY is not supported for this object
- NotImplementedError: Unable to calculate center of mass of this object
-
- Returns:
- Vector: center
- """
- if center_of == CenterOf.GEOMETRY:
- raise ValueError("Center of GEOMETRY is not supported for this object")
- if center_of == CenterOf.MASS:
- properties = GProp_GProps()
- calc_function = Shape.shape_properties_LUT[shapetype(self.wrapped)]
- if calc_function:
- calc_function(self.wrapped, properties)
- middle = Vector(properties.CentreOfMass())
- else:
- raise NotImplementedError
- elif center_of == CenterOf.BOUNDING_BOX:
- middle = self.bounding_box().center()
- return middle
-
- def hollow(
- self,
- faces: Optional[Iterable[Face]],
- thickness: float,
- tolerance: float = 0.0001,
- kind: Kind = Kind.ARC,
- ) -> Solid:
- """Hollow
-
- Return the outer shelled solid of self.
-
- Args:
- faces (Optional[Iterable[Face]]): faces to be removed,
- which must be part of the solid. Can be an empty list.
- thickness (float): shell thickness - positive shells outwards, negative
- shells inwards.
- tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001.
- kind (Kind, optional): intersection type. Defaults to Kind.ARC.
-
- Raises:
- ValueError: Kind.TANGENT not supported
-
- Returns:
- Solid: A hollow solid.
- """
- faces = list(faces) if faces else []
- if kind == Kind.TANGENT:
- raise ValueError("Kind.TANGENT not supported")
-
- kind_dict = {
- Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
- Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
- }
-
- occ_faces_list = TopTools_ListOfShape()
- for face in faces:
- occ_faces_list.Append(face.wrapped)
-
- shell_builder = BRepOffsetAPI_MakeThickSolid()
- shell_builder.MakeThickSolidByJoin(
- self.wrapped,
- occ_faces_list,
- thickness,
- tolerance,
- Intersection=True,
- Join=kind_dict[kind],
- )
- shell_builder.Build()
-
- if faces:
- return_value = self.__class__(shell_builder.Shape())
-
- else: # if no faces provided a watertight solid will be constructed
- shell1 = self.__class__(shell_builder.Shape()).shells()[0].wrapped
- shell2 = self.shells()[0].wrapped
-
- # s1 can be outer or inner shell depending on the thickness sign
- if thickness > 0:
- sol = BRepBuilderAPI_MakeSolid(shell1, shell2)
- else:
- sol = BRepBuilderAPI_MakeSolid(shell2, shell1)
-
- # fix needed for the orientations
- return_value = self.__class__(sol.Shape()).fix()
-
- return return_value
-
- def offset_3d(
- self,
- openings: Optional[Iterable[Face]],
- thickness: float,
- tolerance: float = 0.0001,
- kind: Kind = Kind.ARC,
- ) -> Solid:
- """Shell
-
- Make an offset solid of self.
-
- Args:
- openings (Optional[Iterable[Face]]): faces to be removed,
- which must be part of the solid. Can be an empty list.
- thickness (float): offset amount - positive offset outwards, negative inwards
- tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001.
- kind (Kind, optional): intersection type. Defaults to Kind.ARC.
-
- Raises:
- ValueError: Kind.TANGENT not supported
-
- Returns:
- Solid: A shelled solid.
- """
- openings = list(openings) if openings else []
- if kind == Kind.TANGENT:
- raise ValueError("Kind.TANGENT not supported")
-
- kind_dict = {
- Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
- Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
- Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
- }
-
- occ_faces_list = TopTools_ListOfShape()
- for face in openings:
- occ_faces_list.Append(face.wrapped)
-
- offset_builder = BRepOffsetAPI_MakeThickSolid()
- offset_builder.MakeThickSolidByJoin(
- self.wrapped,
- occ_faces_list,
- thickness,
- tolerance,
- Intersection=True,
- RemoveIntEdges=True,
- Join=kind_dict[kind],
- )
- offset_builder.Build()
-
- try:
- offset_occt_solid = offset_builder.Shape()
- except (StdFail_NotDone, Standard_Failure) as err:
- raise RuntimeError(
- "offset Error, an alternative kind may resolve this error"
- ) from err
-
- offset_solid = self.__class__(offset_occt_solid)
-
- # The Solid can be inverted, if so reverse
- if offset_solid.volume < 0:
- offset_solid.wrapped.Reverse()
-
- return offset_solid
-
- def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool:
- """Returns whether or not the point is inside a solid or compound
- object within the specified tolerance.
-
- Args:
- point: tuple or Vector representing 3D point to be tested
- tolerance: tolerance for inside determination, default=1.0e-6
- point: VectorLike:
- tolerance: float: (Default value = 1.0e-6)
-
- Returns:
- bool indicating whether or not point is within solid
-
- """
- solid_classifier = BRepClass3d_SolidClassifier(self.wrapped)
- solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance)
-
- return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace()
-
- def dprism(
- self,
- basis: Optional[Face],
- bounds: list[Union[Face, Wire]],
- depth: float = None,
- taper: float = 0,
- up_to_face: Face = None,
- thru_all: bool = True,
- additive: bool = True,
- ) -> Solid:
- """dprism
-
- Make a prismatic feature (additive or subtractive)
-
- Args:
- basis (Optional[Face]): face to perform the operation on
- bounds (list[Union[Face,Wire]]): list of profiles
- depth (float, optional): depth of the cut or extrusion. Defaults to None.
- taper (float, optional): in degrees. Defaults to 0.
- up_to_face (Face, optional): a face to extrude until. Defaults to None.
- thru_all (bool, optional): cut thru_all. Defaults to True.
- additive (bool, optional): Defaults to True.
-
- Returns:
- Solid: prismatic feature
- """
- if isinstance(bounds[0], Wire):
- sorted_profiles = sort_wires_by_build_order(bounds)
- faces = [Face(p[0], p[1:]) for p in sorted_profiles]
- else:
- faces = bounds
-
- shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped
- for face in faces:
- feat = BRepFeat_MakeDPrism(
- shape,
- face.wrapped,
- basis.wrapped if basis else TopoDS_Face(),
- taper * DEG2RAD,
- additive,
- False,
- )
-
- if up_to_face is not None:
- feat.Perform(up_to_face.wrapped)
- elif thru_all or depth is None:
- feat.PerformThruAll()
- else:
- feat.Perform(depth)
-
- shape = feat.Shape()
-
- return self.__class__(shape)
-
-
TOPODS = TypeVar("TOPODS", bound=TopoDS_Shape)
@@ -4401,6 +2925,1448 @@ class GroupBy(Generic[T, K]):
return self.group(self.key_f(shape))
+class Mixin1D(Shape):
+ """Methods to add to the Edge and Wire classes"""
+
+ def __add__(self, other: Shape | Iterable[Shape]) -> Edge | Wire | ShapeList[Edge]:
+ """fuse shape to wire/edge operator +"""
+
+ # Convert `other` to list of base topods objects and filter out None values
+ summands = [
+ shape
+ for o in (other if isinstance(other, (list, tuple)) else [other])
+ if o is not None
+ for shape in get_top_level_topods_shapes(o.wrapped)
+ ]
+ # If there is nothing to add return the original object
+ if not summands:
+ return self
+
+ if not all(topods_dim(summand) == 1 for summand in summands):
+ raise ValueError("Only shapes with the same dimension can be added")
+
+ # Convert back to Edge/Wire objects now that it's safe to do so
+ summands = [Mixin1D.cast(s) for s in summands]
+ summand_edges = [e for summand in summands for e in summand.edges()]
+
+ if self.wrapped is None: # an empty object
+ if len(summands) == 1:
+ sum_shape = summands[0]
+ else:
+ try:
+ sum_shape = Wire(summand_edges)
+ except Exception:
+ sum_shape = summands[0].fuse(*summands[1:])
+ if type(self).order == 4:
+ sum_shape = type(self)(sum_shape)
+ else:
+ try:
+ sum_shape = Wire(self.edges() + ShapeList(summand_edges))
+ except Exception:
+ sum_shape = self.fuse(*summands)
+
+ if SkipClean.clean and not isinstance(sum_shape, list):
+ sum_shape = sum_shape.clean()
+
+ # If there is only one Edge, return that
+ sum_shape = sum_shape.edge() if len(sum_shape.edges()) == 1 else sum_shape
+
+ return sum_shape
+
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # Extend the lookup table with additional entries
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
+ @overload
+ def split(
+ self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM]
+ ) -> Self | list[Self] | None:
+ """split and keep inside or outside"""
+
+ @overload
+ def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[
+ Self | list[Self] | None,
+ Self | list[Self] | None,
+ ]:
+ """split and keep inside and outside"""
+
+ @overload
+ def split(self, tool: TrimmingTool) -> Self | list[Self] | None:
+ """split and keep inside (default)"""
+
+ def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP):
+ """split
+
+ Split this shape by the provided plane or face.
+
+ Args:
+ surface (Union[Plane,Face]): surface to segment shape
+ keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP.
+
+ Returns:
+ Shape: result of split
+ Returns:
+ Self | list[Self] | None,
+ Tuple[Self | list[Self] | None]: The result of the split operation.
+
+ - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None`
+ if no top is found.
+ - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None`
+ if no bottom is found.
+ - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is
+ either a `Self` or `list[Self]`, or `None` if no corresponding part is found.
+ """
+ shape_list = TopTools_ListOfShape()
+ shape_list.Append(self.wrapped)
+
+ # Define the splitting tool
+ trim_tool = (
+ BRepBuilderAPI_MakeFace(tool.wrapped).Face() # Plane to Face
+ if isinstance(tool, Plane)
+ else tool.wrapped
+ )
+ tool_list = TopTools_ListOfShape()
+ tool_list.Append(trim_tool)
+
+ # Create the splitter algorithm
+ splitter = BRepAlgoAPI_Splitter()
+
+ # Set the shape to be split and the splitting tool (plane face)
+ splitter.SetArguments(shape_list)
+ splitter.SetTools(tool_list)
+
+ # Perform the splitting operation
+ splitter.Build()
+
+ split_result = downcast(splitter.Shape())
+ # Remove unnecessary TopoDS_Compound around single shape
+ if isinstance(split_result, TopoDS_Compound):
+ split_result = unwrap_topods_compound(split_result, True)
+
+ if not isinstance(tool, Plane):
+ # Create solids from the surfaces for sorting by thickening
+ offset_builder = BRepOffset_MakeOffset()
+ offset_builder.Initialize(
+ tool.wrapped,
+ Offset=0.1,
+ Tol=1.0e-5,
+ Intersection=True,
+ Join=GeomAbs_Intersection,
+ Thickening=True,
+ )
+ offset_builder.MakeOffsetShape()
+ try:
+ tool_thickened = downcast(offset_builder.Shape())
+ except StdFail_NotDone as err:
+ raise RuntimeError("Error determining top/bottom") from err
+
+ tops: list[Shape] = []
+ bottoms: list[Shape] = []
+ properties = GProp_GProps()
+ for part in get_top_level_topods_shapes(split_result):
+ sub_shape = self.__class__.cast(part)
+ if isinstance(tool, Plane):
+ is_up = tool.to_local_coords(sub_shape).center().Z >= 0
+ else:
+ # Intersect self and the thickened tool
+ is_up_obj = _topods_bool_op(
+ (part,), (tool_thickened,), BRepAlgoAPI_Common()
+ )
+ # Calculate volume of intersection
+ BRepGProp.VolumeProperties_s(is_up_obj, properties)
+ is_up = properties.Mass() >= TOLERANCE
+ (tops if is_up else bottoms).append(sub_shape)
+
+ top = None if not tops else tops[0] if len(tops) == 1 else tops
+ bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms
+
+ if keep == Keep.BOTH:
+ return (top, bottom)
+ if keep == Keep.TOP:
+ return top
+ if keep == Keep.BOTTOM:
+ return bottom
+ return None
+
+ def vertices(self) -> ShapeList[Vertex]:
+ """vertices - all the vertices in this Shape"""
+ return Shape.get_shape_list(self, "Vertex")
+
+ def vertex(self) -> Vertex:
+ """Return the Vertex"""
+ return Shape.get_single_shape(self, "Vertex")
+
+ def edges(self) -> ShapeList[Edge]:
+ """edges - all the edges in this Shape"""
+ edge_list = Shape.get_shape_list(self, "Edge")
+ return edge_list.filter_by(
+ lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
+ )
+
+ def edge(self) -> Edge:
+ """Return the Edge"""
+ return Shape.get_single_shape(self, "Edge")
+
+ def wires(self) -> ShapeList[Wire]:
+ """wires - all the wires in this Shape"""
+ return Shape.get_shape_list(self, "Wire")
+
+ def wire(self) -> Wire:
+ """Return the Wire"""
+ return Shape.get_single_shape(self, "Wire")
+
+ def start_point(self) -> Vector:
+ """The start point of this edge
+
+ Note that circles may have identical start and end points.
+ """
+ curve = self.geom_adaptor()
+ umin = curve.FirstParameter()
+
+ return Vector(curve.Value(umin))
+
+ def end_point(self) -> Vector:
+ """The end point of this edge.
+
+ Note that circles may have identical start and end points.
+ """
+ curve = self.geom_adaptor()
+ umax = curve.LastParameter()
+
+ return Vector(curve.Value(umax))
+
+ def param_at(self, distance: float) -> float:
+ """Parameter along a curve
+
+ Compute parameter value at the specified normalized distance.
+
+ Args:
+ d (float): normalized distance (0.0 >= d >= 1.0)
+
+ Returns:
+ float: parameter value
+ """
+ curve = self.geom_adaptor()
+
+ length = GCPnts_AbscissaPoint.Length_s(curve)
+ return GCPnts_AbscissaPoint(
+ curve, length * distance, curve.FirstParameter()
+ ).Parameter()
+
+ def tangent_at(
+ self,
+ position: Union[float, VectorLike] = 0.5,
+ position_mode: PositionMode = PositionMode.PARAMETER,
+ ) -> Vector:
+ """tangent_at
+
+ Find the tangent at a given position on the 1D shape where the position
+ is either a float (or int) parameter or a point that lies on the shape.
+
+ Args:
+ position (Union[float, VectorLike]): distance, parameter value, or
+ point on shape. Defaults to 0.5.
+ position_mode (PositionMode, optional): position calculation mode.
+ Defaults to PositionMode.PARAMETER.
+
+ Raises:
+ ValueError: invalid position
+
+ Returns:
+ Vector: tangent value
+ """
+ if isinstance(position, (float, int)):
+ curve = self.geom_adaptor()
+ if position_mode == PositionMode.PARAMETER:
+ parameter = self.param_at(position)
+ else:
+ parameter = self.param_at(position / self.length)
+ else:
+ try:
+ pnt = Vector(position)
+ except Exception as exc:
+ raise ValueError("position must be a float or a point") from exc
+ # GeomAPI_ProjectPointOnCurve only works with Edges so find
+ # the closest Edge if the shape has multiple Edges.
+ my_edges: list[Edge] = self.edges()
+ distances = [(e.distance_to(pnt), i) for i, e in enumerate(my_edges)]
+ sorted_distances = sorted(distances, key=lambda x: x[0])
+ closest_edge = my_edges[sorted_distances[0][1]]
+ # Get the extreme of the parameter values for this Edge
+ first: float = closest_edge.param_at(0)
+ last: float = closest_edge.param_at(1)
+ # Extract the Geom_Curve from the Shape
+ curve = BRep_Tool.Curve_s(closest_edge.wrapped, first, last)
+ projector = GeomAPI_ProjectPointOnCurve(pnt.to_pnt(), curve)
+ parameter = projector.LowerDistanceParameter()
+
+ tmp = gp_Pnt()
+ res = gp_Vec()
+ curve.D1(parameter, tmp, res)
+
+ return Vector(gp_Dir(res))
+
+ def tangent_angle_at(
+ self,
+ location_param: float = 0.5,
+ position_mode: PositionMode = PositionMode.PARAMETER,
+ plane: Plane = Plane.XY,
+ ) -> float:
+ """tangent_angle_at
+
+ Compute the tangent angle at the specified location
+
+ Args:
+ location_param (float, optional): distance or parameter value. Defaults to 0.5.
+ position_mode (PositionMode, optional): position calculation mode.
+ Defaults to PositionMode.PARAMETER.
+ plane (Plane, optional): plane line was constructed on. Defaults to Plane.XY.
+
+ Returns:
+ float: angle in degrees between 0 and 360
+ """
+ tan_vector = self.tangent_at(location_param, position_mode)
+ angle = (plane.x_dir.get_signed_angle(tan_vector, plane.z_dir) + 360) % 360.0
+ return angle
+
+ def normal(self) -> Vector:
+ """Calculate the normal Vector. Only possible for planar curves.
+
+ :return: normal vector
+
+ Args:
+
+ Returns:
+
+ """
+ curve = self.geom_adaptor()
+ gtype = self.geom_type
+
+ if gtype == GeomType.CIRCLE:
+ circ = curve.Circle()
+ return_value = Vector(circ.Axis().Direction())
+ elif gtype == GeomType.ELLIPSE:
+ ell = curve.Ellipse()
+ return_value = Vector(ell.Axis().Direction())
+ else:
+ find_surface = BRepLib_FindSurface(self.wrapped, OnlyPlane=True)
+ surf = find_surface.Surface()
+
+ if isinstance(surf, Geom_Plane):
+ pln = surf.Pln()
+ return_value = Vector(pln.Axis().Direction())
+ else:
+ raise ValueError("Normal not defined")
+
+ return return_value
+
+ def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector:
+ """Center of object
+
+ Return the center based on center_of
+
+ Args:
+ center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY.
+
+ Returns:
+ Vector: center
+ """
+ if center_of == CenterOf.GEOMETRY:
+ middle = self.position_at(0.5)
+ elif center_of == CenterOf.MASS:
+ properties = GProp_GProps()
+ BRepGProp.LinearProperties_s(self.wrapped, properties)
+ middle = Vector(properties.CentreOfMass())
+ elif center_of == CenterOf.BOUNDING_BOX:
+ middle = self.bounding_box().center()
+ return middle
+
+ def common_plane(self, *lines: Edge | Wire | None) -> Union[None, Plane]:
+ """common_plane
+
+ Find the plane containing all the edges/wires (including self). If there
+ is no common plane return None. If the edges are coaxial, select one
+ of the infinite number of valid planes.
+
+ Args:
+ lines (sequence of Union[Edge,Wire]): edges in common with self
+
+ Returns:
+ Union[None, Plane]: Either the common plane or None
+ """
+ # pylint: disable=too-many-locals
+ # Note: BRepLib_FindSurface is not helpful as it requires the
+ # Edges to form a surface perimeter.
+ points: list[Vector] = []
+ all_lines: list[Edge | Wire] = [
+ line for line in [self, *lines] if line is not None
+ ]
+ if any(not isinstance(line, (Edge, Wire)) for line in all_lines):
+ raise ValueError("Only Edges or Wires are valid")
+
+ result = None
+ # Are they all co-axial - if so, select one of the infinite planes
+ all_edges: list[Edge] = [e for l in all_lines for e in l.edges()]
+ if all(e.geom_type == GeomType.LINE for e in all_edges):
+ as_axis = [Axis(e @ 0, e % 0) for e in all_edges]
+ if all(a0.is_coaxial(a1) for a0, a1 in combinations(as_axis, 2)):
+ origin = as_axis[0].position
+ x_dir = as_axis[0].direction
+ z_dir = as_axis[0].to_plane().x_dir
+ c_plane = Plane(origin, z_dir=z_dir)
+ result = c_plane.shift_origin((0, 0))
+
+ if result is None: # not coaxial
+ # Shorten any infinite lines (from converted Axis)
+ normal_lines = list(filter(lambda line: line.length <= 1e50, all_lines))
+ infinite_lines = filter(lambda line: line.length > 1e50, all_lines)
+ shortened_lines = [l.trim_to_length(0.5, 10) for l in infinite_lines]
+ all_lines = normal_lines + shortened_lines
+
+ for line in all_lines:
+ num_points = 2 if line.geom_type == GeomType.LINE else 8
+ points.extend(
+ [line.position_at(i / (num_points - 1)) for i in range(num_points)]
+ )
+ points = list(set(points)) # unique points
+ extreme_areas = {}
+ for subset in combinations(points, 3):
+ vector1 = subset[1] - subset[0]
+ vector2 = subset[2] - subset[0]
+ area = 0.5 * (vector1.cross(vector2).length)
+ extreme_areas[area] = subset
+ # The points that create the largest area make the most accurate plane
+ extremes = extreme_areas[sorted(list(extreme_areas.keys()))[-1]]
+
+ # Create a plane from these points
+ x_dir = (extremes[1] - extremes[0]).normalized()
+ z_dir = (extremes[2] - extremes[0]).cross(x_dir)
+ try:
+ c_plane = Plane(
+ origin=(sum(extremes, Vector(0, 0, 0)) / 3), z_dir=z_dir
+ )
+ c_plane = c_plane.shift_origin((0, 0))
+ except ValueError:
+ # There is no valid common plane
+ result = None
+ else:
+ # Are all of the points on the common plane
+ common = all(c_plane.contains(p) for p in points)
+ result = c_plane if common else None
+
+ return result
+
+ @property
+ def length(self) -> float:
+ """Edge or Wire length"""
+ return GCPnts_AbscissaPoint.Length_s(self.geom_adaptor())
+
+ @property
+ def radius(self) -> float:
+ """Calculate the radius.
+
+ Note that when applied to a Wire, the radius is simply the radius of the first edge.
+
+ Args:
+
+ Returns:
+ radius
+
+ Raises:
+ ValueError: if kernel can not reduce the shape to a circular edge
+
+ """
+ geom = self.geom_adaptor()
+ try:
+ circ = geom.Circle()
+ except (Standard_NoSuchObject, Standard_Failure) as err:
+ raise ValueError("Shape could not be reduced to a circle") from err
+ return circ.Radius()
+
+ @property
+ def is_forward(self) -> bool:
+ """Does the Edge/Wire loop forward or reverse"""
+ if self.wrapped is None:
+ raise ValueError("Can't determine direction of empty Edge or Wire")
+ return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD
+
+ @property
+ def is_closed(self) -> bool:
+ """Are the start and end points equal?"""
+ if self.wrapped is None:
+ raise ValueError("Can't determine if empty Edge or Wire is closed")
+ return BRep_Tool.IsClosed_s(self.wrapped)
+
+ @property
+ def volume(self) -> float:
+ """volume - the volume of this Edge or Wire, which is always zero"""
+ return 0.0
+
+ def position_at(
+ self, distance: float, position_mode: PositionMode = PositionMode.PARAMETER
+ ) -> Vector:
+ """Position At
+
+ Generate a position along the underlying curve.
+
+ Args:
+ distance (float): distance or parameter value
+ position_mode (PositionMode, optional): position calculation mode. Defaults to
+ PositionMode.PARAMETER.
+
+ Returns:
+ Vector: position on the underlying curve
+ """
+ curve = self.geom_adaptor()
+
+ if position_mode == PositionMode.PARAMETER:
+ param = self.param_at(distance)
+ else:
+ param = self.param_at(distance / self.length)
+
+ return Vector(curve.Value(param))
+
+ def positions(
+ self,
+ distances: Iterable[float],
+ position_mode: PositionMode = PositionMode.PARAMETER,
+ ) -> list[Vector]:
+ """Positions along curve
+
+ Generate positions along the underlying curve
+
+ Args:
+ distances (Iterable[float]): distance or parameter values
+ position_mode (PositionMode, optional): position calculation mode.
+ Defaults to PositionMode.PARAMETER.
+
+ Returns:
+ list[Vector]: positions along curve
+ """
+ return [self.position_at(d, position_mode) for d in distances]
+
+ def location_at(
+ self,
+ distance: float,
+ position_mode: PositionMode = PositionMode.PARAMETER,
+ frame_method: FrameMethod = FrameMethod.FRENET,
+ planar: bool = False,
+ ) -> Location:
+ """Locations along curve
+
+ Generate a location along the underlying curve.
+
+ Args:
+ distance (float): distance or parameter value
+ position_mode (PositionMode, optional): position calculation mode.
+ Defaults to PositionMode.PARAMETER.
+ frame_method (FrameMethod, optional): moving frame calculation method.
+ Defaults to FrameMethod.FRENET.
+ planar (bool, optional): planar mode. Defaults to False.
+
+ Returns:
+ Location: A Location object representing local coordinate system
+ at the specified distance.
+ """
+ curve = self.geom_adaptor()
+
+ if position_mode == PositionMode.PARAMETER:
+ param = self.param_at(distance)
+ else:
+ param = self.param_at(distance / self.length)
+
+ law: GeomFill_TrihedronLaw
+ if frame_method == FrameMethod.FRENET:
+ law = GeomFill_Frenet()
+ else:
+ law = GeomFill_CorrectedFrenet()
+
+ law.SetCurve(curve)
+
+ tangent, normal, binormal = gp_Vec(), gp_Vec(), gp_Vec()
+
+ law.D0(param, tangent, normal, binormal)
+ pnt = curve.Value(param)
+
+ transformation = gp_Trsf()
+ if planar:
+ transformation.SetTransformation(
+ gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3()
+ )
+ else:
+ transformation.SetTransformation(
+ gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3()
+ )
+
+ return Location(TopLoc_Location(transformation))
+
+ def locations(
+ self,
+ distances: Iterable[float],
+ position_mode: PositionMode = PositionMode.PARAMETER,
+ frame_method: FrameMethod = FrameMethod.FRENET,
+ planar: bool = False,
+ ) -> list[Location]:
+ """Locations along curve
+
+ Generate location along the curve
+
+ Args:
+ distances (Iterable[float]): distance or parameter values
+ position_mode (PositionMode, optional): position calculation mode.
+ Defaults to PositionMode.PARAMETER.
+ frame_method (FrameMethod, optional): moving frame calculation method.
+ Defaults to FrameMethod.FRENET.
+ planar (bool, optional): planar mode. Defaults to False.
+
+ Returns:
+ list[Location]: A list of Location objects representing local coordinate
+ systems at the specified distances.
+ """
+ return [
+ self.location_at(d, position_mode, frame_method, planar) for d in distances
+ ]
+
+ def __matmul__(self, position: float) -> Vector:
+ """Position on wire operator @"""
+ return self.position_at(position)
+
+ def __mod__(self, position: float) -> Vector:
+ """Tangent on wire operator %"""
+ return self.tangent_at(position)
+
+ def __xor__(self, position: float) -> Location:
+ """Location on wire operator ^"""
+ return self.location_at(position)
+
+ def offset_2d(
+ self,
+ distance: float,
+ kind: Kind = Kind.ARC,
+ side: Side = Side.BOTH,
+ closed: bool = True,
+ ) -> Union[Edge, Wire]:
+ """2d Offset
+
+ Offsets a planar edge/wire
+
+ Args:
+ distance (float): distance from edge/wire to offset
+ kind (Kind, optional): offset corner transition. Defaults to Kind.ARC.
+ side (Side, optional): side to place offset. Defaults to Side.BOTH.
+ closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT
+ offset. Defaults to True.
+ Raises:
+ RuntimeError: Multiple Wires generated
+ RuntimeError: Unexpected result type
+
+ Returns:
+ Wire: offset wire
+ """
+ # pylint: disable=too-many-branches, too-many-locals, too-many-statements
+ kind_dict = {
+ Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
+ Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
+ Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
+ }
+ line = self if isinstance(self, Wire) else Wire([self])
+
+ # Avoiding a bug when the wire contains a single Edge
+ if len(line.edges()) == 1:
+ edge = line.edges()[0]
+ edges = [edge.trim(0.0, 0.5), edge.trim(0.5, 1.0)]
+ topods_wire = Wire(edges).wrapped
+ else:
+ topods_wire = line.wrapped
+
+ offset_builder = BRepOffsetAPI_MakeOffset()
+ offset_builder.Init(kind_dict[kind])
+ # offset_builder.SetApprox(True)
+ offset_builder.AddWire(topods_wire)
+ offset_builder.Perform(distance)
+
+ obj = downcast(offset_builder.Shape())
+ if isinstance(obj, TopoDS_Compound):
+ obj = unwrap_topods_compound(obj, fully=True)
+ if isinstance(obj, TopoDS_Wire):
+ offset_wire = Wire(obj)
+ else: # Likely multiple Wires were generated
+ raise RuntimeError("Unexpected result type")
+
+ if side != Side.BOTH:
+ # Find and remove the end arcs
+ offset_edges = offset_wire.edges()
+ edges_to_keep: list[list[int]] = [[], [], []]
+ i = 0
+ for edge in offset_edges:
+ if edge.geom_type == GeomType.CIRCLE and (
+ edge.arc_center == line.position_at(0)
+ or edge.arc_center == line.position_at(1)
+ ):
+ i += 1
+ else:
+ edges_to_keep[i].append(edge)
+ edges_to_keep[0] += edges_to_keep[2]
+ wires = [Wire(edges) for edges in edges_to_keep[0:2]]
+ centers = [w.position_at(0.5) for w in wires]
+ angles = [
+ line.tangent_at(0).get_signed_angle(c - line.position_at(0))
+ for c in centers
+ ]
+ if side == Side.LEFT:
+ offset_wire = wires[int(angles[0] > angles[1])]
+ else:
+ offset_wire = wires[int(angles[0] <= angles[1])]
+
+ if closed:
+ self0 = line.position_at(0)
+ self1 = line.position_at(1)
+ end0 = offset_wire.position_at(0)
+ end1 = offset_wire.position_at(1)
+ if (self0 - end0).length - abs(distance) <= TOLERANCE:
+ edge0 = Edge.make_line(self0, end0)
+ edge1 = Edge.make_line(self1, end1)
+ else:
+ edge0 = Edge.make_line(self0, end1)
+ edge1 = Edge.make_line(self1, end0)
+ offset_wire = Wire(
+ line.edges() + offset_wire.edges() + ShapeList([edge0, edge1])
+ )
+
+ offset_edges = offset_wire.edges()
+ return offset_edges[0] if len(offset_edges) == 1 else offset_wire
+
+ def perpendicular_line(
+ self, length: float, u_value: float, plane: Plane = Plane.XY
+ ) -> Edge:
+ """perpendicular_line
+
+ Create a line on the given plane perpendicular to and centered on beginning of self
+
+ Args:
+ length (float): line length
+ u_value (float): position along line between 0.0 and 1.0
+ plane (Plane, optional): plane containing perpendicular line. Defaults to Plane.XY.
+
+ Returns:
+ Edge: perpendicular line
+ """
+ start = self.position_at(u_value)
+ local_plane = Plane(
+ origin=start, x_dir=self.tangent_at(u_value), z_dir=plane.z_dir
+ )
+ line = Edge.make_line(
+ start + local_plane.y_dir * length / 2,
+ start - local_plane.y_dir * length / 2,
+ )
+ return line
+
+ def project(
+ self, face: Face, direction: VectorLike, closest: bool = True
+ ) -> Edge | Wire | ShapeList[Edge | Wire]:
+ """Project onto a face along the specified direction
+
+ Args:
+ face: Face:
+ direction: VectorLike:
+ closest: bool: (Default value = True)
+
+ Returns:
+
+ """
+ if self.wrapped is None:
+ raise ValueError("Can't project an empty Edge or Wire")
+
+ bldr = BRepProj_Projection(
+ self.wrapped, face.wrapped, Vector(direction).to_dir()
+ )
+ shapes: TopoDS_Compound = bldr.Shape()
+
+ # select the closest projection if requested
+ return_value: Edge | Wire | ShapeList[Edge | Wire]
+
+ if closest:
+ dist_calc = BRepExtrema_DistShapeShape()
+ dist_calc.LoadS1(self.wrapped)
+
+ min_dist = inf
+
+ # for shape in shapes:
+ for shape in get_top_level_topods_shapes(shapes):
+ dist_calc.LoadS2(shape)
+ dist_calc.Perform()
+ dist = dist_calc.Value()
+
+ if dist < min_dist:
+ min_dist = dist
+ return_value = Mixin1D.cast(shape)
+
+ else:
+ return_value = ShapeList(
+ Mixin1D.cast(shape) for shape in get_top_level_topods_shapes(shapes)
+ )
+
+ return return_value
+
+ def project_to_viewport(
+ self,
+ viewport_origin: VectorLike,
+ viewport_up: VectorLike = (0, 0, 1),
+ look_at: VectorLike | None = None,
+ ) -> tuple[ShapeList[Edge], ShapeList[Edge]]:
+ """project_to_viewport
+
+ Project a shape onto a viewport returning visible and hidden Edges.
+
+ Args:
+ viewport_origin (VectorLike): location of viewport
+ viewport_up (VectorLike, optional): direction of the viewport y axis.
+ Defaults to (0, 0, 1).
+ look_at (VectorLike, optional): point to look at.
+ Defaults to None (center of shape).
+
+ Returns:
+ tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges
+ """
+
+ def extract_edges(compound):
+ edges = [] # List to store the extracted edges
+
+ # Create a TopExp_Explorer to traverse the sub-shapes of the compound
+ explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE)
+
+ # Loop through the sub-shapes and extract edges
+ while explorer.More():
+ edge = downcast(explorer.Current())
+ edges.append(edge)
+ explorer.Next()
+
+ return edges
+
+ # Setup the projector
+ hidden_line_removal = HLRBRep_Algo()
+ hidden_line_removal.Add(self.wrapped)
+
+ viewport_origin = Vector(viewport_origin)
+ look_at = Vector(look_at) if look_at else self.center()
+ projection_dir: Vector = (viewport_origin - look_at).normalized()
+ viewport_up = Vector(viewport_up).normalized()
+ camera_coordinate_system = gp_Ax2()
+ camera_coordinate_system.SetAxis(
+ gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir())
+ )
+ camera_coordinate_system.SetYDirection(viewport_up.to_dir())
+ projector = HLRAlgo_Projector(camera_coordinate_system)
+
+ hidden_line_removal.Projector(projector)
+ hidden_line_removal.Update()
+ hidden_line_removal.Hide()
+
+ hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal)
+
+ # Create the visible edges
+ visible_edges = []
+ for edges in [
+ hlr_shapes.VCompound(),
+ hlr_shapes.Rg1LineVCompound(),
+ hlr_shapes.OutLineVCompound(),
+ ]:
+ if not edges.IsNull():
+ visible_edges.extend(extract_edges(downcast(edges)))
+
+ # Create the hidden edges
+ hidden_edges = []
+ for edges in [
+ hlr_shapes.HCompound(),
+ hlr_shapes.OutLineHCompound(),
+ hlr_shapes.Rg1LineHCompound(),
+ ]:
+ if not edges.IsNull():
+ hidden_edges.extend(extract_edges(downcast(edges)))
+
+ # Fix the underlying geometry - otherwise we will get segfaults
+ for edge in visible_edges:
+ BRepLib.BuildCurves3d_s(edge, TOLERANCE)
+ for edge in hidden_edges:
+ BRepLib.BuildCurves3d_s(edge, TOLERANCE)
+
+ # convert to native shape objects
+ visible_edges = ShapeList(Edge(e) for e in visible_edges)
+ hidden_edges = ShapeList(Edge(e) for e in hidden_edges)
+
+ return (visible_edges, hidden_edges)
+
+
+class Mixin2D(Shape):
+ """Additional methods to add to Face and Shell class"""
+
+ project_to_viewport = Mixin1D.project_to_viewport
+ split = Mixin1D.split
+
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire | Face | Shell:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # define the shape lookup table for casting
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ ta.TopAbs_FACE: Face,
+ ta.TopAbs_SHELL: Shell,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
+ vertices = Mixin1D.vertices
+ vertex = Mixin1D.vertex
+ edges = Mixin1D.edges
+ edge = Mixin1D.edge
+ wires = Mixin1D.wires
+
+ def faces(self) -> ShapeList[Face]:
+ """faces - all the faces in this Shape"""
+ return Shape.get_shape_list(self, "Face")
+
+ def face(self) -> Face:
+ """Return the Face"""
+ return Shape.get_single_shape(self, "Face")
+
+ def shells(self) -> ShapeList[Shell]:
+ """shells - all the shells in this Shape"""
+ return Shape.get_shape_list(self, "Shell")
+
+ def shell(self) -> Shell:
+ """Return the Shell"""
+ return Shape.get_single_shape(self, "Shell")
+
+ def __neg__(self) -> Self:
+ """Reverse normal operator -"""
+ if self.wrapped is None:
+ raise ValueError("Invalid Shape")
+ new_surface = copy.deepcopy(self)
+ new_surface.wrapped = downcast(self.wrapped.Complemented())
+
+ return new_surface
+
+ def offset(self, amount: float) -> Self:
+ """Return a copy of self moved along the normal by amount"""
+ return copy.deepcopy(self).moved(Location(self.normal_at() * amount))
+
+ def find_intersection_points(
+ self, other: Axis, tolerance: float = TOLERANCE
+ ) -> list[tuple[Vector, Vector]]:
+ """Find point and normal at intersection
+
+ Return both the point(s) and normal(s) of the intersection of the axis and the shape
+
+ Args:
+ axis (Axis): axis defining the intersection line
+
+ Returns:
+ list[tuple[Vector, Vector]]: Point and normal of intersection
+ """
+ if self.wrapped is None:
+ return []
+
+ intersection_line = gce_MakeLin(other.wrapped).Value()
+ intersect_maker = BRepIntCurveSurface_Inter()
+ intersect_maker.Init(self.wrapped, intersection_line, tolerance)
+
+ intersections = []
+ while intersect_maker.More():
+ inter_pt = intersect_maker.Pnt()
+ # Calculate distance along axis
+ distance = other.to_plane().to_local_coords(Vector(inter_pt)).Z
+ intersections.append(
+ (
+ intersect_maker.Face(), # TopoDS_Face
+ Vector(inter_pt),
+ distance,
+ )
+ )
+ intersect_maker.Next()
+
+ intersections.sort(key=lambda x: x[2])
+ intersecting_faces = [i[0] for i in intersections]
+ intersecting_points = [i[1] for i in intersections]
+ intersecting_normals = [
+ _topods_face_normal_at(f, intersecting_points[i].to_pnt())
+ for i, f in enumerate(intersecting_faces)
+ ]
+ result = []
+ for pnt, normal in zip(intersecting_points, intersecting_normals):
+ result.append((pnt, normal))
+
+ return result
+
+
+class Mixin3D(Shape):
+ """Additional methods to add to 3D Shape classes"""
+
+ project_to_viewport = Mixin1D.project_to_viewport
+ split = Mixin1D.split
+ find_intersection_points = Mixin2D.find_intersection_points
+
+ @classmethod
+ def cast(cls, obj: TopoDS_Shape) -> Self:
+ "Returns the right type of wrapper, given a OCCT object"
+
+ # define the shape lookup table for casting
+ constructor_lut = {
+ ta.TopAbs_VERTEX: Vertex,
+ ta.TopAbs_EDGE: Edge,
+ ta.TopAbs_WIRE: Wire,
+ ta.TopAbs_FACE: Face,
+ ta.TopAbs_SHELL: Shell,
+ ta.TopAbs_SOLID: Solid,
+ }
+
+ shape_type = shapetype(obj)
+ # NB downcast is needed to handle TopoDS_Shape types
+ return constructor_lut[shape_type](downcast(obj))
+
+ vertices = Mixin1D.vertices
+ vertex = Mixin1D.vertex
+ edges = Mixin1D.edges
+ edge = Mixin1D.edge
+ wires = Mixin1D.wires
+ wire = Mixin1D.wire
+ faces = Mixin2D.faces
+ face = Mixin2D.face
+ shells = Mixin2D.shells
+ shell = Mixin2D.shell
+
+ def solids(self) -> ShapeList[Solid]:
+ """solids - all the solids in this Shape"""
+ return Shape.get_shape_list(self, "Solid")
+
+ def solid(self) -> Solid:
+ """Return the Solid"""
+ return Shape.get_single_shape(self, "Solid")
+
+ def fillet(self, radius: float, edge_list: Iterable[Edge]) -> Self:
+ """Fillet
+
+ Fillets the specified edges of this solid.
+
+ Args:
+ radius (float): float > 0, the radius of the fillet
+ edge_list (Iterable[Edge]): a list of Edge objects, which must belong to this solid
+
+ Returns:
+ Any: Filleted solid
+ """
+ native_edges = [e.wrapped for e in edge_list]
+
+ fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped)
+
+ for native_edge in native_edges:
+ fillet_builder.Add(radius, native_edge)
+
+ try:
+ new_shape = self.__class__(fillet_builder.Shape())
+ if not new_shape.is_valid():
+ raise Standard_Failure
+ except (StdFail_NotDone, Standard_Failure) as err:
+ raise ValueError(
+ f"Failed creating a fillet with radius of {radius}, try a smaller value"
+ f" or use max_fillet() to find the largest valid fillet radius"
+ ) from err
+
+ return new_shape
+
+ def max_fillet(
+ self,
+ edge_list: Iterable[Edge],
+ tolerance=0.1,
+ max_iterations: int = 10,
+ ) -> float:
+ """Find Maximum Fillet Size
+
+ Find the largest fillet radius for the given Shape and edges with a
+ recursive binary search.
+
+ Example:
+
+ max_fillet_radius = my_shape.max_fillet(shape_edges)
+ max_fillet_radius = my_shape.max_fillet(shape_edges, tolerance=0.5, max_iterations=8)
+
+
+ Args:
+ edge_list (Iterable[Edge]): a sequence of Edge objects, which must belong to this solid
+ tolerance (float, optional): maximum error from actual value. Defaults to 0.1.
+ max_iterations (int, optional): maximum number of recursive iterations. Defaults to 10.
+
+ Raises:
+ RuntimeError: failed to find the max value
+ ValueError: the provided Shape is invalid
+
+ Returns:
+ float: maximum fillet radius
+ """
+
+ def __max_fillet(window_min: float, window_max: float, current_iteration: int):
+ window_mid = (window_min + window_max) / 2
+
+ if current_iteration == max_iterations:
+ raise RuntimeError(
+ f"Failed to find the max value within {tolerance} in {max_iterations}"
+ )
+
+ fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped)
+
+ for native_edge in native_edges:
+ fillet_builder.Add(window_mid, native_edge)
+
+ # Do these numbers work? - if not try with the smaller window
+ try:
+ new_shape = self.__class__(fillet_builder.Shape())
+ if not new_shape.is_valid():
+ raise fillet_exception
+ except fillet_exception:
+ return __max_fillet(window_min, window_mid, current_iteration + 1)
+
+ # These numbers work, are they close enough? - if not try larger window
+ if window_mid - window_min <= tolerance:
+ return_value = window_mid
+ else:
+ return_value = __max_fillet(
+ window_mid, window_max, current_iteration + 1
+ )
+ return return_value
+
+ if not self.is_valid():
+ raise ValueError("Invalid Shape")
+
+ native_edges = [e.wrapped for e in edge_list]
+
+ # Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform
+ # specific exceptions are required.
+ if platform.system() == "Darwin":
+ fillet_exception = Standard_Failure
+ else:
+ fillet_exception = StdFail_NotDone
+
+ max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0)
+
+ return max_radius
+
+ def chamfer(
+ self,
+ length: float,
+ length2: Optional[float],
+ edge_list: Iterable[Edge],
+ face: Face = None,
+ ) -> Self:
+ """Chamfer
+
+ Chamfers the specified edges of this solid.
+
+ Args:
+ length (float): length > 0, the length (length) of the chamfer
+ length2 (Optional[float]): length2 > 0, optional parameter for asymmetrical
+ chamfer. Should be `None` if not required.
+ edge_list (Iterable[Edge]): a list of Edge objects, which must belong to
+ this solid
+ face (Face): identifies the side where length is measured. The edge(s) must be
+ part of the face
+
+ Returns:
+ Self: Chamfered solid
+ """
+ edge_list = list(edge_list)
+ if face:
+ if any((edge for edge in edge_list if edge not in face.edges())):
+ raise ValueError("Some edges are not part of the face")
+
+ native_edges = [e.wrapped for e in edge_list]
+
+ # make a edge --> faces mapping
+ edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
+ TopExp.MapShapesAndAncestors_s(
+ self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map
+ )
+
+ # note: we prefer 'length' word to 'radius' as opposed to FreeCAD's API
+ chamfer_builder = BRepFilletAPI_MakeChamfer(self.wrapped)
+
+ if length2:
+ distance1 = length
+ distance2 = length2
+ else:
+ distance1 = length
+ distance2 = length
+
+ for native_edge in native_edges:
+ if face:
+ topo_face = face.wrapped
+ else:
+ topo_face = edge_face_map.FindFromKey(native_edge).First()
+
+ chamfer_builder.Add(
+ distance1, distance2, native_edge, TopoDS.Face_s(topo_face)
+ ) # NB: edge_face_map return a generic TopoDS_Shape
+
+ try:
+ new_shape = self.__class__(chamfer_builder.Shape())
+ if not new_shape.is_valid():
+ raise Standard_Failure
+ except (StdFail_NotDone, Standard_Failure) as err:
+ raise ValueError(
+ "Failed creating a chamfer, try a smaller length value(s)"
+ ) from err
+
+ return new_shape
+
+ def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector:
+ """Return center of object
+
+ Find center of object
+
+ Args:
+ center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS.
+
+ Raises:
+ ValueError: Center of GEOMETRY is not supported for this object
+ NotImplementedError: Unable to calculate center of mass of this object
+
+ Returns:
+ Vector: center
+ """
+ if center_of == CenterOf.GEOMETRY:
+ raise ValueError("Center of GEOMETRY is not supported for this object")
+ if center_of == CenterOf.MASS:
+ properties = GProp_GProps()
+ calc_function = Shape.shape_properties_LUT[shapetype(self.wrapped)]
+ if calc_function:
+ calc_function(self.wrapped, properties)
+ middle = Vector(properties.CentreOfMass())
+ else:
+ raise NotImplementedError
+ elif center_of == CenterOf.BOUNDING_BOX:
+ middle = self.bounding_box().center()
+ return middle
+
+ def hollow(
+ self,
+ faces: Optional[Iterable[Face]],
+ thickness: float,
+ tolerance: float = 0.0001,
+ kind: Kind = Kind.ARC,
+ ) -> Solid:
+ """Hollow
+
+ Return the outer shelled solid of self.
+
+ Args:
+ faces (Optional[Iterable[Face]]): faces to be removed,
+ which must be part of the solid. Can be an empty list.
+ thickness (float): shell thickness - positive shells outwards, negative
+ shells inwards.
+ tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001.
+ kind (Kind, optional): intersection type. Defaults to Kind.ARC.
+
+ Raises:
+ ValueError: Kind.TANGENT not supported
+
+ Returns:
+ Solid: A hollow solid.
+ """
+ faces = list(faces) if faces else []
+ if kind == Kind.TANGENT:
+ raise ValueError("Kind.TANGENT not supported")
+
+ kind_dict = {
+ Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
+ Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
+ }
+
+ occ_faces_list = TopTools_ListOfShape()
+ for face in faces:
+ occ_faces_list.Append(face.wrapped)
+
+ shell_builder = BRepOffsetAPI_MakeThickSolid()
+ shell_builder.MakeThickSolidByJoin(
+ self.wrapped,
+ occ_faces_list,
+ thickness,
+ tolerance,
+ Intersection=True,
+ Join=kind_dict[kind],
+ )
+ shell_builder.Build()
+
+ if faces:
+ return_value = self.__class__(shell_builder.Shape())
+
+ else: # if no faces provided a watertight solid will be constructed
+ shell1 = self.__class__(shell_builder.Shape()).shells()[0].wrapped
+ shell2 = self.shells()[0].wrapped
+
+ # s1 can be outer or inner shell depending on the thickness sign
+ if thickness > 0:
+ sol = BRepBuilderAPI_MakeSolid(shell1, shell2)
+ else:
+ sol = BRepBuilderAPI_MakeSolid(shell2, shell1)
+
+ # fix needed for the orientations
+ return_value = self.__class__(sol.Shape()).fix()
+
+ return return_value
+
+ def offset_3d(
+ self,
+ openings: Optional[Iterable[Face]],
+ thickness: float,
+ tolerance: float = 0.0001,
+ kind: Kind = Kind.ARC,
+ ) -> Solid:
+ """Shell
+
+ Make an offset solid of self.
+
+ Args:
+ openings (Optional[Iterable[Face]]): faces to be removed,
+ which must be part of the solid. Can be an empty list.
+ thickness (float): offset amount - positive offset outwards, negative inwards
+ tolerance (float, optional): modelling tolerance of the method. Defaults to 0.0001.
+ kind (Kind, optional): intersection type. Defaults to Kind.ARC.
+
+ Raises:
+ ValueError: Kind.TANGENT not supported
+
+ Returns:
+ Solid: A shelled solid.
+ """
+ openings = list(openings) if openings else []
+ if kind == Kind.TANGENT:
+ raise ValueError("Kind.TANGENT not supported")
+
+ kind_dict = {
+ Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
+ Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
+ Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
+ }
+
+ occ_faces_list = TopTools_ListOfShape()
+ for face in openings:
+ occ_faces_list.Append(face.wrapped)
+
+ offset_builder = BRepOffsetAPI_MakeThickSolid()
+ offset_builder.MakeThickSolidByJoin(
+ self.wrapped,
+ occ_faces_list,
+ thickness,
+ tolerance,
+ Intersection=True,
+ RemoveIntEdges=True,
+ Join=kind_dict[kind],
+ )
+ offset_builder.Build()
+
+ try:
+ offset_occt_solid = offset_builder.Shape()
+ except (StdFail_NotDone, Standard_Failure) as err:
+ raise RuntimeError(
+ "offset Error, an alternative kind may resolve this error"
+ ) from err
+
+ offset_solid = self.__class__(offset_occt_solid)
+
+ # The Solid can be inverted, if so reverse
+ if offset_solid.volume < 0:
+ offset_solid.wrapped.Reverse()
+
+ return offset_solid
+
+ def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool:
+ """Returns whether or not the point is inside a solid or compound
+ object within the specified tolerance.
+
+ Args:
+ point: tuple or Vector representing 3D point to be tested
+ tolerance: tolerance for inside determination, default=1.0e-6
+ point: VectorLike:
+ tolerance: float: (Default value = 1.0e-6)
+
+ Returns:
+ bool indicating whether or not point is within solid
+
+ """
+ solid_classifier = BRepClass3d_SolidClassifier(self.wrapped)
+ solid_classifier.Perform(gp_Pnt(*Vector(point).to_tuple()), tolerance)
+
+ return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace()
+
+ def dprism(
+ self,
+ basis: Optional[Face],
+ bounds: list[Union[Face, Wire]],
+ depth: float = None,
+ taper: float = 0,
+ up_to_face: Face = None,
+ thru_all: bool = True,
+ additive: bool = True,
+ ) -> Solid:
+ """dprism
+
+ Make a prismatic feature (additive or subtractive)
+
+ Args:
+ basis (Optional[Face]): face to perform the operation on
+ bounds (list[Union[Face,Wire]]): list of profiles
+ depth (float, optional): depth of the cut or extrusion. Defaults to None.
+ taper (float, optional): in degrees. Defaults to 0.
+ up_to_face (Face, optional): a face to extrude until. Defaults to None.
+ thru_all (bool, optional): cut thru_all. Defaults to True.
+ additive (bool, optional): Defaults to True.
+
+ Returns:
+ Solid: prismatic feature
+ """
+ if isinstance(bounds[0], Wire):
+ sorted_profiles = sort_wires_by_build_order(bounds)
+ faces = [Face(p[0], p[1:]) for p in sorted_profiles]
+ else:
+ faces = bounds
+
+ shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped
+ for face in faces:
+ feat = BRepFeat_MakeDPrism(
+ shape,
+ face.wrapped,
+ basis.wrapped if basis else TopoDS_Face(),
+ taper * DEG2RAD,
+ additive,
+ False,
+ )
+
+ if up_to_face is not None:
+ feat.Perform(up_to_face.wrapped)
+ elif thru_all or depth is None:
+ feat.PerformThruAll()
+ else:
+ feat.Perform(depth)
+
+ shape = feat.Shape()
+
+ return self.__class__(shape)
+
+
class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""A Compound in build123d is a topological entity representing a collection of
geometric shapes grouped together within a single structure. It serves as a
@@ -6057,7 +6023,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
parent (Compound, optional): assembly parent. Defaults to None.
"""
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args: Any, **kwargs: Any):
outer_wire, inner_wires, obj, label, color, parent = (None,) * 6
if args:
@@ -6107,7 +6073,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
self.created_on: Plane | None = None
@property
- def length(self) -> float:
+ def length(self) -> None | float:
"""length of planar face"""
result = None
if self.is_planar:
@@ -6123,7 +6089,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return 0.0
@property
- def width(self) -> float:
+ def width(self) -> None | float:
"""width of planar face"""
result = None
if self.is_planar:
@@ -6134,17 +6100,17 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return result
@property
- def geometry(self) -> str:
+ def geometry(self) -> None | str:
"""geometry of planar face"""
result = None
if self.is_planar:
- flat_face = Plane(self).to_local_coords(self)
+ flat_face: Face = Plane(self).to_local_coords(self)
flat_face_edges = flat_face.edges()
if all(e.geom_type == GeomType.LINE for e in flat_face_edges):
flat_face_vertices = flat_face.vertices()
result = "POLYGON"
if len(flat_face_edges) == 4:
- edge_pairs = []
+ edge_pairs: list[list[Edge]] = []
for vertex in flat_face_vertices:
edge_pairs.append(
[e for e in flat_face_edges if vertex in e.vertices()]
@@ -6223,7 +6189,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
Returns:
Vector: surface normal direction
"""
- surface_point, u, v = (None,) * 3
+ surface_point, u, v = None, -1.0, -1.0
if args:
if isinstance(args[0], Sequence):
@@ -6242,9 +6208,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
surface_point = kwargs.get("surface_point", surface_point)
u = kwargs.get("u", u)
v = kwargs.get("v", v)
- if surface_point is None and u is None and v is None:
+ if surface_point is None and u < 0 and v < 0:
u, v = 0.5, 0.5
- elif surface_point is None and sum(i is None for i in [u, v]) == 1:
+ elif surface_point is None and sum(i == -1.0 for i in [u, v]) == 1:
raise ValueError("Both u & v values must be specified")
# get the geometry
@@ -6416,9 +6382,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
...
@classmethod
- def make_surface_from_curves(
- cls, curve1: Union[Edge, Wire], curve2: Union[Edge, Wire]
- ) -> Face:
+ def make_surface_from_curves(cls, *args, **kwargs) -> Face:
"""make_surface_from_curves
Create a ruled surface out of two edges or two wires. If wires are used then
@@ -6431,6 +6395,28 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
Returns:
Face: potentially non planar surface
"""
+ curve1, curve2 = None, None
+ if args:
+ if len(args) != 2 or type(args[0]) is not type(args[1]):
+ raise TypeError(
+ "Both curves must be of the same type (both Edge or both Wire)."
+ )
+ curve1, curve2 = args
+
+ curve1 = kwargs.pop("edge1", curve1)
+ curve2 = kwargs.pop("edge2", curve2)
+ curve1 = kwargs.pop("wire1", curve1)
+ curve2 = kwargs.pop("wire2", curve2)
+
+ # Handle unexpected kwargs
+ if kwargs:
+ raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
+
+ if not isinstance(curve1, (Edge, Wire)) or not isinstance(curve2, (Edge, Wire)):
+ raise TypeError(
+ "Both curves must be of the same type (both Edge or both Wire)."
+ )
+
if isinstance(curve1, Wire):
return_value = cls.cast(BRepFill.Shell_s(curve1.wrapped, curve2.wrapped))
else:
@@ -6438,7 +6424,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return return_value
@classmethod
- def sew_faces(cls, faces: Iterable[Face]) -> ShapeList[ShapeList[Face]]:
+ def sew_faces(cls, faces: Iterable[Face]) -> list[ShapeList[Face]]:
"""sew faces
Group contiguous faces and return them in a list of ShapeList
@@ -6450,12 +6436,12 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
RuntimeError: OCCT SewedShape generated unexpected output
Returns:
- ShapeList[ShapeList[Face]]: grouped contiguous faces
+ list[ShapeList[Face]]: grouped contiguous faces
"""
# Sew the faces
sewed_shape = _sew_topods_faces([f.wrapped for f in faces])
top_level_shapes = get_top_level_topods_shapes(sewed_shape)
- sewn_faces = ShapeList()
+ sewn_faces: list[ShapeList] = []
# For each of the top level shapes create a ShapeList of Face
for top_level_shape in top_level_shapes:
@@ -6608,14 +6594,14 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
raise ValueError("A weight must be provided for each control point")
points_ = TColgp_HArray2OfPnt(1, len(points), 1, len(points[0]))
- for i, row in enumerate(points):
- for j, point in enumerate(row):
+ for i, row_points in enumerate(points):
+ for j, point in enumerate(row_points):
points_.SetValue(i + 1, j + 1, Vector(point).to_pnt())
if weights:
weights_ = TColStd_HArray2OfReal(1, len(weights), 1, len(weights[0]))
- for i, row in enumerate(weights):
- for j, weight in enumerate(row):
+ for i, row_weights in enumerate(weights):
+ for j, weight in enumerate(row_weights):
weights_.SetValue(i + 1, j + 1, float(weight))
bezier = Geom_BezierSurface(points_, weights_)
else:
@@ -6654,9 +6640,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
exterior = list(exterior) if isinstance(exterior, Iterable) else exterior
# pylint: disable=too-many-branches
if surface_points:
- surface_points = [Vector(p) for p in surface_points]
+ surface_point_vectors = [Vector(p) for p in surface_points]
else:
- surface_points = None
+ surface_point_vectors = None
# First, create the non-planar surface
surface = BRepOffsetAPI_MakeFilling(
@@ -6684,7 +6670,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
elif isinstance(exterior, Iterable) and all(
isinstance(o, Edge) for o in exterior
):
- outside_edges = exterior
+ outside_edges = ShapeList(exterior)
else:
raise ValueError("exterior must be a Wire or list of Edges")
@@ -6703,8 +6689,8 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
raise RuntimeError(
"Error building non-planar face with provided exterior"
) from err
- if surface_points:
- for point in surface_points:
+ if surface_point_vectors:
+ for point in surface_point_vectors:
surface.Add(gp_Pnt(*point.to_tuple()))
try:
surface.Build()
@@ -6849,7 +6835,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
self.wrapped, Vector(direction) * max_dimension
)
- intersected_shapes = ShapeList()
+ intersected_shapes: ShapeList[Face | Shell] = ShapeList()
if isinstance(target_object, Vertex):
raise TypeError("projection to a vertex is not supported")
if isinstance(target_object, Face):
@@ -6869,9 +6855,9 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
intersected_shapes.append(Shell(topods_shell))
intersected_shapes = intersected_shapes.sort_by(Axis(self.center(), direction))
- intersected_shapes = [
+ intersected_shapes = ShapeList(
s.face() if len(s.faces()) == 1 else s for s in intersected_shapes
- ]
+ )
return intersected_shapes
def make_holes(self, interior_wires: list[Wire]) -> Face:
@@ -6989,8 +6975,8 @@ class Shell(Mixin2D, Shape[TopoDS_Shell]):
parent (Compound, optional): assembly parent. Defaults to None.
"""
obj = list(obj) if isinstance(obj, Iterable) else obj
- if isinstance(obj, Iterable) and len(obj) == 1:
- obj = obj[0]
+ if isinstance(obj, Iterable) and len(obj_list := list(obj)) == 1:
+ obj = obj_list[0]
if isinstance(obj, Face):
builder = BRepBuilderAPI_MakeShell(
From ea3e8b3edc3d7ab6c5ec76f0f96eba1fc0e8b130 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 30 Dec 2024 14:54:50 -0500
Subject: [PATCH 052/518] Fixed three_d.py typing problems
---
src/build123d/topology.py | 65 +++++++++++++++++++++++----------------
1 file changed, 39 insertions(+), 26 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index f5cc68b..beb83ba 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -4070,7 +4070,7 @@ class Mixin3D(Shape):
length: float,
length2: Optional[float],
edge_list: Iterable[Edge],
- face: Face = None,
+ face: Face | None = None,
) -> Self:
"""Chamfer
@@ -4082,8 +4082,8 @@ class Mixin3D(Shape):
chamfer. Should be `None` if not required.
edge_list (Iterable[Edge]): a list of Edge objects, which must belong to
this solid
- face (Face): identifies the side where length is measured. The edge(s) must be
- part of the face
+ face (Face, optional): identifies the side where length is measured. The edge(s)
+ must be part of the face
Returns:
Self: Chamfered solid
@@ -4286,6 +4286,7 @@ class Mixin3D(Shape):
) from err
offset_solid = self.__class__(offset_occt_solid)
+ assert offset_solid.wrapped is not None
# The Solid can be inverted, if so reverse
if offset_solid.volume < 0:
@@ -4316,9 +4317,9 @@ class Mixin3D(Shape):
self,
basis: Optional[Face],
bounds: list[Union[Face, Wire]],
- depth: float = None,
+ depth: float | None = None,
taper: float = 0,
- up_to_face: Face = None,
+ up_to_face: Face | None = None,
thru_all: bool = True,
additive: bool = True,
) -> Solid:
@@ -7442,15 +7443,17 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]):
flip = -1 if i > 0 and flip_inner else 1
local: Wire = Plane(profile).to_local_coords(wire)
local_taper = local.offset_2d(flip * offset_amt, kind=Kind.INTERSECTION)
- taper = Plane(profile).from_local_coords(local_taper)
- taper.move(Location(direction))
- taper_wires.append(taper)
+ taper_wire: Wire = Plane(profile).from_local_coords(local_taper)
+ taper_wire.move(Location(direction))
+ taper_wires.append(taper_wire)
solids = [
Solid.make_loft([p, t]) for p, t in zip(profile_wires, taper_wires)
]
if len(solids) > 1:
- new_solid = solids[0].cut(*solids[1:])
+ complex_solid = solids[0].cut(*solids[1:])
+ assert isinstance(complex_solid, Solid) # Can't be a list
+ new_solid = complex_solid
else:
new_solid = solids[0]
@@ -7605,39 +7608,47 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]):
clipping_objects = [o for o in clipping_objects if o.volume > 1e-9]
if until == Until.NEXT:
- extrusion = extrusion.cut(target_object)
+ trimmed_extrusion = extrusion.cut(target_object)
+ if isinstance(trimmed_extrusion, ShapeList):
+ closest_extrusion = trimmed_extrusion.sort_by(direction_axis)[0]
+ else:
+ closest_extrusion = trimmed_extrusion
for clipping_object in clipping_objects:
# It's possible for clipping faces to self intersect when they are extruded
# thus they could be non manifold which results failed boolean operations
# - so skip these objects
try:
- extrusion = (
- extrusion.cut(clipping_object)
- .solids()
- .sort_by(direction_axis)[0]
- )
+ extrusion_shapes = closest_extrusion.cut(clipping_object)
except Exception:
warnings.warn(
"clipping error - extrusion may be incorrect",
stacklevel=2,
)
else:
- extrusion_parts = [extrusion.intersect(target_object)]
+ base_part = extrusion.intersect(target_object)
+ if isinstance(base_part, ShapeList):
+ extrusion_parts = base_part
+ elif base_part is None:
+ extrusion_parts = ShapeList()
+ else:
+ extrusion_parts = ShapeList([base_part])
for clipping_object in clipping_objects:
try:
- extrusion_parts.append(
- extrusion.intersect(clipping_object)
- .solids()
- .sort_by(direction_axis)[0]
- )
+ clipped_extrusion = extrusion.intersect(clipping_object)
+ if clipped_extrusion is not None:
+ extrusion_parts.append(
+ clipped_extrusion.solids().sort_by(direction_axis)[0]
+ )
except Exception:
warnings.warn(
"clipping error - extrusion may be incorrect",
stacklevel=2,
)
- extrusion = Solid.fuse(*extrusion_parts)
+ extrusion_shapes = Solid.fuse(*extrusion_parts)
- return extrusion
+ result = extrusion_shapes.solids().sort_by(direction_axis)[0]
+
+ return result
@classmethod
def revolve(
@@ -7754,12 +7765,14 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]):
shapes.append(Mixin3D.cast(builder.Shape()))
- return_value, inner_shapes = shapes[0], shapes[1:]
+ outer_shape, inner_shapes = shapes[0], shapes[1:]
if inner_shapes:
- return_value = return_value.cut(*inner_shapes)
+ hollow_outer_shape = outer_shape.cut(*inner_shapes)
+ assert isinstance(hollow_outer_shape, Solid)
+ return hollow_outer_shape
- return return_value
+ return outer_shape
@classmethod
def sweep_multi(
From b77ed1a5e9b3316058cc212a90279772f8197f9b Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 30 Dec 2024 16:02:49 -0500
Subject: [PATCH 053/518] Fixed composite.py typing problems
---
src/build123d/topology.py | 61 +++++++++++++++++++++------------------
1 file changed, 33 insertions(+), 28 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index beb83ba..6199bc1 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -4383,7 +4383,9 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
project_to_viewport = Mixin1D.project_to_viewport
@classmethod
- def cast(cls, obj: TopoDS_Shape) -> Self:
+ def cast(
+ cls, obj: TopoDS_Shape
+ ) -> Vertex | Edge | Wire | Face | Shell | Solid | Compound:
"Returns the right type of wrapper, given a OCCT object"
# define the shape lookup table for casting
@@ -4429,10 +4431,14 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""
if isinstance(obj, Iterable):
- obj = _make_topods_compound_from_shapes(s.wrapped for s in obj)
+ topods_compound = _make_topods_compound_from_shapes(
+ [s.wrapped for s in obj]
+ )
+ else:
+ topods_compound = obj
super().__init__(
- obj=obj,
+ obj=topods_compound,
label=label,
color=color,
parent=parent,
@@ -4502,7 +4508,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
logger.debug("Removing parent of %s (%s)", self.label, parent.label)
if parent.children:
parent.wrapped = _make_topods_compound_from_shapes(
- c.wrapped for c in parent.children
+ [c.wrapped for c in parent.children]
)
else:
parent.wrapped = None
@@ -4516,7 +4522,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""Method call after attaching to `parent`."""
logger.debug("Updated parent of %s to %s", self.label, parent.label)
parent.wrapped = _make_topods_compound_from_shapes(
- c.wrapped for c in parent.children
+ [c.wrapped for c in parent.children]
)
def _post_detach_children(self, children):
@@ -4525,7 +4531,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
kids = ",".join([child.label for child in children])
logger.debug("Removing children %s from %s", kids, self.label)
self.wrapped = _make_topods_compound_from_shapes(
- c.wrapped for c in self.children
+ [c.wrapped for c in self.children]
)
# else:
# logger.debug("Removing no children from %s", self.label)
@@ -4541,7 +4547,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
kids = ",".join([child.label for child in children])
logger.debug("Adding children %s to %s", kids, self.label)
self.wrapped = _make_topods_compound_from_shapes(
- c.wrapped for c in self.children
+ [c.wrapped for c in self.children]
)
# else:
# logger.debug("Adding no children to %s", self.label)
@@ -4557,17 +4563,19 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
return curve + other
- summands = [
+ summands = ShapeList(
shape
for o in (other if isinstance(other, (list, tuple)) else [other])
if o is not None
for shape in o.get_top_level_shapes()
- ]
+ )
# If there is nothing to add return the original object
if not summands:
return self
- summands = [s for s in self.get_top_level_shapes() + summands if s is not None]
+ summands = ShapeList(
+ s for s in self.get_top_level_shapes() + summands if s is not None
+ )
# Only fuse the parts if necessary
if len(summands) <= 1:
@@ -4576,10 +4584,12 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
fuse_op = BRepAlgoAPI_Fuse()
fuse_op.SetFuzzyValue(TOLERANCE)
self.copy_attributes_to(summands[0], ["wrapped", "_NodeMixin__children"])
- result = self._bool_op(summands[:1], summands[1:], fuse_op)
- if isinstance(result, list):
- result = Compound(result)
+ bool_result = self._bool_op(summands[:1], summands[1:], fuse_op)
+ if isinstance(bool_result, list):
+ result = Compound(bool_result)
self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
+ else:
+ result = bool_result
if SkipClean.clean:
result = result.clean()
@@ -4617,7 +4627,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
sub_compounds = []
return ShapeList(sub_compounds)
- def compound(self) -> Compound:
+ def compound(self) -> Compound | None:
"""Return the Compound"""
shape_list = self.compounds()
entity_count = len(shape_list)
@@ -4650,7 +4660,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
def do_children_intersect(
self, include_parent: bool = False, tolerance: float = 1e-5
- ) -> tuple[bool, tuple[Shape, Shape], float]:
+ ) -> tuple[bool, tuple[Shape | None, Shape | None], float]:
"""Do Children Intersect
Determine if any of the child objects within a Compound/assembly intersect by
@@ -4701,7 +4711,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
),
common_volume,
)
- return (False, (), 0.0)
+ return (False, (None, None), 0.0)
@classmethod
def make_text(
@@ -4749,13 +4759,14 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
"""
# pylint: disable=too-many-locals
- def position_face(orig_face: "Face") -> "Face":
+ def position_face(orig_face: Face) -> Face:
"""
Reposition a face to the provided path
Local coordinates are used to calculate the position of the face
relative to the path. Global coordinates to position the face.
"""
+ assert text_path is not None
bbox = orig_face.bounding_box()
face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0)
relative_position_on_wire = (
@@ -4805,9 +4816,9 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt)))
# Align the text from the bounding box
- align = tuplify(align, 2)
+ align_text = tuplify(align, 2)
text_flat = text_flat.translate(
- Vector(*text_flat.bounding_box().to_align_offset(align))
+ Vector(*text_flat.bounding_box().to_align_offset(align_text))
)
if text_path is not None:
@@ -4965,8 +4976,6 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
class Part(Compound):
"""A Compound containing 3D objects - aka Solids"""
- _dim = 3
-
@property
def _dim(self) -> int:
return 3
@@ -4975,8 +4984,6 @@ class Part(Compound):
class Sketch(Compound):
"""A Compound containing 2D objects - aka Faces"""
- _dim = 2
-
@property
def _dim(self) -> int:
return 2
@@ -4985,13 +4992,11 @@ class Sketch(Compound):
class Curve(Compound):
"""A Compound containing 1D objects - aka Edges"""
- _dim = 1
-
@property
def _dim(self) -> int:
return 1
- __add__ = Mixin1D.__add__
+ __add__ = Mixin1D.__add__ # type: ignore
def __matmul__(self, position: float) -> Vector:
"""Position on curve operator @ - only works if continuous"""
@@ -5005,7 +5010,7 @@ class Curve(Compound):
"""Location on wire operator ^ - only works if continuous"""
return Wire(self.edges()).location_at(position)
- def wires(self) -> list[Wire]:
+ def wires(self) -> ShapeList[Wire]: # type: ignore
"""A list of wires created from the edges"""
return Wire.combine(self.edges())
@@ -9570,7 +9575,7 @@ def _make_topods_compound_from_shapes(
def find_max_dimension(shapes: Shape | Iterable[Shape]) -> float:
"""Return the maximum dimension of one or more shapes"""
shapes = shapes if isinstance(shapes, Iterable) else [shapes]
- composite = _make_topods_compound_from_shapes(s.wrapped for s in shapes)
+ composite = _make_topods_compound_from_shapes([s.wrapped for s in shapes])
bbox = BoundBox.from_topo_ds(composite, tolerance=TOLERANCE, optimal=True)
return bbox.diagonal
From 678b715e75531468a7440e0f276acaab40981ef6 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 30 Dec 2024 19:22:50 -0500
Subject: [PATCH 054/518] Fixed invalid references post file split
---
src/build123d/topology.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/build123d/topology.py b/src/build123d/topology.py
index 6199bc1..3cf6e8c 100644
--- a/src/build123d/topology.py
+++ b/src/build123d/topology.py
@@ -2393,7 +2393,9 @@ class ShapeList(list[T]):
def axis_parallel_predicate(axis: Axis, tolerance: float):
def pred(shape: Shape):
if shape.is_planar_face:
- assert shape.wrapped is not None and isinstance(shape, Face)
+ assert shape.wrapped is not None and isinstance(
+ shape.wrapped, TopoDS_Face
+ )
gp_pnt = gp_Pnt()
surface_normal = gp_Vec()
u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped)
@@ -2428,7 +2430,9 @@ class ShapeList(list[T]):
def pred(shape: Shape):
if shape.is_planar_face:
- assert shape.wrapped is not None and isinstance(shape, Face)
+ assert shape.wrapped is not None and isinstance(
+ shape.wrapped, TopoDS_Face
+ )
gp_pnt: gp_Pnt = gp_Pnt()
surface_normal: gp_Vec = gp_Vec()
u_val, _, v_val, _ = BRepTools.UVBounds_s(shape.wrapped)
From e5c976454eadcaff2c923a930f10c8689cd54e41 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Tue, 31 Dec 2024 17:18:23 +0400
Subject: [PATCH 055/518] deprecate param without breaking API yet
---
src/build123d/importers.py | 9 +++++++++
tests/test_importers.py | 18 ++++++++++++++++++
2 files changed, 27 insertions(+)
diff --git a/src/build123d/importers.py b/src/build123d/importers.py
index 1bb1693..5d85455 100644
--- a/src/build123d/importers.py
+++ b/src/build123d/importers.py
@@ -36,6 +36,7 @@ import unicodedata
from math import degrees
from pathlib import Path
from typing import Optional, TextIO, Union
+import warnings
from OCP.BRep import BRep_Builder
from OCP.BRepGProp import BRepGProp
@@ -338,6 +339,7 @@ def import_svg(
flip_y: bool = True,
ignore_visibility: bool = False,
label_by: str = "id",
+ is_inkscape_label: bool | None = None, # TODO remove for `1.0` release
) -> ShapeList[Union[Wire, Face]]:
"""import_svg
@@ -355,6 +357,13 @@ def import_svg(
Returns:
ShapeList[Union[Wire, Face]]: objects contained in svg
"""
+ if is_inkscape_label is not None: # TODO remove for `1.0` release
+ msg = "`is_inkscape_label` parameter is deprecated"
+ if is_inkscape_label:
+ label_by = "inkscape:" + label_by
+ msg += f", use `label_by={label_by!r}` instead"
+ warnings.warn(msg, stacklevel=2)
+
shapes = []
label_by = re.sub(
r"^inkscape:(.+)", r"{http://www.inkscape.org/namespaces/inkscape}\1", label_by
diff --git a/tests/test_importers.py b/tests/test_importers.py
index 3e1155b..0fa7088 100644
--- a/tests/test_importers.py
+++ b/tests/test_importers.py
@@ -84,6 +84,24 @@ class ImportSVG(unittest.TestCase):
self.assertEqual(len(list(hole_faces)), 2)
self.assertEqual(len(list(test_wires)), 1)
+ def test_import_svg_deprecated_param(self): # TODO remove for `1.0` release
+ svg_file = Path(__file__).parent / "../tests/svg_import_test.svg"
+
+ with self.assertWarns(UserWarning):
+ svg = import_svg(svg_file, label_by="label", is_inkscape_label=True)
+
+ # Exact the shape of the plate & holes
+ base_faces = svg.filter_by(lambda f: "base" in f.label)
+ hole_faces = svg.filter_by(lambda f: "hole" in f.label)
+ test_wires = svg.filter_by(lambda f: "wire" in f.label)
+
+ self.assertEqual(len(list(base_faces)), 1)
+ self.assertEqual(len(list(hole_faces)), 2)
+ self.assertEqual(len(list(test_wires)), 1)
+
+ with self.assertWarns(UserWarning):
+ svg = import_svg(svg_file, is_inkscape_label=False)
+
def test_import_svg_colors(self):
svg_file = StringIO(
'
Build123d is a python-based, parametric, boundary representation (BREP) modeling framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as FreeCAD and SolidWorks.
From a4c8d847188eb3b3aebc6d45bdaace40255ea94a Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 14 Jan 2025 10:14:35 -0500
Subject: [PATCH 134/518] Updating badges
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2ebd0b0..a7e67bc 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
+
[](https://build123d.readthedocs.io/en/latest/?badge=latest)
[](https://github.com/gumyr/build123d/actions/workflows/test.yml)
@@ -15,7 +16,6 @@
[](https://pepy.tech/project/build123d)
[](https://pepy.tech/project/build123d)
[](https://pypi.org/project/build123d/)
-
Build123d is a python-based, parametric, boundary representation (BREP) modeling framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as FreeCAD and SolidWorks.
From 4ee07ada6f628b4fa03d3a9f0044390bf287818b Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 14 Jan 2025 10:18:47 -0500
Subject: [PATCH 135/518] Fixed typo
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index a7e67bc..8bb86b6 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ The recommended method for most users is to install **build123d** is:
pip install build123d
```
-To get the latest non-released version of **build123d*** one can install from GitHub using one of the following two commands:
+To get the latest non-released version of **build123d** one can install from GitHub using one of the following two commands:
In Linux/MacOS, use the following command:
```
From 393d64ea3fe8efbd1a5b06eca6ce615865fad070 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Tue, 14 Jan 2025 20:08:15 +0400
Subject: [PATCH 136/518] add typing for common param values
---
src/build123d/importers.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/build123d/importers.py b/src/build123d/importers.py
index 364b62b..4979306 100644
--- a/src/build123d/importers.py
+++ b/src/build123d/importers.py
@@ -35,7 +35,7 @@ import re
import unicodedata
from math import degrees
from pathlib import Path
-from typing import Optional, TextIO, Union
+from typing import Literal, Optional, TextIO, Union
import warnings
from OCP.BRep import BRep_Builder
@@ -338,7 +338,7 @@ def import_svg(
*,
flip_y: bool = True,
ignore_visibility: bool = False,
- label_by: str = "id",
+ label_by: Literal["id", "class", "inkscape:label"] | str = "id",
is_inkscape_label: bool | None = None, # TODO remove for `1.0` release
) -> ShapeList[Wire | Face]:
"""import_svg
From 7811afc54483fe3be7d4ab59bf69587f7a398dde Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 14 Jan 2025 11:30:37 -0500
Subject: [PATCH 137/518] Re-enabling tests after topology split
---
tests/test_direct_api.py | 104 +++++++++++++++++++--------------------
1 file changed, 51 insertions(+), 53 deletions(-)
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index f26c958..b2a622f 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -690,39 +690,38 @@ class TestCadObjects(DirectApiTestCase):
self.assertAlmostEqual(many_rad.radius, 1.0)
-# TODO: Enable after the split of topology.py
-# class TestCleanMethod(unittest.TestCase):
-# def setUp(self):
-# # Create a mock object
-# self.solid = Solid()
-# self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object
+class TestCleanMethod(unittest.TestCase):
+ def setUp(self):
+ # Create a mock object
+ self.solid = Solid()
+ self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object
-# @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain")
-# def test_clean_warning_on_exception(self, mock_shape_upgrade):
-# # Mock the upgrader
-# mock_upgrader = mock_shape_upgrade.return_value
-# mock_upgrader.Build.side_effect = Exception("Mocked Build failure")
+ @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain")
+ def test_clean_warning_on_exception(self, mock_shape_upgrade):
+ # Mock the upgrader
+ mock_upgrader = mock_shape_upgrade.return_value
+ mock_upgrader.Build.side_effect = Exception("Mocked Build failure")
-# # Capture warnings
-# with self.assertWarns(Warning) as warn_context:
-# self.solid.clean()
+ # Capture warnings
+ with self.assertWarns(Warning) as warn_context:
+ self.solid.clean()
-# # Assert the warning message
-# self.assertIn("Unable to clean", str(warn_context.warning))
+ # Assert the warning message
+ self.assertIn("Unable to clean", str(warn_context.warning))
-# # Verify the upgrader was constructed with the correct arguments
-# mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True)
+ # Verify the upgrader was constructed with the correct arguments
+ mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True)
-# # Verify the Build method was called
-# mock_upgrader.Build.assert_called_once()
+ # Verify the Build method was called
+ mock_upgrader.Build.assert_called_once()
-# def test_clean_with_none_wrapped(self):
-# # Set `wrapped` to None to simulate the error condition
-# self.solid.wrapped = None
+ def test_clean_with_none_wrapped(self):
+ # Set `wrapped` to None to simulate the error condition
+ self.solid.wrapped = None
-# # Call clean and ensure it returns self
-# result = self.solid.clean()
-# self.assertIs(result, self.solid) # Ensure it returns the same object
+ # Call clean and ensure it returns self
+ result = self.solid.clean()
+ self.assertIs(result, self.solid) # Ensure it returns the same object
class TestColor(DirectApiTestCase):
@@ -3793,34 +3792,33 @@ class TestShapeList(DirectApiTestCase):
def test_group_by_str_repr(self):
nonagon = RegularPolygon(5, 9)
- # TODO: re-enable this test once the topology refactor complete
- # expected = [
- # "[[],",
- # " [,",
- # " ],",
- # " [,",
- # " ],",
- # " [,",
- # " ],",
- # " [,",
- # " ]]",
- # ]
- # self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
+ expected = [
+ "[[],",
+ " [,",
+ " ],",
+ " [,",
+ " ],",
+ " [,",
+ " ],",
+ " [,",
+ " ]]",
+ ]
+ self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
- # expected_repr = (
- # "[[],"
- # " [,"
- # " ],"
- # " [,"
- # " ],"
- # " [,"
- # " ],"
- # " [,"
- # " ]]"
- # )
- # self.assertDunderReprEqual(
- # repr(nonagon.edges().group_by(Axis.X)), expected_repr
- # )
+ expected_repr = (
+ "[[],"
+ " [,"
+ " ],"
+ " [,"
+ " ],"
+ " [,"
+ " ],"
+ " [,"
+ " ]]"
+ )
+ self.assertDunderReprEqual(
+ repr(nonagon.edges().group_by(Axis.X)), expected_repr
+ )
f = io.StringIO()
p = pretty.PrettyPrinter(f)
From dd8416a141a2285c3f0bdccdef0fa33be9acd015 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 14 Jan 2025 11:32:28 -0600
Subject: [PATCH 138/518] move mesher benchmark to `test_benchmarks.py`
---
tests/test_benchmarks.py | 18 ++++++++++++++++++
tests/test_mesher.py | 13 -------------
2 files changed, 18 insertions(+), 13 deletions(-)
diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py
index 7f9484f..34bac6b 100644
--- a/tests/test_benchmarks.py
+++ b/tests/test_benchmarks.py
@@ -1,11 +1,13 @@
import pytest
import importlib
from math import sqrt
+import time
from build123d import *
pytest_benchmark = pytest.importorskip("pytest_benchmark")
+
def test_ppp_0101(benchmark):
def model():
"""
@@ -616,9 +618,11 @@ def test_ttt_23_02_02(benchmark):
benchmark(model)
+
# def test_ttt_23_T_24(benchmark):
# excluding because it requires sympy
+
def test_ttt_24_SPO_06(benchmark):
def model():
densa = 7800 / 1e6 # carbon steel density g/mm^3
@@ -675,3 +679,17 @@ def test_ttt_24_SPO_06(benchmark):
assert p.part.scale(IN).volume * densa / LB == pytest.approx(3.92, 0.03)
benchmark(model)
+
+
+@pytest.mark.parametrize("test_input", [100, 1000, 10000, 100000])
+def test_mesher_benchmark(benchmark, test_input):
+ # in the 100_000 case test should take on the order of 0.2 seconds
+ # but usually less than 1 second
+ def test_create_3mf_mesh(i):
+ vertices = [(float(i), 0.0, 0.0) for i in range(i)]
+ triangles = [[i, i + 1, i + 2] for i in range(0, i - 3, 3)]
+ mesher = Mesher()._create_3mf_mesh(vertices, triangles)
+ assert len(mesher[0]) == i
+ assert len(mesher[1]) == int(i / 3)
+
+ benchmark(test_create_3mf_mesh, test_input)
diff --git a/tests/test_mesher.py b/tests/test_mesher.py
index 16d2d23..9547d08 100644
--- a/tests/test_mesher.py
+++ b/tests/test_mesher.py
@@ -17,19 +17,6 @@ from build123d.topology import Compound, Solid
from build123d.geometry import Axis, Color, Location, Vector, VectorLike
from build123d.mesher import Mesher
-class InternalApiBenchmark(unittest.TestCase):
- def test_create_3mf_mesh(self):
- start = time.perf_counter()
- for i in [100, 1000, 10000, 100000]:
- vertices = [(float(i), 0.0, 0.0) for i in range(i)]
- triangles = [[i, i+1, i+2] for i in range(0, i-3, 3)]
- start = time.perf_counter()
- Mesher()._create_3mf_mesh(vertices, triangles)
- runtime = time.perf_counter() - start
- print(f"| {i} | {runtime:.3f} |")
- final_runtime = time.perf_counter() - start
- max_runtime = 1.0
- self.assertLessEqual(final_runtime, max_runtime, f"All meshes took {final_runtime:.3f}s > {max_runtime}s")
class DirectApiTestCase(unittest.TestCase):
def assertTupleAlmostEquals(
From 09e99b19d35bfa9c6676b0b816a0db4c21908010 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 14 Jan 2025 11:38:01 -0600
Subject: [PATCH 139/518] remove unused imports time, importlib
---
tests/test_benchmarks.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py
index 34bac6b..7755ab2 100644
--- a/tests/test_benchmarks.py
+++ b/tests/test_benchmarks.py
@@ -1,7 +1,5 @@
import pytest
-import importlib
from math import sqrt
-import time
from build123d import *
From 7b16193559090718438e0479ccbf07fae0ea8c54 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 14 Jan 2025 13:38:27 -0500
Subject: [PATCH 140/518] Fixing Issue #796 + pylint improvements
---
src/build123d/geometry.py | 108 ++++++++++++++++++++------------------
tests/test_direct_api.py | 10 ++++
2 files changed, 67 insertions(+), 51 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 4f7f851..370435c 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -131,7 +131,8 @@ class Vector:
x (float): x component
y (float): y component
z (float): z component
- vec (Vector | Sequence(float) | gp_Vec | gp_Pnt | gp_Dir | gp_XYZ): vector representations
+ vec (Vector | Sequence(float) | gp_Vec | gp_Pnt | gp_Dir | gp_XYZ): vector
+ representations
Note that if no z value is provided it's assumed to be zero. If no values are provided
the returned Vector has the value of 0, 0, 0.
@@ -477,7 +478,8 @@ class Vector:
Vector: transformed vector
"""
if not is_direction:
- # to gp_Pnt to obey build123d transformation convention (in OCP.vectors do not translate)
+ # to gp_Pnt to obey build123d transformation convention (in OCP.vectors do not
+ # translate)
pnt = self.to_pnt()
pnt_t = pnt.Transformed(affine_transform.wrapped.Trsf())
return_value = Vector(gp_Vec(pnt_t.XYZ()))
@@ -524,16 +526,16 @@ class Vector:
if axis is not None:
return axis.intersect(self)
- elif plane is not None:
+ if plane is not None:
return plane.intersect(self)
- elif vector is not None and self == vector:
+ if vector is not None and self == vector:
return vector
- elif location is not None:
+ if location is not None:
return location.intersect(self)
- elif shape is not None:
+ if shape is not None:
return shape.intersect(self)
@@ -1637,16 +1639,16 @@ class Location:
if axis is not None:
return axis.intersect(self)
- elif plane is not None:
+ if plane is not None:
return plane.intersect(self)
- elif vector is not None and self.position == vector:
+ if vector is not None and self.position == vector:
return vector
- elif location is not None and self == location:
+ if location is not None and self == location:
return self
- elif shape is not None:
+ if shape is not None:
return shape.intersect(self)
@@ -1700,7 +1702,8 @@ class Rotation(Location):
X (float): rotation in degrees about X axis
Y (float): rotation in degrees about Y axis
Z (float): rotation in degrees about Z axis
- optionally specify rotation ordering with Intrinsic or Extrinsic enums, defaults to Intrinsic.XYZ
+ optionally specify rotation ordering with Intrinsic or Extrinsic enums,
+ defaults to Intrinsic.XYZ
"""
@@ -1781,29 +1784,30 @@ class Pos(Location):
"""Position by X, Y, Z"""
def __init__(self, *args, **kwargs):
- position = [0, 0, 0]
- # VectorLike
- if len(args) == 1 and isinstance(args[0], (tuple, Vector)):
- position = list(args[0])
- # Vertex
- elif len(args) == 1 and isinstance(args[0], Iterable):
- position = list(args[0])
- # Values
- elif 1 <= len(args) <= 3 and all([isinstance(v, (float, int)) for v in args]):
- position = list(args) + [0] * (3 - len(args))
+ x, y, z, v = 0, 0, 0, None
- unknown_args = ", ".join(set(kwargs.keys()).difference(["v", "X", "Y", "Z"]))
- if unknown_args:
- raise ValueError(f"Unexpected argument(s) {unknown_args}")
+ # Handle args
+ if args:
+ if all(isinstance(v, (float, int)) for v in args):
+ x, y, z = Vector(args)
+ elif len(args) == 1:
+ x, y, z = Vector(args[0])
+ else:
+ raise TypeError(f"Invalid inputs to Pos {args}")
- if "X" in kwargs:
- position[0] = kwargs["X"]
- if "Y" in kwargs:
- position[1] = kwargs["Y"]
- if "Z" in kwargs:
- position[2] = kwargs["Z"]
+ # Handle kwargs
+ x = kwargs.pop("X", x)
+ y = kwargs.pop("Y", y)
+ z = kwargs.pop("Z", z)
+ v = kwargs.pop("v", Vector(x, y, z))
- super().__init__(tuple(position))
+ # Handle unexpected kwargs
+ if kwargs:
+ raise ValueError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
+
+ if v is not None:
+ x, y, z = v
+ super().__init__(Vector(x, y, z))
class Matrix:
@@ -2374,8 +2378,10 @@ class Plane(metaclass=PlaneMeta):
manually chain together multiple rotate() commands.
Args:
- rotation (VectorLike, optional): (xDegrees, yDegrees, zDegrees). Defaults to (0, 0, 0).
- ordering (Intrinsic | Extrinsic, optional): order of rotations in Intrinsic or Extrinsic rotation mode, defaults to Intrinsic.XYZ
+ rotation (VectorLike, optional): (xDegrees, yDegrees, zDegrees).
+ Defaults to (0, 0, 0).
+ ordering (Intrinsic | Extrinsic, optional): order of rotations in
+ Intrinsic or Extrinsic rotation mode, defaults to Intrinsic.XYZ
Returns:
Plane: a copy of this plane rotated as requested.
@@ -2600,24 +2606,24 @@ class Plane(metaclass=PlaneMeta):
if axis is not None:
if self.contains(axis):
return axis
+
+ geom_line = Geom_Line(axis.wrapped)
+ geom_plane = Geom_Plane(self.local_coord_system)
+
+ intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
+
+ if (
+ intersection_calculator.IsDone()
+ and intersection_calculator.NbPoints() == 1
+ ):
+ # Get the intersection point
+ intersection_point = Vector(intersection_calculator.Point(1))
else:
- geom_line = Geom_Line(axis.wrapped)
- geom_plane = Geom_Plane(self.local_coord_system)
+ intersection_point = None
- intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane)
+ return intersection_point
- if (
- intersection_calculator.IsDone()
- and intersection_calculator.NbPoints() == 1
- ):
- # Get the intersection point
- intersection_point = Vector(intersection_calculator.Point(1))
- else:
- intersection_point = None
-
- return intersection_point
-
- elif plane is not None:
+ if plane is not None:
surface1 = Geom_Plane(self.wrapped)
surface2 = Geom_Plane(plane.wrapped)
intersector = GeomAPI_IntSS(surface1, surface2, TOLERANCE)
@@ -2628,15 +2634,15 @@ class Plane(metaclass=PlaneMeta):
axis = intersection_line.Position()
return Axis(axis)
- elif vector is not None and self.contains(vector):
+ if vector is not None and self.contains(vector):
return vector
- elif location is not None:
+ if location is not None:
pln = Plane(location)
if pln.origin == self.origin and pln.z_dir == self.z_dir:
return location
- elif shape is not None:
+ if shape is not None:
return shape.intersect(self)
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index b2a622f..8446cf9 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -2027,6 +2027,16 @@ class TestLocation(DirectApiTestCase):
self.assertIsNone(b.intersect(b.moved(Pos(X=10))))
+ def test_pos(self):
+ with self.assertRaises(TypeError):
+ Pos(0, "foo")
+ self.assertEqual(Pos(1, 2, 3).position, Vector(1, 2, 3))
+ self.assertEqual(Pos((1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(v=(1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(X=1, Y=2, Z=3).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(Vector(1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3))
+
class TestMatrix(DirectApiTestCase):
def test_matrix_creation_and_access(self):
From 2559262ffffb40e0093650badbf47d22fbcd58b4 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 14 Jan 2025 13:02:09 -0600
Subject: [PATCH 141/518] add missing [docs] dependencies, pin sphinx to a new
version, point readthedocs to a .[docs] installation
---
.readthedocs.yaml | 11 +++++++++--
docs/requirements.txt | 1 +
pyproject.toml | 4 +++-
3 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 3c263a9..9925d2f 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -15,7 +15,14 @@ build:
sphinx:
configuration: docs/conf.py
-# Explicitly set the version of Python and its requirements
python:
install:
- - requirements: docs/requirements.txt
+ - method: pip
+ path: .
+ extra_requirements:
+ - docs
+
+# Explicitly set the version of Python and its requirements
+# python:
+# install:
+# - requirements: docs/requirements.txt
diff --git a/docs/requirements.txt b/docs/requirements.txt
index feec70d..c8e4e60 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,3 +1,4 @@
+# TODO: delete this file as it is no longer used to build the docs
# Defining the exact version will make sure things don't break
sphinx==5.3.0
sphinx_rtd_theme>=0.5.1
diff --git a/pyproject.toml b/pyproject.toml
index 1005d53..970a0d9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,10 +75,12 @@ benchmark = [
# dependencies to build the docs
docs = [
- "sphinx",
+ "sphinx==8.1.3", # pin for stability of docs builds
"sphinx-design",
"sphinx-copybutton",
"sphinx-hoverxref",
+ "sphinx-rtd-theme",
+ "sphinx_autodoc_typehints",
]
# all dependencies
From cd69e6ef022a7bce77ace7d9b5227fab56c142d1 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 15 Jan 2025 10:06:39 -0500
Subject: [PATCH 142/518] Improve Edge.intersect & coverage
---
src/build123d/topology/one_d.py | 62 ++++++++++++++-------------------
tests/test_direct_api.py | 27 ++++++++++++++
2 files changed, 54 insertions(+), 35 deletions(-)
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index 819779f..a4c27b0 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -54,11 +54,14 @@ from __future__ import annotations
import copy
import itertools
import warnings
+from collections.abc import Iterable
from itertools import combinations
from math import radians, inf, pi, cos, copysign, ceil, floor
-from typing import Tuple, Union, overload, TYPE_CHECKING
-
-from collections.abc import Iterable
+from typing import Literal, overload, TYPE_CHECKING
+from typing_extensions import Self
+from numpy import ndarray
+from scipy.optimize import minimize
+from scipy.spatial import ConvexHull
import OCP.TopAbs as ta
from OCP.BRep import BRep_Tool
@@ -163,12 +166,6 @@ from build123d.geometry import (
VectorLike,
logger,
)
-from numpy import ndarray
-from scipy.optimize import minimize
-from scipy.spatial import ConvexHull
-from typing_extensions import Self
-
-from typing import Literal
from .shape_core import (
Shape,
@@ -370,10 +367,10 @@ class Mixin1D(Shape):
of the infinite number of valid planes.
Args:
- lines (sequence of Union[Edge,Wire]): edges in common with self
+ lines (sequence of Edge | Wire): edges in common with self
Returns:
- Union[None, Plane]: Either the common plane or None
+ None | Plane: Either the common plane or None
"""
# pylint: disable=too-many-locals
# Note: BRepLib_FindSurface is not helpful as it requires the
@@ -912,7 +909,7 @@ class Mixin1D(Shape):
Split this shape by the provided plane or face.
Args:
- surface (Union[Plane,Face]): surface to segment shape
+ surface (Plane | Face): surface to segment shape
keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP.
Returns:
@@ -1044,7 +1041,7 @@ class Mixin1D(Shape):
is either a float (or int) parameter or a point that lies on the shape.
Args:
- position (Union[float, VectorLike]): distance, parameter value, or
+ position (float | VectorLike): distance, parameter value, or
point on shape. Defaults to 0.5.
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.PARAMETER.
@@ -1650,7 +1647,7 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
Distribute locations along edge or wire.
Args:
- self: Union[Wire:Edge]:
+ self: Wire:Edge:
count(int): Number of locations to generate
start(float): position along Edge|Wire to start. Defaults to 0.0.
stop(float): position along Edge|Wire to end. Defaults to 1.0.
@@ -1824,10 +1821,10 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
"""intersect Edge with Edge or Axis
Args:
- other (Union[Edge, Axis]): other object
+ other (Edge | Axis): other object
Returns:
- Union[Shape, None]: Compound of vertices and/or edges
+ Shape | None: Compound of vertices and/or edges
"""
edges: list[Edge] = []
planes: list[Plane] = []
@@ -1846,10 +1843,16 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
# Find any edge / edge intersection points
points_sets: list[set[Vector]] = []
+ # Find crossing points
for edge_pair in combinations([self] + edges, 2):
intersection_points = edge_pair[0].find_intersection_points(edge_pair[1])
points_sets.append(set(intersection_points))
+ # Find common end points
+ self_end_points = set(Vector(v) for v in self.vertices())
+ edge_end_points = set(Vector(v) for edge in edges for v in edge.vertices())
+ common_end_points = set.intersection(self_end_points, edge_end_points)
+
# Find any edge / plane intersection points & edges
for edge, plane in itertools.product([self] + edges, planes):
# Find point intersections
@@ -1867,15 +1870,18 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
points_sets.append(set(plane_intersection_points))
# Find edge intersections
- if (edge_plane := edge.common_plane()) is not None: # is a 2D edge
- if plane.z_dir in (edge_plane.z_dir, -edge_plane.z_dir):
- edges_common_to_planes.append(edge)
+ if all(
+ plane.contains(v) for v in edge.positions(i / 7 for i in range(8))
+ ): # is a 2D edge
+ edges_common_to_planes.append(edge)
edges.extend(edges_common_to_planes)
# Find the intersection of all sets
common_points = set.intersection(*points_sets)
- common_vertices = [Vertex(*pnt) for pnt in common_points]
+ common_vertices = [
+ Vertex(pnt) for pnt in common_points.union(common_end_points)
+ ]
# Find Edge/Edge overlaps
common_edges: list[Edge] = []
@@ -2054,20 +2060,6 @@ class Edge(Mixin1D, Shape[TopoDS_Edge]):
new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge()
return Edge(new_edge)
- def _intersect_with_edge(self, edge: Edge) -> tuple[list[Vertex], list[Edge]]:
- """find intersection vertices and edges"""
-
- # Find any intersection points
- vertex_intersections = [
- Vertex(pnt) for pnt in self.find_intersection_points(edge)
- ]
-
- # Find Edge/Edge overlaps
- intersect_op = BRepAlgoAPI_Common()
- edge_intersections = self._bool_op((self,), (edge,), intersect_op).edges()
-
- return vertex_intersections, edge_intersections
-
class Wire(Mixin1D, Shape[TopoDS_Wire]):
"""A Wire in build123d is a topological entity representing a connected sequence
@@ -2313,7 +2305,7 @@ class Wire(Mixin1D, Shape[TopoDS_Wire]):
Combine a list of wires and edges into a list of Wires.
Args:
- wires (Iterable[Union[Wire, Edge]]): unsorted
+ wires (Iterable[Wire | Edge]): unsorted
tol (float, optional): tolerance. Defaults to 1e-9.
Returns:
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 8446cf9..da5bfef 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -2027,6 +2027,33 @@ class TestLocation(DirectApiTestCase):
self.assertIsNone(b.intersect(b.moved(Pos(X=10))))
+ # Look for common vertices
+ e1 = Edge.make_line((0, 0), (1, 0))
+ e2 = Edge.make_line((1, 0), (1, 1))
+ e3 = Edge.make_line((1, 0), (2, 0))
+ i = e1.intersect(e2)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+ i = e1.intersect(e3)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+
+ # Intersect with plane
+ e1 = Edge.make_line((0, 0), (2, 0))
+ p1 = Plane.YZ.offset(1)
+ i = e1.intersect(p1)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+
+ e2 = Edge.make_line(p1.origin, p1.origin + 2 * p1.x_dir)
+ i = e2.intersect(p1)
+ self.assertEqual(len(i.vertices()), 2)
+ self.assertEqual(len(i.edges()), 1)
+ self.assertAlmostEqual(i.edge().length, 2, 5)
+
+ with self.assertRaises(ValueError):
+ e1.intersect("line")
+
def test_pos(self):
with self.assertRaises(TypeError):
Pos(0, "foo")
From d78ca933fcda7ff2d54a8664a560418f6f247a60 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Wed, 15 Jan 2025 12:46:19 -0600
Subject: [PATCH 143/518] readthedocs fixes to topology inheritance diagram,
add mixin classes to `topology/__init__.py`, delete docs/requirements.txt,
streamline workflows
---
.github/actions/setup/action.yml | 3 +--
docs/direct_api_reference.rst | 4 +++-
docs/requirements.txt | 11 -----------
src/build123d/topology/__init__.py | 6 +++---
4 files changed, 7 insertions(+), 17 deletions(-)
delete mode 100644 docs/requirements.txt
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index fdb5eba..9b459bb 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -14,5 +14,4 @@ runs:
- name: Install Requirements
shell: bash
run: |
- pip install wheel mypy pytest pytest-cov pylint
- pip install .
+ pip install .[development]
diff --git a/docs/direct_api_reference.rst b/docs/direct_api_reference.rst
index b54c86f..02b6cfc 100644
--- a/docs/direct_api_reference.rst
+++ b/docs/direct_api_reference.rst
@@ -1,3 +1,4 @@
+
####################
Direct API Reference
####################
@@ -52,7 +53,7 @@ supplementary functionality specific to 1D
`~topology.Solid`) objects respectively.
Note that a :class:`~topology.Compound` may be contain only 1D, 2D (:class:`~topology.Face`) or 3D objects.
-.. inheritance-diagram:: topology
+.. inheritance-diagram:: topology.shape_core topology.zero_d topology.one_d topology.two_d topology.three_d topology.composite topology.utils
:parts: 1
.. py:module:: topology
@@ -63,6 +64,7 @@ Note that a :class:`~topology.Compound` may be contain only 1D, 2D (:class:`~top
:special-members: __neg__
.. autoclass:: Mixin1D
:special-members: __matmul__, __mod__
+.. autoclass:: Mixin2D
.. autoclass:: Mixin3D
.. autoclass:: Shape
:special-members: __add__, __sub__, __and__, __rmul__, __eq__, __copy__, __deepcopy__, __hash__
diff --git a/docs/requirements.txt b/docs/requirements.txt
deleted file mode 100644
index c8e4e60..0000000
--- a/docs/requirements.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-# TODO: delete this file as it is no longer used to build the docs
-# Defining the exact version will make sure things don't break
-sphinx==5.3.0
-sphinx_rtd_theme>=0.5.1
-docutils<0.17
-readthedocs-sphinx-search>=0.3.2
-sphinx_autodoc_typehints==1.12.0
-sphinx_copybutton
-sphinx-hoverxref
-sphinx_design
--e git+https://github.com/gumyr/build123d.git#egg=build123d
diff --git a/src/build123d/topology/__init__.py b/src/build123d/topology/__init__.py
index 6b810b5..abee547 100644
--- a/src/build123d/topology/__init__.py
+++ b/src/build123d/topology/__init__.py
@@ -51,9 +51,9 @@ from .utils import (
find_max_dimension,
)
from .zero_d import Vertex, topo_explore_common_vertex
-from .one_d import Edge, Wire, edges_to_wires, topo_explore_connected_edges
-from .two_d import Face, Shell, sort_wires_by_build_order
-from .three_d import Solid
+from .one_d import Edge, Wire, Mixin1D, edges_to_wires, topo_explore_connected_edges
+from .two_d import Face, Shell, Mixin2D,sort_wires_by_build_order
+from .three_d import Solid, Mixin3D
from .composite import Compound, Curve, Sketch, Part
__all__ = [
From 19d925f774c16074f9dbaa9f77650b56d1639661 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 16 Jan 2025 14:18:10 -0500
Subject: [PATCH 144/518] Improved split with a non-planar tool
---
src/build123d/topology/one_d.py | 59 +++++++++++++++++++++------------
tests/test_direct_api.py | 13 ++++++++
2 files changed, 50 insertions(+), 22 deletions(-)
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index a4c27b0..62993b7 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -78,10 +78,10 @@ from OCP.BRepBuilderAPI import (
)
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d
-from OCP.BRepGProp import BRepGProp
+from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepLib import BRepLib, BRepLib_FindSurface
-from OCP.BRepOffset import BRepOffset_MakeOffset
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
+from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepProj import BRepProj_Projection
from OCP.BRepTools import BRepTools
from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse
@@ -104,7 +104,7 @@ from OCP.GeomAPI import (
GeomAPI_PointsToBSpline,
GeomAPI_ProjectPointOnCurve,
)
-from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_JoinType
+from OCP.GeomAbs import GeomAbs_JoinType
from OCP.GeomAdaptor import GeomAdaptor_Curve
from OCP.GeomFill import (
GeomFill_CorrectedFrenet,
@@ -116,7 +116,6 @@ from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe
from OCP.Standard import Standard_Failure, Standard_NoSuchObject
-from OCP.StdFail import StdFail_NotDone
from OCP.TColStd import (
TColStd_Array1OfReal,
TColStd_HArray1OfBoolean,
@@ -131,7 +130,14 @@ from OCP.TopTools import (
TopTools_IndexedDataMapOfShapeListOfShape,
TopTools_ListOfShape,
)
-from OCP.TopoDS import TopoDS, TopoDS_Compound, TopoDS_Shape, TopoDS_Edge, TopoDS_Wire
+from OCP.TopoDS import (
+ TopoDS,
+ TopoDS_Compound,
+ TopoDS_Edge,
+ TopoDS_Shape,
+ TopoDS_Shell,
+ TopoDS_Wire,
+)
from OCP.gp import (
gp_Ax1,
gp_Ax2,
@@ -953,21 +959,29 @@ class Mixin1D(Shape):
split_result = unwrap_topods_compound(split_result, True)
if not isinstance(tool, Plane):
- # Create solids from the surfaces for sorting by thickening
- offset_builder = BRepOffset_MakeOffset()
- offset_builder.Initialize(
- tool.wrapped,
- Offset=0.1,
- Tol=1.0e-5,
- Intersection=True,
- Join=GeomAbs_Intersection,
- Thickening=True,
+ # Get a TopoDS_Face to work with from the tool
+ if isinstance(trim_tool, TopoDS_Shell):
+ faceExplorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE)
+ tool_face = TopoDS.Face_s(faceExplorer.Current())
+ else:
+ tool_face = trim_tool
+
+ # Create a reference point off the +ve side of the tool
+ surface_point = gp_Pnt()
+ surface_normal = gp_Vec()
+ u_min, u_max, v_min, v_max = BRepTools.UVBounds_s(tool_face)
+ BRepGProp_Face(tool_face).Normal(
+ (u_min + u_max) / 2, (v_min + v_max) / 2, surface_point, surface_normal
)
- offset_builder.MakeOffsetShape()
- try:
- tool_thickened = downcast(offset_builder.Shape())
- except StdFail_NotDone as err:
- raise RuntimeError("Error determining top/bottom") from err
+ normalized_surface_normal = Vector(
+ surface_normal.X(), surface_normal.Y(), surface_normal.Z()
+ ).normalized()
+ surface_point = Vector(surface_point)
+ ref_point = surface_point + normalized_surface_normal
+
+ # Create a HalfSpace - Solidish object to determine top/bottom
+ halfSpaceMaker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt())
+ tool_solid = halfSpaceMaker.Solid()
tops: list[Shape] = []
bottoms: list[Shape] = []
@@ -979,10 +993,11 @@ class Mixin1D(Shape):
else:
# Intersect self and the thickened tool
is_up_obj = _topods_bool_op(
- (part,), (tool_thickened,), BRepAlgoAPI_Common()
+ (part,), (tool_solid,), BRepAlgoAPI_Common()
)
- # Calculate volume of intersection
- BRepGProp.VolumeProperties_s(is_up_obj, properties)
+ # Check for valid intersections
+ BRepGProp.LinearProperties_s(is_up_obj, properties)
+ # Mass represents the total length for linear properties
is_up = properties.Mass() >= TOLERANCE
(tops if is_up else bottoms).append(sub_shape)
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index da5bfef..4c10981 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -3248,6 +3248,19 @@ class TestShape(DirectApiTestCase):
outer_vol = 5 * 5
self.assertAlmostEqual(split.volume, outer_vol - inner_vol)
+ def test_split_edge_by_shell(self):
+ edge = Edge.make_line((-5, 0, 0), (5, 0, 0))
+ tool = Wire.make_rect(4, 4)
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ top = edge.split(tool_shell, keep=Keep.TOP)
+ self.assertEqual(len(top), 2)
+ self.assertAlmostEqual(top[0].length, 3, 5)
+
+ def test_split_return_none(self):
+ shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
+ split_shape = shape.split(Plane.XY, keep=Keep.INSIDE)
+ self.assertIsNone(split_shape)
+
def test_split_by_perimeter(self):
# Test 0 - extract a spherical cap
target0 = Solid.make_sphere(10).rotate(Axis.Z, 90)
From eebd82d06a85d1d9ba6d2772bbaf535c47408ca5 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 17 Jan 2025 10:09:23 -0500
Subject: [PATCH 145/518] Added Keep.ALL to split
---
src/build123d/build_enums.py | 5 +++--
src/build123d/topology/one_d.py | 12 ++++++++++++
tests/test_direct_api.py | 6 ++++++
3 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py
index 9d890fe..8f8059a 100644
--- a/src/build123d/build_enums.py
+++ b/src/build123d/build_enums.py
@@ -179,11 +179,12 @@ class Intrinsic(Enum):
class Keep(Enum):
"""Split options"""
- TOP = auto()
+ ALL = auto()
BOTTOM = auto()
+ BOTH = auto()
INSIDE = auto()
OUTSIDE = auto()
- BOTH = auto()
+ TOP = auto()
def __repr__(self):
return f"<{self.__class__.__name__}.{self.name}>"
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index 62993b7..b5a3acf 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -898,6 +898,10 @@ class Mixin1D(Shape):
) -> Self | list[Self] | None:
"""split and keep inside or outside"""
+ @overload
+ def split(self, tool: TrimmingTool, keep: Literal[Keep.ALL]) -> list[Self]:
+ """split and return the unordered pieces"""
+
@overload
def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[
Self | list[Self] | None,
@@ -958,6 +962,14 @@ class Mixin1D(Shape):
if isinstance(split_result, TopoDS_Compound):
split_result = unwrap_topods_compound(split_result, True)
+ # For speed the user may just want all the objects which they
+ # can sort more efficiently then the generic algoritm below
+ if keep == Keep.ALL:
+ return ShapeList(
+ self.__class__.cast(part)
+ for part in get_top_level_topods_shapes(split_result)
+ )
+
if not isinstance(tool, Plane):
# Get a TopoDS_Face to work with from the tool
if isinstance(trim_tool, TopoDS_Shell):
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 4c10981..4dcbfaf 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -3248,6 +3248,12 @@ class TestShape(DirectApiTestCase):
outer_vol = 5 * 5
self.assertAlmostEqual(split.volume, outer_vol - inner_vol)
+ def test_split_keep_all(self):
+ shape = Box(1, 1, 1)
+ split_shape = shape.split(Plane.XY, keep=Keep.ALL)
+ self.assertTrue(isinstance(split_shape, ShapeList))
+ self.assertEqual(len(split_shape), 2)
+
def test_split_edge_by_shell(self):
edge = Edge.make_line((-5, 0, 0), (5, 0, 0))
tool = Wire.make_rect(4, 4)
From 65ead1cce6a598095a93411953feff650ddf93fc Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Sat, 18 Jan 2025 15:56:29 -0600
Subject: [PATCH 146/518] exporters3d.py -> add build123d to exported steps
---
src/build123d/exporters3d.py | 19 ++++++++-----------
1 file changed, 8 insertions(+), 11 deletions(-)
diff --git a/src/build123d/exporters3d.py b/src/build123d/exporters3d.py
index f084246..b3c483c 100644
--- a/src/build123d/exporters3d.py
+++ b/src/build123d/exporters3d.py
@@ -46,7 +46,7 @@ from OCP.RWGltf import RWGltf_CafWriter
from OCP.STEPCAFControl import STEPCAFControl_Controller, STEPCAFControl_Writer
from OCP.STEPControl import STEPControl_Controller, STEPControl_StepModelType
from OCP.StlAPI import StlAPI_Writer
-from OCP.TCollection import TCollection_AsciiString, TCollection_ExtendedString
+from OCP.TCollection import TCollection_AsciiString, TCollection_ExtendedString, TCollection_HAsciiString
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
from OCP.TDataStd import TDataStd_Name
from OCP.TDF import TDF_Label
@@ -55,6 +55,7 @@ from OCP.TopExp import TopExp_Explorer
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import XCAFDoc_ColorType, XCAFDoc_DocumentTool
from OCP.XSControl import XSControl_WorkSession
+from OCP.APIHeaderSection import APIHeaderSection_MakeHeader
from build123d.build_common import UNITS_PER_METER
from build123d.build_enums import PrecisionMode, Unit
@@ -298,16 +299,12 @@ def export_step(
writer.SetLayerMode(True)
writer.SetNameMode(True)
- #
- # APIHeaderSection doesn't seem to be supported by OCP - TBD
- #
-
- # APIHeaderSection_MakeHeader makeHeader(writer.Writer().Model())
- # makeHeader.SetName(TCollection_HAsciiString(path))
- # makeHeader.SetAuthorValue (1, TCollection_HAsciiString("Volker"));
- # makeHeader.SetOrganizationValue (1, TCollection_HAsciiString("myCompanyName"));
- # makeHeader.SetOriginatingSystem(TCollection_HAsciiString("myApplicationName"));
- # makeHeader.SetDescriptionValue(1, TCollection_HAsciiString("myApplication Model"));
+ header = APIHeaderSection_MakeHeader(writer.Writer().Model())
+ # header.SetName(TCollection_HAsciiString(path))
+ # header.SetAuthorValue(1, TCollection_HAsciiString("Volker"));
+ # header.SetOrganizationValue(1, TCollection_HAsciiString("myCompanyName"));
+ header.SetOriginatingSystem(TCollection_HAsciiString("build123d"));
+ # header.SetDescriptionValue(1, TCollection_HAsciiString("myApplication Model"));
STEPCAFControl_Controller.Init_s()
STEPControl_Controller.Init_s()
From 7aaea78094114dd389cdd0f0cc7c4f6e6d84353c Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Sun, 19 Jan 2025 15:11:31 -0600
Subject: [PATCH 147/518] exporters3d.py -> reorganize import, remove semicolon
add comment about header properties
---
src/build123d/exporters3d.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/build123d/exporters3d.py b/src/build123d/exporters3d.py
index b3c483c..dd47af4 100644
--- a/src/build123d/exporters3d.py
+++ b/src/build123d/exporters3d.py
@@ -36,6 +36,7 @@ from typing import Union
import OCP.TopAbs as ta
from anytree import PreOrderIter
+from OCP.APIHeaderSection import APIHeaderSection_MakeHeader
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepTools import BRepTools
from OCP.IFSelect import IFSelect_ReturnStatus
@@ -55,7 +56,6 @@ from OCP.TopExp import TopExp_Explorer
from OCP.XCAFApp import XCAFApp_Application
from OCP.XCAFDoc import XCAFDoc_ColorType, XCAFDoc_DocumentTool
from OCP.XSControl import XSControl_WorkSession
-from OCP.APIHeaderSection import APIHeaderSection_MakeHeader
from build123d.build_common import UNITS_PER_METER
from build123d.build_enums import PrecisionMode, Unit
@@ -301,9 +301,10 @@ def export_step(
header = APIHeaderSection_MakeHeader(writer.Writer().Model())
# header.SetName(TCollection_HAsciiString(path))
+ # consider using e.g. the non *Value versions instead
# header.SetAuthorValue(1, TCollection_HAsciiString("Volker"));
# header.SetOrganizationValue(1, TCollection_HAsciiString("myCompanyName"));
- header.SetOriginatingSystem(TCollection_HAsciiString("build123d"));
+ header.SetOriginatingSystem(TCollection_HAsciiString("build123d"))
# header.SetDescriptionValue(1, TCollection_HAsciiString("myApplication Model"));
STEPCAFControl_Controller.Init_s()
From 254eec1df1d51c5e0b5eb0d2df50423defe18df3 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Sun, 19 Jan 2025 20:28:01 -0600
Subject: [PATCH 148/518] pyproject.toml -> add optional `cadquery-ocp-stubs`
to [development] optional extras
Please review version pins too
---
pyproject.toml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 970a0d9..9daf205 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,7 +35,7 @@ classifiers = [
]
dependencies = [
- "cadquery-ocp >= 7.8.0, < 7.9.0",
+ "cadquery-ocp >= 7.8, < 7.9",
"typing_extensions >= 4.6.0, < 5",
"numpy >= 2, < 3",
"svgpathtools >= 1.5.1, < 2",
@@ -60,6 +60,7 @@ ocp_vscode = [
# development dependencies
development = [
+ "cadquery-ocp-stubs >= 7.8, < 7.9",
"wheel",
"pytest",
"pytest-cov",
From cc9f6c613d458cc35959a80aef72c2ad282dece3 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 20 Jan 2025 13:01:54 -0500
Subject: [PATCH 149/518] Adding the MC length constant
---
src/build123d/__init__.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py
index a16a83d..a1f59ca 100644
--- a/src/build123d/__init__.py
+++ b/src/build123d/__init__.py
@@ -28,6 +28,7 @@ modify_copyreg()
__all__ = [
# Length Constants
+ "MC",
"MM",
"CM",
"M",
From 9ce9306a671612c72cdf299cbeece80e79e1eb4e Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Tue, 21 Jan 2025 13:34:32 -0600
Subject: [PATCH 150/518] exporters3d.py -> set step file name to build123d
label attribute
---
src/build123d/exporters3d.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/build123d/exporters3d.py b/src/build123d/exporters3d.py
index dd47af4..7f2b53b 100644
--- a/src/build123d/exporters3d.py
+++ b/src/build123d/exporters3d.py
@@ -300,7 +300,8 @@ def export_step(
writer.SetNameMode(True)
header = APIHeaderSection_MakeHeader(writer.Writer().Model())
- # header.SetName(TCollection_HAsciiString(path))
+ if to_export.label:
+ header.SetName(TCollection_HAsciiString(to_export.label))
# consider using e.g. the non *Value versions instead
# header.SetAuthorValue(1, TCollection_HAsciiString("Volker"));
# header.SetOrganizationValue(1, TCollection_HAsciiString("myCompanyName"));
From 23e035a1ce105a72712b88fd3fbc74c39c95f221 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 21 Jan 2025 21:37:52 -0500
Subject: [PATCH 151/518] Split test_direct_api.py in many smaller tests
---
tests/__init__.py | 0
tests/test_direct_api.py | 17 +-
tests/test_direct_api/test_always_equal.py | 40 ++
tests/test_direct_api/test_assembly.py | 127 ++++
tests/test_direct_api/test_axis.py | 208 ++++++
tests/test_direct_api/test_bound_box.py | 107 +++
tests/test_direct_api/test_cad_objects.py | 267 ++++++++
tests/test_direct_api/test_clean_method.py | 72 ++
tests/test_direct_api/test_color.py | 127 ++++
tests/test_direct_api/test_compound.py | 165 +++++
.../test_direct_api_test_case.py | 60 ++
tests/test_direct_api/test_edge.py | 293 +++++++++
tests/test_direct_api/test_face.py | 459 +++++++++++++
tests/test_direct_api/test_functions.py | 107 +++
tests/test_direct_api/test_group_by.py | 71 ++
tests/test_direct_api/test_import_export.py | 70 ++
tests/test_direct_api/test_jupyter.py | 60 ++
tests/test_direct_api/test_location.py | 392 +++++++++++
tests/test_direct_api/test_matrix.py | 197 ++++++
tests/test_direct_api/test_mixin1_d.py | 321 +++++++++
tests/test_direct_api/test_mixin3_d.py | 157 +++++
tests/test_direct_api/test_plane.py | 504 ++++++++++++++
tests/test_direct_api/test_projection.py | 106 +++
tests/test_direct_api/test_rotation.py | 61 ++
tests/test_direct_api/test_shape.py | 615 ++++++++++++++++++
tests/test_direct_api/test_shape_list.py | 364 +++++++++++
tests/test_direct_api/test_shells.py | 118 ++++
tests/test_direct_api/test_skip_clean.py | 70 ++
tests/test_direct_api/test_solid.py | 245 +++++++
tests/test_direct_api/test_v_t_k_poly_data.py | 89 +++
tests/test_direct_api/test_vector.py | 287 ++++++++
tests/test_direct_api/test_vector_like.py | 58 ++
tests/test_direct_api/test_vertex.py | 111 ++++
tests/test_direct_api/test_wire.py | 223 +++++++
34 files changed, 6158 insertions(+), 10 deletions(-)
create mode 100644 tests/__init__.py
create mode 100644 tests/test_direct_api/test_always_equal.py
create mode 100644 tests/test_direct_api/test_assembly.py
create mode 100644 tests/test_direct_api/test_axis.py
create mode 100644 tests/test_direct_api/test_bound_box.py
create mode 100644 tests/test_direct_api/test_cad_objects.py
create mode 100644 tests/test_direct_api/test_clean_method.py
create mode 100644 tests/test_direct_api/test_color.py
create mode 100644 tests/test_direct_api/test_compound.py
create mode 100644 tests/test_direct_api/test_direct_api_test_case.py
create mode 100644 tests/test_direct_api/test_edge.py
create mode 100644 tests/test_direct_api/test_face.py
create mode 100644 tests/test_direct_api/test_functions.py
create mode 100644 tests/test_direct_api/test_group_by.py
create mode 100644 tests/test_direct_api/test_import_export.py
create mode 100644 tests/test_direct_api/test_jupyter.py
create mode 100644 tests/test_direct_api/test_location.py
create mode 100644 tests/test_direct_api/test_matrix.py
create mode 100644 tests/test_direct_api/test_mixin1_d.py
create mode 100644 tests/test_direct_api/test_mixin3_d.py
create mode 100644 tests/test_direct_api/test_plane.py
create mode 100644 tests/test_direct_api/test_projection.py
create mode 100644 tests/test_direct_api/test_rotation.py
create mode 100644 tests/test_direct_api/test_shape.py
create mode 100644 tests/test_direct_api/test_shape_list.py
create mode 100644 tests/test_direct_api/test_shells.py
create mode 100644 tests/test_direct_api/test_skip_clean.py
create mode 100644 tests/test_direct_api/test_solid.py
create mode 100644 tests/test_direct_api/test_v_t_k_poly_data.py
create mode 100644 tests/test_direct_api/test_vector.py
create mode 100644 tests/test_direct_api/test_vector_like.py
create mode 100644 tests/test_direct_api/test_vertex.py
create mode 100644 tests/test_direct_api/test_wire.py
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 4dcbfaf..7d2f47c 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -1054,18 +1054,15 @@ class TestEdge(DirectApiTestCase):
line.find_intersection_points(Plane.YZ)
# def test_intersections_tolerance(self):
+ # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
+ # l1 = Edge.make_line((1, 0), (2, 0))
+ # i1 = l1.intersect(*r1)
- # Multiple operands not currently supported
+ # r2 = Rectangle(2, 2).edges()
+ # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
+ # i2 = l2.intersect(*r2)
- # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
- # l1 = Edge.make_line((1, 0), (2, 0))
- # i1 = l1.intersect(*r1)
-
- # r2 = Rectangle(2, 2).edges()
- # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
- # i2 = l2.intersect(*r2)
-
- # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
+ # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
def test_trim(self):
line = Edge.make_line((-2, 0), (2, 0))
diff --git a/tests/test_direct_api/test_always_equal.py b/tests/test_direct_api/test_always_equal.py
new file mode 100644
index 0000000..9b2292f
--- /dev/null
+++ b/tests/test_direct_api/test_always_equal.py
@@ -0,0 +1,40 @@
+"""
+build123d direct api tests
+
+name: test_always_equal.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py
new file mode 100644
index 0000000..b464a0c
--- /dev/null
+++ b/tests/test_direct_api/test_assembly.py
@@ -0,0 +1,127 @@
+"""
+build123d direct api tests
+
+name: test_assembly.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 re
+import unittest
+
+from build123d.topology import Compound, Solid
+
+
+class TestAssembly(unittest.TestCase):
+ @staticmethod
+ def create_test_assembly() -> Compound:
+ box = Solid.make_box(1, 1, 1)
+ box.orientation = (45, 45, 0)
+ box.label = "box"
+ sphere = Solid.make_sphere(1)
+ sphere.label = "sphere"
+ sphere.position = (1, 2, 3)
+ assembly = Compound(label="assembly", children=[box])
+ sphere.parent = assembly
+ return assembly
+
+ def assertTopoEqual(self, actual_topo: str, expected_topo_lines: list[str]):
+ actual_topo_lines = actual_topo.splitlines()
+ self.assertEqual(len(actual_topo_lines), len(expected_topo_lines))
+ for actual_line, expected_line in zip(actual_topo_lines, expected_topo_lines):
+ start, end = re.split(r"at 0x[0-9a-f]+,", expected_line, 2, re.I)
+ self.assertTrue(actual_line.startswith(start))
+ self.assertTrue(actual_line.endswith(end))
+
+ def test_attributes(self):
+ box = Solid.make_box(1, 1, 1)
+ box.label = "box"
+ sphere = Solid.make_sphere(1)
+ sphere.label = "sphere"
+ assembly = Compound(label="assembly", children=[box])
+ sphere.parent = assembly
+
+ self.assertEqual(len(box.children), 0)
+ self.assertEqual(box.label, "box")
+ self.assertEqual(box.parent, assembly)
+ self.assertEqual(sphere.parent, assembly)
+ self.assertEqual(len(assembly.children), 2)
+
+ def test_show_topology_compound(self):
+ assembly = TestAssembly.create_test_assembly()
+ expected = [
+ "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))",
+ "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))",
+ "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))",
+ ]
+ self.assertTopoEqual(assembly.show_topology("Solid"), expected)
+
+ def test_show_topology_shape_location(self):
+ assembly = TestAssembly.create_test_assembly()
+ expected = [
+ "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)",
+ "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)",
+ " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)",
+ ]
+ self.assertTopoEqual(
+ assembly.children[1].show_topology("Face", show_center=False), expected
+ )
+
+ def test_show_topology_shape(self):
+ assembly = TestAssembly.create_test_assembly()
+ expected = [
+ "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)",
+ "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)",
+ " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)",
+ ]
+ self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected)
+
+ def test_remove_child(self):
+ assembly = TestAssembly.create_test_assembly()
+ self.assertEqual(len(assembly.children), 2)
+ assembly.children = list(assembly.children)[1:]
+ self.assertEqual(len(assembly.children), 1)
+
+ def test_do_children_intersect(self):
+ (
+ overlap,
+ pair,
+ distance,
+ ) = TestAssembly.create_test_assembly().do_children_intersect()
+ self.assertFalse(overlap)
+ box = Solid.make_box(1, 1, 1)
+ box.orientation = (45, 45, 0)
+ box.label = "box"
+ sphere = Solid.make_sphere(1)
+ sphere.label = "sphere"
+ sphere.position = (0, 0, 0)
+ assembly = Compound(label="assembly", children=[box])
+ sphere.parent = assembly
+ overlap, pair, distance = assembly.do_children_intersect()
+ self.assertTrue(overlap)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py
new file mode 100644
index 0000000..f4d0a4a
--- /dev/null
+++ b/tests/test_direct_api/test_axis.py
@@ -0,0 +1,208 @@
+"""
+build123d direct api tests
+
+name: test_axis.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 copy
+import unittest
+
+from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt
+from build123d.geometry import Axis, Location, Plane, Vector
+from build123d.topology import Edge
+from tests.base_test import DirectApiTestCase, AlwaysEqual
+
+
+class TestAxis(DirectApiTestCase):
+ """Test the Axis class"""
+
+ def test_axis_init(self):
+ test_axis = Axis((1, 2, 3), (0, 0, 1))
+ self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis((1, 2, 3), direction=(0, 0, 1))
+ self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1))
+ self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4)))
+ self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4)))
+ self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+
+ with self.assertRaises(ValueError):
+ Axis("one", "up")
+ with self.assertRaises(ValueError):
+ Axis(one="up")
+
+ def test_axis_from_occt(self):
+ occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0))
+ test_axis = Axis(occt_axis)
+ self.assertVectorAlmostEquals(test_axis.position, (1, 1, 1), 5)
+ self.assertVectorAlmostEquals(test_axis.direction, (0, 1, 0), 5)
+
+ def test_axis_repr_and_str(self):
+ self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))")
+ self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))")
+
+ def test_axis_copy(self):
+ x_copy = copy.copy(Axis.X)
+ self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5)
+ x_copy = copy.deepcopy(Axis.X)
+ self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5)
+
+ def test_axis_to_location(self):
+ # TODO: Verify this is correct
+ x_location = Axis.X.location
+ self.assertTrue(isinstance(x_location, Location))
+ self.assertVectorAlmostEquals(x_location.position, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(x_location.orientation, (0, 90, 180), 5)
+
+ def test_axis_located(self):
+ y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0)))
+ self.assertVectorAlmostEquals(y_axis.position, (0, 0, 1), 5)
+ self.assertVectorAlmostEquals(y_axis.direction, (0, 1, 0), 5)
+
+ def test_axis_to_plane(self):
+ x_plane = Axis.X.to_plane()
+ self.assertTrue(isinstance(x_plane, Plane))
+ self.assertVectorAlmostEquals(x_plane.origin, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(x_plane.z_dir, (1, 0, 0), 5)
+
+ def test_axis_is_coaxial(self):
+ self.assertTrue(Axis.X.is_coaxial(Axis((0, 0, 0), (1, 0, 0))))
+ self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 1), (1, 0, 0))))
+ self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 0), (0, 1, 0))))
+
+ def test_axis_is_normal(self):
+ self.assertTrue(Axis.X.is_normal(Axis.Y))
+ self.assertFalse(Axis.X.is_normal(Axis.X))
+
+ def test_axis_is_opposite(self):
+ self.assertTrue(Axis.X.is_opposite(Axis((1, 1, 1), (-1, 0, 0))))
+ self.assertFalse(Axis.X.is_opposite(Axis.X))
+
+ def test_axis_is_parallel(self):
+ self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0))))
+ self.assertFalse(Axis.X.is_parallel(Axis.Y))
+
+ def test_axis_angle_between(self):
+ self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5)
+ self.assertAlmostEqual(
+ Axis.X.angle_between(Axis((1, 1, 1), (-1, 0, 0))), 180, 5
+ )
+
+ def test_axis_reverse(self):
+ self.assertVectorAlmostEquals(Axis.X.reverse().direction, (-1, 0, 0), 5)
+
+ def test_axis_reverse_op(self):
+ axis = -Axis.X
+ self.assertVectorAlmostEquals(axis.direction, (-1, 0, 0), 5)
+
+ def test_axis_as_edge(self):
+ edge = Edge(Axis.X)
+ self.assertTrue(isinstance(edge, Edge))
+ common = (edge & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
+ self.assertAlmostEqual(common.length, 1, 5)
+
+ def test_axis_intersect(self):
+ common = (Axis.X.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
+ self.assertAlmostEqual(common.length, 1, 5)
+
+ common = (Axis.X & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
+ self.assertAlmostEqual(common.length, 1, 5)
+
+ intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0))
+ self.assertVectorAlmostEquals(intersection, (1, 0, 0), 5)
+
+ i = Axis.X & Axis((1, 0, 0), (1, 0, 0))
+ self.assertEqual(i, Axis.X)
+
+ intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY
+ self.assertTupleAlmostEquals(intersection.to_tuple(), (1, 2, 0), 5)
+
+ arc = Edge.make_circle(20, start_angle=0, end_angle=180)
+ ax0 = Axis((-20, 30, 0), (4, -3, 0))
+ intersections = arc.intersect(ax0).vertices().sort_by(Axis.X)
+ self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5)
+ self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5)
+
+ intersections = ax0.intersect(arc).vertices().sort_by(Axis.X)
+ self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5)
+ self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5)
+
+ i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5)
+ self.assertTrue(isinstance(i, Vector))
+ self.assertVectorAlmostEquals(i, (0.5, 0.5, 1.5), 5)
+ self.assertIsNone(Axis.Y & Vector(2, 0, 0))
+
+ l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1
+ i: Location = Axis.Z & l
+ self.assertTrue(isinstance(i, Location))
+ self.assertVectorAlmostEquals(i.position, l.position, 5)
+ self.assertVectorAlmostEquals(i.orientation, l.orientation, 5)
+
+ self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1))
+ self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1))
+
+ # TODO: uncomment when generalized edge to surface intersections are complete
+ # non_planar = (
+ # Solid.make_cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)
+ # )
+ # intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar
+
+ # self.assertTrue(len(intersections.vertices(), 2))
+ # self.assertTupleAlmostEquals(
+ # intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5
+ # )
+ # self.assertTupleAlmostEquals(
+ # intersection.vertices()[1].to_tuple(), (1, 0, 5), 5
+ # )
+
+ def test_axis_equal(self):
+ self.assertEqual(Axis.X, Axis.X)
+ self.assertEqual(Axis.Y, Axis.Y)
+ self.assertEqual(Axis.Z, Axis.Z)
+ self.assertEqual(Axis.X, AlwaysEqual())
+
+ def test_axis_not_equal(self):
+ self.assertNotEqual(Axis.X, Axis.Y)
+ random_obj = object()
+ self.assertNotEqual(Axis.X, random_obj)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_bound_box.py b/tests/test_direct_api/test_bound_box.py
new file mode 100644
index 0000000..c06eaff
--- /dev/null
+++ b/tests/test_direct_api/test_bound_box.py
@@ -0,0 +1,107 @@
+"""
+build123d direct api tests
+
+name: test_bound_box.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.geometry import BoundBox, Vector
+from build123d.topology import Solid, Vertex
+from tests.base_test import DirectApiTestCase
+
+
+class TestBoundBox(DirectApiTestCase):
+ def test_basic_bounding_box(self):
+ v = Vertex(1, 1, 1)
+ v2 = Vertex(2, 2, 2)
+ self.assertEqual(BoundBox, type(v.bounding_box()))
+ self.assertEqual(BoundBox, type(v2.bounding_box()))
+
+ bb1 = v.bounding_box().add(v2.bounding_box())
+
+ # OCC uses some approximations
+ self.assertAlmostEqual(bb1.size.X, 1.0, 1)
+
+ # Test adding to an existing bounding box
+ v0 = Vertex(0, 0, 0)
+ bb2 = v0.bounding_box().add(v.bounding_box())
+
+ bb3 = bb1.add(bb2)
+ self.assertVectorAlmostEquals(bb3.size, (2, 2, 2), 7)
+
+ bb3 = bb2.add((3, 3, 3))
+ self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7)
+
+ bb3 = bb2.add(Vector(3, 3, 3))
+ self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7)
+
+ # Test 2D bounding boxes
+ bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box())
+ bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box())
+ bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box())
+ # Test that bb2 contains bb1
+ self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2))
+ self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1))
+ # Test that neither bounding box contains the other
+ self.assertIsNone(BoundBox.find_outside_box_2d(bb1, bb3))
+
+ # Test creation of a bounding box from a shape - note the low accuracy comparison
+ # as the box is a little larger than the shape
+ bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
+ self.assertVectorAlmostEquals(bb1.size, (2, 2, 1), 1)
+
+ bb2 = BoundBox.from_topo_ds(
+ Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False
+ )
+ self.assertTrue(bb2.is_inside(bb1))
+
+ def test_bounding_box_repr(self):
+ bb = Solid.make_box(1, 1, 1).bounding_box()
+ self.assertEqual(
+ repr(bb), "bbox: 0.0 <= x <= 1.0, 0.0 <= y <= 1.0, 0.0 <= z <= 1.0"
+ )
+
+ def test_center_of_boundbox(self):
+ self.assertVectorAlmostEquals(
+ Solid.make_box(1, 1, 1).bounding_box().center(),
+ (0.5, 0.5, 0.5),
+ 5,
+ )
+
+ def test_combined_center_of_boundbox(self):
+ pass
+
+ def test_clean_boundbox(self):
+ s = Solid.make_sphere(3)
+ self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5)
+ s.mesh(1e-3)
+ self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_cad_objects.py b/tests/test_direct_api/test_cad_objects.py
new file mode 100644
index 0000000..fa9a186
--- /dev/null
+++ b/tests/test_direct_api/test_cad_objects.py
@@ -0,0 +1,267 @@
+"""
+build123d direct api tests
+
+name: test_cad_objects.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import unittest
+
+from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+from OCP.gp import gp, gp_Ax2, gp_Circ, gp_Elips, gp_Pnt
+from build123d.build_enums import CenterOf
+from build123d.geometry import Plane, Vector
+from build123d.topology import Edge, Face, Wire
+from tests.base_test import DirectApiTestCase, DEG2RAD
+
+
+class TestCadObjects(DirectApiTestCase):
+ def _make_circle(self):
+ circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0)
+ return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
+
+ def _make_ellipse(self):
+ ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0)
+ return Edge.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge())
+
+ def test_edge_wrapper_center(self):
+ e = self._make_circle()
+
+ self.assertVectorAlmostEquals(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3)
+
+ def test_edge_wrapper_ellipse_center(self):
+ e = self._make_ellipse()
+ w = Wire([e])
+ self.assertVectorAlmostEquals(Face(w).center(), (1.0, 2.0, 3.0), 3)
+
+ def test_edge_wrapper_make_circle(self):
+ halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180)
+
+ # self.assertVectorAlmostEquals((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),3)
+ self.assertVectorAlmostEquals(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3)
+ self.assertVectorAlmostEquals(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3)
+
+ def test_edge_wrapper_make_tangent_arc(self):
+ tangent_arc = Edge.make_tangent_arc(
+ Vector(1, 1), # starts at 1, 1
+ Vector(0, 1), # tangent at start of arc is in the +y direction
+ Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1
+ )
+ self.assertVectorAlmostEquals(tangent_arc.start_point(), (1, 1, 0), 3)
+ self.assertVectorAlmostEquals(tangent_arc.end_point(), (2, 1, 0), 3)
+ self.assertVectorAlmostEquals(tangent_arc.tangent_at(0), (0, 1, 0), 3)
+ self.assertVectorAlmostEquals(tangent_arc.tangent_at(0.5), (1, 0, 0), 3)
+ self.assertVectorAlmostEquals(tangent_arc.tangent_at(1), (0, -1, 0), 3)
+
+ def test_edge_wrapper_make_ellipse1(self):
+ # Check x_radius > y_radius
+ x_radius, y_radius = 20, 10
+ angle1, angle2 = -75.0, 90.0
+ arcEllipseEdge = Edge.make_ellipse(
+ x_radius=x_radius,
+ y_radius=y_radius,
+ plane=Plane.XY,
+ start_angle=angle1,
+ end_angle=angle2,
+ )
+
+ start = (
+ x_radius * math.cos(angle1 * DEG2RAD),
+ y_radius * math.sin(angle1 * DEG2RAD),
+ 0.0,
+ )
+ end = (
+ x_radius * math.cos(angle2 * DEG2RAD),
+ y_radius * math.sin(angle2 * DEG2RAD),
+ 0.0,
+ )
+ self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
+ self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
+
+ def test_edge_wrapper_make_ellipse2(self):
+ # Check x_radius < y_radius
+ x_radius, y_radius = 10, 20
+ angle1, angle2 = 0.0, 45.0
+ arcEllipseEdge = Edge.make_ellipse(
+ x_radius=x_radius,
+ y_radius=y_radius,
+ plane=Plane.XY,
+ start_angle=angle1,
+ end_angle=angle2,
+ )
+
+ start = (
+ x_radius * math.cos(angle1 * DEG2RAD),
+ y_radius * math.sin(angle1 * DEG2RAD),
+ 0.0,
+ )
+ end = (
+ x_radius * math.cos(angle2 * DEG2RAD),
+ y_radius * math.sin(angle2 * DEG2RAD),
+ 0.0,
+ )
+ self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
+ self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
+
+ def test_edge_wrapper_make_circle_with_ellipse(self):
+ # Check x_radius == y_radius
+ x_radius, y_radius = 20, 20
+ angle1, angle2 = 15.0, 60.0
+ arcEllipseEdge = Edge.make_ellipse(
+ x_radius=x_radius,
+ y_radius=y_radius,
+ plane=Plane.XY,
+ start_angle=angle1,
+ end_angle=angle2,
+ )
+
+ start = (
+ x_radius * math.cos(angle1 * DEG2RAD),
+ y_radius * math.sin(angle1 * DEG2RAD),
+ 0.0,
+ )
+ end = (
+ x_radius * math.cos(angle2 * DEG2RAD),
+ y_radius * math.sin(angle2 * DEG2RAD),
+ 0.0,
+ )
+ self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
+ self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
+
+ def test_face_wrapper_make_rect(self):
+ mplane = Face.make_rect(10, 10)
+
+ self.assertVectorAlmostEquals(mplane.normal_at(), (0.0, 0.0, 1.0), 3)
+
+ # def testCompoundcenter(self):
+ # """
+ # Tests whether or not a proper weighted center can be found for a compound
+ # """
+
+ # def cylinders(self, radius, height):
+
+ # c = Solid.make_cylinder(radius, height, Vector())
+
+ # # Combine all the cylinders into a single compound
+ # r = self.eachpoint(lambda loc: c.located(loc), True).combinesolids()
+
+ # return r
+
+ # Workplane.cyl = cylinders
+
+ # # Now test. here we want weird workplane to see if the objects are transformed right
+ # s = (
+ # Workplane("XY")
+ # .rect(2.0, 3.0, for_construction=true)
+ # .vertices()
+ # .cyl(0.25, 0.5)
+ # )
+
+ # self.assertEqual(4, len(s.val().solids()))
+ # self.assertVectorAlmostEquals((0.0, 0.0, 0.25), s.val().center, 3)
+
+ def test_translate(self):
+ e = Edge.make_circle(2, Plane((1, 2, 3)))
+ e2 = e.translate(Vector(0, 0, 1))
+
+ self.assertVectorAlmostEquals(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
+
+ def test_vertices(self):
+ e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
+ self.assertEqual(2, len(e.vertices()))
+
+ def test_edge_wrapper_radius(self):
+ # get a radius from a simple circle
+ e0 = Edge.make_circle(2.4)
+ self.assertAlmostEqual(e0.radius, 2.4)
+
+ # radius of an arc
+ e1 = Edge.make_circle(
+ 1.8, Plane(origin=(5, 6, 7), z_dir=(1, 1, 1)), start_angle=20, end_angle=30
+ )
+ self.assertAlmostEqual(e1.radius, 1.8)
+
+ # test value errors
+ e2 = Edge.make_ellipse(10, 20)
+ with self.assertRaises(ValueError):
+ e2.radius
+
+ # radius from a wire
+ w0 = Wire.make_circle(10, Plane(origin=(1, 2, 3), z_dir=(-1, 0, 1)))
+ self.assertAlmostEqual(w0.radius, 10)
+
+ # radius from a wire with multiple edges
+ rad = 2.3
+ plane = Plane(origin=(7, 8, 0), z_dir=(1, 0.5, 0.1))
+ w1 = Wire(
+ [
+ Edge.make_circle(rad, plane, 0, 10),
+ Edge.make_circle(rad, plane, 10, 25),
+ Edge.make_circle(rad, plane, 25, 230),
+ ]
+ )
+ self.assertAlmostEqual(w1.radius, rad)
+
+ # test value error from wire
+ w2 = Wire.make_polygon(
+ [
+ Vector(-1, 0, 0),
+ Vector(0, 1, 0),
+ Vector(1, -1, 0),
+ ]
+ )
+ with self.assertRaises(ValueError):
+ w2.radius
+
+ # (I think) the radius of a wire is the radius of it's first edge.
+ # Since this is stated in the docstring better make sure.
+ no_rad = Wire(
+ [
+ Edge.make_line(Vector(0, 0, 0), Vector(0, 1, 0)),
+ Edge.make_circle(1.0, start_angle=90, end_angle=270),
+ ]
+ )
+ with self.assertRaises(ValueError):
+ no_rad.radius
+ yes_rad = Wire(
+ [
+ Edge.make_circle(1.0, start_angle=90, end_angle=270),
+ Edge.make_line(Vector(0, -1, 0), Vector(0, 1, 0)),
+ ]
+ )
+ self.assertAlmostEqual(yes_rad.radius, 1.0)
+ many_rad = Wire(
+ [
+ Edge.make_circle(1.0, start_angle=0, end_angle=180),
+ Edge.make_circle(3.0, Plane((2, 0, 0)), start_angle=180, end_angle=359),
+ ]
+ )
+ self.assertAlmostEqual(many_rad.radius, 1.0)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_clean_method.py b/tests/test_direct_api/test_clean_method.py
new file mode 100644
index 0000000..4c5dfa0
--- /dev/null
+++ b/tests/test_direct_api/test_clean_method.py
@@ -0,0 +1,72 @@
+"""
+build123d direct api tests
+
+name: test_clean_method.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 unittest.mock import patch, MagicMock
+
+from build123d.topology import Solid
+
+
+class TestCleanMethod(unittest.TestCase):
+ def setUp(self):
+ # Create a mock object
+ self.solid = Solid()
+ self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object
+
+ @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain")
+ def test_clean_warning_on_exception(self, mock_shape_upgrade):
+ # Mock the upgrader
+ mock_upgrader = mock_shape_upgrade.return_value
+ mock_upgrader.Build.side_effect = Exception("Mocked Build failure")
+
+ # Capture warnings
+ with self.assertWarns(Warning) as warn_context:
+ self.solid.clean()
+
+ # Assert the warning message
+ self.assertIn("Unable to clean", str(warn_context.warning))
+
+ # Verify the upgrader was constructed with the correct arguments
+ mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True)
+
+ # Verify the Build method was called
+ mock_upgrader.Build.assert_called_once()
+
+ def test_clean_with_none_wrapped(self):
+ # Set `wrapped` to None to simulate the error condition
+ self.solid.wrapped = None
+
+ # Call clean and ensure it returns self
+ result = self.solid.clean()
+ self.assertIs(result, self.solid)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py
new file mode 100644
index 0000000..f18ab95
--- /dev/null
+++ b/tests/test_direct_api/test_color.py
@@ -0,0 +1,127 @@
+"""
+build123d direct api tests
+
+name: test_color.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 copy
+import unittest
+
+from build123d.geometry import Color
+from tests.base_test import DirectApiTestCase
+
+
+class TestColor(DirectApiTestCase):
+ def test_name1(self):
+ c = Color("blue")
+ self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
+
+ def test_name2(self):
+ c = Color("blue", alpha=0.5)
+ self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
+
+ def test_name3(self):
+ c = Color("blue", 0.5)
+ self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
+
+ def test_rgb0(self):
+ c = Color(0.0, 1.0, 0.0)
+ self.assertTupleAlmostEquals(tuple(c), (0, 1, 0, 1), 5)
+
+ def test_rgba1(self):
+ c = Color(1.0, 1.0, 0.0, 0.5)
+ self.assertEqual(c.wrapped.GetRGB().Red(), 1.0)
+ self.assertEqual(c.wrapped.GetRGB().Green(), 1.0)
+ self.assertEqual(c.wrapped.GetRGB().Blue(), 0.0)
+ self.assertEqual(c.wrapped.Alpha(), 0.5)
+
+ def test_rgba2(self):
+ c = Color(1.0, 1.0, 0.0, alpha=0.5)
+ self.assertTupleAlmostEquals(tuple(c), (1, 1, 0, 0.5), 5)
+
+ def test_rgba3(self):
+ c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5)
+ self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.5), 5)
+
+ def test_bad_color_name(self):
+ with self.assertRaises(ValueError):
+ Color("build123d")
+
+ def test_to_tuple(self):
+ c = Color("blue", alpha=0.5)
+ self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
+
+ def test_hex(self):
+ c = Color(0x996692)
+ self.assertTupleAlmostEquals(
+ tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5
+ )
+
+ c = Color(0x006692, 0x80)
+ self.assertTupleAlmostEquals(
+ tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5
+ )
+
+ c = Color(0x006692, alpha=0x80)
+ self.assertTupleAlmostEquals(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 5)
+
+ c = Color(color_code=0x996692, alpha=0xCC)
+ self.assertTupleAlmostEquals(
+ tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5
+ )
+
+ c = Color(0.0, 0.0, 1.0, 1.0)
+ self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
+
+ c = Color(0, 0, 1, 1)
+ self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
+
+ def test_copy(self):
+ c = Color(0.1, 0.2, 0.3, alpha=0.4)
+ c_copy = copy.copy(c)
+ self.assertTupleAlmostEquals(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 5)
+
+ def test_str_repr(self):
+ c = Color(1, 0, 0)
+ self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) ~ RED")
+ self.assertEqual(repr(c), "Color(1.0, 0.0, 0.0, 1.0)")
+
+ def test_tuple(self):
+ c = Color((0.1,))
+ self.assertTupleAlmostEquals(tuple(c), (0.1, 1.0, 1.0, 1.0), 5)
+ c = Color((0.1, 0.2))
+ self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 1.0, 1.0), 5)
+ c = Color((0.1, 0.2, 0.3))
+ self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 1.0), 5)
+ c = Color((0.1, 0.2, 0.3, 0.4))
+ self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5)
+ c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4))
+ self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_compound.py b/tests/test_direct_api/test_compound.py
new file mode 100644
index 0000000..8ee33ca
--- /dev/null
+++ b/tests/test_direct_api/test_compound.py
@@ -0,0 +1,165 @@
+"""
+build123d direct api tests
+
+name: test_compound.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 itertools
+import unittest
+
+from build123d.build_common import GridLocations, PolarLocations
+from build123d.build_enums import Align, CenterOf
+from build123d.geometry import Location, Plane
+from build123d.objects_part import Box
+from build123d.objects_sketch import Circle
+from build123d.topology import Compound, Edge, Face, ShapeList, Solid, Sketch
+from tests.base_test import DirectApiTestCase
+
+
+class TestCompound(DirectApiTestCase):
+ def test_make_text(self):
+ arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0))
+ text = Compound.make_text("test", 10, text_path=arc)
+ self.assertEqual(len(text.faces()), 4)
+ text = Compound.make_text(
+ "test", 10, align=(Align.MAX, Align.MAX), text_path=arc
+ )
+ self.assertEqual(len(text.faces()), 4)
+
+ def test_fuse(self):
+ box1 = Solid.make_box(1, 1, 1)
+ box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
+ combined = Compound([box1]).fuse(box2, glue=True)
+ self.assertTrue(combined.is_valid())
+ self.assertAlmostEqual(combined.volume, 2, 5)
+ fuzzy = Compound([box1]).fuse(box2, tol=1e-6)
+ self.assertTrue(fuzzy.is_valid())
+ self.assertAlmostEqual(fuzzy.volume, 2, 5)
+
+ def test_remove(self):
+ box1 = Solid.make_box(1, 1, 1)
+ box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
+ combined = Compound([box1, box2])
+ self.assertTrue(len(combined._remove(box2).solids()), 1)
+
+ def test_repr(self):
+ simple = Compound([Solid.make_box(1, 1, 1)])
+ simple_str = repr(simple).split("0x")[0] + repr(simple).split(", ")[1]
+ self.assertEqual(simple_str, "Compound at label()")
+
+ assembly = Compound([Solid.make_box(1, 1, 1)])
+ assembly.children = [Solid.make_box(1, 1, 1)]
+ assembly.label = "test"
+ assembly_str = repr(assembly).split("0x")[0] + repr(assembly).split(", l")[1]
+ self.assertEqual(assembly_str, "Compound at abel(test), #children(1)")
+
+ def test_center(self):
+ test_compound = Compound(
+ [
+ Solid.make_box(2, 2, 2).locate(Location((-1, -1, -1))),
+ Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))),
+ ]
+ )
+ self.assertVectorAlmostEquals(test_compound.center(CenterOf.MASS), (1, 0, 0), 5)
+ self.assertVectorAlmostEquals(
+ test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5
+ )
+ with self.assertRaises(ValueError):
+ test_compound.center(CenterOf.GEOMETRY)
+
+ def test_triad(self):
+ triad = Compound.make_triad(10)
+ bbox = triad.bounding_box()
+ self.assertGreater(bbox.min.X, -10 / 8)
+ self.assertLess(bbox.min.X, 0)
+ self.assertGreater(bbox.min.Y, -10 / 8)
+ self.assertLess(bbox.min.Y, 0)
+ self.assertGreater(bbox.min.Y, -10 / 8)
+ self.assertAlmostEqual(bbox.min.Z, 0, 4)
+ self.assertLess(bbox.size.Z, 12.5)
+ self.assertEqual(triad.volume, 0)
+
+ def test_volume(self):
+ e = Edge.make_line((0, 0), (1, 1))
+ self.assertAlmostEqual(e.volume, 0, 5)
+
+ f = Face.make_rect(1, 1)
+ self.assertAlmostEqual(f.volume, 0, 5)
+
+ b = Solid.make_box(1, 1, 1)
+ self.assertAlmostEqual(b.volume, 1, 5)
+
+ bb = Box(1, 1, 1)
+ self.assertAlmostEqual(bb.volume, 1, 5)
+
+ c = Compound(children=[e, f, b, bb, b.translate((0, 5, 0))])
+ self.assertAlmostEqual(c.volume, 3, 5)
+ # N.B. b and bb overlap but still add to Compound volume
+
+ def test_constructor(self):
+ with self.assertRaises(TypeError):
+ Compound(foo="bar")
+
+ def test_len(self):
+ self.assertEqual(len(Compound()), 0)
+ skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
+ self.assertEqual(len(skt), 4)
+
+ def test_iteration(self):
+ skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
+ for c1, c2 in itertools.combinations(skt, 2):
+ self.assertGreaterEqual((c1.position - c2.position).length, 10)
+
+ def test_unwrap(self):
+ skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
+ skt2 = Compound(children=[skt])
+ self.assertEqual(len(skt2), 1)
+ skt3 = skt2.unwrap(fully=False)
+ self.assertEqual(len(skt3), 4)
+
+ comp1 = Compound().unwrap()
+ self.assertEqual(len(comp1), 0)
+ comp2 = Compound(children=[Face.make_rect(1, 1)])
+ comp3 = Compound(children=[comp2])
+ self.assertEqual(len(comp3), 1)
+ self.assertTrue(isinstance(next(iter(comp3)), Compound))
+ comp4 = comp3.unwrap(fully=True)
+ self.assertTrue(isinstance(comp4, Face))
+
+ def test_get_top_level_shapes(self):
+ base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4))
+ fls = base_shapes.get_top_level_shapes()
+ self.assertTrue(isinstance(fls, ShapeList))
+ self.assertEqual(len(fls), 20)
+ self.assertTrue(all(isinstance(s, Solid) for s in fls))
+
+ b1 = Box(1, 1, 1).solid()
+ self.assertEqual(b1.get_top_level_shapes()[0], b1)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_direct_api_test_case.py b/tests/test_direct_api/test_direct_api_test_case.py
new file mode 100644
index 0000000..a165857
--- /dev/null
+++ b/tests/test_direct_api/test_direct_api_test_case.py
@@ -0,0 +1,60 @@
+"""
+build123d direct api tests
+
+name: test_direct_api_test_case.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 typing import Optional
+
+from build123d.geometry import Vector, VectorLike
+
+
+class DirectApiTestCase(unittest.TestCase):
+ def assertTupleAlmostEquals(
+ self,
+ first: tuple[float, ...],
+ second: tuple[float, ...],
+ places: int,
+ msg: str | None = None,
+ ):
+ """Check Tuples"""
+ self.assertEqual(len(second), len(first))
+ for i, j in zip(second, first):
+ self.assertAlmostEqual(i, j, places, msg=msg)
+
+ def assertVectorAlmostEquals(
+ self, first: Vector, second: VectorLike, places: int, msg: str | None = None
+ ):
+ second_vector = Vector(second)
+ self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg)
+ self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg)
+ self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py
new file mode 100644
index 0000000..da36172
--- /dev/null
+++ b/tests/test_direct_api/test_edge.py
@@ -0,0 +1,293 @@
+"""
+build123d direct api tests
+
+name: test_edge.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import unittest
+
+from build123d.build_enums import AngularDirection
+from build123d.geometry import Axis, Plane, Vector
+from build123d.objects_curve import CenterArc, EllipticalCenterArc
+from build123d.topology import Edge
+from tests.base_test import DirectApiTestCase
+
+
+class TestEdge(DirectApiTestCase):
+ def test_close(self):
+ self.assertAlmostEqual(
+ Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5
+ )
+ self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5)
+
+ def test_make_half_circle(self):
+ half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180)
+ self.assertVectorAlmostEquals(half_circle.start_point(), (1, 0, 0), 3)
+ self.assertVectorAlmostEquals(half_circle.end_point(), (-1, 0, 0), 3)
+
+ def test_make_half_circle2(self):
+ half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90)
+ self.assertVectorAlmostEquals(half_circle.start_point(), (0, -1, 0), 3)
+ self.assertVectorAlmostEquals(half_circle.end_point(), (0, 1, 0), 3)
+
+ def test_make_clockwise_half_circle(self):
+ half_circle = Edge.make_circle(
+ radius=1,
+ start_angle=180,
+ end_angle=0,
+ angular_direction=AngularDirection.CLOCKWISE,
+ )
+ self.assertVectorAlmostEquals(half_circle.end_point(), (1, 0, 0), 3)
+ self.assertVectorAlmostEquals(half_circle.start_point(), (-1, 0, 0), 3)
+
+ def test_make_clockwise_half_circle2(self):
+ half_circle = Edge.make_circle(
+ radius=1,
+ start_angle=90,
+ end_angle=-90,
+ angular_direction=AngularDirection.CLOCKWISE,
+ )
+ self.assertVectorAlmostEquals(half_circle.start_point(), (0, 1, 0), 3)
+ self.assertVectorAlmostEquals(half_circle.end_point(), (0, -1, 0), 3)
+
+ def test_arc_center(self):
+ self.assertVectorAlmostEquals(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5)
+ with self.assertRaises(ValueError):
+ Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center
+
+ def test_spline_with_parameters(self):
+ spline = Edge.make_spline(
+ points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0]
+ )
+ self.assertVectorAlmostEquals(spline.end_point(), (2, 0, 0), 5)
+ with self.assertRaises(ValueError):
+ Edge.make_spline(
+ points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0]
+ )
+ with self.assertRaises(ValueError):
+ Edge.make_spline(
+ points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], tangents=[(1, 1, 0)]
+ )
+
+ def test_spline_approx(self):
+ spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)])
+ self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5)
+ spline = Edge.make_spline_approx(
+ [(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0)
+ )
+ self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5)
+
+ def test_distribute_locations(self):
+ line = Edge.make_line((0, 0, 0), (10, 0, 0))
+ locs = line.distribute_locations(3)
+ for i, x in enumerate([0, 5, 10]):
+ self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5)
+ self.assertVectorAlmostEquals(locs[0].orientation, (0, 90, 180), 5)
+
+ locs = line.distribute_locations(3, positions_only=True)
+ for i, x in enumerate([0, 5, 10]):
+ self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5)
+ self.assertVectorAlmostEquals(locs[0].orientation, (0, 0, 0), 5)
+
+ def test_to_wire(self):
+ edge = Edge.make_line((0, 0, 0), (1, 1, 1))
+ for end in [0, 1]:
+ self.assertVectorAlmostEquals(
+ edge.position_at(end),
+ edge.to_wire().position_at(end),
+ 5,
+ )
+
+ def test_arc_center2(self):
+ edges = [
+ Edge.make_circle(1, plane=Plane((1, 2, 3)), end_angle=30),
+ Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30),
+ ]
+ for edge in edges:
+ self.assertVectorAlmostEquals(edge.arc_center, (1, 2, 3), 5)
+ with self.assertRaises(ValueError):
+ Edge.make_line((0, 0), (1, 1)).arc_center
+
+ def test_find_intersection_points(self):
+ circle = Edge.make_circle(1)
+ line = Edge.make_line((0, -2), (0, 2))
+ crosses = circle.find_intersection_points(line)
+ for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses):
+ self.assertVectorAlmostEquals(actual, target, 5)
+
+ with self.assertRaises(ValueError):
+ circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
+ with self.assertRaises(ValueError):
+ circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
+
+ self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)])
+ self.assertVectorAlmostEquals(
+ self_intersect.find_intersection_points()[0],
+ (-2.6861636507066047, 0, 0),
+ 5,
+ )
+ line = Edge.make_line((1, -2), (1, 2))
+ crosses = line.find_intersection_points(Axis.X)
+ self.assertVectorAlmostEquals(crosses[0], (1, 0, 0), 5)
+
+ with self.assertRaises(ValueError):
+ line.find_intersection_points(Plane.YZ)
+
+ # def test_intersections_tolerance(self):
+ # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
+ # l1 = Edge.make_line((1, 0), (2, 0))
+ # i1 = l1.intersect(*r1)
+
+ # r2 = Rectangle(2, 2).edges()
+ # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
+ # i2 = l2.intersect(*r2)
+
+ # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
+
+ def test_trim(self):
+ line = Edge.make_line((-2, 0), (2, 0))
+ self.assertVectorAlmostEquals(
+ line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5
+ )
+ self.assertVectorAlmostEquals(
+ line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5
+ )
+ with self.assertRaises(ValueError):
+ line.trim(0.75, 0.25)
+
+ def test_trim_to_length(self):
+
+ e1 = Edge.make_line((0, 0), (10, 10))
+ e1_trim = e1.trim_to_length(0.0, 10)
+ self.assertAlmostEqual(e1_trim.length, 10, 5)
+
+ e2 = Edge.make_circle(10, start_angle=0, end_angle=90)
+ e2_trim = e2.trim_to_length(0.5, 1)
+ self.assertAlmostEqual(e2_trim.length, 1, 5)
+ self.assertVectorAlmostEquals(
+ e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5
+ )
+
+ e3 = Edge.make_spline(
+ [(0, 10, 0), (-4, 5, 2), (0, 0, 0)], tangents=[(-1, 0), (1, 0)]
+ )
+ e3_trim = e3.trim_to_length(0, 7)
+ self.assertAlmostEqual(e3_trim.length, 7, 5)
+
+ a4 = Axis((0, 0, 0), (1, 1, 1))
+ e4_trim = Edge(a4).trim_to_length(0.5, 2)
+ self.assertAlmostEqual(e4_trim.length, 2, 5)
+
+ def test_bezier(self):
+ with self.assertRaises(ValueError):
+ Edge.make_bezier((1, 1))
+ cntl_pnts = [(1, 2, 3)] * 30
+ with self.assertRaises(ValueError):
+ Edge.make_bezier(*cntl_pnts)
+ with self.assertRaises(ValueError):
+ Edge.make_bezier((0, 0, 0), (1, 1, 1), weights=[1.0])
+
+ bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0))
+ bbox = bezier.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(bbox.max, (1, 0.75, 0), 5)
+
+ def test_mid_way(self):
+ mid = Edge.make_mid_way(
+ Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25
+ )
+ self.assertVectorAlmostEquals(mid.position_at(0), (0.25, 0, 0), 5)
+ self.assertVectorAlmostEquals(mid.position_at(1), (0.25, 1, 0), 5)
+
+ def test_distribute_locations2(self):
+ with self.assertRaises(ValueError):
+ Edge.make_circle(1).distribute_locations(1)
+
+ locs = Edge.make_circle(1).distribute_locations(5, positions_only=True)
+ for i, loc in enumerate(locs):
+ self.assertVectorAlmostEquals(
+ loc.position,
+ Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(),
+ 5,
+ )
+ self.assertVectorAlmostEquals(loc.orientation, (0, 0, 0), 5)
+
+ def test_find_tangent(self):
+ circle = Edge.make_circle(1)
+ parm = circle.find_tangent(135)[0]
+ self.assertVectorAlmostEquals(
+ circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
+ )
+ line = Edge.make_line((0, 0), (1, 1))
+ parm = line.find_tangent(45)[0]
+ self.assertAlmostEqual(parm, 0, 5)
+ parm = line.find_tangent(0)
+ self.assertEqual(len(parm), 0)
+
+ def test_param_at_point(self):
+ u = Edge.make_circle(1).param_at_point((0, 1))
+ self.assertAlmostEqual(u, 0.25, 5)
+
+ u = 0.3
+ edge = Edge.make_line((0, 0), (34, 56))
+ pnt = edge.position_at(u)
+ self.assertAlmostEqual(edge.param_at_point(pnt), u, 5)
+
+ ca = CenterArc((0, 0), 1, -200, 220).edge()
+ for u in [0.3, 1.0]:
+ pnt = ca.position_at(u)
+ self.assertAlmostEqual(ca.param_at_point(pnt), u, 5)
+
+ ea = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270).edge()
+ for u in [0.3, 0.9]:
+ pnt = ea.position_at(u)
+ self.assertAlmostEqual(ea.param_at_point(pnt), u, 5)
+
+ with self.assertRaises(ValueError):
+ edge.param_at_point((-1, 1))
+
+ def test_conical_helix(self):
+ helix = Edge.make_helix(1, 4, 1, normal=(-1, 0, 0), angle=10, lefthand=True)
+ self.assertAlmostEqual(helix.bounding_box().min.X, -4, 5)
+
+ def test_reverse(self):
+ e1 = Edge.make_line((0, 0), (1, 1))
+ self.assertVectorAlmostEquals(e1 @ 0.1, (0.1, 0.1, 0), 5)
+ self.assertVectorAlmostEquals(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5)
+
+ e2 = Edge.make_circle(1, start_angle=0, end_angle=180)
+ e2r = e2.reversed()
+ self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5)
+
+ def test_init(self):
+ with self.assertRaises(TypeError):
+ Edge(direction=(1, 0, 0))
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
new file mode 100644
index 0000000..45f8da5
--- /dev/null
+++ b/tests/test_direct_api/test_face.py
@@ -0,0 +1,459 @@
+"""
+build123d direct api tests
+
+name: test_face.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import os
+import platform
+import random
+import unittest
+
+from build123d.build_common import Locations
+from build123d.build_enums import Align, CenterOf, GeomType
+from build123d.build_line import BuildLine
+from build123d.build_part import BuildPart
+from build123d.build_sketch import BuildSketch
+from build123d.exporters3d import export_stl
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.importers import import_stl
+from build123d.objects_curve import Polyline
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import Rectangle, RegularPolygon
+from build123d.operations_generic import fillet
+from build123d.operations_part import extrude
+from build123d.operations_sketch import make_face
+from build123d.topology import Edge, Face, Solid, Wire
+from tests.base_test import DirectApiTestCase
+
+
+class TestFace(DirectApiTestCase):
+ def test_make_surface_from_curves(self):
+ bottom_edge = Edge.make_circle(radius=1, end_angle=90)
+ top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90)
+ curved = Face.make_surface_from_curves(bottom_edge, top_edge)
+ self.assertTrue(curved.is_valid())
+ self.assertAlmostEqual(curved.area, math.pi / 2, 5)
+ self.assertVectorAlmostEquals(
+ curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
+ )
+
+ bottom_wire = Wire.make_circle(1)
+ top_wire = Wire.make_circle(1, Plane((0, 0, 1)))
+ curved = Face.make_surface_from_curves(bottom_wire, top_wire)
+ self.assertTrue(curved.is_valid())
+ self.assertAlmostEqual(curved.area, 2 * math.pi, 5)
+
+ def test_center(self):
+ test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)]))
+ self.assertVectorAlmostEquals(
+ test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1
+ )
+ self.assertVectorAlmostEquals(
+ test_face.center(CenterOf.BOUNDING_BOX),
+ (0.5, 0.5, 0),
+ 5,
+ )
+
+ def test_face_volume(self):
+ rect = Face.make_rect(1, 1)
+ self.assertAlmostEqual(rect.volume, 0, 5)
+
+ def test_chamfer_2d(self):
+ test_face = Face.make_rect(10, 10)
+ test_face = test_face.chamfer_2d(
+ distance=1, distance2=2, vertices=test_face.vertices()
+ )
+ self.assertAlmostEqual(test_face.area, 100 - 4 * 0.5 * 1 * 2)
+
+ def test_chamfer_2d_reference(self):
+ test_face = Face.make_rect(10, 10)
+ edge = test_face.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ test_face = test_face.chamfer_2d(
+ distance=1, distance2=2, vertices=[vertex], edge=edge
+ )
+ self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 9)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 8)
+
+ def test_chamfer_2d_reference_inverted(self):
+ test_face = Face.make_rect(10, 10)
+ edge = test_face.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ test_face = test_face.chamfer_2d(
+ distance=2, distance2=1, vertices=[vertex], edge=edge
+ )
+ self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 8)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 9)
+
+ def test_chamfer_2d_error_checking(self):
+ with self.assertRaises(ValueError):
+ test_face = Face.make_rect(10, 10)
+ edge = test_face.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ other_edge = test_face.edges().sort_by(Axis.Y)[-1]
+ test_face = test_face.chamfer_2d(
+ distance=1, distance2=2, vertices=[vertex], edge=other_edge
+ )
+
+ def test_make_rect(self):
+ test_face = Face.make_plane()
+ self.assertVectorAlmostEquals(test_face.normal_at(), (0, 0, 1), 5)
+
+ def test_length_width(self):
+ test_face = Face.make_rect(8, 10, Plane.XZ)
+ self.assertAlmostEqual(test_face.length, 8, 5)
+ self.assertAlmostEqual(test_face.width, 10, 5)
+
+ def test_geometry(self):
+ box = Solid.make_box(1, 1, 2)
+ self.assertEqual(box.faces().sort_by(Axis.Z).last.geometry, "SQUARE")
+ self.assertEqual(box.faces().sort_by(Axis.Y).last.geometry, "RECTANGLE")
+ with BuildPart() as test:
+ with BuildSketch():
+ RegularPolygon(1, 3)
+ extrude(amount=1)
+ self.assertEqual(test.faces().sort_by(Axis.Z).last.geometry, "POLYGON")
+
+ def test_is_planar(self):
+ self.assertTrue(Face.make_rect(1, 1).is_planar)
+ self.assertFalse(
+ Solid.make_cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0].is_planar
+ )
+ # Some of these faces have geom_type BSPLINE but are planar
+ mount = Solid.make_loft(
+ [
+ Rectangle((1 + 16 + 4), 20, align=(Align.MIN, Align.CENTER)).wire(),
+ Pos(1, 0, 4)
+ * Rectangle(16, 20, align=(Align.MIN, Align.CENTER)).wire(),
+ ],
+ )
+ self.assertTrue(all(f.is_planar for f in mount.faces()))
+
+ def test_negate(self):
+ square = Face.make_rect(1, 1)
+ self.assertVectorAlmostEquals(square.normal_at(), (0, 0, 1), 5)
+ flipped_square = -square
+ self.assertVectorAlmostEquals(flipped_square.normal_at(), (0, 0, -1), 5)
+
+ def test_offset(self):
+ bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-1, -1, 5), 5)
+ self.assertVectorAlmostEquals(bbox.max, (1, 1, 5), 5)
+
+ def test_make_from_wires(self):
+ outer = Wire.make_circle(10)
+ inners = [
+ Wire.make_circle(1).locate(Location((-2, 2, 0))),
+ Wire.make_circle(1).locate(Location((2, 2, 0))),
+ ]
+ happy = Face(outer, inners)
+ self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5)
+
+ outer = Edge.make_circle(10, end_angle=180).to_wire()
+ with self.assertRaises(ValueError):
+ Face(outer, inners)
+ with self.assertRaises(ValueError):
+ Face(Wire.make_circle(10, Plane.XZ), inners)
+
+ outer = Wire.make_circle(10)
+ inners = [
+ Wire.make_circle(1).locate(Location((-2, 2, 0))),
+ Edge.make_circle(1, end_angle=180).to_wire().locate(Location((2, 2, 0))),
+ ]
+ with self.assertRaises(ValueError):
+ Face(outer, inners)
+
+ def test_sew_faces(self):
+ patches = [
+ Face.make_rect(1, 1, Plane((x, y, z)))
+ for x in range(2)
+ for y in range(2)
+ for z in range(3)
+ ]
+ random.shuffle(patches)
+ sheets = Face.sew_faces(patches)
+ self.assertEqual(len(sheets), 3)
+ self.assertEqual(len(sheets[0]), 4)
+ self.assertTrue(isinstance(sheets[0][0], Face))
+
+ def test_surface_from_array_of_points(self):
+ pnts = [
+ [
+ Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
+ for x in range(11)
+ ]
+ for y in range(11)
+ ]
+ surface = Face.make_surface_from_array_of_points(pnts)
+ bbox = surface.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (0, 0, -1), 3)
+ self.assertVectorAlmostEquals(bbox.max, (10, 10, 2), 2)
+
+ def test_bezier_surface(self):
+ points = [
+ [
+ (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
+ for x in range(-1, 2)
+ ]
+ for y in range(-1, 2)
+ ]
+ surface = Face.make_bezier_surface(points)
+ bbox = surface.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3)
+ self.assertVectorAlmostEquals(bbox.max, (+1, +1, +1), 1)
+ self.assertLess(bbox.max.Z, 1.0)
+
+ weights = [
+ [2 if x == 0 or y == 0 else 1 for x in range(-1, 2)] for y in range(-1, 2)
+ ]
+ surface = Face.make_bezier_surface(points, weights)
+ bbox = surface.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3)
+ self.assertGreater(bbox.max.Z, 1.0)
+
+ too_many_points = [
+ [
+ (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
+ for x in range(-1, 27)
+ ]
+ for y in range(-1, 27)
+ ]
+
+ with self.assertRaises(ValueError):
+ Face.make_bezier_surface([[(0, 0)]])
+ with self.assertRaises(ValueError):
+ Face.make_bezier_surface(points, [[1, 1], [1, 1]])
+ with self.assertRaises(ValueError):
+ Face.make_bezier_surface(too_many_points)
+
+ def test_thicken(self):
+ pnts = [
+ [
+ Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
+ for x in range(11)
+ ]
+ for y in range(11)
+ ]
+ surface = Face.make_surface_from_array_of_points(pnts)
+ solid = Solid.thicken(surface, 1)
+ self.assertAlmostEqual(solid.volume, 101.59, 2)
+
+ square = Face.make_rect(10, 10)
+ bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-5, -5, -1), 5)
+ self.assertVectorAlmostEquals(bbox.max, (5, 5, 0), 5)
+
+ def test_make_holes(self):
+ radius = 10
+ circumference = 2 * math.pi * radius
+ hex_diagonal = 4 * (circumference / 10) / 3
+ cylinder = Solid.make_cylinder(radius, hex_diagonal * 5)
+ cylinder_wall: Face = cylinder.faces().filter_by(GeomType.PLANE, reverse=True)[
+ 0
+ ]
+ with BuildSketch(Plane.XZ.offset(radius)) as hex:
+ with Locations((0, hex_diagonal)):
+ RegularPolygon(
+ hex_diagonal * 0.4, 6, align=(Align.CENTER, Align.CENTER)
+ )
+ hex_wire_vertical: Wire = hex.sketch.faces()[0].outer_wire()
+
+ projected_wire: Wire = hex_wire_vertical.project_to_shape(
+ target_object=cylinder, center=(0, 0, hex_wire_vertical.center().Z)
+ )[0]
+ projected_wires = [
+ projected_wire.rotate(Axis.Z, -90 + i * 360 / 10).translate(
+ (0, 0, (j + (i % 2) / 2) * hex_diagonal)
+ )
+ for i in range(5)
+ for j in range(4 - i % 2)
+ ]
+ cylinder_walls_with_holes = cylinder_wall.make_holes(projected_wires)
+ self.assertTrue(cylinder_walls_with_holes.is_valid())
+ self.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area)
+
+ def test_is_inside(self):
+ square = Face.make_rect(10, 10)
+ self.assertTrue(square.is_inside((1, 1)))
+ self.assertFalse(square.is_inside((20, 1)))
+
+ def test_import_stl(self):
+ torus = Solid.make_torus(10, 1)
+ # exporter = Mesher()
+ # exporter.add_shape(torus)
+ # exporter.write("test_torus.stl")
+ export_stl(torus, "test_torus.stl")
+ imported_torus = import_stl("test_torus.stl")
+ # The torus from stl is tessellated therefore the areas will only be close
+ self.assertAlmostEqual(imported_torus.area, torus.area, 0)
+ os.remove("test_torus.stl")
+
+ def test_is_coplanar(self):
+ square = Face.make_rect(1, 1, plane=Plane.XZ)
+ self.assertTrue(square.is_coplanar(Plane.XZ))
+ self.assertTrue((-square).is_coplanar(Plane.XZ))
+ self.assertFalse(square.is_coplanar(Plane.XY))
+ surface: Face = Solid.make_sphere(1).faces()[0]
+ self.assertFalse(surface.is_coplanar(Plane.XY))
+
+ def test_center_location(self):
+ square = Face.make_rect(1, 1, plane=Plane.XZ)
+ cl = square.center_location
+ self.assertVectorAlmostEquals(cl.position, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(Plane(cl).z_dir, Plane.XZ.z_dir, 5)
+
+ def test_position_at(self):
+ square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1))
+ p = square.position_at(0.25, 0.75)
+ self.assertVectorAlmostEquals(p, (-0.5, -1.0, 0.5), 5)
+
+ def test_location_at(self):
+ bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0]
+ loc = bottom.location_at(0.5, 0.5)
+ self.assertVectorAlmostEquals(loc.position, (0.5, 1, 0), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (-180, 0, -180), 5)
+
+ front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0]
+ loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1))
+ self.assertVectorAlmostEquals(loc.position, (0.0, 1.0, 1.5), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (0, -90, 0), 5)
+
+ def test_make_surface(self):
+ corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
+ net_exterior = Wire(
+ [
+ Edge.make_line(corners[3], corners[1]),
+ Edge.make_line(corners[1], corners[0]),
+ Edge.make_line(corners[0], corners[2]),
+ Edge.make_three_point_arc(
+ corners[2],
+ (corners[2] + corners[3]) / 2 - Vector(0, 0, 3),
+ corners[3],
+ ),
+ ]
+ )
+ surface = Face.make_surface(
+ net_exterior,
+ surface_points=[Vector(0, 0, -5)],
+ )
+ hole_flat = Wire.make_circle(10)
+ hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0]
+ surface = Face.make_surface(
+ exterior=net_exterior,
+ surface_points=[Vector(0, 0, -5)],
+ interior_wires=[hole],
+ )
+ self.assertTrue(surface.is_valid())
+ self.assertEqual(surface.geom_type, GeomType.BSPLINE)
+ bbox = surface.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -5.113393280136395), 5)
+ self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5)
+
+ # With no surface point
+ surface = Face.make_surface(net_exterior)
+ bbox = surface.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -3), 5)
+ self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5)
+
+ # Exterior Edge
+ surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)])
+ bbox = surface.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-50, -50, -5), 5)
+ self.assertVectorAlmostEquals(bbox.max, (50, 50, 0), 5)
+
+ def test_make_surface_error_checking(self):
+ with self.assertRaises(ValueError):
+ Face.make_surface(Edge.make_line((0, 0), (1, 0)))
+
+ with self.assertRaises(RuntimeError):
+ Face.make_surface([Edge.make_line((0, 0), (1, 0))])
+
+ if platform.system() != "Darwin":
+ with self.assertRaises(RuntimeError):
+ Face.make_surface(
+ [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)]
+ )
+
+ with self.assertRaises(RuntimeError):
+ Face.make_surface(
+ [Edge.make_circle(50)],
+ interior_wires=[Wire.make_circle(5, Plane.XZ)],
+ )
+
+ def test_sweep(self):
+ edge = Edge.make_line((1, 0), (2, 0))
+ path = Wire.make_circle(1)
+ circle_with_hole = Face.sweep(edge, path)
+ self.assertTrue(isinstance(circle_with_hole, Face))
+ self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5)
+ with self.assertRaises(ValueError):
+ Face.sweep(edge, Polyline((0, 0), (0.1, 0), (0.2, 0.1)))
+
+ def test_to_arcs(self):
+ with BuildSketch() as bs:
+ with BuildLine() as bl:
+ Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0))
+ fillet(bl.vertices(), radius=0.1)
+ make_face()
+ smooth = bs.faces()[0]
+ fragmented = smooth.to_arcs()
+ self.assertLess(len(smooth.edges()), len(fragmented.edges()))
+
+ def test_outer_wire(self):
+ face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
+ self.assertAlmostEqual(face.outer_wire().length, 4, 5)
+
+ def test_wire(self):
+ face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
+ with self.assertWarns(UserWarning):
+ outer = face.wire()
+ self.assertAlmostEqual(outer.length, 4, 5)
+
+ def test_constructor(self):
+ with self.assertRaises(ValueError):
+ Face(bob="fred")
+
+ def test_normal_at(self):
+ face = Face.make_rect(1, 1)
+ self.assertVectorAlmostEquals(face.normal_at(0, 0), (0, 0, 1), 5)
+ self.assertVectorAlmostEquals(
+ face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5
+ )
+ with self.assertRaises(ValueError):
+ face.normal_at(0)
+ with self.assertRaises(ValueError):
+ face.normal_at(center=(0, 0))
+ face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
+ self.assertVectorAlmostEquals(face.normal_at(0, 1), (1, 0, 0), 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_functions.py b/tests/test_direct_api/test_functions.py
new file mode 100644
index 0000000..3f2bdf2
--- /dev/null
+++ b/tests/test_direct_api/test_functions.py
@@ -0,0 +1,107 @@
+"""
+build123d direct api tests
+
+name: test_functions.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import unittest
+
+from build123d.geometry import Plane, Vector
+from build123d.objects_part import Box
+from build123d.topology import (
+ Compound,
+ Face,
+ Solid,
+ edges_to_wires,
+ polar,
+ new_edges,
+ delta,
+ unwrap_topods_compound,
+)
+
+
+class TestFunctions(unittest.TestCase):
+ def test_edges_to_wires(self):
+ square_edges = Face.make_rect(1, 1).edges()
+ rectangle_edges = Face.make_rect(2, 1, Plane((5, 0))).edges()
+ wires = edges_to_wires(square_edges + rectangle_edges)
+ self.assertEqual(len(wires), 2)
+ self.assertAlmostEqual(wires[0].length, 4, 5)
+ self.assertAlmostEqual(wires[1].length, 6, 5)
+
+ def test_polar(self):
+ pnt = polar(1, 30)
+ self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5)
+ self.assertAlmostEqual(pnt[1], 0.5, 5)
+
+ def test_new_edges(self):
+ c = Solid.make_cylinder(1, 5)
+ s = Solid.make_sphere(2)
+ s_minus_c = s - c
+ seams = new_edges(c, s, combined=s_minus_c)
+ self.assertEqual(len(seams), 1)
+ self.assertAlmostEqual(seams[0].radius, 1, 5)
+
+ def test_delta(self):
+ cyl = Solid.make_cylinder(1, 5)
+ sph = Solid.make_sphere(2)
+ con = Solid.make_cone(2, 1, 2)
+ plug = delta([cyl, sph, con], [sph, con])
+ self.assertEqual(len(plug), 1)
+ self.assertEqual(plug[0], cyl)
+
+ def test_parse_intersect_args(self):
+
+ with self.assertRaises(TypeError):
+ Vector(1, 1, 1) & ("x", "y", "z")
+
+ def test_unwrap_topods_compound(self):
+ # Complex Compound
+ b1 = Box(1, 1, 1).solid()
+ b2 = Box(2, 2, 2).solid()
+ c1 = Compound([b1, b2])
+ c2 = Compound([b1, c1])
+ c3 = Compound([c2])
+ c4 = Compound([c3])
+ self.assertEqual(c4.wrapped.NbChildren(), 1)
+ c5 = Compound(unwrap_topods_compound(c4.wrapped, False))
+ self.assertEqual(c5.wrapped.NbChildren(), 2)
+
+ # unwrap fully
+ c0 = Compound([b1])
+ c1 = Compound([c0])
+ result = Compound.cast(unwrap_topods_compound(c1.wrapped, True))
+ self.assertTrue(isinstance(result, Solid))
+
+ # unwrap not fully
+ result = Compound.cast(unwrap_topods_compound(c1.wrapped, False))
+ self.assertTrue(isinstance(result, Compound))
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_group_by.py b/tests/test_direct_api/test_group_by.py
new file mode 100644
index 0000000..6d0ed3f
--- /dev/null
+++ b/tests/test_direct_api/test_group_by.py
@@ -0,0 +1,71 @@
+"""
+build123d direct api tests
+
+name: test_group_by.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 pprint
+import unittest
+
+from build123d.geometry import Axis
+from build123d.topology import Solid
+
+
+class TestGroupBy(unittest.TestCase):
+
+ def setUp(self):
+ # Ensure the class variable is in its default state before each test
+ self.v = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
+
+ def test_str(self):
+ self.assertEqual(
+ str(self.v),
+ f"""[[Vertex(0.0, 0.0, 0.0),
+ Vertex(0.0, 1.0, 0.0),
+ Vertex(1.0, 0.0, 0.0),
+ Vertex(1.0, 1.0, 0.0)],
+ [Vertex(0.0, 0.0, 1.0),
+ Vertex(0.0, 1.0, 1.0),
+ Vertex(1.0, 0.0, 1.0),
+ Vertex(1.0, 1.0, 1.0)]]""",
+ )
+
+ def test_repr(self):
+ self.assertEqual(
+ repr(self.v),
+ "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
+ )
+
+ def test_pp(self):
+ self.assertEqual(
+ pprint.pformat(self.v),
+ "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
+ )
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_import_export.py b/tests/test_direct_api/test_import_export.py
new file mode 100644
index 0000000..9dbabce
--- /dev/null
+++ b/tests/test_direct_api/test_import_export.py
@@ -0,0 +1,70 @@
+"""
+build123d direct api tests
+
+name: test_import_export.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 os
+import unittest
+
+from build123d.exporters3d import export_brep, export_step
+from build123d.importers import import_brep, import_step, import_stl
+from build123d.mesher import Mesher
+from build123d.topology import Solid
+from tests.base_test import DirectApiTestCase
+
+
+class TestImportExport(DirectApiTestCase):
+ def test_import_export(self):
+ original_box = Solid.make_box(1, 1, 1)
+ export_step(original_box, "test_box.step")
+ step_box = import_step("test_box.step")
+ self.assertTrue(step_box.is_valid())
+ self.assertAlmostEqual(step_box.volume, 1, 5)
+ export_brep(step_box, "test_box.brep")
+ brep_box = import_brep("test_box.brep")
+ self.assertTrue(brep_box.is_valid())
+ self.assertAlmostEqual(brep_box.volume, 1, 5)
+ os.remove("test_box.step")
+ os.remove("test_box.brep")
+ with self.assertRaises(FileNotFoundError):
+ step_box = import_step("test_box.step")
+
+ def test_import_stl(self):
+ # export solid
+ original_box = Solid.make_box(1, 2, 3)
+ exporter = Mesher()
+ exporter.add_shape(original_box)
+ exporter.write("test.stl")
+
+ # import as face
+ stl_box = import_stl("test.stl")
+ self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_jupyter.py b/tests/test_direct_api/test_jupyter.py
new file mode 100644
index 0000000..870be3f
--- /dev/null
+++ b/tests/test_direct_api/test_jupyter.py
@@ -0,0 +1,60 @@
+"""
+build123d direct api tests
+
+name: test_jupyter.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.geometry import Vector
+from build123d.jupyter_tools import to_vtkpoly_string, display
+from build123d.topology import Solid
+from tests.base_test import DirectApiTestCase
+
+
+class TestJupyter(DirectApiTestCase):
+ def test_repr_javascript(self):
+ shape = Solid.make_box(1, 1, 1)
+
+ # Test no exception on rendering to js
+ js1 = shape._repr_javascript_()
+
+ assert "function render" in js1
+
+ def test_display_error(self):
+ with self.assertRaises(AttributeError):
+ display(Vector())
+
+ with self.assertRaises(ValueError):
+ to_vtkpoly_string("invalid")
+
+ with self.assertRaises(ValueError):
+ display("invalid")
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py
new file mode 100644
index 0000000..662d28f
--- /dev/null
+++ b/tests/test_direct_api/test_location.py
@@ -0,0 +1,392 @@
+"""
+build123d direct api tests
+
+name: test_location.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 copy
+import json
+import math
+import os
+import unittest
+from random import uniform
+
+from OCP.gp import (
+ gp_Ax1,
+ gp_Dir,
+ gp_EulerSequence,
+ gp_Pnt,
+ gp_Quaternion,
+ gp_Trsf,
+ gp_Vec,
+)
+from build123d.build_common import GridLocations
+from build123d.build_enums import Extrinsic, Intrinsic
+from build123d.geometry import Axis, Location, LocationEncoder, Plane, Pos, Vector
+from build123d.topology import Edge, Solid, Vertex
+from tests.base_test import DirectApiTestCase, RAD2DEG, AlwaysEqual
+
+
+class TestLocation(DirectApiTestCase):
+ def test_location(self):
+ loc0 = Location()
+ T = loc0.wrapped.Transformation().TranslationPart()
+ self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 0), 6)
+ angle = loc0.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ self.assertAlmostEqual(0, angle)
+
+ # Tuple
+ loc0 = Location((0, 0, 1))
+
+ T = loc0.wrapped.Transformation().TranslationPart()
+ self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+
+ # List
+ loc0 = Location([0, 0, 1])
+
+ T = loc0.wrapped.Transformation().TranslationPart()
+ self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+
+ # Vector
+ loc1 = Location(Vector(0, 0, 1))
+
+ T = loc1.wrapped.Transformation().TranslationPart()
+ self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+
+ # rotation + translation
+ loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45)
+
+ angle = loc2.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ self.assertAlmostEqual(45, angle)
+
+ # gp_Trsf
+ T = gp_Trsf()
+ T.SetTranslation(gp_Vec(0, 0, 1))
+ loc3 = Location(T)
+
+ self.assertEqual(
+ loc1.wrapped.Transformation().TranslationPart().Z(),
+ loc3.wrapped.Transformation().TranslationPart().Z(),
+ )
+
+ # Test creation from the OCP.gp.gp_Trsf object
+ loc4 = Location(gp_Trsf())
+ self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 0), 7)
+ self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7)
+
+ # Test creation from Plane and Vector
+ loc4 = Location(Plane.XY, (0, 0, 1))
+ self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 1), 7)
+ self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7)
+
+ # Test composition
+ loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15)
+
+ loc5 = loc1 * loc4
+ loc6 = loc4 * loc4
+ loc7 = loc4**2
+
+ T = loc5.wrapped.Transformation().TranslationPart()
+ self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+
+ angle5 = (
+ loc5.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ )
+ self.assertAlmostEqual(15, angle5)
+
+ angle6 = (
+ loc6.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ )
+ self.assertAlmostEqual(30, angle6)
+
+ angle7 = (
+ loc7.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ )
+ self.assertAlmostEqual(30, angle7)
+
+ # Test error handling on creation
+ with self.assertRaises(TypeError):
+ Location("xy_plane")
+
+ # Test that the computed rotation matrix and intrinsic euler angles return the same
+
+ about_x = uniform(-2 * math.pi, 2 * math.pi)
+ about_y = uniform(-2 * math.pi, 2 * math.pi)
+ about_z = uniform(-2 * math.pi, 2 * math.pi)
+
+ rot_x = gp_Trsf()
+ rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), about_x)
+ rot_y = gp_Trsf()
+ rot_y.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), about_y)
+ rot_z = gp_Trsf()
+ rot_z.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), about_z)
+ loc1 = Location(rot_x * rot_y * rot_z)
+
+ q = gp_Quaternion()
+ q.SetEulerAngles(
+ gp_EulerSequence.gp_Intrinsic_XYZ,
+ about_x,
+ about_y,
+ about_z,
+ )
+ t = gp_Trsf()
+ t.SetRotationPart(q)
+ loc2 = Location(t)
+
+ self.assertTupleAlmostEquals(loc1.to_tuple()[0], loc2.to_tuple()[0], 6)
+ self.assertTupleAlmostEquals(loc1.to_tuple()[1], loc2.to_tuple()[1], 6)
+
+ loc1 = Location((1, 2), 34)
+ self.assertTupleAlmostEquals(loc1.to_tuple()[0], (1, 2, 0), 6)
+ self.assertTupleAlmostEquals(loc1.to_tuple()[1], (0, 0, 34), 6)
+
+ rot_angles = (-115.00, 35.00, -135.00)
+ loc2 = Location((1, 2, 3), rot_angles)
+ self.assertTupleAlmostEquals(loc2.to_tuple()[0], (1, 2, 3), 6)
+ self.assertTupleAlmostEquals(loc2.to_tuple()[1], rot_angles, 6)
+
+ loc3 = Location(loc2)
+ self.assertTupleAlmostEquals(loc3.to_tuple()[0], (1, 2, 3), 6)
+ self.assertTupleAlmostEquals(loc3.to_tuple()[1], rot_angles, 6)
+
+ def test_location_parameters(self):
+ loc = Location((10, 20, 30))
+ self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
+
+ loc = Location((10, 20, 30), (10, 20, 30))
+ self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
+
+ loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ)
+ self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
+
+ loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX)
+ self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
+
+ with self.assertRaises(TypeError):
+ Location(x=10)
+
+ with self.assertRaises(TypeError):
+ Location((10, 20, 30), (30, 20, 10), (10, 20, 30))
+
+ with self.assertRaises(TypeError):
+ Location(Intrinsic.XYZ)
+
+ def test_location_repr_and_str(self):
+ self.assertEqual(
+ repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))"
+ )
+ self.assertEqual(
+ str(Location()),
+ "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))",
+ )
+ loc = Location((1, 2, 3), (33, 45, 67))
+ self.assertEqual(
+ str(loc),
+ "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))",
+ )
+
+ def test_location_inverted(self):
+ loc = Location(Plane.XZ)
+ self.assertVectorAlmostEquals(loc.inverse().orientation, (-90, 0, 0), 6)
+
+ def test_set_position(self):
+ loc = Location(Plane.XZ)
+ loc.position = (1, 2, 3)
+ self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6)
+ self.assertVectorAlmostEquals(loc.orientation, (90, 0, 0), 6)
+
+ def test_set_orientation(self):
+ loc = Location((1, 2, 3), (90, 0, 0))
+ loc.orientation = (-90, 0, 0)
+ self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6)
+ self.assertVectorAlmostEquals(loc.orientation, (-90, 0, 0), 6)
+
+ def test_copy(self):
+ loc1 = Location((1, 2, 3), (90, 45, 22.5))
+ loc2 = copy.copy(loc1)
+ loc3 = copy.deepcopy(loc1)
+ self.assertVectorAlmostEquals(loc1.position, loc2.position.to_tuple(), 6)
+ self.assertVectorAlmostEquals(loc1.orientation, loc2.orientation.to_tuple(), 6)
+ self.assertVectorAlmostEquals(loc1.position, loc3.position.to_tuple(), 6)
+ self.assertVectorAlmostEquals(loc1.orientation, loc3.orientation.to_tuple(), 6)
+
+ def test_to_axis(self):
+ axis = Location((1, 2, 3), (-90, 0, 0)).to_axis()
+ self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 6)
+ self.assertVectorAlmostEquals(axis.direction, (0, 1, 0), 6)
+
+ def test_equal(self):
+ loc = Location((1, 2, 3), (4, 5, 6))
+ same = Location((1, 2, 3), (4, 5, 6))
+
+ self.assertEqual(loc, same)
+ self.assertEqual(loc, AlwaysEqual())
+
+ def test_not_equal(self):
+ loc = Location((1, 2, 3), (40, 50, 60))
+ diff_position = Location((3, 2, 1), (40, 50, 60))
+ diff_orientation = Location((1, 2, 3), (60, 50, 40))
+
+ self.assertNotEqual(loc, diff_position)
+ self.assertNotEqual(loc, diff_orientation)
+ self.assertNotEqual(loc, object())
+
+ def test_neg(self):
+ loc = Location((1, 2, 3), (0, 35, 127))
+ n_loc = -loc
+ self.assertVectorAlmostEquals(n_loc.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(n_loc.orientation, (180, -35, -127), 5)
+
+ def test_mult_iterable(self):
+ locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1)
+ self.assertVectorAlmostEquals(locs[0].position, (-1, 2, 0), 5)
+ self.assertVectorAlmostEquals(locs[1].position, (3, 2, 0), 5)
+
+ def test_as_json(self):
+ data_dict = {
+ "part1": {
+ "joint_one": Location((1, 2, 3), (4, 5, 6)),
+ "joint_two": Location((7, 8, 9), (10, 11, 12)),
+ },
+ "part2": {
+ "joint_one": Location((13, 14, 15), (16, 17, 18)),
+ "joint_two": Location((19, 20, 21), (22, 23, 24)),
+ },
+ }
+
+ # Serializing json with custom Location encoder
+ json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder)
+
+ # Writing to sample.json
+ with open("sample.json", "w") as outfile:
+ outfile.write(json_object)
+
+ # Reading from sample.json
+ with open("sample.json") as infile:
+ read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
+
+ # Validate locations
+ for key, value in read_json.items():
+ for k, v in value.items():
+ if key == "part1" and k == "joint_one":
+ self.assertVectorAlmostEquals(v.position, (1, 2, 3), 5)
+ elif key == "part1" and k == "joint_two":
+ self.assertVectorAlmostEquals(v.position, (7, 8, 9), 5)
+ elif key == "part2" and k == "joint_one":
+ self.assertVectorAlmostEquals(v.position, (13, 14, 15), 5)
+ elif key == "part2" and k == "joint_two":
+ self.assertVectorAlmostEquals(v.position, (19, 20, 21), 5)
+ else:
+ self.assertTrue(False)
+ os.remove("sample.json")
+
+ def test_intersection(self):
+ e = Edge.make_line((0, 0, 0), (1, 1, 1))
+ l0 = e.location_at(0)
+ l1 = e.location_at(1)
+ self.assertIsNone(l0 & l1)
+ self.assertEqual(l1 & l1, l1)
+
+ i = l1 & Vector(1, 1, 1)
+ self.assertTrue(isinstance(i, Vector))
+ self.assertVectorAlmostEquals(i, (1, 1, 1), 5)
+
+ i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1))
+ self.assertTrue(isinstance(i, Location))
+ self.assertEqual(i, l1)
+
+ p = Plane.XY.rotated((45, 0, 0)).shift_origin((1, 0, 0))
+ l = Location((1, 0, 0), (1, 0, 0), 45)
+ i = l & p
+ self.assertTrue(isinstance(i, Location))
+ self.assertVectorAlmostEquals(i.position, (1, 0, 0), 5)
+ self.assertVectorAlmostEquals(i.orientation, l.orientation, 5)
+
+ b = Solid.make_box(1, 1, 1)
+ l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45)
+ i = (l & b).vertex()
+ self.assertTrue(isinstance(i, Vertex))
+ self.assertVectorAlmostEquals(Vector(i), (0.5, 0.5, 0.5), 5)
+
+ e1 = Edge.make_line((0, -1), (2, 1))
+ e2 = Edge.make_line((0, 1), (2, -1))
+ e3 = Edge.make_line((0, 0), (2, 0))
+
+ i = e1.intersect(e2, e3)
+ self.assertTrue(isinstance(i, Vertex))
+ self.assertVectorAlmostEquals(i, (1, 0, 0), 5)
+
+ e4 = Edge.make_line((1, -1), (1, 1))
+ e5 = Edge.make_line((2, -1), (2, 1))
+ i = e3.intersect(e4, e5)
+ self.assertIsNone(i)
+
+ self.assertIsNone(b.intersect(b.moved(Pos(X=10))))
+
+ # Look for common vertices
+ e1 = Edge.make_line((0, 0), (1, 0))
+ e2 = Edge.make_line((1, 0), (1, 1))
+ e3 = Edge.make_line((1, 0), (2, 0))
+ i = e1.intersect(e2)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+ i = e1.intersect(e3)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+
+ # Intersect with plane
+ e1 = Edge.make_line((0, 0), (2, 0))
+ p1 = Plane.YZ.offset(1)
+ i = e1.intersect(p1)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+
+ e2 = Edge.make_line(p1.origin, p1.origin + 2 * p1.x_dir)
+ i = e2.intersect(p1)
+ self.assertEqual(len(i.vertices()), 2)
+ self.assertEqual(len(i.edges()), 1)
+ self.assertAlmostEqual(i.edge().length, 2, 5)
+
+ with self.assertRaises(ValueError):
+ e1.intersect("line")
+
+ def test_pos(self):
+ with self.assertRaises(TypeError):
+ Pos(0, "foo")
+ self.assertEqual(Pos(1, 2, 3).position, Vector(1, 2, 3))
+ self.assertEqual(Pos((1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(v=(1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(X=1, Y=2, Z=3).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(Vector(1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3))
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_matrix.py b/tests/test_direct_api/test_matrix.py
new file mode 100644
index 0000000..23d9fbc
--- /dev/null
+++ b/tests/test_direct_api/test_matrix.py
@@ -0,0 +1,197 @@
+"""
+build123d direct api tests
+
+name: test_matrix.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 copy
+import math
+import unittest
+
+from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf
+from build123d.geometry import Axis, Matrix, Vector
+from tests.base_test import DirectApiTestCase, DEG2RAD
+
+
+class TestMatrix(DirectApiTestCase):
+ def test_matrix_creation_and_access(self):
+ def matrix_vals(m):
+ return [[m[r, c] for c in range(4)] for r in range(4)]
+
+ # default constructor creates a 4x4 identity matrix
+ m = Matrix()
+ identity = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ self.assertEqual(identity, matrix_vals(m))
+
+ vals4x4 = [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ vals4x4_tuple = tuple(tuple(r) for r in vals4x4)
+
+ # test constructor with 16-value input
+ m = Matrix(vals4x4)
+ self.assertEqual(vals4x4, matrix_vals(m))
+ m = Matrix(vals4x4_tuple)
+ self.assertEqual(vals4x4, matrix_vals(m))
+
+ # test constructor with 12-value input (the last 4 are an implied
+ # [0,0,0,1])
+ m = Matrix(vals4x4[:3])
+ self.assertEqual(vals4x4, matrix_vals(m))
+ m = Matrix(vals4x4_tuple[:3])
+ self.assertEqual(vals4x4, matrix_vals(m))
+
+ # Test 16-value input with invalid values for the last 4
+ invalid = [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [1.0, 2.0, 3.0, 4.0],
+ ]
+ with self.assertRaises(ValueError):
+ Matrix(invalid)
+ # Test input with invalid type
+ with self.assertRaises(TypeError):
+ Matrix("invalid")
+ # Test input with invalid size / nested types
+ with self.assertRaises(TypeError):
+ Matrix([[1, 2, 3, 4], [1, 2, 3], [1, 2, 3, 4]])
+ with self.assertRaises(TypeError):
+ Matrix([1, 2, 3])
+
+ # Invalid sub-type
+ with self.assertRaises(TypeError):
+ Matrix([[1, 2, 3, 4], "abc", [1, 2, 3, 4]])
+
+ # test out-of-bounds access
+ m = Matrix()
+ with self.assertRaises(IndexError):
+ m[0, 4]
+ with self.assertRaises(IndexError):
+ m[4, 0]
+ with self.assertRaises(IndexError):
+ m["ab"]
+
+ # test __repr__ methods
+ m = Matrix(vals4x4)
+ mRepr = "Matrix([[1.0, 0.0, 0.0, 1.0],\n [0.0, 1.0, 0.0, 2.0],\n [0.0, 0.0, 1.0, 3.0],\n [0.0, 0.0, 0.0, 1.0]])"
+ self.assertEqual(repr(m), mRepr)
+ self.assertEqual(str(eval(repr(m))), mRepr)
+
+ def test_matrix_functionality(self):
+ # Test rotate methods
+ def matrix_almost_equal(m, target_matrix):
+ for r, row in enumerate(target_matrix):
+ for c, target_value in enumerate(row):
+ self.assertAlmostEqual(m[r, c], target_value)
+
+ root_3_over_2 = math.sqrt(3) / 2
+ m_rotate_x_30 = [
+ [1, 0, 0, 0],
+ [0, root_3_over_2, -1 / 2, 0],
+ [0, 1 / 2, root_3_over_2, 0],
+ [0, 0, 0, 1],
+ ]
+ mx = Matrix()
+ mx.rotate(Axis.X, 30 * DEG2RAD)
+ matrix_almost_equal(mx, m_rotate_x_30)
+
+ m_rotate_y_30 = [
+ [root_3_over_2, 0, 1 / 2, 0],
+ [0, 1, 0, 0],
+ [-1 / 2, 0, root_3_over_2, 0],
+ [0, 0, 0, 1],
+ ]
+ my = Matrix()
+ my.rotate(Axis.Y, 30 * DEG2RAD)
+ matrix_almost_equal(my, m_rotate_y_30)
+
+ m_rotate_z_30 = [
+ [root_3_over_2, -1 / 2, 0, 0],
+ [1 / 2, root_3_over_2, 0, 0],
+ [0, 0, 1, 0],
+ [0, 0, 0, 1],
+ ]
+ mz = Matrix()
+ mz.rotate(Axis.Z, 30 * DEG2RAD)
+ matrix_almost_equal(mz, m_rotate_z_30)
+
+ # Test matrix multiply vector
+ v = Vector(1, 0, 0)
+ self.assertVectorAlmostEquals(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7)
+
+ # Test matrix multiply matrix
+ m_rotate_xy_30 = [
+ [root_3_over_2, 0, 1 / 2, 0],
+ [1 / 4, root_3_over_2, -root_3_over_2 / 2, 0],
+ [-root_3_over_2 / 2, 1 / 2, 3 / 4, 0],
+ [0, 0, 0, 1],
+ ]
+ mxy = mx.multiply(my)
+ matrix_almost_equal(mxy, m_rotate_xy_30)
+
+ # Test matrix inverse
+ vals4x4 = [[1, 2, 3, 4], [5, 1, 6, 7], [8, 9, 1, 10], [0, 0, 0, 1]]
+ vals4x4_invert = [
+ [-53 / 144, 25 / 144, 1 / 16, -53 / 144],
+ [43 / 144, -23 / 144, 1 / 16, -101 / 144],
+ [37 / 144, 7 / 144, -1 / 16, -107 / 144],
+ [0, 0, 0, 1],
+ ]
+ m = Matrix(vals4x4).inverse()
+ matrix_almost_equal(m, vals4x4_invert)
+
+ # Test matrix created from transfer function
+ rot_x = gp_Trsf()
+ θ = math.pi
+ rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), θ)
+ m = Matrix(rot_x)
+ rot_x_matrix = [
+ [1, 0, 0, 0],
+ [0, math.cos(θ), -math.sin(θ), 0],
+ [0, math.sin(θ), math.cos(θ), 0],
+ [0, 0, 0, 1],
+ ]
+ matrix_almost_equal(m, rot_x_matrix)
+
+ # Test copy
+ m2 = copy.copy(m)
+ matrix_almost_equal(m2, rot_x_matrix)
+ m3 = copy.deepcopy(m)
+ matrix_almost_equal(m3, rot_x_matrix)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py
new file mode 100644
index 0000000..63589e4
--- /dev/null
+++ b/tests/test_direct_api/test_mixin1_d.py
@@ -0,0 +1,321 @@
+"""
+build123d direct api tests
+
+name: test_mixin1_d.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import unittest
+
+from build123d.build_enums import CenterOf, GeomType, PositionMode, Side, SortBy
+from build123d.geometry import Axis, Location, Plane, Vector
+from build123d.objects_part import Box, Cylinder
+from build123d.topology import Compound, Edge, Face, Wire
+from tests.base_test import DirectApiTestCase
+
+
+class TestMixin1D(DirectApiTestCase):
+ """Test the add in methods"""
+
+ def test_position_at(self):
+ self.assertVectorAlmostEquals(
+ Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5),
+ (0.5, 0.5, 0.5),
+ 5,
+ )
+ # Not sure what PARAMETER mode returns - but it's in the ballpark
+ point = (
+ Edge.make_line((0, 0, 0), (1, 1, 1))
+ .position_at(0.5, position_mode=PositionMode.PARAMETER)
+ .to_tuple()
+ )
+ self.assertTrue(all([0.0 < v < 1.0 for v in point]))
+
+ wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))])
+ self.assertVectorAlmostEquals(wire.position_at(0.3), (3, 0, 0), 5)
+ self.assertVectorAlmostEquals(
+ wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
+ )
+ self.assertVectorAlmostEquals(wire.edge().position_at(0.3), (3, 0, 0), 5)
+ self.assertVectorAlmostEquals(
+ wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
+ )
+
+ circle_wire = Wire(
+ [
+ Edge.make_circle(1, start_angle=0, end_angle=180),
+ Edge.make_circle(1, start_angle=180, end_angle=360),
+ ]
+ )
+ p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH)
+ p2 = circle_wire.position_at(math.pi / circle_wire.length)
+ self.assertVectorAlmostEquals(p1, (-1, 0, 0), 14)
+ self.assertVectorAlmostEquals(p2, (-1, 0, 0), 14)
+ self.assertVectorAlmostEquals(p1, p2, 14)
+
+ circle_edge = Edge.make_circle(1)
+ p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH)
+ p4 = circle_edge.position_at(math.pi / circle_edge.length)
+ self.assertVectorAlmostEquals(p3, (-1, 0, 0), 14)
+ self.assertVectorAlmostEquals(p4, (-1, 0, 0), 14)
+ self.assertVectorAlmostEquals(p3, p4, 14)
+
+ circle = Wire(
+ [
+ Edge.make_circle(2, start_angle=0, end_angle=180),
+ Edge.make_circle(2, start_angle=180, end_angle=360),
+ ]
+ )
+ self.assertVectorAlmostEquals(
+ circle.position_at(0.5),
+ (-2, 0, 0),
+ 5,
+ )
+ self.assertVectorAlmostEquals(
+ circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH),
+ (-2, 0, 0),
+ 5,
+ )
+
+ def test_positions(self):
+ e = Edge.make_line((0, 0, 0), (1, 1, 1))
+ distances = [i / 4 for i in range(3)]
+ pts = e.positions(distances)
+ for i, position in enumerate(pts):
+ self.assertVectorAlmostEquals(position, (i / 4, i / 4, i / 4), 5)
+
+ def test_tangent_at(self):
+ self.assertVectorAlmostEquals(
+ Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0),
+ (-1, 0, 0),
+ 5,
+ )
+ tangent = (
+ Edge.make_circle(1, start_angle=0, end_angle=90)
+ .tangent_at(0.0, position_mode=PositionMode.PARAMETER)
+ .to_tuple()
+ )
+ self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent]))
+
+ self.assertVectorAlmostEquals(
+ Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at(
+ math.pi / 2, position_mode=PositionMode.LENGTH
+ ),
+ (-1, 0, 0),
+ 5,
+ )
+
+ def test_tangent_at_point(self):
+ circle = Wire(
+ [
+ Edge.make_circle(1, start_angle=0, end_angle=180),
+ Edge.make_circle(1, start_angle=180, end_angle=360),
+ ]
+ )
+ pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4))
+ tan = circle.tangent_at(pnt_on_circle)
+ self.assertVectorAlmostEquals(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5)
+
+ def test_tangent_at_by_length(self):
+ circle = Edge.make_circle(1)
+ tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH)
+ self.assertVectorAlmostEquals(tan, (0, -1), 5)
+
+ def test_tangent_at_error(self):
+ with self.assertRaises(ValueError):
+ Edge.make_circle(1).tangent_at("start")
+
+ def test_normal(self):
+ self.assertVectorAlmostEquals(
+ Edge.make_circle(
+ 1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60
+ ).normal(),
+ (1, 0, 0),
+ 5,
+ )
+ self.assertVectorAlmostEquals(
+ Edge.make_ellipse(
+ 1,
+ 0.5,
+ Plane(origin=(0, 0, 0), z_dir=(1, 1, 0)),
+ start_angle=0,
+ end_angle=90,
+ ).normal(),
+ (math.sqrt(2) / 2, math.sqrt(2) / 2, 0),
+ 5,
+ )
+ self.assertVectorAlmostEquals(
+ Edge.make_spline(
+ [
+ (1, 0),
+ (math.sqrt(2) / 2, math.sqrt(2) / 2),
+ (0, 1),
+ ],
+ tangents=((0, 1, 0), (-1, 0, 0)),
+ ).normal(),
+ (0, 0, 1),
+ 5,
+ )
+ with self.assertRaises(ValueError):
+ Edge.make_line((0, 0, 0), (1, 1, 1)).normal()
+
+ def test_center(self):
+ c = Edge.make_circle(1, start_angle=0, end_angle=180)
+ self.assertVectorAlmostEquals(c.center(), (0, 1, 0), 5)
+ self.assertVectorAlmostEquals(
+ c.center(CenterOf.MASS),
+ (0, 0.6366197723675814, 0),
+ 5,
+ )
+ self.assertVectorAlmostEquals(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5)
+
+ def test_location_at(self):
+ loc = Edge.make_circle(1).location_at(0.25)
+ self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5)
+
+ loc = Edge.make_circle(1).location_at(
+ math.pi / 2, position_mode=PositionMode.LENGTH
+ )
+ self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5)
+
+ def test_locations(self):
+ locs = Edge.make_circle(1).locations([i / 4 for i in range(4)])
+ self.assertVectorAlmostEquals(locs[0].position, (1, 0, 0), 5)
+ self.assertVectorAlmostEquals(locs[0].orientation, (-90, 0, -180), 5)
+ self.assertVectorAlmostEquals(locs[1].position, (0, 1, 0), 5)
+ self.assertVectorAlmostEquals(locs[1].orientation, (0, -90, -90), 5)
+ self.assertVectorAlmostEquals(locs[2].position, (-1, 0, 0), 5)
+ self.assertVectorAlmostEquals(locs[2].orientation, (90, 0, 0), 5)
+ self.assertVectorAlmostEquals(locs[3].position, (0, -1, 0), 5)
+ self.assertVectorAlmostEquals(locs[3].orientation, (0, 90, 90), 5)
+
+ def test_project(self):
+ target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0)))
+ circle = Edge.make_circle(1).locate(Location((0, 0, 10)))
+ ellipse: Wire = circle.project(target, (0, 0, -1))
+ bbox = ellipse.bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5)
+ self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5)
+
+ def test_project2(self):
+ target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
+ square = Wire.make_rect(1, 1, Plane.YZ).locate(Location((10, 0, 0)))
+ projections: list[Wire] = square.project(
+ target, direction=(-1, 0, 0), closest=False
+ )
+ self.assertEqual(len(projections), 2)
+
+ def test_is_forward(self):
+ plate = Box(10, 10, 1) - Cylinder(1, 1)
+ hole_edges = plate.edges().filter_by(GeomType.CIRCLE)
+ self.assertTrue(hole_edges.sort_by(Axis.Z)[-1].is_forward)
+ self.assertFalse(hole_edges.sort_by(Axis.Z)[0].is_forward)
+
+ def test_offset_2d(self):
+ base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False)
+ corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1]
+ base_wire = base_wire.fillet_2d(0.4, [corner])
+ offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT)
+ self.assertTrue(offset_wire.is_closed)
+ self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6)
+ self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2)
+ offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT)
+ self.assertAlmostEqual(
+ offset_wire_right.edges()
+ .filter_by(GeomType.CIRCLE)
+ .sort_by(SortBy.RADIUS)[-1]
+ .radius,
+ 0.5,
+ 4,
+ )
+ h_perimeter = Compound.make_text("h", font_size=10).wire()
+ with self.assertRaises(RuntimeError):
+ h_perimeter.offset_2d(-1)
+
+ # Test for returned Edge - can't find a way to do this
+ # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50)
+ # self.assertTrue(isinstance(offset_edge, Edge))
+ # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
+ # self.assertTrue(offset_edge.geom_type == GeomType.CIRCLE)
+ # self.assertAlmostEqual(offset_edge.radius, 12, 5)
+ # base_edge = Edge.make_line((0, 1), (1, 10))
+ # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
+ # self.assertTrue(isinstance(offset_edge, Edge))
+ # self.assertTrue(offset_edge.geom_type == GeomType.LINE)
+ # self.assertAlmostEqual(offset_edge.position_at(0).X, 3)
+
+ def test_common_plane(self):
+ # Straight and circular lines
+ l = Edge.make_line((0, 0, 0), (5, 0, 0))
+ c = Edge.make_circle(2, Plane.XZ, -90, 90)
+ common = l.common_plane(c)
+ self.assertAlmostEqual(common.z_dir.X, 0, 5)
+ self.assertAlmostEqual(abs(common.z_dir.Y), 1, 5) # the direction isn't known
+ self.assertAlmostEqual(common.z_dir.Z, 0, 5)
+
+ # Co-axial straight lines
+ l1 = Edge.make_line((0, 0), (1, 1))
+ l2 = Edge.make_line((0.25, 0.25), (0.75, 0.75))
+ common = l1.common_plane(l2)
+ # the z_dir isn't know
+ self.assertAlmostEqual(common.x_dir.Z, 0, 5)
+
+ # Parallel lines
+ l1 = Edge.make_line((0, 0), (1, 0))
+ l2 = Edge.make_line((0, 1), (1, 1))
+ common = l1.common_plane(l2)
+ self.assertAlmostEqual(common.z_dir.X, 0, 5)
+ self.assertAlmostEqual(common.z_dir.Y, 0, 5)
+ self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
+
+ # Many lines
+ common = Edge.common_plane(*Wire.make_rect(10, 10).edges())
+ self.assertAlmostEqual(common.z_dir.X, 0, 5)
+ self.assertAlmostEqual(common.z_dir.Y, 0, 5)
+ self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
+
+ # Wire and Edges
+ c = Wire.make_circle(1, Plane.YZ)
+ lines = Wire.make_rect(2, 2, Plane.YZ).edges()
+ common = c.common_plane(*lines)
+ self.assertAlmostEqual(abs(common.z_dir.X), 1, 5) # the direction isn't known
+ self.assertAlmostEqual(common.z_dir.Y, 0, 5)
+ self.assertAlmostEqual(common.z_dir.Z, 0, 5)
+
+ def test_edge_volume(self):
+ edge = Edge.make_line((0, 0), (1, 1))
+ self.assertAlmostEqual(edge.volume, 0, 5)
+
+ def test_wire_volume(self):
+ wire = Wire.make_rect(1, 1)
+ self.assertAlmostEqual(wire.volume, 0, 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_mixin3_d.py b/tests/test_direct_api/test_mixin3_d.py
new file mode 100644
index 0000000..100c680
--- /dev/null
+++ b/tests/test_direct_api/test_mixin3_d.py
@@ -0,0 +1,157 @@
+"""
+build123d direct api tests
+
+name: test_mixin3_d.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 unittest.mock import patch
+
+from build123d.build_enums import CenterOf, Kind
+from build123d.geometry import Axis, Plane
+from build123d.topology import Face, Shape, Solid
+from tests.base_test import DirectApiTestCase
+
+
+class TestMixin3D(DirectApiTestCase):
+ """Test that 3D add ins"""
+
+ def test_chamfer(self):
+ box = Solid.make_box(1, 1, 1)
+ chamfer_box = box.chamfer(0.1, None, box.edges().sort_by(Axis.Z)[-1:])
+ self.assertAlmostEqual(chamfer_box.volume, 1 - 0.005, 5)
+
+ def test_chamfer_asym_length(self):
+ box = Solid.make_box(1, 1, 1)
+ chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:])
+ self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
+
+ def test_chamfer_asym_length_with_face(self):
+ box = Solid.make_box(1, 1, 1)
+ face = box.faces().sort_by(Axis.Z)[0]
+ edge = [face.edges().sort_by(Axis.Y)[0]]
+ chamfer_box = box.chamfer(0.1, 0.2, edge, face=face)
+ self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
+
+ def test_chamfer_too_high_length(self):
+ box = Solid.make_box(1, 1, 1)
+ face = box.faces
+ self.assertRaises(
+ ValueError, box.chamfer, 2, None, box.edges().sort_by(Axis.Z)[-1:]
+ )
+
+ def test_chamfer_edge_not_part_of_face(self):
+ box = Solid.make_box(1, 1, 1)
+ edge = box.edges().sort_by(Axis.Z)[-1:]
+ face = box.faces().sort_by(Axis.Z)[0]
+ self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face)
+
+ @patch.object(Shape, "is_valid", return_value=False)
+ def test_chamfer_invalid_shape_raises_error(self, mock_is_valid):
+ box = Solid.make_box(1, 1, 1)
+
+ # Assert that ValueError is raised
+ with self.assertRaises(ValueError) as chamfer_context:
+ max = box.chamfer(0.1, None, box.edges())
+
+ # Check the error message
+ self.assertEqual(
+ str(chamfer_context.exception),
+ "Failed creating a chamfer, try a smaller length value(s)",
+ )
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ def test_hollow(self):
+ shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1)
+ self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5)
+
+ shell_box = Solid.make_box(1, 1, 1)
+ shell_box = shell_box.hollow(
+ shell_box.faces().filter_by(Axis.Z), thickness=0.1, kind=Kind.INTERSECTION
+ )
+ self.assertAlmostEqual(shell_box.volume, 1 * 1.2**2 - 1**3, 5)
+
+ shell_box = Solid.make_box(1, 1, 1).hollow(
+ [], thickness=0.1, kind=Kind.INTERSECTION
+ )
+ self.assertAlmostEqual(shell_box.volume, 1.2**3 - 1**3, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT)
+
+ def test_is_inside(self):
+ self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5)))
+
+ def test_dprism(self):
+ # face
+ f = Face.make_rect(0.5, 0.5)
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [f], additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
+
+ # face with depth
+ f = Face.make_rect(0.5, 0.5)
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [f], depth=0.5, thru_all=False, additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
+
+ # face until
+ f = Face.make_rect(0.5, 0.5)
+ limit = Face.make_rect(1, 1, Plane((0, 0, 0.5)))
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [f], up_to_face=limit, thru_all=False, additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
+
+ # wire
+ w = Face.make_rect(0.5, 0.5).outer_wire()
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [w], additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
+
+ def test_center(self):
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY)
+
+ self.assertVectorAlmostEquals(
+ Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX),
+ (0.5, 0.5, 0.5),
+ 5,
+ )
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py
new file mode 100644
index 0000000..f88664f
--- /dev/null
+++ b/tests/test_direct_api/test_plane.py
@@ -0,0 +1,504 @@
+"""
+build123d direct api tests
+
+name: test_plane.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 copy
+import math
+import random
+import unittest
+
+from OCP.BRepGProp import BRepGProp
+from OCP.GProp import GProp_GProps
+from build123d.build_common import Locations
+from build123d.build_enums import Align, GeomType, Mode
+from build123d.build_part import BuildPart
+from build123d.build_sketch import BuildSketch
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import Circle, Rectangle
+from build123d.operations_generic import fillet, add
+from build123d.operations_part import extrude
+from build123d.topology import Edge, Face, Solid, Vertex
+from tests.base_test import DirectApiTestCase, AlwaysEqual
+
+
+class TestPlane(DirectApiTestCase):
+ """Plane with class properties"""
+
+ def test_class_properties(self):
+ """Validate
+ Name x_dir y_dir z_dir
+ ======= ====== ====== ======
+ XY +x +y +z
+ YZ +y +z +x
+ ZX +z +x +y
+ XZ +x +z -y
+ YX +y +x -z
+ ZY +z +y -x
+ front +x +z -y
+ back -x +z +y
+ left -y +z -x
+ right +y +z +x
+ top +x +y +z
+ bottom +x -y -z
+ isometric +x+y -x+y+z +x+y-z
+ """
+ planes = [
+ (Plane.XY, (1, 0, 0), (0, 0, 1)),
+ (Plane.YZ, (0, 1, 0), (1, 0, 0)),
+ (Plane.ZX, (0, 0, 1), (0, 1, 0)),
+ (Plane.XZ, (1, 0, 0), (0, -1, 0)),
+ (Plane.YX, (0, 1, 0), (0, 0, -1)),
+ (Plane.ZY, (0, 0, 1), (-1, 0, 0)),
+ (Plane.front, (1, 0, 0), (0, -1, 0)),
+ (Plane.back, (-1, 0, 0), (0, 1, 0)),
+ (Plane.left, (0, -1, 0), (-1, 0, 0)),
+ (Plane.right, (0, 1, 0), (1, 0, 0)),
+ (Plane.top, (1, 0, 0), (0, 0, 1)),
+ (Plane.bottom, (1, 0, 0), (0, 0, -1)),
+ (
+ Plane.isometric,
+ (1 / 2**0.5, 1 / 2**0.5, 0),
+ (1 / 3**0.5, -1 / 3**0.5, 1 / 3**0.5),
+ ),
+ ]
+ for plane, x_dir, z_dir in planes:
+ self.assertVectorAlmostEquals(plane.x_dir, x_dir, 5)
+ self.assertVectorAlmostEquals(plane.z_dir, z_dir, 5)
+
+ def test_plane_init(self):
+ # from origin
+ o = (0, 0, 0)
+ x = (1, 0, 0)
+ y = (0, 1, 0)
+ z = (0, 0, 1)
+ planes = [
+ Plane(o),
+ Plane(o, x),
+ Plane(o, x, z),
+ Plane(o, x, z_dir=z),
+ Plane(o, x_dir=x, z_dir=z),
+ Plane(o, x_dir=x),
+ Plane(o, z_dir=z),
+ Plane(origin=o, x_dir=x, z_dir=z),
+ Plane(origin=o, x_dir=x),
+ Plane(origin=o, z_dir=z),
+ ]
+ for p in planes:
+ self.assertVectorAlmostEquals(p.origin, o, 6)
+ self.assertVectorAlmostEquals(p.x_dir, x, 6)
+ self.assertVectorAlmostEquals(p.y_dir, y, 6)
+ self.assertVectorAlmostEquals(p.z_dir, z, 6)
+ with self.assertRaises(TypeError):
+ Plane()
+ with self.assertRaises(TypeError):
+ Plane(o, z_dir="up")
+
+ # rotated location around z
+ loc = Location((0, 0, 0), (0, 0, 45))
+ p_from_loc = Plane(loc)
+ p_from_named_loc = Plane(location=loc)
+ for p in [p_from_loc, p_from_named_loc]:
+ self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
+ self.assertVectorAlmostEquals(
+ p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
+ )
+ self.assertVectorAlmostEquals(
+ p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
+ )
+ self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
+ self.assertVectorAlmostEquals(loc.position, p.location.position, 6)
+ self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6)
+
+ # rotated location around x and origin <> (0,0,0)
+ loc = Location((0, 2, -1), (45, 0, 0))
+ p = Plane(loc)
+ self.assertVectorAlmostEquals(p.origin, (0, 2, -1), 6)
+ self.assertVectorAlmostEquals(p.x_dir, (1, 0, 0), 6)
+ self.assertVectorAlmostEquals(
+ p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6
+ )
+ self.assertVectorAlmostEquals(
+ p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
+ )
+ self.assertVectorAlmostEquals(loc.position, p.location.position, 6)
+ self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6)
+
+ # from a face
+ f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45)))
+ p_from_face = Plane(f)
+ p_from_named_face = Plane(face=f)
+ plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped)
+ p_deep_copy = copy.deepcopy(p_from_face)
+ for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]:
+ self.assertVectorAlmostEquals(p.origin, (1, 2, 3), 6)
+ self.assertVectorAlmostEquals(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
+ self.assertVectorAlmostEquals(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
+ self.assertVectorAlmostEquals(
+ p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
+ )
+ self.assertVectorAlmostEquals(f.location.position, p.location.position, 6)
+ self.assertVectorAlmostEquals(
+ f.location.orientation, p.location.orientation, 6
+ )
+
+ # from a face with x_dir
+ f = Face.make_rect(1, 2)
+ x = (1, 1)
+ y = (-1, 1)
+ planes = [
+ Plane(f, x),
+ Plane(f, x_dir=x),
+ Plane(face=f, x_dir=x),
+ ]
+ for p in planes:
+ self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
+ self.assertVectorAlmostEquals(p.x_dir, Vector(x).normalized(), 6)
+ self.assertVectorAlmostEquals(p.y_dir, Vector(y).normalized(), 6)
+ self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
+
+ with self.assertRaises(TypeError):
+ Plane(Edge.make_line((0, 0), (0, 1)))
+
+ # can be instantiated from planar faces of surface types other than Geom_Plane
+ # this loft creates the trapezoid faces of type Geom_BSplineSurface
+ lofted_solid = Solid.make_loft(
+ [
+ Rectangle(3, 1).wire(),
+ Pos(0, 0, 1) * Rectangle(1, 1).wire(),
+ ]
+ )
+
+ expected = [
+ # Trapezoid face, negative y coordinate
+ (
+ Axis.X.direction, # plane x_dir
+ Axis.Z.direction, # plane y_dir
+ -Axis.Y.direction, # plane z_dir
+ ),
+ # Trapezoid face, positive y coordinate
+ (
+ -Axis.X.direction,
+ Axis.Z.direction,
+ Axis.Y.direction,
+ ),
+ ]
+ # assert properties of the trapezoid faces
+ for i, f in enumerate(lofted_solid.faces() | Plane.XZ > Axis.Y):
+ p = Plane(f)
+ f_props = GProp_GProps()
+ BRepGProp.SurfaceProperties_s(f.wrapped, f_props)
+ self.assertVectorAlmostEquals(p.origin, f_props.CentreOfMass(), 6)
+ self.assertVectorAlmostEquals(p.x_dir, expected[i][0], 6)
+ self.assertVectorAlmostEquals(p.y_dir, expected[i][1], 6)
+ self.assertVectorAlmostEquals(p.z_dir, expected[i][2], 6)
+
+ def test_plane_neg(self):
+ p = Plane(
+ origin=(1, 2, 3),
+ x_dir=Vector(1, 2, 3).normalized(),
+ z_dir=Vector(4, 5, 6).normalized(),
+ )
+ p2 = -p
+ self.assertVectorAlmostEquals(p2.origin, p.origin, 6)
+ self.assertVectorAlmostEquals(p2.x_dir, p.x_dir, 6)
+ self.assertVectorAlmostEquals(p2.z_dir, -p.z_dir, 6)
+ self.assertVectorAlmostEquals(
+ p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6
+ )
+ p3 = p.reverse()
+ self.assertVectorAlmostEquals(p3.origin, p.origin, 6)
+ self.assertVectorAlmostEquals(p3.x_dir, p.x_dir, 6)
+ self.assertVectorAlmostEquals(p3.z_dir, -p.z_dir, 6)
+ self.assertVectorAlmostEquals(
+ p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6
+ )
+
+ def test_plane_mul(self):
+ p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1))
+ p2 = p * Location((1, 2, -1), (0, 0, 45))
+ self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
+ self.assertVectorAlmostEquals(
+ p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
+ )
+ self.assertVectorAlmostEquals(
+ p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
+ )
+ self.assertVectorAlmostEquals(p2.z_dir, (0, 0, 1), 6)
+
+ p2 = p * Location((1, 2, -1), (0, 45, 0))
+ self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
+ self.assertVectorAlmostEquals(
+ p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6
+ )
+ self.assertVectorAlmostEquals(p2.y_dir, (0, 1, 0), 6)
+ self.assertVectorAlmostEquals(
+ p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6
+ )
+
+ p2 = p * Location((1, 2, -1), (45, 0, 0))
+ self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
+ self.assertVectorAlmostEquals(p2.x_dir, (1, 0, 0), 6)
+ self.assertVectorAlmostEquals(
+ p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6
+ )
+ self.assertVectorAlmostEquals(
+ p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
+ )
+ with self.assertRaises(TypeError):
+ p2 * Vector(1, 1, 1)
+
+ def test_plane_methods(self):
+ # Test error checking
+ p = Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 0))
+ with self.assertRaises(ValueError):
+ p.to_local_coords("box")
+
+ # Test translation to local coordinates
+ local_box = p.to_local_coords(Solid.make_box(1, 1, 1))
+ local_box_vertices = [(v.X, v.Y, v.Z) for v in local_box.vertices()]
+ target_vertices = [
+ (0, -1, 0),
+ (0, 0, 0),
+ (0, -1, 1),
+ (0, 0, 1),
+ (1, -1, 0),
+ (1, 0, 0),
+ (1, -1, 1),
+ (1, 0, 1),
+ ]
+ for i, target_point in enumerate(target_vertices):
+ self.assertTupleAlmostEquals(target_point, local_box_vertices[i], 7)
+
+ def test_localize_vertex(self):
+ vertex = Vertex(random.random(), random.random(), random.random())
+ self.assertTupleAlmostEquals(
+ Plane.YZ.to_local_coords(vertex).to_tuple(),
+ Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(),
+ 5,
+ )
+
+ def test_repr(self):
+ self.assertEqual(
+ repr(Plane.XY),
+ "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))",
+ )
+
+ def test_shift_origin_axis(self):
+ cyl = Cylinder(1, 2, align=Align.MIN)
+ top = cyl.faces().sort_by(Axis.Z)[-1]
+ pln = Plane(top).shift_origin(Axis.Z)
+ with BuildPart() as p:
+ add(cyl)
+ with BuildSketch(pln):
+ with Locations((1, 1)):
+ Circle(0.5)
+ extrude(amount=-2, mode=Mode.SUBTRACT)
+ self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5)
+
+ def test_shift_origin_vertex(self):
+ box = Box(1, 1, 1, align=Align.MIN)
+ front = box.faces().sort_by(Axis.X)[-1]
+ pln = Plane(front).shift_origin(
+ front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1]
+ )
+ with BuildPart() as p:
+ add(box)
+ with BuildSketch(pln):
+ with Locations((-0.5, 0.5)):
+ Circle(0.5)
+ extrude(amount=-1, mode=Mode.SUBTRACT)
+ self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5)
+
+ def test_shift_origin_vector(self):
+ with BuildPart() as p:
+ Box(4, 4, 2)
+ b = fillet(p.edges().filter_by(Axis.Z), 0.5)
+ top = p.faces().sort_by(Axis.Z)[-1]
+ ref = (
+ top.edges()
+ .filter_by(GeomType.CIRCLE)
+ .group_by(Axis.X)[-1]
+ .sort_by(Axis.Y)[0]
+ .arc_center
+ )
+ pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref)
+ with BuildSketch(pln):
+ with Locations((0.5, 0.5)):
+ Rectangle(2, 2, align=Align.MIN)
+ extrude(amount=-1, mode=Mode.SUBTRACT)
+ self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5)
+
+ def test_shift_origin_error(self):
+ with self.assertRaises(ValueError):
+ Plane.XY.shift_origin(Vertex(1, 1, 1))
+
+ with self.assertRaises(ValueError):
+ Plane.XY.shift_origin((1, 1, 1))
+
+ with self.assertRaises(ValueError):
+ Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0)))
+
+ with self.assertRaises(TypeError):
+ Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1)))
+
+ def test_move(self):
+ pln = Plane.XY.move(Location((1, 2, 3)))
+ self.assertVectorAlmostEquals(pln.origin, (1, 2, 3), 5)
+
+ def test_rotated(self):
+ rotated_plane = Plane.XY.rotated((45, 0, 0))
+ self.assertVectorAlmostEquals(rotated_plane.x_dir, (1, 0, 0), 5)
+ self.assertVectorAlmostEquals(
+ rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5
+ )
+
+ def test_invalid_plane(self):
+ # Test plane creation error handling
+ with self.assertRaises(ValueError):
+ Plane(origin=(0, 0, 0), x_dir=(0, 0, 0), z_dir=(0, 1, 1))
+ with self.assertRaises(ValueError):
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 0))
+
+ def test_plane_equal(self):
+ # default orientation
+ self.assertEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ )
+ # moved origin
+ self.assertEqual(
+ Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ )
+ # moved x-axis
+ self.assertEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
+ )
+ # moved z-axis
+ self.assertEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
+ )
+ # __eq__ cooperation
+ self.assertEqual(Plane.XY, AlwaysEqual())
+
+ def test_plane_not_equal(self):
+ # type difference
+ for value in [None, 0, 1, "abc"]:
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), value
+ )
+ # origin difference
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ )
+ # x-axis difference
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
+ )
+ # z-axis difference
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
+ )
+
+ def test_to_location(self):
+ loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location
+ self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(loc.orientation, (0, 0, 90), 5)
+
+ def test_intersect(self):
+ self.assertVectorAlmostEquals(
+ Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5
+ )
+ self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0))))
+
+ self.assertEqual(Plane.XY.intersect(Plane.XZ), Axis.X)
+
+ self.assertIsNone(Plane.XY.intersect(Plane.XY.offset(1)))
+
+ with self.assertRaises(ValueError):
+ Plane.XY.intersect("Plane.XZ")
+
+ with self.assertRaises(ValueError):
+ Plane.XY.intersect(pln=Plane.XZ)
+
+ def test_from_non_planar_face(self):
+ flat = Face.make_rect(1, 1)
+ pln = Plane(flat)
+ self.assertTrue(isinstance(pln, Plane))
+ cyl = (
+ Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0]
+ )
+ with self.assertRaises(ValueError):
+ pln = Plane(cyl)
+
+ def test_plane_intersect(self):
+ section = Plane.XY.intersect(Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5)))
+ self.assertEqual(len(section.solids()), 0)
+ self.assertEqual(len(section.faces()), 1)
+ self.assertAlmostEqual(section.face().area, 2)
+
+ section = Plane.XY & Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5))
+ self.assertEqual(len(section.solids()), 0)
+ self.assertEqual(len(section.faces()), 1)
+ self.assertAlmostEqual(section.face().area, 2)
+
+ self.assertEqual(Plane.XY & Plane.XZ, Axis.X)
+ # x_axis_as_edge = Plane.XY & Plane.XZ
+ # common = (x_axis_as_edge.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
+ # self.assertAlmostEqual(common.length, 1, 5)
+
+ i = Plane.XY & Vector(1, 2)
+ self.assertTrue(isinstance(i, Vector))
+ self.assertVectorAlmostEquals(i, (1, 2, 0), 5)
+
+ a = Axis((0, 0, 0), (1, 1, 0))
+ i = Plane.XY & a
+ self.assertTrue(isinstance(i, Axis))
+ self.assertEqual(i, a)
+
+ a = Axis((1, 2, -1), (0, 0, 1))
+ i = Plane.XY & a
+ self.assertTrue(isinstance(i, Vector))
+ self.assertVectorAlmostEquals(i, Vector(1, 2, 0), 5)
+
+ def test_plane_origin_setter(self):
+ pln = Plane.XY
+ pln.origin = (1, 2, 3)
+ ocp_origin = Vector(pln.wrapped.Location())
+ self.assertVectorAlmostEquals(ocp_origin, (1, 2, 3), 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_projection.py b/tests/test_direct_api/test_projection.py
new file mode 100644
index 0000000..62b23d4
--- /dev/null
+++ b/tests/test_direct_api/test_projection.py
@@ -0,0 +1,106 @@
+"""
+build123d direct api tests
+
+name: test_projection.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.build_enums import Align
+from build123d.geometry import Axis, Plane, Pos, Vector
+from build123d.objects_part import Box
+from build123d.topology import Compound, Edge, Solid, Wire
+from tests.base_test import DirectApiTestCase
+
+
+class TestProjection(DirectApiTestCase):
+ def test_flat_projection(self):
+ sphere = Solid.make_sphere(50)
+ projection_direction = Vector(0, -1, 0)
+ planar_text_faces = (
+ Compound.make_text("Flat", 30, align=(Align.CENTER, Align.CENTER))
+ .rotate(Axis.X, 90)
+ .faces()
+ )
+ projected_text_faces = [
+ f.project_to_shape(sphere, projection_direction)[0]
+ for f in planar_text_faces
+ ]
+ self.assertEqual(len(projected_text_faces), 4)
+
+ def test_multiple_output_wires(self):
+ target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2)
+ circle = Wire.make_circle(3, Plane.XY.offset(10))
+ projection = circle.project_to_shape(target, (0, 0, -1))
+ bbox = projection[0].bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-3, -3, 1), 2)
+ self.assertVectorAlmostEquals(bbox.max, (3, 3, 2), 2)
+ bbox = projection[1].bounding_box()
+ self.assertVectorAlmostEquals(bbox.min, (-3, -3, -2), 2)
+ self.assertVectorAlmostEquals(bbox.max, (3, 3, -2), 2)
+
+ def test_text_projection(self):
+ sphere = Solid.make_sphere(50)
+ arch_path = (
+ sphere.cut(
+ Solid.make_cylinder(
+ 80, 100, Plane(origin=(-50, 0, -70), z_dir=(1, 0, 0))
+ )
+ )
+ .edges()
+ .sort_by(Axis.Z)[0]
+ )
+
+ projected_text = sphere.project_faces(
+ faces=Compound.make_text("dog", font_size=14),
+ path=arch_path,
+ start=0.01, # avoid a character spanning the sphere edge
+ )
+ self.assertEqual(len(projected_text.solids()), 0)
+ self.assertEqual(len(projected_text.faces()), 3)
+
+ def test_error_handling(self):
+ sphere = Solid.make_sphere(50)
+ circle = Wire.make_circle(1)
+ with self.assertRaises(ValueError):
+ circle.project_to_shape(sphere, center=None, direction=None)[0]
+
+ def test_project_edge(self):
+ projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape(
+ Solid.make_box(1, 1, 1), (0, 0, 1)
+ )
+ self.assertVectorAlmostEquals(projection[0].position_at(1), (1, 0, 0), 5)
+ self.assertVectorAlmostEquals(projection[0].position_at(0), (0, 1, 0), 5)
+ self.assertVectorAlmostEquals(projection[0].arc_center, (0, 0, 0), 5)
+
+ def test_to_axis(self):
+ with self.assertRaises(ValueError):
+ Edge.make_circle(1, end_angle=30).to_axis()
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_rotation.py b/tests/test_direct_api/test_rotation.py
new file mode 100644
index 0000000..3efd378
--- /dev/null
+++ b/tests/test_direct_api/test_rotation.py
@@ -0,0 +1,61 @@
+"""
+build123d direct api tests
+
+name: test_rotation.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.build_enums import Extrinsic, Intrinsic
+from build123d.geometry import Rotation
+from tests.base_test import DirectApiTestCase
+
+
+class TestRotation(DirectApiTestCase):
+ def test_rotation_parameters(self):
+ r = Rotation(10, 20, 30)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, 20, Z=30)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, Y=20, Z=30)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation((10, 20, 30))
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, 20, 30, Intrinsic.XYZ)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation((30, 20, 10), Extrinsic.ZYX)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX)
+ self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ with self.assertRaises(TypeError):
+ Rotation(x=10)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py
new file mode 100644
index 0000000..1907473
--- /dev/null
+++ b/tests/test_direct_api/test_shape.py
@@ -0,0 +1,615 @@
+"""
+build123d direct api tests
+
+name: test_shape.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 random import uniform
+from unittest.mock import patch
+
+from build123d.build_enums import CenterOf, Keep
+from build123d.geometry import (
+ Axis,
+ Color,
+ Location,
+ Matrix,
+ Plane,
+ Pos,
+ Rotation,
+ Vector,
+)
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import Circle
+from build123d.operations_part import extrude
+from build123d.topology import (
+ Compound,
+ Edge,
+ Face,
+ Shape,
+ ShapeList,
+ Shell,
+ Solid,
+ Vertex,
+ Wire,
+)
+from tests.base_test import DirectApiTestCase, AlwaysEqual
+
+
+class TestShape(DirectApiTestCase):
+ """Misc Shape tests"""
+
+ def test_mirror(self):
+ box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box()
+ self.assertAlmostEqual(box_bb.min.X, 0, 5)
+ self.assertAlmostEqual(box_bb.max.X, 1, 5)
+ self.assertAlmostEqual(box_bb.min.Y, -1, 5)
+ self.assertAlmostEqual(box_bb.max.Y, 0, 5)
+
+ box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box()
+ self.assertAlmostEqual(box_bb.min.Z, -1, 5)
+ self.assertAlmostEqual(box_bb.max.Z, 0, 5)
+
+ def test_compute_mass(self):
+ with self.assertRaises(NotImplementedError):
+ Shape.compute_mass(Vertex())
+
+ def test_combined_center(self):
+ objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]]
+ self.assertVectorAlmostEquals(
+ Shape.combined_center(objs, center_of=CenterOf.MASS),
+ (0, 0.5, 0.5),
+ 5,
+ )
+
+ objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]]
+ self.assertVectorAlmostEquals(
+ Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX),
+ (-0.5, 0, 0),
+ 5,
+ )
+ with self.assertRaises(ValueError):
+ Shape.combined_center(objs, center_of=CenterOf.GEOMETRY)
+
+ def test_shape_type(self):
+ self.assertEqual(Vertex().shape_type(), "Vertex")
+
+ def test_scale(self):
+ self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5)
+
+ def test_fuse(self):
+ box1 = Solid.make_box(1, 1, 1)
+ box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
+ combined = box1.fuse(box2, glue=True)
+ self.assertTrue(combined.is_valid())
+ self.assertAlmostEqual(combined.volume, 2, 5)
+ fuzzy = box1.fuse(box2, tol=1e-6)
+ self.assertTrue(fuzzy.is_valid())
+ self.assertAlmostEqual(fuzzy.volume, 2, 5)
+
+ def test_faces_intersected_by_axis(self):
+ box = Solid.make_box(1, 1, 1, Plane((0, 0, 1)))
+ intersected_faces = box.faces_intersected_by_axis(Axis.Z)
+ self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces)
+ self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces)
+
+ def test_split(self):
+ shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
+ split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM)
+ self.assertTrue(isinstance(split_shape, list))
+ self.assertEqual(len(split_shape), 2)
+ self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5)
+ split_shape = shape.split(Plane.XY, keep=Keep.TOP)
+ self.assertEqual(len(split_shape.solids()), 1)
+ self.assertTrue(isinstance(split_shape, Solid))
+ self.assertAlmostEqual(split_shape.volume, 0.5, 5)
+
+ s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10))
+ tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face()
+ s2 = s.split(tool, keep=Keep.TOP)
+ self.assertLess(s2.volume, s.volume)
+ self.assertGreater(s2.volume, 0.0)
+
+ def test_split_by_non_planar_face(self):
+ box = Solid.make_box(1, 1, 1)
+ tool = Circle(1).wire()
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ top, bottom = box.split(tool_shell, keep=Keep.BOTH)
+
+ self.assertFalse(top is None)
+ self.assertFalse(bottom is None)
+ self.assertGreater(top.volume, bottom.volume)
+
+ def test_split_by_shell(self):
+ box = Solid.make_box(5, 5, 1)
+ tool = Wire.make_rect(4, 4)
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ split = box.split(tool_shell, keep=Keep.TOP)
+ inner_vol = 2 * 2
+ outer_vol = 5 * 5
+ self.assertAlmostEqual(split.volume, outer_vol - inner_vol)
+
+ def test_split_keep_all(self):
+ shape = Box(1, 1, 1)
+ split_shape = shape.split(Plane.XY, keep=Keep.ALL)
+ self.assertTrue(isinstance(split_shape, ShapeList))
+ self.assertEqual(len(split_shape), 2)
+
+ def test_split_edge_by_shell(self):
+ edge = Edge.make_line((-5, 0, 0), (5, 0, 0))
+ tool = Wire.make_rect(4, 4)
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ top = edge.split(tool_shell, keep=Keep.TOP)
+ self.assertEqual(len(top), 2)
+ self.assertAlmostEqual(top[0].length, 3, 5)
+
+ def test_split_return_none(self):
+ shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
+ split_shape = shape.split(Plane.XY, keep=Keep.INSIDE)
+ self.assertIsNone(split_shape)
+
+ def test_split_by_perimeter(self):
+ # Test 0 - extract a spherical cap
+ target0 = Solid.make_sphere(10).rotate(Axis.Z, 90)
+ circle = Plane.YZ.offset(15) * Circle(5).face()
+ circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0]
+ circle_outerwire = circle_projected.edge()
+ inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH)
+ self.assertLess(inside0.area, outside0.area)
+
+ # Test 1 - extract ring of a sphere
+ ring = Pos(Z=15) * (Circle(5) - Circle(3)).face()
+ ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0]
+ ring_outerwire = ring_projected.outer_wire()
+ inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH)
+ if isinstance(inside1, list):
+ inside1 = Compound(inside1)
+ if isinstance(outside1, list):
+ outside1 = Compound(outside1)
+ self.assertLess(inside1.area, outside1.area)
+ self.assertEqual(len(outside1.faces()), 2)
+
+ # Test 2 - extract multiple faces
+ target2 = Box(1, 10, 10)
+ square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0)))
+ square_projected = square.project_to_shape(target2, (-1, 0, 0))[0]
+ outside2 = target2.split_by_perimeter(
+ square_projected.outer_wire(), Keep.OUTSIDE
+ )
+ self.assertTrue(isinstance(outside2, Shell))
+ inside2 = target2.split_by_perimeter(square_projected.outer_wire(), Keep.INSIDE)
+ self.assertTrue(isinstance(inside2, Face))
+
+ # Test 4 - invalid inputs
+ with self.assertRaises(ValueError):
+ _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH)
+
+ with self.assertRaises(ValueError):
+ _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP)
+
+ def test_distance(self):
+ sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
+ sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
+ self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5)
+
+ def test_distances(self):
+ sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
+ sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
+ sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5)))
+ distances = [8, 3]
+ for i, distance in enumerate(sphere1.distances(sphere2, sphere3)):
+ self.assertAlmostEqual(distances[i], distance, 5)
+
+ def test_max_fillet(self):
+ test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)]
+ max_values = [0.96, 3.84]
+ for i, test_object in enumerate(test_solids):
+ with self.subTest("solids" + str(i)):
+ max = test_object.max_fillet(test_object.edges())
+ self.assertAlmostEqual(max, max_values[i], 2)
+ with self.assertRaises(RuntimeError):
+ test_solids[0].max_fillet(
+ test_solids[0].edges(), tolerance=1e-6, max_iterations=1
+ )
+ with self.assertRaises(ValueError):
+ box = Solid.make_box(1, 1, 1)
+ box.fillet(0.75, box.edges())
+ # invalid_object = box.fillet(0.75, box.edges())
+ # invalid_object.max_fillet(invalid_object.edges())
+
+ @patch.object(Shape, "is_valid", return_value=False)
+ def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid):
+ box = Solid.make_box(1, 1, 1)
+
+ # Assert that ValueError is raised
+ with self.assertRaises(ValueError) as max_fillet_context:
+ max = box.max_fillet(box.edges())
+
+ # Check the error message
+ self.assertEqual(str(max_fillet_context.exception), "Invalid Shape")
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ def test_locate_bb(self):
+ bounding_box = Solid.make_cone(1, 2, 1).bounding_box()
+ relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box)
+ self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5)
+ self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5)
+ self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5)
+ self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5)
+ self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5)
+ self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5)
+
+ def test_is_equal(self):
+ box = Solid.make_box(1, 1, 1)
+ self.assertTrue(box.is_equal(box))
+
+ def test_equal(self):
+ box = Solid.make_box(1, 1, 1)
+ self.assertEqual(box, box)
+ self.assertEqual(box, AlwaysEqual())
+
+ def test_not_equal(self):
+ box = Solid.make_box(1, 1, 1)
+ diff = Solid.make_box(1, 2, 3)
+ self.assertNotEqual(box, diff)
+ self.assertNotEqual(box, object())
+
+ def test_tessellate(self):
+ box123 = Solid.make_box(1, 2, 3)
+ verts, triangles = box123.tessellate(1e-6)
+ self.assertEqual(len(verts), 24)
+ self.assertEqual(len(triangles), 12)
+
+ def test_transformed(self):
+ """Validate that transformed works the same as changing location"""
+ rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360))
+ offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50))
+ shape = Solid.make_box(1, 1, 1).transformed(rotation, offset)
+ predicted_location = Location(offset) * Rotation(*rotation)
+ located_shape = Solid.make_box(1, 1, 1).locate(predicted_location)
+ intersect = shape.intersect(located_shape)
+ self.assertAlmostEqual(intersect.volume, 1, 5)
+
+ def test_position_and_orientation(self):
+ box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30)))
+ self.assertVectorAlmostEquals(box.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(box.orientation, (10, 20, 30), 5)
+
+ def test_distance_to_with_closest_points(self):
+ s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0)))
+ s1 = Solid.make_sphere(1)
+ distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1)
+ self.assertAlmostEqual(distance, 0.1, 5)
+ self.assertVectorAlmostEquals(pnt0, (0, 1.1, 0), 5)
+ self.assertVectorAlmostEquals(pnt1, (0, 1, 0), 5)
+
+ def test_closest_points(self):
+ c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
+ c1 = Edge.make_circle(1)
+ closest = c0.closest_points(c1)
+ self.assertVectorAlmostEquals(closest[0], c0.position_at(0.75).to_tuple(), 5)
+ self.assertVectorAlmostEquals(closest[1], c1.position_at(0.25).to_tuple(), 5)
+
+ def test_distance_to(self):
+ c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
+ c1 = Edge.make_circle(1)
+ distance = c0.distance_to(c1)
+ self.assertAlmostEqual(distance, 0.1, 5)
+
+ def test_intersection(self):
+ box = Solid.make_box(1, 1, 1)
+ intersections = (
+ box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z)
+ )
+ self.assertVectorAlmostEquals(intersections[0], (0.5, 0.5, 0), 5)
+ self.assertVectorAlmostEquals(intersections[1], (0.5, 0.5, 1), 5)
+
+ def test_clean_error(self):
+ """Note that this test is here to alert build123d to changes in bad OCCT clean behavior
+ with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this.
+ """
+ sphere = Solid.make_sphere(1)
+ divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5)))
+ positive_half, negative_half = (s.clean() for s in sphere.cut(divider).solids())
+ self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1)
+
+ def test_clean_empty(self):
+ obj = Solid()
+ self.assertIs(obj, obj.clean())
+
+ def test_relocate(self):
+ box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5)))
+ cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0)))
+
+ box_with_hole = box.cut(cylinder)
+ box_with_hole.relocate(box.location)
+
+ self.assertEqual(box.location, box_with_hole.location)
+
+ bbox1 = box.bounding_box()
+ bbox2 = box_with_hole.bounding_box()
+ self.assertVectorAlmostEquals(bbox1.min, bbox2.min, 5)
+ self.assertVectorAlmostEquals(bbox1.max, bbox2.max, 5)
+
+ def test_project_to_viewport(self):
+ # Basic test
+ box = Solid.make_box(10, 10, 10)
+ visible, hidden = box.project_to_viewport((-20, 20, 20))
+ self.assertEqual(len(visible), 9)
+ self.assertEqual(len(hidden), 3)
+
+ # Contour edges
+ cyl = Solid.make_cylinder(2, 10)
+ visible, hidden = cyl.project_to_viewport((-20, 20, 20))
+ # Note that some edges are broken into two
+ self.assertEqual(len(visible), 6)
+ self.assertEqual(len(hidden), 2)
+
+ # Hidden contour edges
+ hole = box - cyl
+ visible, hidden = hole.project_to_viewport((-20, 20, 20))
+ self.assertEqual(len(visible), 13)
+ self.assertEqual(len(hidden), 6)
+
+ # Outline edges
+ sphere = Solid.make_sphere(5)
+ visible, hidden = sphere.project_to_viewport((-20, 20, 20))
+ self.assertEqual(len(visible), 1)
+ self.assertEqual(len(hidden), 0)
+
+ def test_vertex(self):
+ v = Edge.make_circle(1).vertex()
+ self.assertTrue(isinstance(v, Vertex))
+ with self.assertWarns(UserWarning):
+ Wire.make_rect(1, 1).vertex()
+
+ def test_edge(self):
+ e = Edge.make_circle(1).edge()
+ self.assertTrue(isinstance(e, Edge))
+ with self.assertWarns(UserWarning):
+ Wire.make_rect(1, 1).edge()
+
+ def test_wire(self):
+ w = Wire.make_circle(1).wire()
+ self.assertTrue(isinstance(w, Wire))
+ with self.assertWarns(UserWarning):
+ Solid.make_box(1, 1, 1).wire()
+
+ def test_compound(self):
+ c = Compound.make_text("hello", 10)
+ self.assertTrue(isinstance(c, Compound))
+ c2 = Compound.make_text("world", 10)
+ with self.assertWarns(UserWarning):
+ Compound(children=[c, c2]).compound()
+
+ def test_face(self):
+ f = Face.make_rect(1, 1)
+ self.assertTrue(isinstance(f, Face))
+ with self.assertWarns(UserWarning):
+ Solid.make_box(1, 1, 1).face()
+
+ def test_shell(self):
+ s = Solid.make_sphere(1).shell()
+ self.assertTrue(isinstance(s, Shell))
+ with self.assertWarns(UserWarning):
+ extrude(Compound.make_text("two", 10), amount=5).shell()
+
+ def test_solid(self):
+ s = Solid.make_sphere(1).solid()
+ self.assertTrue(isinstance(s, Solid))
+ with self.assertWarns(UserWarning):
+ Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
+
+ def test_manifold(self):
+ self.assertTrue(Solid.make_box(1, 1, 1).is_manifold)
+ self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold)
+ self.assertFalse(
+ Solid.make_box(1, 1, 1)
+ .shell()
+ .cut(Solid.make_box(0.5, 0.5, 0.5))
+ .is_manifold
+ )
+ self.assertTrue(
+ Compound(
+ children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]
+ ).is_manifold
+ )
+
+ def test_inherit_color(self):
+ # Create some objects and assign colors to them
+ b = Box(1, 1, 1).locate(Pos(2, 2, 0))
+ b.color = Color("blue") # Blue
+ c = Cylinder(1, 1).locate(Pos(-2, 2, 0))
+ a = Compound(children=[b, c])
+ a.color = Color(0, 1, 0)
+ # Check that assigned colors stay and iheritance works
+ self.assertTupleAlmostEquals(tuple(a.color), (0, 1, 0, 1), 5)
+ self.assertTupleAlmostEquals(tuple(b.color), (0, 0, 1, 1), 5)
+
+ def test_ocp_section(self):
+ # Vertex
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Vertex(1, 2, 0))
+ self.assertListEqual(verts, []) # ?
+ self.assertListEqual(edges, [])
+
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4)))
+ self.assertListEqual(verts, []) # ?
+ self.assertListEqual(edges, [])
+
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5))
+ self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5)
+ self.assertListEqual(edges, [])
+
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY))
+ self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5)
+ self.assertListEqual(edges, [])
+
+ # spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5))
+ # cylinder = Pos(Z=-10) * extrude(Circle(5), 20)
+ # cylinder2 = (Rot((0, 90, 0)) * cylinder).face()
+ # pln = Plane.XY
+ # box1 = Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN))
+ # box2 = Pos(Z=-10) * box1
+
+ # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln))
+ # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln))
+ # print(vertices1, edges1)
+
+ # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln))
+ # print(vertices2, edges2)
+
+ # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln))
+ # print(vertices3, edges3)
+
+ # # vertices4, edges4 = cylinder2.ocp_section(cylinder)
+
+ # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln))
+ # print(vertices5, edges5)
+
+ # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1])
+
+ def test_copy_attributes_to(self):
+ box = Box(1, 1, 1)
+ box2 = Box(10, 10, 10)
+ box.label = "box"
+ box.color = Color("Red")
+ box.children = [Box(1, 1, 1), Box(2, 2, 2)]
+ box.topo_parent = box2
+
+ blank = Compound()
+ box.copy_attributes_to(blank)
+ self.assertEqual(blank.label, "box")
+ self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red"))))
+ self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children)))
+ self.assertEqual(blank.topo_parent, box2)
+
+ def test_empty_shape(self):
+ empty = Solid()
+ box = Solid.make_box(1, 1, 1)
+ self.assertIsNone(empty.location)
+ self.assertIsNone(empty.position)
+ self.assertIsNone(empty.orientation)
+ self.assertFalse(empty.is_manifold)
+ with self.assertRaises(ValueError):
+ empty.geom_type
+ self.assertIs(empty, empty.fix())
+ self.assertEqual(hash(empty), 0)
+ self.assertFalse(empty.is_same(Solid()))
+ self.assertFalse(empty.is_equal(Solid()))
+ self.assertTrue(empty.is_valid())
+ empty_bbox = empty.bounding_box()
+ self.assertEqual(tuple(empty_bbox.size), (0, 0, 0))
+ self.assertIs(empty, empty.mirror(Plane.XY))
+ self.assertEqual(Shape.compute_mass(empty), 0)
+ self.assertEqual(empty.entities("Face"), [])
+ self.assertEqual(empty.area, 0)
+ self.assertIs(empty, empty.rotate(Axis.Z, 90))
+ translate_matrix = [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ self.assertIs(empty, empty.transform_shape(Matrix(translate_matrix)))
+ self.assertIs(empty, empty.transform_geometry(Matrix(translate_matrix)))
+ with self.assertRaises(ValueError):
+ empty.locate(Location())
+ empty_loc = Location()
+ empty_loc.wrapped = None
+ with self.assertRaises(ValueError):
+ box.locate(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.located(Location())
+ with self.assertRaises(ValueError):
+ box.located(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.move(Location())
+ with self.assertRaises(ValueError):
+ box.move(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.moved(Location())
+ with self.assertRaises(ValueError):
+ box.moved(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.relocate(Location())
+ with self.assertRaises(ValueError):
+ box.relocate(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.distance_to(Vector(1, 1, 1))
+ with self.assertRaises(ValueError):
+ empty.distance_to_with_closest_points(Vector(1, 1, 1))
+ with self.assertRaises(ValueError):
+ empty.distance_to(Vector(1, 1, 1))
+ with self.assertRaises(ValueError):
+ box.intersect(empty_loc)
+ self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], []))
+ self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList())
+ with self.assertRaises(ValueError):
+ empty.split_by_perimeter(Circle(1).wire())
+ with self.assertRaises(ValueError):
+ empty.distance(Vertex(1, 1, 1))
+ with self.assertRaises(ValueError):
+ list(empty.distances(Vertex(0, 0, 0), Vertex(1, 1, 1)))
+ with self.assertRaises(ValueError):
+ list(box.distances(empty, Vertex(1, 1, 1)))
+ with self.assertRaises(ValueError):
+ empty.mesh(0.001)
+ with self.assertRaises(ValueError):
+ empty.tessellate(0.001)
+ with self.assertRaises(ValueError):
+ empty.to_splines()
+ empty_axis = Axis((0, 0, 0), (1, 0, 0))
+ empty_axis.wrapped = None
+ with self.assertRaises(ValueError):
+ box.vertices().group_by(empty_axis)
+ empty_wire = Wire()
+ with self.assertRaises(ValueError):
+ box.vertices().group_by(empty_wire)
+ with self.assertRaises(ValueError):
+ box.vertices().sort_by(empty_axis)
+ with self.assertRaises(ValueError):
+ box.vertices().sort_by(empty_wire)
+
+ def test_empty_selectors(self):
+ self.assertEqual(Vertex(1, 1, 1).edges(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).wires(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).faces(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList())
+ self.assertIsNone(Vertex(1, 1, 1).edge())
+ self.assertIsNone(Vertex(1, 1, 1).wire())
+ self.assertIsNone(Vertex(1, 1, 1).face())
+ self.assertIsNone(Vertex(1, 1, 1).shell())
+ self.assertIsNone(Vertex(1, 1, 1).solid())
+ self.assertIsNone(Vertex(1, 1, 1).compound())
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py
new file mode 100644
index 0000000..3ceb5fa
--- /dev/null
+++ b/tests/test_direct_api/test_shape_list.py
@@ -0,0 +1,364 @@
+"""
+build123d direct api tests
+
+name: test_shape_list.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 io
+import math
+import re
+import unittest
+
+from IPython.lib import pretty
+from build123d.build_common import GridLocations, PolarLocations
+from build123d.build_enums import GeomType, SortBy
+from build123d.build_part import BuildPart
+from build123d.geometry import Axis, Plane
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import RegularPolygon
+from build123d.topology import (
+ Compound,
+ Edge,
+ Face,
+ ShapeList,
+ Shell,
+ Solid,
+ Vertex,
+ Wire,
+)
+from tests.base_test import DirectApiTestCase, AlwaysEqual
+
+
+class TestShapeList(DirectApiTestCase):
+ """Test ShapeList functionality"""
+
+ def assertDunderStrEqual(self, actual: str, expected_lines: list[str]):
+ actual_lines = actual.splitlines()
+ self.assertEqual(len(actual_lines), len(expected_lines))
+ for actual_line, expected_line in zip(actual_lines, expected_lines):
+ start, end = re.split(r"at 0x[0-9a-f]+", expected_line, 2, re.I)
+ self.assertTrue(actual_line.startswith(start))
+ self.assertTrue(actual_line.endswith(end))
+
+ def assertDunderReprEqual(self, actual: str, expected: str):
+ splitter = r"at 0x[0-9a-f]+"
+ actual_split_list = re.split(splitter, actual, 0, re.I)
+ expected_split_list = re.split(splitter, expected, 0, re.I)
+ for actual_split, expected_split in zip(actual_split_list, expected_split_list):
+ self.assertEqual(actual_split, expected_split)
+
+ def test_sort_by(self):
+ faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA
+ self.assertAlmostEqual(faces[-1].area, 2, 5)
+
+ def test_filter_by_geomtype(self):
+ non_planar_faces = (
+ Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True)
+ )
+ self.assertEqual(len(non_planar_faces), 1)
+ self.assertAlmostEqual(non_planar_faces[0].area, 2 * math.pi, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).faces().filter_by("True")
+
+ def test_filter_by_axis(self):
+ box = Solid.make_box(1, 1, 1)
+ self.assertEqual(len(box.faces().filter_by(Axis.X)), 2)
+ self.assertEqual(len(box.edges().filter_by(Axis.X)), 4)
+ self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0)
+
+ def test_filter_by_callable_predicate(self):
+ boxes = [Solid.make_box(1, 1, 1) for _ in range(3)]
+ boxes[0].label = "A"
+ boxes[1].label = "A"
+ boxes[2].label = "B"
+ shapelist = ShapeList(boxes)
+
+ self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2)
+ self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1)
+
+ def test_first_last(self):
+ vertices = (
+ Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1)))
+ )
+ self.assertVectorAlmostEquals(vertices.last, (1, 1, 1), 5)
+ self.assertVectorAlmostEquals(vertices.first, (0, 0, 0), 5)
+
+ def test_group_by(self):
+ vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
+ self.assertEqual(len(vertices[0]), 4)
+
+ edges = Solid.make_box(1, 1, 1).edges().group_by(SortBy.LENGTH)
+ self.assertEqual(len(edges[0]), 12)
+
+ edges = (
+ Solid.make_cone(2, 1, 2)
+ .edges()
+ .filter_by(GeomType.CIRCLE)
+ .group_by(SortBy.RADIUS)
+ )
+ self.assertEqual(len(edges[0]), 1)
+
+ edges = (Solid.make_cone(2, 1, 2).edges() | GeomType.CIRCLE) << SortBy.RADIUS
+ self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5)
+
+ vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE)
+ self.assertVectorAlmostEquals(vertices[-1][0], (1, 1, 1), 5)
+
+ box = Solid.make_box(1, 1, 2)
+ self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2)
+ self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4)
+
+ line = Edge.make_line((0, 0, 0), (1, 1, 2))
+ vertices_by_line = box.vertices().group_by(line)
+ self.assertEqual(len(vertices_by_line[0]), 1)
+ self.assertEqual(len(vertices_by_line[1]), 2)
+ self.assertEqual(len(vertices_by_line[2]), 1)
+ self.assertEqual(len(vertices_by_line[3]), 1)
+ self.assertEqual(len(vertices_by_line[4]), 2)
+ self.assertEqual(len(vertices_by_line[5]), 1)
+ self.assertVectorAlmostEquals(vertices_by_line[0][0], (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(vertices_by_line[-1][0], (1, 1, 2), 5)
+
+ with BuildPart() as boxes:
+ with GridLocations(10, 10, 3, 3):
+ Box(1, 1, 1)
+ with PolarLocations(100, 10):
+ Box(1, 1, 2)
+ self.assertEqual(len(boxes.solids().group_by(SortBy.VOLUME)[-1]), 10)
+ self.assertEqual(len((boxes.solids()) << SortBy.VOLUME), 9)
+
+ with self.assertRaises(ValueError):
+ boxes.solids().group_by("AREA")
+
+ def test_group_by_callable_predicate(self):
+ boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
+ boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
+ for box in boxesA:
+ box.label = "A"
+ for box in boxesB:
+ box.label = "B"
+ boxNoLabel = Solid.make_box(1, 1, 1)
+
+ shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
+ result = shapelist.group_by(lambda shape: shape.label)
+
+ self.assertEqual([len(group) for group in result], [1, 3, 2])
+
+ def test_group_by_retrieve_groups(self):
+ boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
+ boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
+ for box in boxesA:
+ box.label = "A"
+ for box in boxesB:
+ box.label = "B"
+ boxNoLabel = Solid.make_box(1, 1, 1)
+
+ shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
+ result = shapelist.group_by(lambda shape: shape.label)
+
+ self.assertEqual(len(result.group("")), 1)
+ self.assertEqual(len(result.group("A")), 3)
+ self.assertEqual(len(result.group("B")), 2)
+ self.assertEqual(result.group(""), result[0])
+ self.assertEqual(result.group("A"), result[1])
+ self.assertEqual(result.group("B"), result[2])
+ self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0]))
+ self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0]))
+ with self.assertRaises(KeyError):
+ result.group("C")
+
+ def test_group_by_str_repr(self):
+ nonagon = RegularPolygon(5, 9)
+
+ expected = [
+ "[[],",
+ " [,",
+ " ],",
+ " [,",
+ " ],",
+ " [,",
+ " ],",
+ " [,",
+ " ]]",
+ ]
+ self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
+
+ expected_repr = (
+ "[[],"
+ " [,"
+ " ],"
+ " [,"
+ " ],"
+ " [,"
+ " ],"
+ " [,"
+ " ]]"
+ )
+ self.assertDunderReprEqual(
+ repr(nonagon.edges().group_by(Axis.X)), expected_repr
+ )
+
+ f = io.StringIO()
+ p = pretty.PrettyPrinter(f)
+ nonagon.edges().group_by(Axis.X)._repr_pretty_(p, cycle=True)
+ self.assertEqual(f.getvalue(), "(...)")
+
+ def test_distance(self):
+ with BuildPart() as box:
+ Box(1, 2, 3)
+ obj = (-0.2, 0.1, 0.5)
+ edges = box.edges().sort_by_distance(obj)
+ distances = [Vertex(*obj).distance_to(edge) for edge in edges]
+ self.assertTrue(
+ all([distances[i] >= distances[i - 1] for i in range(1, len(edges))])
+ )
+
+ def test_distance_reverse(self):
+ with BuildPart() as box:
+ Box(1, 2, 3)
+ obj = (-0.2, 0.1, 0.5)
+ edges = box.edges().sort_by_distance(obj, reverse=True)
+ distances = [Vertex(*obj).distance_to(edge) for edge in edges]
+ self.assertTrue(
+ all([distances[i] <= distances[i - 1] for i in range(1, len(edges))])
+ )
+
+ def test_distance_equal(self):
+ with BuildPart() as box:
+ Box(1, 1, 1)
+ self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12)
+
+ def test_vertices(self):
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ self.assertEqual(len(sl.vertices()), 8)
+
+ def test_vertex(self):
+ sl = ShapeList([Edge.make_circle(1)])
+ self.assertTupleAlmostEquals(sl.vertex().to_tuple(), (1, 0, 0), 5)
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ with self.assertWarns(UserWarning):
+ sl.vertex()
+ self.assertEqual(len(Edge().vertices()), 0)
+
+ def test_edges(self):
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ self.assertEqual(len(sl.edges()), 8)
+ self.assertEqual(len(Edge().edges()), 0)
+
+ def test_edge(self):
+ sl = ShapeList([Edge.make_circle(1)])
+ self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5)
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ with self.assertWarns(UserWarning):
+ sl.edge()
+
+ def test_wires(self):
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ self.assertEqual(len(sl.wires()), 2)
+ self.assertEqual(len(Wire().wires()), 0)
+
+ def test_wire(self):
+ sl = ShapeList([Wire.make_circle(1)])
+ self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5)
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ with self.assertWarns(UserWarning):
+ sl.wire()
+
+ def test_faces(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ self.assertEqual(len(sl.faces()), 9)
+ self.assertEqual(len(Face().faces()), 0)
+
+ def test_face(self):
+ sl = ShapeList(
+ [Vertex(1, 1, 1), Edge.make_line((0, 0), (1, 1)), Face.make_rect(2, 1)]
+ )
+ self.assertAlmostEqual(sl.face().area, 2 * 1, 5)
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.face()
+
+ def test_shells(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ self.assertEqual(len(sl.shells()), 2)
+ self.assertEqual(len(Shell().shells()), 0)
+
+ def test_shell(self):
+ sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
+ self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5)
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.shell()
+
+ def test_solids(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ self.assertEqual(len(sl.solids()), 2)
+ self.assertEqual(len(Solid().solids()), 0)
+
+ def test_solid(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.solid()
+ sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)])
+ self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5)
+
+ def test_compounds(self):
+ sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
+ self.assertEqual(len(sl.compounds()), 2)
+ self.assertEqual(len(Compound().compounds()), 0)
+
+ def test_compound(self):
+ sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.compound()
+ sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)])
+ self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5)
+
+ def test_equal(self):
+ box = Box(1, 1, 1)
+ cyl = Cylinder(1, 1)
+ sl = ShapeList([box, cyl])
+ same = ShapeList([cyl, box])
+ self.assertEqual(sl, same)
+ self.assertEqual(sl, AlwaysEqual())
+
+ def test_not_equal(self):
+ sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
+ diff = ShapeList([Box(1, 1, 1), Box(1, 2, 3)])
+ self.assertNotEqual(sl, diff)
+ self.assertNotEqual(sl, object())
+
+ def test_center(self):
+ self.assertEqual(tuple(ShapeList().center()), (0, 0, 0))
+ self.assertEqual(
+ tuple(ShapeList(Vertex(i, 0, 0) for i in range(3)).center()), (1, 0, 0)
+ )
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_shells.py b/tests/test_direct_api/test_shells.py
new file mode 100644
index 0000000..d1f3480
--- /dev/null
+++ b/tests/test_direct_api/test_shells.py
@@ -0,0 +1,118 @@
+"""
+build123d direct api tests
+
+name: test_shells.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import unittest
+
+from build123d.geometry import Plane, Rot, Vector
+from build123d.objects_curve import JernArc, Polyline, Spline
+from build123d.objects_sketch import Circle
+from build123d.operations_generic import sweep
+from build123d.topology import Shell, Solid, Wire
+from tests.base_test import DirectApiTestCase
+
+
+class TestShells(DirectApiTestCase):
+ def test_shell_init(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ self.assertTrue(box_shell.is_valid())
+
+ def test_center(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ self.assertVectorAlmostEquals(box_shell.center(), (0.5, 0.5, 0.5), 5)
+
+ def test_manifold_shell_volume(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ self.assertAlmostEqual(box_shell.volume, 1, 5)
+
+ def test_nonmanifold_shell_volume(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ nm_shell = Shell(box_faces)
+ nm_shell -= nm_shell.faces()[0]
+ self.assertAlmostEqual(nm_shell.volume, 0, 5)
+
+ def test_constructor(self):
+ with self.assertRaises(TypeError):
+ Shell(foo="bar")
+
+ x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5))
+ surface = sweep(x_section, Circle(5).wire())
+ single_face = Shell(surface.face())
+ self.assertTrue(single_face.is_valid())
+ single_face = Shell(surface.faces())
+ self.assertTrue(single_face.is_valid())
+
+ def test_sweep(self):
+ path_c1 = JernArc((0, 0), (-1, 0), 1, 180)
+ path_e = path_c1.edge()
+ path_c2 = JernArc((0, 0), (-1, 0), 1, 180) + JernArc((0, 0), (1, 0), 2, -90)
+ path_w = path_c2.wire()
+ section_e = Circle(0.5).edge()
+ section_c2 = Polyline((0, 0), (0.1, 0), (0.2, 0.1))
+ section_w = section_c2.wire()
+
+ sweep_e_w = Shell.sweep((path_w ^ 0) * section_e, path_w)
+ sweep_w_e = Shell.sweep((path_e ^ 0) * section_w, path_e)
+ sweep_w_w = Shell.sweep((path_w ^ 0) * section_w, path_w)
+ sweep_c2_c1 = Shell.sweep((path_c1 ^ 0) * section_c2, path_c1)
+ sweep_c2_c2 = Shell.sweep((path_c2 ^ 0) * section_c2, path_c2)
+
+ self.assertEqual(len(sweep_e_w.faces()), 2)
+ self.assertEqual(len(sweep_w_e.faces()), 2)
+ self.assertEqual(len(sweep_c2_c1.faces()), 2)
+ self.assertEqual(len(sweep_w_w.faces()), 3) # 3 with clean, 4 without
+ self.assertEqual(len(sweep_c2_c2.faces()), 3) # 3 with clean, 4 without
+
+ def test_make_loft(self):
+ r = 3
+ h = 2
+ loft = Shell.make_loft(
+ [Wire.make_circle(r, Plane((0, 0, h))), Wire.make_circle(r)]
+ )
+ self.assertEqual(loft.volume, 0, "A shell has no volume")
+ cylinder_area = 2 * math.pi * r * h
+ self.assertAlmostEqual(loft.area, cylinder_area)
+
+ def test_thicken(self):
+ rect = Wire.make_rect(10, 5)
+ shell: Shell = Shell.extrude(rect, Vector(0, 0, 3))
+ thick = Solid.thicken(shell, 1)
+
+ self.assertEqual(isinstance(thick, Solid), True)
+ inner_vol = 3 * 10 * 5
+ outer_vol = 3 * 12 * 7
+ self.assertAlmostEqual(thick.volume, outer_vol - inner_vol)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_skip_clean.py b/tests/test_direct_api/test_skip_clean.py
new file mode 100644
index 0000000..a39aeef
--- /dev/null
+++ b/tests/test_direct_api/test_skip_clean.py
@@ -0,0 +1,70 @@
+"""
+build123d direct api tests
+
+name: test_skip_clean.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.topology import SkipClean
+
+
+class TestSkipClean(unittest.TestCase):
+ def setUp(self):
+ # Ensure the class variable is in its default state before each test
+ SkipClean.clean = True
+
+ def test_context_manager_sets_clean_false(self):
+ # Verify `clean` is initially True
+ self.assertTrue(SkipClean.clean)
+
+ # Use the context manager
+ with SkipClean():
+ # Within the context, `clean` should be False
+ self.assertFalse(SkipClean.clean)
+
+ # After exiting the context, `clean` should revert to True
+ self.assertTrue(SkipClean.clean)
+
+ def test_exception_handling_does_not_affect_clean(self):
+ # Verify `clean` is initially True
+ self.assertTrue(SkipClean.clean)
+
+ # Use the context manager and raise an exception
+ try:
+ with SkipClean():
+ self.assertFalse(SkipClean.clean)
+ raise ValueError("Test exception")
+ except ValueError:
+ pass
+
+ # Ensure `clean` is restored to True after an exception
+ self.assertTrue(SkipClean.clean)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py
new file mode 100644
index 0000000..fad617a
--- /dev/null
+++ b/tests/test_direct_api/test_solid.py
@@ -0,0 +1,245 @@
+"""
+build123d direct api tests
+
+name: test_solid.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import unittest
+
+from build123d.build_enums import GeomType, Kind, Until
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.objects_curve import Spline
+from build123d.objects_sketch import Circle, Rectangle
+from build123d.topology import Compound, Edge, Face, Shell, Solid, Vertex, Wire
+from tests.base_test import DirectApiTestCase
+
+
+class TestSolid(DirectApiTestCase):
+ def test_make_solid(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ box = Solid(box_shell)
+ self.assertAlmostEqual(box.area, 6, 5)
+ self.assertAlmostEqual(box.volume, 1, 5)
+ self.assertTrue(box.is_valid())
+
+ def test_extrude(self):
+ v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1))
+ self.assertAlmostEqual(v.length, 1, 5)
+
+ e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1))
+ self.assertAlmostEqual(e.area, 1, 5)
+
+ w = Shell.extrude(
+ Wire([Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))]),
+ (0, 0, 1),
+ )
+ self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5)
+
+ f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1))
+ self.assertAlmostEqual(f.volume, 1, 5)
+
+ s = Compound.extrude(
+ Shell(
+ Solid.make_box(1, 1, 1)
+ .locate(Location((-2, 1, 0)))
+ .faces()
+ .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:]
+ ),
+ (0.1, 0.1, 0.1),
+ )
+ self.assertAlmostEqual(s.volume, 0.2, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1))
+
+ def test_extrude_taper(self):
+ a = 1
+ rect = Face.make_rect(a, a)
+ flipped = -rect
+ for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]:
+ for taper in [10, -10]:
+ offset_amt = -direction.length * math.tan(math.radians(taper))
+ for face in [rect, flipped]:
+ with self.subTest(
+ f"{direction=}, {taper=}, flipped={face==flipped}"
+ ):
+ taper_solid = Solid.extrude_taper(face, direction, taper)
+ # V = 1/3 × h × (a² + b² + ab)
+ h = Vector(direction).length
+ b = a + 2 * offset_amt
+ v = h * (a**2 + b**2 + a * b) / 3
+ self.assertAlmostEqual(taper_solid.volume, v, 5)
+ bbox = taper_solid.bounding_box()
+ size = max(1, b) / 2
+ if direction.Z > 0:
+ self.assertVectorAlmostEquals(
+ bbox.min, (-size, -size, 0), 1
+ )
+ self.assertVectorAlmostEquals(bbox.max, (size, size, h), 1)
+ else:
+ self.assertVectorAlmostEquals(
+ bbox.min, (-size, -size, -h), 1
+ )
+ self.assertVectorAlmostEquals(bbox.max, (size, size, 0), 1)
+
+ def test_extrude_taper_with_hole(self):
+ rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
+ direction = Vector(0, 0, 0.5)
+ taper = 10
+ taper_solid = Solid.extrude_taper(rect_hole, direction, taper)
+ offset_amt = -direction.length * math.tan(math.radians(taper))
+ hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
+ self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5)
+
+ def test_extrude_taper_with_hole_flipped(self):
+ rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
+ direction = Vector(0, 0, 1)
+ taper = 10
+ taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True)
+ taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False)
+ hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
+ hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
+ self.assertGreater(hole_t.radius, hole_f.radius)
+
+ def test_extrude_taper_oblique(self):
+ rect = Face.make_rect(2, 1)
+ rect_hole = rect.make_holes([Wire.make_circle(0.25)])
+ o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77))
+ taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5)
+ taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5)
+ self.assertAlmostEqual(taper0.volume, taper1.volume, 5)
+
+ def test_extrude_linear_with_rotation(self):
+ # Face
+ base = Face.make_rect(1, 1)
+ twist = Solid.extrude_linear_with_rotation(
+ base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
+ )
+ self.assertAlmostEqual(twist.volume, 1, 5)
+ top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
+ bottom = twist.faces().sort_by(Axis.Z)[0]
+ self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
+ # Wire
+ base = Wire.make_rect(1, 1)
+ twist = Solid.extrude_linear_with_rotation(
+ base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
+ )
+ self.assertAlmostEqual(twist.volume, 1, 5)
+ top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
+ bottom = twist.faces().sort_by(Axis.Z)[0]
+ self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
+
+ def test_make_loft(self):
+ loft = Solid.make_loft(
+ [Wire.make_rect(2, 2), Wire.make_circle(1, Plane((0, 0, 1)))]
+ )
+ self.assertAlmostEqual(loft.volume, (4 + math.pi) / 2, 1)
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft([Wire.make_rect(1, 1)])
+
+ def test_make_loft_with_vertices(self):
+ loft = Solid.make_loft(
+ [Vertex(0, 0, -1), Wire.make_rect(1, 1.5), Vertex(0, 0, 1)], True
+ )
+ self.assertAlmostEqual(loft.volume, 1, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft(
+ [Wire.make_rect(1, 1), Vertex(0, 0, 1), Wire.make_rect(1, 1)]
+ )
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft([Vertex(0, 0, 1), Vertex(0, 0, 2)])
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft(
+ [
+ Vertex(0, 0, 1),
+ Wire.make_rect(1, 1),
+ Vertex(0, 0, 2),
+ Vertex(0, 0, 3),
+ ]
+ )
+
+ def test_extrude_until(self):
+ square = Face.make_rect(1, 1)
+ box = Solid.make_box(4, 4, 1, Plane((-2, -2, 3)))
+ extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST)
+ self.assertAlmostEqual(extrusion.volume, 4, 5)
+
+ square = Face.make_rect(1, 1)
+ box = Solid.make_box(4, 4, 1, Plane((-2, -2, -3)))
+ extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.PREVIOUS)
+ self.assertAlmostEqual(extrusion.volume, 2, 5)
+
+ def test_sweep(self):
+ path = Edge.make_spline([(0, 0), (3, 5), (7, -2)])
+ section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0))
+ area = Face(section).area
+ swept = Solid.sweep(section, path)
+ self.assertAlmostEqual(swept.volume, path.length * area, 0)
+
+ def test_hollow_sweep(self):
+ path = Edge.make_line((0, 0, 0), (0, 0, 5))
+ section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0]
+ swept = Solid.sweep(section, path)
+ self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5)
+
+ def test_sweep_multi(self):
+ f0 = Face.make_rect(1, 1)
+ f1 = Pos(X=10) * Circle(1).face()
+ path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (0, 0, -1)))
+ binormal = Edge.make_line((0, 1), (10, 1))
+ swept = Solid.sweep_multi([f0, f1], path, is_frenet=True, binormal=binormal)
+ self.assertAlmostEqual(swept.volume, 23.78, 2)
+
+ path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (1, 0, 0)))
+ swept = Solid.sweep_multi(
+ [f0, f1], path, is_frenet=True, binormal=Vector(5, 0, 1)
+ )
+ self.assertAlmostEqual(swept.volume, 20.75, 2)
+
+ def test_constructor(self):
+ with self.assertRaises(TypeError):
+ Solid(foo="bar")
+
+ def test_offset_3d(self):
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).offset_3d(None, 0.1, kind=Kind.TANGENT)
+
+ def test_revolve(self):
+ r = Solid.revolve(
+ Face.make_rect(1, 1, Plane((10, 0, 0))).wire(), 180, axis=Axis.Y
+ )
+ self.assertEqual(len(r.faces()), 6)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_v_t_k_poly_data.py b/tests/test_direct_api/test_v_t_k_poly_data.py
new file mode 100644
index 0000000..344e957
--- /dev/null
+++ b/tests/test_direct_api/test_v_t_k_poly_data.py
@@ -0,0 +1,89 @@
+"""
+build123d direct api tests
+
+name: test_v_t_k_poly_data.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.topology import Solid
+from vtkmodules.vtkCommonDataModel import vtkPolyData
+from vtkmodules.vtkFiltersCore import vtkTriangleFilter
+
+
+class TestVTKPolyData(unittest.TestCase):
+ def setUp(self):
+ # Create a simple test object (e.g., a cylinder)
+ self.object_under_test = Solid.make_cylinder(1, 2)
+
+ def test_to_vtk_poly_data(self):
+ # Generate VTK data
+ vtk_data = self.object_under_test.to_vtk_poly_data(
+ tolerance=0.1, angular_tolerance=0.2, normals=True
+ )
+
+ # Verify the result is of type vtkPolyData
+ self.assertIsInstance(vtk_data, vtkPolyData)
+
+ # Further verification can include:
+ # - Checking the number of points, polygons, or cells
+ self.assertGreater(
+ vtk_data.GetNumberOfPoints(), 0, "VTK data should have points."
+ )
+ self.assertGreater(
+ vtk_data.GetNumberOfCells(), 0, "VTK data should have cells."
+ )
+
+ # Optionally, compare the output with a known reference object
+ # (if available) by exporting or analyzing the VTK data
+ known_filter = vtkTriangleFilter()
+ known_filter.SetInputData(vtk_data)
+ known_filter.Update()
+ known_output = known_filter.GetOutput()
+
+ self.assertEqual(
+ vtk_data.GetNumberOfPoints(),
+ known_output.GetNumberOfPoints(),
+ "Number of points in VTK data does not match the expected output.",
+ )
+ self.assertEqual(
+ vtk_data.GetNumberOfCells(),
+ known_output.GetNumberOfCells(),
+ "Number of cells in VTK data does not match the expected output.",
+ )
+
+ def test_empty_shape(self):
+ # Test handling of empty shape
+ empty_object = Solid() # Create an empty object
+ with self.assertRaises(ValueError) as context:
+ empty_object.to_vtk_poly_data()
+
+ self.assertEqual(str(context.exception), "Cannot convert an empty shape")
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py
new file mode 100644
index 0000000..9c635d7
--- /dev/null
+++ b/tests/test_direct_api/test_vector.py
@@ -0,0 +1,287 @@
+"""
+build123d direct api tests
+
+name: test_vector.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 copy
+import math
+import unittest
+
+from OCP.gp import gp_Vec, gp_XYZ
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.topology import Solid, Vertex
+from tests.base_test import DirectApiTestCase, DEG2RAD, AlwaysEqual
+
+
+class TestVector(DirectApiTestCase):
+ """Test the Vector methods"""
+
+ def test_vector_constructors(self):
+ v1 = Vector(1, 2, 3)
+ v2 = Vector((1, 2, 3))
+ v3 = Vector(gp_Vec(1, 2, 3))
+ v4 = Vector([1, 2, 3])
+ v5 = Vector(gp_XYZ(1, 2, 3))
+ v5b = Vector(X=1, Y=2, Z=3)
+ v5c = Vector(v=gp_XYZ(1, 2, 3))
+
+ for v in [v1, v2, v3, v4, v5, v5b, v5c]:
+ self.assertVectorAlmostEquals(v, (1, 2, 3), 4)
+
+ v6 = Vector((1, 2))
+ v7 = Vector([1, 2])
+ v8 = Vector(1, 2)
+ v8b = Vector(X=1, Y=2)
+
+ for v in [v6, v7, v8, v8b]:
+ self.assertVectorAlmostEquals(v, (1, 2, 0), 4)
+
+ v9 = Vector()
+ self.assertVectorAlmostEquals(v9, (0, 0, 0), 4)
+
+ v9.X = 1.0
+ v9.Y = 2.0
+ v9.Z = 3.0
+ self.assertVectorAlmostEquals(v9, (1, 2, 3), 4)
+ self.assertVectorAlmostEquals(Vector(1, 2, 3, 4), (1, 2, 3), 4)
+
+ v10 = Vector(1)
+ v11 = Vector((1,))
+ v12 = Vector([1])
+ v13 = Vector(X=1)
+ for v in [v10, v11, v12, v13]:
+ self.assertVectorAlmostEquals(v, (1, 0, 0), 4)
+
+ vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10))
+ self.assertVectorAlmostEquals(Vector(vertex), (0, 0, 10), 4)
+
+ with self.assertRaises(TypeError):
+ Vector("vector")
+ with self.assertRaises(ValueError):
+ Vector(x=1)
+
+ def test_vector_rotate(self):
+ """Validate vector rotate methods"""
+ vector_x = Vector(1, 0, 1).rotate(Axis.X, 45)
+ vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45)
+ vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45)
+ self.assertVectorAlmostEquals(
+ vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7
+ )
+ self.assertVectorAlmostEquals(vector_y, (math.sqrt(2), 2, 0), 7)
+ self.assertVectorAlmostEquals(vector_z, (0, -math.sqrt(2), 3), 7)
+
+ def test_get_signed_angle(self):
+ """Verify getSignedAngle calculations with and without a provided normal"""
+ a = math.pi / 3
+ v1 = Vector(1, 0, 0)
+ v2 = Vector(math.cos(a), -math.sin(a), 0)
+ d1 = v1.get_signed_angle(v2)
+ d2 = v1.get_signed_angle(v2, Vector(0, 0, 1))
+ self.assertAlmostEqual(d1, a * 180 / math.pi)
+ self.assertAlmostEqual(d2, -a * 180 / math.pi)
+
+ def test_center(self):
+ v = Vector(1, 1, 1)
+ self.assertAlmostEqual(v, v.center())
+
+ def test_dot(self):
+ v1 = Vector(2, 2, 2)
+ v2 = Vector(1, -1, 1)
+ self.assertEqual(2.0, v1.dot(v2))
+
+ def test_vector_add(self):
+ result = Vector(1, 2, 0) + Vector(0, 0, 3)
+ self.assertVectorAlmostEquals(result, (1.0, 2.0, 3.0), 3)
+
+ def test_vector_operators(self):
+ result = Vector(1, 1, 1) + Vector(2, 2, 2)
+ self.assertEqual(Vector(3, 3, 3), result)
+
+ result = Vector(1, 2, 3) - Vector(3, 2, 1)
+ self.assertEqual(Vector(-2, 0, 2), result)
+
+ result = Vector(1, 2, 3) * 2
+ self.assertEqual(Vector(2, 4, 6), result)
+
+ result = 3 * Vector(1, 2, 3)
+ self.assertEqual(Vector(3, 6, 9), result)
+
+ result = Vector(2, 4, 6) / 2
+ self.assertEqual(Vector(1, 2, 3), result)
+
+ self.assertEqual(Vector(-1, -1, -1), -Vector(1, 1, 1))
+
+ self.assertEqual(0, abs(Vector(0, 0, 0)))
+ self.assertEqual(1, abs(Vector(1, 0, 0)))
+ self.assertEqual((1 + 4 + 9) ** 0.5, abs(Vector(1, 2, 3)))
+
+ def test_vector_equals(self):
+ a = Vector(1, 2, 3)
+ b = Vector(1, 2, 3)
+ c = Vector(1, 2, 3.000001)
+ self.assertEqual(a, b)
+ self.assertEqual(a, c)
+ self.assertEqual(a, AlwaysEqual())
+
+ def test_vector_not_equal(self):
+ a = Vector(1, 2, 3)
+ b = Vector(3, 2, 1)
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, object())
+
+ def test_vector_distance(self):
+ """
+ Test line distance from plane.
+ """
+ v = Vector(1, 2, 3)
+
+ self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ))
+ self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX))
+ self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY))
+ self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY))
+ self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ))
+ self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX))
+
+ self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ))
+ self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX))
+ self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY))
+ self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY))
+ self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ))
+ self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX))
+
+ def test_vector_project(self):
+ """
+ Test line projection and plane projection methods of Vector
+ """
+ decimal_places = 9
+
+ z_dir = Vector(1, 2, 3)
+ base = Vector(5, 7, 9)
+ x_dir = Vector(1, 0, 0)
+
+ # test passing Plane object
+ point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir))
+ self.assertVectorAlmostEquals(point, (59 / 7, 55 / 7, 51 / 7), decimal_places)
+
+ # test line projection
+ vec = Vector(10, 10, 10)
+ line = Vector(3, 4, 5)
+ angle = vec.get_angle(line) * DEG2RAD
+
+ vecLineProjection = vec.project_to_line(line)
+
+ self.assertVectorAlmostEquals(
+ vecLineProjection.normalized(),
+ line.normalized(),
+ decimal_places,
+ )
+ self.assertAlmostEqual(
+ vec.length * math.cos(angle), vecLineProjection.length, decimal_places
+ )
+
+ def test_vector_not_implemented(self):
+ pass
+
+ def test_vector_special_methods(self):
+ self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)")
+ self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)")
+ self.assertEqual(
+ str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)),
+ "Vector(10, -23.65, 0)",
+ )
+
+ def test_vector_iter(self):
+ self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6)
+
+ def test_reverse(self):
+ self.assertVectorAlmostEquals(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7)
+
+ def test_copy(self):
+ v2 = copy.copy(Vector(1, 2, 3))
+ v3 = copy.deepcopy(Vector(1, 2, 3))
+ self.assertVectorAlmostEquals(v2, (1, 2, 3), 7)
+ self.assertVectorAlmostEquals(v3, (1, 2, 3), 7)
+
+ def test_radd(self):
+ vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)]
+ vector_sum = sum(vectors)
+ self.assertVectorAlmostEquals(vector_sum, (12, 15, 18), 5)
+
+ def test_hash(self):
+ vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)]
+ unique_vectors = list(set(vectors))
+ self.assertEqual(len(vectors), 4)
+ self.assertEqual(len(unique_vectors), 3)
+
+ def test_vector_transform(self):
+ a = Vector(1, 2, 3)
+ pxy = Plane.XY
+ pxy_o1 = Plane.XY.offset(1)
+ self.assertEqual(a.transform(pxy.forward_transform, is_direction=False), a)
+ self.assertEqual(
+ a.transform(pxy.forward_transform, is_direction=True), a.normalized()
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.forward_transform, is_direction=False), Vector(1, 2, 2)
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.forward_transform, is_direction=True), a.normalized()
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.reverse_transform, is_direction=False), Vector(1, 2, 4)
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.reverse_transform, is_direction=True), a.normalized()
+ )
+
+ def test_intersect(self):
+ v1 = Vector(1, 2, 3)
+ self.assertVectorAlmostEquals(v1 & Vector(1, 2, 3), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Vector(0, 0, 0))
+
+ self.assertVectorAlmostEquals(v1 & Location((1, 2, 3)), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Location())
+
+ self.assertVectorAlmostEquals(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Axis.X)
+
+ self.assertVectorAlmostEquals(v1 & Plane((1, 2, 3)), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Plane.XY)
+
+ self.assertVectorAlmostEquals(
+ (v1 & Solid.make_box(2, 4, 5)).vertex(), (1, 2, 3), 5
+ )
+ self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)))
+ self.assertIsNone(
+ Vertex(-10, -10, -10).intersect(Solid.make_box(0.5, 0.5, 0.5))
+ )
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_vector_like.py b/tests/test_direct_api/test_vector_like.py
new file mode 100644
index 0000000..5e4d083
--- /dev/null
+++ b/tests/test_direct_api/test_vector_like.py
@@ -0,0 +1,58 @@
+"""
+build123d direct api tests
+
+name: test_vector_like.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.geometry import Axis, Vector
+from build123d.topology import Vertex
+from tests.base_test import DirectApiTestCase
+
+
+class TestVectorLike(DirectApiTestCase):
+ """Test typedef"""
+
+ def test_axis_from_vertex(self):
+ axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1))
+ self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
+
+ def test_axis_from_vector(self):
+ axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1))
+ self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
+
+ def test_axis_from_tuple(self):
+ axis = Axis((1, 2, 3), (0, 0, 1))
+ self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
+ self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_vertex.py b/tests/test_direct_api/test_vertex.py
new file mode 100644
index 0000000..550ba07
--- /dev/null
+++ b/tests/test_direct_api/test_vertex.py
@@ -0,0 +1,111 @@
+"""
+build123d direct api tests
+
+name: test_vertex.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 build123d.geometry import Axis, Vector
+from build123d.topology import Vertex
+from tests.base_test import DirectApiTestCase
+
+
+class TestVertex(DirectApiTestCase):
+ """Test the extensions to the cadquery Vertex class"""
+
+ def test_basic_vertex(self):
+ v = Vertex()
+ self.assertEqual(0, v.X)
+
+ v = Vertex(1, 1, 1)
+ self.assertEqual(1, v.X)
+ self.assertEqual(Vector, type(v.center()))
+
+ self.assertVectorAlmostEquals(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7)
+ self.assertVectorAlmostEquals(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7)
+ self.assertVectorAlmostEquals(Vector(Vertex((7,))), (7, 0, 0), 7)
+ self.assertVectorAlmostEquals(Vector(Vertex((8, 9))), (8, 9, 0), 7)
+
+ def test_vertex_volume(self):
+ v = Vertex(1, 1, 1)
+ self.assertAlmostEqual(v.volume, 0, 5)
+
+ def test_vertex_add(self):
+ test_vertex = Vertex(0, 0, 0)
+ self.assertVectorAlmostEquals(
+ Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7
+ )
+ self.assertVectorAlmostEquals(
+ Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7
+ )
+ self.assertVectorAlmostEquals(
+ Vector(test_vertex + Vertex(100, -40, 10)),
+ (100, -40, 10),
+ 7,
+ )
+ with self.assertRaises(TypeError):
+ test_vertex + [1, 2, 3]
+
+ def test_vertex_sub(self):
+ test_vertex = Vertex(0, 0, 0)
+ self.assertVectorAlmostEquals(
+ Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7
+ )
+ self.assertVectorAlmostEquals(
+ Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7
+ )
+ self.assertVectorAlmostEquals(
+ Vector(test_vertex - Vertex(100, -40, 10)),
+ (-100, 40, -10),
+ 7,
+ )
+ with self.assertRaises(TypeError):
+ test_vertex - [1, 2, 3]
+
+ def test_vertex_str(self):
+ self.assertEqual(str(Vertex(0, 0, 0)), "Vertex(0.0, 0.0, 0.0)")
+
+ def test_vertex_to_vector(self):
+ self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector)
+ self.assertVectorAlmostEquals(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7)
+
+ def test_vertex_init_error(self):
+ with self.assertRaises(TypeError):
+ Vertex(Axis.Z)
+ with self.assertRaises(ValueError):
+ Vertex(x=1)
+ with self.assertRaises(TypeError):
+ Vertex((Axis.X, Axis.Y, Axis.Z))
+
+ def test_no_intersect(self):
+ with self.assertRaises(NotImplementedError):
+ Vertex(1, 2, 3) & Vertex(5, 6, 7)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py
new file mode 100644
index 0000000..7737bf4
--- /dev/null
+++ b/tests/test_direct_api/test_wire.py
@@ -0,0 +1,223 @@
+"""
+build123d direct api tests
+
+name: test_wire.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 math
+import random
+import unittest
+
+from build123d.build_enums import Side
+from build123d.geometry import Axis, Color, Location
+from build123d.objects_curve import Polyline, Spline
+from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
+from build123d.topology import Edge, Face, Wire
+from tests.base_test import DirectApiTestCase
+
+
+class TestWire(DirectApiTestCase):
+ def test_ellipse_arc(self):
+ full_ellipse = Wire.make_ellipse(2, 1)
+ half_ellipse = Wire.make_ellipse(
+ 2, 1, start_angle=0, end_angle=180, closed=True
+ )
+ self.assertAlmostEqual(full_ellipse.area / 2, half_ellipse.area, 5)
+
+ def test_stitch(self):
+ half_ellipse1 = Wire.make_ellipse(
+ 2, 1, start_angle=0, end_angle=180, closed=False
+ )
+ half_ellipse2 = Wire.make_ellipse(
+ 2, 1, start_angle=180, end_angle=360, closed=False
+ )
+ ellipse = half_ellipse1.stitch(half_ellipse2)
+ self.assertEqual(len(ellipse.wires()), 1)
+
+ def test_fillet_2d(self):
+ square = Wire.make_rect(1, 1)
+ squaroid = square.fillet_2d(0.1, square.vertices())
+ self.assertAlmostEqual(
+ squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5
+ )
+
+ def test_chamfer_2d(self):
+ square = Wire.make_rect(1, 1)
+ squaroid = square.chamfer_2d(0.1, 0.1, square.vertices())
+ self.assertAlmostEqual(
+ squaroid.length, 4 * (1 - 2 * 0.1 + 0.1 * math.sqrt(2)), 5
+ )
+
+ def test_chamfer_2d_edge(self):
+ square = Wire.make_rect(1, 1)
+ edge = square.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ square = square.chamfer_2d(
+ distance=0.1, distance2=0.2, vertices=[vertex], edge=edge
+ )
+ self.assertAlmostEqual(square.edges().sort_by(Axis.Y)[0].length, 0.9)
+
+ def test_make_convex_hull(self):
+ # overlapping_edges = [
+ # Edge.make_circle(10, end_angle=60),
+ # Edge.make_circle(10, start_angle=30, end_angle=90),
+ # Edge.make_line((-10, 10), (10, -10)),
+ # ]
+ # with self.assertRaises(ValueError):
+ # Wire.make_convex_hull(overlapping_edges)
+
+ adjoining_edges = [
+ Edge.make_circle(10, end_angle=45),
+ Edge.make_circle(10, start_angle=315, end_angle=360),
+ Edge.make_line((-10, 10), (-10, -10)),
+ ]
+ hull_wire = Wire.make_convex_hull(adjoining_edges)
+ self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4)
+
+ # def test_fix_degenerate_edges(self):
+ # # Can't find a way to create one
+ # edge0 = Edge.make_line((0, 0, 0), (1, 0, 0))
+ # edge1 = Edge.make_line(edge0 @ 0, edge0 @ 0 + Vector(0, 1, 0))
+ # edge1a = edge1.trim(0, 1e-7)
+ # edge1b = edge1.trim(1e-7, 1.0)
+ # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0))
+ # wire = Wire([edge0, edge1a, edge1b, edge2])
+ # fixed_wire = wire.fix_degenerate_edges(1e-6)
+ # self.assertEqual(len(fixed_wire.edges()), 2)
+
+ def test_trim(self):
+ e0 = Edge.make_line((0, 0), (1, 0))
+ e1 = Edge.make_line((2, 0), (1, 0))
+ e2 = Edge.make_line((2, 0), (3, 0))
+ w1 = Wire([e0, e1, e2])
+ t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0)))
+ self.assertAlmostEqual(t1.length, 2.1, 5)
+
+ e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
+ # Three edges are created 0->0.5->0.75->1.0
+ o = e.offset_2d(10, side=Side.RIGHT, closed=False)
+ t2 = o.trim(0.1, 0.9)
+ self.assertAlmostEqual(t2.length, o.length * 0.8, 5)
+
+ t3 = o.trim(0.5, 1.0)
+ self.assertAlmostEqual(t3.length, o.length * 0.5, 5)
+
+ t4 = o.trim(0.5, 0.75)
+ self.assertAlmostEqual(t4.length, o.length * 0.25, 5)
+
+ with self.assertRaises(ValueError):
+ o.trim(0.75, 0.25)
+ spline = Spline(
+ (0, 0, 0),
+ (0, 10, 0),
+ tangents=((0, 0, 1), (0, 0, -1)),
+ tangent_scalars=(2, 2),
+ )
+ half = spline.trim(0.5, 1)
+ self.assertVectorAlmostEquals(spline @ 0.5, half @ 0, 4)
+ self.assertVectorAlmostEquals(spline @ 1, half @ 1, 4)
+
+ w = Rectangle(3, 1).wire()
+ t5 = w.trim(0, 0.5)
+ self.assertAlmostEqual(t5.length, 4, 5)
+ t6 = w.trim(0.5, 1)
+ self.assertAlmostEqual(t6.length, 4, 5)
+
+ p = RegularPolygon(10, 20).wire()
+ t7 = p.trim(0.1, 0.2)
+ self.assertAlmostEqual(p.length * 0.1, t7.length, 5)
+
+ c = Circle(10).wire()
+ t8 = c.trim(0.4, 0.9)
+ self.assertAlmostEqual(c.length * 0.5, t8.length, 5)
+
+ def test_param_at_point(self):
+ e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
+ # Three edges are created 0->0.5->0.75->1.0
+ o = e.offset_2d(10, side=Side.RIGHT, closed=False)
+
+ e0 = Edge.make_line((0, 0), (1, 0))
+ e1 = Edge.make_line((2, 0), (1, 0))
+ e2 = Edge.make_line((2, 0), (3, 0))
+ w1 = Wire([e0, e1, e2])
+ for wire in [o, w1]:
+ u_value = random.random()
+ position = wire.position_at(u_value)
+ self.assertAlmostEqual(wire.param_at_point(position), u_value, 4)
+
+ with self.assertRaises(ValueError):
+ o.param_at_point((-1, 1))
+
+ with self.assertRaises(ValueError):
+ w1.param_at_point((20, 20, 20))
+
+ def test_order_edges(self):
+ w1 = Wire(
+ [
+ Edge.make_line((0, 0), (1, 0)),
+ Edge.make_line((1, 1), (1, 0)),
+ Edge.make_line((0, 1), (1, 1)),
+ ]
+ )
+ ordered_edges = w1.order_edges()
+ self.assertFalse(all(e.is_forward for e in w1.edges()))
+ self.assertTrue(all(e.is_forward for e in ordered_edges))
+ self.assertVectorAlmostEquals(ordered_edges[0] @ 0, (0, 0, 0), 5)
+ self.assertVectorAlmostEquals(ordered_edges[1] @ 0, (1, 0, 0), 5)
+ self.assertVectorAlmostEquals(ordered_edges[2] @ 0, (1, 1, 0), 5)
+
+ def test_constructor(self):
+ e0 = Edge.make_line((0, 0), (1, 0))
+ e1 = Edge.make_line((1, 0), (1, 1))
+ w0 = Wire.make_circle(1)
+ w1 = Wire(e0)
+ self.assertTrue(w1.is_valid())
+ w2 = Wire([e0])
+ self.assertAlmostEqual(w2.length, 1, 5)
+ self.assertTrue(w2.is_valid())
+ w3 = Wire([e0, e1])
+ self.assertTrue(w3.is_valid())
+ self.assertAlmostEqual(w3.length, 2, 5)
+ w4 = Wire(w0.wrapped)
+ self.assertTrue(w4.is_valid())
+ w5 = Wire(obj=w0.wrapped)
+ self.assertTrue(w5.is_valid())
+ w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red"))
+ self.assertTrue(w6.is_valid())
+ self.assertEqual(w6.label, "w6")
+ self.assertTupleAlmostEquals(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 5)
+ w7 = Wire(w6)
+ self.assertTrue(w7.is_valid())
+ c0 = Polyline((0, 0), (1, 0), (1, 1))
+ w8 = Wire(c0)
+ self.assertTrue(w8.is_valid())
+ with self.assertRaises(ValueError):
+ Wire(bob="fred")
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
From bb6a542244001f75db470876136c937983f1d291 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 21 Jan 2025 21:42:05 -0500
Subject: [PATCH 152/518] Revert "Split test_direct_api.py in many smaller
tests"
This reverts commit 23e035a1ce105a72712b88fd3fbc74c39c95f221.
---
tests/__init__.py | 0
tests/test_direct_api.py | 17 +-
tests/test_direct_api/test_always_equal.py | 40 --
tests/test_direct_api/test_assembly.py | 127 ----
tests/test_direct_api/test_axis.py | 208 ------
tests/test_direct_api/test_bound_box.py | 107 ---
tests/test_direct_api/test_cad_objects.py | 267 --------
tests/test_direct_api/test_clean_method.py | 72 --
tests/test_direct_api/test_color.py | 127 ----
tests/test_direct_api/test_compound.py | 165 -----
.../test_direct_api_test_case.py | 60 --
tests/test_direct_api/test_edge.py | 293 ---------
tests/test_direct_api/test_face.py | 459 -------------
tests/test_direct_api/test_functions.py | 107 ---
tests/test_direct_api/test_group_by.py | 71 --
tests/test_direct_api/test_import_export.py | 70 --
tests/test_direct_api/test_jupyter.py | 60 --
tests/test_direct_api/test_location.py | 392 -----------
tests/test_direct_api/test_matrix.py | 197 ------
tests/test_direct_api/test_mixin1_d.py | 321 ---------
tests/test_direct_api/test_mixin3_d.py | 157 -----
tests/test_direct_api/test_plane.py | 504 --------------
tests/test_direct_api/test_projection.py | 106 ---
tests/test_direct_api/test_rotation.py | 61 --
tests/test_direct_api/test_shape.py | 615 ------------------
tests/test_direct_api/test_shape_list.py | 364 -----------
tests/test_direct_api/test_shells.py | 118 ----
tests/test_direct_api/test_skip_clean.py | 70 --
tests/test_direct_api/test_solid.py | 245 -------
tests/test_direct_api/test_v_t_k_poly_data.py | 89 ---
tests/test_direct_api/test_vector.py | 287 --------
tests/test_direct_api/test_vector_like.py | 58 --
tests/test_direct_api/test_vertex.py | 111 ----
tests/test_direct_api/test_wire.py | 223 -------
34 files changed, 10 insertions(+), 6158 deletions(-)
delete mode 100644 tests/__init__.py
delete mode 100644 tests/test_direct_api/test_always_equal.py
delete mode 100644 tests/test_direct_api/test_assembly.py
delete mode 100644 tests/test_direct_api/test_axis.py
delete mode 100644 tests/test_direct_api/test_bound_box.py
delete mode 100644 tests/test_direct_api/test_cad_objects.py
delete mode 100644 tests/test_direct_api/test_clean_method.py
delete mode 100644 tests/test_direct_api/test_color.py
delete mode 100644 tests/test_direct_api/test_compound.py
delete mode 100644 tests/test_direct_api/test_direct_api_test_case.py
delete mode 100644 tests/test_direct_api/test_edge.py
delete mode 100644 tests/test_direct_api/test_face.py
delete mode 100644 tests/test_direct_api/test_functions.py
delete mode 100644 tests/test_direct_api/test_group_by.py
delete mode 100644 tests/test_direct_api/test_import_export.py
delete mode 100644 tests/test_direct_api/test_jupyter.py
delete mode 100644 tests/test_direct_api/test_location.py
delete mode 100644 tests/test_direct_api/test_matrix.py
delete mode 100644 tests/test_direct_api/test_mixin1_d.py
delete mode 100644 tests/test_direct_api/test_mixin3_d.py
delete mode 100644 tests/test_direct_api/test_plane.py
delete mode 100644 tests/test_direct_api/test_projection.py
delete mode 100644 tests/test_direct_api/test_rotation.py
delete mode 100644 tests/test_direct_api/test_shape.py
delete mode 100644 tests/test_direct_api/test_shape_list.py
delete mode 100644 tests/test_direct_api/test_shells.py
delete mode 100644 tests/test_direct_api/test_skip_clean.py
delete mode 100644 tests/test_direct_api/test_solid.py
delete mode 100644 tests/test_direct_api/test_v_t_k_poly_data.py
delete mode 100644 tests/test_direct_api/test_vector.py
delete mode 100644 tests/test_direct_api/test_vector_like.py
delete mode 100644 tests/test_direct_api/test_vertex.py
delete mode 100644 tests/test_direct_api/test_wire.py
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 7d2f47c..4dcbfaf 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -1054,15 +1054,18 @@ class TestEdge(DirectApiTestCase):
line.find_intersection_points(Plane.YZ)
# def test_intersections_tolerance(self):
- # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
- # l1 = Edge.make_line((1, 0), (2, 0))
- # i1 = l1.intersect(*r1)
- # r2 = Rectangle(2, 2).edges()
- # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
- # i2 = l2.intersect(*r2)
+ # Multiple operands not currently supported
- # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
+ # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
+ # l1 = Edge.make_line((1, 0), (2, 0))
+ # i1 = l1.intersect(*r1)
+
+ # r2 = Rectangle(2, 2).edges()
+ # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
+ # i2 = l2.intersect(*r2)
+
+ # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
def test_trim(self):
line = Edge.make_line((-2, 0), (2, 0))
diff --git a/tests/test_direct_api/test_always_equal.py b/tests/test_direct_api/test_always_equal.py
deleted file mode 100644
index 9b2292f..0000000
--- a/tests/test_direct_api/test_always_equal.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_always_equal.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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
-
-
-class AlwaysEqual:
- def __eq__(self, other):
- return True
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py
deleted file mode 100644
index b464a0c..0000000
--- a/tests/test_direct_api/test_assembly.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_assembly.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 re
-import unittest
-
-from build123d.topology import Compound, Solid
-
-
-class TestAssembly(unittest.TestCase):
- @staticmethod
- def create_test_assembly() -> Compound:
- box = Solid.make_box(1, 1, 1)
- box.orientation = (45, 45, 0)
- box.label = "box"
- sphere = Solid.make_sphere(1)
- sphere.label = "sphere"
- sphere.position = (1, 2, 3)
- assembly = Compound(label="assembly", children=[box])
- sphere.parent = assembly
- return assembly
-
- def assertTopoEqual(self, actual_topo: str, expected_topo_lines: list[str]):
- actual_topo_lines = actual_topo.splitlines()
- self.assertEqual(len(actual_topo_lines), len(expected_topo_lines))
- for actual_line, expected_line in zip(actual_topo_lines, expected_topo_lines):
- start, end = re.split(r"at 0x[0-9a-f]+,", expected_line, 2, re.I)
- self.assertTrue(actual_line.startswith(start))
- self.assertTrue(actual_line.endswith(end))
-
- def test_attributes(self):
- box = Solid.make_box(1, 1, 1)
- box.label = "box"
- sphere = Solid.make_sphere(1)
- sphere.label = "sphere"
- assembly = Compound(label="assembly", children=[box])
- sphere.parent = assembly
-
- self.assertEqual(len(box.children), 0)
- self.assertEqual(box.label, "box")
- self.assertEqual(box.parent, assembly)
- self.assertEqual(sphere.parent, assembly)
- self.assertEqual(len(assembly.children), 2)
-
- def test_show_topology_compound(self):
- assembly = TestAssembly.create_test_assembly()
- expected = [
- "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))",
- "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))",
- "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))",
- ]
- self.assertTopoEqual(assembly.show_topology("Solid"), expected)
-
- def test_show_topology_shape_location(self):
- assembly = TestAssembly.create_test_assembly()
- expected = [
- "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)",
- "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)",
- " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)",
- ]
- self.assertTopoEqual(
- assembly.children[1].show_topology("Face", show_center=False), expected
- )
-
- def test_show_topology_shape(self):
- assembly = TestAssembly.create_test_assembly()
- expected = [
- "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)",
- "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)",
- " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)",
- ]
- self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected)
-
- def test_remove_child(self):
- assembly = TestAssembly.create_test_assembly()
- self.assertEqual(len(assembly.children), 2)
- assembly.children = list(assembly.children)[1:]
- self.assertEqual(len(assembly.children), 1)
-
- def test_do_children_intersect(self):
- (
- overlap,
- pair,
- distance,
- ) = TestAssembly.create_test_assembly().do_children_intersect()
- self.assertFalse(overlap)
- box = Solid.make_box(1, 1, 1)
- box.orientation = (45, 45, 0)
- box.label = "box"
- sphere = Solid.make_sphere(1)
- sphere.label = "sphere"
- sphere.position = (0, 0, 0)
- assembly = Compound(label="assembly", children=[box])
- sphere.parent = assembly
- overlap, pair, distance = assembly.do_children_intersect()
- self.assertTrue(overlap)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py
deleted file mode 100644
index f4d0a4a..0000000
--- a/tests/test_direct_api/test_axis.py
+++ /dev/null
@@ -1,208 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_axis.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 copy
-import unittest
-
-from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt
-from build123d.geometry import Axis, Location, Plane, Vector
-from build123d.topology import Edge
-from tests.base_test import DirectApiTestCase, AlwaysEqual
-
-
-class TestAxis(DirectApiTestCase):
- """Test the Axis class"""
-
- def test_axis_init(self):
- test_axis = Axis((1, 2, 3), (0, 0, 1))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis((1, 2, 3), direction=(0, 0, 1))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4)))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4)))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
-
- with self.assertRaises(ValueError):
- Axis("one", "up")
- with self.assertRaises(ValueError):
- Axis(one="up")
-
- def test_axis_from_occt(self):
- occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0))
- test_axis = Axis(occt_axis)
- self.assertVectorAlmostEquals(test_axis.position, (1, 1, 1), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 1, 0), 5)
-
- def test_axis_repr_and_str(self):
- self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))")
- self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))")
-
- def test_axis_copy(self):
- x_copy = copy.copy(Axis.X)
- self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5)
- x_copy = copy.deepcopy(Axis.X)
- self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5)
-
- def test_axis_to_location(self):
- # TODO: Verify this is correct
- x_location = Axis.X.location
- self.assertTrue(isinstance(x_location, Location))
- self.assertVectorAlmostEquals(x_location.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_location.orientation, (0, 90, 180), 5)
-
- def test_axis_located(self):
- y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0)))
- self.assertVectorAlmostEquals(y_axis.position, (0, 0, 1), 5)
- self.assertVectorAlmostEquals(y_axis.direction, (0, 1, 0), 5)
-
- def test_axis_to_plane(self):
- x_plane = Axis.X.to_plane()
- self.assertTrue(isinstance(x_plane, Plane))
- self.assertVectorAlmostEquals(x_plane.origin, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_plane.z_dir, (1, 0, 0), 5)
-
- def test_axis_is_coaxial(self):
- self.assertTrue(Axis.X.is_coaxial(Axis((0, 0, 0), (1, 0, 0))))
- self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 1), (1, 0, 0))))
- self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 0), (0, 1, 0))))
-
- def test_axis_is_normal(self):
- self.assertTrue(Axis.X.is_normal(Axis.Y))
- self.assertFalse(Axis.X.is_normal(Axis.X))
-
- def test_axis_is_opposite(self):
- self.assertTrue(Axis.X.is_opposite(Axis((1, 1, 1), (-1, 0, 0))))
- self.assertFalse(Axis.X.is_opposite(Axis.X))
-
- def test_axis_is_parallel(self):
- self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0))))
- self.assertFalse(Axis.X.is_parallel(Axis.Y))
-
- def test_axis_angle_between(self):
- self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5)
- self.assertAlmostEqual(
- Axis.X.angle_between(Axis((1, 1, 1), (-1, 0, 0))), 180, 5
- )
-
- def test_axis_reverse(self):
- self.assertVectorAlmostEquals(Axis.X.reverse().direction, (-1, 0, 0), 5)
-
- def test_axis_reverse_op(self):
- axis = -Axis.X
- self.assertVectorAlmostEquals(axis.direction, (-1, 0, 0), 5)
-
- def test_axis_as_edge(self):
- edge = Edge(Axis.X)
- self.assertTrue(isinstance(edge, Edge))
- common = (edge & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
- self.assertAlmostEqual(common.length, 1, 5)
-
- def test_axis_intersect(self):
- common = (Axis.X.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
- self.assertAlmostEqual(common.length, 1, 5)
-
- common = (Axis.X & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
- self.assertAlmostEqual(common.length, 1, 5)
-
- intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0))
- self.assertVectorAlmostEquals(intersection, (1, 0, 0), 5)
-
- i = Axis.X & Axis((1, 0, 0), (1, 0, 0))
- self.assertEqual(i, Axis.X)
-
- intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY
- self.assertTupleAlmostEquals(intersection.to_tuple(), (1, 2, 0), 5)
-
- arc = Edge.make_circle(20, start_angle=0, end_angle=180)
- ax0 = Axis((-20, 30, 0), (4, -3, 0))
- intersections = arc.intersect(ax0).vertices().sort_by(Axis.X)
- self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5)
- self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5)
-
- intersections = ax0.intersect(arc).vertices().sort_by(Axis.X)
- self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5)
- self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5)
-
- i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5)
- self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, (0.5, 0.5, 1.5), 5)
- self.assertIsNone(Axis.Y & Vector(2, 0, 0))
-
- l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1
- i: Location = Axis.Z & l
- self.assertTrue(isinstance(i, Location))
- self.assertVectorAlmostEquals(i.position, l.position, 5)
- self.assertVectorAlmostEquals(i.orientation, l.orientation, 5)
-
- self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1))
- self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1))
-
- # TODO: uncomment when generalized edge to surface intersections are complete
- # non_planar = (
- # Solid.make_cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)
- # )
- # intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar
-
- # self.assertTrue(len(intersections.vertices(), 2))
- # self.assertTupleAlmostEquals(
- # intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5
- # )
- # self.assertTupleAlmostEquals(
- # intersection.vertices()[1].to_tuple(), (1, 0, 5), 5
- # )
-
- def test_axis_equal(self):
- self.assertEqual(Axis.X, Axis.X)
- self.assertEqual(Axis.Y, Axis.Y)
- self.assertEqual(Axis.Z, Axis.Z)
- self.assertEqual(Axis.X, AlwaysEqual())
-
- def test_axis_not_equal(self):
- self.assertNotEqual(Axis.X, Axis.Y)
- random_obj = object()
- self.assertNotEqual(Axis.X, random_obj)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_bound_box.py b/tests/test_direct_api/test_bound_box.py
deleted file mode 100644
index c06eaff..0000000
--- a/tests/test_direct_api/test_bound_box.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_bound_box.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.geometry import BoundBox, Vector
-from build123d.topology import Solid, Vertex
-from tests.base_test import DirectApiTestCase
-
-
-class TestBoundBox(DirectApiTestCase):
- def test_basic_bounding_box(self):
- v = Vertex(1, 1, 1)
- v2 = Vertex(2, 2, 2)
- self.assertEqual(BoundBox, type(v.bounding_box()))
- self.assertEqual(BoundBox, type(v2.bounding_box()))
-
- bb1 = v.bounding_box().add(v2.bounding_box())
-
- # OCC uses some approximations
- self.assertAlmostEqual(bb1.size.X, 1.0, 1)
-
- # Test adding to an existing bounding box
- v0 = Vertex(0, 0, 0)
- bb2 = v0.bounding_box().add(v.bounding_box())
-
- bb3 = bb1.add(bb2)
- self.assertVectorAlmostEquals(bb3.size, (2, 2, 2), 7)
-
- bb3 = bb2.add((3, 3, 3))
- self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7)
-
- bb3 = bb2.add(Vector(3, 3, 3))
- self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7)
-
- # Test 2D bounding boxes
- bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box())
- bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box())
- bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box())
- # Test that bb2 contains bb1
- self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2))
- self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1))
- # Test that neither bounding box contains the other
- self.assertIsNone(BoundBox.find_outside_box_2d(bb1, bb3))
-
- # Test creation of a bounding box from a shape - note the low accuracy comparison
- # as the box is a little larger than the shape
- bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
- self.assertVectorAlmostEquals(bb1.size, (2, 2, 1), 1)
-
- bb2 = BoundBox.from_topo_ds(
- Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False
- )
- self.assertTrue(bb2.is_inside(bb1))
-
- def test_bounding_box_repr(self):
- bb = Solid.make_box(1, 1, 1).bounding_box()
- self.assertEqual(
- repr(bb), "bbox: 0.0 <= x <= 1.0, 0.0 <= y <= 1.0, 0.0 <= z <= 1.0"
- )
-
- def test_center_of_boundbox(self):
- self.assertVectorAlmostEquals(
- Solid.make_box(1, 1, 1).bounding_box().center(),
- (0.5, 0.5, 0.5),
- 5,
- )
-
- def test_combined_center_of_boundbox(self):
- pass
-
- def test_clean_boundbox(self):
- s = Solid.make_sphere(3)
- self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5)
- s.mesh(1e-3)
- self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_cad_objects.py b/tests/test_direct_api/test_cad_objects.py
deleted file mode 100644
index fa9a186..0000000
--- a/tests/test_direct_api/test_cad_objects.py
+++ /dev/null
@@ -1,267 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_cad_objects.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import unittest
-
-from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
-from OCP.gp import gp, gp_Ax2, gp_Circ, gp_Elips, gp_Pnt
-from build123d.build_enums import CenterOf
-from build123d.geometry import Plane, Vector
-from build123d.topology import Edge, Face, Wire
-from tests.base_test import DirectApiTestCase, DEG2RAD
-
-
-class TestCadObjects(DirectApiTestCase):
- def _make_circle(self):
- circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0)
- return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
-
- def _make_ellipse(self):
- ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0)
- return Edge.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge())
-
- def test_edge_wrapper_center(self):
- e = self._make_circle()
-
- self.assertVectorAlmostEquals(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3)
-
- def test_edge_wrapper_ellipse_center(self):
- e = self._make_ellipse()
- w = Wire([e])
- self.assertVectorAlmostEquals(Face(w).center(), (1.0, 2.0, 3.0), 3)
-
- def test_edge_wrapper_make_circle(self):
- halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180)
-
- # self.assertVectorAlmostEquals((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),3)
- self.assertVectorAlmostEquals(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3)
- self.assertVectorAlmostEquals(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3)
-
- def test_edge_wrapper_make_tangent_arc(self):
- tangent_arc = Edge.make_tangent_arc(
- Vector(1, 1), # starts at 1, 1
- Vector(0, 1), # tangent at start of arc is in the +y direction
- Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1
- )
- self.assertVectorAlmostEquals(tangent_arc.start_point(), (1, 1, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.end_point(), (2, 1, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.tangent_at(0), (0, 1, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.tangent_at(0.5), (1, 0, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.tangent_at(1), (0, -1, 0), 3)
-
- def test_edge_wrapper_make_ellipse1(self):
- # Check x_radius > y_radius
- x_radius, y_radius = 20, 10
- angle1, angle2 = -75.0, 90.0
- arcEllipseEdge = Edge.make_ellipse(
- x_radius=x_radius,
- y_radius=y_radius,
- plane=Plane.XY,
- start_angle=angle1,
- end_angle=angle2,
- )
-
- start = (
- x_radius * math.cos(angle1 * DEG2RAD),
- y_radius * math.sin(angle1 * DEG2RAD),
- 0.0,
- )
- end = (
- x_radius * math.cos(angle2 * DEG2RAD),
- y_radius * math.sin(angle2 * DEG2RAD),
- 0.0,
- )
- self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
- self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
-
- def test_edge_wrapper_make_ellipse2(self):
- # Check x_radius < y_radius
- x_radius, y_radius = 10, 20
- angle1, angle2 = 0.0, 45.0
- arcEllipseEdge = Edge.make_ellipse(
- x_radius=x_radius,
- y_radius=y_radius,
- plane=Plane.XY,
- start_angle=angle1,
- end_angle=angle2,
- )
-
- start = (
- x_radius * math.cos(angle1 * DEG2RAD),
- y_radius * math.sin(angle1 * DEG2RAD),
- 0.0,
- )
- end = (
- x_radius * math.cos(angle2 * DEG2RAD),
- y_radius * math.sin(angle2 * DEG2RAD),
- 0.0,
- )
- self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
- self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
-
- def test_edge_wrapper_make_circle_with_ellipse(self):
- # Check x_radius == y_radius
- x_radius, y_radius = 20, 20
- angle1, angle2 = 15.0, 60.0
- arcEllipseEdge = Edge.make_ellipse(
- x_radius=x_radius,
- y_radius=y_radius,
- plane=Plane.XY,
- start_angle=angle1,
- end_angle=angle2,
- )
-
- start = (
- x_radius * math.cos(angle1 * DEG2RAD),
- y_radius * math.sin(angle1 * DEG2RAD),
- 0.0,
- )
- end = (
- x_radius * math.cos(angle2 * DEG2RAD),
- y_radius * math.sin(angle2 * DEG2RAD),
- 0.0,
- )
- self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
- self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
-
- def test_face_wrapper_make_rect(self):
- mplane = Face.make_rect(10, 10)
-
- self.assertVectorAlmostEquals(mplane.normal_at(), (0.0, 0.0, 1.0), 3)
-
- # def testCompoundcenter(self):
- # """
- # Tests whether or not a proper weighted center can be found for a compound
- # """
-
- # def cylinders(self, radius, height):
-
- # c = Solid.make_cylinder(radius, height, Vector())
-
- # # Combine all the cylinders into a single compound
- # r = self.eachpoint(lambda loc: c.located(loc), True).combinesolids()
-
- # return r
-
- # Workplane.cyl = cylinders
-
- # # Now test. here we want weird workplane to see if the objects are transformed right
- # s = (
- # Workplane("XY")
- # .rect(2.0, 3.0, for_construction=true)
- # .vertices()
- # .cyl(0.25, 0.5)
- # )
-
- # self.assertEqual(4, len(s.val().solids()))
- # self.assertVectorAlmostEquals((0.0, 0.0, 0.25), s.val().center, 3)
-
- def test_translate(self):
- e = Edge.make_circle(2, Plane((1, 2, 3)))
- e2 = e.translate(Vector(0, 0, 1))
-
- self.assertVectorAlmostEquals(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
-
- def test_vertices(self):
- e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
- self.assertEqual(2, len(e.vertices()))
-
- def test_edge_wrapper_radius(self):
- # get a radius from a simple circle
- e0 = Edge.make_circle(2.4)
- self.assertAlmostEqual(e0.radius, 2.4)
-
- # radius of an arc
- e1 = Edge.make_circle(
- 1.8, Plane(origin=(5, 6, 7), z_dir=(1, 1, 1)), start_angle=20, end_angle=30
- )
- self.assertAlmostEqual(e1.radius, 1.8)
-
- # test value errors
- e2 = Edge.make_ellipse(10, 20)
- with self.assertRaises(ValueError):
- e2.radius
-
- # radius from a wire
- w0 = Wire.make_circle(10, Plane(origin=(1, 2, 3), z_dir=(-1, 0, 1)))
- self.assertAlmostEqual(w0.radius, 10)
-
- # radius from a wire with multiple edges
- rad = 2.3
- plane = Plane(origin=(7, 8, 0), z_dir=(1, 0.5, 0.1))
- w1 = Wire(
- [
- Edge.make_circle(rad, plane, 0, 10),
- Edge.make_circle(rad, plane, 10, 25),
- Edge.make_circle(rad, plane, 25, 230),
- ]
- )
- self.assertAlmostEqual(w1.radius, rad)
-
- # test value error from wire
- w2 = Wire.make_polygon(
- [
- Vector(-1, 0, 0),
- Vector(0, 1, 0),
- Vector(1, -1, 0),
- ]
- )
- with self.assertRaises(ValueError):
- w2.radius
-
- # (I think) the radius of a wire is the radius of it's first edge.
- # Since this is stated in the docstring better make sure.
- no_rad = Wire(
- [
- Edge.make_line(Vector(0, 0, 0), Vector(0, 1, 0)),
- Edge.make_circle(1.0, start_angle=90, end_angle=270),
- ]
- )
- with self.assertRaises(ValueError):
- no_rad.radius
- yes_rad = Wire(
- [
- Edge.make_circle(1.0, start_angle=90, end_angle=270),
- Edge.make_line(Vector(0, -1, 0), Vector(0, 1, 0)),
- ]
- )
- self.assertAlmostEqual(yes_rad.radius, 1.0)
- many_rad = Wire(
- [
- Edge.make_circle(1.0, start_angle=0, end_angle=180),
- Edge.make_circle(3.0, Plane((2, 0, 0)), start_angle=180, end_angle=359),
- ]
- )
- self.assertAlmostEqual(many_rad.radius, 1.0)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_clean_method.py b/tests/test_direct_api/test_clean_method.py
deleted file mode 100644
index 4c5dfa0..0000000
--- a/tests/test_direct_api/test_clean_method.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_clean_method.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 unittest.mock import patch, MagicMock
-
-from build123d.topology import Solid
-
-
-class TestCleanMethod(unittest.TestCase):
- def setUp(self):
- # Create a mock object
- self.solid = Solid()
- self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object
-
- @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain")
- def test_clean_warning_on_exception(self, mock_shape_upgrade):
- # Mock the upgrader
- mock_upgrader = mock_shape_upgrade.return_value
- mock_upgrader.Build.side_effect = Exception("Mocked Build failure")
-
- # Capture warnings
- with self.assertWarns(Warning) as warn_context:
- self.solid.clean()
-
- # Assert the warning message
- self.assertIn("Unable to clean", str(warn_context.warning))
-
- # Verify the upgrader was constructed with the correct arguments
- mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True)
-
- # Verify the Build method was called
- mock_upgrader.Build.assert_called_once()
-
- def test_clean_with_none_wrapped(self):
- # Set `wrapped` to None to simulate the error condition
- self.solid.wrapped = None
-
- # Call clean and ensure it returns self
- result = self.solid.clean()
- self.assertIs(result, self.solid)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py
deleted file mode 100644
index f18ab95..0000000
--- a/tests/test_direct_api/test_color.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_color.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 copy
-import unittest
-
-from build123d.geometry import Color
-from tests.base_test import DirectApiTestCase
-
-
-class TestColor(DirectApiTestCase):
- def test_name1(self):
- c = Color("blue")
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
-
- def test_name2(self):
- c = Color("blue", alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
-
- def test_name3(self):
- c = Color("blue", 0.5)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
-
- def test_rgb0(self):
- c = Color(0.0, 1.0, 0.0)
- self.assertTupleAlmostEquals(tuple(c), (0, 1, 0, 1), 5)
-
- def test_rgba1(self):
- c = Color(1.0, 1.0, 0.0, 0.5)
- self.assertEqual(c.wrapped.GetRGB().Red(), 1.0)
- self.assertEqual(c.wrapped.GetRGB().Green(), 1.0)
- self.assertEqual(c.wrapped.GetRGB().Blue(), 0.0)
- self.assertEqual(c.wrapped.Alpha(), 0.5)
-
- def test_rgba2(self):
- c = Color(1.0, 1.0, 0.0, alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (1, 1, 0, 0.5), 5)
-
- def test_rgba3(self):
- c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.5), 5)
-
- def test_bad_color_name(self):
- with self.assertRaises(ValueError):
- Color("build123d")
-
- def test_to_tuple(self):
- c = Color("blue", alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
-
- def test_hex(self):
- c = Color(0x996692)
- self.assertTupleAlmostEquals(
- tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5
- )
-
- c = Color(0x006692, 0x80)
- self.assertTupleAlmostEquals(
- tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5
- )
-
- c = Color(0x006692, alpha=0x80)
- self.assertTupleAlmostEquals(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 5)
-
- c = Color(color_code=0x996692, alpha=0xCC)
- self.assertTupleAlmostEquals(
- tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5
- )
-
- c = Color(0.0, 0.0, 1.0, 1.0)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
-
- c = Color(0, 0, 1, 1)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
-
- def test_copy(self):
- c = Color(0.1, 0.2, 0.3, alpha=0.4)
- c_copy = copy.copy(c)
- self.assertTupleAlmostEquals(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 5)
-
- def test_str_repr(self):
- c = Color(1, 0, 0)
- self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) ~ RED")
- self.assertEqual(repr(c), "Color(1.0, 0.0, 0.0, 1.0)")
-
- def test_tuple(self):
- c = Color((0.1,))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 1.0, 1.0, 1.0), 5)
- c = Color((0.1, 0.2))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 1.0, 1.0), 5)
- c = Color((0.1, 0.2, 0.3))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 1.0), 5)
- c = Color((0.1, 0.2, 0.3, 0.4))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5)
- c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_compound.py b/tests/test_direct_api/test_compound.py
deleted file mode 100644
index 8ee33ca..0000000
--- a/tests/test_direct_api/test_compound.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_compound.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 itertools
-import unittest
-
-from build123d.build_common import GridLocations, PolarLocations
-from build123d.build_enums import Align, CenterOf
-from build123d.geometry import Location, Plane
-from build123d.objects_part import Box
-from build123d.objects_sketch import Circle
-from build123d.topology import Compound, Edge, Face, ShapeList, Solid, Sketch
-from tests.base_test import DirectApiTestCase
-
-
-class TestCompound(DirectApiTestCase):
- def test_make_text(self):
- arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0))
- text = Compound.make_text("test", 10, text_path=arc)
- self.assertEqual(len(text.faces()), 4)
- text = Compound.make_text(
- "test", 10, align=(Align.MAX, Align.MAX), text_path=arc
- )
- self.assertEqual(len(text.faces()), 4)
-
- def test_fuse(self):
- box1 = Solid.make_box(1, 1, 1)
- box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
- combined = Compound([box1]).fuse(box2, glue=True)
- self.assertTrue(combined.is_valid())
- self.assertAlmostEqual(combined.volume, 2, 5)
- fuzzy = Compound([box1]).fuse(box2, tol=1e-6)
- self.assertTrue(fuzzy.is_valid())
- self.assertAlmostEqual(fuzzy.volume, 2, 5)
-
- def test_remove(self):
- box1 = Solid.make_box(1, 1, 1)
- box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
- combined = Compound([box1, box2])
- self.assertTrue(len(combined._remove(box2).solids()), 1)
-
- def test_repr(self):
- simple = Compound([Solid.make_box(1, 1, 1)])
- simple_str = repr(simple).split("0x")[0] + repr(simple).split(", ")[1]
- self.assertEqual(simple_str, "Compound at label()")
-
- assembly = Compound([Solid.make_box(1, 1, 1)])
- assembly.children = [Solid.make_box(1, 1, 1)]
- assembly.label = "test"
- assembly_str = repr(assembly).split("0x")[0] + repr(assembly).split(", l")[1]
- self.assertEqual(assembly_str, "Compound at abel(test), #children(1)")
-
- def test_center(self):
- test_compound = Compound(
- [
- Solid.make_box(2, 2, 2).locate(Location((-1, -1, -1))),
- Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))),
- ]
- )
- self.assertVectorAlmostEquals(test_compound.center(CenterOf.MASS), (1, 0, 0), 5)
- self.assertVectorAlmostEquals(
- test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5
- )
- with self.assertRaises(ValueError):
- test_compound.center(CenterOf.GEOMETRY)
-
- def test_triad(self):
- triad = Compound.make_triad(10)
- bbox = triad.bounding_box()
- self.assertGreater(bbox.min.X, -10 / 8)
- self.assertLess(bbox.min.X, 0)
- self.assertGreater(bbox.min.Y, -10 / 8)
- self.assertLess(bbox.min.Y, 0)
- self.assertGreater(bbox.min.Y, -10 / 8)
- self.assertAlmostEqual(bbox.min.Z, 0, 4)
- self.assertLess(bbox.size.Z, 12.5)
- self.assertEqual(triad.volume, 0)
-
- def test_volume(self):
- e = Edge.make_line((0, 0), (1, 1))
- self.assertAlmostEqual(e.volume, 0, 5)
-
- f = Face.make_rect(1, 1)
- self.assertAlmostEqual(f.volume, 0, 5)
-
- b = Solid.make_box(1, 1, 1)
- self.assertAlmostEqual(b.volume, 1, 5)
-
- bb = Box(1, 1, 1)
- self.assertAlmostEqual(bb.volume, 1, 5)
-
- c = Compound(children=[e, f, b, bb, b.translate((0, 5, 0))])
- self.assertAlmostEqual(c.volume, 3, 5)
- # N.B. b and bb overlap but still add to Compound volume
-
- def test_constructor(self):
- with self.assertRaises(TypeError):
- Compound(foo="bar")
-
- def test_len(self):
- self.assertEqual(len(Compound()), 0)
- skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
- self.assertEqual(len(skt), 4)
-
- def test_iteration(self):
- skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
- for c1, c2 in itertools.combinations(skt, 2):
- self.assertGreaterEqual((c1.position - c2.position).length, 10)
-
- def test_unwrap(self):
- skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
- skt2 = Compound(children=[skt])
- self.assertEqual(len(skt2), 1)
- skt3 = skt2.unwrap(fully=False)
- self.assertEqual(len(skt3), 4)
-
- comp1 = Compound().unwrap()
- self.assertEqual(len(comp1), 0)
- comp2 = Compound(children=[Face.make_rect(1, 1)])
- comp3 = Compound(children=[comp2])
- self.assertEqual(len(comp3), 1)
- self.assertTrue(isinstance(next(iter(comp3)), Compound))
- comp4 = comp3.unwrap(fully=True)
- self.assertTrue(isinstance(comp4, Face))
-
- def test_get_top_level_shapes(self):
- base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4))
- fls = base_shapes.get_top_level_shapes()
- self.assertTrue(isinstance(fls, ShapeList))
- self.assertEqual(len(fls), 20)
- self.assertTrue(all(isinstance(s, Solid) for s in fls))
-
- b1 = Box(1, 1, 1).solid()
- self.assertEqual(b1.get_top_level_shapes()[0], b1)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_direct_api_test_case.py b/tests/test_direct_api/test_direct_api_test_case.py
deleted file mode 100644
index a165857..0000000
--- a/tests/test_direct_api/test_direct_api_test_case.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_direct_api_test_case.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 typing import Optional
-
-from build123d.geometry import Vector, VectorLike
-
-
-class DirectApiTestCase(unittest.TestCase):
- def assertTupleAlmostEquals(
- self,
- first: tuple[float, ...],
- second: tuple[float, ...],
- places: int,
- msg: str | None = None,
- ):
- """Check Tuples"""
- self.assertEqual(len(second), len(first))
- for i, j in zip(second, first):
- self.assertAlmostEqual(i, j, places, msg=msg)
-
- def assertVectorAlmostEquals(
- self, first: Vector, second: VectorLike, places: int, msg: str | None = None
- ):
- second_vector = Vector(second)
- self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg)
- self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg)
- self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py
deleted file mode 100644
index da36172..0000000
--- a/tests/test_direct_api/test_edge.py
+++ /dev/null
@@ -1,293 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_edge.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import unittest
-
-from build123d.build_enums import AngularDirection
-from build123d.geometry import Axis, Plane, Vector
-from build123d.objects_curve import CenterArc, EllipticalCenterArc
-from build123d.topology import Edge
-from tests.base_test import DirectApiTestCase
-
-
-class TestEdge(DirectApiTestCase):
- def test_close(self):
- self.assertAlmostEqual(
- Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5
- )
- self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5)
-
- def test_make_half_circle(self):
- half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180)
- self.assertVectorAlmostEquals(half_circle.start_point(), (1, 0, 0), 3)
- self.assertVectorAlmostEquals(half_circle.end_point(), (-1, 0, 0), 3)
-
- def test_make_half_circle2(self):
- half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90)
- self.assertVectorAlmostEquals(half_circle.start_point(), (0, -1, 0), 3)
- self.assertVectorAlmostEquals(half_circle.end_point(), (0, 1, 0), 3)
-
- def test_make_clockwise_half_circle(self):
- half_circle = Edge.make_circle(
- radius=1,
- start_angle=180,
- end_angle=0,
- angular_direction=AngularDirection.CLOCKWISE,
- )
- self.assertVectorAlmostEquals(half_circle.end_point(), (1, 0, 0), 3)
- self.assertVectorAlmostEquals(half_circle.start_point(), (-1, 0, 0), 3)
-
- def test_make_clockwise_half_circle2(self):
- half_circle = Edge.make_circle(
- radius=1,
- start_angle=90,
- end_angle=-90,
- angular_direction=AngularDirection.CLOCKWISE,
- )
- self.assertVectorAlmostEquals(half_circle.start_point(), (0, 1, 0), 3)
- self.assertVectorAlmostEquals(half_circle.end_point(), (0, -1, 0), 3)
-
- def test_arc_center(self):
- self.assertVectorAlmostEquals(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5)
- with self.assertRaises(ValueError):
- Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center
-
- def test_spline_with_parameters(self):
- spline = Edge.make_spline(
- points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0]
- )
- self.assertVectorAlmostEquals(spline.end_point(), (2, 0, 0), 5)
- with self.assertRaises(ValueError):
- Edge.make_spline(
- points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0]
- )
- with self.assertRaises(ValueError):
- Edge.make_spline(
- points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], tangents=[(1, 1, 0)]
- )
-
- def test_spline_approx(self):
- spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)])
- self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5)
- spline = Edge.make_spline_approx(
- [(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0)
- )
- self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5)
-
- def test_distribute_locations(self):
- line = Edge.make_line((0, 0, 0), (10, 0, 0))
- locs = line.distribute_locations(3)
- for i, x in enumerate([0, 5, 10]):
- self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[0].orientation, (0, 90, 180), 5)
-
- locs = line.distribute_locations(3, positions_only=True)
- for i, x in enumerate([0, 5, 10]):
- self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[0].orientation, (0, 0, 0), 5)
-
- def test_to_wire(self):
- edge = Edge.make_line((0, 0, 0), (1, 1, 1))
- for end in [0, 1]:
- self.assertVectorAlmostEquals(
- edge.position_at(end),
- edge.to_wire().position_at(end),
- 5,
- )
-
- def test_arc_center2(self):
- edges = [
- Edge.make_circle(1, plane=Plane((1, 2, 3)), end_angle=30),
- Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30),
- ]
- for edge in edges:
- self.assertVectorAlmostEquals(edge.arc_center, (1, 2, 3), 5)
- with self.assertRaises(ValueError):
- Edge.make_line((0, 0), (1, 1)).arc_center
-
- def test_find_intersection_points(self):
- circle = Edge.make_circle(1)
- line = Edge.make_line((0, -2), (0, 2))
- crosses = circle.find_intersection_points(line)
- for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses):
- self.assertVectorAlmostEquals(actual, target, 5)
-
- with self.assertRaises(ValueError):
- circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
- with self.assertRaises(ValueError):
- circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
-
- self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)])
- self.assertVectorAlmostEquals(
- self_intersect.find_intersection_points()[0],
- (-2.6861636507066047, 0, 0),
- 5,
- )
- line = Edge.make_line((1, -2), (1, 2))
- crosses = line.find_intersection_points(Axis.X)
- self.assertVectorAlmostEquals(crosses[0], (1, 0, 0), 5)
-
- with self.assertRaises(ValueError):
- line.find_intersection_points(Plane.YZ)
-
- # def test_intersections_tolerance(self):
- # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
- # l1 = Edge.make_line((1, 0), (2, 0))
- # i1 = l1.intersect(*r1)
-
- # r2 = Rectangle(2, 2).edges()
- # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
- # i2 = l2.intersect(*r2)
-
- # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
-
- def test_trim(self):
- line = Edge.make_line((-2, 0), (2, 0))
- self.assertVectorAlmostEquals(
- line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5
- )
- self.assertVectorAlmostEquals(
- line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5
- )
- with self.assertRaises(ValueError):
- line.trim(0.75, 0.25)
-
- def test_trim_to_length(self):
-
- e1 = Edge.make_line((0, 0), (10, 10))
- e1_trim = e1.trim_to_length(0.0, 10)
- self.assertAlmostEqual(e1_trim.length, 10, 5)
-
- e2 = Edge.make_circle(10, start_angle=0, end_angle=90)
- e2_trim = e2.trim_to_length(0.5, 1)
- self.assertAlmostEqual(e2_trim.length, 1, 5)
- self.assertVectorAlmostEquals(
- e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5
- )
-
- e3 = Edge.make_spline(
- [(0, 10, 0), (-4, 5, 2), (0, 0, 0)], tangents=[(-1, 0), (1, 0)]
- )
- e3_trim = e3.trim_to_length(0, 7)
- self.assertAlmostEqual(e3_trim.length, 7, 5)
-
- a4 = Axis((0, 0, 0), (1, 1, 1))
- e4_trim = Edge(a4).trim_to_length(0.5, 2)
- self.assertAlmostEqual(e4_trim.length, 2, 5)
-
- def test_bezier(self):
- with self.assertRaises(ValueError):
- Edge.make_bezier((1, 1))
- cntl_pnts = [(1, 2, 3)] * 30
- with self.assertRaises(ValueError):
- Edge.make_bezier(*cntl_pnts)
- with self.assertRaises(ValueError):
- Edge.make_bezier((0, 0, 0), (1, 1, 1), weights=[1.0])
-
- bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0))
- bbox = bezier.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(bbox.max, (1, 0.75, 0), 5)
-
- def test_mid_way(self):
- mid = Edge.make_mid_way(
- Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25
- )
- self.assertVectorAlmostEquals(mid.position_at(0), (0.25, 0, 0), 5)
- self.assertVectorAlmostEquals(mid.position_at(1), (0.25, 1, 0), 5)
-
- def test_distribute_locations2(self):
- with self.assertRaises(ValueError):
- Edge.make_circle(1).distribute_locations(1)
-
- locs = Edge.make_circle(1).distribute_locations(5, positions_only=True)
- for i, loc in enumerate(locs):
- self.assertVectorAlmostEquals(
- loc.position,
- Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(),
- 5,
- )
- self.assertVectorAlmostEquals(loc.orientation, (0, 0, 0), 5)
-
- def test_find_tangent(self):
- circle = Edge.make_circle(1)
- parm = circle.find_tangent(135)[0]
- self.assertVectorAlmostEquals(
- circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
- )
- line = Edge.make_line((0, 0), (1, 1))
- parm = line.find_tangent(45)[0]
- self.assertAlmostEqual(parm, 0, 5)
- parm = line.find_tangent(0)
- self.assertEqual(len(parm), 0)
-
- def test_param_at_point(self):
- u = Edge.make_circle(1).param_at_point((0, 1))
- self.assertAlmostEqual(u, 0.25, 5)
-
- u = 0.3
- edge = Edge.make_line((0, 0), (34, 56))
- pnt = edge.position_at(u)
- self.assertAlmostEqual(edge.param_at_point(pnt), u, 5)
-
- ca = CenterArc((0, 0), 1, -200, 220).edge()
- for u in [0.3, 1.0]:
- pnt = ca.position_at(u)
- self.assertAlmostEqual(ca.param_at_point(pnt), u, 5)
-
- ea = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270).edge()
- for u in [0.3, 0.9]:
- pnt = ea.position_at(u)
- self.assertAlmostEqual(ea.param_at_point(pnt), u, 5)
-
- with self.assertRaises(ValueError):
- edge.param_at_point((-1, 1))
-
- def test_conical_helix(self):
- helix = Edge.make_helix(1, 4, 1, normal=(-1, 0, 0), angle=10, lefthand=True)
- self.assertAlmostEqual(helix.bounding_box().min.X, -4, 5)
-
- def test_reverse(self):
- e1 = Edge.make_line((0, 0), (1, 1))
- self.assertVectorAlmostEquals(e1 @ 0.1, (0.1, 0.1, 0), 5)
- self.assertVectorAlmostEquals(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5)
-
- e2 = Edge.make_circle(1, start_angle=0, end_angle=180)
- e2r = e2.reversed()
- self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5)
-
- def test_init(self):
- with self.assertRaises(TypeError):
- Edge(direction=(1, 0, 0))
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
deleted file mode 100644
index 45f8da5..0000000
--- a/tests/test_direct_api/test_face.py
+++ /dev/null
@@ -1,459 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_face.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import os
-import platform
-import random
-import unittest
-
-from build123d.build_common import Locations
-from build123d.build_enums import Align, CenterOf, GeomType
-from build123d.build_line import BuildLine
-from build123d.build_part import BuildPart
-from build123d.build_sketch import BuildSketch
-from build123d.exporters3d import export_stl
-from build123d.geometry import Axis, Location, Plane, Pos, Vector
-from build123d.importers import import_stl
-from build123d.objects_curve import Polyline
-from build123d.objects_part import Box, Cylinder
-from build123d.objects_sketch import Rectangle, RegularPolygon
-from build123d.operations_generic import fillet
-from build123d.operations_part import extrude
-from build123d.operations_sketch import make_face
-from build123d.topology import Edge, Face, Solid, Wire
-from tests.base_test import DirectApiTestCase
-
-
-class TestFace(DirectApiTestCase):
- def test_make_surface_from_curves(self):
- bottom_edge = Edge.make_circle(radius=1, end_angle=90)
- top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90)
- curved = Face.make_surface_from_curves(bottom_edge, top_edge)
- self.assertTrue(curved.is_valid())
- self.assertAlmostEqual(curved.area, math.pi / 2, 5)
- self.assertVectorAlmostEquals(
- curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
- )
-
- bottom_wire = Wire.make_circle(1)
- top_wire = Wire.make_circle(1, Plane((0, 0, 1)))
- curved = Face.make_surface_from_curves(bottom_wire, top_wire)
- self.assertTrue(curved.is_valid())
- self.assertAlmostEqual(curved.area, 2 * math.pi, 5)
-
- def test_center(self):
- test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)]))
- self.assertVectorAlmostEquals(
- test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1
- )
- self.assertVectorAlmostEquals(
- test_face.center(CenterOf.BOUNDING_BOX),
- (0.5, 0.5, 0),
- 5,
- )
-
- def test_face_volume(self):
- rect = Face.make_rect(1, 1)
- self.assertAlmostEqual(rect.volume, 0, 5)
-
- def test_chamfer_2d(self):
- test_face = Face.make_rect(10, 10)
- test_face = test_face.chamfer_2d(
- distance=1, distance2=2, vertices=test_face.vertices()
- )
- self.assertAlmostEqual(test_face.area, 100 - 4 * 0.5 * 1 * 2)
-
- def test_chamfer_2d_reference(self):
- test_face = Face.make_rect(10, 10)
- edge = test_face.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- test_face = test_face.chamfer_2d(
- distance=1, distance2=2, vertices=[vertex], edge=edge
- )
- self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 9)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 8)
-
- def test_chamfer_2d_reference_inverted(self):
- test_face = Face.make_rect(10, 10)
- edge = test_face.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- test_face = test_face.chamfer_2d(
- distance=2, distance2=1, vertices=[vertex], edge=edge
- )
- self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 8)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 9)
-
- def test_chamfer_2d_error_checking(self):
- with self.assertRaises(ValueError):
- test_face = Face.make_rect(10, 10)
- edge = test_face.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- other_edge = test_face.edges().sort_by(Axis.Y)[-1]
- test_face = test_face.chamfer_2d(
- distance=1, distance2=2, vertices=[vertex], edge=other_edge
- )
-
- def test_make_rect(self):
- test_face = Face.make_plane()
- self.assertVectorAlmostEquals(test_face.normal_at(), (0, 0, 1), 5)
-
- def test_length_width(self):
- test_face = Face.make_rect(8, 10, Plane.XZ)
- self.assertAlmostEqual(test_face.length, 8, 5)
- self.assertAlmostEqual(test_face.width, 10, 5)
-
- def test_geometry(self):
- box = Solid.make_box(1, 1, 2)
- self.assertEqual(box.faces().sort_by(Axis.Z).last.geometry, "SQUARE")
- self.assertEqual(box.faces().sort_by(Axis.Y).last.geometry, "RECTANGLE")
- with BuildPart() as test:
- with BuildSketch():
- RegularPolygon(1, 3)
- extrude(amount=1)
- self.assertEqual(test.faces().sort_by(Axis.Z).last.geometry, "POLYGON")
-
- def test_is_planar(self):
- self.assertTrue(Face.make_rect(1, 1).is_planar)
- self.assertFalse(
- Solid.make_cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0].is_planar
- )
- # Some of these faces have geom_type BSPLINE but are planar
- mount = Solid.make_loft(
- [
- Rectangle((1 + 16 + 4), 20, align=(Align.MIN, Align.CENTER)).wire(),
- Pos(1, 0, 4)
- * Rectangle(16, 20, align=(Align.MIN, Align.CENTER)).wire(),
- ],
- )
- self.assertTrue(all(f.is_planar for f in mount.faces()))
-
- def test_negate(self):
- square = Face.make_rect(1, 1)
- self.assertVectorAlmostEquals(square.normal_at(), (0, 0, 1), 5)
- flipped_square = -square
- self.assertVectorAlmostEquals(flipped_square.normal_at(), (0, 0, -1), 5)
-
- def test_offset(self):
- bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, 5), 5)
- self.assertVectorAlmostEquals(bbox.max, (1, 1, 5), 5)
-
- def test_make_from_wires(self):
- outer = Wire.make_circle(10)
- inners = [
- Wire.make_circle(1).locate(Location((-2, 2, 0))),
- Wire.make_circle(1).locate(Location((2, 2, 0))),
- ]
- happy = Face(outer, inners)
- self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5)
-
- outer = Edge.make_circle(10, end_angle=180).to_wire()
- with self.assertRaises(ValueError):
- Face(outer, inners)
- with self.assertRaises(ValueError):
- Face(Wire.make_circle(10, Plane.XZ), inners)
-
- outer = Wire.make_circle(10)
- inners = [
- Wire.make_circle(1).locate(Location((-2, 2, 0))),
- Edge.make_circle(1, end_angle=180).to_wire().locate(Location((2, 2, 0))),
- ]
- with self.assertRaises(ValueError):
- Face(outer, inners)
-
- def test_sew_faces(self):
- patches = [
- Face.make_rect(1, 1, Plane((x, y, z)))
- for x in range(2)
- for y in range(2)
- for z in range(3)
- ]
- random.shuffle(patches)
- sheets = Face.sew_faces(patches)
- self.assertEqual(len(sheets), 3)
- self.assertEqual(len(sheets[0]), 4)
- self.assertTrue(isinstance(sheets[0][0], Face))
-
- def test_surface_from_array_of_points(self):
- pnts = [
- [
- Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
- for x in range(11)
- ]
- for y in range(11)
- ]
- surface = Face.make_surface_from_array_of_points(pnts)
- bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (0, 0, -1), 3)
- self.assertVectorAlmostEquals(bbox.max, (10, 10, 2), 2)
-
- def test_bezier_surface(self):
- points = [
- [
- (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
- for x in range(-1, 2)
- ]
- for y in range(-1, 2)
- ]
- surface = Face.make_bezier_surface(points)
- bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3)
- self.assertVectorAlmostEquals(bbox.max, (+1, +1, +1), 1)
- self.assertLess(bbox.max.Z, 1.0)
-
- weights = [
- [2 if x == 0 or y == 0 else 1 for x in range(-1, 2)] for y in range(-1, 2)
- ]
- surface = Face.make_bezier_surface(points, weights)
- bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3)
- self.assertGreater(bbox.max.Z, 1.0)
-
- too_many_points = [
- [
- (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
- for x in range(-1, 27)
- ]
- for y in range(-1, 27)
- ]
-
- with self.assertRaises(ValueError):
- Face.make_bezier_surface([[(0, 0)]])
- with self.assertRaises(ValueError):
- Face.make_bezier_surface(points, [[1, 1], [1, 1]])
- with self.assertRaises(ValueError):
- Face.make_bezier_surface(too_many_points)
-
- def test_thicken(self):
- pnts = [
- [
- Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
- for x in range(11)
- ]
- for y in range(11)
- ]
- surface = Face.make_surface_from_array_of_points(pnts)
- solid = Solid.thicken(surface, 1)
- self.assertAlmostEqual(solid.volume, 101.59, 2)
-
- square = Face.make_rect(10, 10)
- bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-5, -5, -1), 5)
- self.assertVectorAlmostEquals(bbox.max, (5, 5, 0), 5)
-
- def test_make_holes(self):
- radius = 10
- circumference = 2 * math.pi * radius
- hex_diagonal = 4 * (circumference / 10) / 3
- cylinder = Solid.make_cylinder(radius, hex_diagonal * 5)
- cylinder_wall: Face = cylinder.faces().filter_by(GeomType.PLANE, reverse=True)[
- 0
- ]
- with BuildSketch(Plane.XZ.offset(radius)) as hex:
- with Locations((0, hex_diagonal)):
- RegularPolygon(
- hex_diagonal * 0.4, 6, align=(Align.CENTER, Align.CENTER)
- )
- hex_wire_vertical: Wire = hex.sketch.faces()[0].outer_wire()
-
- projected_wire: Wire = hex_wire_vertical.project_to_shape(
- target_object=cylinder, center=(0, 0, hex_wire_vertical.center().Z)
- )[0]
- projected_wires = [
- projected_wire.rotate(Axis.Z, -90 + i * 360 / 10).translate(
- (0, 0, (j + (i % 2) / 2) * hex_diagonal)
- )
- for i in range(5)
- for j in range(4 - i % 2)
- ]
- cylinder_walls_with_holes = cylinder_wall.make_holes(projected_wires)
- self.assertTrue(cylinder_walls_with_holes.is_valid())
- self.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area)
-
- def test_is_inside(self):
- square = Face.make_rect(10, 10)
- self.assertTrue(square.is_inside((1, 1)))
- self.assertFalse(square.is_inside((20, 1)))
-
- def test_import_stl(self):
- torus = Solid.make_torus(10, 1)
- # exporter = Mesher()
- # exporter.add_shape(torus)
- # exporter.write("test_torus.stl")
- export_stl(torus, "test_torus.stl")
- imported_torus = import_stl("test_torus.stl")
- # The torus from stl is tessellated therefore the areas will only be close
- self.assertAlmostEqual(imported_torus.area, torus.area, 0)
- os.remove("test_torus.stl")
-
- def test_is_coplanar(self):
- square = Face.make_rect(1, 1, plane=Plane.XZ)
- self.assertTrue(square.is_coplanar(Plane.XZ))
- self.assertTrue((-square).is_coplanar(Plane.XZ))
- self.assertFalse(square.is_coplanar(Plane.XY))
- surface: Face = Solid.make_sphere(1).faces()[0]
- self.assertFalse(surface.is_coplanar(Plane.XY))
-
- def test_center_location(self):
- square = Face.make_rect(1, 1, plane=Plane.XZ)
- cl = square.center_location
- self.assertVectorAlmostEquals(cl.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(Plane(cl).z_dir, Plane.XZ.z_dir, 5)
-
- def test_position_at(self):
- square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1))
- p = square.position_at(0.25, 0.75)
- self.assertVectorAlmostEquals(p, (-0.5, -1.0, 0.5), 5)
-
- def test_location_at(self):
- bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0]
- loc = bottom.location_at(0.5, 0.5)
- self.assertVectorAlmostEquals(loc.position, (0.5, 1, 0), 5)
- self.assertVectorAlmostEquals(loc.orientation, (-180, 0, -180), 5)
-
- front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0]
- loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1))
- self.assertVectorAlmostEquals(loc.position, (0.0, 1.0, 1.5), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, -90, 0), 5)
-
- def test_make_surface(self):
- corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
- net_exterior = Wire(
- [
- Edge.make_line(corners[3], corners[1]),
- Edge.make_line(corners[1], corners[0]),
- Edge.make_line(corners[0], corners[2]),
- Edge.make_three_point_arc(
- corners[2],
- (corners[2] + corners[3]) / 2 - Vector(0, 0, 3),
- corners[3],
- ),
- ]
- )
- surface = Face.make_surface(
- net_exterior,
- surface_points=[Vector(0, 0, -5)],
- )
- hole_flat = Wire.make_circle(10)
- hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0]
- surface = Face.make_surface(
- exterior=net_exterior,
- surface_points=[Vector(0, 0, -5)],
- interior_wires=[hole],
- )
- self.assertTrue(surface.is_valid())
- self.assertEqual(surface.geom_type, GeomType.BSPLINE)
- bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -5.113393280136395), 5)
- self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5)
-
- # With no surface point
- surface = Face.make_surface(net_exterior)
- bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -3), 5)
- self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5)
-
- # Exterior Edge
- surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)])
- bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-50, -50, -5), 5)
- self.assertVectorAlmostEquals(bbox.max, (50, 50, 0), 5)
-
- def test_make_surface_error_checking(self):
- with self.assertRaises(ValueError):
- Face.make_surface(Edge.make_line((0, 0), (1, 0)))
-
- with self.assertRaises(RuntimeError):
- Face.make_surface([Edge.make_line((0, 0), (1, 0))])
-
- if platform.system() != "Darwin":
- with self.assertRaises(RuntimeError):
- Face.make_surface(
- [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)]
- )
-
- with self.assertRaises(RuntimeError):
- Face.make_surface(
- [Edge.make_circle(50)],
- interior_wires=[Wire.make_circle(5, Plane.XZ)],
- )
-
- def test_sweep(self):
- edge = Edge.make_line((1, 0), (2, 0))
- path = Wire.make_circle(1)
- circle_with_hole = Face.sweep(edge, path)
- self.assertTrue(isinstance(circle_with_hole, Face))
- self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5)
- with self.assertRaises(ValueError):
- Face.sweep(edge, Polyline((0, 0), (0.1, 0), (0.2, 0.1)))
-
- def test_to_arcs(self):
- with BuildSketch() as bs:
- with BuildLine() as bl:
- Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0))
- fillet(bl.vertices(), radius=0.1)
- make_face()
- smooth = bs.faces()[0]
- fragmented = smooth.to_arcs()
- self.assertLess(len(smooth.edges()), len(fragmented.edges()))
-
- def test_outer_wire(self):
- face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
- self.assertAlmostEqual(face.outer_wire().length, 4, 5)
-
- def test_wire(self):
- face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
- with self.assertWarns(UserWarning):
- outer = face.wire()
- self.assertAlmostEqual(outer.length, 4, 5)
-
- def test_constructor(self):
- with self.assertRaises(ValueError):
- Face(bob="fred")
-
- def test_normal_at(self):
- face = Face.make_rect(1, 1)
- self.assertVectorAlmostEquals(face.normal_at(0, 0), (0, 0, 1), 5)
- self.assertVectorAlmostEquals(
- face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5
- )
- with self.assertRaises(ValueError):
- face.normal_at(0)
- with self.assertRaises(ValueError):
- face.normal_at(center=(0, 0))
- face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
- self.assertVectorAlmostEquals(face.normal_at(0, 1), (1, 0, 0), 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_functions.py b/tests/test_direct_api/test_functions.py
deleted file mode 100644
index 3f2bdf2..0000000
--- a/tests/test_direct_api/test_functions.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_functions.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import unittest
-
-from build123d.geometry import Plane, Vector
-from build123d.objects_part import Box
-from build123d.topology import (
- Compound,
- Face,
- Solid,
- edges_to_wires,
- polar,
- new_edges,
- delta,
- unwrap_topods_compound,
-)
-
-
-class TestFunctions(unittest.TestCase):
- def test_edges_to_wires(self):
- square_edges = Face.make_rect(1, 1).edges()
- rectangle_edges = Face.make_rect(2, 1, Plane((5, 0))).edges()
- wires = edges_to_wires(square_edges + rectangle_edges)
- self.assertEqual(len(wires), 2)
- self.assertAlmostEqual(wires[0].length, 4, 5)
- self.assertAlmostEqual(wires[1].length, 6, 5)
-
- def test_polar(self):
- pnt = polar(1, 30)
- self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5)
- self.assertAlmostEqual(pnt[1], 0.5, 5)
-
- def test_new_edges(self):
- c = Solid.make_cylinder(1, 5)
- s = Solid.make_sphere(2)
- s_minus_c = s - c
- seams = new_edges(c, s, combined=s_minus_c)
- self.assertEqual(len(seams), 1)
- self.assertAlmostEqual(seams[0].radius, 1, 5)
-
- def test_delta(self):
- cyl = Solid.make_cylinder(1, 5)
- sph = Solid.make_sphere(2)
- con = Solid.make_cone(2, 1, 2)
- plug = delta([cyl, sph, con], [sph, con])
- self.assertEqual(len(plug), 1)
- self.assertEqual(plug[0], cyl)
-
- def test_parse_intersect_args(self):
-
- with self.assertRaises(TypeError):
- Vector(1, 1, 1) & ("x", "y", "z")
-
- def test_unwrap_topods_compound(self):
- # Complex Compound
- b1 = Box(1, 1, 1).solid()
- b2 = Box(2, 2, 2).solid()
- c1 = Compound([b1, b2])
- c2 = Compound([b1, c1])
- c3 = Compound([c2])
- c4 = Compound([c3])
- self.assertEqual(c4.wrapped.NbChildren(), 1)
- c5 = Compound(unwrap_topods_compound(c4.wrapped, False))
- self.assertEqual(c5.wrapped.NbChildren(), 2)
-
- # unwrap fully
- c0 = Compound([b1])
- c1 = Compound([c0])
- result = Compound.cast(unwrap_topods_compound(c1.wrapped, True))
- self.assertTrue(isinstance(result, Solid))
-
- # unwrap not fully
- result = Compound.cast(unwrap_topods_compound(c1.wrapped, False))
- self.assertTrue(isinstance(result, Compound))
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_group_by.py b/tests/test_direct_api/test_group_by.py
deleted file mode 100644
index 6d0ed3f..0000000
--- a/tests/test_direct_api/test_group_by.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_group_by.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 pprint
-import unittest
-
-from build123d.geometry import Axis
-from build123d.topology import Solid
-
-
-class TestGroupBy(unittest.TestCase):
-
- def setUp(self):
- # Ensure the class variable is in its default state before each test
- self.v = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
-
- def test_str(self):
- self.assertEqual(
- str(self.v),
- f"""[[Vertex(0.0, 0.0, 0.0),
- Vertex(0.0, 1.0, 0.0),
- Vertex(1.0, 0.0, 0.0),
- Vertex(1.0, 1.0, 0.0)],
- [Vertex(0.0, 0.0, 1.0),
- Vertex(0.0, 1.0, 1.0),
- Vertex(1.0, 0.0, 1.0),
- Vertex(1.0, 1.0, 1.0)]]""",
- )
-
- def test_repr(self):
- self.assertEqual(
- repr(self.v),
- "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
- )
-
- def test_pp(self):
- self.assertEqual(
- pprint.pformat(self.v),
- "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
- )
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_import_export.py b/tests/test_direct_api/test_import_export.py
deleted file mode 100644
index 9dbabce..0000000
--- a/tests/test_direct_api/test_import_export.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_import_export.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 os
-import unittest
-
-from build123d.exporters3d import export_brep, export_step
-from build123d.importers import import_brep, import_step, import_stl
-from build123d.mesher import Mesher
-from build123d.topology import Solid
-from tests.base_test import DirectApiTestCase
-
-
-class TestImportExport(DirectApiTestCase):
- def test_import_export(self):
- original_box = Solid.make_box(1, 1, 1)
- export_step(original_box, "test_box.step")
- step_box = import_step("test_box.step")
- self.assertTrue(step_box.is_valid())
- self.assertAlmostEqual(step_box.volume, 1, 5)
- export_brep(step_box, "test_box.brep")
- brep_box = import_brep("test_box.brep")
- self.assertTrue(brep_box.is_valid())
- self.assertAlmostEqual(brep_box.volume, 1, 5)
- os.remove("test_box.step")
- os.remove("test_box.brep")
- with self.assertRaises(FileNotFoundError):
- step_box = import_step("test_box.step")
-
- def test_import_stl(self):
- # export solid
- original_box = Solid.make_box(1, 2, 3)
- exporter = Mesher()
- exporter.add_shape(original_box)
- exporter.write("test.stl")
-
- # import as face
- stl_box = import_stl("test.stl")
- self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_jupyter.py b/tests/test_direct_api/test_jupyter.py
deleted file mode 100644
index 870be3f..0000000
--- a/tests/test_direct_api/test_jupyter.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_jupyter.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.geometry import Vector
-from build123d.jupyter_tools import to_vtkpoly_string, display
-from build123d.topology import Solid
-from tests.base_test import DirectApiTestCase
-
-
-class TestJupyter(DirectApiTestCase):
- def test_repr_javascript(self):
- shape = Solid.make_box(1, 1, 1)
-
- # Test no exception on rendering to js
- js1 = shape._repr_javascript_()
-
- assert "function render" in js1
-
- def test_display_error(self):
- with self.assertRaises(AttributeError):
- display(Vector())
-
- with self.assertRaises(ValueError):
- to_vtkpoly_string("invalid")
-
- with self.assertRaises(ValueError):
- display("invalid")
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py
deleted file mode 100644
index 662d28f..0000000
--- a/tests/test_direct_api/test_location.py
+++ /dev/null
@@ -1,392 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_location.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 copy
-import json
-import math
-import os
-import unittest
-from random import uniform
-
-from OCP.gp import (
- gp_Ax1,
- gp_Dir,
- gp_EulerSequence,
- gp_Pnt,
- gp_Quaternion,
- gp_Trsf,
- gp_Vec,
-)
-from build123d.build_common import GridLocations
-from build123d.build_enums import Extrinsic, Intrinsic
-from build123d.geometry import Axis, Location, LocationEncoder, Plane, Pos, Vector
-from build123d.topology import Edge, Solid, Vertex
-from tests.base_test import DirectApiTestCase, RAD2DEG, AlwaysEqual
-
-
-class TestLocation(DirectApiTestCase):
- def test_location(self):
- loc0 = Location()
- T = loc0.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 0), 6)
- angle = loc0.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
- self.assertAlmostEqual(0, angle)
-
- # Tuple
- loc0 = Location((0, 0, 1))
-
- T = loc0.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
-
- # List
- loc0 = Location([0, 0, 1])
-
- T = loc0.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
-
- # Vector
- loc1 = Location(Vector(0, 0, 1))
-
- T = loc1.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
-
- # rotation + translation
- loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45)
-
- angle = loc2.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
- self.assertAlmostEqual(45, angle)
-
- # gp_Trsf
- T = gp_Trsf()
- T.SetTranslation(gp_Vec(0, 0, 1))
- loc3 = Location(T)
-
- self.assertEqual(
- loc1.wrapped.Transformation().TranslationPart().Z(),
- loc3.wrapped.Transformation().TranslationPart().Z(),
- )
-
- # Test creation from the OCP.gp.gp_Trsf object
- loc4 = Location(gp_Trsf())
- self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 0), 7)
- self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7)
-
- # Test creation from Plane and Vector
- loc4 = Location(Plane.XY, (0, 0, 1))
- self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 1), 7)
- self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7)
-
- # Test composition
- loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15)
-
- loc5 = loc1 * loc4
- loc6 = loc4 * loc4
- loc7 = loc4**2
-
- T = loc5.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
-
- angle5 = (
- loc5.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
- )
- self.assertAlmostEqual(15, angle5)
-
- angle6 = (
- loc6.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
- )
- self.assertAlmostEqual(30, angle6)
-
- angle7 = (
- loc7.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
- )
- self.assertAlmostEqual(30, angle7)
-
- # Test error handling on creation
- with self.assertRaises(TypeError):
- Location("xy_plane")
-
- # Test that the computed rotation matrix and intrinsic euler angles return the same
-
- about_x = uniform(-2 * math.pi, 2 * math.pi)
- about_y = uniform(-2 * math.pi, 2 * math.pi)
- about_z = uniform(-2 * math.pi, 2 * math.pi)
-
- rot_x = gp_Trsf()
- rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), about_x)
- rot_y = gp_Trsf()
- rot_y.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), about_y)
- rot_z = gp_Trsf()
- rot_z.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), about_z)
- loc1 = Location(rot_x * rot_y * rot_z)
-
- q = gp_Quaternion()
- q.SetEulerAngles(
- gp_EulerSequence.gp_Intrinsic_XYZ,
- about_x,
- about_y,
- about_z,
- )
- t = gp_Trsf()
- t.SetRotationPart(q)
- loc2 = Location(t)
-
- self.assertTupleAlmostEquals(loc1.to_tuple()[0], loc2.to_tuple()[0], 6)
- self.assertTupleAlmostEquals(loc1.to_tuple()[1], loc2.to_tuple()[1], 6)
-
- loc1 = Location((1, 2), 34)
- self.assertTupleAlmostEquals(loc1.to_tuple()[0], (1, 2, 0), 6)
- self.assertTupleAlmostEquals(loc1.to_tuple()[1], (0, 0, 34), 6)
-
- rot_angles = (-115.00, 35.00, -135.00)
- loc2 = Location((1, 2, 3), rot_angles)
- self.assertTupleAlmostEquals(loc2.to_tuple()[0], (1, 2, 3), 6)
- self.assertTupleAlmostEquals(loc2.to_tuple()[1], rot_angles, 6)
-
- loc3 = Location(loc2)
- self.assertTupleAlmostEquals(loc3.to_tuple()[0], (1, 2, 3), 6)
- self.assertTupleAlmostEquals(loc3.to_tuple()[1], rot_angles, 6)
-
- def test_location_parameters(self):
- loc = Location((10, 20, 30))
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
-
- loc = Location((10, 20, 30), (10, 20, 30))
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
- self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
-
- loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ)
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
- self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
-
- loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX)
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
- self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
-
- with self.assertRaises(TypeError):
- Location(x=10)
-
- with self.assertRaises(TypeError):
- Location((10, 20, 30), (30, 20, 10), (10, 20, 30))
-
- with self.assertRaises(TypeError):
- Location(Intrinsic.XYZ)
-
- def test_location_repr_and_str(self):
- self.assertEqual(
- repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))"
- )
- self.assertEqual(
- str(Location()),
- "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))",
- )
- loc = Location((1, 2, 3), (33, 45, 67))
- self.assertEqual(
- str(loc),
- "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))",
- )
-
- def test_location_inverted(self):
- loc = Location(Plane.XZ)
- self.assertVectorAlmostEquals(loc.inverse().orientation, (-90, 0, 0), 6)
-
- def test_set_position(self):
- loc = Location(Plane.XZ)
- loc.position = (1, 2, 3)
- self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(loc.orientation, (90, 0, 0), 6)
-
- def test_set_orientation(self):
- loc = Location((1, 2, 3), (90, 0, 0))
- loc.orientation = (-90, 0, 0)
- self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(loc.orientation, (-90, 0, 0), 6)
-
- def test_copy(self):
- loc1 = Location((1, 2, 3), (90, 45, 22.5))
- loc2 = copy.copy(loc1)
- loc3 = copy.deepcopy(loc1)
- self.assertVectorAlmostEquals(loc1.position, loc2.position.to_tuple(), 6)
- self.assertVectorAlmostEquals(loc1.orientation, loc2.orientation.to_tuple(), 6)
- self.assertVectorAlmostEquals(loc1.position, loc3.position.to_tuple(), 6)
- self.assertVectorAlmostEquals(loc1.orientation, loc3.orientation.to_tuple(), 6)
-
- def test_to_axis(self):
- axis = Location((1, 2, 3), (-90, 0, 0)).to_axis()
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(axis.direction, (0, 1, 0), 6)
-
- def test_equal(self):
- loc = Location((1, 2, 3), (4, 5, 6))
- same = Location((1, 2, 3), (4, 5, 6))
-
- self.assertEqual(loc, same)
- self.assertEqual(loc, AlwaysEqual())
-
- def test_not_equal(self):
- loc = Location((1, 2, 3), (40, 50, 60))
- diff_position = Location((3, 2, 1), (40, 50, 60))
- diff_orientation = Location((1, 2, 3), (60, 50, 40))
-
- self.assertNotEqual(loc, diff_position)
- self.assertNotEqual(loc, diff_orientation)
- self.assertNotEqual(loc, object())
-
- def test_neg(self):
- loc = Location((1, 2, 3), (0, 35, 127))
- n_loc = -loc
- self.assertVectorAlmostEquals(n_loc.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(n_loc.orientation, (180, -35, -127), 5)
-
- def test_mult_iterable(self):
- locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1)
- self.assertVectorAlmostEquals(locs[0].position, (-1, 2, 0), 5)
- self.assertVectorAlmostEquals(locs[1].position, (3, 2, 0), 5)
-
- def test_as_json(self):
- data_dict = {
- "part1": {
- "joint_one": Location((1, 2, 3), (4, 5, 6)),
- "joint_two": Location((7, 8, 9), (10, 11, 12)),
- },
- "part2": {
- "joint_one": Location((13, 14, 15), (16, 17, 18)),
- "joint_two": Location((19, 20, 21), (22, 23, 24)),
- },
- }
-
- # Serializing json with custom Location encoder
- json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder)
-
- # Writing to sample.json
- with open("sample.json", "w") as outfile:
- outfile.write(json_object)
-
- # Reading from sample.json
- with open("sample.json") as infile:
- read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
-
- # Validate locations
- for key, value in read_json.items():
- for k, v in value.items():
- if key == "part1" and k == "joint_one":
- self.assertVectorAlmostEquals(v.position, (1, 2, 3), 5)
- elif key == "part1" and k == "joint_two":
- self.assertVectorAlmostEquals(v.position, (7, 8, 9), 5)
- elif key == "part2" and k == "joint_one":
- self.assertVectorAlmostEquals(v.position, (13, 14, 15), 5)
- elif key == "part2" and k == "joint_two":
- self.assertVectorAlmostEquals(v.position, (19, 20, 21), 5)
- else:
- self.assertTrue(False)
- os.remove("sample.json")
-
- def test_intersection(self):
- e = Edge.make_line((0, 0, 0), (1, 1, 1))
- l0 = e.location_at(0)
- l1 = e.location_at(1)
- self.assertIsNone(l0 & l1)
- self.assertEqual(l1 & l1, l1)
-
- i = l1 & Vector(1, 1, 1)
- self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, (1, 1, 1), 5)
-
- i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1))
- self.assertTrue(isinstance(i, Location))
- self.assertEqual(i, l1)
-
- p = Plane.XY.rotated((45, 0, 0)).shift_origin((1, 0, 0))
- l = Location((1, 0, 0), (1, 0, 0), 45)
- i = l & p
- self.assertTrue(isinstance(i, Location))
- self.assertVectorAlmostEquals(i.position, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(i.orientation, l.orientation, 5)
-
- b = Solid.make_box(1, 1, 1)
- l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45)
- i = (l & b).vertex()
- self.assertTrue(isinstance(i, Vertex))
- self.assertVectorAlmostEquals(Vector(i), (0.5, 0.5, 0.5), 5)
-
- e1 = Edge.make_line((0, -1), (2, 1))
- e2 = Edge.make_line((0, 1), (2, -1))
- e3 = Edge.make_line((0, 0), (2, 0))
-
- i = e1.intersect(e2, e3)
- self.assertTrue(isinstance(i, Vertex))
- self.assertVectorAlmostEquals(i, (1, 0, 0), 5)
-
- e4 = Edge.make_line((1, -1), (1, 1))
- e5 = Edge.make_line((2, -1), (2, 1))
- i = e3.intersect(e4, e5)
- self.assertIsNone(i)
-
- self.assertIsNone(b.intersect(b.moved(Pos(X=10))))
-
- # Look for common vertices
- e1 = Edge.make_line((0, 0), (1, 0))
- e2 = Edge.make_line((1, 0), (1, 1))
- e3 = Edge.make_line((1, 0), (2, 0))
- i = e1.intersect(e2)
- self.assertEqual(len(i.vertices()), 1)
- self.assertEqual(tuple(i.vertex()), (1, 0, 0))
- i = e1.intersect(e3)
- self.assertEqual(len(i.vertices()), 1)
- self.assertEqual(tuple(i.vertex()), (1, 0, 0))
-
- # Intersect with plane
- e1 = Edge.make_line((0, 0), (2, 0))
- p1 = Plane.YZ.offset(1)
- i = e1.intersect(p1)
- self.assertEqual(len(i.vertices()), 1)
- self.assertEqual(tuple(i.vertex()), (1, 0, 0))
-
- e2 = Edge.make_line(p1.origin, p1.origin + 2 * p1.x_dir)
- i = e2.intersect(p1)
- self.assertEqual(len(i.vertices()), 2)
- self.assertEqual(len(i.edges()), 1)
- self.assertAlmostEqual(i.edge().length, 2, 5)
-
- with self.assertRaises(ValueError):
- e1.intersect("line")
-
- def test_pos(self):
- with self.assertRaises(TypeError):
- Pos(0, "foo")
- self.assertEqual(Pos(1, 2, 3).position, Vector(1, 2, 3))
- self.assertEqual(Pos((1, 2, 3)).position, Vector(1, 2, 3))
- self.assertEqual(Pos(v=(1, 2, 3)).position, Vector(1, 2, 3))
- self.assertEqual(Pos(X=1, Y=2, Z=3).position, Vector(1, 2, 3))
- self.assertEqual(Pos(Vector(1, 2, 3)).position, Vector(1, 2, 3))
- self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3))
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_matrix.py b/tests/test_direct_api/test_matrix.py
deleted file mode 100644
index 23d9fbc..0000000
--- a/tests/test_direct_api/test_matrix.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_matrix.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 copy
-import math
-import unittest
-
-from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf
-from build123d.geometry import Axis, Matrix, Vector
-from tests.base_test import DirectApiTestCase, DEG2RAD
-
-
-class TestMatrix(DirectApiTestCase):
- def test_matrix_creation_and_access(self):
- def matrix_vals(m):
- return [[m[r, c] for c in range(4)] for r in range(4)]
-
- # default constructor creates a 4x4 identity matrix
- m = Matrix()
- identity = [
- [1.0, 0.0, 0.0, 0.0],
- [0.0, 1.0, 0.0, 0.0],
- [0.0, 0.0, 1.0, 0.0],
- [0.0, 0.0, 0.0, 1.0],
- ]
- self.assertEqual(identity, matrix_vals(m))
-
- vals4x4 = [
- [1.0, 0.0, 0.0, 1.0],
- [0.0, 1.0, 0.0, 2.0],
- [0.0, 0.0, 1.0, 3.0],
- [0.0, 0.0, 0.0, 1.0],
- ]
- vals4x4_tuple = tuple(tuple(r) for r in vals4x4)
-
- # test constructor with 16-value input
- m = Matrix(vals4x4)
- self.assertEqual(vals4x4, matrix_vals(m))
- m = Matrix(vals4x4_tuple)
- self.assertEqual(vals4x4, matrix_vals(m))
-
- # test constructor with 12-value input (the last 4 are an implied
- # [0,0,0,1])
- m = Matrix(vals4x4[:3])
- self.assertEqual(vals4x4, matrix_vals(m))
- m = Matrix(vals4x4_tuple[:3])
- self.assertEqual(vals4x4, matrix_vals(m))
-
- # Test 16-value input with invalid values for the last 4
- invalid = [
- [1.0, 0.0, 0.0, 1.0],
- [0.0, 1.0, 0.0, 2.0],
- [0.0, 0.0, 1.0, 3.0],
- [1.0, 2.0, 3.0, 4.0],
- ]
- with self.assertRaises(ValueError):
- Matrix(invalid)
- # Test input with invalid type
- with self.assertRaises(TypeError):
- Matrix("invalid")
- # Test input with invalid size / nested types
- with self.assertRaises(TypeError):
- Matrix([[1, 2, 3, 4], [1, 2, 3], [1, 2, 3, 4]])
- with self.assertRaises(TypeError):
- Matrix([1, 2, 3])
-
- # Invalid sub-type
- with self.assertRaises(TypeError):
- Matrix([[1, 2, 3, 4], "abc", [1, 2, 3, 4]])
-
- # test out-of-bounds access
- m = Matrix()
- with self.assertRaises(IndexError):
- m[0, 4]
- with self.assertRaises(IndexError):
- m[4, 0]
- with self.assertRaises(IndexError):
- m["ab"]
-
- # test __repr__ methods
- m = Matrix(vals4x4)
- mRepr = "Matrix([[1.0, 0.0, 0.0, 1.0],\n [0.0, 1.0, 0.0, 2.0],\n [0.0, 0.0, 1.0, 3.0],\n [0.0, 0.0, 0.0, 1.0]])"
- self.assertEqual(repr(m), mRepr)
- self.assertEqual(str(eval(repr(m))), mRepr)
-
- def test_matrix_functionality(self):
- # Test rotate methods
- def matrix_almost_equal(m, target_matrix):
- for r, row in enumerate(target_matrix):
- for c, target_value in enumerate(row):
- self.assertAlmostEqual(m[r, c], target_value)
-
- root_3_over_2 = math.sqrt(3) / 2
- m_rotate_x_30 = [
- [1, 0, 0, 0],
- [0, root_3_over_2, -1 / 2, 0],
- [0, 1 / 2, root_3_over_2, 0],
- [0, 0, 0, 1],
- ]
- mx = Matrix()
- mx.rotate(Axis.X, 30 * DEG2RAD)
- matrix_almost_equal(mx, m_rotate_x_30)
-
- m_rotate_y_30 = [
- [root_3_over_2, 0, 1 / 2, 0],
- [0, 1, 0, 0],
- [-1 / 2, 0, root_3_over_2, 0],
- [0, 0, 0, 1],
- ]
- my = Matrix()
- my.rotate(Axis.Y, 30 * DEG2RAD)
- matrix_almost_equal(my, m_rotate_y_30)
-
- m_rotate_z_30 = [
- [root_3_over_2, -1 / 2, 0, 0],
- [1 / 2, root_3_over_2, 0, 0],
- [0, 0, 1, 0],
- [0, 0, 0, 1],
- ]
- mz = Matrix()
- mz.rotate(Axis.Z, 30 * DEG2RAD)
- matrix_almost_equal(mz, m_rotate_z_30)
-
- # Test matrix multiply vector
- v = Vector(1, 0, 0)
- self.assertVectorAlmostEquals(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7)
-
- # Test matrix multiply matrix
- m_rotate_xy_30 = [
- [root_3_over_2, 0, 1 / 2, 0],
- [1 / 4, root_3_over_2, -root_3_over_2 / 2, 0],
- [-root_3_over_2 / 2, 1 / 2, 3 / 4, 0],
- [0, 0, 0, 1],
- ]
- mxy = mx.multiply(my)
- matrix_almost_equal(mxy, m_rotate_xy_30)
-
- # Test matrix inverse
- vals4x4 = [[1, 2, 3, 4], [5, 1, 6, 7], [8, 9, 1, 10], [0, 0, 0, 1]]
- vals4x4_invert = [
- [-53 / 144, 25 / 144, 1 / 16, -53 / 144],
- [43 / 144, -23 / 144, 1 / 16, -101 / 144],
- [37 / 144, 7 / 144, -1 / 16, -107 / 144],
- [0, 0, 0, 1],
- ]
- m = Matrix(vals4x4).inverse()
- matrix_almost_equal(m, vals4x4_invert)
-
- # Test matrix created from transfer function
- rot_x = gp_Trsf()
- θ = math.pi
- rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), θ)
- m = Matrix(rot_x)
- rot_x_matrix = [
- [1, 0, 0, 0],
- [0, math.cos(θ), -math.sin(θ), 0],
- [0, math.sin(θ), math.cos(θ), 0],
- [0, 0, 0, 1],
- ]
- matrix_almost_equal(m, rot_x_matrix)
-
- # Test copy
- m2 = copy.copy(m)
- matrix_almost_equal(m2, rot_x_matrix)
- m3 = copy.deepcopy(m)
- matrix_almost_equal(m3, rot_x_matrix)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py
deleted file mode 100644
index 63589e4..0000000
--- a/tests/test_direct_api/test_mixin1_d.py
+++ /dev/null
@@ -1,321 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_mixin1_d.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import unittest
-
-from build123d.build_enums import CenterOf, GeomType, PositionMode, Side, SortBy
-from build123d.geometry import Axis, Location, Plane, Vector
-from build123d.objects_part import Box, Cylinder
-from build123d.topology import Compound, Edge, Face, Wire
-from tests.base_test import DirectApiTestCase
-
-
-class TestMixin1D(DirectApiTestCase):
- """Test the add in methods"""
-
- def test_position_at(self):
- self.assertVectorAlmostEquals(
- Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5),
- (0.5, 0.5, 0.5),
- 5,
- )
- # Not sure what PARAMETER mode returns - but it's in the ballpark
- point = (
- Edge.make_line((0, 0, 0), (1, 1, 1))
- .position_at(0.5, position_mode=PositionMode.PARAMETER)
- .to_tuple()
- )
- self.assertTrue(all([0.0 < v < 1.0 for v in point]))
-
- wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))])
- self.assertVectorAlmostEquals(wire.position_at(0.3), (3, 0, 0), 5)
- self.assertVectorAlmostEquals(
- wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
- )
- self.assertVectorAlmostEquals(wire.edge().position_at(0.3), (3, 0, 0), 5)
- self.assertVectorAlmostEquals(
- wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
- )
-
- circle_wire = Wire(
- [
- Edge.make_circle(1, start_angle=0, end_angle=180),
- Edge.make_circle(1, start_angle=180, end_angle=360),
- ]
- )
- p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH)
- p2 = circle_wire.position_at(math.pi / circle_wire.length)
- self.assertVectorAlmostEquals(p1, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p2, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p1, p2, 14)
-
- circle_edge = Edge.make_circle(1)
- p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH)
- p4 = circle_edge.position_at(math.pi / circle_edge.length)
- self.assertVectorAlmostEquals(p3, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p4, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p3, p4, 14)
-
- circle = Wire(
- [
- Edge.make_circle(2, start_angle=0, end_angle=180),
- Edge.make_circle(2, start_angle=180, end_angle=360),
- ]
- )
- self.assertVectorAlmostEquals(
- circle.position_at(0.5),
- (-2, 0, 0),
- 5,
- )
- self.assertVectorAlmostEquals(
- circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH),
- (-2, 0, 0),
- 5,
- )
-
- def test_positions(self):
- e = Edge.make_line((0, 0, 0), (1, 1, 1))
- distances = [i / 4 for i in range(3)]
- pts = e.positions(distances)
- for i, position in enumerate(pts):
- self.assertVectorAlmostEquals(position, (i / 4, i / 4, i / 4), 5)
-
- def test_tangent_at(self):
- self.assertVectorAlmostEquals(
- Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0),
- (-1, 0, 0),
- 5,
- )
- tangent = (
- Edge.make_circle(1, start_angle=0, end_angle=90)
- .tangent_at(0.0, position_mode=PositionMode.PARAMETER)
- .to_tuple()
- )
- self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent]))
-
- self.assertVectorAlmostEquals(
- Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at(
- math.pi / 2, position_mode=PositionMode.LENGTH
- ),
- (-1, 0, 0),
- 5,
- )
-
- def test_tangent_at_point(self):
- circle = Wire(
- [
- Edge.make_circle(1, start_angle=0, end_angle=180),
- Edge.make_circle(1, start_angle=180, end_angle=360),
- ]
- )
- pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4))
- tan = circle.tangent_at(pnt_on_circle)
- self.assertVectorAlmostEquals(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5)
-
- def test_tangent_at_by_length(self):
- circle = Edge.make_circle(1)
- tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH)
- self.assertVectorAlmostEquals(tan, (0, -1), 5)
-
- def test_tangent_at_error(self):
- with self.assertRaises(ValueError):
- Edge.make_circle(1).tangent_at("start")
-
- def test_normal(self):
- self.assertVectorAlmostEquals(
- Edge.make_circle(
- 1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60
- ).normal(),
- (1, 0, 0),
- 5,
- )
- self.assertVectorAlmostEquals(
- Edge.make_ellipse(
- 1,
- 0.5,
- Plane(origin=(0, 0, 0), z_dir=(1, 1, 0)),
- start_angle=0,
- end_angle=90,
- ).normal(),
- (math.sqrt(2) / 2, math.sqrt(2) / 2, 0),
- 5,
- )
- self.assertVectorAlmostEquals(
- Edge.make_spline(
- [
- (1, 0),
- (math.sqrt(2) / 2, math.sqrt(2) / 2),
- (0, 1),
- ],
- tangents=((0, 1, 0), (-1, 0, 0)),
- ).normal(),
- (0, 0, 1),
- 5,
- )
- with self.assertRaises(ValueError):
- Edge.make_line((0, 0, 0), (1, 1, 1)).normal()
-
- def test_center(self):
- c = Edge.make_circle(1, start_angle=0, end_angle=180)
- self.assertVectorAlmostEquals(c.center(), (0, 1, 0), 5)
- self.assertVectorAlmostEquals(
- c.center(CenterOf.MASS),
- (0, 0.6366197723675814, 0),
- 5,
- )
- self.assertVectorAlmostEquals(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5)
-
- def test_location_at(self):
- loc = Edge.make_circle(1).location_at(0.25)
- self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5)
-
- loc = Edge.make_circle(1).location_at(
- math.pi / 2, position_mode=PositionMode.LENGTH
- )
- self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5)
-
- def test_locations(self):
- locs = Edge.make_circle(1).locations([i / 4 for i in range(4)])
- self.assertVectorAlmostEquals(locs[0].position, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[0].orientation, (-90, 0, -180), 5)
- self.assertVectorAlmostEquals(locs[1].position, (0, 1, 0), 5)
- self.assertVectorAlmostEquals(locs[1].orientation, (0, -90, -90), 5)
- self.assertVectorAlmostEquals(locs[2].position, (-1, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[2].orientation, (90, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[3].position, (0, -1, 0), 5)
- self.assertVectorAlmostEquals(locs[3].orientation, (0, 90, 90), 5)
-
- def test_project(self):
- target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0)))
- circle = Edge.make_circle(1).locate(Location((0, 0, 10)))
- ellipse: Wire = circle.project(target, (0, 0, -1))
- bbox = ellipse.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5)
- self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5)
-
- def test_project2(self):
- target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
- square = Wire.make_rect(1, 1, Plane.YZ).locate(Location((10, 0, 0)))
- projections: list[Wire] = square.project(
- target, direction=(-1, 0, 0), closest=False
- )
- self.assertEqual(len(projections), 2)
-
- def test_is_forward(self):
- plate = Box(10, 10, 1) - Cylinder(1, 1)
- hole_edges = plate.edges().filter_by(GeomType.CIRCLE)
- self.assertTrue(hole_edges.sort_by(Axis.Z)[-1].is_forward)
- self.assertFalse(hole_edges.sort_by(Axis.Z)[0].is_forward)
-
- def test_offset_2d(self):
- base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False)
- corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1]
- base_wire = base_wire.fillet_2d(0.4, [corner])
- offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT)
- self.assertTrue(offset_wire.is_closed)
- self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6)
- self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2)
- offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT)
- self.assertAlmostEqual(
- offset_wire_right.edges()
- .filter_by(GeomType.CIRCLE)
- .sort_by(SortBy.RADIUS)[-1]
- .radius,
- 0.5,
- 4,
- )
- h_perimeter = Compound.make_text("h", font_size=10).wire()
- with self.assertRaises(RuntimeError):
- h_perimeter.offset_2d(-1)
-
- # Test for returned Edge - can't find a way to do this
- # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50)
- # self.assertTrue(isinstance(offset_edge, Edge))
- # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
- # self.assertTrue(offset_edge.geom_type == GeomType.CIRCLE)
- # self.assertAlmostEqual(offset_edge.radius, 12, 5)
- # base_edge = Edge.make_line((0, 1), (1, 10))
- # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
- # self.assertTrue(isinstance(offset_edge, Edge))
- # self.assertTrue(offset_edge.geom_type == GeomType.LINE)
- # self.assertAlmostEqual(offset_edge.position_at(0).X, 3)
-
- def test_common_plane(self):
- # Straight and circular lines
- l = Edge.make_line((0, 0, 0), (5, 0, 0))
- c = Edge.make_circle(2, Plane.XZ, -90, 90)
- common = l.common_plane(c)
- self.assertAlmostEqual(common.z_dir.X, 0, 5)
- self.assertAlmostEqual(abs(common.z_dir.Y), 1, 5) # the direction isn't known
- self.assertAlmostEqual(common.z_dir.Z, 0, 5)
-
- # Co-axial straight lines
- l1 = Edge.make_line((0, 0), (1, 1))
- l2 = Edge.make_line((0.25, 0.25), (0.75, 0.75))
- common = l1.common_plane(l2)
- # the z_dir isn't know
- self.assertAlmostEqual(common.x_dir.Z, 0, 5)
-
- # Parallel lines
- l1 = Edge.make_line((0, 0), (1, 0))
- l2 = Edge.make_line((0, 1), (1, 1))
- common = l1.common_plane(l2)
- self.assertAlmostEqual(common.z_dir.X, 0, 5)
- self.assertAlmostEqual(common.z_dir.Y, 0, 5)
- self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
-
- # Many lines
- common = Edge.common_plane(*Wire.make_rect(10, 10).edges())
- self.assertAlmostEqual(common.z_dir.X, 0, 5)
- self.assertAlmostEqual(common.z_dir.Y, 0, 5)
- self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
-
- # Wire and Edges
- c = Wire.make_circle(1, Plane.YZ)
- lines = Wire.make_rect(2, 2, Plane.YZ).edges()
- common = c.common_plane(*lines)
- self.assertAlmostEqual(abs(common.z_dir.X), 1, 5) # the direction isn't known
- self.assertAlmostEqual(common.z_dir.Y, 0, 5)
- self.assertAlmostEqual(common.z_dir.Z, 0, 5)
-
- def test_edge_volume(self):
- edge = Edge.make_line((0, 0), (1, 1))
- self.assertAlmostEqual(edge.volume, 0, 5)
-
- def test_wire_volume(self):
- wire = Wire.make_rect(1, 1)
- self.assertAlmostEqual(wire.volume, 0, 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_mixin3_d.py b/tests/test_direct_api/test_mixin3_d.py
deleted file mode 100644
index 100c680..0000000
--- a/tests/test_direct_api/test_mixin3_d.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_mixin3_d.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 unittest.mock import patch
-
-from build123d.build_enums import CenterOf, Kind
-from build123d.geometry import Axis, Plane
-from build123d.topology import Face, Shape, Solid
-from tests.base_test import DirectApiTestCase
-
-
-class TestMixin3D(DirectApiTestCase):
- """Test that 3D add ins"""
-
- def test_chamfer(self):
- box = Solid.make_box(1, 1, 1)
- chamfer_box = box.chamfer(0.1, None, box.edges().sort_by(Axis.Z)[-1:])
- self.assertAlmostEqual(chamfer_box.volume, 1 - 0.005, 5)
-
- def test_chamfer_asym_length(self):
- box = Solid.make_box(1, 1, 1)
- chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:])
- self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
-
- def test_chamfer_asym_length_with_face(self):
- box = Solid.make_box(1, 1, 1)
- face = box.faces().sort_by(Axis.Z)[0]
- edge = [face.edges().sort_by(Axis.Y)[0]]
- chamfer_box = box.chamfer(0.1, 0.2, edge, face=face)
- self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
-
- def test_chamfer_too_high_length(self):
- box = Solid.make_box(1, 1, 1)
- face = box.faces
- self.assertRaises(
- ValueError, box.chamfer, 2, None, box.edges().sort_by(Axis.Z)[-1:]
- )
-
- def test_chamfer_edge_not_part_of_face(self):
- box = Solid.make_box(1, 1, 1)
- edge = box.edges().sort_by(Axis.Z)[-1:]
- face = box.faces().sort_by(Axis.Z)[0]
- self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face)
-
- @patch.object(Shape, "is_valid", return_value=False)
- def test_chamfer_invalid_shape_raises_error(self, mock_is_valid):
- box = Solid.make_box(1, 1, 1)
-
- # Assert that ValueError is raised
- with self.assertRaises(ValueError) as chamfer_context:
- max = box.chamfer(0.1, None, box.edges())
-
- # Check the error message
- self.assertEqual(
- str(chamfer_context.exception),
- "Failed creating a chamfer, try a smaller length value(s)",
- )
-
- # Verify is_valid was called
- mock_is_valid.assert_called_once()
-
- def test_hollow(self):
- shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1)
- self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5)
-
- shell_box = Solid.make_box(1, 1, 1)
- shell_box = shell_box.hollow(
- shell_box.faces().filter_by(Axis.Z), thickness=0.1, kind=Kind.INTERSECTION
- )
- self.assertAlmostEqual(shell_box.volume, 1 * 1.2**2 - 1**3, 5)
-
- shell_box = Solid.make_box(1, 1, 1).hollow(
- [], thickness=0.1, kind=Kind.INTERSECTION
- )
- self.assertAlmostEqual(shell_box.volume, 1.2**3 - 1**3, 5)
-
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT)
-
- def test_is_inside(self):
- self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5)))
-
- def test_dprism(self):
- # face
- f = Face.make_rect(0.5, 0.5)
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [f], additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
-
- # face with depth
- f = Face.make_rect(0.5, 0.5)
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [f], depth=0.5, thru_all=False, additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
-
- # face until
- f = Face.make_rect(0.5, 0.5)
- limit = Face.make_rect(1, 1, Plane((0, 0, 0.5)))
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [f], up_to_face=limit, thru_all=False, additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
-
- # wire
- w = Face.make_rect(0.5, 0.5).outer_wire()
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [w], additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
-
- def test_center(self):
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY)
-
- self.assertVectorAlmostEquals(
- Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX),
- (0.5, 0.5, 0.5),
- 5,
- )
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py
deleted file mode 100644
index f88664f..0000000
--- a/tests/test_direct_api/test_plane.py
+++ /dev/null
@@ -1,504 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_plane.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 copy
-import math
-import random
-import unittest
-
-from OCP.BRepGProp import BRepGProp
-from OCP.GProp import GProp_GProps
-from build123d.build_common import Locations
-from build123d.build_enums import Align, GeomType, Mode
-from build123d.build_part import BuildPart
-from build123d.build_sketch import BuildSketch
-from build123d.geometry import Axis, Location, Plane, Pos, Vector
-from build123d.objects_part import Box, Cylinder
-from build123d.objects_sketch import Circle, Rectangle
-from build123d.operations_generic import fillet, add
-from build123d.operations_part import extrude
-from build123d.topology import Edge, Face, Solid, Vertex
-from tests.base_test import DirectApiTestCase, AlwaysEqual
-
-
-class TestPlane(DirectApiTestCase):
- """Plane with class properties"""
-
- def test_class_properties(self):
- """Validate
- Name x_dir y_dir z_dir
- ======= ====== ====== ======
- XY +x +y +z
- YZ +y +z +x
- ZX +z +x +y
- XZ +x +z -y
- YX +y +x -z
- ZY +z +y -x
- front +x +z -y
- back -x +z +y
- left -y +z -x
- right +y +z +x
- top +x +y +z
- bottom +x -y -z
- isometric +x+y -x+y+z +x+y-z
- """
- planes = [
- (Plane.XY, (1, 0, 0), (0, 0, 1)),
- (Plane.YZ, (0, 1, 0), (1, 0, 0)),
- (Plane.ZX, (0, 0, 1), (0, 1, 0)),
- (Plane.XZ, (1, 0, 0), (0, -1, 0)),
- (Plane.YX, (0, 1, 0), (0, 0, -1)),
- (Plane.ZY, (0, 0, 1), (-1, 0, 0)),
- (Plane.front, (1, 0, 0), (0, -1, 0)),
- (Plane.back, (-1, 0, 0), (0, 1, 0)),
- (Plane.left, (0, -1, 0), (-1, 0, 0)),
- (Plane.right, (0, 1, 0), (1, 0, 0)),
- (Plane.top, (1, 0, 0), (0, 0, 1)),
- (Plane.bottom, (1, 0, 0), (0, 0, -1)),
- (
- Plane.isometric,
- (1 / 2**0.5, 1 / 2**0.5, 0),
- (1 / 3**0.5, -1 / 3**0.5, 1 / 3**0.5),
- ),
- ]
- for plane, x_dir, z_dir in planes:
- self.assertVectorAlmostEquals(plane.x_dir, x_dir, 5)
- self.assertVectorAlmostEquals(plane.z_dir, z_dir, 5)
-
- def test_plane_init(self):
- # from origin
- o = (0, 0, 0)
- x = (1, 0, 0)
- y = (0, 1, 0)
- z = (0, 0, 1)
- planes = [
- Plane(o),
- Plane(o, x),
- Plane(o, x, z),
- Plane(o, x, z_dir=z),
- Plane(o, x_dir=x, z_dir=z),
- Plane(o, x_dir=x),
- Plane(o, z_dir=z),
- Plane(origin=o, x_dir=x, z_dir=z),
- Plane(origin=o, x_dir=x),
- Plane(origin=o, z_dir=z),
- ]
- for p in planes:
- self.assertVectorAlmostEquals(p.origin, o, 6)
- self.assertVectorAlmostEquals(p.x_dir, x, 6)
- self.assertVectorAlmostEquals(p.y_dir, y, 6)
- self.assertVectorAlmostEquals(p.z_dir, z, 6)
- with self.assertRaises(TypeError):
- Plane()
- with self.assertRaises(TypeError):
- Plane(o, z_dir="up")
-
- # rotated location around z
- loc = Location((0, 0, 0), (0, 0, 45))
- p_from_loc = Plane(loc)
- p_from_named_loc = Plane(location=loc)
- for p in [p_from_loc, p_from_named_loc]:
- self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
- self.assertVectorAlmostEquals(
- p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(
- p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
- self.assertVectorAlmostEquals(loc.position, p.location.position, 6)
- self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6)
-
- # rotated location around x and origin <> (0,0,0)
- loc = Location((0, 2, -1), (45, 0, 0))
- p = Plane(loc)
- self.assertVectorAlmostEquals(p.origin, (0, 2, -1), 6)
- self.assertVectorAlmostEquals(p.x_dir, (1, 0, 0), 6)
- self.assertVectorAlmostEquals(
- p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(
- p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(loc.position, p.location.position, 6)
- self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6)
-
- # from a face
- f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45)))
- p_from_face = Plane(f)
- p_from_named_face = Plane(face=f)
- plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped)
- p_deep_copy = copy.deepcopy(p_from_face)
- for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]:
- self.assertVectorAlmostEquals(p.origin, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
- self.assertVectorAlmostEquals(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
- self.assertVectorAlmostEquals(
- p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(f.location.position, p.location.position, 6)
- self.assertVectorAlmostEquals(
- f.location.orientation, p.location.orientation, 6
- )
-
- # from a face with x_dir
- f = Face.make_rect(1, 2)
- x = (1, 1)
- y = (-1, 1)
- planes = [
- Plane(f, x),
- Plane(f, x_dir=x),
- Plane(face=f, x_dir=x),
- ]
- for p in planes:
- self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
- self.assertVectorAlmostEquals(p.x_dir, Vector(x).normalized(), 6)
- self.assertVectorAlmostEquals(p.y_dir, Vector(y).normalized(), 6)
- self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
-
- with self.assertRaises(TypeError):
- Plane(Edge.make_line((0, 0), (0, 1)))
-
- # can be instantiated from planar faces of surface types other than Geom_Plane
- # this loft creates the trapezoid faces of type Geom_BSplineSurface
- lofted_solid = Solid.make_loft(
- [
- Rectangle(3, 1).wire(),
- Pos(0, 0, 1) * Rectangle(1, 1).wire(),
- ]
- )
-
- expected = [
- # Trapezoid face, negative y coordinate
- (
- Axis.X.direction, # plane x_dir
- Axis.Z.direction, # plane y_dir
- -Axis.Y.direction, # plane z_dir
- ),
- # Trapezoid face, positive y coordinate
- (
- -Axis.X.direction,
- Axis.Z.direction,
- Axis.Y.direction,
- ),
- ]
- # assert properties of the trapezoid faces
- for i, f in enumerate(lofted_solid.faces() | Plane.XZ > Axis.Y):
- p = Plane(f)
- f_props = GProp_GProps()
- BRepGProp.SurfaceProperties_s(f.wrapped, f_props)
- self.assertVectorAlmostEquals(p.origin, f_props.CentreOfMass(), 6)
- self.assertVectorAlmostEquals(p.x_dir, expected[i][0], 6)
- self.assertVectorAlmostEquals(p.y_dir, expected[i][1], 6)
- self.assertVectorAlmostEquals(p.z_dir, expected[i][2], 6)
-
- def test_plane_neg(self):
- p = Plane(
- origin=(1, 2, 3),
- x_dir=Vector(1, 2, 3).normalized(),
- z_dir=Vector(4, 5, 6).normalized(),
- )
- p2 = -p
- self.assertVectorAlmostEquals(p2.origin, p.origin, 6)
- self.assertVectorAlmostEquals(p2.x_dir, p.x_dir, 6)
- self.assertVectorAlmostEquals(p2.z_dir, -p.z_dir, 6)
- self.assertVectorAlmostEquals(
- p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6
- )
- p3 = p.reverse()
- self.assertVectorAlmostEquals(p3.origin, p.origin, 6)
- self.assertVectorAlmostEquals(p3.x_dir, p.x_dir, 6)
- self.assertVectorAlmostEquals(p3.z_dir, -p.z_dir, 6)
- self.assertVectorAlmostEquals(
- p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6
- )
-
- def test_plane_mul(self):
- p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1))
- p2 = p * Location((1, 2, -1), (0, 0, 45))
- self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
- self.assertVectorAlmostEquals(
- p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(
- p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(p2.z_dir, (0, 0, 1), 6)
-
- p2 = p * Location((1, 2, -1), (0, 45, 0))
- self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
- self.assertVectorAlmostEquals(
- p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(p2.y_dir, (0, 1, 0), 6)
- self.assertVectorAlmostEquals(
- p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6
- )
-
- p2 = p * Location((1, 2, -1), (45, 0, 0))
- self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
- self.assertVectorAlmostEquals(p2.x_dir, (1, 0, 0), 6)
- self.assertVectorAlmostEquals(
- p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(
- p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- with self.assertRaises(TypeError):
- p2 * Vector(1, 1, 1)
-
- def test_plane_methods(self):
- # Test error checking
- p = Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 0))
- with self.assertRaises(ValueError):
- p.to_local_coords("box")
-
- # Test translation to local coordinates
- local_box = p.to_local_coords(Solid.make_box(1, 1, 1))
- local_box_vertices = [(v.X, v.Y, v.Z) for v in local_box.vertices()]
- target_vertices = [
- (0, -1, 0),
- (0, 0, 0),
- (0, -1, 1),
- (0, 0, 1),
- (1, -1, 0),
- (1, 0, 0),
- (1, -1, 1),
- (1, 0, 1),
- ]
- for i, target_point in enumerate(target_vertices):
- self.assertTupleAlmostEquals(target_point, local_box_vertices[i], 7)
-
- def test_localize_vertex(self):
- vertex = Vertex(random.random(), random.random(), random.random())
- self.assertTupleAlmostEquals(
- Plane.YZ.to_local_coords(vertex).to_tuple(),
- Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(),
- 5,
- )
-
- def test_repr(self):
- self.assertEqual(
- repr(Plane.XY),
- "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))",
- )
-
- def test_shift_origin_axis(self):
- cyl = Cylinder(1, 2, align=Align.MIN)
- top = cyl.faces().sort_by(Axis.Z)[-1]
- pln = Plane(top).shift_origin(Axis.Z)
- with BuildPart() as p:
- add(cyl)
- with BuildSketch(pln):
- with Locations((1, 1)):
- Circle(0.5)
- extrude(amount=-2, mode=Mode.SUBTRACT)
- self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5)
-
- def test_shift_origin_vertex(self):
- box = Box(1, 1, 1, align=Align.MIN)
- front = box.faces().sort_by(Axis.X)[-1]
- pln = Plane(front).shift_origin(
- front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1]
- )
- with BuildPart() as p:
- add(box)
- with BuildSketch(pln):
- with Locations((-0.5, 0.5)):
- Circle(0.5)
- extrude(amount=-1, mode=Mode.SUBTRACT)
- self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5)
-
- def test_shift_origin_vector(self):
- with BuildPart() as p:
- Box(4, 4, 2)
- b = fillet(p.edges().filter_by(Axis.Z), 0.5)
- top = p.faces().sort_by(Axis.Z)[-1]
- ref = (
- top.edges()
- .filter_by(GeomType.CIRCLE)
- .group_by(Axis.X)[-1]
- .sort_by(Axis.Y)[0]
- .arc_center
- )
- pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref)
- with BuildSketch(pln):
- with Locations((0.5, 0.5)):
- Rectangle(2, 2, align=Align.MIN)
- extrude(amount=-1, mode=Mode.SUBTRACT)
- self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5)
-
- def test_shift_origin_error(self):
- with self.assertRaises(ValueError):
- Plane.XY.shift_origin(Vertex(1, 1, 1))
-
- with self.assertRaises(ValueError):
- Plane.XY.shift_origin((1, 1, 1))
-
- with self.assertRaises(ValueError):
- Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0)))
-
- with self.assertRaises(TypeError):
- Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1)))
-
- def test_move(self):
- pln = Plane.XY.move(Location((1, 2, 3)))
- self.assertVectorAlmostEquals(pln.origin, (1, 2, 3), 5)
-
- def test_rotated(self):
- rotated_plane = Plane.XY.rotated((45, 0, 0))
- self.assertVectorAlmostEquals(rotated_plane.x_dir, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(
- rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5
- )
-
- def test_invalid_plane(self):
- # Test plane creation error handling
- with self.assertRaises(ValueError):
- Plane(origin=(0, 0, 0), x_dir=(0, 0, 0), z_dir=(0, 1, 1))
- with self.assertRaises(ValueError):
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 0))
-
- def test_plane_equal(self):
- # default orientation
- self.assertEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- )
- # moved origin
- self.assertEqual(
- Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- )
- # moved x-axis
- self.assertEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
- )
- # moved z-axis
- self.assertEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
- )
- # __eq__ cooperation
- self.assertEqual(Plane.XY, AlwaysEqual())
-
- def test_plane_not_equal(self):
- # type difference
- for value in [None, 0, 1, "abc"]:
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), value
- )
- # origin difference
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- )
- # x-axis difference
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
- )
- # z-axis difference
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
- )
-
- def test_to_location(self):
- loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location
- self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, 0, 90), 5)
-
- def test_intersect(self):
- self.assertVectorAlmostEquals(
- Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5
- )
- self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0))))
-
- self.assertEqual(Plane.XY.intersect(Plane.XZ), Axis.X)
-
- self.assertIsNone(Plane.XY.intersect(Plane.XY.offset(1)))
-
- with self.assertRaises(ValueError):
- Plane.XY.intersect("Plane.XZ")
-
- with self.assertRaises(ValueError):
- Plane.XY.intersect(pln=Plane.XZ)
-
- def test_from_non_planar_face(self):
- flat = Face.make_rect(1, 1)
- pln = Plane(flat)
- self.assertTrue(isinstance(pln, Plane))
- cyl = (
- Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0]
- )
- with self.assertRaises(ValueError):
- pln = Plane(cyl)
-
- def test_plane_intersect(self):
- section = Plane.XY.intersect(Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5)))
- self.assertEqual(len(section.solids()), 0)
- self.assertEqual(len(section.faces()), 1)
- self.assertAlmostEqual(section.face().area, 2)
-
- section = Plane.XY & Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5))
- self.assertEqual(len(section.solids()), 0)
- self.assertEqual(len(section.faces()), 1)
- self.assertAlmostEqual(section.face().area, 2)
-
- self.assertEqual(Plane.XY & Plane.XZ, Axis.X)
- # x_axis_as_edge = Plane.XY & Plane.XZ
- # common = (x_axis_as_edge.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
- # self.assertAlmostEqual(common.length, 1, 5)
-
- i = Plane.XY & Vector(1, 2)
- self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, (1, 2, 0), 5)
-
- a = Axis((0, 0, 0), (1, 1, 0))
- i = Plane.XY & a
- self.assertTrue(isinstance(i, Axis))
- self.assertEqual(i, a)
-
- a = Axis((1, 2, -1), (0, 0, 1))
- i = Plane.XY & a
- self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, Vector(1, 2, 0), 5)
-
- def test_plane_origin_setter(self):
- pln = Plane.XY
- pln.origin = (1, 2, 3)
- ocp_origin = Vector(pln.wrapped.Location())
- self.assertVectorAlmostEquals(ocp_origin, (1, 2, 3), 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_projection.py b/tests/test_direct_api/test_projection.py
deleted file mode 100644
index 62b23d4..0000000
--- a/tests/test_direct_api/test_projection.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_projection.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.build_enums import Align
-from build123d.geometry import Axis, Plane, Pos, Vector
-from build123d.objects_part import Box
-from build123d.topology import Compound, Edge, Solid, Wire
-from tests.base_test import DirectApiTestCase
-
-
-class TestProjection(DirectApiTestCase):
- def test_flat_projection(self):
- sphere = Solid.make_sphere(50)
- projection_direction = Vector(0, -1, 0)
- planar_text_faces = (
- Compound.make_text("Flat", 30, align=(Align.CENTER, Align.CENTER))
- .rotate(Axis.X, 90)
- .faces()
- )
- projected_text_faces = [
- f.project_to_shape(sphere, projection_direction)[0]
- for f in planar_text_faces
- ]
- self.assertEqual(len(projected_text_faces), 4)
-
- def test_multiple_output_wires(self):
- target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2)
- circle = Wire.make_circle(3, Plane.XY.offset(10))
- projection = circle.project_to_shape(target, (0, 0, -1))
- bbox = projection[0].bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-3, -3, 1), 2)
- self.assertVectorAlmostEquals(bbox.max, (3, 3, 2), 2)
- bbox = projection[1].bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-3, -3, -2), 2)
- self.assertVectorAlmostEquals(bbox.max, (3, 3, -2), 2)
-
- def test_text_projection(self):
- sphere = Solid.make_sphere(50)
- arch_path = (
- sphere.cut(
- Solid.make_cylinder(
- 80, 100, Plane(origin=(-50, 0, -70), z_dir=(1, 0, 0))
- )
- )
- .edges()
- .sort_by(Axis.Z)[0]
- )
-
- projected_text = sphere.project_faces(
- faces=Compound.make_text("dog", font_size=14),
- path=arch_path,
- start=0.01, # avoid a character spanning the sphere edge
- )
- self.assertEqual(len(projected_text.solids()), 0)
- self.assertEqual(len(projected_text.faces()), 3)
-
- def test_error_handling(self):
- sphere = Solid.make_sphere(50)
- circle = Wire.make_circle(1)
- with self.assertRaises(ValueError):
- circle.project_to_shape(sphere, center=None, direction=None)[0]
-
- def test_project_edge(self):
- projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape(
- Solid.make_box(1, 1, 1), (0, 0, 1)
- )
- self.assertVectorAlmostEquals(projection[0].position_at(1), (1, 0, 0), 5)
- self.assertVectorAlmostEquals(projection[0].position_at(0), (0, 1, 0), 5)
- self.assertVectorAlmostEquals(projection[0].arc_center, (0, 0, 0), 5)
-
- def test_to_axis(self):
- with self.assertRaises(ValueError):
- Edge.make_circle(1, end_angle=30).to_axis()
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_rotation.py b/tests/test_direct_api/test_rotation.py
deleted file mode 100644
index 3efd378..0000000
--- a/tests/test_direct_api/test_rotation.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_rotation.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.build_enums import Extrinsic, Intrinsic
-from build123d.geometry import Rotation
-from tests.base_test import DirectApiTestCase
-
-
-class TestRotation(DirectApiTestCase):
- def test_rotation_parameters(self):
- r = Rotation(10, 20, 30)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, 20, Z=30)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, Y=20, Z=30)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation((10, 20, 30))
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, 20, 30, Intrinsic.XYZ)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation((30, 20, 10), Extrinsic.ZYX)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
- with self.assertRaises(TypeError):
- Rotation(x=10)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py
deleted file mode 100644
index 1907473..0000000
--- a/tests/test_direct_api/test_shape.py
+++ /dev/null
@@ -1,615 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_shape.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 random import uniform
-from unittest.mock import patch
-
-from build123d.build_enums import CenterOf, Keep
-from build123d.geometry import (
- Axis,
- Color,
- Location,
- Matrix,
- Plane,
- Pos,
- Rotation,
- Vector,
-)
-from build123d.objects_part import Box, Cylinder
-from build123d.objects_sketch import Circle
-from build123d.operations_part import extrude
-from build123d.topology import (
- Compound,
- Edge,
- Face,
- Shape,
- ShapeList,
- Shell,
- Solid,
- Vertex,
- Wire,
-)
-from tests.base_test import DirectApiTestCase, AlwaysEqual
-
-
-class TestShape(DirectApiTestCase):
- """Misc Shape tests"""
-
- def test_mirror(self):
- box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box()
- self.assertAlmostEqual(box_bb.min.X, 0, 5)
- self.assertAlmostEqual(box_bb.max.X, 1, 5)
- self.assertAlmostEqual(box_bb.min.Y, -1, 5)
- self.assertAlmostEqual(box_bb.max.Y, 0, 5)
-
- box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box()
- self.assertAlmostEqual(box_bb.min.Z, -1, 5)
- self.assertAlmostEqual(box_bb.max.Z, 0, 5)
-
- def test_compute_mass(self):
- with self.assertRaises(NotImplementedError):
- Shape.compute_mass(Vertex())
-
- def test_combined_center(self):
- objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]]
- self.assertVectorAlmostEquals(
- Shape.combined_center(objs, center_of=CenterOf.MASS),
- (0, 0.5, 0.5),
- 5,
- )
-
- objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]]
- self.assertVectorAlmostEquals(
- Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX),
- (-0.5, 0, 0),
- 5,
- )
- with self.assertRaises(ValueError):
- Shape.combined_center(objs, center_of=CenterOf.GEOMETRY)
-
- def test_shape_type(self):
- self.assertEqual(Vertex().shape_type(), "Vertex")
-
- def test_scale(self):
- self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5)
-
- def test_fuse(self):
- box1 = Solid.make_box(1, 1, 1)
- box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
- combined = box1.fuse(box2, glue=True)
- self.assertTrue(combined.is_valid())
- self.assertAlmostEqual(combined.volume, 2, 5)
- fuzzy = box1.fuse(box2, tol=1e-6)
- self.assertTrue(fuzzy.is_valid())
- self.assertAlmostEqual(fuzzy.volume, 2, 5)
-
- def test_faces_intersected_by_axis(self):
- box = Solid.make_box(1, 1, 1, Plane((0, 0, 1)))
- intersected_faces = box.faces_intersected_by_axis(Axis.Z)
- self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces)
- self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces)
-
- def test_split(self):
- shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
- split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM)
- self.assertTrue(isinstance(split_shape, list))
- self.assertEqual(len(split_shape), 2)
- self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5)
- split_shape = shape.split(Plane.XY, keep=Keep.TOP)
- self.assertEqual(len(split_shape.solids()), 1)
- self.assertTrue(isinstance(split_shape, Solid))
- self.assertAlmostEqual(split_shape.volume, 0.5, 5)
-
- s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10))
- tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face()
- s2 = s.split(tool, keep=Keep.TOP)
- self.assertLess(s2.volume, s.volume)
- self.assertGreater(s2.volume, 0.0)
-
- def test_split_by_non_planar_face(self):
- box = Solid.make_box(1, 1, 1)
- tool = Circle(1).wire()
- tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
- top, bottom = box.split(tool_shell, keep=Keep.BOTH)
-
- self.assertFalse(top is None)
- self.assertFalse(bottom is None)
- self.assertGreater(top.volume, bottom.volume)
-
- def test_split_by_shell(self):
- box = Solid.make_box(5, 5, 1)
- tool = Wire.make_rect(4, 4)
- tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
- split = box.split(tool_shell, keep=Keep.TOP)
- inner_vol = 2 * 2
- outer_vol = 5 * 5
- self.assertAlmostEqual(split.volume, outer_vol - inner_vol)
-
- def test_split_keep_all(self):
- shape = Box(1, 1, 1)
- split_shape = shape.split(Plane.XY, keep=Keep.ALL)
- self.assertTrue(isinstance(split_shape, ShapeList))
- self.assertEqual(len(split_shape), 2)
-
- def test_split_edge_by_shell(self):
- edge = Edge.make_line((-5, 0, 0), (5, 0, 0))
- tool = Wire.make_rect(4, 4)
- tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
- top = edge.split(tool_shell, keep=Keep.TOP)
- self.assertEqual(len(top), 2)
- self.assertAlmostEqual(top[0].length, 3, 5)
-
- def test_split_return_none(self):
- shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
- split_shape = shape.split(Plane.XY, keep=Keep.INSIDE)
- self.assertIsNone(split_shape)
-
- def test_split_by_perimeter(self):
- # Test 0 - extract a spherical cap
- target0 = Solid.make_sphere(10).rotate(Axis.Z, 90)
- circle = Plane.YZ.offset(15) * Circle(5).face()
- circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0]
- circle_outerwire = circle_projected.edge()
- inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH)
- self.assertLess(inside0.area, outside0.area)
-
- # Test 1 - extract ring of a sphere
- ring = Pos(Z=15) * (Circle(5) - Circle(3)).face()
- ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0]
- ring_outerwire = ring_projected.outer_wire()
- inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH)
- if isinstance(inside1, list):
- inside1 = Compound(inside1)
- if isinstance(outside1, list):
- outside1 = Compound(outside1)
- self.assertLess(inside1.area, outside1.area)
- self.assertEqual(len(outside1.faces()), 2)
-
- # Test 2 - extract multiple faces
- target2 = Box(1, 10, 10)
- square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0)))
- square_projected = square.project_to_shape(target2, (-1, 0, 0))[0]
- outside2 = target2.split_by_perimeter(
- square_projected.outer_wire(), Keep.OUTSIDE
- )
- self.assertTrue(isinstance(outside2, Shell))
- inside2 = target2.split_by_perimeter(square_projected.outer_wire(), Keep.INSIDE)
- self.assertTrue(isinstance(inside2, Face))
-
- # Test 4 - invalid inputs
- with self.assertRaises(ValueError):
- _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH)
-
- with self.assertRaises(ValueError):
- _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP)
-
- def test_distance(self):
- sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
- sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
- self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5)
-
- def test_distances(self):
- sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
- sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
- sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5)))
- distances = [8, 3]
- for i, distance in enumerate(sphere1.distances(sphere2, sphere3)):
- self.assertAlmostEqual(distances[i], distance, 5)
-
- def test_max_fillet(self):
- test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)]
- max_values = [0.96, 3.84]
- for i, test_object in enumerate(test_solids):
- with self.subTest("solids" + str(i)):
- max = test_object.max_fillet(test_object.edges())
- self.assertAlmostEqual(max, max_values[i], 2)
- with self.assertRaises(RuntimeError):
- test_solids[0].max_fillet(
- test_solids[0].edges(), tolerance=1e-6, max_iterations=1
- )
- with self.assertRaises(ValueError):
- box = Solid.make_box(1, 1, 1)
- box.fillet(0.75, box.edges())
- # invalid_object = box.fillet(0.75, box.edges())
- # invalid_object.max_fillet(invalid_object.edges())
-
- @patch.object(Shape, "is_valid", return_value=False)
- def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid):
- box = Solid.make_box(1, 1, 1)
-
- # Assert that ValueError is raised
- with self.assertRaises(ValueError) as max_fillet_context:
- max = box.max_fillet(box.edges())
-
- # Check the error message
- self.assertEqual(str(max_fillet_context.exception), "Invalid Shape")
-
- # Verify is_valid was called
- mock_is_valid.assert_called_once()
-
- def test_locate_bb(self):
- bounding_box = Solid.make_cone(1, 2, 1).bounding_box()
- relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box)
- self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5)
- self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5)
- self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5)
- self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5)
- self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5)
- self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5)
-
- def test_is_equal(self):
- box = Solid.make_box(1, 1, 1)
- self.assertTrue(box.is_equal(box))
-
- def test_equal(self):
- box = Solid.make_box(1, 1, 1)
- self.assertEqual(box, box)
- self.assertEqual(box, AlwaysEqual())
-
- def test_not_equal(self):
- box = Solid.make_box(1, 1, 1)
- diff = Solid.make_box(1, 2, 3)
- self.assertNotEqual(box, diff)
- self.assertNotEqual(box, object())
-
- def test_tessellate(self):
- box123 = Solid.make_box(1, 2, 3)
- verts, triangles = box123.tessellate(1e-6)
- self.assertEqual(len(verts), 24)
- self.assertEqual(len(triangles), 12)
-
- def test_transformed(self):
- """Validate that transformed works the same as changing location"""
- rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360))
- offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50))
- shape = Solid.make_box(1, 1, 1).transformed(rotation, offset)
- predicted_location = Location(offset) * Rotation(*rotation)
- located_shape = Solid.make_box(1, 1, 1).locate(predicted_location)
- intersect = shape.intersect(located_shape)
- self.assertAlmostEqual(intersect.volume, 1, 5)
-
- def test_position_and_orientation(self):
- box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30)))
- self.assertVectorAlmostEquals(box.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(box.orientation, (10, 20, 30), 5)
-
- def test_distance_to_with_closest_points(self):
- s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0)))
- s1 = Solid.make_sphere(1)
- distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1)
- self.assertAlmostEqual(distance, 0.1, 5)
- self.assertVectorAlmostEquals(pnt0, (0, 1.1, 0), 5)
- self.assertVectorAlmostEquals(pnt1, (0, 1, 0), 5)
-
- def test_closest_points(self):
- c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
- c1 = Edge.make_circle(1)
- closest = c0.closest_points(c1)
- self.assertVectorAlmostEquals(closest[0], c0.position_at(0.75).to_tuple(), 5)
- self.assertVectorAlmostEquals(closest[1], c1.position_at(0.25).to_tuple(), 5)
-
- def test_distance_to(self):
- c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
- c1 = Edge.make_circle(1)
- distance = c0.distance_to(c1)
- self.assertAlmostEqual(distance, 0.1, 5)
-
- def test_intersection(self):
- box = Solid.make_box(1, 1, 1)
- intersections = (
- box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z)
- )
- self.assertVectorAlmostEquals(intersections[0], (0.5, 0.5, 0), 5)
- self.assertVectorAlmostEquals(intersections[1], (0.5, 0.5, 1), 5)
-
- def test_clean_error(self):
- """Note that this test is here to alert build123d to changes in bad OCCT clean behavior
- with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this.
- """
- sphere = Solid.make_sphere(1)
- divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5)))
- positive_half, negative_half = (s.clean() for s in sphere.cut(divider).solids())
- self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1)
-
- def test_clean_empty(self):
- obj = Solid()
- self.assertIs(obj, obj.clean())
-
- def test_relocate(self):
- box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5)))
- cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0)))
-
- box_with_hole = box.cut(cylinder)
- box_with_hole.relocate(box.location)
-
- self.assertEqual(box.location, box_with_hole.location)
-
- bbox1 = box.bounding_box()
- bbox2 = box_with_hole.bounding_box()
- self.assertVectorAlmostEquals(bbox1.min, bbox2.min, 5)
- self.assertVectorAlmostEquals(bbox1.max, bbox2.max, 5)
-
- def test_project_to_viewport(self):
- # Basic test
- box = Solid.make_box(10, 10, 10)
- visible, hidden = box.project_to_viewport((-20, 20, 20))
- self.assertEqual(len(visible), 9)
- self.assertEqual(len(hidden), 3)
-
- # Contour edges
- cyl = Solid.make_cylinder(2, 10)
- visible, hidden = cyl.project_to_viewport((-20, 20, 20))
- # Note that some edges are broken into two
- self.assertEqual(len(visible), 6)
- self.assertEqual(len(hidden), 2)
-
- # Hidden contour edges
- hole = box - cyl
- visible, hidden = hole.project_to_viewport((-20, 20, 20))
- self.assertEqual(len(visible), 13)
- self.assertEqual(len(hidden), 6)
-
- # Outline edges
- sphere = Solid.make_sphere(5)
- visible, hidden = sphere.project_to_viewport((-20, 20, 20))
- self.assertEqual(len(visible), 1)
- self.assertEqual(len(hidden), 0)
-
- def test_vertex(self):
- v = Edge.make_circle(1).vertex()
- self.assertTrue(isinstance(v, Vertex))
- with self.assertWarns(UserWarning):
- Wire.make_rect(1, 1).vertex()
-
- def test_edge(self):
- e = Edge.make_circle(1).edge()
- self.assertTrue(isinstance(e, Edge))
- with self.assertWarns(UserWarning):
- Wire.make_rect(1, 1).edge()
-
- def test_wire(self):
- w = Wire.make_circle(1).wire()
- self.assertTrue(isinstance(w, Wire))
- with self.assertWarns(UserWarning):
- Solid.make_box(1, 1, 1).wire()
-
- def test_compound(self):
- c = Compound.make_text("hello", 10)
- self.assertTrue(isinstance(c, Compound))
- c2 = Compound.make_text("world", 10)
- with self.assertWarns(UserWarning):
- Compound(children=[c, c2]).compound()
-
- def test_face(self):
- f = Face.make_rect(1, 1)
- self.assertTrue(isinstance(f, Face))
- with self.assertWarns(UserWarning):
- Solid.make_box(1, 1, 1).face()
-
- def test_shell(self):
- s = Solid.make_sphere(1).shell()
- self.assertTrue(isinstance(s, Shell))
- with self.assertWarns(UserWarning):
- extrude(Compound.make_text("two", 10), amount=5).shell()
-
- def test_solid(self):
- s = Solid.make_sphere(1).solid()
- self.assertTrue(isinstance(s, Solid))
- with self.assertWarns(UserWarning):
- Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
-
- def test_manifold(self):
- self.assertTrue(Solid.make_box(1, 1, 1).is_manifold)
- self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold)
- self.assertFalse(
- Solid.make_box(1, 1, 1)
- .shell()
- .cut(Solid.make_box(0.5, 0.5, 0.5))
- .is_manifold
- )
- self.assertTrue(
- Compound(
- children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]
- ).is_manifold
- )
-
- def test_inherit_color(self):
- # Create some objects and assign colors to them
- b = Box(1, 1, 1).locate(Pos(2, 2, 0))
- b.color = Color("blue") # Blue
- c = Cylinder(1, 1).locate(Pos(-2, 2, 0))
- a = Compound(children=[b, c])
- a.color = Color(0, 1, 0)
- # Check that assigned colors stay and iheritance works
- self.assertTupleAlmostEquals(tuple(a.color), (0, 1, 0, 1), 5)
- self.assertTupleAlmostEquals(tuple(b.color), (0, 0, 1, 1), 5)
-
- def test_ocp_section(self):
- # Vertex
- verts, edges = Vertex(1, 2, 0)._ocp_section(Vertex(1, 2, 0))
- self.assertListEqual(verts, []) # ?
- self.assertListEqual(edges, [])
-
- verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4)))
- self.assertListEqual(verts, []) # ?
- self.assertListEqual(edges, [])
-
- verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5))
- self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5)
- self.assertListEqual(edges, [])
-
- verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY))
- self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5)
- self.assertListEqual(edges, [])
-
- # spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5))
- # cylinder = Pos(Z=-10) * extrude(Circle(5), 20)
- # cylinder2 = (Rot((0, 90, 0)) * cylinder).face()
- # pln = Plane.XY
- # box1 = Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN))
- # box2 = Pos(Z=-10) * box1
-
- # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln))
- # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln))
- # print(vertices1, edges1)
-
- # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln))
- # print(vertices2, edges2)
-
- # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln))
- # print(vertices3, edges3)
-
- # # vertices4, edges4 = cylinder2.ocp_section(cylinder)
-
- # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln))
- # print(vertices5, edges5)
-
- # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1])
-
- def test_copy_attributes_to(self):
- box = Box(1, 1, 1)
- box2 = Box(10, 10, 10)
- box.label = "box"
- box.color = Color("Red")
- box.children = [Box(1, 1, 1), Box(2, 2, 2)]
- box.topo_parent = box2
-
- blank = Compound()
- box.copy_attributes_to(blank)
- self.assertEqual(blank.label, "box")
- self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red"))))
- self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children)))
- self.assertEqual(blank.topo_parent, box2)
-
- def test_empty_shape(self):
- empty = Solid()
- box = Solid.make_box(1, 1, 1)
- self.assertIsNone(empty.location)
- self.assertIsNone(empty.position)
- self.assertIsNone(empty.orientation)
- self.assertFalse(empty.is_manifold)
- with self.assertRaises(ValueError):
- empty.geom_type
- self.assertIs(empty, empty.fix())
- self.assertEqual(hash(empty), 0)
- self.assertFalse(empty.is_same(Solid()))
- self.assertFalse(empty.is_equal(Solid()))
- self.assertTrue(empty.is_valid())
- empty_bbox = empty.bounding_box()
- self.assertEqual(tuple(empty_bbox.size), (0, 0, 0))
- self.assertIs(empty, empty.mirror(Plane.XY))
- self.assertEqual(Shape.compute_mass(empty), 0)
- self.assertEqual(empty.entities("Face"), [])
- self.assertEqual(empty.area, 0)
- self.assertIs(empty, empty.rotate(Axis.Z, 90))
- translate_matrix = [
- [1.0, 0.0, 0.0, 1.0],
- [0.0, 1.0, 0.0, 2.0],
- [0.0, 0.0, 1.0, 3.0],
- [0.0, 0.0, 0.0, 1.0],
- ]
- self.assertIs(empty, empty.transform_shape(Matrix(translate_matrix)))
- self.assertIs(empty, empty.transform_geometry(Matrix(translate_matrix)))
- with self.assertRaises(ValueError):
- empty.locate(Location())
- empty_loc = Location()
- empty_loc.wrapped = None
- with self.assertRaises(ValueError):
- box.locate(empty_loc)
- with self.assertRaises(ValueError):
- empty.located(Location())
- with self.assertRaises(ValueError):
- box.located(empty_loc)
- with self.assertRaises(ValueError):
- empty.move(Location())
- with self.assertRaises(ValueError):
- box.move(empty_loc)
- with self.assertRaises(ValueError):
- empty.moved(Location())
- with self.assertRaises(ValueError):
- box.moved(empty_loc)
- with self.assertRaises(ValueError):
- empty.relocate(Location())
- with self.assertRaises(ValueError):
- box.relocate(empty_loc)
- with self.assertRaises(ValueError):
- empty.distance_to(Vector(1, 1, 1))
- with self.assertRaises(ValueError):
- empty.distance_to_with_closest_points(Vector(1, 1, 1))
- with self.assertRaises(ValueError):
- empty.distance_to(Vector(1, 1, 1))
- with self.assertRaises(ValueError):
- box.intersect(empty_loc)
- self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], []))
- self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList())
- with self.assertRaises(ValueError):
- empty.split_by_perimeter(Circle(1).wire())
- with self.assertRaises(ValueError):
- empty.distance(Vertex(1, 1, 1))
- with self.assertRaises(ValueError):
- list(empty.distances(Vertex(0, 0, 0), Vertex(1, 1, 1)))
- with self.assertRaises(ValueError):
- list(box.distances(empty, Vertex(1, 1, 1)))
- with self.assertRaises(ValueError):
- empty.mesh(0.001)
- with self.assertRaises(ValueError):
- empty.tessellate(0.001)
- with self.assertRaises(ValueError):
- empty.to_splines()
- empty_axis = Axis((0, 0, 0), (1, 0, 0))
- empty_axis.wrapped = None
- with self.assertRaises(ValueError):
- box.vertices().group_by(empty_axis)
- empty_wire = Wire()
- with self.assertRaises(ValueError):
- box.vertices().group_by(empty_wire)
- with self.assertRaises(ValueError):
- box.vertices().sort_by(empty_axis)
- with self.assertRaises(ValueError):
- box.vertices().sort_by(empty_wire)
-
- def test_empty_selectors(self):
- self.assertEqual(Vertex(1, 1, 1).edges(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).wires(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).faces(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList())
- self.assertIsNone(Vertex(1, 1, 1).edge())
- self.assertIsNone(Vertex(1, 1, 1).wire())
- self.assertIsNone(Vertex(1, 1, 1).face())
- self.assertIsNone(Vertex(1, 1, 1).shell())
- self.assertIsNone(Vertex(1, 1, 1).solid())
- self.assertIsNone(Vertex(1, 1, 1).compound())
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py
deleted file mode 100644
index 3ceb5fa..0000000
--- a/tests/test_direct_api/test_shape_list.py
+++ /dev/null
@@ -1,364 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_shape_list.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 io
-import math
-import re
-import unittest
-
-from IPython.lib import pretty
-from build123d.build_common import GridLocations, PolarLocations
-from build123d.build_enums import GeomType, SortBy
-from build123d.build_part import BuildPart
-from build123d.geometry import Axis, Plane
-from build123d.objects_part import Box, Cylinder
-from build123d.objects_sketch import RegularPolygon
-from build123d.topology import (
- Compound,
- Edge,
- Face,
- ShapeList,
- Shell,
- Solid,
- Vertex,
- Wire,
-)
-from tests.base_test import DirectApiTestCase, AlwaysEqual
-
-
-class TestShapeList(DirectApiTestCase):
- """Test ShapeList functionality"""
-
- def assertDunderStrEqual(self, actual: str, expected_lines: list[str]):
- actual_lines = actual.splitlines()
- self.assertEqual(len(actual_lines), len(expected_lines))
- for actual_line, expected_line in zip(actual_lines, expected_lines):
- start, end = re.split(r"at 0x[0-9a-f]+", expected_line, 2, re.I)
- self.assertTrue(actual_line.startswith(start))
- self.assertTrue(actual_line.endswith(end))
-
- def assertDunderReprEqual(self, actual: str, expected: str):
- splitter = r"at 0x[0-9a-f]+"
- actual_split_list = re.split(splitter, actual, 0, re.I)
- expected_split_list = re.split(splitter, expected, 0, re.I)
- for actual_split, expected_split in zip(actual_split_list, expected_split_list):
- self.assertEqual(actual_split, expected_split)
-
- def test_sort_by(self):
- faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA
- self.assertAlmostEqual(faces[-1].area, 2, 5)
-
- def test_filter_by_geomtype(self):
- non_planar_faces = (
- Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True)
- )
- self.assertEqual(len(non_planar_faces), 1)
- self.assertAlmostEqual(non_planar_faces[0].area, 2 * math.pi, 5)
-
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).faces().filter_by("True")
-
- def test_filter_by_axis(self):
- box = Solid.make_box(1, 1, 1)
- self.assertEqual(len(box.faces().filter_by(Axis.X)), 2)
- self.assertEqual(len(box.edges().filter_by(Axis.X)), 4)
- self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0)
-
- def test_filter_by_callable_predicate(self):
- boxes = [Solid.make_box(1, 1, 1) for _ in range(3)]
- boxes[0].label = "A"
- boxes[1].label = "A"
- boxes[2].label = "B"
- shapelist = ShapeList(boxes)
-
- self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2)
- self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1)
-
- def test_first_last(self):
- vertices = (
- Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1)))
- )
- self.assertVectorAlmostEquals(vertices.last, (1, 1, 1), 5)
- self.assertVectorAlmostEquals(vertices.first, (0, 0, 0), 5)
-
- def test_group_by(self):
- vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
- self.assertEqual(len(vertices[0]), 4)
-
- edges = Solid.make_box(1, 1, 1).edges().group_by(SortBy.LENGTH)
- self.assertEqual(len(edges[0]), 12)
-
- edges = (
- Solid.make_cone(2, 1, 2)
- .edges()
- .filter_by(GeomType.CIRCLE)
- .group_by(SortBy.RADIUS)
- )
- self.assertEqual(len(edges[0]), 1)
-
- edges = (Solid.make_cone(2, 1, 2).edges() | GeomType.CIRCLE) << SortBy.RADIUS
- self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5)
-
- vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE)
- self.assertVectorAlmostEquals(vertices[-1][0], (1, 1, 1), 5)
-
- box = Solid.make_box(1, 1, 2)
- self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2)
- self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4)
-
- line = Edge.make_line((0, 0, 0), (1, 1, 2))
- vertices_by_line = box.vertices().group_by(line)
- self.assertEqual(len(vertices_by_line[0]), 1)
- self.assertEqual(len(vertices_by_line[1]), 2)
- self.assertEqual(len(vertices_by_line[2]), 1)
- self.assertEqual(len(vertices_by_line[3]), 1)
- self.assertEqual(len(vertices_by_line[4]), 2)
- self.assertEqual(len(vertices_by_line[5]), 1)
- self.assertVectorAlmostEquals(vertices_by_line[0][0], (0, 0, 0), 5)
- self.assertVectorAlmostEquals(vertices_by_line[-1][0], (1, 1, 2), 5)
-
- with BuildPart() as boxes:
- with GridLocations(10, 10, 3, 3):
- Box(1, 1, 1)
- with PolarLocations(100, 10):
- Box(1, 1, 2)
- self.assertEqual(len(boxes.solids().group_by(SortBy.VOLUME)[-1]), 10)
- self.assertEqual(len((boxes.solids()) << SortBy.VOLUME), 9)
-
- with self.assertRaises(ValueError):
- boxes.solids().group_by("AREA")
-
- def test_group_by_callable_predicate(self):
- boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
- boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
- for box in boxesA:
- box.label = "A"
- for box in boxesB:
- box.label = "B"
- boxNoLabel = Solid.make_box(1, 1, 1)
-
- shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
- result = shapelist.group_by(lambda shape: shape.label)
-
- self.assertEqual([len(group) for group in result], [1, 3, 2])
-
- def test_group_by_retrieve_groups(self):
- boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
- boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
- for box in boxesA:
- box.label = "A"
- for box in boxesB:
- box.label = "B"
- boxNoLabel = Solid.make_box(1, 1, 1)
-
- shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
- result = shapelist.group_by(lambda shape: shape.label)
-
- self.assertEqual(len(result.group("")), 1)
- self.assertEqual(len(result.group("A")), 3)
- self.assertEqual(len(result.group("B")), 2)
- self.assertEqual(result.group(""), result[0])
- self.assertEqual(result.group("A"), result[1])
- self.assertEqual(result.group("B"), result[2])
- self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0]))
- self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0]))
- with self.assertRaises(KeyError):
- result.group("C")
-
- def test_group_by_str_repr(self):
- nonagon = RegularPolygon(5, 9)
-
- expected = [
- "[[],",
- " [,",
- " ],",
- " [,",
- " ],",
- " [,",
- " ],",
- " [,",
- " ]]",
- ]
- self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
-
- expected_repr = (
- "[[],"
- " [,"
- " ],"
- " [,"
- " ],"
- " [,"
- " ],"
- " [,"
- " ]]"
- )
- self.assertDunderReprEqual(
- repr(nonagon.edges().group_by(Axis.X)), expected_repr
- )
-
- f = io.StringIO()
- p = pretty.PrettyPrinter(f)
- nonagon.edges().group_by(Axis.X)._repr_pretty_(p, cycle=True)
- self.assertEqual(f.getvalue(), "(...)")
-
- def test_distance(self):
- with BuildPart() as box:
- Box(1, 2, 3)
- obj = (-0.2, 0.1, 0.5)
- edges = box.edges().sort_by_distance(obj)
- distances = [Vertex(*obj).distance_to(edge) for edge in edges]
- self.assertTrue(
- all([distances[i] >= distances[i - 1] for i in range(1, len(edges))])
- )
-
- def test_distance_reverse(self):
- with BuildPart() as box:
- Box(1, 2, 3)
- obj = (-0.2, 0.1, 0.5)
- edges = box.edges().sort_by_distance(obj, reverse=True)
- distances = [Vertex(*obj).distance_to(edge) for edge in edges]
- self.assertTrue(
- all([distances[i] <= distances[i - 1] for i in range(1, len(edges))])
- )
-
- def test_distance_equal(self):
- with BuildPart() as box:
- Box(1, 1, 1)
- self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12)
-
- def test_vertices(self):
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- self.assertEqual(len(sl.vertices()), 8)
-
- def test_vertex(self):
- sl = ShapeList([Edge.make_circle(1)])
- self.assertTupleAlmostEquals(sl.vertex().to_tuple(), (1, 0, 0), 5)
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- with self.assertWarns(UserWarning):
- sl.vertex()
- self.assertEqual(len(Edge().vertices()), 0)
-
- def test_edges(self):
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- self.assertEqual(len(sl.edges()), 8)
- self.assertEqual(len(Edge().edges()), 0)
-
- def test_edge(self):
- sl = ShapeList([Edge.make_circle(1)])
- self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5)
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- with self.assertWarns(UserWarning):
- sl.edge()
-
- def test_wires(self):
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- self.assertEqual(len(sl.wires()), 2)
- self.assertEqual(len(Wire().wires()), 0)
-
- def test_wire(self):
- sl = ShapeList([Wire.make_circle(1)])
- self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5)
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- with self.assertWarns(UserWarning):
- sl.wire()
-
- def test_faces(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- self.assertEqual(len(sl.faces()), 9)
- self.assertEqual(len(Face().faces()), 0)
-
- def test_face(self):
- sl = ShapeList(
- [Vertex(1, 1, 1), Edge.make_line((0, 0), (1, 1)), Face.make_rect(2, 1)]
- )
- self.assertAlmostEqual(sl.face().area, 2 * 1, 5)
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.face()
-
- def test_shells(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- self.assertEqual(len(sl.shells()), 2)
- self.assertEqual(len(Shell().shells()), 0)
-
- def test_shell(self):
- sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
- self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5)
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.shell()
-
- def test_solids(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- self.assertEqual(len(sl.solids()), 2)
- self.assertEqual(len(Solid().solids()), 0)
-
- def test_solid(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.solid()
- sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)])
- self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5)
-
- def test_compounds(self):
- sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
- self.assertEqual(len(sl.compounds()), 2)
- self.assertEqual(len(Compound().compounds()), 0)
-
- def test_compound(self):
- sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.compound()
- sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)])
- self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5)
-
- def test_equal(self):
- box = Box(1, 1, 1)
- cyl = Cylinder(1, 1)
- sl = ShapeList([box, cyl])
- same = ShapeList([cyl, box])
- self.assertEqual(sl, same)
- self.assertEqual(sl, AlwaysEqual())
-
- def test_not_equal(self):
- sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
- diff = ShapeList([Box(1, 1, 1), Box(1, 2, 3)])
- self.assertNotEqual(sl, diff)
- self.assertNotEqual(sl, object())
-
- def test_center(self):
- self.assertEqual(tuple(ShapeList().center()), (0, 0, 0))
- self.assertEqual(
- tuple(ShapeList(Vertex(i, 0, 0) for i in range(3)).center()), (1, 0, 0)
- )
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_shells.py b/tests/test_direct_api/test_shells.py
deleted file mode 100644
index d1f3480..0000000
--- a/tests/test_direct_api/test_shells.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_shells.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import unittest
-
-from build123d.geometry import Plane, Rot, Vector
-from build123d.objects_curve import JernArc, Polyline, Spline
-from build123d.objects_sketch import Circle
-from build123d.operations_generic import sweep
-from build123d.topology import Shell, Solid, Wire
-from tests.base_test import DirectApiTestCase
-
-
-class TestShells(DirectApiTestCase):
- def test_shell_init(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- self.assertTrue(box_shell.is_valid())
-
- def test_center(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- self.assertVectorAlmostEquals(box_shell.center(), (0.5, 0.5, 0.5), 5)
-
- def test_manifold_shell_volume(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- self.assertAlmostEqual(box_shell.volume, 1, 5)
-
- def test_nonmanifold_shell_volume(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- nm_shell = Shell(box_faces)
- nm_shell -= nm_shell.faces()[0]
- self.assertAlmostEqual(nm_shell.volume, 0, 5)
-
- def test_constructor(self):
- with self.assertRaises(TypeError):
- Shell(foo="bar")
-
- x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5))
- surface = sweep(x_section, Circle(5).wire())
- single_face = Shell(surface.face())
- self.assertTrue(single_face.is_valid())
- single_face = Shell(surface.faces())
- self.assertTrue(single_face.is_valid())
-
- def test_sweep(self):
- path_c1 = JernArc((0, 0), (-1, 0), 1, 180)
- path_e = path_c1.edge()
- path_c2 = JernArc((0, 0), (-1, 0), 1, 180) + JernArc((0, 0), (1, 0), 2, -90)
- path_w = path_c2.wire()
- section_e = Circle(0.5).edge()
- section_c2 = Polyline((0, 0), (0.1, 0), (0.2, 0.1))
- section_w = section_c2.wire()
-
- sweep_e_w = Shell.sweep((path_w ^ 0) * section_e, path_w)
- sweep_w_e = Shell.sweep((path_e ^ 0) * section_w, path_e)
- sweep_w_w = Shell.sweep((path_w ^ 0) * section_w, path_w)
- sweep_c2_c1 = Shell.sweep((path_c1 ^ 0) * section_c2, path_c1)
- sweep_c2_c2 = Shell.sweep((path_c2 ^ 0) * section_c2, path_c2)
-
- self.assertEqual(len(sweep_e_w.faces()), 2)
- self.assertEqual(len(sweep_w_e.faces()), 2)
- self.assertEqual(len(sweep_c2_c1.faces()), 2)
- self.assertEqual(len(sweep_w_w.faces()), 3) # 3 with clean, 4 without
- self.assertEqual(len(sweep_c2_c2.faces()), 3) # 3 with clean, 4 without
-
- def test_make_loft(self):
- r = 3
- h = 2
- loft = Shell.make_loft(
- [Wire.make_circle(r, Plane((0, 0, h))), Wire.make_circle(r)]
- )
- self.assertEqual(loft.volume, 0, "A shell has no volume")
- cylinder_area = 2 * math.pi * r * h
- self.assertAlmostEqual(loft.area, cylinder_area)
-
- def test_thicken(self):
- rect = Wire.make_rect(10, 5)
- shell: Shell = Shell.extrude(rect, Vector(0, 0, 3))
- thick = Solid.thicken(shell, 1)
-
- self.assertEqual(isinstance(thick, Solid), True)
- inner_vol = 3 * 10 * 5
- outer_vol = 3 * 12 * 7
- self.assertAlmostEqual(thick.volume, outer_vol - inner_vol)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_skip_clean.py b/tests/test_direct_api/test_skip_clean.py
deleted file mode 100644
index a39aeef..0000000
--- a/tests/test_direct_api/test_skip_clean.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_skip_clean.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.topology import SkipClean
-
-
-class TestSkipClean(unittest.TestCase):
- def setUp(self):
- # Ensure the class variable is in its default state before each test
- SkipClean.clean = True
-
- def test_context_manager_sets_clean_false(self):
- # Verify `clean` is initially True
- self.assertTrue(SkipClean.clean)
-
- # Use the context manager
- with SkipClean():
- # Within the context, `clean` should be False
- self.assertFalse(SkipClean.clean)
-
- # After exiting the context, `clean` should revert to True
- self.assertTrue(SkipClean.clean)
-
- def test_exception_handling_does_not_affect_clean(self):
- # Verify `clean` is initially True
- self.assertTrue(SkipClean.clean)
-
- # Use the context manager and raise an exception
- try:
- with SkipClean():
- self.assertFalse(SkipClean.clean)
- raise ValueError("Test exception")
- except ValueError:
- pass
-
- # Ensure `clean` is restored to True after an exception
- self.assertTrue(SkipClean.clean)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py
deleted file mode 100644
index fad617a..0000000
--- a/tests/test_direct_api/test_solid.py
+++ /dev/null
@@ -1,245 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_solid.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import unittest
-
-from build123d.build_enums import GeomType, Kind, Until
-from build123d.geometry import Axis, Location, Plane, Pos, Vector
-from build123d.objects_curve import Spline
-from build123d.objects_sketch import Circle, Rectangle
-from build123d.topology import Compound, Edge, Face, Shell, Solid, Vertex, Wire
-from tests.base_test import DirectApiTestCase
-
-
-class TestSolid(DirectApiTestCase):
- def test_make_solid(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- box = Solid(box_shell)
- self.assertAlmostEqual(box.area, 6, 5)
- self.assertAlmostEqual(box.volume, 1, 5)
- self.assertTrue(box.is_valid())
-
- def test_extrude(self):
- v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1))
- self.assertAlmostEqual(v.length, 1, 5)
-
- e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1))
- self.assertAlmostEqual(e.area, 1, 5)
-
- w = Shell.extrude(
- Wire([Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))]),
- (0, 0, 1),
- )
- self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5)
-
- f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1))
- self.assertAlmostEqual(f.volume, 1, 5)
-
- s = Compound.extrude(
- Shell(
- Solid.make_box(1, 1, 1)
- .locate(Location((-2, 1, 0)))
- .faces()
- .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:]
- ),
- (0.1, 0.1, 0.1),
- )
- self.assertAlmostEqual(s.volume, 0.2, 5)
-
- with self.assertRaises(ValueError):
- Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1))
-
- def test_extrude_taper(self):
- a = 1
- rect = Face.make_rect(a, a)
- flipped = -rect
- for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]:
- for taper in [10, -10]:
- offset_amt = -direction.length * math.tan(math.radians(taper))
- for face in [rect, flipped]:
- with self.subTest(
- f"{direction=}, {taper=}, flipped={face==flipped}"
- ):
- taper_solid = Solid.extrude_taper(face, direction, taper)
- # V = 1/3 × h × (a² + b² + ab)
- h = Vector(direction).length
- b = a + 2 * offset_amt
- v = h * (a**2 + b**2 + a * b) / 3
- self.assertAlmostEqual(taper_solid.volume, v, 5)
- bbox = taper_solid.bounding_box()
- size = max(1, b) / 2
- if direction.Z > 0:
- self.assertVectorAlmostEquals(
- bbox.min, (-size, -size, 0), 1
- )
- self.assertVectorAlmostEquals(bbox.max, (size, size, h), 1)
- else:
- self.assertVectorAlmostEquals(
- bbox.min, (-size, -size, -h), 1
- )
- self.assertVectorAlmostEquals(bbox.max, (size, size, 0), 1)
-
- def test_extrude_taper_with_hole(self):
- rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
- direction = Vector(0, 0, 0.5)
- taper = 10
- taper_solid = Solid.extrude_taper(rect_hole, direction, taper)
- offset_amt = -direction.length * math.tan(math.radians(taper))
- hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
- self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5)
-
- def test_extrude_taper_with_hole_flipped(self):
- rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
- direction = Vector(0, 0, 1)
- taper = 10
- taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True)
- taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False)
- hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
- hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
- self.assertGreater(hole_t.radius, hole_f.radius)
-
- def test_extrude_taper_oblique(self):
- rect = Face.make_rect(2, 1)
- rect_hole = rect.make_holes([Wire.make_circle(0.25)])
- o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77))
- taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5)
- taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5)
- self.assertAlmostEqual(taper0.volume, taper1.volume, 5)
-
- def test_extrude_linear_with_rotation(self):
- # Face
- base = Face.make_rect(1, 1)
- twist = Solid.extrude_linear_with_rotation(
- base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
- )
- self.assertAlmostEqual(twist.volume, 1, 5)
- top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
- bottom = twist.faces().sort_by(Axis.Z)[0]
- self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
- # Wire
- base = Wire.make_rect(1, 1)
- twist = Solid.extrude_linear_with_rotation(
- base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
- )
- self.assertAlmostEqual(twist.volume, 1, 5)
- top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
- bottom = twist.faces().sort_by(Axis.Z)[0]
- self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
-
- def test_make_loft(self):
- loft = Solid.make_loft(
- [Wire.make_rect(2, 2), Wire.make_circle(1, Plane((0, 0, 1)))]
- )
- self.assertAlmostEqual(loft.volume, (4 + math.pi) / 2, 1)
-
- with self.assertRaises(ValueError):
- Solid.make_loft([Wire.make_rect(1, 1)])
-
- def test_make_loft_with_vertices(self):
- loft = Solid.make_loft(
- [Vertex(0, 0, -1), Wire.make_rect(1, 1.5), Vertex(0, 0, 1)], True
- )
- self.assertAlmostEqual(loft.volume, 1, 5)
-
- with self.assertRaises(ValueError):
- Solid.make_loft(
- [Wire.make_rect(1, 1), Vertex(0, 0, 1), Wire.make_rect(1, 1)]
- )
-
- with self.assertRaises(ValueError):
- Solid.make_loft([Vertex(0, 0, 1), Vertex(0, 0, 2)])
-
- with self.assertRaises(ValueError):
- Solid.make_loft(
- [
- Vertex(0, 0, 1),
- Wire.make_rect(1, 1),
- Vertex(0, 0, 2),
- Vertex(0, 0, 3),
- ]
- )
-
- def test_extrude_until(self):
- square = Face.make_rect(1, 1)
- box = Solid.make_box(4, 4, 1, Plane((-2, -2, 3)))
- extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST)
- self.assertAlmostEqual(extrusion.volume, 4, 5)
-
- square = Face.make_rect(1, 1)
- box = Solid.make_box(4, 4, 1, Plane((-2, -2, -3)))
- extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.PREVIOUS)
- self.assertAlmostEqual(extrusion.volume, 2, 5)
-
- def test_sweep(self):
- path = Edge.make_spline([(0, 0), (3, 5), (7, -2)])
- section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0))
- area = Face(section).area
- swept = Solid.sweep(section, path)
- self.assertAlmostEqual(swept.volume, path.length * area, 0)
-
- def test_hollow_sweep(self):
- path = Edge.make_line((0, 0, 0), (0, 0, 5))
- section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0]
- swept = Solid.sweep(section, path)
- self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5)
-
- def test_sweep_multi(self):
- f0 = Face.make_rect(1, 1)
- f1 = Pos(X=10) * Circle(1).face()
- path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (0, 0, -1)))
- binormal = Edge.make_line((0, 1), (10, 1))
- swept = Solid.sweep_multi([f0, f1], path, is_frenet=True, binormal=binormal)
- self.assertAlmostEqual(swept.volume, 23.78, 2)
-
- path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (1, 0, 0)))
- swept = Solid.sweep_multi(
- [f0, f1], path, is_frenet=True, binormal=Vector(5, 0, 1)
- )
- self.assertAlmostEqual(swept.volume, 20.75, 2)
-
- def test_constructor(self):
- with self.assertRaises(TypeError):
- Solid(foo="bar")
-
- def test_offset_3d(self):
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).offset_3d(None, 0.1, kind=Kind.TANGENT)
-
- def test_revolve(self):
- r = Solid.revolve(
- Face.make_rect(1, 1, Plane((10, 0, 0))).wire(), 180, axis=Axis.Y
- )
- self.assertEqual(len(r.faces()), 6)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_v_t_k_poly_data.py b/tests/test_direct_api/test_v_t_k_poly_data.py
deleted file mode 100644
index 344e957..0000000
--- a/tests/test_direct_api/test_v_t_k_poly_data.py
+++ /dev/null
@@ -1,89 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_v_t_k_poly_data.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.topology import Solid
-from vtkmodules.vtkCommonDataModel import vtkPolyData
-from vtkmodules.vtkFiltersCore import vtkTriangleFilter
-
-
-class TestVTKPolyData(unittest.TestCase):
- def setUp(self):
- # Create a simple test object (e.g., a cylinder)
- self.object_under_test = Solid.make_cylinder(1, 2)
-
- def test_to_vtk_poly_data(self):
- # Generate VTK data
- vtk_data = self.object_under_test.to_vtk_poly_data(
- tolerance=0.1, angular_tolerance=0.2, normals=True
- )
-
- # Verify the result is of type vtkPolyData
- self.assertIsInstance(vtk_data, vtkPolyData)
-
- # Further verification can include:
- # - Checking the number of points, polygons, or cells
- self.assertGreater(
- vtk_data.GetNumberOfPoints(), 0, "VTK data should have points."
- )
- self.assertGreater(
- vtk_data.GetNumberOfCells(), 0, "VTK data should have cells."
- )
-
- # Optionally, compare the output with a known reference object
- # (if available) by exporting or analyzing the VTK data
- known_filter = vtkTriangleFilter()
- known_filter.SetInputData(vtk_data)
- known_filter.Update()
- known_output = known_filter.GetOutput()
-
- self.assertEqual(
- vtk_data.GetNumberOfPoints(),
- known_output.GetNumberOfPoints(),
- "Number of points in VTK data does not match the expected output.",
- )
- self.assertEqual(
- vtk_data.GetNumberOfCells(),
- known_output.GetNumberOfCells(),
- "Number of cells in VTK data does not match the expected output.",
- )
-
- def test_empty_shape(self):
- # Test handling of empty shape
- empty_object = Solid() # Create an empty object
- with self.assertRaises(ValueError) as context:
- empty_object.to_vtk_poly_data()
-
- self.assertEqual(str(context.exception), "Cannot convert an empty shape")
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py
deleted file mode 100644
index 9c635d7..0000000
--- a/tests/test_direct_api/test_vector.py
+++ /dev/null
@@ -1,287 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_vector.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 copy
-import math
-import unittest
-
-from OCP.gp import gp_Vec, gp_XYZ
-from build123d.geometry import Axis, Location, Plane, Pos, Vector
-from build123d.topology import Solid, Vertex
-from tests.base_test import DirectApiTestCase, DEG2RAD, AlwaysEqual
-
-
-class TestVector(DirectApiTestCase):
- """Test the Vector methods"""
-
- def test_vector_constructors(self):
- v1 = Vector(1, 2, 3)
- v2 = Vector((1, 2, 3))
- v3 = Vector(gp_Vec(1, 2, 3))
- v4 = Vector([1, 2, 3])
- v5 = Vector(gp_XYZ(1, 2, 3))
- v5b = Vector(X=1, Y=2, Z=3)
- v5c = Vector(v=gp_XYZ(1, 2, 3))
-
- for v in [v1, v2, v3, v4, v5, v5b, v5c]:
- self.assertVectorAlmostEquals(v, (1, 2, 3), 4)
-
- v6 = Vector((1, 2))
- v7 = Vector([1, 2])
- v8 = Vector(1, 2)
- v8b = Vector(X=1, Y=2)
-
- for v in [v6, v7, v8, v8b]:
- self.assertVectorAlmostEquals(v, (1, 2, 0), 4)
-
- v9 = Vector()
- self.assertVectorAlmostEquals(v9, (0, 0, 0), 4)
-
- v9.X = 1.0
- v9.Y = 2.0
- v9.Z = 3.0
- self.assertVectorAlmostEquals(v9, (1, 2, 3), 4)
- self.assertVectorAlmostEquals(Vector(1, 2, 3, 4), (1, 2, 3), 4)
-
- v10 = Vector(1)
- v11 = Vector((1,))
- v12 = Vector([1])
- v13 = Vector(X=1)
- for v in [v10, v11, v12, v13]:
- self.assertVectorAlmostEquals(v, (1, 0, 0), 4)
-
- vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10))
- self.assertVectorAlmostEquals(Vector(vertex), (0, 0, 10), 4)
-
- with self.assertRaises(TypeError):
- Vector("vector")
- with self.assertRaises(ValueError):
- Vector(x=1)
-
- def test_vector_rotate(self):
- """Validate vector rotate methods"""
- vector_x = Vector(1, 0, 1).rotate(Axis.X, 45)
- vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45)
- vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45)
- self.assertVectorAlmostEquals(
- vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7
- )
- self.assertVectorAlmostEquals(vector_y, (math.sqrt(2), 2, 0), 7)
- self.assertVectorAlmostEquals(vector_z, (0, -math.sqrt(2), 3), 7)
-
- def test_get_signed_angle(self):
- """Verify getSignedAngle calculations with and without a provided normal"""
- a = math.pi / 3
- v1 = Vector(1, 0, 0)
- v2 = Vector(math.cos(a), -math.sin(a), 0)
- d1 = v1.get_signed_angle(v2)
- d2 = v1.get_signed_angle(v2, Vector(0, 0, 1))
- self.assertAlmostEqual(d1, a * 180 / math.pi)
- self.assertAlmostEqual(d2, -a * 180 / math.pi)
-
- def test_center(self):
- v = Vector(1, 1, 1)
- self.assertAlmostEqual(v, v.center())
-
- def test_dot(self):
- v1 = Vector(2, 2, 2)
- v2 = Vector(1, -1, 1)
- self.assertEqual(2.0, v1.dot(v2))
-
- def test_vector_add(self):
- result = Vector(1, 2, 0) + Vector(0, 0, 3)
- self.assertVectorAlmostEquals(result, (1.0, 2.0, 3.0), 3)
-
- def test_vector_operators(self):
- result = Vector(1, 1, 1) + Vector(2, 2, 2)
- self.assertEqual(Vector(3, 3, 3), result)
-
- result = Vector(1, 2, 3) - Vector(3, 2, 1)
- self.assertEqual(Vector(-2, 0, 2), result)
-
- result = Vector(1, 2, 3) * 2
- self.assertEqual(Vector(2, 4, 6), result)
-
- result = 3 * Vector(1, 2, 3)
- self.assertEqual(Vector(3, 6, 9), result)
-
- result = Vector(2, 4, 6) / 2
- self.assertEqual(Vector(1, 2, 3), result)
-
- self.assertEqual(Vector(-1, -1, -1), -Vector(1, 1, 1))
-
- self.assertEqual(0, abs(Vector(0, 0, 0)))
- self.assertEqual(1, abs(Vector(1, 0, 0)))
- self.assertEqual((1 + 4 + 9) ** 0.5, abs(Vector(1, 2, 3)))
-
- def test_vector_equals(self):
- a = Vector(1, 2, 3)
- b = Vector(1, 2, 3)
- c = Vector(1, 2, 3.000001)
- self.assertEqual(a, b)
- self.assertEqual(a, c)
- self.assertEqual(a, AlwaysEqual())
-
- def test_vector_not_equal(self):
- a = Vector(1, 2, 3)
- b = Vector(3, 2, 1)
- self.assertNotEqual(a, b)
- self.assertNotEqual(a, object())
-
- def test_vector_distance(self):
- """
- Test line distance from plane.
- """
- v = Vector(1, 2, 3)
-
- self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ))
- self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX))
- self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY))
- self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY))
- self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ))
- self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX))
-
- self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ))
- self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX))
- self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY))
- self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY))
- self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ))
- self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX))
-
- def test_vector_project(self):
- """
- Test line projection and plane projection methods of Vector
- """
- decimal_places = 9
-
- z_dir = Vector(1, 2, 3)
- base = Vector(5, 7, 9)
- x_dir = Vector(1, 0, 0)
-
- # test passing Plane object
- point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir))
- self.assertVectorAlmostEquals(point, (59 / 7, 55 / 7, 51 / 7), decimal_places)
-
- # test line projection
- vec = Vector(10, 10, 10)
- line = Vector(3, 4, 5)
- angle = vec.get_angle(line) * DEG2RAD
-
- vecLineProjection = vec.project_to_line(line)
-
- self.assertVectorAlmostEquals(
- vecLineProjection.normalized(),
- line.normalized(),
- decimal_places,
- )
- self.assertAlmostEqual(
- vec.length * math.cos(angle), vecLineProjection.length, decimal_places
- )
-
- def test_vector_not_implemented(self):
- pass
-
- def test_vector_special_methods(self):
- self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)")
- self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)")
- self.assertEqual(
- str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)),
- "Vector(10, -23.65, 0)",
- )
-
- def test_vector_iter(self):
- self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6)
-
- def test_reverse(self):
- self.assertVectorAlmostEquals(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7)
-
- def test_copy(self):
- v2 = copy.copy(Vector(1, 2, 3))
- v3 = copy.deepcopy(Vector(1, 2, 3))
- self.assertVectorAlmostEquals(v2, (1, 2, 3), 7)
- self.assertVectorAlmostEquals(v3, (1, 2, 3), 7)
-
- def test_radd(self):
- vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)]
- vector_sum = sum(vectors)
- self.assertVectorAlmostEquals(vector_sum, (12, 15, 18), 5)
-
- def test_hash(self):
- vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)]
- unique_vectors = list(set(vectors))
- self.assertEqual(len(vectors), 4)
- self.assertEqual(len(unique_vectors), 3)
-
- def test_vector_transform(self):
- a = Vector(1, 2, 3)
- pxy = Plane.XY
- pxy_o1 = Plane.XY.offset(1)
- self.assertEqual(a.transform(pxy.forward_transform, is_direction=False), a)
- self.assertEqual(
- a.transform(pxy.forward_transform, is_direction=True), a.normalized()
- )
- self.assertEqual(
- a.transform(pxy_o1.forward_transform, is_direction=False), Vector(1, 2, 2)
- )
- self.assertEqual(
- a.transform(pxy_o1.forward_transform, is_direction=True), a.normalized()
- )
- self.assertEqual(
- a.transform(pxy_o1.reverse_transform, is_direction=False), Vector(1, 2, 4)
- )
- self.assertEqual(
- a.transform(pxy_o1.reverse_transform, is_direction=True), a.normalized()
- )
-
- def test_intersect(self):
- v1 = Vector(1, 2, 3)
- self.assertVectorAlmostEquals(v1 & Vector(1, 2, 3), (1, 2, 3), 5)
- self.assertIsNone(v1 & Vector(0, 0, 0))
-
- self.assertVectorAlmostEquals(v1 & Location((1, 2, 3)), (1, 2, 3), 5)
- self.assertIsNone(v1 & Location())
-
- self.assertVectorAlmostEquals(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5)
- self.assertIsNone(v1 & Axis.X)
-
- self.assertVectorAlmostEquals(v1 & Plane((1, 2, 3)), (1, 2, 3), 5)
- self.assertIsNone(v1 & Plane.XY)
-
- self.assertVectorAlmostEquals(
- (v1 & Solid.make_box(2, 4, 5)).vertex(), (1, 2, 3), 5
- )
- self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)))
- self.assertIsNone(
- Vertex(-10, -10, -10).intersect(Solid.make_box(0.5, 0.5, 0.5))
- )
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_vector_like.py b/tests/test_direct_api/test_vector_like.py
deleted file mode 100644
index 5e4d083..0000000
--- a/tests/test_direct_api/test_vector_like.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_vector_like.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.geometry import Axis, Vector
-from build123d.topology import Vertex
-from tests.base_test import DirectApiTestCase
-
-
-class TestVectorLike(DirectApiTestCase):
- """Test typedef"""
-
- def test_axis_from_vertex(self):
- axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1))
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
-
- def test_axis_from_vector(self):
- axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1))
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
-
- def test_axis_from_tuple(self):
- axis = Axis((1, 2, 3), (0, 0, 1))
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_vertex.py b/tests/test_direct_api/test_vertex.py
deleted file mode 100644
index 550ba07..0000000
--- a/tests/test_direct_api/test_vertex.py
+++ /dev/null
@@ -1,111 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_vertex.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 build123d.geometry import Axis, Vector
-from build123d.topology import Vertex
-from tests.base_test import DirectApiTestCase
-
-
-class TestVertex(DirectApiTestCase):
- """Test the extensions to the cadquery Vertex class"""
-
- def test_basic_vertex(self):
- v = Vertex()
- self.assertEqual(0, v.X)
-
- v = Vertex(1, 1, 1)
- self.assertEqual(1, v.X)
- self.assertEqual(Vector, type(v.center()))
-
- self.assertVectorAlmostEquals(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7)
- self.assertVectorAlmostEquals(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7)
- self.assertVectorAlmostEquals(Vector(Vertex((7,))), (7, 0, 0), 7)
- self.assertVectorAlmostEquals(Vector(Vertex((8, 9))), (8, 9, 0), 7)
-
- def test_vertex_volume(self):
- v = Vertex(1, 1, 1)
- self.assertAlmostEqual(v.volume, 0, 5)
-
- def test_vertex_add(self):
- test_vertex = Vertex(0, 0, 0)
- self.assertVectorAlmostEquals(
- Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7
- )
- self.assertVectorAlmostEquals(
- Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7
- )
- self.assertVectorAlmostEquals(
- Vector(test_vertex + Vertex(100, -40, 10)),
- (100, -40, 10),
- 7,
- )
- with self.assertRaises(TypeError):
- test_vertex + [1, 2, 3]
-
- def test_vertex_sub(self):
- test_vertex = Vertex(0, 0, 0)
- self.assertVectorAlmostEquals(
- Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7
- )
- self.assertVectorAlmostEquals(
- Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7
- )
- self.assertVectorAlmostEquals(
- Vector(test_vertex - Vertex(100, -40, 10)),
- (-100, 40, -10),
- 7,
- )
- with self.assertRaises(TypeError):
- test_vertex - [1, 2, 3]
-
- def test_vertex_str(self):
- self.assertEqual(str(Vertex(0, 0, 0)), "Vertex(0.0, 0.0, 0.0)")
-
- def test_vertex_to_vector(self):
- self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector)
- self.assertVectorAlmostEquals(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7)
-
- def test_vertex_init_error(self):
- with self.assertRaises(TypeError):
- Vertex(Axis.Z)
- with self.assertRaises(ValueError):
- Vertex(x=1)
- with self.assertRaises(TypeError):
- Vertex((Axis.X, Axis.Y, Axis.Z))
-
- def test_no_intersect(self):
- with self.assertRaises(NotImplementedError):
- Vertex(1, 2, 3) & Vertex(5, 6, 7)
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py
deleted file mode 100644
index 7737bf4..0000000
--- a/tests/test_direct_api/test_wire.py
+++ /dev/null
@@ -1,223 +0,0 @@
-"""
-build123d direct api tests
-
-name: test_wire.py
-by: Gumyr
-date: January 21, 2025
-
-desc:
- This python module contains tests for the build123d project.
-
-license:
-
- Copyright 2025 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 math
-import random
-import unittest
-
-from build123d.build_enums import Side
-from build123d.geometry import Axis, Color, Location
-from build123d.objects_curve import Polyline, Spline
-from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
-from build123d.topology import Edge, Face, Wire
-from tests.base_test import DirectApiTestCase
-
-
-class TestWire(DirectApiTestCase):
- def test_ellipse_arc(self):
- full_ellipse = Wire.make_ellipse(2, 1)
- half_ellipse = Wire.make_ellipse(
- 2, 1, start_angle=0, end_angle=180, closed=True
- )
- self.assertAlmostEqual(full_ellipse.area / 2, half_ellipse.area, 5)
-
- def test_stitch(self):
- half_ellipse1 = Wire.make_ellipse(
- 2, 1, start_angle=0, end_angle=180, closed=False
- )
- half_ellipse2 = Wire.make_ellipse(
- 2, 1, start_angle=180, end_angle=360, closed=False
- )
- ellipse = half_ellipse1.stitch(half_ellipse2)
- self.assertEqual(len(ellipse.wires()), 1)
-
- def test_fillet_2d(self):
- square = Wire.make_rect(1, 1)
- squaroid = square.fillet_2d(0.1, square.vertices())
- self.assertAlmostEqual(
- squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5
- )
-
- def test_chamfer_2d(self):
- square = Wire.make_rect(1, 1)
- squaroid = square.chamfer_2d(0.1, 0.1, square.vertices())
- self.assertAlmostEqual(
- squaroid.length, 4 * (1 - 2 * 0.1 + 0.1 * math.sqrt(2)), 5
- )
-
- def test_chamfer_2d_edge(self):
- square = Wire.make_rect(1, 1)
- edge = square.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- square = square.chamfer_2d(
- distance=0.1, distance2=0.2, vertices=[vertex], edge=edge
- )
- self.assertAlmostEqual(square.edges().sort_by(Axis.Y)[0].length, 0.9)
-
- def test_make_convex_hull(self):
- # overlapping_edges = [
- # Edge.make_circle(10, end_angle=60),
- # Edge.make_circle(10, start_angle=30, end_angle=90),
- # Edge.make_line((-10, 10), (10, -10)),
- # ]
- # with self.assertRaises(ValueError):
- # Wire.make_convex_hull(overlapping_edges)
-
- adjoining_edges = [
- Edge.make_circle(10, end_angle=45),
- Edge.make_circle(10, start_angle=315, end_angle=360),
- Edge.make_line((-10, 10), (-10, -10)),
- ]
- hull_wire = Wire.make_convex_hull(adjoining_edges)
- self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4)
-
- # def test_fix_degenerate_edges(self):
- # # Can't find a way to create one
- # edge0 = Edge.make_line((0, 0, 0), (1, 0, 0))
- # edge1 = Edge.make_line(edge0 @ 0, edge0 @ 0 + Vector(0, 1, 0))
- # edge1a = edge1.trim(0, 1e-7)
- # edge1b = edge1.trim(1e-7, 1.0)
- # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0))
- # wire = Wire([edge0, edge1a, edge1b, edge2])
- # fixed_wire = wire.fix_degenerate_edges(1e-6)
- # self.assertEqual(len(fixed_wire.edges()), 2)
-
- def test_trim(self):
- e0 = Edge.make_line((0, 0), (1, 0))
- e1 = Edge.make_line((2, 0), (1, 0))
- e2 = Edge.make_line((2, 0), (3, 0))
- w1 = Wire([e0, e1, e2])
- t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0)))
- self.assertAlmostEqual(t1.length, 2.1, 5)
-
- e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
- # Three edges are created 0->0.5->0.75->1.0
- o = e.offset_2d(10, side=Side.RIGHT, closed=False)
- t2 = o.trim(0.1, 0.9)
- self.assertAlmostEqual(t2.length, o.length * 0.8, 5)
-
- t3 = o.trim(0.5, 1.0)
- self.assertAlmostEqual(t3.length, o.length * 0.5, 5)
-
- t4 = o.trim(0.5, 0.75)
- self.assertAlmostEqual(t4.length, o.length * 0.25, 5)
-
- with self.assertRaises(ValueError):
- o.trim(0.75, 0.25)
- spline = Spline(
- (0, 0, 0),
- (0, 10, 0),
- tangents=((0, 0, 1), (0, 0, -1)),
- tangent_scalars=(2, 2),
- )
- half = spline.trim(0.5, 1)
- self.assertVectorAlmostEquals(spline @ 0.5, half @ 0, 4)
- self.assertVectorAlmostEquals(spline @ 1, half @ 1, 4)
-
- w = Rectangle(3, 1).wire()
- t5 = w.trim(0, 0.5)
- self.assertAlmostEqual(t5.length, 4, 5)
- t6 = w.trim(0.5, 1)
- self.assertAlmostEqual(t6.length, 4, 5)
-
- p = RegularPolygon(10, 20).wire()
- t7 = p.trim(0.1, 0.2)
- self.assertAlmostEqual(p.length * 0.1, t7.length, 5)
-
- c = Circle(10).wire()
- t8 = c.trim(0.4, 0.9)
- self.assertAlmostEqual(c.length * 0.5, t8.length, 5)
-
- def test_param_at_point(self):
- e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
- # Three edges are created 0->0.5->0.75->1.0
- o = e.offset_2d(10, side=Side.RIGHT, closed=False)
-
- e0 = Edge.make_line((0, 0), (1, 0))
- e1 = Edge.make_line((2, 0), (1, 0))
- e2 = Edge.make_line((2, 0), (3, 0))
- w1 = Wire([e0, e1, e2])
- for wire in [o, w1]:
- u_value = random.random()
- position = wire.position_at(u_value)
- self.assertAlmostEqual(wire.param_at_point(position), u_value, 4)
-
- with self.assertRaises(ValueError):
- o.param_at_point((-1, 1))
-
- with self.assertRaises(ValueError):
- w1.param_at_point((20, 20, 20))
-
- def test_order_edges(self):
- w1 = Wire(
- [
- Edge.make_line((0, 0), (1, 0)),
- Edge.make_line((1, 1), (1, 0)),
- Edge.make_line((0, 1), (1, 1)),
- ]
- )
- ordered_edges = w1.order_edges()
- self.assertFalse(all(e.is_forward for e in w1.edges()))
- self.assertTrue(all(e.is_forward for e in ordered_edges))
- self.assertVectorAlmostEquals(ordered_edges[0] @ 0, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(ordered_edges[1] @ 0, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(ordered_edges[2] @ 0, (1, 1, 0), 5)
-
- def test_constructor(self):
- e0 = Edge.make_line((0, 0), (1, 0))
- e1 = Edge.make_line((1, 0), (1, 1))
- w0 = Wire.make_circle(1)
- w1 = Wire(e0)
- self.assertTrue(w1.is_valid())
- w2 = Wire([e0])
- self.assertAlmostEqual(w2.length, 1, 5)
- self.assertTrue(w2.is_valid())
- w3 = Wire([e0, e1])
- self.assertTrue(w3.is_valid())
- self.assertAlmostEqual(w3.length, 2, 5)
- w4 = Wire(w0.wrapped)
- self.assertTrue(w4.is_valid())
- w5 = Wire(obj=w0.wrapped)
- self.assertTrue(w5.is_valid())
- w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red"))
- self.assertTrue(w6.is_valid())
- self.assertEqual(w6.label, "w6")
- self.assertTupleAlmostEquals(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 5)
- w7 = Wire(w6)
- self.assertTrue(w7.is_valid())
- c0 = Polyline((0, 0), (1, 0), (1, 1))
- w8 = Wire(c0)
- self.assertTrue(w8.is_valid())
- with self.assertRaises(ValueError):
- Wire(bob="fred")
-
-
-if __name__ == "__main__":
- import unittest
-
- unittest.main()
From 589cbcbd6870a9a37248fd8c3e02619be7508fb0 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 22 Jan 2025 15:17:52 -0500
Subject: [PATCH 153/518] Removed unnecessary custom test class and methods.
Script to split test_direct_api.py
---
tests/test_direct_api.py | 948 ++++++++++++++----------------
tools/refactor_test_direct_api.py | 346 +++++++++++
2 files changed, 787 insertions(+), 507 deletions(-)
create mode 100644 tools/refactor_test_direct_api.py
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
index 4dcbfaf..52d8ac8 100644
--- a/tests/test_direct_api.py
+++ b/tests/test_direct_api.py
@@ -5,6 +5,7 @@ from io import StringIO
import itertools
import json
import math
+import numpy as np
import os
import platform
import pprint
@@ -104,9 +105,6 @@ from build123d.topology import (
)
from build123d.jupyter_tools import display
-DEG2RAD = math.pi / 180
-RAD2DEG = 180 / math.pi
-
# Always equal to any other object, to test that __eq__ cooperation is working
class AlwaysEqual:
@@ -114,28 +112,6 @@ class AlwaysEqual:
return True
-class DirectApiTestCase(unittest.TestCase):
- def assertTupleAlmostEquals(
- self,
- first: tuple[float, ...],
- second: tuple[float, ...],
- places: int,
- msg: Optional[str] = None,
- ):
- """Check Tuples"""
- self.assertEqual(len(second), len(first))
- for i, j in zip(second, first):
- self.assertAlmostEqual(i, j, places, msg=msg)
-
- def assertVectorAlmostEquals(
- self, first: Vector, second: VectorLike, places: int, msg: Optional[str] = None
- ):
- second_vector = Vector(second)
- self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg)
- self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg)
- self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg)
-
-
class TestAssembly(unittest.TestCase):
@staticmethod
def create_test_assembly() -> Compound:
@@ -225,29 +201,29 @@ class TestAssembly(unittest.TestCase):
self.assertTrue(overlap)
-class TestAxis(DirectApiTestCase):
+class TestAxis(unittest.TestCase):
"""Test the Axis class"""
def test_axis_init(self):
test_axis = Axis((1, 2, 3), (0, 0, 1))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
test_axis = Axis((1, 2, 3), direction=(0, 0, 1))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4)))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4)))
- self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
with self.assertRaises(ValueError):
Axis("one", "up")
@@ -257,8 +233,8 @@ class TestAxis(DirectApiTestCase):
def test_axis_from_occt(self):
occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0))
test_axis = Axis(occt_axis)
- self.assertVectorAlmostEquals(test_axis.position, (1, 1, 1), 5)
- self.assertVectorAlmostEquals(test_axis.direction, (0, 1, 0), 5)
+ self.assertAlmostEqual(test_axis.position, (1, 1, 1), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5)
def test_axis_repr_and_str(self):
self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))")
@@ -266,29 +242,29 @@ class TestAxis(DirectApiTestCase):
def test_axis_copy(self):
x_copy = copy.copy(Axis.X)
- self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5)
+ self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5)
x_copy = copy.deepcopy(Axis.X)
- self.assertVectorAlmostEquals(x_copy.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_copy.direction, (1, 0, 0), 5)
+ self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5)
def test_axis_to_location(self):
# TODO: Verify this is correct
x_location = Axis.X.location
self.assertTrue(isinstance(x_location, Location))
- self.assertVectorAlmostEquals(x_location.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_location.orientation, (0, 90, 180), 5)
+ self.assertAlmostEqual(x_location.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_location.orientation, (0, 90, 180), 5)
def test_axis_located(self):
y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0)))
- self.assertVectorAlmostEquals(y_axis.position, (0, 0, 1), 5)
- self.assertVectorAlmostEquals(y_axis.direction, (0, 1, 0), 5)
+ self.assertAlmostEqual(y_axis.position, (0, 0, 1), 5)
+ self.assertAlmostEqual(y_axis.direction, (0, 1, 0), 5)
def test_axis_to_plane(self):
x_plane = Axis.X.to_plane()
self.assertTrue(isinstance(x_plane, Plane))
- self.assertVectorAlmostEquals(x_plane.origin, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(x_plane.z_dir, (1, 0, 0), 5)
+ self.assertAlmostEqual(x_plane.origin, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_plane.z_dir, (1, 0, 0), 5)
def test_axis_is_coaxial(self):
self.assertTrue(Axis.X.is_coaxial(Axis((0, 0, 0), (1, 0, 0))))
@@ -314,11 +290,11 @@ class TestAxis(DirectApiTestCase):
)
def test_axis_reverse(self):
- self.assertVectorAlmostEquals(Axis.X.reverse().direction, (-1, 0, 0), 5)
+ self.assertAlmostEqual(Axis.X.reverse().direction, (-1, 0, 0), 5)
def test_axis_reverse_op(self):
axis = -Axis.X
- self.assertVectorAlmostEquals(axis.direction, (-1, 0, 0), 5)
+ self.assertAlmostEqual(axis.direction, (-1, 0, 0), 5)
def test_axis_as_edge(self):
edge = Edge(Axis.X)
@@ -334,34 +310,34 @@ class TestAxis(DirectApiTestCase):
self.assertAlmostEqual(common.length, 1, 5)
intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0))
- self.assertVectorAlmostEquals(intersection, (1, 0, 0), 5)
+ self.assertAlmostEqual(intersection, (1, 0, 0), 5)
i = Axis.X & Axis((1, 0, 0), (1, 0, 0))
self.assertEqual(i, Axis.X)
intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY
- self.assertTupleAlmostEquals(intersection.to_tuple(), (1, 2, 0), 5)
+ self.assertAlmostEqual(intersection.to_tuple(), (1, 2, 0), 5)
arc = Edge.make_circle(20, start_angle=0, end_angle=180)
ax0 = Axis((-20, 30, 0), (4, -3, 0))
intersections = arc.intersect(ax0).vertices().sort_by(Axis.X)
- self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5)
- self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5)
+ np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5)
+ np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5)
intersections = ax0.intersect(arc).vertices().sort_by(Axis.X)
- self.assertTupleAlmostEquals(tuple(intersections[0]), (-5.6, 19.2, 0), 5)
- self.assertTupleAlmostEquals(tuple(intersections[1]), (20, 0, 0), 5)
+ np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5)
+ np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5)
i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5)
self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, (0.5, 0.5, 1.5), 5)
+ self.assertAlmostEqual(i, (0.5, 0.5, 1.5), 5)
self.assertIsNone(Axis.Y & Vector(2, 0, 0))
l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1
i: Location = Axis.Z & l
self.assertTrue(isinstance(i, Location))
- self.assertVectorAlmostEquals(i.position, l.position, 5)
- self.assertVectorAlmostEquals(i.orientation, l.orientation, 5)
+ self.assertAlmostEqual(i.position, l.position, 5)
+ self.assertAlmostEqual(i.orientation, l.orientation, 5)
self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1))
self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1))
@@ -373,10 +349,10 @@ class TestAxis(DirectApiTestCase):
# intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar
# self.assertTrue(len(intersections.vertices(), 2))
- # self.assertTupleAlmostEquals(
+ # np.testing.assert_allclose(
# intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5
# )
- # self.assertTupleAlmostEquals(
+ # np.testing.assert_allclose(
# intersection.vertices()[1].to_tuple(), (1, 0, 5), 5
# )
@@ -392,7 +368,7 @@ class TestAxis(DirectApiTestCase):
self.assertNotEqual(Axis.X, random_obj)
-class TestBoundBox(DirectApiTestCase):
+class TestBoundBox(unittest.TestCase):
def test_basic_bounding_box(self):
v = Vertex(1, 1, 1)
v2 = Vertex(2, 2, 2)
@@ -409,13 +385,13 @@ class TestBoundBox(DirectApiTestCase):
bb2 = v0.bounding_box().add(v.bounding_box())
bb3 = bb1.add(bb2)
- self.assertVectorAlmostEquals(bb3.size, (2, 2, 2), 7)
+ self.assertAlmostEqual(bb3.size, (2, 2, 2), 7)
bb3 = bb2.add((3, 3, 3))
- self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7)
+ self.assertAlmostEqual(bb3.size, (3, 3, 3), 7)
bb3 = bb2.add(Vector(3, 3, 3))
- self.assertVectorAlmostEquals(bb3.size, (3, 3, 3), 7)
+ self.assertAlmostEqual(bb3.size, (3, 3, 3), 7)
# Test 2D bounding boxes
bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box())
@@ -430,7 +406,7 @@ class TestBoundBox(DirectApiTestCase):
# Test creation of a bounding box from a shape - note the low accuracy comparison
# as the box is a little larger than the shape
bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
- self.assertVectorAlmostEquals(bb1.size, (2, 2, 1), 1)
+ self.assertAlmostEqual(bb1.size, (2, 2, 1), 1)
bb2 = BoundBox.from_topo_ds(
Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False
@@ -444,7 +420,7 @@ class TestBoundBox(DirectApiTestCase):
)
def test_center_of_boundbox(self):
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Solid.make_box(1, 1, 1).bounding_box().center(),
(0.5, 0.5, 0.5),
5,
@@ -455,18 +431,18 @@ class TestBoundBox(DirectApiTestCase):
def test_clean_boundbox(self):
s = Solid.make_sphere(3)
- self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5)
+ self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5)
s.mesh(1e-3)
- self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5)
+ self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5)
# def test_to_solid(self):
# bbox = Solid.make_sphere(1).bounding_box()
- # self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5)
- # self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5)
+ # self.assertAlmostEqual(bbox.min, (-1, -1, -1), 5)
+ # self.assertAlmostEqual(bbox.max, (1, 1, 1), 5)
# self.assertAlmostEqual(bbox.to_solid().volume, 2**3, 5)
-class TestCadObjects(DirectApiTestCase):
+class TestCadObjects(unittest.TestCase):
def _make_circle(self):
circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0)
return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
@@ -478,19 +454,19 @@ class TestCadObjects(DirectApiTestCase):
def test_edge_wrapper_center(self):
e = self._make_circle()
- self.assertVectorAlmostEquals(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3)
+ self.assertAlmostEqual(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3)
def test_edge_wrapper_ellipse_center(self):
e = self._make_ellipse()
w = Wire([e])
- self.assertVectorAlmostEquals(Face(w).center(), (1.0, 2.0, 3.0), 3)
+ self.assertAlmostEqual(Face(w).center(), (1.0, 2.0, 3.0), 3)
def test_edge_wrapper_make_circle(self):
halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180)
- # self.assertVectorAlmostEquals((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),3)
- self.assertVectorAlmostEquals(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3)
- self.assertVectorAlmostEquals(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3)
+ # np.testing.assert_allclose((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),1e-3)
+ self.assertAlmostEqual(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3)
+ self.assertAlmostEqual(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3)
def test_edge_wrapper_make_tangent_arc(self):
tangent_arc = Edge.make_tangent_arc(
@@ -498,11 +474,11 @@ class TestCadObjects(DirectApiTestCase):
Vector(0, 1), # tangent at start of arc is in the +y direction
Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1
)
- self.assertVectorAlmostEquals(tangent_arc.start_point(), (1, 1, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.end_point(), (2, 1, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.tangent_at(0), (0, 1, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.tangent_at(0.5), (1, 0, 0), 3)
- self.assertVectorAlmostEquals(tangent_arc.tangent_at(1), (0, -1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.start_point(), (1, 1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.end_point(), (2, 1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.tangent_at(0), (0, 1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.tangent_at(0.5), (1, 0, 0), 3)
+ self.assertAlmostEqual(tangent_arc.tangent_at(1), (0, -1, 0), 3)
def test_edge_wrapper_make_ellipse1(self):
# Check x_radius > y_radius
@@ -517,17 +493,17 @@ class TestCadObjects(DirectApiTestCase):
)
start = (
- x_radius * math.cos(angle1 * DEG2RAD),
- y_radius * math.sin(angle1 * DEG2RAD),
+ x_radius * math.cos(math.radians(angle1)),
+ y_radius * math.sin(math.radians(angle1)),
0.0,
)
end = (
- x_radius * math.cos(angle2 * DEG2RAD),
- y_radius * math.sin(angle2 * DEG2RAD),
+ x_radius * math.cos(math.radians(angle2)),
+ y_radius * math.sin(math.radians(angle2)),
0.0,
)
- self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
- self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
+ self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
+ self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
def test_edge_wrapper_make_ellipse2(self):
# Check x_radius < y_radius
@@ -542,17 +518,17 @@ class TestCadObjects(DirectApiTestCase):
)
start = (
- x_radius * math.cos(angle1 * DEG2RAD),
- y_radius * math.sin(angle1 * DEG2RAD),
+ x_radius * math.cos(math.radians(angle1)),
+ y_radius * math.sin(math.radians(angle1)),
0.0,
)
end = (
- x_radius * math.cos(angle2 * DEG2RAD),
- y_radius * math.sin(angle2 * DEG2RAD),
+ x_radius * math.cos(math.radians(angle2)),
+ y_radius * math.sin(math.radians(angle2)),
0.0,
)
- self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
- self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
+ self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
+ self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
def test_edge_wrapper_make_circle_with_ellipse(self):
# Check x_radius == y_radius
@@ -567,22 +543,22 @@ class TestCadObjects(DirectApiTestCase):
)
start = (
- x_radius * math.cos(angle1 * DEG2RAD),
- y_radius * math.sin(angle1 * DEG2RAD),
+ x_radius * math.cos(math.radians(angle1)),
+ y_radius * math.sin(math.radians(angle1)),
0.0,
)
end = (
- x_radius * math.cos(angle2 * DEG2RAD),
- y_radius * math.sin(angle2 * DEG2RAD),
+ x_radius * math.cos(math.radians(angle2)),
+ y_radius * math.sin(math.radians(angle2)),
0.0,
)
- self.assertVectorAlmostEquals(arcEllipseEdge.start_point(), start, 3)
- self.assertVectorAlmostEquals(arcEllipseEdge.end_point(), end, 3)
+ self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
+ self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
def test_face_wrapper_make_rect(self):
mplane = Face.make_rect(10, 10)
- self.assertVectorAlmostEquals(mplane.normal_at(), (0.0, 0.0, 1.0), 3)
+ self.assertAlmostEqual(mplane.normal_at(), (0.0, 0.0, 1.0), 3)
# def testCompoundcenter(self):
# """
@@ -609,13 +585,13 @@ class TestCadObjects(DirectApiTestCase):
# )
# self.assertEqual(4, len(s.val().solids()))
- # self.assertVectorAlmostEquals((0.0, 0.0, 0.25), s.val().center, 3)
+ # np.testing.assert_allclose((0.0, 0.0, 0.25), s.val().center, 1e-3)
def test_translate(self):
e = Edge.make_circle(2, Plane((1, 2, 3)))
e2 = e.translate(Vector(0, 0, 1))
- self.assertVectorAlmostEquals(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
+ self.assertAlmostEqual(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
def test_vertices(self):
e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
@@ -724,22 +700,22 @@ class TestCleanMethod(unittest.TestCase):
self.assertIs(result, self.solid) # Ensure it returns the same object
-class TestColor(DirectApiTestCase):
+class TestColor(unittest.TestCase):
def test_name1(self):
c = Color("blue")
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
def test_name2(self):
c = Color("blue", alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
def test_name3(self):
c = Color("blue", 0.5)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
def test_rgb0(self):
c = Color(0.0, 1.0, 0.0)
- self.assertTupleAlmostEquals(tuple(c), (0, 1, 0, 1), 5)
+ np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 1e-5)
def test_rgba1(self):
c = Color(1.0, 1.0, 0.0, 0.5)
@@ -750,11 +726,11 @@ class TestColor(DirectApiTestCase):
def test_rgba2(self):
c = Color(1.0, 1.0, 0.0, alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (1, 1, 0, 0.5), 5)
+ np.testing.assert_allclose(tuple(c), (1, 1, 0, 0.5), 1e-5)
def test_rgba3(self):
c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.5), 5)
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.5), 1e-5)
def test_bad_color_name(self):
with self.assertRaises(ValueError):
@@ -762,37 +738,37 @@ class TestColor(DirectApiTestCase):
def test_to_tuple(self):
c = Color("blue", alpha=0.5)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 0.5), 5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
def test_hex(self):
c = Color(0x996692)
- self.assertTupleAlmostEquals(
+ np.testing.assert_allclose(
tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5
)
c = Color(0x006692, 0x80)
- self.assertTupleAlmostEquals(
+ np.testing.assert_allclose(
tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5
)
c = Color(0x006692, alpha=0x80)
- self.assertTupleAlmostEquals(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 5)
+ np.testing.assert_allclose(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 1e-5)
c = Color(color_code=0x996692, alpha=0xCC)
- self.assertTupleAlmostEquals(
+ np.testing.assert_allclose(
tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5
)
c = Color(0.0, 0.0, 1.0, 1.0)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
c = Color(0, 0, 1, 1)
- self.assertTupleAlmostEquals(tuple(c), (0, 0, 1, 1), 5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
def test_copy(self):
c = Color(0.1, 0.2, 0.3, alpha=0.4)
c_copy = copy.copy(c)
- self.assertTupleAlmostEquals(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 5)
+ np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 1e-5)
def test_str_repr(self):
c = Color(1, 0, 0)
@@ -801,18 +777,18 @@ class TestColor(DirectApiTestCase):
def test_tuple(self):
c = Color((0.1,))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 1.0, 1.0, 1.0), 5)
+ np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5)
c = Color((0.1, 0.2))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 1.0, 1.0), 5)
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 1.0, 1.0), 1e-5)
c = Color((0.1, 0.2, 0.3))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 1.0), 5)
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 1.0), 1e-5)
c = Color((0.1, 0.2, 0.3, 0.4))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5)
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4))
- self.assertTupleAlmostEquals(tuple(c), (0.1, 0.2, 0.3, 0.4), 5)
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
-class TestCompound(DirectApiTestCase):
+class TestCompound(unittest.TestCase):
def test_make_text(self):
arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0))
text = Compound.make_text("test", 10, text_path=arc)
@@ -856,8 +832,8 @@ class TestCompound(DirectApiTestCase):
Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))),
]
)
- self.assertVectorAlmostEquals(test_compound.center(CenterOf.MASS), (1, 0, 0), 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(test_compound.center(CenterOf.MASS), (1, 0, 0), 5)
+ self.assertAlmostEqual(
test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5
)
with self.assertRaises(ValueError):
@@ -933,7 +909,7 @@ class TestCompound(DirectApiTestCase):
self.assertEqual(b1.get_top_level_shapes()[0], b1)
-class TestEdge(DirectApiTestCase):
+class TestEdge(unittest.TestCase):
def test_close(self):
self.assertAlmostEqual(
Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5
@@ -942,13 +918,13 @@ class TestEdge(DirectApiTestCase):
def test_make_half_circle(self):
half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180)
- self.assertVectorAlmostEquals(half_circle.start_point(), (1, 0, 0), 3)
- self.assertVectorAlmostEquals(half_circle.end_point(), (-1, 0, 0), 3)
+ self.assertAlmostEqual(half_circle.start_point(), (1, 0, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (-1, 0, 0), 3)
def test_make_half_circle2(self):
half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90)
- self.assertVectorAlmostEquals(half_circle.start_point(), (0, -1, 0), 3)
- self.assertVectorAlmostEquals(half_circle.end_point(), (0, 1, 0), 3)
+ self.assertAlmostEqual(half_circle.start_point(), (0, -1, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (0, 1, 0), 3)
def test_make_clockwise_half_circle(self):
half_circle = Edge.make_circle(
@@ -957,8 +933,8 @@ class TestEdge(DirectApiTestCase):
end_angle=0,
angular_direction=AngularDirection.CLOCKWISE,
)
- self.assertVectorAlmostEquals(half_circle.end_point(), (1, 0, 0), 3)
- self.assertVectorAlmostEquals(half_circle.start_point(), (-1, 0, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (1, 0, 0), 3)
+ self.assertAlmostEqual(half_circle.start_point(), (-1, 0, 0), 3)
def test_make_clockwise_half_circle2(self):
half_circle = Edge.make_circle(
@@ -967,11 +943,11 @@ class TestEdge(DirectApiTestCase):
end_angle=-90,
angular_direction=AngularDirection.CLOCKWISE,
)
- self.assertVectorAlmostEquals(half_circle.start_point(), (0, 1, 0), 3)
- self.assertVectorAlmostEquals(half_circle.end_point(), (0, -1, 0), 3)
+ self.assertAlmostEqual(half_circle.start_point(), (0, 1, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (0, -1, 0), 3)
def test_arc_center(self):
- self.assertVectorAlmostEquals(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5)
+ self.assertAlmostEqual(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5)
with self.assertRaises(ValueError):
Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center
@@ -979,7 +955,7 @@ class TestEdge(DirectApiTestCase):
spline = Edge.make_spline(
points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0]
)
- self.assertVectorAlmostEquals(spline.end_point(), (2, 0, 0), 5)
+ self.assertAlmostEqual(spline.end_point(), (2, 0, 0), 5)
with self.assertRaises(ValueError):
Edge.make_spline(
points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0]
@@ -991,28 +967,28 @@ class TestEdge(DirectApiTestCase):
def test_spline_approx(self):
spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)])
- self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5)
+ self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
spline = Edge.make_spline_approx(
[(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0)
)
- self.assertVectorAlmostEquals(spline.end_point(), (3, 0, 0), 5)
+ self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
def test_distribute_locations(self):
line = Edge.make_line((0, 0, 0), (10, 0, 0))
locs = line.distribute_locations(3)
for i, x in enumerate([0, 5, 10]):
- self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[0].orientation, (0, 90, 180), 5)
+ self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5)
+ self.assertAlmostEqual(locs[0].orientation, (0, 90, 180), 5)
locs = line.distribute_locations(3, positions_only=True)
for i, x in enumerate([0, 5, 10]):
- self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[0].orientation, (0, 0, 0), 5)
+ self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5)
+ self.assertAlmostEqual(locs[0].orientation, (0, 0, 0), 5)
def test_to_wire(self):
edge = Edge.make_line((0, 0, 0), (1, 1, 1))
for end in [0, 1]:
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
edge.position_at(end),
edge.to_wire().position_at(end),
5,
@@ -1024,7 +1000,7 @@ class TestEdge(DirectApiTestCase):
Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30),
]
for edge in edges:
- self.assertVectorAlmostEquals(edge.arc_center, (1, 2, 3), 5)
+ self.assertAlmostEqual(edge.arc_center, (1, 2, 3), 5)
with self.assertRaises(ValueError):
Edge.make_line((0, 0), (1, 1)).arc_center
@@ -1033,7 +1009,7 @@ class TestEdge(DirectApiTestCase):
line = Edge.make_line((0, -2), (0, 2))
crosses = circle.find_intersection_points(line)
for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses):
- self.assertVectorAlmostEquals(actual, target, 5)
+ self.assertAlmostEqual(actual, target, 5)
with self.assertRaises(ValueError):
circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
@@ -1041,14 +1017,14 @@ class TestEdge(DirectApiTestCase):
circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)])
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
self_intersect.find_intersection_points()[0],
(-2.6861636507066047, 0, 0),
5,
)
line = Edge.make_line((1, -2), (1, 2))
crosses = line.find_intersection_points(Axis.X)
- self.assertVectorAlmostEquals(crosses[0], (1, 0, 0), 5)
+ self.assertAlmostEqual(crosses[0], (1, 0, 0), 5)
with self.assertRaises(ValueError):
line.find_intersection_points(Plane.YZ)
@@ -1069,12 +1045,8 @@ class TestEdge(DirectApiTestCase):
def test_trim(self):
line = Edge.make_line((-2, 0), (2, 0))
- self.assertVectorAlmostEquals(
- line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5
- )
- self.assertVectorAlmostEquals(
- line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5
- )
+ self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5)
+ self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5)
with self.assertRaises(ValueError):
line.trim(0.75, 0.25)
@@ -1087,7 +1059,7 @@ class TestEdge(DirectApiTestCase):
e2 = Edge.make_circle(10, start_angle=0, end_angle=90)
e2_trim = e2.trim_to_length(0.5, 1)
self.assertAlmostEqual(e2_trim.length, 1, 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5
)
@@ -1112,15 +1084,15 @@ class TestEdge(DirectApiTestCase):
bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0))
bbox = bezier.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(bbox.max, (1, 0.75, 0), 5)
+ self.assertAlmostEqual(bbox.min, (0, 0, 0), 5)
+ self.assertAlmostEqual(bbox.max, (1, 0.75, 0), 5)
def test_mid_way(self):
mid = Edge.make_mid_way(
Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25
)
- self.assertVectorAlmostEquals(mid.position_at(0), (0.25, 0, 0), 5)
- self.assertVectorAlmostEquals(mid.position_at(1), (0.25, 1, 0), 5)
+ self.assertAlmostEqual(mid.position_at(0), (0.25, 0, 0), 5)
+ self.assertAlmostEqual(mid.position_at(1), (0.25, 1, 0), 5)
def test_distribute_locations2(self):
with self.assertRaises(ValueError):
@@ -1128,17 +1100,17 @@ class TestEdge(DirectApiTestCase):
locs = Edge.make_circle(1).distribute_locations(5, positions_only=True)
for i, loc in enumerate(locs):
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
loc.position,
Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(),
5,
)
- self.assertVectorAlmostEquals(loc.orientation, (0, 0, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (0, 0, 0), 5)
def test_find_tangent(self):
circle = Edge.make_circle(1)
parm = circle.find_tangent(135)[0]
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
)
line = Edge.make_line((0, 0), (1, 1))
@@ -1175,8 +1147,8 @@ class TestEdge(DirectApiTestCase):
def test_reverse(self):
e1 = Edge.make_line((0, 0), (1, 1))
- self.assertVectorAlmostEquals(e1 @ 0.1, (0.1, 0.1, 0), 5)
- self.assertVectorAlmostEquals(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5)
+ self.assertAlmostEqual(e1 @ 0.1, (0.1, 0.1, 0), 5)
+ self.assertAlmostEqual(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5)
e2 = Edge.make_circle(1, start_angle=0, end_angle=180)
e2r = e2.reversed()
@@ -1187,14 +1159,14 @@ class TestEdge(DirectApiTestCase):
Edge(direction=(1, 0, 0))
-class TestFace(DirectApiTestCase):
+class TestFace(unittest.TestCase):
def test_make_surface_from_curves(self):
bottom_edge = Edge.make_circle(radius=1, end_angle=90)
top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90)
curved = Face.make_surface_from_curves(bottom_edge, top_edge)
self.assertTrue(curved.is_valid())
self.assertAlmostEqual(curved.area, math.pi / 2, 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
)
@@ -1206,10 +1178,8 @@ class TestFace(DirectApiTestCase):
def test_center(self):
test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)]))
- self.assertVectorAlmostEquals(
- test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1
- )
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1)
+ self.assertAlmostEqual(
test_face.center(CenterOf.BOUNDING_BOX),
(0.5, 0.5, 0),
5,
@@ -1260,7 +1230,7 @@ class TestFace(DirectApiTestCase):
def test_make_rect(self):
test_face = Face.make_plane()
- self.assertVectorAlmostEquals(test_face.normal_at(), (0, 0, 1), 5)
+ self.assertAlmostEqual(test_face.normal_at(), (0, 0, 1), 5)
def test_length_width(self):
test_face = Face.make_rect(8, 10, Plane.XZ)
@@ -1294,14 +1264,14 @@ class TestFace(DirectApiTestCase):
def test_negate(self):
square = Face.make_rect(1, 1)
- self.assertVectorAlmostEquals(square.normal_at(), (0, 0, 1), 5)
+ self.assertAlmostEqual(square.normal_at(), (0, 0, 1), 5)
flipped_square = -square
- self.assertVectorAlmostEquals(flipped_square.normal_at(), (0, 0, -1), 5)
+ self.assertAlmostEqual(flipped_square.normal_at(), (0, 0, -1), 5)
def test_offset(self):
bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, 5), 5)
- self.assertVectorAlmostEquals(bbox.max, (1, 1, 5), 5)
+ self.assertAlmostEqual(bbox.min, (-1, -1, 5), 5)
+ self.assertAlmostEqual(bbox.max, (1, 1, 5), 5)
def test_make_from_wires(self):
outer = Wire.make_circle(10)
@@ -1349,8 +1319,8 @@ class TestFace(DirectApiTestCase):
]
surface = Face.make_surface_from_array_of_points(pnts)
bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (0, 0, -1), 3)
- self.assertVectorAlmostEquals(bbox.max, (10, 10, 2), 2)
+ self.assertAlmostEqual(bbox.min, (0, 0, -1), 3)
+ self.assertAlmostEqual(bbox.max, (10, 10, 2), 2)
def test_bezier_surface(self):
points = [
@@ -1362,8 +1332,8 @@ class TestFace(DirectApiTestCase):
]
surface = Face.make_bezier_surface(points)
bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3)
- self.assertVectorAlmostEquals(bbox.max, (+1, +1, +1), 1)
+ self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3)
+ self.assertAlmostEqual(bbox.max, (+1, +1, +1), 1)
self.assertLess(bbox.max.Z, 1.0)
weights = [
@@ -1371,7 +1341,7 @@ class TestFace(DirectApiTestCase):
]
surface = Face.make_bezier_surface(points, weights)
bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, 0), 3)
+ self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3)
self.assertGreater(bbox.max.Z, 1.0)
too_many_points = [
@@ -1403,8 +1373,8 @@ class TestFace(DirectApiTestCase):
square = Face.make_rect(10, 10)
bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-5, -5, -1), 5)
- self.assertVectorAlmostEquals(bbox.max, (5, 5, 0), 5)
+ self.assertAlmostEqual(bbox.min, (-5, -5, -1), 5)
+ self.assertAlmostEqual(bbox.max, (5, 5, 0), 5)
def test_make_holes(self):
radius = 10
@@ -1462,24 +1432,24 @@ class TestFace(DirectApiTestCase):
def test_center_location(self):
square = Face.make_rect(1, 1, plane=Plane.XZ)
cl = square.center_location
- self.assertVectorAlmostEquals(cl.position, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(Plane(cl).z_dir, Plane.XZ.z_dir, 5)
+ self.assertAlmostEqual(cl.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(Plane(cl).z_dir, Plane.XZ.z_dir, 5)
def test_position_at(self):
square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1))
p = square.position_at(0.25, 0.75)
- self.assertVectorAlmostEquals(p, (-0.5, -1.0, 0.5), 5)
+ self.assertAlmostEqual(p, (-0.5, -1.0, 0.5), 5)
def test_location_at(self):
bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0]
loc = bottom.location_at(0.5, 0.5)
- self.assertVectorAlmostEquals(loc.position, (0.5, 1, 0), 5)
- self.assertVectorAlmostEquals(loc.orientation, (-180, 0, -180), 5)
+ self.assertAlmostEqual(loc.position, (0.5, 1, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (-180, 0, -180), 5)
front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0]
loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1))
- self.assertVectorAlmostEquals(loc.position, (0.0, 1.0, 1.5), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, -90, 0), 5)
+ self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5)
+ self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5)
def test_make_surface(self):
corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
@@ -1509,20 +1479,20 @@ class TestFace(DirectApiTestCase):
self.assertTrue(surface.is_valid())
self.assertEqual(surface.geom_type, GeomType.BSPLINE)
bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -5.113393280136395), 5)
- self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5)
+ self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -5.113393280136395), 5)
+ self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5)
# With no surface point
surface = Face.make_surface(net_exterior)
bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -3), 5)
- self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5)
+ self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -3), 5)
+ self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5)
# Exterior Edge
surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)])
bbox = surface.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-50, -50, -5), 5)
- self.assertVectorAlmostEquals(bbox.max, (50, 50, 0), 5)
+ self.assertAlmostEqual(bbox.min, (-50, -50, -5), 5)
+ self.assertAlmostEqual(bbox.max, (50, 50, 0), 5)
def test_make_surface_error_checking(self):
with self.assertRaises(ValueError):
@@ -1578,16 +1548,14 @@ class TestFace(DirectApiTestCase):
def test_normal_at(self):
face = Face.make_rect(1, 1)
- self.assertVectorAlmostEquals(face.normal_at(0, 0), (0, 0, 1), 5)
- self.assertVectorAlmostEquals(
- face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5
- )
+ self.assertAlmostEqual(face.normal_at(0, 0), (0, 0, 1), 5)
+ self.assertAlmostEqual(face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5)
with self.assertRaises(ValueError):
face.normal_at(0)
with self.assertRaises(ValueError):
face.normal_at(center=(0, 0))
face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
- self.assertVectorAlmostEquals(face.normal_at(0, 1), (1, 0, 0), 5)
+ self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5)
class TestFunctions(unittest.TestCase):
@@ -1680,7 +1648,7 @@ class TestGroupBy(unittest.TestCase):
)
-class TestImportExport(DirectApiTestCase):
+class TestImportExport(unittest.TestCase):
def test_import_export(self):
original_box = Solid.make_box(1, 1, 1)
export_step(original_box, "test_box.step")
@@ -1705,10 +1673,10 @@ class TestImportExport(DirectApiTestCase):
# import as face
stl_box = import_stl("test.stl")
- self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(stl_box.position, (0, 0, 0), 5)
-class TestJupyter(DirectApiTestCase):
+class TestJupyter(unittest.TestCase):
def test_repr_javascript(self):
shape = Solid.make_box(1, 1, 1)
@@ -1728,36 +1696,40 @@ class TestJupyter(DirectApiTestCase):
display("invalid")
-class TestLocation(DirectApiTestCase):
+class TestLocation(unittest.TestCase):
def test_location(self):
loc0 = Location()
T = loc0.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 0), 6)
- angle = loc0.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 0), 1e-6)
+ angle = math.degrees(
+ loc0.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
self.assertAlmostEqual(0, angle)
# Tuple
loc0 = Location((0, 0, 1))
T = loc0.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
# List
loc0 = Location([0, 0, 1])
T = loc0.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
# Vector
loc1 = Location(Vector(0, 0, 1))
T = loc1.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
# rotation + translation
loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45)
- angle = loc2.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ angle = math.degrees(
+ loc2.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
self.assertAlmostEqual(45, angle)
# gp_Trsf
@@ -1772,13 +1744,13 @@ class TestLocation(DirectApiTestCase):
# Test creation from the OCP.gp.gp_Trsf object
loc4 = Location(gp_Trsf())
- self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 0), 7)
- self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7)
+ np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 0), 1e-7)
+ np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7)
# Test creation from Plane and Vector
loc4 = Location(Plane.XY, (0, 0, 1))
- self.assertTupleAlmostEquals(loc4.to_tuple()[0], (0, 0, 1), 7)
- self.assertTupleAlmostEquals(loc4.to_tuple()[1], (0, 0, 0), 7)
+ np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 1), 1e-7)
+ np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7)
# Test composition
loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15)
@@ -1788,20 +1760,20 @@ class TestLocation(DirectApiTestCase):
loc7 = loc4**2
T = loc5.wrapped.Transformation().TranslationPart()
- self.assertTupleAlmostEquals((T.X(), T.Y(), T.Z()), (0, 0, 1), 6)
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
- angle5 = (
- loc5.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ angle5 = math.degrees(
+ loc5.wrapped.Transformation().GetRotation().GetRotationAngle()
)
self.assertAlmostEqual(15, angle5)
- angle6 = (
- loc6.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ angle6 = math.degrees(
+ loc6.wrapped.Transformation().GetRotation().GetRotationAngle()
)
self.assertAlmostEqual(30, angle6)
- angle7 = (
- loc7.wrapped.Transformation().GetRotation().GetRotationAngle() * RAD2DEG
+ angle7 = math.degrees(
+ loc7.wrapped.Transformation().GetRotation().GetRotationAngle()
)
self.assertAlmostEqual(30, angle7)
@@ -1834,37 +1806,37 @@ class TestLocation(DirectApiTestCase):
t.SetRotationPart(q)
loc2 = Location(t)
- self.assertTupleAlmostEquals(loc1.to_tuple()[0], loc2.to_tuple()[0], 6)
- self.assertTupleAlmostEquals(loc1.to_tuple()[1], loc2.to_tuple()[1], 6)
+ np.testing.assert_allclose(loc1.to_tuple()[0], loc2.to_tuple()[0], 1e-6)
+ np.testing.assert_allclose(loc1.to_tuple()[1], loc2.to_tuple()[1], 1e-6)
loc1 = Location((1, 2), 34)
- self.assertTupleAlmostEquals(loc1.to_tuple()[0], (1, 2, 0), 6)
- self.assertTupleAlmostEquals(loc1.to_tuple()[1], (0, 0, 34), 6)
+ np.testing.assert_allclose(loc1.to_tuple()[0], (1, 2, 0), 1e-6)
+ np.testing.assert_allclose(loc1.to_tuple()[1], (0, 0, 34), 1e-6)
rot_angles = (-115.00, 35.00, -135.00)
loc2 = Location((1, 2, 3), rot_angles)
- self.assertTupleAlmostEquals(loc2.to_tuple()[0], (1, 2, 3), 6)
- self.assertTupleAlmostEquals(loc2.to_tuple()[1], rot_angles, 6)
+ np.testing.assert_allclose(loc2.to_tuple()[0], (1, 2, 3), 1e-6)
+ np.testing.assert_allclose(loc2.to_tuple()[1], rot_angles, 1e-6)
loc3 = Location(loc2)
- self.assertTupleAlmostEquals(loc3.to_tuple()[0], (1, 2, 3), 6)
- self.assertTupleAlmostEquals(loc3.to_tuple()[1], rot_angles, 6)
+ np.testing.assert_allclose(loc3.to_tuple()[0], (1, 2, 3), 1e-6)
+ np.testing.assert_allclose(loc3.to_tuple()[1], rot_angles, 1e-6)
def test_location_parameters(self):
loc = Location((10, 20, 30))
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
loc = Location((10, 20, 30), (10, 20, 30))
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
- self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ)
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
- self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX)
- self.assertVectorAlmostEquals(loc.position, (10, 20, 30), 5)
- self.assertVectorAlmostEquals(loc.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
with self.assertRaises(TypeError):
Location(x=10)
@@ -1891,33 +1863,33 @@ class TestLocation(DirectApiTestCase):
def test_location_inverted(self):
loc = Location(Plane.XZ)
- self.assertVectorAlmostEquals(loc.inverse().orientation, (-90, 0, 0), 6)
+ self.assertAlmostEqual(loc.inverse().orientation, (-90, 0, 0), 6)
def test_set_position(self):
loc = Location(Plane.XZ)
loc.position = (1, 2, 3)
- self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(loc.orientation, (90, 0, 0), 6)
+ self.assertAlmostEqual(loc.position, (1, 2, 3), 6)
+ self.assertAlmostEqual(loc.orientation, (90, 0, 0), 6)
def test_set_orientation(self):
loc = Location((1, 2, 3), (90, 0, 0))
loc.orientation = (-90, 0, 0)
- self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(loc.orientation, (-90, 0, 0), 6)
+ self.assertAlmostEqual(loc.position, (1, 2, 3), 6)
+ self.assertAlmostEqual(loc.orientation, (-90, 0, 0), 6)
def test_copy(self):
loc1 = Location((1, 2, 3), (90, 45, 22.5))
loc2 = copy.copy(loc1)
loc3 = copy.deepcopy(loc1)
- self.assertVectorAlmostEquals(loc1.position, loc2.position.to_tuple(), 6)
- self.assertVectorAlmostEquals(loc1.orientation, loc2.orientation.to_tuple(), 6)
- self.assertVectorAlmostEquals(loc1.position, loc3.position.to_tuple(), 6)
- self.assertVectorAlmostEquals(loc1.orientation, loc3.orientation.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.position, loc2.position.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.orientation, loc2.orientation.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.position, loc3.position.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.orientation, loc3.orientation.to_tuple(), 6)
def test_to_axis(self):
axis = Location((1, 2, 3), (-90, 0, 0)).to_axis()
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(axis.direction, (0, 1, 0), 6)
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 6)
+ self.assertAlmostEqual(axis.direction, (0, 1, 0), 6)
def test_equal(self):
loc = Location((1, 2, 3), (4, 5, 6))
@@ -1938,13 +1910,13 @@ class TestLocation(DirectApiTestCase):
def test_neg(self):
loc = Location((1, 2, 3), (0, 35, 127))
n_loc = -loc
- self.assertVectorAlmostEquals(n_loc.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(n_loc.orientation, (180, -35, -127), 5)
+ self.assertAlmostEqual(n_loc.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(n_loc.orientation, (180, -35, -127), 5)
def test_mult_iterable(self):
locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1)
- self.assertVectorAlmostEquals(locs[0].position, (-1, 2, 0), 5)
- self.assertVectorAlmostEquals(locs[1].position, (3, 2, 0), 5)
+ self.assertAlmostEqual(locs[0].position, (-1, 2, 0), 5)
+ self.assertAlmostEqual(locs[1].position, (3, 2, 0), 5)
def test_as_json(self):
data_dict = {
@@ -1973,13 +1945,13 @@ class TestLocation(DirectApiTestCase):
for key, value in read_json.items():
for k, v in value.items():
if key == "part1" and k == "joint_one":
- self.assertVectorAlmostEquals(v.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(v.position, (1, 2, 3), 5)
elif key == "part1" and k == "joint_two":
- self.assertVectorAlmostEquals(v.position, (7, 8, 9), 5)
+ self.assertAlmostEqual(v.position, (7, 8, 9), 5)
elif key == "part2" and k == "joint_one":
- self.assertVectorAlmostEquals(v.position, (13, 14, 15), 5)
+ self.assertAlmostEqual(v.position, (13, 14, 15), 5)
elif key == "part2" and k == "joint_two":
- self.assertVectorAlmostEquals(v.position, (19, 20, 21), 5)
+ self.assertAlmostEqual(v.position, (19, 20, 21), 5)
else:
self.assertTrue(False)
os.remove("sample.json")
@@ -1993,7 +1965,7 @@ class TestLocation(DirectApiTestCase):
i = l1 & Vector(1, 1, 1)
self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, (1, 1, 1), 5)
+ self.assertAlmostEqual(i, (1, 1, 1), 5)
i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1))
self.assertTrue(isinstance(i, Location))
@@ -2003,14 +1975,14 @@ class TestLocation(DirectApiTestCase):
l = Location((1, 0, 0), (1, 0, 0), 45)
i = l & p
self.assertTrue(isinstance(i, Location))
- self.assertVectorAlmostEquals(i.position, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(i.orientation, l.orientation, 5)
+ self.assertAlmostEqual(i.position, (1, 0, 0), 5)
+ self.assertAlmostEqual(i.orientation, l.orientation, 5)
b = Solid.make_box(1, 1, 1)
l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45)
i = (l & b).vertex()
self.assertTrue(isinstance(i, Vertex))
- self.assertVectorAlmostEquals(Vector(i), (0.5, 0.5, 0.5), 5)
+ self.assertAlmostEqual(Vector(i), (0.5, 0.5, 0.5), 5)
e1 = Edge.make_line((0, -1), (2, 1))
e2 = Edge.make_line((0, 1), (2, -1))
@@ -2018,7 +1990,7 @@ class TestLocation(DirectApiTestCase):
i = e1.intersect(e2, e3)
self.assertTrue(isinstance(i, Vertex))
- self.assertVectorAlmostEquals(i, (1, 0, 0), 5)
+ self.assertAlmostEqual(Vector(i), (1, 0, 0), 5)
e4 = Edge.make_line((1, -1), (1, 1))
e5 = Edge.make_line((2, -1), (2, 1))
@@ -2065,7 +2037,7 @@ class TestLocation(DirectApiTestCase):
self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3))
-class TestMatrix(DirectApiTestCase):
+class TestMatrix(unittest.TestCase):
def test_matrix_creation_and_access(self):
def matrix_vals(m):
return [[m[r, c] for c in range(4)] for r in range(4)]
@@ -2153,7 +2125,7 @@ class TestMatrix(DirectApiTestCase):
[0, 0, 0, 1],
]
mx = Matrix()
- mx.rotate(Axis.X, 30 * DEG2RAD)
+ mx.rotate(Axis.X, math.radians(30))
matrix_almost_equal(mx, m_rotate_x_30)
m_rotate_y_30 = [
@@ -2163,7 +2135,7 @@ class TestMatrix(DirectApiTestCase):
[0, 0, 0, 1],
]
my = Matrix()
- my.rotate(Axis.Y, 30 * DEG2RAD)
+ my.rotate(Axis.Y, math.radians(30))
matrix_almost_equal(my, m_rotate_y_30)
m_rotate_z_30 = [
@@ -2173,12 +2145,12 @@ class TestMatrix(DirectApiTestCase):
[0, 0, 0, 1],
]
mz = Matrix()
- mz.rotate(Axis.Z, 30 * DEG2RAD)
+ mz.rotate(Axis.Z, math.radians(30))
matrix_almost_equal(mz, m_rotate_z_30)
# Test matrix multiply vector
v = Vector(1, 0, 0)
- self.assertVectorAlmostEquals(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7)
+ self.assertAlmostEqual(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7)
# Test matrix multiply matrix
m_rotate_xy_30 = [
@@ -2221,11 +2193,11 @@ class TestMatrix(DirectApiTestCase):
matrix_almost_equal(m3, rot_x_matrix)
-class TestMixin1D(DirectApiTestCase):
+class TestMixin1D(unittest.TestCase):
"""Test the add in methods"""
def test_position_at(self):
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5),
(0.5, 0.5, 0.5),
5,
@@ -2239,12 +2211,12 @@ class TestMixin1D(DirectApiTestCase):
self.assertTrue(all([0.0 < v < 1.0 for v in point]))
wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))])
- self.assertVectorAlmostEquals(wire.position_at(0.3), (3, 0, 0), 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(wire.position_at(0.3), (3, 0, 0), 5)
+ self.assertAlmostEqual(
wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
)
- self.assertVectorAlmostEquals(wire.edge().position_at(0.3), (3, 0, 0), 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(wire.edge().position_at(0.3), (3, 0, 0), 5)
+ self.assertAlmostEqual(
wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
)
@@ -2256,16 +2228,16 @@ class TestMixin1D(DirectApiTestCase):
)
p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH)
p2 = circle_wire.position_at(math.pi / circle_wire.length)
- self.assertVectorAlmostEquals(p1, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p2, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p1, p2, 14)
+ self.assertAlmostEqual(p1, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p2, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p1, p2, 14)
circle_edge = Edge.make_circle(1)
p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH)
p4 = circle_edge.position_at(math.pi / circle_edge.length)
- self.assertVectorAlmostEquals(p3, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p4, (-1, 0, 0), 14)
- self.assertVectorAlmostEquals(p3, p4, 14)
+ self.assertAlmostEqual(p3, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p4, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p3, p4, 14)
circle = Wire(
[
@@ -2273,12 +2245,12 @@ class TestMixin1D(DirectApiTestCase):
Edge.make_circle(2, start_angle=180, end_angle=360),
]
)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
circle.position_at(0.5),
(-2, 0, 0),
5,
)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH),
(-2, 0, 0),
5,
@@ -2289,10 +2261,10 @@ class TestMixin1D(DirectApiTestCase):
distances = [i / 4 for i in range(3)]
pts = e.positions(distances)
for i, position in enumerate(pts):
- self.assertVectorAlmostEquals(position, (i / 4, i / 4, i / 4), 5)
+ self.assertAlmostEqual(position, (i / 4, i / 4, i / 4), 5)
def test_tangent_at(self):
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0),
(-1, 0, 0),
5,
@@ -2304,7 +2276,7 @@ class TestMixin1D(DirectApiTestCase):
)
self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent]))
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at(
math.pi / 2, position_mode=PositionMode.LENGTH
),
@@ -2321,26 +2293,26 @@ class TestMixin1D(DirectApiTestCase):
)
pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4))
tan = circle.tangent_at(pnt_on_circle)
- self.assertVectorAlmostEquals(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5)
+ self.assertAlmostEqual(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5)
def test_tangent_at_by_length(self):
circle = Edge.make_circle(1)
tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH)
- self.assertVectorAlmostEquals(tan, (0, -1), 5)
+ self.assertAlmostEqual(tan, (0, -1), 5)
def test_tangent_at_error(self):
with self.assertRaises(ValueError):
Edge.make_circle(1).tangent_at("start")
def test_normal(self):
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Edge.make_circle(
1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60
).normal(),
(1, 0, 0),
5,
)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Edge.make_ellipse(
1,
0.5,
@@ -2351,7 +2323,7 @@ class TestMixin1D(DirectApiTestCase):
(math.sqrt(2) / 2, math.sqrt(2) / 2, 0),
5,
)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Edge.make_spline(
[
(1, 0),
@@ -2368,43 +2340,43 @@ class TestMixin1D(DirectApiTestCase):
def test_center(self):
c = Edge.make_circle(1, start_angle=0, end_angle=180)
- self.assertVectorAlmostEquals(c.center(), (0, 1, 0), 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(c.center(), (0, 1, 0), 5)
+ self.assertAlmostEqual(
c.center(CenterOf.MASS),
(0, 0.6366197723675814, 0),
5,
)
- self.assertVectorAlmostEquals(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5)
+ self.assertAlmostEqual(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5)
def test_location_at(self):
loc = Edge.make_circle(1).location_at(0.25)
- self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5)
+ self.assertAlmostEqual(loc.position, (0, 1, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5)
loc = Edge.make_circle(1).location_at(
math.pi / 2, position_mode=PositionMode.LENGTH
)
- self.assertVectorAlmostEquals(loc.position, (0, 1, 0), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, -90, -90), 5)
+ self.assertAlmostEqual(loc.position, (0, 1, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5)
def test_locations(self):
locs = Edge.make_circle(1).locations([i / 4 for i in range(4)])
- self.assertVectorAlmostEquals(locs[0].position, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[0].orientation, (-90, 0, -180), 5)
- self.assertVectorAlmostEquals(locs[1].position, (0, 1, 0), 5)
- self.assertVectorAlmostEquals(locs[1].orientation, (0, -90, -90), 5)
- self.assertVectorAlmostEquals(locs[2].position, (-1, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[2].orientation, (90, 0, 0), 5)
- self.assertVectorAlmostEquals(locs[3].position, (0, -1, 0), 5)
- self.assertVectorAlmostEquals(locs[3].orientation, (0, 90, 90), 5)
+ self.assertAlmostEqual(locs[0].position, (1, 0, 0), 5)
+ self.assertAlmostEqual(locs[0].orientation, (-90, 0, -180), 5)
+ self.assertAlmostEqual(locs[1].position, (0, 1, 0), 5)
+ self.assertAlmostEqual(locs[1].orientation, (0, -90, -90), 5)
+ self.assertAlmostEqual(locs[2].position, (-1, 0, 0), 5)
+ self.assertAlmostEqual(locs[2].orientation, (90, 0, 0), 5)
+ self.assertAlmostEqual(locs[3].position, (0, -1, 0), 5)
+ self.assertAlmostEqual(locs[3].orientation, (0, 90, 90), 5)
def test_project(self):
target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0)))
circle = Edge.make_circle(1).locate(Location((0, 0, 10)))
ellipse: Wire = circle.project(target, (0, 0, -1))
bbox = ellipse.bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5)
- self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5)
+ self.assertAlmostEqual(bbox.min, (-1, -1, -1), 5)
+ self.assertAlmostEqual(bbox.max, (1, 1, 1), 5)
def test_project2(self):
target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
@@ -2500,7 +2472,7 @@ class TestMixin1D(DirectApiTestCase):
self.assertAlmostEqual(wire.volume, 0, 5)
-class TestMixin3D(DirectApiTestCase):
+class TestMixin3D(unittest.TestCase):
"""Test that 3D add ins"""
def test_chamfer(self):
@@ -2609,14 +2581,14 @@ class TestMixin3D(DirectApiTestCase):
with self.assertRaises(ValueError):
Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX),
(0.5, 0.5, 0.5),
5,
)
-class TestPlane(DirectApiTestCase):
+class TestPlane(unittest.TestCase):
"""Plane with class properties"""
def test_class_properties(self):
@@ -2657,8 +2629,8 @@ class TestPlane(DirectApiTestCase):
),
]
for plane, x_dir, z_dir in planes:
- self.assertVectorAlmostEquals(plane.x_dir, x_dir, 5)
- self.assertVectorAlmostEquals(plane.z_dir, z_dir, 5)
+ self.assertAlmostEqual(plane.x_dir, x_dir, 5)
+ self.assertAlmostEqual(plane.z_dir, z_dir, 5)
def test_plane_init(self):
# from origin
@@ -2679,10 +2651,10 @@ class TestPlane(DirectApiTestCase):
Plane(origin=o, z_dir=z),
]
for p in planes:
- self.assertVectorAlmostEquals(p.origin, o, 6)
- self.assertVectorAlmostEquals(p.x_dir, x, 6)
- self.assertVectorAlmostEquals(p.y_dir, y, 6)
- self.assertVectorAlmostEquals(p.z_dir, z, 6)
+ self.assertAlmostEqual(p.origin, o, 6)
+ self.assertAlmostEqual(p.x_dir, x, 6)
+ self.assertAlmostEqual(p.y_dir, y, 6)
+ self.assertAlmostEqual(p.z_dir, z, 6)
with self.assertRaises(TypeError):
Plane()
with self.assertRaises(TypeError):
@@ -2693,30 +2665,22 @@ class TestPlane(DirectApiTestCase):
p_from_loc = Plane(loc)
p_from_named_loc = Plane(location=loc)
for p in [p_from_loc, p_from_named_loc]:
- self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
- self.assertVectorAlmostEquals(
- p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(
- p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
- self.assertVectorAlmostEquals(loc.position, p.location.position, 6)
- self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6)
+ self.assertAlmostEqual(p.origin, (0, 0, 0), 6)
+ self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6)
+ self.assertAlmostEqual(loc.position, p.location.position, 6)
+ self.assertAlmostEqual(loc.orientation, p.location.orientation, 6)
# rotated location around x and origin <> (0,0,0)
loc = Location((0, 2, -1), (45, 0, 0))
p = Plane(loc)
- self.assertVectorAlmostEquals(p.origin, (0, 2, -1), 6)
- self.assertVectorAlmostEquals(p.x_dir, (1, 0, 0), 6)
- self.assertVectorAlmostEquals(
- p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(
- p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(loc.position, p.location.position, 6)
- self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6)
+ self.assertAlmostEqual(p.origin, (0, 2, -1), 6)
+ self.assertAlmostEqual(p.x_dir, (1, 0, 0), 6)
+ self.assertAlmostEqual(p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(loc.position, p.location.position, 6)
+ self.assertAlmostEqual(loc.orientation, p.location.orientation, 6)
# from a face
f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45)))
@@ -2725,16 +2689,12 @@ class TestPlane(DirectApiTestCase):
plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped)
p_deep_copy = copy.deepcopy(p_from_face)
for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]:
- self.assertVectorAlmostEquals(p.origin, (1, 2, 3), 6)
- self.assertVectorAlmostEquals(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
- self.assertVectorAlmostEquals(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
- self.assertVectorAlmostEquals(
- p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(f.location.position, p.location.position, 6)
- self.assertVectorAlmostEquals(
- f.location.orientation, p.location.orientation, 6
- )
+ self.assertAlmostEqual(p.origin, (1, 2, 3), 6)
+ self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
+ self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
+ self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(f.location.position, p.location.position, 6)
+ self.assertAlmostEqual(f.location.orientation, p.location.orientation, 6)
# from a face with x_dir
f = Face.make_rect(1, 2)
@@ -2746,10 +2706,10 @@ class TestPlane(DirectApiTestCase):
Plane(face=f, x_dir=x),
]
for p in planes:
- self.assertVectorAlmostEquals(p.origin, (0, 0, 0), 6)
- self.assertVectorAlmostEquals(p.x_dir, Vector(x).normalized(), 6)
- self.assertVectorAlmostEquals(p.y_dir, Vector(y).normalized(), 6)
- self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6)
+ self.assertAlmostEqual(p.origin, (0, 0, 0), 6)
+ self.assertAlmostEqual(p.x_dir, Vector(x).normalized(), 6)
+ self.assertAlmostEqual(p.y_dir, Vector(y).normalized(), 6)
+ self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6)
with self.assertRaises(TypeError):
Plane(Edge.make_line((0, 0), (0, 1)))
@@ -2782,10 +2742,10 @@ class TestPlane(DirectApiTestCase):
p = Plane(f)
f_props = GProp_GProps()
BRepGProp.SurfaceProperties_s(f.wrapped, f_props)
- self.assertVectorAlmostEquals(p.origin, f_props.CentreOfMass(), 6)
- self.assertVectorAlmostEquals(p.x_dir, expected[i][0], 6)
- self.assertVectorAlmostEquals(p.y_dir, expected[i][1], 6)
- self.assertVectorAlmostEquals(p.z_dir, expected[i][2], 6)
+ self.assertAlmostEqual(p.origin, Vector(f_props.CentreOfMass()), 6)
+ self.assertAlmostEqual(p.x_dir, expected[i][0], 6)
+ self.assertAlmostEqual(p.y_dir, expected[i][1], 6)
+ self.assertAlmostEqual(p.z_dir, expected[i][2], 6)
def test_plane_neg(self):
p = Plane(
@@ -2794,51 +2754,35 @@ class TestPlane(DirectApiTestCase):
z_dir=Vector(4, 5, 6).normalized(),
)
p2 = -p
- self.assertVectorAlmostEquals(p2.origin, p.origin, 6)
- self.assertVectorAlmostEquals(p2.x_dir, p.x_dir, 6)
- self.assertVectorAlmostEquals(p2.z_dir, -p.z_dir, 6)
- self.assertVectorAlmostEquals(
- p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6
- )
+ self.assertAlmostEqual(p2.origin, p.origin, 6)
+ self.assertAlmostEqual(p2.x_dir, p.x_dir, 6)
+ self.assertAlmostEqual(p2.z_dir, -p.z_dir, 6)
+ self.assertAlmostEqual(p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6)
p3 = p.reverse()
- self.assertVectorAlmostEquals(p3.origin, p.origin, 6)
- self.assertVectorAlmostEquals(p3.x_dir, p.x_dir, 6)
- self.assertVectorAlmostEquals(p3.z_dir, -p.z_dir, 6)
- self.assertVectorAlmostEquals(
- p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6
- )
+ self.assertAlmostEqual(p3.origin, p.origin, 6)
+ self.assertAlmostEqual(p3.x_dir, p.x_dir, 6)
+ self.assertAlmostEqual(p3.z_dir, -p.z_dir, 6)
+ self.assertAlmostEqual(p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6)
def test_plane_mul(self):
p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1))
p2 = p * Location((1, 2, -1), (0, 0, 45))
- self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
- self.assertVectorAlmostEquals(
- p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(
- p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6
- )
- self.assertVectorAlmostEquals(p2.z_dir, (0, 0, 1), 6)
+ self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
+ self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p2.z_dir, (0, 0, 1), 6)
p2 = p * Location((1, 2, -1), (0, 45, 0))
- self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
- self.assertVectorAlmostEquals(
- p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(p2.y_dir, (0, 1, 0), 6)
- self.assertVectorAlmostEquals(
- p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6
- )
+ self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
+ self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(p2.y_dir, (0, 1, 0), 6)
+ self.assertAlmostEqual(p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6)
p2 = p * Location((1, 2, -1), (45, 0, 0))
- self.assertVectorAlmostEquals(p2.origin, (2, 4, 2), 6)
- self.assertVectorAlmostEquals(p2.x_dir, (1, 0, 0), 6)
- self.assertVectorAlmostEquals(
- p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
- self.assertVectorAlmostEquals(
- p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6
- )
+ self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
+ self.assertAlmostEqual(p2.x_dir, (1, 0, 0), 6)
+ self.assertAlmostEqual(p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
with self.assertRaises(TypeError):
p2 * Vector(1, 1, 1)
@@ -2862,11 +2806,11 @@ class TestPlane(DirectApiTestCase):
(1, 0, 1),
]
for i, target_point in enumerate(target_vertices):
- self.assertTupleAlmostEquals(target_point, local_box_vertices[i], 7)
+ np.testing.assert_allclose(target_point, local_box_vertices[i], 1e-7)
def test_localize_vertex(self):
vertex = Vertex(random.random(), random.random(), random.random())
- self.assertTupleAlmostEquals(
+ np.testing.assert_allclose(
Plane.YZ.to_local_coords(vertex).to_tuple(),
Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(),
5,
@@ -2938,12 +2882,12 @@ class TestPlane(DirectApiTestCase):
def test_move(self):
pln = Plane.XY.move(Location((1, 2, 3)))
- self.assertVectorAlmostEquals(pln.origin, (1, 2, 3), 5)
+ self.assertAlmostEqual(pln.origin, (1, 2, 3), 5)
def test_rotated(self):
rotated_plane = Plane.XY.rotated((45, 0, 0))
- self.assertVectorAlmostEquals(rotated_plane.x_dir, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(rotated_plane.x_dir, (1, 0, 0), 5)
+ self.assertAlmostEqual(
rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5
)
@@ -3002,11 +2946,11 @@ class TestPlane(DirectApiTestCase):
def test_to_location(self):
loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location
- self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(loc.orientation, (0, 0, 90), 5)
+ self.assertAlmostEqual(loc.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(loc.orientation, (0, 0, 90), 5)
def test_intersect(self):
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5
)
self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0))))
@@ -3049,7 +2993,7 @@ class TestPlane(DirectApiTestCase):
i = Plane.XY & Vector(1, 2)
self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, (1, 2, 0), 5)
+ self.assertAlmostEqual(i, (1, 2, 0), 5)
a = Axis((0, 0, 0), (1, 1, 0))
i = Plane.XY & a
@@ -3059,16 +3003,16 @@ class TestPlane(DirectApiTestCase):
a = Axis((1, 2, -1), (0, 0, 1))
i = Plane.XY & a
self.assertTrue(isinstance(i, Vector))
- self.assertVectorAlmostEquals(i, Vector(1, 2, 0), 5)
+ self.assertAlmostEqual(i, Vector(1, 2, 0), 5)
def test_plane_origin_setter(self):
pln = Plane.XY
pln.origin = (1, 2, 3)
ocp_origin = Vector(pln.wrapped.Location())
- self.assertVectorAlmostEquals(ocp_origin, (1, 2, 3), 5)
+ self.assertAlmostEqual(ocp_origin, (1, 2, 3), 5)
-class TestProjection(DirectApiTestCase):
+class TestProjection(unittest.TestCase):
def test_flat_projection(self):
sphere = Solid.make_sphere(50)
projection_direction = Vector(0, -1, 0)
@@ -3088,11 +3032,11 @@ class TestProjection(DirectApiTestCase):
circle = Wire.make_circle(3, Plane.XY.offset(10))
projection = circle.project_to_shape(target, (0, 0, -1))
bbox = projection[0].bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-3, -3, 1), 2)
- self.assertVectorAlmostEquals(bbox.max, (3, 3, 2), 2)
+ self.assertAlmostEqual(bbox.min, (-3, -3, 1), 2)
+ self.assertAlmostEqual(bbox.max, (3, 3, 2), 2)
bbox = projection[1].bounding_box()
- self.assertVectorAlmostEquals(bbox.min, (-3, -3, -2), 2)
- self.assertVectorAlmostEquals(bbox.max, (3, 3, -2), 2)
+ self.assertAlmostEqual(bbox.min, (-3, -3, -2), 2)
+ self.assertAlmostEqual(bbox.max, (3, 3, -2), 2)
def test_text_projection(self):
sphere = Solid.make_sphere(50)
@@ -3124,38 +3068,38 @@ class TestProjection(DirectApiTestCase):
projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape(
Solid.make_box(1, 1, 1), (0, 0, 1)
)
- self.assertVectorAlmostEquals(projection[0].position_at(1), (1, 0, 0), 5)
- self.assertVectorAlmostEquals(projection[0].position_at(0), (0, 1, 0), 5)
- self.assertVectorAlmostEquals(projection[0].arc_center, (0, 0, 0), 5)
+ self.assertAlmostEqual(projection[0].position_at(1), (1, 0, 0), 5)
+ self.assertAlmostEqual(projection[0].position_at(0), (0, 1, 0), 5)
+ self.assertAlmostEqual(projection[0].arc_center, (0, 0, 0), 5)
def test_to_axis(self):
with self.assertRaises(ValueError):
Edge.make_circle(1, end_angle=30).to_axis()
-class TestRotation(DirectApiTestCase):
+class TestRotation(unittest.TestCase):
def test_rotation_parameters(self):
r = Rotation(10, 20, 30)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation(10, 20, Z=30)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation(10, Y=20, Z=30)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation((10, 20, 30))
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation(10, 20, 30, Intrinsic.XYZ)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation((30, 20, 10), Extrinsic.ZYX)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX)
- self.assertVectorAlmostEquals(r.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
with self.assertRaises(TypeError):
Rotation(x=10)
-class TestShape(DirectApiTestCase):
+class TestShape(unittest.TestCase):
"""Misc Shape tests"""
def test_mirror(self):
@@ -3175,14 +3119,14 @@ class TestShape(DirectApiTestCase):
def test_combined_center(self):
objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]]
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Shape.combined_center(objs, center_of=CenterOf.MASS),
(0, 0.5, 0.5),
5,
)
objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]]
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX),
(-0.5, 0, 0),
5,
@@ -3393,23 +3337,23 @@ class TestShape(DirectApiTestCase):
def test_position_and_orientation(self):
box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30)))
- self.assertVectorAlmostEquals(box.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(box.orientation, (10, 20, 30), 5)
+ self.assertAlmostEqual(box.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(box.orientation, (10, 20, 30), 5)
def test_distance_to_with_closest_points(self):
s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0)))
s1 = Solid.make_sphere(1)
distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1)
self.assertAlmostEqual(distance, 0.1, 5)
- self.assertVectorAlmostEquals(pnt0, (0, 1.1, 0), 5)
- self.assertVectorAlmostEquals(pnt1, (0, 1, 0), 5)
+ self.assertAlmostEqual(pnt0, (0, 1.1, 0), 5)
+ self.assertAlmostEqual(pnt1, (0, 1, 0), 5)
def test_closest_points(self):
c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
c1 = Edge.make_circle(1)
closest = c0.closest_points(c1)
- self.assertVectorAlmostEquals(closest[0], c0.position_at(0.75).to_tuple(), 5)
- self.assertVectorAlmostEquals(closest[1], c1.position_at(0.25).to_tuple(), 5)
+ self.assertAlmostEqual(closest[0], c0.position_at(0.75).to_tuple(), 5)
+ self.assertAlmostEqual(closest[1], c1.position_at(0.25).to_tuple(), 5)
def test_distance_to(self):
c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
@@ -3422,8 +3366,8 @@ class TestShape(DirectApiTestCase):
intersections = (
box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z)
)
- self.assertVectorAlmostEquals(intersections[0], (0.5, 0.5, 0), 5)
- self.assertVectorAlmostEquals(intersections[1], (0.5, 0.5, 1), 5)
+ self.assertAlmostEqual(Vector(intersections[0]), (0.5, 0.5, 0), 5)
+ self.assertAlmostEqual(Vector(intersections[1]), (0.5, 0.5, 1), 5)
def test_clean_error(self):
"""Note that this test is here to alert build123d to changes in bad OCCT clean behavior
@@ -3449,8 +3393,8 @@ class TestShape(DirectApiTestCase):
bbox1 = box.bounding_box()
bbox2 = box_with_hole.bounding_box()
- self.assertVectorAlmostEquals(bbox1.min, bbox2.min, 5)
- self.assertVectorAlmostEquals(bbox1.max, bbox2.max, 5)
+ self.assertAlmostEqual(bbox1.min, bbox2.min, 5)
+ self.assertAlmostEqual(bbox1.max, bbox2.max, 5)
def test_project_to_viewport(self):
# Basic test
@@ -3544,8 +3488,8 @@ class TestShape(DirectApiTestCase):
a = Compound(children=[b, c])
a.color = Color(0, 1, 0)
# Check that assigned colors stay and iheritance works
- self.assertTupleAlmostEquals(tuple(a.color), (0, 1, 0, 1), 5)
- self.assertTupleAlmostEquals(tuple(b.color), (0, 0, 1, 1), 5)
+ np.testing.assert_allclose(tuple(a.color), (0, 1, 0, 1), 1e-5)
+ np.testing.assert_allclose(tuple(b.color), (0, 0, 1, 1), 1e-5)
def test_ocp_section(self):
# Vertex
@@ -3558,11 +3502,11 @@ class TestShape(DirectApiTestCase):
self.assertListEqual(edges, [])
verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5))
- self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5)
+ np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5)
self.assertListEqual(edges, [])
verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY))
- self.assertTupleAlmostEquals(tuple(verts[0]), (1, 2, 0), 5)
+ np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5)
self.assertListEqual(edges, [])
# spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5))
@@ -3706,7 +3650,7 @@ class TestShape(DirectApiTestCase):
self.assertIsNone(Vertex(1, 1, 1).compound())
-class TestShapeList(DirectApiTestCase):
+class TestShapeList(unittest.TestCase):
"""Test ShapeList functionality"""
def assertDunderStrEqual(self, actual: str, expected_lines: list[str]):
@@ -3758,8 +3702,8 @@ class TestShapeList(DirectApiTestCase):
vertices = (
Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1)))
)
- self.assertVectorAlmostEquals(vertices.last, (1, 1, 1), 5)
- self.assertVectorAlmostEquals(vertices.first, (0, 0, 0), 5)
+ self.assertAlmostEqual(Vector(vertices.last), (1, 1, 1), 5)
+ self.assertAlmostEqual(Vector(vertices.first), (0, 0, 0), 5)
def test_group_by(self):
vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
@@ -3780,7 +3724,7 @@ class TestShapeList(DirectApiTestCase):
self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5)
vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE)
- self.assertVectorAlmostEquals(vertices[-1][0], (1, 1, 1), 5)
+ self.assertAlmostEqual(Vector(vertices[-1][0]), (1, 1, 1), 5)
box = Solid.make_box(1, 1, 2)
self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2)
@@ -3794,8 +3738,8 @@ class TestShapeList(DirectApiTestCase):
self.assertEqual(len(vertices_by_line[3]), 1)
self.assertEqual(len(vertices_by_line[4]), 2)
self.assertEqual(len(vertices_by_line[5]), 1)
- self.assertVectorAlmostEquals(vertices_by_line[0][0], (0, 0, 0), 5)
- self.assertVectorAlmostEquals(vertices_by_line[-1][0], (1, 1, 2), 5)
+ self.assertAlmostEqual(Vector(vertices_by_line[0][0]), (0, 0, 0), 5)
+ self.assertAlmostEqual(Vector(vertices_by_line[-1][0]), (1, 1, 2), 5)
with BuildPart() as boxes:
with GridLocations(10, 10, 3, 3):
@@ -3912,7 +3856,7 @@ class TestShapeList(DirectApiTestCase):
def test_vertex(self):
sl = ShapeList([Edge.make_circle(1)])
- self.assertTupleAlmostEquals(sl.vertex().to_tuple(), (1, 0, 0), 5)
+ np.testing.assert_allclose(sl.vertex().to_tuple(), (1, 0, 0), 1e-5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
sl.vertex()
@@ -4013,7 +3957,7 @@ class TestShapeList(DirectApiTestCase):
)
-class TestShells(DirectApiTestCase):
+class TestShells(unittest.TestCase):
def test_shell_init(self):
box_faces = Solid.make_box(1, 1, 1).faces()
box_shell = Shell(box_faces)
@@ -4022,7 +3966,7 @@ class TestShells(DirectApiTestCase):
def test_center(self):
box_faces = Solid.make_box(1, 1, 1).faces()
box_shell = Shell(box_faces)
- self.assertVectorAlmostEquals(box_shell.center(), (0.5, 0.5, 0.5), 5)
+ self.assertAlmostEqual(box_shell.center(), (0.5, 0.5, 0.5), 5)
def test_manifold_shell_volume(self):
box_faces = Solid.make_box(1, 1, 1).faces()
@@ -4088,7 +4032,7 @@ class TestShells(DirectApiTestCase):
self.assertAlmostEqual(thick.volume, outer_vol - inner_vol)
-class TestSolid(DirectApiTestCase):
+class TestSolid(unittest.TestCase):
def test_make_solid(self):
box_faces = Solid.make_box(1, 1, 1).faces()
box_shell = Shell(box_faces)
@@ -4147,15 +4091,11 @@ class TestSolid(DirectApiTestCase):
bbox = taper_solid.bounding_box()
size = max(1, b) / 2
if direction.Z > 0:
- self.assertVectorAlmostEquals(
- bbox.min, (-size, -size, 0), 1
- )
- self.assertVectorAlmostEquals(bbox.max, (size, size, h), 1)
+ self.assertAlmostEqual(bbox.min, (-size, -size, 0), 1)
+ self.assertAlmostEqual(bbox.max, (size, size, h), 1)
else:
- self.assertVectorAlmostEquals(
- bbox.min, (-size, -size, -h), 1
- )
- self.assertVectorAlmostEquals(bbox.max, (size, size, 0), 1)
+ self.assertAlmostEqual(bbox.min, (-size, -size, -h), 1)
+ self.assertAlmostEqual(bbox.max, (size, size, 0), 1)
def test_extrude_taper_with_hole(self):
rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
@@ -4323,7 +4263,7 @@ class TestSkipClean(unittest.TestCase):
self.assertTrue(SkipClean.clean)
-class TestVector(DirectApiTestCase):
+class TestVector(unittest.TestCase):
"""Test the Vector methods"""
def test_vector_constructors(self):
@@ -4336,7 +4276,7 @@ class TestVector(DirectApiTestCase):
v5c = Vector(v=gp_XYZ(1, 2, 3))
for v in [v1, v2, v3, v4, v5, v5b, v5c]:
- self.assertVectorAlmostEquals(v, (1, 2, 3), 4)
+ self.assertAlmostEqual(v, (1, 2, 3), 4)
v6 = Vector((1, 2))
v7 = Vector([1, 2])
@@ -4344,26 +4284,26 @@ class TestVector(DirectApiTestCase):
v8b = Vector(X=1, Y=2)
for v in [v6, v7, v8, v8b]:
- self.assertVectorAlmostEquals(v, (1, 2, 0), 4)
+ self.assertAlmostEqual(v, (1, 2, 0), 4)
v9 = Vector()
- self.assertVectorAlmostEquals(v9, (0, 0, 0), 4)
+ self.assertAlmostEqual(v9, (0, 0, 0), 4)
v9.X = 1.0
v9.Y = 2.0
v9.Z = 3.0
- self.assertVectorAlmostEquals(v9, (1, 2, 3), 4)
- self.assertVectorAlmostEquals(Vector(1, 2, 3, 4), (1, 2, 3), 4)
+ self.assertAlmostEqual(v9, (1, 2, 3), 4)
+ self.assertAlmostEqual(Vector(1, 2, 3, 4), (1, 2, 3), 4)
v10 = Vector(1)
v11 = Vector((1,))
v12 = Vector([1])
v13 = Vector(X=1)
for v in [v10, v11, v12, v13]:
- self.assertVectorAlmostEquals(v, (1, 0, 0), 4)
+ self.assertAlmostEqual(v, (1, 0, 0), 4)
vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10))
- self.assertVectorAlmostEquals(Vector(vertex), (0, 0, 10), 4)
+ self.assertAlmostEqual(Vector(vertex), (0, 0, 10), 4)
with self.assertRaises(TypeError):
Vector("vector")
@@ -4375,11 +4315,9 @@ class TestVector(DirectApiTestCase):
vector_x = Vector(1, 0, 1).rotate(Axis.X, 45)
vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45)
vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45)
- self.assertVectorAlmostEquals(
- vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7
- )
- self.assertVectorAlmostEquals(vector_y, (math.sqrt(2), 2, 0), 7)
- self.assertVectorAlmostEquals(vector_z, (0, -math.sqrt(2), 3), 7)
+ self.assertAlmostEqual(vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7)
+ self.assertAlmostEqual(vector_y, (math.sqrt(2), 2, 0), 7)
+ self.assertAlmostEqual(vector_z, (0, -math.sqrt(2), 3), 7)
def test_get_signed_angle(self):
"""Verify getSignedAngle calculations with and without a provided normal"""
@@ -4402,7 +4340,7 @@ class TestVector(DirectApiTestCase):
def test_vector_add(self):
result = Vector(1, 2, 0) + Vector(0, 0, 3)
- self.assertVectorAlmostEquals(result, (1.0, 2.0, 3.0), 3)
+ self.assertAlmostEqual(result, (1.0, 2.0, 3.0), 3)
def test_vector_operators(self):
result = Vector(1, 1, 1) + Vector(2, 2, 2)
@@ -4472,16 +4410,16 @@ class TestVector(DirectApiTestCase):
# test passing Plane object
point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir))
- self.assertVectorAlmostEquals(point, (59 / 7, 55 / 7, 51 / 7), decimal_places)
+ self.assertAlmostEqual(point, (59 / 7, 55 / 7, 51 / 7), decimal_places)
# test line projection
vec = Vector(10, 10, 10)
line = Vector(3, 4, 5)
- angle = vec.get_angle(line) * DEG2RAD
+ angle = math.radians(vec.get_angle(line))
vecLineProjection = vec.project_to_line(line)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
vecLineProjection.normalized(),
line.normalized(),
decimal_places,
@@ -4505,18 +4443,18 @@ class TestVector(DirectApiTestCase):
self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6)
def test_reverse(self):
- self.assertVectorAlmostEquals(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7)
+ self.assertAlmostEqual(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7)
def test_copy(self):
v2 = copy.copy(Vector(1, 2, 3))
v3 = copy.deepcopy(Vector(1, 2, 3))
- self.assertVectorAlmostEquals(v2, (1, 2, 3), 7)
- self.assertVectorAlmostEquals(v3, (1, 2, 3), 7)
+ self.assertAlmostEqual(v2, (1, 2, 3), 7)
+ self.assertAlmostEqual(v3, (1, 2, 3), 7)
def test_radd(self):
vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)]
vector_sum = sum(vectors)
- self.assertVectorAlmostEquals(vector_sum, (12, 15, 18), 5)
+ self.assertAlmostEqual(vector_sum, (12, 15, 18), 5)
def test_hash(self):
vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)]
@@ -4547,20 +4485,20 @@ class TestVector(DirectApiTestCase):
def test_intersect(self):
v1 = Vector(1, 2, 3)
- self.assertVectorAlmostEquals(v1 & Vector(1, 2, 3), (1, 2, 3), 5)
+ self.assertAlmostEqual(v1 & Vector(1, 2, 3), (1, 2, 3), 5)
self.assertIsNone(v1 & Vector(0, 0, 0))
- self.assertVectorAlmostEquals(v1 & Location((1, 2, 3)), (1, 2, 3), 5)
+ self.assertAlmostEqual(v1 & Location((1, 2, 3)), (1, 2, 3), 5)
self.assertIsNone(v1 & Location())
- self.assertVectorAlmostEquals(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5)
+ self.assertAlmostEqual(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5)
self.assertIsNone(v1 & Axis.X)
- self.assertVectorAlmostEquals(v1 & Plane((1, 2, 3)), (1, 2, 3), 5)
+ self.assertAlmostEqual(v1 & Plane((1, 2, 3)), (1, 2, 3), 5)
self.assertIsNone(v1 & Plane.XY)
- self.assertVectorAlmostEquals(
- (v1 & Solid.make_box(2, 4, 5)).vertex(), (1, 2, 3), 5
+ self.assertAlmostEqual(
+ Vector((v1 & Solid.make_box(2, 4, 5)).vertex()), (1, 2, 3), 5
)
self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)))
self.assertIsNone(
@@ -4568,26 +4506,26 @@ class TestVector(DirectApiTestCase):
)
-class TestVectorLike(DirectApiTestCase):
+class TestVectorLike(unittest.TestCase):
"""Test typedef"""
def test_axis_from_vertex(self):
axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1))
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
def test_axis_from_vector(self):
axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1))
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
def test_axis_from_tuple(self):
axis = Axis((1, 2, 3), (0, 0, 1))
- self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 5)
- self.assertVectorAlmostEquals(axis.direction, (0, 0, 1), 5)
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
-class TestVertex(DirectApiTestCase):
+class TestVertex(unittest.TestCase):
"""Test the extensions to the cadquery Vertex class"""
def test_basic_vertex(self):
@@ -4598,10 +4536,10 @@ class TestVertex(DirectApiTestCase):
self.assertEqual(1, v.X)
self.assertEqual(Vector, type(v.center()))
- self.assertVectorAlmostEquals(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7)
- self.assertVectorAlmostEquals(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7)
- self.assertVectorAlmostEquals(Vector(Vertex((7,))), (7, 0, 0), 7)
- self.assertVectorAlmostEquals(Vector(Vertex((8, 9))), (8, 9, 0), 7)
+ self.assertAlmostEqual(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7)
+ self.assertAlmostEqual(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7)
+ self.assertAlmostEqual(Vector(Vertex((7,))), (7, 0, 0), 7)
+ self.assertAlmostEqual(Vector(Vertex((8, 9))), (8, 9, 0), 7)
def test_vertex_volume(self):
v = Vertex(1, 1, 1)
@@ -4609,13 +4547,11 @@ class TestVertex(DirectApiTestCase):
def test_vertex_add(self):
test_vertex = Vertex(0, 0, 0)
- self.assertVectorAlmostEquals(
- Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7
- )
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7)
+ self.assertAlmostEqual(
Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7
)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Vector(test_vertex + Vertex(100, -40, 10)),
(100, -40, 10),
7,
@@ -4625,13 +4561,11 @@ class TestVertex(DirectApiTestCase):
def test_vertex_sub(self):
test_vertex = Vertex(0, 0, 0)
- self.assertVectorAlmostEquals(
- Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7
- )
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7)
+ self.assertAlmostEqual(
Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7
)
- self.assertVectorAlmostEquals(
+ self.assertAlmostEqual(
Vector(test_vertex - Vertex(100, -40, 10)),
(-100, 40, -10),
7,
@@ -4644,7 +4578,7 @@ class TestVertex(DirectApiTestCase):
def test_vertex_to_vector(self):
self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector)
- self.assertVectorAlmostEquals(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7)
+ self.assertAlmostEqual(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7)
def test_vertex_init_error(self):
with self.assertRaises(TypeError):
@@ -4709,7 +4643,7 @@ class TestVTKPolyData(unittest.TestCase):
self.assertEqual(str(context.exception), "Cannot convert an empty shape")
-class TestWire(DirectApiTestCase):
+class TestWire(unittest.TestCase):
def test_ellipse_arc(self):
full_ellipse = Wire.make_ellipse(2, 1)
half_ellipse = Wire.make_ellipse(
@@ -4807,8 +4741,8 @@ class TestWire(DirectApiTestCase):
tangent_scalars=(2, 2),
)
half = spline.trim(0.5, 1)
- self.assertVectorAlmostEquals(spline @ 0.5, half @ 0, 4)
- self.assertVectorAlmostEquals(spline @ 1, half @ 1, 4)
+ self.assertAlmostEqual(spline @ 0.5, half @ 0, 4)
+ self.assertAlmostEqual(spline @ 1, half @ 1, 4)
w = Rectangle(3, 1).wire()
t5 = w.trim(0, 0.5)
@@ -4855,9 +4789,9 @@ class TestWire(DirectApiTestCase):
ordered_edges = w1.order_edges()
self.assertFalse(all(e.is_forward for e in w1.edges()))
self.assertTrue(all(e.is_forward for e in ordered_edges))
- self.assertVectorAlmostEquals(ordered_edges[0] @ 0, (0, 0, 0), 5)
- self.assertVectorAlmostEquals(ordered_edges[1] @ 0, (1, 0, 0), 5)
- self.assertVectorAlmostEquals(ordered_edges[2] @ 0, (1, 1, 0), 5)
+ self.assertAlmostEqual(ordered_edges[0] @ 0, (0, 0, 0), 5)
+ self.assertAlmostEqual(ordered_edges[1] @ 0, (1, 0, 0), 5)
+ self.assertAlmostEqual(ordered_edges[2] @ 0, (1, 1, 0), 5)
def test_constructor(self):
e0 = Edge.make_line((0, 0), (1, 0))
@@ -4878,7 +4812,7 @@ class TestWire(DirectApiTestCase):
w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red"))
self.assertTrue(w6.is_valid())
self.assertEqual(w6.label, "w6")
- self.assertTupleAlmostEquals(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 5)
+ np.testing.assert_allclose(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 1e-5)
w7 = Wire(w6)
self.assertTrue(w7.is_valid())
c0 = Polyline((0, 0), (1, 0), (1, 1))
diff --git a/tools/refactor_test_direct_api.py b/tools/refactor_test_direct_api.py
new file mode 100644
index 0000000..be201e9
--- /dev/null
+++ b/tools/refactor_test_direct_api.py
@@ -0,0 +1,346 @@
+"""
+
+name: refactor_test_direct_api.py
+by: Gumyr
+date: January 22, 2025
+
+Description:
+ This script automates the process of splitting a large test file into smaller,
+ more manageable test files based on class definitions. Each generated test file
+ includes necessary imports, an optional header with project and license information,
+ and the appropriate class definitions. Additionally, the script dynamically injects
+ shared utilities like the `AlwaysEqual` class only into files where they are needed.
+
+Features:
+ - Splits a large test file into separate files by test class.
+ - Adds a standardized header with project details and an Apache 2.0 license.
+ - Dynamically includes shared utilities like `AlwaysEqual` where required.
+ - Supports `unittest` compatibility by adding a `unittest.main()` block for direct execution.
+ - Ensures imports are cleaned and Python syntax is upgraded to modern standards using
+ `rope` and `pyupgrade`.
+
+Usage:
+ Run the script with the input file and output directory as arguments:
+ python refactor_test_direct_api.py
+
+Dependencies:
+ - libcst: For parsing and analyzing the test file structure.
+ - rope: For organizing and pruning unused imports.
+ - pyupgrade: For upgrading Python syntax to the latest standards.
+
+License:
+ Copyright 2025 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.
+
+"""
+
+from pathlib import Path
+import libcst as cst
+from libcst.metadata import PositionProvider, MetadataWrapper
+import os
+from rope.base.project import Project
+from rope.refactor.importutils import ImportOrganizer
+import subprocess
+from datetime import datetime
+
+
+class TestFileSplitter(cst.CSTVisitor):
+ METADATA_DEPENDENCIES = (PositionProvider,)
+
+ def __init__(self, module_content, output_dir):
+ self.module_content = module_content
+ self.output_dir = output_dir
+ self.current_class = None
+ self.current_class_code = []
+ self.global_imports = []
+
+ def visit_Import(self, node: cst.Import):
+ # Capture global import statements
+ self.global_imports.append(self._extract_code(node))
+
+ def visit_ImportFrom(self, node: cst.ImportFrom):
+ # Capture global import statements
+ self.global_imports.append(self._extract_code(node))
+
+ def visit_ClassDef(self, node: cst.ClassDef):
+ if self.current_class:
+ # Write the previous class to a file
+ self._write_class_file()
+
+ # Start collecting for the new class
+ self.current_class = node.name.value
+
+ # Get the start and end positions of the node
+ position = self.get_metadata(PositionProvider, node)
+ start = self._calculate_offset(position.start.line, position.start.column)
+ end = self._calculate_offset(position.end.line, position.end.column)
+
+ # Extract the source code for the class
+ class_code = self.module_content[start:end]
+ self.current_class_code = [class_code]
+
+ def leave_Module(self, original_node: cst.Module):
+ # Write the last class to a file
+ if self.current_class:
+ self._write_class_file()
+
+ def _write_class_file(self):
+ """
+ Write the current class to a file, including a header, ensuring no redundant 'test_' prefix,
+ and make the file executable when run directly.
+ """
+ # Determine the file name by converting the class name to snake_case
+ snake_case_name = self._convert_to_snake_case(self.current_class)
+
+ # Avoid redundant 'test_' prefix if it already exists
+ if snake_case_name.startswith("test_"):
+ filename = f"{snake_case_name}.py"
+ else:
+ filename = f"test_{snake_case_name}.py"
+
+ filepath = os.path.join(self.output_dir, filename)
+
+ # Generate the header with the current date and year
+ current_date = datetime.now().strftime("%B %d, %Y")
+ current_year = datetime.now().year
+ header = f'''
+"""
+build123d direct api tests
+
+name: {filename}
+by: Gumyr
+date: {current_date}
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright {current_year} 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.
+
+"""
+'''
+ # Define imports for base class and shared utilities
+ base_class_import = "from ..base_test import AlwaysEqual"
+
+ # Add the main block to run tests
+ main_block = """
+if __name__ == "__main__":
+ unittest.main()
+"""
+
+ # Write the header, imports, class definition, and main block
+ with open(filepath, "w") as f:
+ # Combine all parts into the file
+ f.write(header + "\n\n")
+ f.write("\n".join(self.global_imports) + "\n\n")
+ f.write(base_class_import + "\n\n")
+ f.write("\n".join(self.current_class_code) + "\n\n")
+ f.write(main_block)
+
+ # Prune unused imports and upgrade the code
+ self._prune_unused_imports(filepath)
+
+ def _write_class_file(self):
+ """
+ Write the current class to a file, including a header, ensuring no redundant 'test_' prefix,
+ and dynamically inject the AlwaysEqual class if used.
+ """
+ # Determine the file name by converting the class name to snake_case
+ snake_case_name = self._convert_to_snake_case(self.current_class)
+
+ # Avoid redundant 'test_' prefix if it already exists
+ if snake_case_name.startswith("test_"):
+ filename = f"{snake_case_name}.py"
+ else:
+ filename = f"test_{snake_case_name}.py"
+
+ filepath = os.path.join(self.output_dir, filename)
+
+ # Check if the current class code references AlwaysEqual
+ needs_always_equal = (
+ any("AlwaysEqual" in line for line in self.current_class_code)
+ and not filename == "test_always_equal.py"
+ )
+
+ # Generate the header with the current date and year
+ current_date = datetime.now().strftime("%B %d, %Y")
+ current_year = datetime.now().year
+ header = f'''
+"""
+build123d imports
+
+name: {filename}
+by: Gumyr
+date: {current_date}
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright {current_year} 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 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.
+
+"""
+'''
+
+ # Define the AlwaysEqual class if needed
+ always_equal_definition = (
+ """
+# Always equal to any other object, to test that __eq__ cooperation is working
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+"""
+ if needs_always_equal
+ else ""
+ )
+
+ # Add the main block to run tests
+ main_block = """
+if __name__ == "__main__":
+ unittest.main()
+"""
+
+ # Write the header, AlwaysEqual (if needed), imports, class definition, and main block
+ with open(filepath, "w") as f:
+ # Combine all parts into the file
+ f.write(header + "\n\n")
+ f.write(always_equal_definition + "\n\n")
+ f.write("\n".join(self.global_imports) + "\n\n")
+ f.write("\n".join(self.current_class_code) + "\n\n")
+ f.write(main_block)
+
+ # Prune unused imports and upgrade the code
+ self._prune_unused_imports(filepath)
+
+ def _convert_to_snake_case(self, name: str) -> str:
+ """
+ Convert a PascalCase or camelCase name to snake_case.
+ """
+ import re
+
+ name = re.sub(r"(? str:
+ """
+ Extract the source code of a given node using PositionProvider.
+ """
+ position = self.get_metadata(PositionProvider, node)
+ start = self._calculate_offset(position.start.line, position.start.column)
+ end = self._calculate_offset(position.end.line, position.end.column)
+ return self.module_content[start:end]
+
+ def _calculate_offset(self, line: int, column: int) -> int:
+ """
+ Calculate the byte offset in the source content based on line and column numbers.
+ """
+ lines = self.module_content.splitlines(keepends=True)
+ offset = sum(len(lines[i]) for i in range(line - 1)) + column
+ return offset
+
+ def _prune_unused_imports(self, filepath):
+ """
+ Wrapper for remove_unused_imports to clean unused imports in a file and upgrade the code.
+ """
+ # Initialize the Rope project
+ project = Project(self.output_dir)
+
+ # Use the shared function to remove unused imports
+ remove_unused_imports(Path(filepath), project)
+
+ # Run pyupgrade on the file to modernize the Python syntax
+ print(f"Upgrading Python syntax in {filepath} with pyupgrade...")
+ subprocess.run(["pyupgrade", "--py310-plus", str(filepath)])
+
+
+def remove_unused_imports(file_path: Path, project: Project) -> None:
+ """Remove unused imports from a Python file using Rope.
+
+ Args:
+ file_path: Path to the Python file to clean imports
+ project: Rope project instance to refresh and use for cleaning
+ """
+ # Get the relative file path from the project root
+ relative_path = file_path.relative_to(project.address)
+
+ # Refresh the project to recognize new files
+ project.validate()
+
+ # Get the resource (file) to work on
+ resource = project.get_resource(str(relative_path))
+
+ # Create import organizer
+ import_organizer = ImportOrganizer(project)
+
+ # Get and apply the changes
+ changes = import_organizer.organize_imports(resource)
+ if changes:
+ changes.do()
+ print(f"Cleaned imports in {file_path}")
+ subprocess.run(["black", str(file_path)])
+ else:
+ print(f"No unused imports found in {file_path}")
+
+
+def split_test_file(input_file, output_dir):
+ # Ensure the output directory exists
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Read the input file
+ with open(input_file, "r") as f:
+ content = f.read()
+
+ # Parse the file and wrap it with metadata
+ module = cst.parse_module(content)
+ wrapper = MetadataWrapper(module)
+
+ # Process the file
+ splitter = TestFileSplitter(module_content=content, output_dir=output_dir)
+ wrapper.visit(splitter)
+
+
+# Define paths
+script_dir = Path(__file__).parent
+test_direct_api_file = script_dir / ".." / "tests" / "test_direct_api.py"
+output_dir = script_dir / ".." / "tests" / "test_direct_api"
+test_direct_api_file = test_direct_api_file.resolve()
+output_dir = output_dir.resolve()
+
+split_test_file(test_direct_api_file, output_dir)
From e126c502b1f8de3565a37a416d11a117b736f7e5 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 22 Jan 2025 15:23:56 -0500
Subject: [PATCH 154/518] Adding separate direct api tests
---
tests/test_direct_api/test_always_equal.py | 38 ++
tests/test_direct_api/test_assembly.py | 125 ++++
tests/test_direct_api/test_axis.py | 212 ++++++
tests/test_direct_api/test_bound_box.py | 104 +++
tests/test_direct_api/test_cad_objects.py | 264 ++++++++
tests/test_direct_api/test_clean_method.py | 70 ++
tests/test_direct_api/test_color.py | 125 ++++
tests/test_direct_api/test_compound.py | 162 +++++
.../test_direct_api_test_case.py | 60 ++
tests/test_direct_api/test_edge.py | 289 ++++++++
tests/test_direct_api/test_face.py | 452 +++++++++++++
tests/test_direct_api/test_functions.py | 105 +++
tests/test_direct_api/test_group_by.py | 69 ++
tests/test_direct_api/test_import_export.py | 67 ++
tests/test_direct_api/test_jupyter.py | 57 ++
tests/test_direct_api/test_location.py | 400 +++++++++++
tests/test_direct_api/test_matrix.py | 194 ++++++
tests/test_direct_api/test_mixin1_d.py | 318 +++++++++
tests/test_direct_api/test_mixin3_d.py | 154 +++++
tests/test_direct_api/test_plane.py | 480 ++++++++++++++
tests/test_direct_api/test_projection.py | 103 +++
tests/test_direct_api/test_rotation.py | 58 ++
tests/test_direct_api/test_shape.py | 619 ++++++++++++++++++
tests/test_direct_api/test_shape_list.py | 368 +++++++++++
tests/test_direct_api/test_shells.py | 115 ++++
tests/test_direct_api/test_skip_clean.py | 68 ++
tests/test_direct_api/test_solid.py | 238 +++++++
tests/test_direct_api/test_vector.py | 288 ++++++++
tests/test_direct_api/test_vector_like.py | 55 ++
tests/test_direct_api/test_vertex.py | 104 +++
tests/test_direct_api/test_vtk_poly_data.py | 87 +++
tests/test_direct_api/test_wire.py | 221 +++++++
32 files changed, 6069 insertions(+)
create mode 100644 tests/test_direct_api/test_always_equal.py
create mode 100644 tests/test_direct_api/test_assembly.py
create mode 100644 tests/test_direct_api/test_axis.py
create mode 100644 tests/test_direct_api/test_bound_box.py
create mode 100644 tests/test_direct_api/test_cad_objects.py
create mode 100644 tests/test_direct_api/test_clean_method.py
create mode 100644 tests/test_direct_api/test_color.py
create mode 100644 tests/test_direct_api/test_compound.py
create mode 100644 tests/test_direct_api/test_direct_api_test_case.py
create mode 100644 tests/test_direct_api/test_edge.py
create mode 100644 tests/test_direct_api/test_face.py
create mode 100644 tests/test_direct_api/test_functions.py
create mode 100644 tests/test_direct_api/test_group_by.py
create mode 100644 tests/test_direct_api/test_import_export.py
create mode 100644 tests/test_direct_api/test_jupyter.py
create mode 100644 tests/test_direct_api/test_location.py
create mode 100644 tests/test_direct_api/test_matrix.py
create mode 100644 tests/test_direct_api/test_mixin1_d.py
create mode 100644 tests/test_direct_api/test_mixin3_d.py
create mode 100644 tests/test_direct_api/test_plane.py
create mode 100644 tests/test_direct_api/test_projection.py
create mode 100644 tests/test_direct_api/test_rotation.py
create mode 100644 tests/test_direct_api/test_shape.py
create mode 100644 tests/test_direct_api/test_shape_list.py
create mode 100644 tests/test_direct_api/test_shells.py
create mode 100644 tests/test_direct_api/test_skip_clean.py
create mode 100644 tests/test_direct_api/test_solid.py
create mode 100644 tests/test_direct_api/test_vector.py
create mode 100644 tests/test_direct_api/test_vector_like.py
create mode 100644 tests/test_direct_api/test_vertex.py
create mode 100644 tests/test_direct_api/test_vtk_poly_data.py
create mode 100644 tests/test_direct_api/test_wire.py
diff --git a/tests/test_direct_api/test_always_equal.py b/tests/test_direct_api/test_always_equal.py
new file mode 100644
index 0000000..ded5c9b
--- /dev/null
+++ b/tests/test_direct_api/test_always_equal.py
@@ -0,0 +1,38 @@
+"""
+build123d imports
+
+name: test_always_equal.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_assembly.py b/tests/test_direct_api/test_assembly.py
new file mode 100644
index 0000000..7ac43be
--- /dev/null
+++ b/tests/test_direct_api/test_assembly.py
@@ -0,0 +1,125 @@
+"""
+build123d imports
+
+name: test_assembly.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 re
+import unittest
+
+from build123d.topology import Compound, Solid
+
+
+class TestAssembly(unittest.TestCase):
+ @staticmethod
+ def create_test_assembly() -> Compound:
+ box = Solid.make_box(1, 1, 1)
+ box.orientation = (45, 45, 0)
+ box.label = "box"
+ sphere = Solid.make_sphere(1)
+ sphere.label = "sphere"
+ sphere.position = (1, 2, 3)
+ assembly = Compound(label="assembly", children=[box])
+ sphere.parent = assembly
+ return assembly
+
+ def assertTopoEqual(self, actual_topo: str, expected_topo_lines: list[str]):
+ actual_topo_lines = actual_topo.splitlines()
+ self.assertEqual(len(actual_topo_lines), len(expected_topo_lines))
+ for actual_line, expected_line in zip(actual_topo_lines, expected_topo_lines):
+ start, end = re.split(r"at 0x[0-9a-f]+,", expected_line, 2, re.I)
+ self.assertTrue(actual_line.startswith(start))
+ self.assertTrue(actual_line.endswith(end))
+
+ def test_attributes(self):
+ box = Solid.make_box(1, 1, 1)
+ box.label = "box"
+ sphere = Solid.make_sphere(1)
+ sphere.label = "sphere"
+ assembly = Compound(label="assembly", children=[box])
+ sphere.parent = assembly
+
+ self.assertEqual(len(box.children), 0)
+ self.assertEqual(box.label, "box")
+ self.assertEqual(box.parent, assembly)
+ self.assertEqual(sphere.parent, assembly)
+ self.assertEqual(len(assembly.children), 2)
+
+ def test_show_topology_compound(self):
+ assembly = TestAssembly.create_test_assembly()
+ expected = [
+ "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))",
+ "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))",
+ "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))",
+ ]
+ self.assertTopoEqual(assembly.show_topology("Solid"), expected)
+
+ def test_show_topology_shape_location(self):
+ assembly = TestAssembly.create_test_assembly()
+ expected = [
+ "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)",
+ "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)",
+ " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)",
+ ]
+ self.assertTopoEqual(
+ assembly.children[1].show_topology("Face", show_center=False), expected
+ )
+
+ def test_show_topology_shape(self):
+ assembly = TestAssembly.create_test_assembly()
+ expected = [
+ "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)",
+ "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)",
+ " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)",
+ ]
+ self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected)
+
+ def test_remove_child(self):
+ assembly = TestAssembly.create_test_assembly()
+ self.assertEqual(len(assembly.children), 2)
+ assembly.children = list(assembly.children)[1:]
+ self.assertEqual(len(assembly.children), 1)
+
+ def test_do_children_intersect(self):
+ (
+ overlap,
+ pair,
+ distance,
+ ) = TestAssembly.create_test_assembly().do_children_intersect()
+ self.assertFalse(overlap)
+ box = Solid.make_box(1, 1, 1)
+ box.orientation = (45, 45, 0)
+ box.label = "box"
+ sphere = Solid.make_sphere(1)
+ sphere.label = "sphere"
+ sphere.position = (0, 0, 0)
+ assembly = Compound(label="assembly", children=[box])
+ sphere.parent = assembly
+ overlap, pair, distance = assembly.do_children_intersect()
+ self.assertTrue(overlap)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py
new file mode 100644
index 0000000..658e273
--- /dev/null
+++ b/tests/test_direct_api/test_axis.py
@@ -0,0 +1,212 @@
+"""
+build123d imports
+
+name: test_axis.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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.
+
+"""
+
+# Always equal to any other object, to test that __eq__ cooperation is working
+import copy
+import unittest
+
+import numpy as np
+from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt
+from build123d.geometry import Axis, Location, Plane, Vector
+from build123d.topology import Edge
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+class TestAxis(unittest.TestCase):
+ """Test the Axis class"""
+
+ def test_axis_init(self):
+ test_axis = Axis((1, 2, 3), (0, 0, 1))
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis((1, 2, 3), direction=(0, 0, 1))
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1))
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4)))
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
+
+ test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4)))
+ self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
+
+ with self.assertRaises(ValueError):
+ Axis("one", "up")
+ with self.assertRaises(ValueError):
+ Axis(one="up")
+
+ def test_axis_from_occt(self):
+ occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0))
+ test_axis = Axis(occt_axis)
+ self.assertAlmostEqual(test_axis.position, (1, 1, 1), 5)
+ self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5)
+
+ def test_axis_repr_and_str(self):
+ self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))")
+ self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))")
+
+ def test_axis_copy(self):
+ x_copy = copy.copy(Axis.X)
+ self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5)
+ x_copy = copy.deepcopy(Axis.X)
+ self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5)
+
+ def test_axis_to_location(self):
+ # TODO: Verify this is correct
+ x_location = Axis.X.location
+ self.assertTrue(isinstance(x_location, Location))
+ self.assertAlmostEqual(x_location.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_location.orientation, (0, 90, 180), 5)
+
+ def test_axis_located(self):
+ y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0)))
+ self.assertAlmostEqual(y_axis.position, (0, 0, 1), 5)
+ self.assertAlmostEqual(y_axis.direction, (0, 1, 0), 5)
+
+ def test_axis_to_plane(self):
+ x_plane = Axis.X.to_plane()
+ self.assertTrue(isinstance(x_plane, Plane))
+ self.assertAlmostEqual(x_plane.origin, (0, 0, 0), 5)
+ self.assertAlmostEqual(x_plane.z_dir, (1, 0, 0), 5)
+
+ def test_axis_is_coaxial(self):
+ self.assertTrue(Axis.X.is_coaxial(Axis((0, 0, 0), (1, 0, 0))))
+ self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 1), (1, 0, 0))))
+ self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 0), (0, 1, 0))))
+
+ def test_axis_is_normal(self):
+ self.assertTrue(Axis.X.is_normal(Axis.Y))
+ self.assertFalse(Axis.X.is_normal(Axis.X))
+
+ def test_axis_is_opposite(self):
+ self.assertTrue(Axis.X.is_opposite(Axis((1, 1, 1), (-1, 0, 0))))
+ self.assertFalse(Axis.X.is_opposite(Axis.X))
+
+ def test_axis_is_parallel(self):
+ self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0))))
+ self.assertFalse(Axis.X.is_parallel(Axis.Y))
+
+ def test_axis_angle_between(self):
+ self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5)
+ self.assertAlmostEqual(
+ Axis.X.angle_between(Axis((1, 1, 1), (-1, 0, 0))), 180, 5
+ )
+
+ def test_axis_reverse(self):
+ self.assertAlmostEqual(Axis.X.reverse().direction, (-1, 0, 0), 5)
+
+ def test_axis_reverse_op(self):
+ axis = -Axis.X
+ self.assertAlmostEqual(axis.direction, (-1, 0, 0), 5)
+
+ def test_axis_as_edge(self):
+ edge = Edge(Axis.X)
+ self.assertTrue(isinstance(edge, Edge))
+ common = (edge & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
+ self.assertAlmostEqual(common.length, 1, 5)
+
+ def test_axis_intersect(self):
+ common = (Axis.X.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
+ self.assertAlmostEqual(common.length, 1, 5)
+
+ common = (Axis.X & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
+ self.assertAlmostEqual(common.length, 1, 5)
+
+ intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0))
+ self.assertAlmostEqual(intersection, (1, 0, 0), 5)
+
+ i = Axis.X & Axis((1, 0, 0), (1, 0, 0))
+ self.assertEqual(i, Axis.X)
+
+ intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY
+ self.assertAlmostEqual(intersection.to_tuple(), (1, 2, 0), 5)
+
+ arc = Edge.make_circle(20, start_angle=0, end_angle=180)
+ ax0 = Axis((-20, 30, 0), (4, -3, 0))
+ intersections = arc.intersect(ax0).vertices().sort_by(Axis.X)
+ np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5)
+ np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5)
+
+ intersections = ax0.intersect(arc).vertices().sort_by(Axis.X)
+ np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5)
+ np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5)
+
+ i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5)
+ self.assertTrue(isinstance(i, Vector))
+ self.assertAlmostEqual(i, (0.5, 0.5, 1.5), 5)
+ self.assertIsNone(Axis.Y & Vector(2, 0, 0))
+
+ l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1
+ i: Location = Axis.Z & l
+ self.assertTrue(isinstance(i, Location))
+ self.assertAlmostEqual(i.position, l.position, 5)
+ self.assertAlmostEqual(i.orientation, l.orientation, 5)
+
+ self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1))
+ self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1))
+
+ # TODO: uncomment when generalized edge to surface intersections are complete
+ # non_planar = (
+ # Solid.make_cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)
+ # )
+ # intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar
+
+ # self.assertTrue(len(intersections.vertices(), 2))
+ # np.testing.assert_allclose(
+ # intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5
+ # )
+ # np.testing.assert_allclose(
+ # intersection.vertices()[1].to_tuple(), (1, 0, 5), 5
+ # )
+
+ def test_axis_equal(self):
+ self.assertEqual(Axis.X, Axis.X)
+ self.assertEqual(Axis.Y, Axis.Y)
+ self.assertEqual(Axis.Z, Axis.Z)
+ self.assertEqual(Axis.X, AlwaysEqual())
+
+ def test_axis_not_equal(self):
+ self.assertNotEqual(Axis.X, Axis.Y)
+ random_obj = object()
+ self.assertNotEqual(Axis.X, random_obj)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_bound_box.py b/tests/test_direct_api/test_bound_box.py
new file mode 100644
index 0000000..de4ebee
--- /dev/null
+++ b/tests/test_direct_api/test_bound_box.py
@@ -0,0 +1,104 @@
+"""
+build123d imports
+
+name: test_bound_box.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.geometry import BoundBox, Vector
+from build123d.topology import Solid, Vertex
+
+
+class TestBoundBox(unittest.TestCase):
+ def test_basic_bounding_box(self):
+ v = Vertex(1, 1, 1)
+ v2 = Vertex(2, 2, 2)
+ self.assertEqual(BoundBox, type(v.bounding_box()))
+ self.assertEqual(BoundBox, type(v2.bounding_box()))
+
+ bb1 = v.bounding_box().add(v2.bounding_box())
+
+ # OCC uses some approximations
+ self.assertAlmostEqual(bb1.size.X, 1.0, 1)
+
+ # Test adding to an existing bounding box
+ v0 = Vertex(0, 0, 0)
+ bb2 = v0.bounding_box().add(v.bounding_box())
+
+ bb3 = bb1.add(bb2)
+ self.assertAlmostEqual(bb3.size, (2, 2, 2), 7)
+
+ bb3 = bb2.add((3, 3, 3))
+ self.assertAlmostEqual(bb3.size, (3, 3, 3), 7)
+
+ bb3 = bb2.add(Vector(3, 3, 3))
+ self.assertAlmostEqual(bb3.size, (3, 3, 3), 7)
+
+ # Test 2D bounding boxes
+ bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box())
+ bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box())
+ bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box())
+ # Test that bb2 contains bb1
+ self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2))
+ self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1))
+ # Test that neither bounding box contains the other
+ self.assertIsNone(BoundBox.find_outside_box_2d(bb1, bb3))
+
+ # Test creation of a bounding box from a shape - note the low accuracy comparison
+ # as the box is a little larger than the shape
+ bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
+ self.assertAlmostEqual(bb1.size, (2, 2, 1), 1)
+
+ bb2 = BoundBox.from_topo_ds(
+ Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False
+ )
+ self.assertTrue(bb2.is_inside(bb1))
+
+ def test_bounding_box_repr(self):
+ bb = Solid.make_box(1, 1, 1).bounding_box()
+ self.assertEqual(
+ repr(bb), "bbox: 0.0 <= x <= 1.0, 0.0 <= y <= 1.0, 0.0 <= z <= 1.0"
+ )
+
+ def test_center_of_boundbox(self):
+ self.assertAlmostEqual(
+ Solid.make_box(1, 1, 1).bounding_box().center(),
+ (0.5, 0.5, 0.5),
+ 5,
+ )
+
+ def test_combined_center_of_boundbox(self):
+ pass
+
+ def test_clean_boundbox(self):
+ s = Solid.make_sphere(3)
+ self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5)
+ s.mesh(1e-3)
+ self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_cad_objects.py b/tests/test_direct_api/test_cad_objects.py
new file mode 100644
index 0000000..ce36057
--- /dev/null
+++ b/tests/test_direct_api/test_cad_objects.py
@@ -0,0 +1,264 @@
+"""
+build123d imports
+
+name: test_cad_objects.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import unittest
+
+from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
+from OCP.gp import gp, gp_Ax2, gp_Circ, gp_Elips, gp_Pnt
+from build123d.build_enums import CenterOf
+from build123d.geometry import Plane, Vector
+from build123d.topology import Edge, Face, Wire
+
+
+class TestCadObjects(unittest.TestCase):
+ def _make_circle(self):
+ circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0)
+ return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
+
+ def _make_ellipse(self):
+ ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0)
+ return Edge.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge())
+
+ def test_edge_wrapper_center(self):
+ e = self._make_circle()
+
+ self.assertAlmostEqual(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3)
+
+ def test_edge_wrapper_ellipse_center(self):
+ e = self._make_ellipse()
+ w = Wire([e])
+ self.assertAlmostEqual(Face(w).center(), (1.0, 2.0, 3.0), 3)
+
+ def test_edge_wrapper_make_circle(self):
+ halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180)
+
+ # np.testing.assert_allclose((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),1e-3)
+ self.assertAlmostEqual(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3)
+ self.assertAlmostEqual(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3)
+
+ def test_edge_wrapper_make_tangent_arc(self):
+ tangent_arc = Edge.make_tangent_arc(
+ Vector(1, 1), # starts at 1, 1
+ Vector(0, 1), # tangent at start of arc is in the +y direction
+ Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1
+ )
+ self.assertAlmostEqual(tangent_arc.start_point(), (1, 1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.end_point(), (2, 1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.tangent_at(0), (0, 1, 0), 3)
+ self.assertAlmostEqual(tangent_arc.tangent_at(0.5), (1, 0, 0), 3)
+ self.assertAlmostEqual(tangent_arc.tangent_at(1), (0, -1, 0), 3)
+
+ def test_edge_wrapper_make_ellipse1(self):
+ # Check x_radius > y_radius
+ x_radius, y_radius = 20, 10
+ angle1, angle2 = -75.0, 90.0
+ arcEllipseEdge = Edge.make_ellipse(
+ x_radius=x_radius,
+ y_radius=y_radius,
+ plane=Plane.XY,
+ start_angle=angle1,
+ end_angle=angle2,
+ )
+
+ start = (
+ x_radius * math.cos(math.radians(angle1)),
+ y_radius * math.sin(math.radians(angle1)),
+ 0.0,
+ )
+ end = (
+ x_radius * math.cos(math.radians(angle2)),
+ y_radius * math.sin(math.radians(angle2)),
+ 0.0,
+ )
+ self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
+ self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
+
+ def test_edge_wrapper_make_ellipse2(self):
+ # Check x_radius < y_radius
+ x_radius, y_radius = 10, 20
+ angle1, angle2 = 0.0, 45.0
+ arcEllipseEdge = Edge.make_ellipse(
+ x_radius=x_radius,
+ y_radius=y_radius,
+ plane=Plane.XY,
+ start_angle=angle1,
+ end_angle=angle2,
+ )
+
+ start = (
+ x_radius * math.cos(math.radians(angle1)),
+ y_radius * math.sin(math.radians(angle1)),
+ 0.0,
+ )
+ end = (
+ x_radius * math.cos(math.radians(angle2)),
+ y_radius * math.sin(math.radians(angle2)),
+ 0.0,
+ )
+ self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
+ self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
+
+ def test_edge_wrapper_make_circle_with_ellipse(self):
+ # Check x_radius == y_radius
+ x_radius, y_radius = 20, 20
+ angle1, angle2 = 15.0, 60.0
+ arcEllipseEdge = Edge.make_ellipse(
+ x_radius=x_radius,
+ y_radius=y_radius,
+ plane=Plane.XY,
+ start_angle=angle1,
+ end_angle=angle2,
+ )
+
+ start = (
+ x_radius * math.cos(math.radians(angle1)),
+ y_radius * math.sin(math.radians(angle1)),
+ 0.0,
+ )
+ end = (
+ x_radius * math.cos(math.radians(angle2)),
+ y_radius * math.sin(math.radians(angle2)),
+ 0.0,
+ )
+ self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
+ self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
+
+ def test_face_wrapper_make_rect(self):
+ mplane = Face.make_rect(10, 10)
+
+ self.assertAlmostEqual(mplane.normal_at(), (0.0, 0.0, 1.0), 3)
+
+ # def testCompoundcenter(self):
+ # """
+ # Tests whether or not a proper weighted center can be found for a compound
+ # """
+
+ # def cylinders(self, radius, height):
+
+ # c = Solid.make_cylinder(radius, height, Vector())
+
+ # # Combine all the cylinders into a single compound
+ # r = self.eachpoint(lambda loc: c.located(loc), True).combinesolids()
+
+ # return r
+
+ # Workplane.cyl = cylinders
+
+ # # Now test. here we want weird workplane to see if the objects are transformed right
+ # s = (
+ # Workplane("XY")
+ # .rect(2.0, 3.0, for_construction=true)
+ # .vertices()
+ # .cyl(0.25, 0.5)
+ # )
+
+ # self.assertEqual(4, len(s.val().solids()))
+ # np.testing.assert_allclose((0.0, 0.0, 0.25), s.val().center, 1e-3)
+
+ def test_translate(self):
+ e = Edge.make_circle(2, Plane((1, 2, 3)))
+ e2 = e.translate(Vector(0, 0, 1))
+
+ self.assertAlmostEqual(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
+
+ def test_vertices(self):
+ e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
+ self.assertEqual(2, len(e.vertices()))
+
+ def test_edge_wrapper_radius(self):
+ # get a radius from a simple circle
+ e0 = Edge.make_circle(2.4)
+ self.assertAlmostEqual(e0.radius, 2.4)
+
+ # radius of an arc
+ e1 = Edge.make_circle(
+ 1.8, Plane(origin=(5, 6, 7), z_dir=(1, 1, 1)), start_angle=20, end_angle=30
+ )
+ self.assertAlmostEqual(e1.radius, 1.8)
+
+ # test value errors
+ e2 = Edge.make_ellipse(10, 20)
+ with self.assertRaises(ValueError):
+ e2.radius
+
+ # radius from a wire
+ w0 = Wire.make_circle(10, Plane(origin=(1, 2, 3), z_dir=(-1, 0, 1)))
+ self.assertAlmostEqual(w0.radius, 10)
+
+ # radius from a wire with multiple edges
+ rad = 2.3
+ plane = Plane(origin=(7, 8, 0), z_dir=(1, 0.5, 0.1))
+ w1 = Wire(
+ [
+ Edge.make_circle(rad, plane, 0, 10),
+ Edge.make_circle(rad, plane, 10, 25),
+ Edge.make_circle(rad, plane, 25, 230),
+ ]
+ )
+ self.assertAlmostEqual(w1.radius, rad)
+
+ # test value error from wire
+ w2 = Wire.make_polygon(
+ [
+ Vector(-1, 0, 0),
+ Vector(0, 1, 0),
+ Vector(1, -1, 0),
+ ]
+ )
+ with self.assertRaises(ValueError):
+ w2.radius
+
+ # (I think) the radius of a wire is the radius of it's first edge.
+ # Since this is stated in the docstring better make sure.
+ no_rad = Wire(
+ [
+ Edge.make_line(Vector(0, 0, 0), Vector(0, 1, 0)),
+ Edge.make_circle(1.0, start_angle=90, end_angle=270),
+ ]
+ )
+ with self.assertRaises(ValueError):
+ no_rad.radius
+ yes_rad = Wire(
+ [
+ Edge.make_circle(1.0, start_angle=90, end_angle=270),
+ Edge.make_line(Vector(0, -1, 0), Vector(0, 1, 0)),
+ ]
+ )
+ self.assertAlmostEqual(yes_rad.radius, 1.0)
+ many_rad = Wire(
+ [
+ Edge.make_circle(1.0, start_angle=0, end_angle=180),
+ Edge.make_circle(3.0, Plane((2, 0, 0)), start_angle=180, end_angle=359),
+ ]
+ )
+ self.assertAlmostEqual(many_rad.radius, 1.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_clean_method.py b/tests/test_direct_api/test_clean_method.py
new file mode 100644
index 0000000..04bedcf
--- /dev/null
+++ b/tests/test_direct_api/test_clean_method.py
@@ -0,0 +1,70 @@
+"""
+build123d imports
+
+name: test_clean_method.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 unittest.mock import patch, MagicMock
+
+from build123d.topology import Solid
+
+
+class TestCleanMethod(unittest.TestCase):
+ def setUp(self):
+ # Create a mock object
+ self.solid = Solid()
+ self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object
+
+ @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain")
+ def test_clean_warning_on_exception(self, mock_shape_upgrade):
+ # Mock the upgrader
+ mock_upgrader = mock_shape_upgrade.return_value
+ mock_upgrader.Build.side_effect = Exception("Mocked Build failure")
+
+ # Capture warnings
+ with self.assertWarns(Warning) as warn_context:
+ self.solid.clean()
+
+ # Assert the warning message
+ self.assertIn("Unable to clean", str(warn_context.warning))
+
+ # Verify the upgrader was constructed with the correct arguments
+ mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True)
+
+ # Verify the Build method was called
+ mock_upgrader.Build.assert_called_once()
+
+ def test_clean_with_none_wrapped(self):
+ # Set `wrapped` to None to simulate the error condition
+ self.solid.wrapped = None
+
+ # Call clean and ensure it returns self
+ result = self.solid.clean()
+ self.assertIs(result, self.solid)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_color.py b/tests/test_direct_api/test_color.py
new file mode 100644
index 0000000..6bd6b8f
--- /dev/null
+++ b/tests/test_direct_api/test_color.py
@@ -0,0 +1,125 @@
+"""
+build123d imports
+
+name: test_color.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 copy
+import unittest
+
+import numpy as np
+from build123d.geometry import Color
+
+
+class TestColor(unittest.TestCase):
+ def test_name1(self):
+ c = Color("blue")
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
+
+ def test_name2(self):
+ c = Color("blue", alpha=0.5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
+
+ def test_name3(self):
+ c = Color("blue", 0.5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
+
+ def test_rgb0(self):
+ c = Color(0.0, 1.0, 0.0)
+ np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 1e-5)
+
+ def test_rgba1(self):
+ c = Color(1.0, 1.0, 0.0, 0.5)
+ self.assertEqual(c.wrapped.GetRGB().Red(), 1.0)
+ self.assertEqual(c.wrapped.GetRGB().Green(), 1.0)
+ self.assertEqual(c.wrapped.GetRGB().Blue(), 0.0)
+ self.assertEqual(c.wrapped.Alpha(), 0.5)
+
+ def test_rgba2(self):
+ c = Color(1.0, 1.0, 0.0, alpha=0.5)
+ np.testing.assert_allclose(tuple(c), (1, 1, 0, 0.5), 1e-5)
+
+ def test_rgba3(self):
+ c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5)
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.5), 1e-5)
+
+ def test_bad_color_name(self):
+ with self.assertRaises(ValueError):
+ Color("build123d")
+
+ def test_to_tuple(self):
+ c = Color("blue", alpha=0.5)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
+
+ def test_hex(self):
+ c = Color(0x996692)
+ np.testing.assert_allclose(
+ tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5
+ )
+
+ c = Color(0x006692, 0x80)
+ np.testing.assert_allclose(
+ tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5
+ )
+
+ c = Color(0x006692, alpha=0x80)
+ np.testing.assert_allclose(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 1e-5)
+
+ c = Color(color_code=0x996692, alpha=0xCC)
+ np.testing.assert_allclose(
+ tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5
+ )
+
+ c = Color(0.0, 0.0, 1.0, 1.0)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
+
+ c = Color(0, 0, 1, 1)
+ np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
+
+ def test_copy(self):
+ c = Color(0.1, 0.2, 0.3, alpha=0.4)
+ c_copy = copy.copy(c)
+ np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 1e-5)
+
+ def test_str_repr(self):
+ c = Color(1, 0, 0)
+ self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) ~ RED")
+ self.assertEqual(repr(c), "Color(1.0, 0.0, 0.0, 1.0)")
+
+ def test_tuple(self):
+ c = Color((0.1,))
+ np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5)
+ c = Color((0.1, 0.2))
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 1.0, 1.0), 1e-5)
+ c = Color((0.1, 0.2, 0.3))
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 1.0), 1e-5)
+ c = Color((0.1, 0.2, 0.3, 0.4))
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
+ c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4))
+ np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_compound.py b/tests/test_direct_api/test_compound.py
new file mode 100644
index 0000000..e4eb6f2
--- /dev/null
+++ b/tests/test_direct_api/test_compound.py
@@ -0,0 +1,162 @@
+"""
+build123d imports
+
+name: test_compound.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 itertools
+import unittest
+
+from build123d.build_common import GridLocations, PolarLocations
+from build123d.build_enums import Align, CenterOf
+from build123d.geometry import Location, Plane
+from build123d.objects_part import Box
+from build123d.objects_sketch import Circle
+from build123d.topology import Compound, Edge, Face, ShapeList, Solid, Sketch
+
+
+class TestCompound(unittest.TestCase):
+ def test_make_text(self):
+ arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0))
+ text = Compound.make_text("test", 10, text_path=arc)
+ self.assertEqual(len(text.faces()), 4)
+ text = Compound.make_text(
+ "test", 10, align=(Align.MAX, Align.MAX), text_path=arc
+ )
+ self.assertEqual(len(text.faces()), 4)
+
+ def test_fuse(self):
+ box1 = Solid.make_box(1, 1, 1)
+ box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
+ combined = Compound([box1]).fuse(box2, glue=True)
+ self.assertTrue(combined.is_valid())
+ self.assertAlmostEqual(combined.volume, 2, 5)
+ fuzzy = Compound([box1]).fuse(box2, tol=1e-6)
+ self.assertTrue(fuzzy.is_valid())
+ self.assertAlmostEqual(fuzzy.volume, 2, 5)
+
+ def test_remove(self):
+ box1 = Solid.make_box(1, 1, 1)
+ box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
+ combined = Compound([box1, box2])
+ self.assertTrue(len(combined._remove(box2).solids()), 1)
+
+ def test_repr(self):
+ simple = Compound([Solid.make_box(1, 1, 1)])
+ simple_str = repr(simple).split("0x")[0] + repr(simple).split(", ")[1]
+ self.assertEqual(simple_str, "Compound at label()")
+
+ assembly = Compound([Solid.make_box(1, 1, 1)])
+ assembly.children = [Solid.make_box(1, 1, 1)]
+ assembly.label = "test"
+ assembly_str = repr(assembly).split("0x")[0] + repr(assembly).split(", l")[1]
+ self.assertEqual(assembly_str, "Compound at abel(test), #children(1)")
+
+ def test_center(self):
+ test_compound = Compound(
+ [
+ Solid.make_box(2, 2, 2).locate(Location((-1, -1, -1))),
+ Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))),
+ ]
+ )
+ self.assertAlmostEqual(test_compound.center(CenterOf.MASS), (1, 0, 0), 5)
+ self.assertAlmostEqual(
+ test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5
+ )
+ with self.assertRaises(ValueError):
+ test_compound.center(CenterOf.GEOMETRY)
+
+ def test_triad(self):
+ triad = Compound.make_triad(10)
+ bbox = triad.bounding_box()
+ self.assertGreater(bbox.min.X, -10 / 8)
+ self.assertLess(bbox.min.X, 0)
+ self.assertGreater(bbox.min.Y, -10 / 8)
+ self.assertLess(bbox.min.Y, 0)
+ self.assertGreater(bbox.min.Y, -10 / 8)
+ self.assertAlmostEqual(bbox.min.Z, 0, 4)
+ self.assertLess(bbox.size.Z, 12.5)
+ self.assertEqual(triad.volume, 0)
+
+ def test_volume(self):
+ e = Edge.make_line((0, 0), (1, 1))
+ self.assertAlmostEqual(e.volume, 0, 5)
+
+ f = Face.make_rect(1, 1)
+ self.assertAlmostEqual(f.volume, 0, 5)
+
+ b = Solid.make_box(1, 1, 1)
+ self.assertAlmostEqual(b.volume, 1, 5)
+
+ bb = Box(1, 1, 1)
+ self.assertAlmostEqual(bb.volume, 1, 5)
+
+ c = Compound(children=[e, f, b, bb, b.translate((0, 5, 0))])
+ self.assertAlmostEqual(c.volume, 3, 5)
+ # N.B. b and bb overlap but still add to Compound volume
+
+ def test_constructor(self):
+ with self.assertRaises(TypeError):
+ Compound(foo="bar")
+
+ def test_len(self):
+ self.assertEqual(len(Compound()), 0)
+ skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
+ self.assertEqual(len(skt), 4)
+
+ def test_iteration(self):
+ skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
+ for c1, c2 in itertools.combinations(skt, 2):
+ self.assertGreaterEqual((c1.position - c2.position).length, 10)
+
+ def test_unwrap(self):
+ skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
+ skt2 = Compound(children=[skt])
+ self.assertEqual(len(skt2), 1)
+ skt3 = skt2.unwrap(fully=False)
+ self.assertEqual(len(skt3), 4)
+
+ comp1 = Compound().unwrap()
+ self.assertEqual(len(comp1), 0)
+ comp2 = Compound(children=[Face.make_rect(1, 1)])
+ comp3 = Compound(children=[comp2])
+ self.assertEqual(len(comp3), 1)
+ self.assertTrue(isinstance(next(iter(comp3)), Compound))
+ comp4 = comp3.unwrap(fully=True)
+ self.assertTrue(isinstance(comp4, Face))
+
+ def test_get_top_level_shapes(self):
+ base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4))
+ fls = base_shapes.get_top_level_shapes()
+ self.assertTrue(isinstance(fls, ShapeList))
+ self.assertEqual(len(fls), 20)
+ self.assertTrue(all(isinstance(s, Solid) for s in fls))
+
+ b1 = Box(1, 1, 1).solid()
+ self.assertEqual(b1.get_top_level_shapes()[0], b1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_direct_api_test_case.py b/tests/test_direct_api/test_direct_api_test_case.py
new file mode 100644
index 0000000..a165857
--- /dev/null
+++ b/tests/test_direct_api/test_direct_api_test_case.py
@@ -0,0 +1,60 @@
+"""
+build123d direct api tests
+
+name: test_direct_api_test_case.py
+by: Gumyr
+date: January 21, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 typing import Optional
+
+from build123d.geometry import Vector, VectorLike
+
+
+class DirectApiTestCase(unittest.TestCase):
+ def assertTupleAlmostEquals(
+ self,
+ first: tuple[float, ...],
+ second: tuple[float, ...],
+ places: int,
+ msg: str | None = None,
+ ):
+ """Check Tuples"""
+ self.assertEqual(len(second), len(first))
+ for i, j in zip(second, first):
+ self.assertAlmostEqual(i, j, places, msg=msg)
+
+ def assertVectorAlmostEquals(
+ self, first: Vector, second: VectorLike, places: int, msg: str | None = None
+ ):
+ second_vector = Vector(second)
+ self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg)
+ self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg)
+ self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg)
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py
new file mode 100644
index 0000000..3589ec0
--- /dev/null
+++ b/tests/test_direct_api/test_edge.py
@@ -0,0 +1,289 @@
+"""
+build123d imports
+
+name: test_edge.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import unittest
+
+from build123d.build_enums import AngularDirection
+from build123d.geometry import Axis, Plane, Vector
+from build123d.objects_curve import CenterArc, EllipticalCenterArc
+from build123d.topology import Edge
+
+
+class TestEdge(unittest.TestCase):
+ def test_close(self):
+ self.assertAlmostEqual(
+ Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5
+ )
+ self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5)
+
+ def test_make_half_circle(self):
+ half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180)
+ self.assertAlmostEqual(half_circle.start_point(), (1, 0, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (-1, 0, 0), 3)
+
+ def test_make_half_circle2(self):
+ half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90)
+ self.assertAlmostEqual(half_circle.start_point(), (0, -1, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (0, 1, 0), 3)
+
+ def test_make_clockwise_half_circle(self):
+ half_circle = Edge.make_circle(
+ radius=1,
+ start_angle=180,
+ end_angle=0,
+ angular_direction=AngularDirection.CLOCKWISE,
+ )
+ self.assertAlmostEqual(half_circle.end_point(), (1, 0, 0), 3)
+ self.assertAlmostEqual(half_circle.start_point(), (-1, 0, 0), 3)
+
+ def test_make_clockwise_half_circle2(self):
+ half_circle = Edge.make_circle(
+ radius=1,
+ start_angle=90,
+ end_angle=-90,
+ angular_direction=AngularDirection.CLOCKWISE,
+ )
+ self.assertAlmostEqual(half_circle.start_point(), (0, 1, 0), 3)
+ self.assertAlmostEqual(half_circle.end_point(), (0, -1, 0), 3)
+
+ def test_arc_center(self):
+ self.assertAlmostEqual(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5)
+ with self.assertRaises(ValueError):
+ Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center
+
+ def test_spline_with_parameters(self):
+ spline = Edge.make_spline(
+ points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0]
+ )
+ self.assertAlmostEqual(spline.end_point(), (2, 0, 0), 5)
+ with self.assertRaises(ValueError):
+ Edge.make_spline(
+ points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0]
+ )
+ with self.assertRaises(ValueError):
+ Edge.make_spline(
+ points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], tangents=[(1, 1, 0)]
+ )
+
+ def test_spline_approx(self):
+ spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)])
+ self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
+ spline = Edge.make_spline_approx(
+ [(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0)
+ )
+ self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
+
+ def test_distribute_locations(self):
+ line = Edge.make_line((0, 0, 0), (10, 0, 0))
+ locs = line.distribute_locations(3)
+ for i, x in enumerate([0, 5, 10]):
+ self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5)
+ self.assertAlmostEqual(locs[0].orientation, (0, 90, 180), 5)
+
+ locs = line.distribute_locations(3, positions_only=True)
+ for i, x in enumerate([0, 5, 10]):
+ self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5)
+ self.assertAlmostEqual(locs[0].orientation, (0, 0, 0), 5)
+
+ def test_to_wire(self):
+ edge = Edge.make_line((0, 0, 0), (1, 1, 1))
+ for end in [0, 1]:
+ self.assertAlmostEqual(
+ edge.position_at(end),
+ edge.to_wire().position_at(end),
+ 5,
+ )
+
+ def test_arc_center2(self):
+ edges = [
+ Edge.make_circle(1, plane=Plane((1, 2, 3)), end_angle=30),
+ Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30),
+ ]
+ for edge in edges:
+ self.assertAlmostEqual(edge.arc_center, (1, 2, 3), 5)
+ with self.assertRaises(ValueError):
+ Edge.make_line((0, 0), (1, 1)).arc_center
+
+ def test_find_intersection_points(self):
+ circle = Edge.make_circle(1)
+ line = Edge.make_line((0, -2), (0, 2))
+ crosses = circle.find_intersection_points(line)
+ for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses):
+ self.assertAlmostEqual(actual, target, 5)
+
+ with self.assertRaises(ValueError):
+ circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
+ with self.assertRaises(ValueError):
+ circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
+
+ self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)])
+ self.assertAlmostEqual(
+ self_intersect.find_intersection_points()[0],
+ (-2.6861636507066047, 0, 0),
+ 5,
+ )
+ line = Edge.make_line((1, -2), (1, 2))
+ crosses = line.find_intersection_points(Axis.X)
+ self.assertAlmostEqual(crosses[0], (1, 0, 0), 5)
+
+ with self.assertRaises(ValueError):
+ line.find_intersection_points(Plane.YZ)
+
+ # def test_intersections_tolerance(self):
+
+ # Multiple operands not currently supported
+
+ # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
+ # l1 = Edge.make_line((1, 0), (2, 0))
+ # i1 = l1.intersect(*r1)
+
+ # r2 = Rectangle(2, 2).edges()
+ # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
+ # i2 = l2.intersect(*r2)
+
+ # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
+
+ def test_trim(self):
+ line = Edge.make_line((-2, 0), (2, 0))
+ self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5)
+ self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5)
+ with self.assertRaises(ValueError):
+ line.trim(0.75, 0.25)
+
+ def test_trim_to_length(self):
+
+ e1 = Edge.make_line((0, 0), (10, 10))
+ e1_trim = e1.trim_to_length(0.0, 10)
+ self.assertAlmostEqual(e1_trim.length, 10, 5)
+
+ e2 = Edge.make_circle(10, start_angle=0, end_angle=90)
+ e2_trim = e2.trim_to_length(0.5, 1)
+ self.assertAlmostEqual(e2_trim.length, 1, 5)
+ self.assertAlmostEqual(
+ e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5
+ )
+
+ e3 = Edge.make_spline(
+ [(0, 10, 0), (-4, 5, 2), (0, 0, 0)], tangents=[(-1, 0), (1, 0)]
+ )
+ e3_trim = e3.trim_to_length(0, 7)
+ self.assertAlmostEqual(e3_trim.length, 7, 5)
+
+ a4 = Axis((0, 0, 0), (1, 1, 1))
+ e4_trim = Edge(a4).trim_to_length(0.5, 2)
+ self.assertAlmostEqual(e4_trim.length, 2, 5)
+
+ def test_bezier(self):
+ with self.assertRaises(ValueError):
+ Edge.make_bezier((1, 1))
+ cntl_pnts = [(1, 2, 3)] * 30
+ with self.assertRaises(ValueError):
+ Edge.make_bezier(*cntl_pnts)
+ with self.assertRaises(ValueError):
+ Edge.make_bezier((0, 0, 0), (1, 1, 1), weights=[1.0])
+
+ bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0))
+ bbox = bezier.bounding_box()
+ self.assertAlmostEqual(bbox.min, (0, 0, 0), 5)
+ self.assertAlmostEqual(bbox.max, (1, 0.75, 0), 5)
+
+ def test_mid_way(self):
+ mid = Edge.make_mid_way(
+ Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25
+ )
+ self.assertAlmostEqual(mid.position_at(0), (0.25, 0, 0), 5)
+ self.assertAlmostEqual(mid.position_at(1), (0.25, 1, 0), 5)
+
+ def test_distribute_locations2(self):
+ with self.assertRaises(ValueError):
+ Edge.make_circle(1).distribute_locations(1)
+
+ locs = Edge.make_circle(1).distribute_locations(5, positions_only=True)
+ for i, loc in enumerate(locs):
+ self.assertAlmostEqual(
+ loc.position,
+ Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(),
+ 5,
+ )
+ self.assertAlmostEqual(loc.orientation, (0, 0, 0), 5)
+
+ def test_find_tangent(self):
+ circle = Edge.make_circle(1)
+ parm = circle.find_tangent(135)[0]
+ self.assertAlmostEqual(
+ circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
+ )
+ line = Edge.make_line((0, 0), (1, 1))
+ parm = line.find_tangent(45)[0]
+ self.assertAlmostEqual(parm, 0, 5)
+ parm = line.find_tangent(0)
+ self.assertEqual(len(parm), 0)
+
+ def test_param_at_point(self):
+ u = Edge.make_circle(1).param_at_point((0, 1))
+ self.assertAlmostEqual(u, 0.25, 5)
+
+ u = 0.3
+ edge = Edge.make_line((0, 0), (34, 56))
+ pnt = edge.position_at(u)
+ self.assertAlmostEqual(edge.param_at_point(pnt), u, 5)
+
+ ca = CenterArc((0, 0), 1, -200, 220).edge()
+ for u in [0.3, 1.0]:
+ pnt = ca.position_at(u)
+ self.assertAlmostEqual(ca.param_at_point(pnt), u, 5)
+
+ ea = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270).edge()
+ for u in [0.3, 0.9]:
+ pnt = ea.position_at(u)
+ self.assertAlmostEqual(ea.param_at_point(pnt), u, 5)
+
+ with self.assertRaises(ValueError):
+ edge.param_at_point((-1, 1))
+
+ def test_conical_helix(self):
+ helix = Edge.make_helix(1, 4, 1, normal=(-1, 0, 0), angle=10, lefthand=True)
+ self.assertAlmostEqual(helix.bounding_box().min.X, -4, 5)
+
+ def test_reverse(self):
+ e1 = Edge.make_line((0, 0), (1, 1))
+ self.assertAlmostEqual(e1 @ 0.1, (0.1, 0.1, 0), 5)
+ self.assertAlmostEqual(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5)
+
+ e2 = Edge.make_circle(1, start_angle=0, end_angle=180)
+ e2r = e2.reversed()
+ self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5)
+
+ def test_init(self):
+ with self.assertRaises(TypeError):
+ Edge(direction=(1, 0, 0))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
new file mode 100644
index 0000000..3092833
--- /dev/null
+++ b/tests/test_direct_api/test_face.py
@@ -0,0 +1,452 @@
+"""
+build123d imports
+
+name: test_face.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import os
+import platform
+import random
+import unittest
+
+from build123d.build_common import Locations
+from build123d.build_enums import Align, CenterOf, GeomType
+from build123d.build_line import BuildLine
+from build123d.build_part import BuildPart
+from build123d.build_sketch import BuildSketch
+from build123d.exporters3d import export_stl
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.importers import import_stl
+from build123d.objects_curve import Polyline
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import Rectangle, RegularPolygon
+from build123d.operations_generic import fillet
+from build123d.operations_part import extrude
+from build123d.operations_sketch import make_face
+from build123d.topology import Edge, Face, Solid, Wire
+
+
+class TestFace(unittest.TestCase):
+ def test_make_surface_from_curves(self):
+ bottom_edge = Edge.make_circle(radius=1, end_angle=90)
+ top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90)
+ curved = Face.make_surface_from_curves(bottom_edge, top_edge)
+ self.assertTrue(curved.is_valid())
+ self.assertAlmostEqual(curved.area, math.pi / 2, 5)
+ self.assertAlmostEqual(
+ curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
+ )
+
+ bottom_wire = Wire.make_circle(1)
+ top_wire = Wire.make_circle(1, Plane((0, 0, 1)))
+ curved = Face.make_surface_from_curves(bottom_wire, top_wire)
+ self.assertTrue(curved.is_valid())
+ self.assertAlmostEqual(curved.area, 2 * math.pi, 5)
+
+ def test_center(self):
+ test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)]))
+ self.assertAlmostEqual(test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1)
+ self.assertAlmostEqual(
+ test_face.center(CenterOf.BOUNDING_BOX),
+ (0.5, 0.5, 0),
+ 5,
+ )
+
+ def test_face_volume(self):
+ rect = Face.make_rect(1, 1)
+ self.assertAlmostEqual(rect.volume, 0, 5)
+
+ def test_chamfer_2d(self):
+ test_face = Face.make_rect(10, 10)
+ test_face = test_face.chamfer_2d(
+ distance=1, distance2=2, vertices=test_face.vertices()
+ )
+ self.assertAlmostEqual(test_face.area, 100 - 4 * 0.5 * 1 * 2)
+
+ def test_chamfer_2d_reference(self):
+ test_face = Face.make_rect(10, 10)
+ edge = test_face.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ test_face = test_face.chamfer_2d(
+ distance=1, distance2=2, vertices=[vertex], edge=edge
+ )
+ self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 9)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 8)
+
+ def test_chamfer_2d_reference_inverted(self):
+ test_face = Face.make_rect(10, 10)
+ edge = test_face.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ test_face = test_face.chamfer_2d(
+ distance=2, distance2=1, vertices=[vertex], edge=edge
+ )
+ self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 8)
+ self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 9)
+
+ def test_chamfer_2d_error_checking(self):
+ with self.assertRaises(ValueError):
+ test_face = Face.make_rect(10, 10)
+ edge = test_face.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ other_edge = test_face.edges().sort_by(Axis.Y)[-1]
+ test_face = test_face.chamfer_2d(
+ distance=1, distance2=2, vertices=[vertex], edge=other_edge
+ )
+
+ def test_make_rect(self):
+ test_face = Face.make_plane()
+ self.assertAlmostEqual(test_face.normal_at(), (0, 0, 1), 5)
+
+ def test_length_width(self):
+ test_face = Face.make_rect(8, 10, Plane.XZ)
+ self.assertAlmostEqual(test_face.length, 8, 5)
+ self.assertAlmostEqual(test_face.width, 10, 5)
+
+ def test_geometry(self):
+ box = Solid.make_box(1, 1, 2)
+ self.assertEqual(box.faces().sort_by(Axis.Z).last.geometry, "SQUARE")
+ self.assertEqual(box.faces().sort_by(Axis.Y).last.geometry, "RECTANGLE")
+ with BuildPart() as test:
+ with BuildSketch():
+ RegularPolygon(1, 3)
+ extrude(amount=1)
+ self.assertEqual(test.faces().sort_by(Axis.Z).last.geometry, "POLYGON")
+
+ def test_is_planar(self):
+ self.assertTrue(Face.make_rect(1, 1).is_planar)
+ self.assertFalse(
+ Solid.make_cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0].is_planar
+ )
+ # Some of these faces have geom_type BSPLINE but are planar
+ mount = Solid.make_loft(
+ [
+ Rectangle((1 + 16 + 4), 20, align=(Align.MIN, Align.CENTER)).wire(),
+ Pos(1, 0, 4)
+ * Rectangle(16, 20, align=(Align.MIN, Align.CENTER)).wire(),
+ ],
+ )
+ self.assertTrue(all(f.is_planar for f in mount.faces()))
+
+ def test_negate(self):
+ square = Face.make_rect(1, 1)
+ self.assertAlmostEqual(square.normal_at(), (0, 0, 1), 5)
+ flipped_square = -square
+ self.assertAlmostEqual(flipped_square.normal_at(), (0, 0, -1), 5)
+
+ def test_offset(self):
+ bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box()
+ self.assertAlmostEqual(bbox.min, (-1, -1, 5), 5)
+ self.assertAlmostEqual(bbox.max, (1, 1, 5), 5)
+
+ def test_make_from_wires(self):
+ outer = Wire.make_circle(10)
+ inners = [
+ Wire.make_circle(1).locate(Location((-2, 2, 0))),
+ Wire.make_circle(1).locate(Location((2, 2, 0))),
+ ]
+ happy = Face(outer, inners)
+ self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5)
+
+ outer = Edge.make_circle(10, end_angle=180).to_wire()
+ with self.assertRaises(ValueError):
+ Face(outer, inners)
+ with self.assertRaises(ValueError):
+ Face(Wire.make_circle(10, Plane.XZ), inners)
+
+ outer = Wire.make_circle(10)
+ inners = [
+ Wire.make_circle(1).locate(Location((-2, 2, 0))),
+ Edge.make_circle(1, end_angle=180).to_wire().locate(Location((2, 2, 0))),
+ ]
+ with self.assertRaises(ValueError):
+ Face(outer, inners)
+
+ def test_sew_faces(self):
+ patches = [
+ Face.make_rect(1, 1, Plane((x, y, z)))
+ for x in range(2)
+ for y in range(2)
+ for z in range(3)
+ ]
+ random.shuffle(patches)
+ sheets = Face.sew_faces(patches)
+ self.assertEqual(len(sheets), 3)
+ self.assertEqual(len(sheets[0]), 4)
+ self.assertTrue(isinstance(sheets[0][0], Face))
+
+ def test_surface_from_array_of_points(self):
+ pnts = [
+ [
+ Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
+ for x in range(11)
+ ]
+ for y in range(11)
+ ]
+ surface = Face.make_surface_from_array_of_points(pnts)
+ bbox = surface.bounding_box()
+ self.assertAlmostEqual(bbox.min, (0, 0, -1), 3)
+ self.assertAlmostEqual(bbox.max, (10, 10, 2), 2)
+
+ def test_bezier_surface(self):
+ points = [
+ [
+ (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
+ for x in range(-1, 2)
+ ]
+ for y in range(-1, 2)
+ ]
+ surface = Face.make_bezier_surface(points)
+ bbox = surface.bounding_box()
+ self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3)
+ self.assertAlmostEqual(bbox.max, (+1, +1, +1), 1)
+ self.assertLess(bbox.max.Z, 1.0)
+
+ weights = [
+ [2 if x == 0 or y == 0 else 1 for x in range(-1, 2)] for y in range(-1, 2)
+ ]
+ surface = Face.make_bezier_surface(points, weights)
+ bbox = surface.bounding_box()
+ self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3)
+ self.assertGreater(bbox.max.Z, 1.0)
+
+ too_many_points = [
+ [
+ (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
+ for x in range(-1, 27)
+ ]
+ for y in range(-1, 27)
+ ]
+
+ with self.assertRaises(ValueError):
+ Face.make_bezier_surface([[(0, 0)]])
+ with self.assertRaises(ValueError):
+ Face.make_bezier_surface(points, [[1, 1], [1, 1]])
+ with self.assertRaises(ValueError):
+ Face.make_bezier_surface(too_many_points)
+
+ def test_thicken(self):
+ pnts = [
+ [
+ Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
+ for x in range(11)
+ ]
+ for y in range(11)
+ ]
+ surface = Face.make_surface_from_array_of_points(pnts)
+ solid = Solid.thicken(surface, 1)
+ self.assertAlmostEqual(solid.volume, 101.59, 2)
+
+ square = Face.make_rect(10, 10)
+ bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box()
+ self.assertAlmostEqual(bbox.min, (-5, -5, -1), 5)
+ self.assertAlmostEqual(bbox.max, (5, 5, 0), 5)
+
+ def test_make_holes(self):
+ radius = 10
+ circumference = 2 * math.pi * radius
+ hex_diagonal = 4 * (circumference / 10) / 3
+ cylinder = Solid.make_cylinder(radius, hex_diagonal * 5)
+ cylinder_wall: Face = cylinder.faces().filter_by(GeomType.PLANE, reverse=True)[
+ 0
+ ]
+ with BuildSketch(Plane.XZ.offset(radius)) as hex:
+ with Locations((0, hex_diagonal)):
+ RegularPolygon(
+ hex_diagonal * 0.4, 6, align=(Align.CENTER, Align.CENTER)
+ )
+ hex_wire_vertical: Wire = hex.sketch.faces()[0].outer_wire()
+
+ projected_wire: Wire = hex_wire_vertical.project_to_shape(
+ target_object=cylinder, center=(0, 0, hex_wire_vertical.center().Z)
+ )[0]
+ projected_wires = [
+ projected_wire.rotate(Axis.Z, -90 + i * 360 / 10).translate(
+ (0, 0, (j + (i % 2) / 2) * hex_diagonal)
+ )
+ for i in range(5)
+ for j in range(4 - i % 2)
+ ]
+ cylinder_walls_with_holes = cylinder_wall.make_holes(projected_wires)
+ self.assertTrue(cylinder_walls_with_holes.is_valid())
+ self.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area)
+
+ def test_is_inside(self):
+ square = Face.make_rect(10, 10)
+ self.assertTrue(square.is_inside((1, 1)))
+ self.assertFalse(square.is_inside((20, 1)))
+
+ def test_import_stl(self):
+ torus = Solid.make_torus(10, 1)
+ # exporter = Mesher()
+ # exporter.add_shape(torus)
+ # exporter.write("test_torus.stl")
+ export_stl(torus, "test_torus.stl")
+ imported_torus = import_stl("test_torus.stl")
+ # The torus from stl is tessellated therefore the areas will only be close
+ self.assertAlmostEqual(imported_torus.area, torus.area, 0)
+ os.remove("test_torus.stl")
+
+ def test_is_coplanar(self):
+ square = Face.make_rect(1, 1, plane=Plane.XZ)
+ self.assertTrue(square.is_coplanar(Plane.XZ))
+ self.assertTrue((-square).is_coplanar(Plane.XZ))
+ self.assertFalse(square.is_coplanar(Plane.XY))
+ surface: Face = Solid.make_sphere(1).faces()[0]
+ self.assertFalse(surface.is_coplanar(Plane.XY))
+
+ def test_center_location(self):
+ square = Face.make_rect(1, 1, plane=Plane.XZ)
+ cl = square.center_location
+ self.assertAlmostEqual(cl.position, (0, 0, 0), 5)
+ self.assertAlmostEqual(Plane(cl).z_dir, Plane.XZ.z_dir, 5)
+
+ def test_position_at(self):
+ square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1))
+ p = square.position_at(0.25, 0.75)
+ self.assertAlmostEqual(p, (-0.5, -1.0, 0.5), 5)
+
+ def test_location_at(self):
+ bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0]
+ loc = bottom.location_at(0.5, 0.5)
+ self.assertAlmostEqual(loc.position, (0.5, 1, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (-180, 0, -180), 5)
+
+ front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0]
+ loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1))
+ self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5)
+ self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5)
+
+ def test_make_surface(self):
+ corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
+ net_exterior = Wire(
+ [
+ Edge.make_line(corners[3], corners[1]),
+ Edge.make_line(corners[1], corners[0]),
+ Edge.make_line(corners[0], corners[2]),
+ Edge.make_three_point_arc(
+ corners[2],
+ (corners[2] + corners[3]) / 2 - Vector(0, 0, 3),
+ corners[3],
+ ),
+ ]
+ )
+ surface = Face.make_surface(
+ net_exterior,
+ surface_points=[Vector(0, 0, -5)],
+ )
+ hole_flat = Wire.make_circle(10)
+ hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0]
+ surface = Face.make_surface(
+ exterior=net_exterior,
+ surface_points=[Vector(0, 0, -5)],
+ interior_wires=[hole],
+ )
+ self.assertTrue(surface.is_valid())
+ self.assertEqual(surface.geom_type, GeomType.BSPLINE)
+ bbox = surface.bounding_box()
+ self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -5.113393280136395), 5)
+ self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5)
+
+ # With no surface point
+ surface = Face.make_surface(net_exterior)
+ bbox = surface.bounding_box()
+ self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -3), 5)
+ self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5)
+
+ # Exterior Edge
+ surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)])
+ bbox = surface.bounding_box()
+ self.assertAlmostEqual(bbox.min, (-50, -50, -5), 5)
+ self.assertAlmostEqual(bbox.max, (50, 50, 0), 5)
+
+ def test_make_surface_error_checking(self):
+ with self.assertRaises(ValueError):
+ Face.make_surface(Edge.make_line((0, 0), (1, 0)))
+
+ with self.assertRaises(RuntimeError):
+ Face.make_surface([Edge.make_line((0, 0), (1, 0))])
+
+ if platform.system() != "Darwin":
+ with self.assertRaises(RuntimeError):
+ Face.make_surface(
+ [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)]
+ )
+
+ with self.assertRaises(RuntimeError):
+ Face.make_surface(
+ [Edge.make_circle(50)],
+ interior_wires=[Wire.make_circle(5, Plane.XZ)],
+ )
+
+ def test_sweep(self):
+ edge = Edge.make_line((1, 0), (2, 0))
+ path = Wire.make_circle(1)
+ circle_with_hole = Face.sweep(edge, path)
+ self.assertTrue(isinstance(circle_with_hole, Face))
+ self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5)
+ with self.assertRaises(ValueError):
+ Face.sweep(edge, Polyline((0, 0), (0.1, 0), (0.2, 0.1)))
+
+ def test_to_arcs(self):
+ with BuildSketch() as bs:
+ with BuildLine() as bl:
+ Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0))
+ fillet(bl.vertices(), radius=0.1)
+ make_face()
+ smooth = bs.faces()[0]
+ fragmented = smooth.to_arcs()
+ self.assertLess(len(smooth.edges()), len(fragmented.edges()))
+
+ def test_outer_wire(self):
+ face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
+ self.assertAlmostEqual(face.outer_wire().length, 4, 5)
+
+ def test_wire(self):
+ face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
+ with self.assertWarns(UserWarning):
+ outer = face.wire()
+ self.assertAlmostEqual(outer.length, 4, 5)
+
+ def test_constructor(self):
+ with self.assertRaises(ValueError):
+ Face(bob="fred")
+
+ def test_normal_at(self):
+ face = Face.make_rect(1, 1)
+ self.assertAlmostEqual(face.normal_at(0, 0), (0, 0, 1), 5)
+ self.assertAlmostEqual(face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5)
+ with self.assertRaises(ValueError):
+ face.normal_at(0)
+ with self.assertRaises(ValueError):
+ face.normal_at(center=(0, 0))
+ face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
+ self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_functions.py b/tests/test_direct_api/test_functions.py
new file mode 100644
index 0000000..a8d7002
--- /dev/null
+++ b/tests/test_direct_api/test_functions.py
@@ -0,0 +1,105 @@
+"""
+build123d imports
+
+name: test_functions.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import unittest
+
+from build123d.geometry import Plane, Vector
+from build123d.objects_part import Box
+from build123d.topology import (
+ Compound,
+ Face,
+ Solid,
+ edges_to_wires,
+ polar,
+ new_edges,
+ delta,
+ unwrap_topods_compound,
+)
+
+
+class TestFunctions(unittest.TestCase):
+ def test_edges_to_wires(self):
+ square_edges = Face.make_rect(1, 1).edges()
+ rectangle_edges = Face.make_rect(2, 1, Plane((5, 0))).edges()
+ wires = edges_to_wires(square_edges + rectangle_edges)
+ self.assertEqual(len(wires), 2)
+ self.assertAlmostEqual(wires[0].length, 4, 5)
+ self.assertAlmostEqual(wires[1].length, 6, 5)
+
+ def test_polar(self):
+ pnt = polar(1, 30)
+ self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5)
+ self.assertAlmostEqual(pnt[1], 0.5, 5)
+
+ def test_new_edges(self):
+ c = Solid.make_cylinder(1, 5)
+ s = Solid.make_sphere(2)
+ s_minus_c = s - c
+ seams = new_edges(c, s, combined=s_minus_c)
+ self.assertEqual(len(seams), 1)
+ self.assertAlmostEqual(seams[0].radius, 1, 5)
+
+ def test_delta(self):
+ cyl = Solid.make_cylinder(1, 5)
+ sph = Solid.make_sphere(2)
+ con = Solid.make_cone(2, 1, 2)
+ plug = delta([cyl, sph, con], [sph, con])
+ self.assertEqual(len(plug), 1)
+ self.assertEqual(plug[0], cyl)
+
+ def test_parse_intersect_args(self):
+
+ with self.assertRaises(TypeError):
+ Vector(1, 1, 1) & ("x", "y", "z")
+
+ def test_unwrap_topods_compound(self):
+ # Complex Compound
+ b1 = Box(1, 1, 1).solid()
+ b2 = Box(2, 2, 2).solid()
+ c1 = Compound([b1, b2])
+ c2 = Compound([b1, c1])
+ c3 = Compound([c2])
+ c4 = Compound([c3])
+ self.assertEqual(c4.wrapped.NbChildren(), 1)
+ c5 = Compound(unwrap_topods_compound(c4.wrapped, False))
+ self.assertEqual(c5.wrapped.NbChildren(), 2)
+
+ # unwrap fully
+ c0 = Compound([b1])
+ c1 = Compound([c0])
+ result = Compound.cast(unwrap_topods_compound(c1.wrapped, True))
+ self.assertTrue(isinstance(result, Solid))
+
+ # unwrap not fully
+ result = Compound.cast(unwrap_topods_compound(c1.wrapped, False))
+ self.assertTrue(isinstance(result, Compound))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_group_by.py b/tests/test_direct_api/test_group_by.py
new file mode 100644
index 0000000..2e024e8
--- /dev/null
+++ b/tests/test_direct_api/test_group_by.py
@@ -0,0 +1,69 @@
+"""
+build123d imports
+
+name: test_group_by.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 pprint
+import unittest
+
+from build123d.geometry import Axis
+from build123d.topology import Solid
+
+
+class TestGroupBy(unittest.TestCase):
+
+ def setUp(self):
+ # Ensure the class variable is in its default state before each test
+ self.v = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
+
+ def test_str(self):
+ self.assertEqual(
+ str(self.v),
+ f"""[[Vertex(0.0, 0.0, 0.0),
+ Vertex(0.0, 1.0, 0.0),
+ Vertex(1.0, 0.0, 0.0),
+ Vertex(1.0, 1.0, 0.0)],
+ [Vertex(0.0, 0.0, 1.0),
+ Vertex(0.0, 1.0, 1.0),
+ Vertex(1.0, 0.0, 1.0),
+ Vertex(1.0, 1.0, 1.0)]]""",
+ )
+
+ def test_repr(self):
+ self.assertEqual(
+ repr(self.v),
+ "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
+ )
+
+ def test_pp(self):
+ self.assertEqual(
+ pprint.pformat(self.v),
+ "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_import_export.py b/tests/test_direct_api/test_import_export.py
new file mode 100644
index 0000000..9b22dd5
--- /dev/null
+++ b/tests/test_direct_api/test_import_export.py
@@ -0,0 +1,67 @@
+"""
+build123d imports
+
+name: test_import_export.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 os
+import unittest
+
+from build123d.exporters3d import export_brep, export_step
+from build123d.importers import import_brep, import_step, import_stl
+from build123d.mesher import Mesher
+from build123d.topology import Solid
+
+
+class TestImportExport(unittest.TestCase):
+ def test_import_export(self):
+ original_box = Solid.make_box(1, 1, 1)
+ export_step(original_box, "test_box.step")
+ step_box = import_step("test_box.step")
+ self.assertTrue(step_box.is_valid())
+ self.assertAlmostEqual(step_box.volume, 1, 5)
+ export_brep(step_box, "test_box.brep")
+ brep_box = import_brep("test_box.brep")
+ self.assertTrue(brep_box.is_valid())
+ self.assertAlmostEqual(brep_box.volume, 1, 5)
+ os.remove("test_box.step")
+ os.remove("test_box.brep")
+ with self.assertRaises(FileNotFoundError):
+ step_box = import_step("test_box.step")
+
+ def test_import_stl(self):
+ # export solid
+ original_box = Solid.make_box(1, 2, 3)
+ exporter = Mesher()
+ exporter.add_shape(original_box)
+ exporter.write("test.stl")
+
+ # import as face
+ stl_box = import_stl("test.stl")
+ self.assertAlmostEqual(stl_box.position, (0, 0, 0), 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_jupyter.py b/tests/test_direct_api/test_jupyter.py
new file mode 100644
index 0000000..51053a4
--- /dev/null
+++ b/tests/test_direct_api/test_jupyter.py
@@ -0,0 +1,57 @@
+"""
+build123d imports
+
+name: test_jupyter.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.geometry import Vector
+from build123d.jupyter_tools import to_vtkpoly_string, display
+from build123d.topology import Solid
+
+
+class TestJupyter(unittest.TestCase):
+ def test_repr_javascript(self):
+ shape = Solid.make_box(1, 1, 1)
+
+ # Test no exception on rendering to js
+ js1 = shape._repr_javascript_()
+
+ assert "function render" in js1
+
+ def test_display_error(self):
+ with self.assertRaises(AttributeError):
+ display(Vector())
+
+ with self.assertRaises(ValueError):
+ to_vtkpoly_string("invalid")
+
+ with self.assertRaises(ValueError):
+ display("invalid")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py
new file mode 100644
index 0000000..95fd94c
--- /dev/null
+++ b/tests/test_direct_api/test_location.py
@@ -0,0 +1,400 @@
+"""
+build123d imports
+
+name: test_location.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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.
+
+"""
+
+# Always equal to any other object, to test that __eq__ cooperation is working
+import copy
+import json
+import math
+import os
+import unittest
+from random import uniform
+
+import numpy as np
+from OCP.gp import (
+ gp_Ax1,
+ gp_Dir,
+ gp_EulerSequence,
+ gp_Pnt,
+ gp_Quaternion,
+ gp_Trsf,
+ gp_Vec,
+)
+from build123d.build_common import GridLocations
+from build123d.build_enums import Extrinsic, Intrinsic
+from build123d.geometry import Axis, Location, LocationEncoder, Plane, Pos, Vector
+from build123d.topology import Edge, Solid, Vertex
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+class TestLocation(unittest.TestCase):
+ def test_location(self):
+ loc0 = Location()
+ T = loc0.wrapped.Transformation().TranslationPart()
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 0), 1e-6)
+ angle = math.degrees(
+ loc0.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
+ self.assertAlmostEqual(0, angle)
+
+ # Tuple
+ loc0 = Location((0, 0, 1))
+
+ T = loc0.wrapped.Transformation().TranslationPart()
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
+
+ # List
+ loc0 = Location([0, 0, 1])
+
+ T = loc0.wrapped.Transformation().TranslationPart()
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
+
+ # Vector
+ loc1 = Location(Vector(0, 0, 1))
+
+ T = loc1.wrapped.Transformation().TranslationPart()
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
+
+ # rotation + translation
+ loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45)
+
+ angle = math.degrees(
+ loc2.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
+ self.assertAlmostEqual(45, angle)
+
+ # gp_Trsf
+ T = gp_Trsf()
+ T.SetTranslation(gp_Vec(0, 0, 1))
+ loc3 = Location(T)
+
+ self.assertEqual(
+ loc1.wrapped.Transformation().TranslationPart().Z(),
+ loc3.wrapped.Transformation().TranslationPart().Z(),
+ )
+
+ # Test creation from the OCP.gp.gp_Trsf object
+ loc4 = Location(gp_Trsf())
+ np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 0), 1e-7)
+ np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7)
+
+ # Test creation from Plane and Vector
+ loc4 = Location(Plane.XY, (0, 0, 1))
+ np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 1), 1e-7)
+ np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7)
+
+ # Test composition
+ loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15)
+
+ loc5 = loc1 * loc4
+ loc6 = loc4 * loc4
+ loc7 = loc4**2
+
+ T = loc5.wrapped.Transformation().TranslationPart()
+ np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
+
+ angle5 = math.degrees(
+ loc5.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
+ self.assertAlmostEqual(15, angle5)
+
+ angle6 = math.degrees(
+ loc6.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
+ self.assertAlmostEqual(30, angle6)
+
+ angle7 = math.degrees(
+ loc7.wrapped.Transformation().GetRotation().GetRotationAngle()
+ )
+ self.assertAlmostEqual(30, angle7)
+
+ # Test error handling on creation
+ with self.assertRaises(TypeError):
+ Location("xy_plane")
+
+ # Test that the computed rotation matrix and intrinsic euler angles return the same
+
+ about_x = uniform(-2 * math.pi, 2 * math.pi)
+ about_y = uniform(-2 * math.pi, 2 * math.pi)
+ about_z = uniform(-2 * math.pi, 2 * math.pi)
+
+ rot_x = gp_Trsf()
+ rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), about_x)
+ rot_y = gp_Trsf()
+ rot_y.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), about_y)
+ rot_z = gp_Trsf()
+ rot_z.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), about_z)
+ loc1 = Location(rot_x * rot_y * rot_z)
+
+ q = gp_Quaternion()
+ q.SetEulerAngles(
+ gp_EulerSequence.gp_Intrinsic_XYZ,
+ about_x,
+ about_y,
+ about_z,
+ )
+ t = gp_Trsf()
+ t.SetRotationPart(q)
+ loc2 = Location(t)
+
+ np.testing.assert_allclose(loc1.to_tuple()[0], loc2.to_tuple()[0], 1e-6)
+ np.testing.assert_allclose(loc1.to_tuple()[1], loc2.to_tuple()[1], 1e-6)
+
+ loc1 = Location((1, 2), 34)
+ np.testing.assert_allclose(loc1.to_tuple()[0], (1, 2, 0), 1e-6)
+ np.testing.assert_allclose(loc1.to_tuple()[1], (0, 0, 34), 1e-6)
+
+ rot_angles = (-115.00, 35.00, -135.00)
+ loc2 = Location((1, 2, 3), rot_angles)
+ np.testing.assert_allclose(loc2.to_tuple()[0], (1, 2, 3), 1e-6)
+ np.testing.assert_allclose(loc2.to_tuple()[1], rot_angles, 1e-6)
+
+ loc3 = Location(loc2)
+ np.testing.assert_allclose(loc3.to_tuple()[0], (1, 2, 3), 1e-6)
+ np.testing.assert_allclose(loc3.to_tuple()[1], rot_angles, 1e-6)
+
+ def test_location_parameters(self):
+ loc = Location((10, 20, 30))
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+
+ loc = Location((10, 20, 30), (10, 20, 30))
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
+
+ loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ)
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
+
+ loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX)
+ self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
+ self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
+
+ with self.assertRaises(TypeError):
+ Location(x=10)
+
+ with self.assertRaises(TypeError):
+ Location((10, 20, 30), (30, 20, 10), (10, 20, 30))
+
+ with self.assertRaises(TypeError):
+ Location(Intrinsic.XYZ)
+
+ def test_location_repr_and_str(self):
+ self.assertEqual(
+ repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))"
+ )
+ self.assertEqual(
+ str(Location()),
+ "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))",
+ )
+ loc = Location((1, 2, 3), (33, 45, 67))
+ self.assertEqual(
+ str(loc),
+ "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))",
+ )
+
+ def test_location_inverted(self):
+ loc = Location(Plane.XZ)
+ self.assertAlmostEqual(loc.inverse().orientation, (-90, 0, 0), 6)
+
+ def test_set_position(self):
+ loc = Location(Plane.XZ)
+ loc.position = (1, 2, 3)
+ self.assertAlmostEqual(loc.position, (1, 2, 3), 6)
+ self.assertAlmostEqual(loc.orientation, (90, 0, 0), 6)
+
+ def test_set_orientation(self):
+ loc = Location((1, 2, 3), (90, 0, 0))
+ loc.orientation = (-90, 0, 0)
+ self.assertAlmostEqual(loc.position, (1, 2, 3), 6)
+ self.assertAlmostEqual(loc.orientation, (-90, 0, 0), 6)
+
+ def test_copy(self):
+ loc1 = Location((1, 2, 3), (90, 45, 22.5))
+ loc2 = copy.copy(loc1)
+ loc3 = copy.deepcopy(loc1)
+ self.assertAlmostEqual(loc1.position, loc2.position.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.orientation, loc2.orientation.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.position, loc3.position.to_tuple(), 6)
+ self.assertAlmostEqual(loc1.orientation, loc3.orientation.to_tuple(), 6)
+
+ def test_to_axis(self):
+ axis = Location((1, 2, 3), (-90, 0, 0)).to_axis()
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 6)
+ self.assertAlmostEqual(axis.direction, (0, 1, 0), 6)
+
+ def test_equal(self):
+ loc = Location((1, 2, 3), (4, 5, 6))
+ same = Location((1, 2, 3), (4, 5, 6))
+
+ self.assertEqual(loc, same)
+ self.assertEqual(loc, AlwaysEqual())
+
+ def test_not_equal(self):
+ loc = Location((1, 2, 3), (40, 50, 60))
+ diff_position = Location((3, 2, 1), (40, 50, 60))
+ diff_orientation = Location((1, 2, 3), (60, 50, 40))
+
+ self.assertNotEqual(loc, diff_position)
+ self.assertNotEqual(loc, diff_orientation)
+ self.assertNotEqual(loc, object())
+
+ def test_neg(self):
+ loc = Location((1, 2, 3), (0, 35, 127))
+ n_loc = -loc
+ self.assertAlmostEqual(n_loc.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(n_loc.orientation, (180, -35, -127), 5)
+
+ def test_mult_iterable(self):
+ locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1)
+ self.assertAlmostEqual(locs[0].position, (-1, 2, 0), 5)
+ self.assertAlmostEqual(locs[1].position, (3, 2, 0), 5)
+
+ def test_as_json(self):
+ data_dict = {
+ "part1": {
+ "joint_one": Location((1, 2, 3), (4, 5, 6)),
+ "joint_two": Location((7, 8, 9), (10, 11, 12)),
+ },
+ "part2": {
+ "joint_one": Location((13, 14, 15), (16, 17, 18)),
+ "joint_two": Location((19, 20, 21), (22, 23, 24)),
+ },
+ }
+
+ # Serializing json with custom Location encoder
+ json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder)
+
+ # Writing to sample.json
+ with open("sample.json", "w") as outfile:
+ outfile.write(json_object)
+
+ # Reading from sample.json
+ with open("sample.json") as infile:
+ read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
+
+ # Validate locations
+ for key, value in read_json.items():
+ for k, v in value.items():
+ if key == "part1" and k == "joint_one":
+ self.assertAlmostEqual(v.position, (1, 2, 3), 5)
+ elif key == "part1" and k == "joint_two":
+ self.assertAlmostEqual(v.position, (7, 8, 9), 5)
+ elif key == "part2" and k == "joint_one":
+ self.assertAlmostEqual(v.position, (13, 14, 15), 5)
+ elif key == "part2" and k == "joint_two":
+ self.assertAlmostEqual(v.position, (19, 20, 21), 5)
+ else:
+ self.assertTrue(False)
+ os.remove("sample.json")
+
+ def test_intersection(self):
+ e = Edge.make_line((0, 0, 0), (1, 1, 1))
+ l0 = e.location_at(0)
+ l1 = e.location_at(1)
+ self.assertIsNone(l0 & l1)
+ self.assertEqual(l1 & l1, l1)
+
+ i = l1 & Vector(1, 1, 1)
+ self.assertTrue(isinstance(i, Vector))
+ self.assertAlmostEqual(i, (1, 1, 1), 5)
+
+ i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1))
+ self.assertTrue(isinstance(i, Location))
+ self.assertEqual(i, l1)
+
+ p = Plane.XY.rotated((45, 0, 0)).shift_origin((1, 0, 0))
+ l = Location((1, 0, 0), (1, 0, 0), 45)
+ i = l & p
+ self.assertTrue(isinstance(i, Location))
+ self.assertAlmostEqual(i.position, (1, 0, 0), 5)
+ self.assertAlmostEqual(i.orientation, l.orientation, 5)
+
+ b = Solid.make_box(1, 1, 1)
+ l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45)
+ i = (l & b).vertex()
+ self.assertTrue(isinstance(i, Vertex))
+ self.assertAlmostEqual(Vector(i), (0.5, 0.5, 0.5), 5)
+
+ e1 = Edge.make_line((0, -1), (2, 1))
+ e2 = Edge.make_line((0, 1), (2, -1))
+ e3 = Edge.make_line((0, 0), (2, 0))
+
+ i = e1.intersect(e2, e3)
+ self.assertTrue(isinstance(i, Vertex))
+ self.assertAlmostEqual(Vector(i), (1, 0, 0), 5)
+
+ e4 = Edge.make_line((1, -1), (1, 1))
+ e5 = Edge.make_line((2, -1), (2, 1))
+ i = e3.intersect(e4, e5)
+ self.assertIsNone(i)
+
+ self.assertIsNone(b.intersect(b.moved(Pos(X=10))))
+
+ # Look for common vertices
+ e1 = Edge.make_line((0, 0), (1, 0))
+ e2 = Edge.make_line((1, 0), (1, 1))
+ e3 = Edge.make_line((1, 0), (2, 0))
+ i = e1.intersect(e2)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+ i = e1.intersect(e3)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+
+ # Intersect with plane
+ e1 = Edge.make_line((0, 0), (2, 0))
+ p1 = Plane.YZ.offset(1)
+ i = e1.intersect(p1)
+ self.assertEqual(len(i.vertices()), 1)
+ self.assertEqual(tuple(i.vertex()), (1, 0, 0))
+
+ e2 = Edge.make_line(p1.origin, p1.origin + 2 * p1.x_dir)
+ i = e2.intersect(p1)
+ self.assertEqual(len(i.vertices()), 2)
+ self.assertEqual(len(i.edges()), 1)
+ self.assertAlmostEqual(i.edge().length, 2, 5)
+
+ with self.assertRaises(ValueError):
+ e1.intersect("line")
+
+ def test_pos(self):
+ with self.assertRaises(TypeError):
+ Pos(0, "foo")
+ self.assertEqual(Pos(1, 2, 3).position, Vector(1, 2, 3))
+ self.assertEqual(Pos((1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(v=(1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(X=1, Y=2, Z=3).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(Vector(1, 2, 3)).position, Vector(1, 2, 3))
+ self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_matrix.py b/tests/test_direct_api/test_matrix.py
new file mode 100644
index 0000000..09501b5
--- /dev/null
+++ b/tests/test_direct_api/test_matrix.py
@@ -0,0 +1,194 @@
+"""
+build123d imports
+
+name: test_matrix.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 copy
+import math
+import unittest
+
+from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf
+from build123d.geometry import Axis, Matrix, Vector
+
+
+class TestMatrix(unittest.TestCase):
+ def test_matrix_creation_and_access(self):
+ def matrix_vals(m):
+ return [[m[r, c] for c in range(4)] for r in range(4)]
+
+ # default constructor creates a 4x4 identity matrix
+ m = Matrix()
+ identity = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ self.assertEqual(identity, matrix_vals(m))
+
+ vals4x4 = [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ vals4x4_tuple = tuple(tuple(r) for r in vals4x4)
+
+ # test constructor with 16-value input
+ m = Matrix(vals4x4)
+ self.assertEqual(vals4x4, matrix_vals(m))
+ m = Matrix(vals4x4_tuple)
+ self.assertEqual(vals4x4, matrix_vals(m))
+
+ # test constructor with 12-value input (the last 4 are an implied
+ # [0,0,0,1])
+ m = Matrix(vals4x4[:3])
+ self.assertEqual(vals4x4, matrix_vals(m))
+ m = Matrix(vals4x4_tuple[:3])
+ self.assertEqual(vals4x4, matrix_vals(m))
+
+ # Test 16-value input with invalid values for the last 4
+ invalid = [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [1.0, 2.0, 3.0, 4.0],
+ ]
+ with self.assertRaises(ValueError):
+ Matrix(invalid)
+ # Test input with invalid type
+ with self.assertRaises(TypeError):
+ Matrix("invalid")
+ # Test input with invalid size / nested types
+ with self.assertRaises(TypeError):
+ Matrix([[1, 2, 3, 4], [1, 2, 3], [1, 2, 3, 4]])
+ with self.assertRaises(TypeError):
+ Matrix([1, 2, 3])
+
+ # Invalid sub-type
+ with self.assertRaises(TypeError):
+ Matrix([[1, 2, 3, 4], "abc", [1, 2, 3, 4]])
+
+ # test out-of-bounds access
+ m = Matrix()
+ with self.assertRaises(IndexError):
+ m[0, 4]
+ with self.assertRaises(IndexError):
+ m[4, 0]
+ with self.assertRaises(IndexError):
+ m["ab"]
+
+ # test __repr__ methods
+ m = Matrix(vals4x4)
+ mRepr = "Matrix([[1.0, 0.0, 0.0, 1.0],\n [0.0, 1.0, 0.0, 2.0],\n [0.0, 0.0, 1.0, 3.0],\n [0.0, 0.0, 0.0, 1.0]])"
+ self.assertEqual(repr(m), mRepr)
+ self.assertEqual(str(eval(repr(m))), mRepr)
+
+ def test_matrix_functionality(self):
+ # Test rotate methods
+ def matrix_almost_equal(m, target_matrix):
+ for r, row in enumerate(target_matrix):
+ for c, target_value in enumerate(row):
+ self.assertAlmostEqual(m[r, c], target_value)
+
+ root_3_over_2 = math.sqrt(3) / 2
+ m_rotate_x_30 = [
+ [1, 0, 0, 0],
+ [0, root_3_over_2, -1 / 2, 0],
+ [0, 1 / 2, root_3_over_2, 0],
+ [0, 0, 0, 1],
+ ]
+ mx = Matrix()
+ mx.rotate(Axis.X, math.radians(30))
+ matrix_almost_equal(mx, m_rotate_x_30)
+
+ m_rotate_y_30 = [
+ [root_3_over_2, 0, 1 / 2, 0],
+ [0, 1, 0, 0],
+ [-1 / 2, 0, root_3_over_2, 0],
+ [0, 0, 0, 1],
+ ]
+ my = Matrix()
+ my.rotate(Axis.Y, math.radians(30))
+ matrix_almost_equal(my, m_rotate_y_30)
+
+ m_rotate_z_30 = [
+ [root_3_over_2, -1 / 2, 0, 0],
+ [1 / 2, root_3_over_2, 0, 0],
+ [0, 0, 1, 0],
+ [0, 0, 0, 1],
+ ]
+ mz = Matrix()
+ mz.rotate(Axis.Z, math.radians(30))
+ matrix_almost_equal(mz, m_rotate_z_30)
+
+ # Test matrix multiply vector
+ v = Vector(1, 0, 0)
+ self.assertAlmostEqual(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7)
+
+ # Test matrix multiply matrix
+ m_rotate_xy_30 = [
+ [root_3_over_2, 0, 1 / 2, 0],
+ [1 / 4, root_3_over_2, -root_3_over_2 / 2, 0],
+ [-root_3_over_2 / 2, 1 / 2, 3 / 4, 0],
+ [0, 0, 0, 1],
+ ]
+ mxy = mx.multiply(my)
+ matrix_almost_equal(mxy, m_rotate_xy_30)
+
+ # Test matrix inverse
+ vals4x4 = [[1, 2, 3, 4], [5, 1, 6, 7], [8, 9, 1, 10], [0, 0, 0, 1]]
+ vals4x4_invert = [
+ [-53 / 144, 25 / 144, 1 / 16, -53 / 144],
+ [43 / 144, -23 / 144, 1 / 16, -101 / 144],
+ [37 / 144, 7 / 144, -1 / 16, -107 / 144],
+ [0, 0, 0, 1],
+ ]
+ m = Matrix(vals4x4).inverse()
+ matrix_almost_equal(m, vals4x4_invert)
+
+ # Test matrix created from transfer function
+ rot_x = gp_Trsf()
+ θ = math.pi
+ rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), θ)
+ m = Matrix(rot_x)
+ rot_x_matrix = [
+ [1, 0, 0, 0],
+ [0, math.cos(θ), -math.sin(θ), 0],
+ [0, math.sin(θ), math.cos(θ), 0],
+ [0, 0, 0, 1],
+ ]
+ matrix_almost_equal(m, rot_x_matrix)
+
+ # Test copy
+ m2 = copy.copy(m)
+ matrix_almost_equal(m2, rot_x_matrix)
+ m3 = copy.deepcopy(m)
+ matrix_almost_equal(m3, rot_x_matrix)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py
new file mode 100644
index 0000000..ebd1744
--- /dev/null
+++ b/tests/test_direct_api/test_mixin1_d.py
@@ -0,0 +1,318 @@
+"""
+build123d imports
+
+name: test_mixin1_d.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import unittest
+
+from build123d.build_enums import CenterOf, GeomType, PositionMode, Side, SortBy
+from build123d.geometry import Axis, Location, Plane, Vector
+from build123d.objects_part import Box, Cylinder
+from build123d.topology import Compound, Edge, Face, Wire
+
+
+class TestMixin1D(unittest.TestCase):
+ """Test the add in methods"""
+
+ def test_position_at(self):
+ self.assertAlmostEqual(
+ Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5),
+ (0.5, 0.5, 0.5),
+ 5,
+ )
+ # Not sure what PARAMETER mode returns - but it's in the ballpark
+ point = (
+ Edge.make_line((0, 0, 0), (1, 1, 1))
+ .position_at(0.5, position_mode=PositionMode.PARAMETER)
+ .to_tuple()
+ )
+ self.assertTrue(all([0.0 < v < 1.0 for v in point]))
+
+ wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))])
+ self.assertAlmostEqual(wire.position_at(0.3), (3, 0, 0), 5)
+ self.assertAlmostEqual(
+ wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
+ )
+ self.assertAlmostEqual(wire.edge().position_at(0.3), (3, 0, 0), 5)
+ self.assertAlmostEqual(
+ wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
+ )
+
+ circle_wire = Wire(
+ [
+ Edge.make_circle(1, start_angle=0, end_angle=180),
+ Edge.make_circle(1, start_angle=180, end_angle=360),
+ ]
+ )
+ p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH)
+ p2 = circle_wire.position_at(math.pi / circle_wire.length)
+ self.assertAlmostEqual(p1, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p2, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p1, p2, 14)
+
+ circle_edge = Edge.make_circle(1)
+ p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH)
+ p4 = circle_edge.position_at(math.pi / circle_edge.length)
+ self.assertAlmostEqual(p3, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p4, (-1, 0, 0), 14)
+ self.assertAlmostEqual(p3, p4, 14)
+
+ circle = Wire(
+ [
+ Edge.make_circle(2, start_angle=0, end_angle=180),
+ Edge.make_circle(2, start_angle=180, end_angle=360),
+ ]
+ )
+ self.assertAlmostEqual(
+ circle.position_at(0.5),
+ (-2, 0, 0),
+ 5,
+ )
+ self.assertAlmostEqual(
+ circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH),
+ (-2, 0, 0),
+ 5,
+ )
+
+ def test_positions(self):
+ e = Edge.make_line((0, 0, 0), (1, 1, 1))
+ distances = [i / 4 for i in range(3)]
+ pts = e.positions(distances)
+ for i, position in enumerate(pts):
+ self.assertAlmostEqual(position, (i / 4, i / 4, i / 4), 5)
+
+ def test_tangent_at(self):
+ self.assertAlmostEqual(
+ Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0),
+ (-1, 0, 0),
+ 5,
+ )
+ tangent = (
+ Edge.make_circle(1, start_angle=0, end_angle=90)
+ .tangent_at(0.0, position_mode=PositionMode.PARAMETER)
+ .to_tuple()
+ )
+ self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent]))
+
+ self.assertAlmostEqual(
+ Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at(
+ math.pi / 2, position_mode=PositionMode.LENGTH
+ ),
+ (-1, 0, 0),
+ 5,
+ )
+
+ def test_tangent_at_point(self):
+ circle = Wire(
+ [
+ Edge.make_circle(1, start_angle=0, end_angle=180),
+ Edge.make_circle(1, start_angle=180, end_angle=360),
+ ]
+ )
+ pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4))
+ tan = circle.tangent_at(pnt_on_circle)
+ self.assertAlmostEqual(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5)
+
+ def test_tangent_at_by_length(self):
+ circle = Edge.make_circle(1)
+ tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH)
+ self.assertAlmostEqual(tan, (0, -1), 5)
+
+ def test_tangent_at_error(self):
+ with self.assertRaises(ValueError):
+ Edge.make_circle(1).tangent_at("start")
+
+ def test_normal(self):
+ self.assertAlmostEqual(
+ Edge.make_circle(
+ 1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60
+ ).normal(),
+ (1, 0, 0),
+ 5,
+ )
+ self.assertAlmostEqual(
+ Edge.make_ellipse(
+ 1,
+ 0.5,
+ Plane(origin=(0, 0, 0), z_dir=(1, 1, 0)),
+ start_angle=0,
+ end_angle=90,
+ ).normal(),
+ (math.sqrt(2) / 2, math.sqrt(2) / 2, 0),
+ 5,
+ )
+ self.assertAlmostEqual(
+ Edge.make_spline(
+ [
+ (1, 0),
+ (math.sqrt(2) / 2, math.sqrt(2) / 2),
+ (0, 1),
+ ],
+ tangents=((0, 1, 0), (-1, 0, 0)),
+ ).normal(),
+ (0, 0, 1),
+ 5,
+ )
+ with self.assertRaises(ValueError):
+ Edge.make_line((0, 0, 0), (1, 1, 1)).normal()
+
+ def test_center(self):
+ c = Edge.make_circle(1, start_angle=0, end_angle=180)
+ self.assertAlmostEqual(c.center(), (0, 1, 0), 5)
+ self.assertAlmostEqual(
+ c.center(CenterOf.MASS),
+ (0, 0.6366197723675814, 0),
+ 5,
+ )
+ self.assertAlmostEqual(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5)
+
+ def test_location_at(self):
+ loc = Edge.make_circle(1).location_at(0.25)
+ self.assertAlmostEqual(loc.position, (0, 1, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5)
+
+ loc = Edge.make_circle(1).location_at(
+ math.pi / 2, position_mode=PositionMode.LENGTH
+ )
+ self.assertAlmostEqual(loc.position, (0, 1, 0), 5)
+ self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5)
+
+ def test_locations(self):
+ locs = Edge.make_circle(1).locations([i / 4 for i in range(4)])
+ self.assertAlmostEqual(locs[0].position, (1, 0, 0), 5)
+ self.assertAlmostEqual(locs[0].orientation, (-90, 0, -180), 5)
+ self.assertAlmostEqual(locs[1].position, (0, 1, 0), 5)
+ self.assertAlmostEqual(locs[1].orientation, (0, -90, -90), 5)
+ self.assertAlmostEqual(locs[2].position, (-1, 0, 0), 5)
+ self.assertAlmostEqual(locs[2].orientation, (90, 0, 0), 5)
+ self.assertAlmostEqual(locs[3].position, (0, -1, 0), 5)
+ self.assertAlmostEqual(locs[3].orientation, (0, 90, 90), 5)
+
+ def test_project(self):
+ target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0)))
+ circle = Edge.make_circle(1).locate(Location((0, 0, 10)))
+ ellipse: Wire = circle.project(target, (0, 0, -1))
+ bbox = ellipse.bounding_box()
+ self.assertAlmostEqual(bbox.min, (-1, -1, -1), 5)
+ self.assertAlmostEqual(bbox.max, (1, 1, 1), 5)
+
+ def test_project2(self):
+ target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
+ square = Wire.make_rect(1, 1, Plane.YZ).locate(Location((10, 0, 0)))
+ projections: list[Wire] = square.project(
+ target, direction=(-1, 0, 0), closest=False
+ )
+ self.assertEqual(len(projections), 2)
+
+ def test_is_forward(self):
+ plate = Box(10, 10, 1) - Cylinder(1, 1)
+ hole_edges = plate.edges().filter_by(GeomType.CIRCLE)
+ self.assertTrue(hole_edges.sort_by(Axis.Z)[-1].is_forward)
+ self.assertFalse(hole_edges.sort_by(Axis.Z)[0].is_forward)
+
+ def test_offset_2d(self):
+ base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False)
+ corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1]
+ base_wire = base_wire.fillet_2d(0.4, [corner])
+ offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT)
+ self.assertTrue(offset_wire.is_closed)
+ self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6)
+ self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2)
+ offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT)
+ self.assertAlmostEqual(
+ offset_wire_right.edges()
+ .filter_by(GeomType.CIRCLE)
+ .sort_by(SortBy.RADIUS)[-1]
+ .radius,
+ 0.5,
+ 4,
+ )
+ h_perimeter = Compound.make_text("h", font_size=10).wire()
+ with self.assertRaises(RuntimeError):
+ h_perimeter.offset_2d(-1)
+
+ # Test for returned Edge - can't find a way to do this
+ # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50)
+ # self.assertTrue(isinstance(offset_edge, Edge))
+ # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
+ # self.assertTrue(offset_edge.geom_type == GeomType.CIRCLE)
+ # self.assertAlmostEqual(offset_edge.radius, 12, 5)
+ # base_edge = Edge.make_line((0, 1), (1, 10))
+ # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
+ # self.assertTrue(isinstance(offset_edge, Edge))
+ # self.assertTrue(offset_edge.geom_type == GeomType.LINE)
+ # self.assertAlmostEqual(offset_edge.position_at(0).X, 3)
+
+ def test_common_plane(self):
+ # Straight and circular lines
+ l = Edge.make_line((0, 0, 0), (5, 0, 0))
+ c = Edge.make_circle(2, Plane.XZ, -90, 90)
+ common = l.common_plane(c)
+ self.assertAlmostEqual(common.z_dir.X, 0, 5)
+ self.assertAlmostEqual(abs(common.z_dir.Y), 1, 5) # the direction isn't known
+ self.assertAlmostEqual(common.z_dir.Z, 0, 5)
+
+ # Co-axial straight lines
+ l1 = Edge.make_line((0, 0), (1, 1))
+ l2 = Edge.make_line((0.25, 0.25), (0.75, 0.75))
+ common = l1.common_plane(l2)
+ # the z_dir isn't know
+ self.assertAlmostEqual(common.x_dir.Z, 0, 5)
+
+ # Parallel lines
+ l1 = Edge.make_line((0, 0), (1, 0))
+ l2 = Edge.make_line((0, 1), (1, 1))
+ common = l1.common_plane(l2)
+ self.assertAlmostEqual(common.z_dir.X, 0, 5)
+ self.assertAlmostEqual(common.z_dir.Y, 0, 5)
+ self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
+
+ # Many lines
+ common = Edge.common_plane(*Wire.make_rect(10, 10).edges())
+ self.assertAlmostEqual(common.z_dir.X, 0, 5)
+ self.assertAlmostEqual(common.z_dir.Y, 0, 5)
+ self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
+
+ # Wire and Edges
+ c = Wire.make_circle(1, Plane.YZ)
+ lines = Wire.make_rect(2, 2, Plane.YZ).edges()
+ common = c.common_plane(*lines)
+ self.assertAlmostEqual(abs(common.z_dir.X), 1, 5) # the direction isn't known
+ self.assertAlmostEqual(common.z_dir.Y, 0, 5)
+ self.assertAlmostEqual(common.z_dir.Z, 0, 5)
+
+ def test_edge_volume(self):
+ edge = Edge.make_line((0, 0), (1, 1))
+ self.assertAlmostEqual(edge.volume, 0, 5)
+
+ def test_wire_volume(self):
+ wire = Wire.make_rect(1, 1)
+ self.assertAlmostEqual(wire.volume, 0, 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_mixin3_d.py b/tests/test_direct_api/test_mixin3_d.py
new file mode 100644
index 0000000..5e04e7b
--- /dev/null
+++ b/tests/test_direct_api/test_mixin3_d.py
@@ -0,0 +1,154 @@
+"""
+build123d imports
+
+name: test_mixin3_d.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 unittest.mock import patch
+
+from build123d.build_enums import CenterOf, Kind
+from build123d.geometry import Axis, Plane
+from build123d.topology import Face, Shape, Solid
+
+
+class TestMixin3D(unittest.TestCase):
+ """Test that 3D add ins"""
+
+ def test_chamfer(self):
+ box = Solid.make_box(1, 1, 1)
+ chamfer_box = box.chamfer(0.1, None, box.edges().sort_by(Axis.Z)[-1:])
+ self.assertAlmostEqual(chamfer_box.volume, 1 - 0.005, 5)
+
+ def test_chamfer_asym_length(self):
+ box = Solid.make_box(1, 1, 1)
+ chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:])
+ self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
+
+ def test_chamfer_asym_length_with_face(self):
+ box = Solid.make_box(1, 1, 1)
+ face = box.faces().sort_by(Axis.Z)[0]
+ edge = [face.edges().sort_by(Axis.Y)[0]]
+ chamfer_box = box.chamfer(0.1, 0.2, edge, face=face)
+ self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
+
+ def test_chamfer_too_high_length(self):
+ box = Solid.make_box(1, 1, 1)
+ face = box.faces
+ self.assertRaises(
+ ValueError, box.chamfer, 2, None, box.edges().sort_by(Axis.Z)[-1:]
+ )
+
+ def test_chamfer_edge_not_part_of_face(self):
+ box = Solid.make_box(1, 1, 1)
+ edge = box.edges().sort_by(Axis.Z)[-1:]
+ face = box.faces().sort_by(Axis.Z)[0]
+ self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face)
+
+ @patch.object(Shape, "is_valid", return_value=False)
+ def test_chamfer_invalid_shape_raises_error(self, mock_is_valid):
+ box = Solid.make_box(1, 1, 1)
+
+ # Assert that ValueError is raised
+ with self.assertRaises(ValueError) as chamfer_context:
+ max = box.chamfer(0.1, None, box.edges())
+
+ # Check the error message
+ self.assertEqual(
+ str(chamfer_context.exception),
+ "Failed creating a chamfer, try a smaller length value(s)",
+ )
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ def test_hollow(self):
+ shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1)
+ self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5)
+
+ shell_box = Solid.make_box(1, 1, 1)
+ shell_box = shell_box.hollow(
+ shell_box.faces().filter_by(Axis.Z), thickness=0.1, kind=Kind.INTERSECTION
+ )
+ self.assertAlmostEqual(shell_box.volume, 1 * 1.2**2 - 1**3, 5)
+
+ shell_box = Solid.make_box(1, 1, 1).hollow(
+ [], thickness=0.1, kind=Kind.INTERSECTION
+ )
+ self.assertAlmostEqual(shell_box.volume, 1.2**3 - 1**3, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT)
+
+ def test_is_inside(self):
+ self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5)))
+
+ def test_dprism(self):
+ # face
+ f = Face.make_rect(0.5, 0.5)
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [f], additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
+
+ # face with depth
+ f = Face.make_rect(0.5, 0.5)
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [f], depth=0.5, thru_all=False, additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
+
+ # face until
+ f = Face.make_rect(0.5, 0.5)
+ limit = Face.make_rect(1, 1, Plane((0, 0, 0.5)))
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [f], up_to_face=limit, thru_all=False, additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
+
+ # wire
+ w = Face.make_rect(0.5, 0.5).outer_wire()
+ d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
+ None, [w], additive=False
+ )
+ self.assertTrue(d.is_valid())
+ self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
+
+ def test_center(self):
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY)
+
+ self.assertAlmostEqual(
+ Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX),
+ (0.5, 0.5, 0.5),
+ 5,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py
new file mode 100644
index 0000000..3a2f899
--- /dev/null
+++ b/tests/test_direct_api/test_plane.py
@@ -0,0 +1,480 @@
+"""
+build123d imports
+
+name: test_plane.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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.
+
+"""
+
+# Always equal to any other object, to test that __eq__ cooperation is working
+import copy
+import math
+import random
+import unittest
+
+import numpy as np
+from OCP.BRepGProp import BRepGProp
+from OCP.GProp import GProp_GProps
+from build123d.build_common import Locations
+from build123d.build_enums import Align, GeomType, Mode
+from build123d.build_part import BuildPart
+from build123d.build_sketch import BuildSketch
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import Circle, Rectangle
+from build123d.operations_generic import fillet, add
+from build123d.operations_part import extrude
+from build123d.topology import Edge, Face, Solid, Vertex
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+class TestPlane(unittest.TestCase):
+ """Plane with class properties"""
+
+ def test_class_properties(self):
+ """Validate
+ Name x_dir y_dir z_dir
+ ======= ====== ====== ======
+ XY +x +y +z
+ YZ +y +z +x
+ ZX +z +x +y
+ XZ +x +z -y
+ YX +y +x -z
+ ZY +z +y -x
+ front +x +z -y
+ back -x +z +y
+ left -y +z -x
+ right +y +z +x
+ top +x +y +z
+ bottom +x -y -z
+ isometric +x+y -x+y+z +x+y-z
+ """
+ planes = [
+ (Plane.XY, (1, 0, 0), (0, 0, 1)),
+ (Plane.YZ, (0, 1, 0), (1, 0, 0)),
+ (Plane.ZX, (0, 0, 1), (0, 1, 0)),
+ (Plane.XZ, (1, 0, 0), (0, -1, 0)),
+ (Plane.YX, (0, 1, 0), (0, 0, -1)),
+ (Plane.ZY, (0, 0, 1), (-1, 0, 0)),
+ (Plane.front, (1, 0, 0), (0, -1, 0)),
+ (Plane.back, (-1, 0, 0), (0, 1, 0)),
+ (Plane.left, (0, -1, 0), (-1, 0, 0)),
+ (Plane.right, (0, 1, 0), (1, 0, 0)),
+ (Plane.top, (1, 0, 0), (0, 0, 1)),
+ (Plane.bottom, (1, 0, 0), (0, 0, -1)),
+ (
+ Plane.isometric,
+ (1 / 2**0.5, 1 / 2**0.5, 0),
+ (1 / 3**0.5, -1 / 3**0.5, 1 / 3**0.5),
+ ),
+ ]
+ for plane, x_dir, z_dir in planes:
+ self.assertAlmostEqual(plane.x_dir, x_dir, 5)
+ self.assertAlmostEqual(plane.z_dir, z_dir, 5)
+
+ def test_plane_init(self):
+ # from origin
+ o = (0, 0, 0)
+ x = (1, 0, 0)
+ y = (0, 1, 0)
+ z = (0, 0, 1)
+ planes = [
+ Plane(o),
+ Plane(o, x),
+ Plane(o, x, z),
+ Plane(o, x, z_dir=z),
+ Plane(o, x_dir=x, z_dir=z),
+ Plane(o, x_dir=x),
+ Plane(o, z_dir=z),
+ Plane(origin=o, x_dir=x, z_dir=z),
+ Plane(origin=o, x_dir=x),
+ Plane(origin=o, z_dir=z),
+ ]
+ for p in planes:
+ self.assertAlmostEqual(p.origin, o, 6)
+ self.assertAlmostEqual(p.x_dir, x, 6)
+ self.assertAlmostEqual(p.y_dir, y, 6)
+ self.assertAlmostEqual(p.z_dir, z, 6)
+ with self.assertRaises(TypeError):
+ Plane()
+ with self.assertRaises(TypeError):
+ Plane(o, z_dir="up")
+
+ # rotated location around z
+ loc = Location((0, 0, 0), (0, 0, 45))
+ p_from_loc = Plane(loc)
+ p_from_named_loc = Plane(location=loc)
+ for p in [p_from_loc, p_from_named_loc]:
+ self.assertAlmostEqual(p.origin, (0, 0, 0), 6)
+ self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6)
+ self.assertAlmostEqual(loc.position, p.location.position, 6)
+ self.assertAlmostEqual(loc.orientation, p.location.orientation, 6)
+
+ # rotated location around x and origin <> (0,0,0)
+ loc = Location((0, 2, -1), (45, 0, 0))
+ p = Plane(loc)
+ self.assertAlmostEqual(p.origin, (0, 2, -1), 6)
+ self.assertAlmostEqual(p.x_dir, (1, 0, 0), 6)
+ self.assertAlmostEqual(p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(loc.position, p.location.position, 6)
+ self.assertAlmostEqual(loc.orientation, p.location.orientation, 6)
+
+ # from a face
+ f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45)))
+ p_from_face = Plane(f)
+ p_from_named_face = Plane(face=f)
+ plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped)
+ p_deep_copy = copy.deepcopy(p_from_face)
+ for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]:
+ self.assertAlmostEqual(p.origin, (1, 2, 3), 6)
+ self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
+ self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
+ self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(f.location.position, p.location.position, 6)
+ self.assertAlmostEqual(f.location.orientation, p.location.orientation, 6)
+
+ # from a face with x_dir
+ f = Face.make_rect(1, 2)
+ x = (1, 1)
+ y = (-1, 1)
+ planes = [
+ Plane(f, x),
+ Plane(f, x_dir=x),
+ Plane(face=f, x_dir=x),
+ ]
+ for p in planes:
+ self.assertAlmostEqual(p.origin, (0, 0, 0), 6)
+ self.assertAlmostEqual(p.x_dir, Vector(x).normalized(), 6)
+ self.assertAlmostEqual(p.y_dir, Vector(y).normalized(), 6)
+ self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6)
+
+ with self.assertRaises(TypeError):
+ Plane(Edge.make_line((0, 0), (0, 1)))
+
+ # can be instantiated from planar faces of surface types other than Geom_Plane
+ # this loft creates the trapezoid faces of type Geom_BSplineSurface
+ lofted_solid = Solid.make_loft(
+ [
+ Rectangle(3, 1).wire(),
+ Pos(0, 0, 1) * Rectangle(1, 1).wire(),
+ ]
+ )
+
+ expected = [
+ # Trapezoid face, negative y coordinate
+ (
+ Axis.X.direction, # plane x_dir
+ Axis.Z.direction, # plane y_dir
+ -Axis.Y.direction, # plane z_dir
+ ),
+ # Trapezoid face, positive y coordinate
+ (
+ -Axis.X.direction,
+ Axis.Z.direction,
+ Axis.Y.direction,
+ ),
+ ]
+ # assert properties of the trapezoid faces
+ for i, f in enumerate(lofted_solid.faces() | Plane.XZ > Axis.Y):
+ p = Plane(f)
+ f_props = GProp_GProps()
+ BRepGProp.SurfaceProperties_s(f.wrapped, f_props)
+ self.assertAlmostEqual(p.origin, Vector(f_props.CentreOfMass()), 6)
+ self.assertAlmostEqual(p.x_dir, expected[i][0], 6)
+ self.assertAlmostEqual(p.y_dir, expected[i][1], 6)
+ self.assertAlmostEqual(p.z_dir, expected[i][2], 6)
+
+ def test_plane_neg(self):
+ p = Plane(
+ origin=(1, 2, 3),
+ x_dir=Vector(1, 2, 3).normalized(),
+ z_dir=Vector(4, 5, 6).normalized(),
+ )
+ p2 = -p
+ self.assertAlmostEqual(p2.origin, p.origin, 6)
+ self.assertAlmostEqual(p2.x_dir, p.x_dir, 6)
+ self.assertAlmostEqual(p2.z_dir, -p.z_dir, 6)
+ self.assertAlmostEqual(p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6)
+ p3 = p.reverse()
+ self.assertAlmostEqual(p3.origin, p.origin, 6)
+ self.assertAlmostEqual(p3.x_dir, p.x_dir, 6)
+ self.assertAlmostEqual(p3.z_dir, -p.z_dir, 6)
+ self.assertAlmostEqual(p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6)
+
+ def test_plane_mul(self):
+ p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1))
+ p2 = p * Location((1, 2, -1), (0, 0, 45))
+ self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
+ self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
+ self.assertAlmostEqual(p2.z_dir, (0, 0, 1), 6)
+
+ p2 = p * Location((1, 2, -1), (0, 45, 0))
+ self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
+ self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(p2.y_dir, (0, 1, 0), 6)
+ self.assertAlmostEqual(p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6)
+
+ p2 = p * Location((1, 2, -1), (45, 0, 0))
+ self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
+ self.assertAlmostEqual(p2.x_dir, (1, 0, 0), 6)
+ self.assertAlmostEqual(p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ self.assertAlmostEqual(p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
+ with self.assertRaises(TypeError):
+ p2 * Vector(1, 1, 1)
+
+ def test_plane_methods(self):
+ # Test error checking
+ p = Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 0))
+ with self.assertRaises(ValueError):
+ p.to_local_coords("box")
+
+ # Test translation to local coordinates
+ local_box = p.to_local_coords(Solid.make_box(1, 1, 1))
+ local_box_vertices = [(v.X, v.Y, v.Z) for v in local_box.vertices()]
+ target_vertices = [
+ (0, -1, 0),
+ (0, 0, 0),
+ (0, -1, 1),
+ (0, 0, 1),
+ (1, -1, 0),
+ (1, 0, 0),
+ (1, -1, 1),
+ (1, 0, 1),
+ ]
+ for i, target_point in enumerate(target_vertices):
+ np.testing.assert_allclose(target_point, local_box_vertices[i], 1e-7)
+
+ def test_localize_vertex(self):
+ vertex = Vertex(random.random(), random.random(), random.random())
+ np.testing.assert_allclose(
+ Plane.YZ.to_local_coords(vertex).to_tuple(),
+ Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(),
+ 5,
+ )
+
+ def test_repr(self):
+ self.assertEqual(
+ repr(Plane.XY),
+ "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))",
+ )
+
+ def test_shift_origin_axis(self):
+ cyl = Cylinder(1, 2, align=Align.MIN)
+ top = cyl.faces().sort_by(Axis.Z)[-1]
+ pln = Plane(top).shift_origin(Axis.Z)
+ with BuildPart() as p:
+ add(cyl)
+ with BuildSketch(pln):
+ with Locations((1, 1)):
+ Circle(0.5)
+ extrude(amount=-2, mode=Mode.SUBTRACT)
+ self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5)
+
+ def test_shift_origin_vertex(self):
+ box = Box(1, 1, 1, align=Align.MIN)
+ front = box.faces().sort_by(Axis.X)[-1]
+ pln = Plane(front).shift_origin(
+ front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1]
+ )
+ with BuildPart() as p:
+ add(box)
+ with BuildSketch(pln):
+ with Locations((-0.5, 0.5)):
+ Circle(0.5)
+ extrude(amount=-1, mode=Mode.SUBTRACT)
+ self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5)
+
+ def test_shift_origin_vector(self):
+ with BuildPart() as p:
+ Box(4, 4, 2)
+ b = fillet(p.edges().filter_by(Axis.Z), 0.5)
+ top = p.faces().sort_by(Axis.Z)[-1]
+ ref = (
+ top.edges()
+ .filter_by(GeomType.CIRCLE)
+ .group_by(Axis.X)[-1]
+ .sort_by(Axis.Y)[0]
+ .arc_center
+ )
+ pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref)
+ with BuildSketch(pln):
+ with Locations((0.5, 0.5)):
+ Rectangle(2, 2, align=Align.MIN)
+ extrude(amount=-1, mode=Mode.SUBTRACT)
+ self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5)
+
+ def test_shift_origin_error(self):
+ with self.assertRaises(ValueError):
+ Plane.XY.shift_origin(Vertex(1, 1, 1))
+
+ with self.assertRaises(ValueError):
+ Plane.XY.shift_origin((1, 1, 1))
+
+ with self.assertRaises(ValueError):
+ Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0)))
+
+ with self.assertRaises(TypeError):
+ Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1)))
+
+ def test_move(self):
+ pln = Plane.XY.move(Location((1, 2, 3)))
+ self.assertAlmostEqual(pln.origin, (1, 2, 3), 5)
+
+ def test_rotated(self):
+ rotated_plane = Plane.XY.rotated((45, 0, 0))
+ self.assertAlmostEqual(rotated_plane.x_dir, (1, 0, 0), 5)
+ self.assertAlmostEqual(
+ rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5
+ )
+
+ def test_invalid_plane(self):
+ # Test plane creation error handling
+ with self.assertRaises(ValueError):
+ Plane(origin=(0, 0, 0), x_dir=(0, 0, 0), z_dir=(0, 1, 1))
+ with self.assertRaises(ValueError):
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 0))
+
+ def test_plane_equal(self):
+ # default orientation
+ self.assertEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ )
+ # moved origin
+ self.assertEqual(
+ Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ )
+ # moved x-axis
+ self.assertEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
+ )
+ # moved z-axis
+ self.assertEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
+ )
+ # __eq__ cooperation
+ self.assertEqual(Plane.XY, AlwaysEqual())
+
+ def test_plane_not_equal(self):
+ # type difference
+ for value in [None, 0, 1, "abc"]:
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), value
+ )
+ # origin difference
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ )
+ # x-axis difference
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
+ )
+ # z-axis difference
+ self.assertNotEqual(
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
+ Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
+ )
+
+ def test_to_location(self):
+ loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location
+ self.assertAlmostEqual(loc.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(loc.orientation, (0, 0, 90), 5)
+
+ def test_intersect(self):
+ self.assertAlmostEqual(
+ Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5
+ )
+ self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0))))
+
+ self.assertEqual(Plane.XY.intersect(Plane.XZ), Axis.X)
+
+ self.assertIsNone(Plane.XY.intersect(Plane.XY.offset(1)))
+
+ with self.assertRaises(ValueError):
+ Plane.XY.intersect("Plane.XZ")
+
+ with self.assertRaises(ValueError):
+ Plane.XY.intersect(pln=Plane.XZ)
+
+ def test_from_non_planar_face(self):
+ flat = Face.make_rect(1, 1)
+ pln = Plane(flat)
+ self.assertTrue(isinstance(pln, Plane))
+ cyl = (
+ Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0]
+ )
+ with self.assertRaises(ValueError):
+ pln = Plane(cyl)
+
+ def test_plane_intersect(self):
+ section = Plane.XY.intersect(Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5)))
+ self.assertEqual(len(section.solids()), 0)
+ self.assertEqual(len(section.faces()), 1)
+ self.assertAlmostEqual(section.face().area, 2)
+
+ section = Plane.XY & Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5))
+ self.assertEqual(len(section.solids()), 0)
+ self.assertEqual(len(section.faces()), 1)
+ self.assertAlmostEqual(section.face().area, 2)
+
+ self.assertEqual(Plane.XY & Plane.XZ, Axis.X)
+ # x_axis_as_edge = Plane.XY & Plane.XZ
+ # common = (x_axis_as_edge.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
+ # self.assertAlmostEqual(common.length, 1, 5)
+
+ i = Plane.XY & Vector(1, 2)
+ self.assertTrue(isinstance(i, Vector))
+ self.assertAlmostEqual(i, (1, 2, 0), 5)
+
+ a = Axis((0, 0, 0), (1, 1, 0))
+ i = Plane.XY & a
+ self.assertTrue(isinstance(i, Axis))
+ self.assertEqual(i, a)
+
+ a = Axis((1, 2, -1), (0, 0, 1))
+ i = Plane.XY & a
+ self.assertTrue(isinstance(i, Vector))
+ self.assertAlmostEqual(i, Vector(1, 2, 0), 5)
+
+ def test_plane_origin_setter(self):
+ pln = Plane.XY
+ pln.origin = (1, 2, 3)
+ ocp_origin = Vector(pln.wrapped.Location())
+ self.assertAlmostEqual(ocp_origin, (1, 2, 3), 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_projection.py b/tests/test_direct_api/test_projection.py
new file mode 100644
index 0000000..5fbb7bd
--- /dev/null
+++ b/tests/test_direct_api/test_projection.py
@@ -0,0 +1,103 @@
+"""
+build123d imports
+
+name: test_projection.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.build_enums import Align
+from build123d.geometry import Axis, Plane, Pos, Vector
+from build123d.objects_part import Box
+from build123d.topology import Compound, Edge, Solid, Wire
+
+
+class TestProjection(unittest.TestCase):
+ def test_flat_projection(self):
+ sphere = Solid.make_sphere(50)
+ projection_direction = Vector(0, -1, 0)
+ planar_text_faces = (
+ Compound.make_text("Flat", 30, align=(Align.CENTER, Align.CENTER))
+ .rotate(Axis.X, 90)
+ .faces()
+ )
+ projected_text_faces = [
+ f.project_to_shape(sphere, projection_direction)[0]
+ for f in planar_text_faces
+ ]
+ self.assertEqual(len(projected_text_faces), 4)
+
+ def test_multiple_output_wires(self):
+ target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2)
+ circle = Wire.make_circle(3, Plane.XY.offset(10))
+ projection = circle.project_to_shape(target, (0, 0, -1))
+ bbox = projection[0].bounding_box()
+ self.assertAlmostEqual(bbox.min, (-3, -3, 1), 2)
+ self.assertAlmostEqual(bbox.max, (3, 3, 2), 2)
+ bbox = projection[1].bounding_box()
+ self.assertAlmostEqual(bbox.min, (-3, -3, -2), 2)
+ self.assertAlmostEqual(bbox.max, (3, 3, -2), 2)
+
+ def test_text_projection(self):
+ sphere = Solid.make_sphere(50)
+ arch_path = (
+ sphere.cut(
+ Solid.make_cylinder(
+ 80, 100, Plane(origin=(-50, 0, -70), z_dir=(1, 0, 0))
+ )
+ )
+ .edges()
+ .sort_by(Axis.Z)[0]
+ )
+
+ projected_text = sphere.project_faces(
+ faces=Compound.make_text("dog", font_size=14),
+ path=arch_path,
+ start=0.01, # avoid a character spanning the sphere edge
+ )
+ self.assertEqual(len(projected_text.solids()), 0)
+ self.assertEqual(len(projected_text.faces()), 3)
+
+ def test_error_handling(self):
+ sphere = Solid.make_sphere(50)
+ circle = Wire.make_circle(1)
+ with self.assertRaises(ValueError):
+ circle.project_to_shape(sphere, center=None, direction=None)[0]
+
+ def test_project_edge(self):
+ projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape(
+ Solid.make_box(1, 1, 1), (0, 0, 1)
+ )
+ self.assertAlmostEqual(projection[0].position_at(1), (1, 0, 0), 5)
+ self.assertAlmostEqual(projection[0].position_at(0), (0, 1, 0), 5)
+ self.assertAlmostEqual(projection[0].arc_center, (0, 0, 0), 5)
+
+ def test_to_axis(self):
+ with self.assertRaises(ValueError):
+ Edge.make_circle(1, end_angle=30).to_axis()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_rotation.py b/tests/test_direct_api/test_rotation.py
new file mode 100644
index 0000000..aae49c8
--- /dev/null
+++ b/tests/test_direct_api/test_rotation.py
@@ -0,0 +1,58 @@
+"""
+build123d imports
+
+name: test_rotation.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.build_enums import Extrinsic, Intrinsic
+from build123d.geometry import Rotation
+
+
+class TestRotation(unittest.TestCase):
+ def test_rotation_parameters(self):
+ r = Rotation(10, 20, 30)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, 20, Z=30)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, Y=20, Z=30)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation((10, 20, 30))
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation(10, 20, 30, Intrinsic.XYZ)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation((30, 20, 10), Extrinsic.ZYX)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX)
+ self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
+ with self.assertRaises(TypeError):
+ Rotation(x=10)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_shape.py b/tests/test_direct_api/test_shape.py
new file mode 100644
index 0000000..835b70b
--- /dev/null
+++ b/tests/test_direct_api/test_shape.py
@@ -0,0 +1,619 @@
+"""
+build123d imports
+
+name: test_shape.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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.
+
+"""
+
+# Always equal to any other object, to test that __eq__ cooperation is working
+import unittest
+from random import uniform
+from unittest.mock import patch
+
+import numpy as np
+from build123d.build_enums import CenterOf, Keep
+from build123d.geometry import (
+ Axis,
+ Color,
+ Location,
+ Matrix,
+ Plane,
+ Pos,
+ Rotation,
+ Vector,
+)
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import Circle
+from build123d.operations_part import extrude
+from build123d.topology import (
+ Compound,
+ Edge,
+ Face,
+ Shape,
+ ShapeList,
+ Shell,
+ Solid,
+ Vertex,
+ Wire,
+)
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+class TestShape(unittest.TestCase):
+ """Misc Shape tests"""
+
+ def test_mirror(self):
+ box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box()
+ self.assertAlmostEqual(box_bb.min.X, 0, 5)
+ self.assertAlmostEqual(box_bb.max.X, 1, 5)
+ self.assertAlmostEqual(box_bb.min.Y, -1, 5)
+ self.assertAlmostEqual(box_bb.max.Y, 0, 5)
+
+ box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box()
+ self.assertAlmostEqual(box_bb.min.Z, -1, 5)
+ self.assertAlmostEqual(box_bb.max.Z, 0, 5)
+
+ def test_compute_mass(self):
+ with self.assertRaises(NotImplementedError):
+ Shape.compute_mass(Vertex())
+
+ def test_combined_center(self):
+ objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]]
+ self.assertAlmostEqual(
+ Shape.combined_center(objs, center_of=CenterOf.MASS),
+ (0, 0.5, 0.5),
+ 5,
+ )
+
+ objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]]
+ self.assertAlmostEqual(
+ Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX),
+ (-0.5, 0, 0),
+ 5,
+ )
+ with self.assertRaises(ValueError):
+ Shape.combined_center(objs, center_of=CenterOf.GEOMETRY)
+
+ def test_shape_type(self):
+ self.assertEqual(Vertex().shape_type(), "Vertex")
+
+ def test_scale(self):
+ self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5)
+
+ def test_fuse(self):
+ box1 = Solid.make_box(1, 1, 1)
+ box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
+ combined = box1.fuse(box2, glue=True)
+ self.assertTrue(combined.is_valid())
+ self.assertAlmostEqual(combined.volume, 2, 5)
+ fuzzy = box1.fuse(box2, tol=1e-6)
+ self.assertTrue(fuzzy.is_valid())
+ self.assertAlmostEqual(fuzzy.volume, 2, 5)
+
+ def test_faces_intersected_by_axis(self):
+ box = Solid.make_box(1, 1, 1, Plane((0, 0, 1)))
+ intersected_faces = box.faces_intersected_by_axis(Axis.Z)
+ self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces)
+ self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces)
+
+ def test_split(self):
+ shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
+ split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM)
+ self.assertTrue(isinstance(split_shape, list))
+ self.assertEqual(len(split_shape), 2)
+ self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5)
+ split_shape = shape.split(Plane.XY, keep=Keep.TOP)
+ self.assertEqual(len(split_shape.solids()), 1)
+ self.assertTrue(isinstance(split_shape, Solid))
+ self.assertAlmostEqual(split_shape.volume, 0.5, 5)
+
+ s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10))
+ tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face()
+ s2 = s.split(tool, keep=Keep.TOP)
+ self.assertLess(s2.volume, s.volume)
+ self.assertGreater(s2.volume, 0.0)
+
+ def test_split_by_non_planar_face(self):
+ box = Solid.make_box(1, 1, 1)
+ tool = Circle(1).wire()
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ top, bottom = box.split(tool_shell, keep=Keep.BOTH)
+
+ self.assertFalse(top is None)
+ self.assertFalse(bottom is None)
+ self.assertGreater(top.volume, bottom.volume)
+
+ def test_split_by_shell(self):
+ box = Solid.make_box(5, 5, 1)
+ tool = Wire.make_rect(4, 4)
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ split = box.split(tool_shell, keep=Keep.TOP)
+ inner_vol = 2 * 2
+ outer_vol = 5 * 5
+ self.assertAlmostEqual(split.volume, outer_vol - inner_vol)
+
+ def test_split_keep_all(self):
+ shape = Box(1, 1, 1)
+ split_shape = shape.split(Plane.XY, keep=Keep.ALL)
+ self.assertTrue(isinstance(split_shape, ShapeList))
+ self.assertEqual(len(split_shape), 2)
+
+ def test_split_edge_by_shell(self):
+ edge = Edge.make_line((-5, 0, 0), (5, 0, 0))
+ tool = Wire.make_rect(4, 4)
+ tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
+ top = edge.split(tool_shell, keep=Keep.TOP)
+ self.assertEqual(len(top), 2)
+ self.assertAlmostEqual(top[0].length, 3, 5)
+
+ def test_split_return_none(self):
+ shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
+ split_shape = shape.split(Plane.XY, keep=Keep.INSIDE)
+ self.assertIsNone(split_shape)
+
+ def test_split_by_perimeter(self):
+ # Test 0 - extract a spherical cap
+ target0 = Solid.make_sphere(10).rotate(Axis.Z, 90)
+ circle = Plane.YZ.offset(15) * Circle(5).face()
+ circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0]
+ circle_outerwire = circle_projected.edge()
+ inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH)
+ self.assertLess(inside0.area, outside0.area)
+
+ # Test 1 - extract ring of a sphere
+ ring = Pos(Z=15) * (Circle(5) - Circle(3)).face()
+ ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0]
+ ring_outerwire = ring_projected.outer_wire()
+ inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH)
+ if isinstance(inside1, list):
+ inside1 = Compound(inside1)
+ if isinstance(outside1, list):
+ outside1 = Compound(outside1)
+ self.assertLess(inside1.area, outside1.area)
+ self.assertEqual(len(outside1.faces()), 2)
+
+ # Test 2 - extract multiple faces
+ target2 = Box(1, 10, 10)
+ square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0)))
+ square_projected = square.project_to_shape(target2, (-1, 0, 0))[0]
+ outside2 = target2.split_by_perimeter(
+ square_projected.outer_wire(), Keep.OUTSIDE
+ )
+ self.assertTrue(isinstance(outside2, Shell))
+ inside2 = target2.split_by_perimeter(square_projected.outer_wire(), Keep.INSIDE)
+ self.assertTrue(isinstance(inside2, Face))
+
+ # Test 4 - invalid inputs
+ with self.assertRaises(ValueError):
+ _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH)
+
+ with self.assertRaises(ValueError):
+ _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP)
+
+ def test_distance(self):
+ sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
+ sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
+ self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5)
+
+ def test_distances(self):
+ sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
+ sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
+ sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5)))
+ distances = [8, 3]
+ for i, distance in enumerate(sphere1.distances(sphere2, sphere3)):
+ self.assertAlmostEqual(distances[i], distance, 5)
+
+ def test_max_fillet(self):
+ test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)]
+ max_values = [0.96, 3.84]
+ for i, test_object in enumerate(test_solids):
+ with self.subTest("solids" + str(i)):
+ max = test_object.max_fillet(test_object.edges())
+ self.assertAlmostEqual(max, max_values[i], 2)
+ with self.assertRaises(RuntimeError):
+ test_solids[0].max_fillet(
+ test_solids[0].edges(), tolerance=1e-6, max_iterations=1
+ )
+ with self.assertRaises(ValueError):
+ box = Solid.make_box(1, 1, 1)
+ box.fillet(0.75, box.edges())
+ # invalid_object = box.fillet(0.75, box.edges())
+ # invalid_object.max_fillet(invalid_object.edges())
+
+ @patch.object(Shape, "is_valid", return_value=False)
+ def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid):
+ box = Solid.make_box(1, 1, 1)
+
+ # Assert that ValueError is raised
+ with self.assertRaises(ValueError) as max_fillet_context:
+ max = box.max_fillet(box.edges())
+
+ # Check the error message
+ self.assertEqual(str(max_fillet_context.exception), "Invalid Shape")
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ def test_locate_bb(self):
+ bounding_box = Solid.make_cone(1, 2, 1).bounding_box()
+ relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box)
+ self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5)
+ self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5)
+ self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5)
+ self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5)
+ self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5)
+ self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5)
+
+ def test_is_equal(self):
+ box = Solid.make_box(1, 1, 1)
+ self.assertTrue(box.is_equal(box))
+
+ def test_equal(self):
+ box = Solid.make_box(1, 1, 1)
+ self.assertEqual(box, box)
+ self.assertEqual(box, AlwaysEqual())
+
+ def test_not_equal(self):
+ box = Solid.make_box(1, 1, 1)
+ diff = Solid.make_box(1, 2, 3)
+ self.assertNotEqual(box, diff)
+ self.assertNotEqual(box, object())
+
+ def test_tessellate(self):
+ box123 = Solid.make_box(1, 2, 3)
+ verts, triangles = box123.tessellate(1e-6)
+ self.assertEqual(len(verts), 24)
+ self.assertEqual(len(triangles), 12)
+
+ def test_transformed(self):
+ """Validate that transformed works the same as changing location"""
+ rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360))
+ offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50))
+ shape = Solid.make_box(1, 1, 1).transformed(rotation, offset)
+ predicted_location = Location(offset) * Rotation(*rotation)
+ located_shape = Solid.make_box(1, 1, 1).locate(predicted_location)
+ intersect = shape.intersect(located_shape)
+ self.assertAlmostEqual(intersect.volume, 1, 5)
+
+ def test_position_and_orientation(self):
+ box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30)))
+ self.assertAlmostEqual(box.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(box.orientation, (10, 20, 30), 5)
+
+ def test_distance_to_with_closest_points(self):
+ s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0)))
+ s1 = Solid.make_sphere(1)
+ distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1)
+ self.assertAlmostEqual(distance, 0.1, 5)
+ self.assertAlmostEqual(pnt0, (0, 1.1, 0), 5)
+ self.assertAlmostEqual(pnt1, (0, 1, 0), 5)
+
+ def test_closest_points(self):
+ c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
+ c1 = Edge.make_circle(1)
+ closest = c0.closest_points(c1)
+ self.assertAlmostEqual(closest[0], c0.position_at(0.75).to_tuple(), 5)
+ self.assertAlmostEqual(closest[1], c1.position_at(0.25).to_tuple(), 5)
+
+ def test_distance_to(self):
+ c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
+ c1 = Edge.make_circle(1)
+ distance = c0.distance_to(c1)
+ self.assertAlmostEqual(distance, 0.1, 5)
+
+ def test_intersection(self):
+ box = Solid.make_box(1, 1, 1)
+ intersections = (
+ box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z)
+ )
+ self.assertAlmostEqual(Vector(intersections[0]), (0.5, 0.5, 0), 5)
+ self.assertAlmostEqual(Vector(intersections[1]), (0.5, 0.5, 1), 5)
+
+ def test_clean_error(self):
+ """Note that this test is here to alert build123d to changes in bad OCCT clean behavior
+ with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this.
+ """
+ sphere = Solid.make_sphere(1)
+ divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5)))
+ positive_half, negative_half = (s.clean() for s in sphere.cut(divider).solids())
+ self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1)
+
+ def test_clean_empty(self):
+ obj = Solid()
+ self.assertIs(obj, obj.clean())
+
+ def test_relocate(self):
+ box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5)))
+ cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0)))
+
+ box_with_hole = box.cut(cylinder)
+ box_with_hole.relocate(box.location)
+
+ self.assertEqual(box.location, box_with_hole.location)
+
+ bbox1 = box.bounding_box()
+ bbox2 = box_with_hole.bounding_box()
+ self.assertAlmostEqual(bbox1.min, bbox2.min, 5)
+ self.assertAlmostEqual(bbox1.max, bbox2.max, 5)
+
+ def test_project_to_viewport(self):
+ # Basic test
+ box = Solid.make_box(10, 10, 10)
+ visible, hidden = box.project_to_viewport((-20, 20, 20))
+ self.assertEqual(len(visible), 9)
+ self.assertEqual(len(hidden), 3)
+
+ # Contour edges
+ cyl = Solid.make_cylinder(2, 10)
+ visible, hidden = cyl.project_to_viewport((-20, 20, 20))
+ # Note that some edges are broken into two
+ self.assertEqual(len(visible), 6)
+ self.assertEqual(len(hidden), 2)
+
+ # Hidden contour edges
+ hole = box - cyl
+ visible, hidden = hole.project_to_viewport((-20, 20, 20))
+ self.assertEqual(len(visible), 13)
+ self.assertEqual(len(hidden), 6)
+
+ # Outline edges
+ sphere = Solid.make_sphere(5)
+ visible, hidden = sphere.project_to_viewport((-20, 20, 20))
+ self.assertEqual(len(visible), 1)
+ self.assertEqual(len(hidden), 0)
+
+ def test_vertex(self):
+ v = Edge.make_circle(1).vertex()
+ self.assertTrue(isinstance(v, Vertex))
+ with self.assertWarns(UserWarning):
+ Wire.make_rect(1, 1).vertex()
+
+ def test_edge(self):
+ e = Edge.make_circle(1).edge()
+ self.assertTrue(isinstance(e, Edge))
+ with self.assertWarns(UserWarning):
+ Wire.make_rect(1, 1).edge()
+
+ def test_wire(self):
+ w = Wire.make_circle(1).wire()
+ self.assertTrue(isinstance(w, Wire))
+ with self.assertWarns(UserWarning):
+ Solid.make_box(1, 1, 1).wire()
+
+ def test_compound(self):
+ c = Compound.make_text("hello", 10)
+ self.assertTrue(isinstance(c, Compound))
+ c2 = Compound.make_text("world", 10)
+ with self.assertWarns(UserWarning):
+ Compound(children=[c, c2]).compound()
+
+ def test_face(self):
+ f = Face.make_rect(1, 1)
+ self.assertTrue(isinstance(f, Face))
+ with self.assertWarns(UserWarning):
+ Solid.make_box(1, 1, 1).face()
+
+ def test_shell(self):
+ s = Solid.make_sphere(1).shell()
+ self.assertTrue(isinstance(s, Shell))
+ with self.assertWarns(UserWarning):
+ extrude(Compound.make_text("two", 10), amount=5).shell()
+
+ def test_solid(self):
+ s = Solid.make_sphere(1).solid()
+ self.assertTrue(isinstance(s, Solid))
+ with self.assertWarns(UserWarning):
+ Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
+
+ def test_manifold(self):
+ self.assertTrue(Solid.make_box(1, 1, 1).is_manifold)
+ self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold)
+ self.assertFalse(
+ Solid.make_box(1, 1, 1)
+ .shell()
+ .cut(Solid.make_box(0.5, 0.5, 0.5))
+ .is_manifold
+ )
+ self.assertTrue(
+ Compound(
+ children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]
+ ).is_manifold
+ )
+
+ def test_inherit_color(self):
+ # Create some objects and assign colors to them
+ b = Box(1, 1, 1).locate(Pos(2, 2, 0))
+ b.color = Color("blue") # Blue
+ c = Cylinder(1, 1).locate(Pos(-2, 2, 0))
+ a = Compound(children=[b, c])
+ a.color = Color(0, 1, 0)
+ # Check that assigned colors stay and iheritance works
+ np.testing.assert_allclose(tuple(a.color), (0, 1, 0, 1), 1e-5)
+ np.testing.assert_allclose(tuple(b.color), (0, 0, 1, 1), 1e-5)
+
+ def test_ocp_section(self):
+ # Vertex
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Vertex(1, 2, 0))
+ self.assertListEqual(verts, []) # ?
+ self.assertListEqual(edges, [])
+
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4)))
+ self.assertListEqual(verts, []) # ?
+ self.assertListEqual(edges, [])
+
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5))
+ np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5)
+ self.assertListEqual(edges, [])
+
+ verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY))
+ np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5)
+ self.assertListEqual(edges, [])
+
+ # spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5))
+ # cylinder = Pos(Z=-10) * extrude(Circle(5), 20)
+ # cylinder2 = (Rot((0, 90, 0)) * cylinder).face()
+ # pln = Plane.XY
+ # box1 = Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN))
+ # box2 = Pos(Z=-10) * box1
+
+ # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln))
+ # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln))
+ # print(vertices1, edges1)
+
+ # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln))
+ # print(vertices2, edges2)
+
+ # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln))
+ # print(vertices3, edges3)
+
+ # # vertices4, edges4 = cylinder2.ocp_section(cylinder)
+
+ # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln))
+ # print(vertices5, edges5)
+
+ # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1])
+
+ def test_copy_attributes_to(self):
+ box = Box(1, 1, 1)
+ box2 = Box(10, 10, 10)
+ box.label = "box"
+ box.color = Color("Red")
+ box.children = [Box(1, 1, 1), Box(2, 2, 2)]
+ box.topo_parent = box2
+
+ blank = Compound()
+ box.copy_attributes_to(blank)
+ self.assertEqual(blank.label, "box")
+ self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red"))))
+ self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children)))
+ self.assertEqual(blank.topo_parent, box2)
+
+ def test_empty_shape(self):
+ empty = Solid()
+ box = Solid.make_box(1, 1, 1)
+ self.assertIsNone(empty.location)
+ self.assertIsNone(empty.position)
+ self.assertIsNone(empty.orientation)
+ self.assertFalse(empty.is_manifold)
+ with self.assertRaises(ValueError):
+ empty.geom_type
+ self.assertIs(empty, empty.fix())
+ self.assertEqual(hash(empty), 0)
+ self.assertFalse(empty.is_same(Solid()))
+ self.assertFalse(empty.is_equal(Solid()))
+ self.assertTrue(empty.is_valid())
+ empty_bbox = empty.bounding_box()
+ self.assertEqual(tuple(empty_bbox.size), (0, 0, 0))
+ self.assertIs(empty, empty.mirror(Plane.XY))
+ self.assertEqual(Shape.compute_mass(empty), 0)
+ self.assertEqual(empty.entities("Face"), [])
+ self.assertEqual(empty.area, 0)
+ self.assertIs(empty, empty.rotate(Axis.Z, 90))
+ translate_matrix = [
+ [1.0, 0.0, 0.0, 1.0],
+ [0.0, 1.0, 0.0, 2.0],
+ [0.0, 0.0, 1.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ self.assertIs(empty, empty.transform_shape(Matrix(translate_matrix)))
+ self.assertIs(empty, empty.transform_geometry(Matrix(translate_matrix)))
+ with self.assertRaises(ValueError):
+ empty.locate(Location())
+ empty_loc = Location()
+ empty_loc.wrapped = None
+ with self.assertRaises(ValueError):
+ box.locate(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.located(Location())
+ with self.assertRaises(ValueError):
+ box.located(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.move(Location())
+ with self.assertRaises(ValueError):
+ box.move(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.moved(Location())
+ with self.assertRaises(ValueError):
+ box.moved(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.relocate(Location())
+ with self.assertRaises(ValueError):
+ box.relocate(empty_loc)
+ with self.assertRaises(ValueError):
+ empty.distance_to(Vector(1, 1, 1))
+ with self.assertRaises(ValueError):
+ empty.distance_to_with_closest_points(Vector(1, 1, 1))
+ with self.assertRaises(ValueError):
+ empty.distance_to(Vector(1, 1, 1))
+ with self.assertRaises(ValueError):
+ box.intersect(empty_loc)
+ self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], []))
+ self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList())
+ with self.assertRaises(ValueError):
+ empty.split_by_perimeter(Circle(1).wire())
+ with self.assertRaises(ValueError):
+ empty.distance(Vertex(1, 1, 1))
+ with self.assertRaises(ValueError):
+ list(empty.distances(Vertex(0, 0, 0), Vertex(1, 1, 1)))
+ with self.assertRaises(ValueError):
+ list(box.distances(empty, Vertex(1, 1, 1)))
+ with self.assertRaises(ValueError):
+ empty.mesh(0.001)
+ with self.assertRaises(ValueError):
+ empty.tessellate(0.001)
+ with self.assertRaises(ValueError):
+ empty.to_splines()
+ empty_axis = Axis((0, 0, 0), (1, 0, 0))
+ empty_axis.wrapped = None
+ with self.assertRaises(ValueError):
+ box.vertices().group_by(empty_axis)
+ empty_wire = Wire()
+ with self.assertRaises(ValueError):
+ box.vertices().group_by(empty_wire)
+ with self.assertRaises(ValueError):
+ box.vertices().sort_by(empty_axis)
+ with self.assertRaises(ValueError):
+ box.vertices().sort_by(empty_wire)
+
+ def test_empty_selectors(self):
+ self.assertEqual(Vertex(1, 1, 1).edges(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).wires(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).faces(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList())
+ self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList())
+ self.assertIsNone(Vertex(1, 1, 1).edge())
+ self.assertIsNone(Vertex(1, 1, 1).wire())
+ self.assertIsNone(Vertex(1, 1, 1).face())
+ self.assertIsNone(Vertex(1, 1, 1).shell())
+ self.assertIsNone(Vertex(1, 1, 1).solid())
+ self.assertIsNone(Vertex(1, 1, 1).compound())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py
new file mode 100644
index 0000000..45ec7eb
--- /dev/null
+++ b/tests/test_direct_api/test_shape_list.py
@@ -0,0 +1,368 @@
+"""
+build123d imports
+
+name: test_shape_list.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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.
+
+"""
+
+# Always equal to any other object, to test that __eq__ cooperation is working
+import io
+import math
+import re
+import unittest
+
+import numpy as np
+from IPython.lib import pretty
+from build123d.build_common import GridLocations, PolarLocations
+from build123d.build_enums import GeomType, SortBy
+from build123d.build_part import BuildPart
+from build123d.geometry import Axis, Plane, Vector
+from build123d.objects_part import Box, Cylinder
+from build123d.objects_sketch import RegularPolygon
+from build123d.topology import (
+ Compound,
+ Edge,
+ Face,
+ ShapeList,
+ Shell,
+ Solid,
+ Vertex,
+ Wire,
+)
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+class TestShapeList(unittest.TestCase):
+ """Test ShapeList functionality"""
+
+ def assertDunderStrEqual(self, actual: str, expected_lines: list[str]):
+ actual_lines = actual.splitlines()
+ self.assertEqual(len(actual_lines), len(expected_lines))
+ for actual_line, expected_line in zip(actual_lines, expected_lines):
+ start, end = re.split(r"at 0x[0-9a-f]+", expected_line, 2, re.I)
+ self.assertTrue(actual_line.startswith(start))
+ self.assertTrue(actual_line.endswith(end))
+
+ def assertDunderReprEqual(self, actual: str, expected: str):
+ splitter = r"at 0x[0-9a-f]+"
+ actual_split_list = re.split(splitter, actual, 0, re.I)
+ expected_split_list = re.split(splitter, expected, 0, re.I)
+ for actual_split, expected_split in zip(actual_split_list, expected_split_list):
+ self.assertEqual(actual_split, expected_split)
+
+ def test_sort_by(self):
+ faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA
+ self.assertAlmostEqual(faces[-1].area, 2, 5)
+
+ def test_filter_by_geomtype(self):
+ non_planar_faces = (
+ Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True)
+ )
+ self.assertEqual(len(non_planar_faces), 1)
+ self.assertAlmostEqual(non_planar_faces[0].area, 2 * math.pi, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).faces().filter_by("True")
+
+ def test_filter_by_axis(self):
+ box = Solid.make_box(1, 1, 1)
+ self.assertEqual(len(box.faces().filter_by(Axis.X)), 2)
+ self.assertEqual(len(box.edges().filter_by(Axis.X)), 4)
+ self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0)
+
+ def test_filter_by_callable_predicate(self):
+ boxes = [Solid.make_box(1, 1, 1) for _ in range(3)]
+ boxes[0].label = "A"
+ boxes[1].label = "A"
+ boxes[2].label = "B"
+ shapelist = ShapeList(boxes)
+
+ self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2)
+ self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1)
+
+ def test_first_last(self):
+ vertices = (
+ Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1)))
+ )
+ self.assertAlmostEqual(Vector(vertices.last), (1, 1, 1), 5)
+ self.assertAlmostEqual(Vector(vertices.first), (0, 0, 0), 5)
+
+ def test_group_by(self):
+ vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
+ self.assertEqual(len(vertices[0]), 4)
+
+ edges = Solid.make_box(1, 1, 1).edges().group_by(SortBy.LENGTH)
+ self.assertEqual(len(edges[0]), 12)
+
+ edges = (
+ Solid.make_cone(2, 1, 2)
+ .edges()
+ .filter_by(GeomType.CIRCLE)
+ .group_by(SortBy.RADIUS)
+ )
+ self.assertEqual(len(edges[0]), 1)
+
+ edges = (Solid.make_cone(2, 1, 2).edges() | GeomType.CIRCLE) << SortBy.RADIUS
+ self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5)
+
+ vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE)
+ self.assertAlmostEqual(Vector(vertices[-1][0]), (1, 1, 1), 5)
+
+ box = Solid.make_box(1, 1, 2)
+ self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2)
+ self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4)
+
+ line = Edge.make_line((0, 0, 0), (1, 1, 2))
+ vertices_by_line = box.vertices().group_by(line)
+ self.assertEqual(len(vertices_by_line[0]), 1)
+ self.assertEqual(len(vertices_by_line[1]), 2)
+ self.assertEqual(len(vertices_by_line[2]), 1)
+ self.assertEqual(len(vertices_by_line[3]), 1)
+ self.assertEqual(len(vertices_by_line[4]), 2)
+ self.assertEqual(len(vertices_by_line[5]), 1)
+ self.assertAlmostEqual(Vector(vertices_by_line[0][0]), (0, 0, 0), 5)
+ self.assertAlmostEqual(Vector(vertices_by_line[-1][0]), (1, 1, 2), 5)
+
+ with BuildPart() as boxes:
+ with GridLocations(10, 10, 3, 3):
+ Box(1, 1, 1)
+ with PolarLocations(100, 10):
+ Box(1, 1, 2)
+ self.assertEqual(len(boxes.solids().group_by(SortBy.VOLUME)[-1]), 10)
+ self.assertEqual(len((boxes.solids()) << SortBy.VOLUME), 9)
+
+ with self.assertRaises(ValueError):
+ boxes.solids().group_by("AREA")
+
+ def test_group_by_callable_predicate(self):
+ boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
+ boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
+ for box in boxesA:
+ box.label = "A"
+ for box in boxesB:
+ box.label = "B"
+ boxNoLabel = Solid.make_box(1, 1, 1)
+
+ shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
+ result = shapelist.group_by(lambda shape: shape.label)
+
+ self.assertEqual([len(group) for group in result], [1, 3, 2])
+
+ def test_group_by_retrieve_groups(self):
+ boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
+ boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
+ for box in boxesA:
+ box.label = "A"
+ for box in boxesB:
+ box.label = "B"
+ boxNoLabel = Solid.make_box(1, 1, 1)
+
+ shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
+ result = shapelist.group_by(lambda shape: shape.label)
+
+ self.assertEqual(len(result.group("")), 1)
+ self.assertEqual(len(result.group("A")), 3)
+ self.assertEqual(len(result.group("B")), 2)
+ self.assertEqual(result.group(""), result[0])
+ self.assertEqual(result.group("A"), result[1])
+ self.assertEqual(result.group("B"), result[2])
+ self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0]))
+ self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0]))
+ with self.assertRaises(KeyError):
+ result.group("C")
+
+ def test_group_by_str_repr(self):
+ nonagon = RegularPolygon(5, 9)
+
+ expected = [
+ "[[],",
+ " [,",
+ " ],",
+ " [,",
+ " ],",
+ " [,",
+ " ],",
+ " [,",
+ " ]]",
+ ]
+ self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
+
+ expected_repr = (
+ "[[],"
+ " [,"
+ " ],"
+ " [,"
+ " ],"
+ " [,"
+ " ],"
+ " [,"
+ " ]]"
+ )
+ self.assertDunderReprEqual(
+ repr(nonagon.edges().group_by(Axis.X)), expected_repr
+ )
+
+ f = io.StringIO()
+ p = pretty.PrettyPrinter(f)
+ nonagon.edges().group_by(Axis.X)._repr_pretty_(p, cycle=True)
+ self.assertEqual(f.getvalue(), "(...)")
+
+ def test_distance(self):
+ with BuildPart() as box:
+ Box(1, 2, 3)
+ obj = (-0.2, 0.1, 0.5)
+ edges = box.edges().sort_by_distance(obj)
+ distances = [Vertex(*obj).distance_to(edge) for edge in edges]
+ self.assertTrue(
+ all([distances[i] >= distances[i - 1] for i in range(1, len(edges))])
+ )
+
+ def test_distance_reverse(self):
+ with BuildPart() as box:
+ Box(1, 2, 3)
+ obj = (-0.2, 0.1, 0.5)
+ edges = box.edges().sort_by_distance(obj, reverse=True)
+ distances = [Vertex(*obj).distance_to(edge) for edge in edges]
+ self.assertTrue(
+ all([distances[i] <= distances[i - 1] for i in range(1, len(edges))])
+ )
+
+ def test_distance_equal(self):
+ with BuildPart() as box:
+ Box(1, 1, 1)
+ self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12)
+
+ def test_vertices(self):
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ self.assertEqual(len(sl.vertices()), 8)
+
+ def test_vertex(self):
+ sl = ShapeList([Edge.make_circle(1)])
+ np.testing.assert_allclose(sl.vertex().to_tuple(), (1, 0, 0), 1e-5)
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ with self.assertWarns(UserWarning):
+ sl.vertex()
+ self.assertEqual(len(Edge().vertices()), 0)
+
+ def test_edges(self):
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ self.assertEqual(len(sl.edges()), 8)
+ self.assertEqual(len(Edge().edges()), 0)
+
+ def test_edge(self):
+ sl = ShapeList([Edge.make_circle(1)])
+ self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5)
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ with self.assertWarns(UserWarning):
+ sl.edge()
+
+ def test_wires(self):
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ self.assertEqual(len(sl.wires()), 2)
+ self.assertEqual(len(Wire().wires()), 0)
+
+ def test_wire(self):
+ sl = ShapeList([Wire.make_circle(1)])
+ self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5)
+ sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
+ with self.assertWarns(UserWarning):
+ sl.wire()
+
+ def test_faces(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ self.assertEqual(len(sl.faces()), 9)
+ self.assertEqual(len(Face().faces()), 0)
+
+ def test_face(self):
+ sl = ShapeList(
+ [Vertex(1, 1, 1), Edge.make_line((0, 0), (1, 1)), Face.make_rect(2, 1)]
+ )
+ self.assertAlmostEqual(sl.face().area, 2 * 1, 5)
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.face()
+
+ def test_shells(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ self.assertEqual(len(sl.shells()), 2)
+ self.assertEqual(len(Shell().shells()), 0)
+
+ def test_shell(self):
+ sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
+ self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5)
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.shell()
+
+ def test_solids(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ self.assertEqual(len(sl.solids()), 2)
+ self.assertEqual(len(Solid().solids()), 0)
+
+ def test_solid(self):
+ sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.solid()
+ sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)])
+ self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5)
+
+ def test_compounds(self):
+ sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
+ self.assertEqual(len(sl.compounds()), 2)
+ self.assertEqual(len(Compound().compounds()), 0)
+
+ def test_compound(self):
+ sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
+ with self.assertWarns(UserWarning):
+ sl.compound()
+ sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)])
+ self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5)
+
+ def test_equal(self):
+ box = Box(1, 1, 1)
+ cyl = Cylinder(1, 1)
+ sl = ShapeList([box, cyl])
+ same = ShapeList([cyl, box])
+ self.assertEqual(sl, same)
+ self.assertEqual(sl, AlwaysEqual())
+
+ def test_not_equal(self):
+ sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
+ diff = ShapeList([Box(1, 1, 1), Box(1, 2, 3)])
+ self.assertNotEqual(sl, diff)
+ self.assertNotEqual(sl, object())
+
+ def test_center(self):
+ self.assertEqual(tuple(ShapeList().center()), (0, 0, 0))
+ self.assertEqual(
+ tuple(ShapeList(Vertex(i, 0, 0) for i in range(3)).center()), (1, 0, 0)
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_shells.py b/tests/test_direct_api/test_shells.py
new file mode 100644
index 0000000..d78de7f
--- /dev/null
+++ b/tests/test_direct_api/test_shells.py
@@ -0,0 +1,115 @@
+"""
+build123d imports
+
+name: test_shells.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import unittest
+
+from build123d.geometry import Plane, Rot, Vector
+from build123d.objects_curve import JernArc, Polyline, Spline
+from build123d.objects_sketch import Circle
+from build123d.operations_generic import sweep
+from build123d.topology import Shell, Solid, Wire
+
+
+class TestShells(unittest.TestCase):
+ def test_shell_init(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ self.assertTrue(box_shell.is_valid())
+
+ def test_center(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ self.assertAlmostEqual(box_shell.center(), (0.5, 0.5, 0.5), 5)
+
+ def test_manifold_shell_volume(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ self.assertAlmostEqual(box_shell.volume, 1, 5)
+
+ def test_nonmanifold_shell_volume(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ nm_shell = Shell(box_faces)
+ nm_shell -= nm_shell.faces()[0]
+ self.assertAlmostEqual(nm_shell.volume, 0, 5)
+
+ def test_constructor(self):
+ with self.assertRaises(TypeError):
+ Shell(foo="bar")
+
+ x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5))
+ surface = sweep(x_section, Circle(5).wire())
+ single_face = Shell(surface.face())
+ self.assertTrue(single_face.is_valid())
+ single_face = Shell(surface.faces())
+ self.assertTrue(single_face.is_valid())
+
+ def test_sweep(self):
+ path_c1 = JernArc((0, 0), (-1, 0), 1, 180)
+ path_e = path_c1.edge()
+ path_c2 = JernArc((0, 0), (-1, 0), 1, 180) + JernArc((0, 0), (1, 0), 2, -90)
+ path_w = path_c2.wire()
+ section_e = Circle(0.5).edge()
+ section_c2 = Polyline((0, 0), (0.1, 0), (0.2, 0.1))
+ section_w = section_c2.wire()
+
+ sweep_e_w = Shell.sweep((path_w ^ 0) * section_e, path_w)
+ sweep_w_e = Shell.sweep((path_e ^ 0) * section_w, path_e)
+ sweep_w_w = Shell.sweep((path_w ^ 0) * section_w, path_w)
+ sweep_c2_c1 = Shell.sweep((path_c1 ^ 0) * section_c2, path_c1)
+ sweep_c2_c2 = Shell.sweep((path_c2 ^ 0) * section_c2, path_c2)
+
+ self.assertEqual(len(sweep_e_w.faces()), 2)
+ self.assertEqual(len(sweep_w_e.faces()), 2)
+ self.assertEqual(len(sweep_c2_c1.faces()), 2)
+ self.assertEqual(len(sweep_w_w.faces()), 3) # 3 with clean, 4 without
+ self.assertEqual(len(sweep_c2_c2.faces()), 3) # 3 with clean, 4 without
+
+ def test_make_loft(self):
+ r = 3
+ h = 2
+ loft = Shell.make_loft(
+ [Wire.make_circle(r, Plane((0, 0, h))), Wire.make_circle(r)]
+ )
+ self.assertEqual(loft.volume, 0, "A shell has no volume")
+ cylinder_area = 2 * math.pi * r * h
+ self.assertAlmostEqual(loft.area, cylinder_area)
+
+ def test_thicken(self):
+ rect = Wire.make_rect(10, 5)
+ shell: Shell = Shell.extrude(rect, Vector(0, 0, 3))
+ thick = Solid.thicken(shell, 1)
+
+ self.assertEqual(isinstance(thick, Solid), True)
+ inner_vol = 3 * 10 * 5
+ outer_vol = 3 * 12 * 7
+ self.assertAlmostEqual(thick.volume, outer_vol - inner_vol)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_skip_clean.py b/tests/test_direct_api/test_skip_clean.py
new file mode 100644
index 0000000..4c26916
--- /dev/null
+++ b/tests/test_direct_api/test_skip_clean.py
@@ -0,0 +1,68 @@
+"""
+build123d imports
+
+name: test_skip_clean.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.topology import SkipClean
+
+
+class TestSkipClean(unittest.TestCase):
+ def setUp(self):
+ # Ensure the class variable is in its default state before each test
+ SkipClean.clean = True
+
+ def test_context_manager_sets_clean_false(self):
+ # Verify `clean` is initially True
+ self.assertTrue(SkipClean.clean)
+
+ # Use the context manager
+ with SkipClean():
+ # Within the context, `clean` should be False
+ self.assertFalse(SkipClean.clean)
+
+ # After exiting the context, `clean` should revert to True
+ self.assertTrue(SkipClean.clean)
+
+ def test_exception_handling_does_not_affect_clean(self):
+ # Verify `clean` is initially True
+ self.assertTrue(SkipClean.clean)
+
+ # Use the context manager and raise an exception
+ try:
+ with SkipClean():
+ self.assertFalse(SkipClean.clean)
+ raise ValueError("Test exception")
+ except ValueError:
+ pass
+
+ # Ensure `clean` is restored to True after an exception
+ self.assertTrue(SkipClean.clean)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py
new file mode 100644
index 0000000..2c67642
--- /dev/null
+++ b/tests/test_direct_api/test_solid.py
@@ -0,0 +1,238 @@
+"""
+build123d imports
+
+name: test_solid.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import unittest
+
+from build123d.build_enums import GeomType, Kind, Until
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.objects_curve import Spline
+from build123d.objects_sketch import Circle, Rectangle
+from build123d.topology import Compound, Edge, Face, Shell, Solid, Vertex, Wire
+
+
+class TestSolid(unittest.TestCase):
+ def test_make_solid(self):
+ box_faces = Solid.make_box(1, 1, 1).faces()
+ box_shell = Shell(box_faces)
+ box = Solid(box_shell)
+ self.assertAlmostEqual(box.area, 6, 5)
+ self.assertAlmostEqual(box.volume, 1, 5)
+ self.assertTrue(box.is_valid())
+
+ def test_extrude(self):
+ v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1))
+ self.assertAlmostEqual(v.length, 1, 5)
+
+ e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1))
+ self.assertAlmostEqual(e.area, 1, 5)
+
+ w = Shell.extrude(
+ Wire([Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))]),
+ (0, 0, 1),
+ )
+ self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5)
+
+ f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1))
+ self.assertAlmostEqual(f.volume, 1, 5)
+
+ s = Compound.extrude(
+ Shell(
+ Solid.make_box(1, 1, 1)
+ .locate(Location((-2, 1, 0)))
+ .faces()
+ .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:]
+ ),
+ (0.1, 0.1, 0.1),
+ )
+ self.assertAlmostEqual(s.volume, 0.2, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1))
+
+ def test_extrude_taper(self):
+ a = 1
+ rect = Face.make_rect(a, a)
+ flipped = -rect
+ for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]:
+ for taper in [10, -10]:
+ offset_amt = -direction.length * math.tan(math.radians(taper))
+ for face in [rect, flipped]:
+ with self.subTest(
+ f"{direction=}, {taper=}, flipped={face==flipped}"
+ ):
+ taper_solid = Solid.extrude_taper(face, direction, taper)
+ # V = 1/3 × h × (a² + b² + ab)
+ h = Vector(direction).length
+ b = a + 2 * offset_amt
+ v = h * (a**2 + b**2 + a * b) / 3
+ self.assertAlmostEqual(taper_solid.volume, v, 5)
+ bbox = taper_solid.bounding_box()
+ size = max(1, b) / 2
+ if direction.Z > 0:
+ self.assertAlmostEqual(bbox.min, (-size, -size, 0), 1)
+ self.assertAlmostEqual(bbox.max, (size, size, h), 1)
+ else:
+ self.assertAlmostEqual(bbox.min, (-size, -size, -h), 1)
+ self.assertAlmostEqual(bbox.max, (size, size, 0), 1)
+
+ def test_extrude_taper_with_hole(self):
+ rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
+ direction = Vector(0, 0, 0.5)
+ taper = 10
+ taper_solid = Solid.extrude_taper(rect_hole, direction, taper)
+ offset_amt = -direction.length * math.tan(math.radians(taper))
+ hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
+ self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5)
+
+ def test_extrude_taper_with_hole_flipped(self):
+ rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
+ direction = Vector(0, 0, 1)
+ taper = 10
+ taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True)
+ taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False)
+ hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
+ hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
+ self.assertGreater(hole_t.radius, hole_f.radius)
+
+ def test_extrude_taper_oblique(self):
+ rect = Face.make_rect(2, 1)
+ rect_hole = rect.make_holes([Wire.make_circle(0.25)])
+ o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77))
+ taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5)
+ taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5)
+ self.assertAlmostEqual(taper0.volume, taper1.volume, 5)
+
+ def test_extrude_linear_with_rotation(self):
+ # Face
+ base = Face.make_rect(1, 1)
+ twist = Solid.extrude_linear_with_rotation(
+ base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
+ )
+ self.assertAlmostEqual(twist.volume, 1, 5)
+ top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
+ bottom = twist.faces().sort_by(Axis.Z)[0]
+ self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
+ # Wire
+ base = Wire.make_rect(1, 1)
+ twist = Solid.extrude_linear_with_rotation(
+ base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
+ )
+ self.assertAlmostEqual(twist.volume, 1, 5)
+ top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
+ bottom = twist.faces().sort_by(Axis.Z)[0]
+ self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
+
+ def test_make_loft(self):
+ loft = Solid.make_loft(
+ [Wire.make_rect(2, 2), Wire.make_circle(1, Plane((0, 0, 1)))]
+ )
+ self.assertAlmostEqual(loft.volume, (4 + math.pi) / 2, 1)
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft([Wire.make_rect(1, 1)])
+
+ def test_make_loft_with_vertices(self):
+ loft = Solid.make_loft(
+ [Vertex(0, 0, -1), Wire.make_rect(1, 1.5), Vertex(0, 0, 1)], True
+ )
+ self.assertAlmostEqual(loft.volume, 1, 5)
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft(
+ [Wire.make_rect(1, 1), Vertex(0, 0, 1), Wire.make_rect(1, 1)]
+ )
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft([Vertex(0, 0, 1), Vertex(0, 0, 2)])
+
+ with self.assertRaises(ValueError):
+ Solid.make_loft(
+ [
+ Vertex(0, 0, 1),
+ Wire.make_rect(1, 1),
+ Vertex(0, 0, 2),
+ Vertex(0, 0, 3),
+ ]
+ )
+
+ def test_extrude_until(self):
+ square = Face.make_rect(1, 1)
+ box = Solid.make_box(4, 4, 1, Plane((-2, -2, 3)))
+ extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST)
+ self.assertAlmostEqual(extrusion.volume, 4, 5)
+
+ square = Face.make_rect(1, 1)
+ box = Solid.make_box(4, 4, 1, Plane((-2, -2, -3)))
+ extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.PREVIOUS)
+ self.assertAlmostEqual(extrusion.volume, 2, 5)
+
+ def test_sweep(self):
+ path = Edge.make_spline([(0, 0), (3, 5), (7, -2)])
+ section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0))
+ area = Face(section).area
+ swept = Solid.sweep(section, path)
+ self.assertAlmostEqual(swept.volume, path.length * area, 0)
+
+ def test_hollow_sweep(self):
+ path = Edge.make_line((0, 0, 0), (0, 0, 5))
+ section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0]
+ swept = Solid.sweep(section, path)
+ self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5)
+
+ def test_sweep_multi(self):
+ f0 = Face.make_rect(1, 1)
+ f1 = Pos(X=10) * Circle(1).face()
+ path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (0, 0, -1)))
+ binormal = Edge.make_line((0, 1), (10, 1))
+ swept = Solid.sweep_multi([f0, f1], path, is_frenet=True, binormal=binormal)
+ self.assertAlmostEqual(swept.volume, 23.78, 2)
+
+ path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (1, 0, 0)))
+ swept = Solid.sweep_multi(
+ [f0, f1], path, is_frenet=True, binormal=Vector(5, 0, 1)
+ )
+ self.assertAlmostEqual(swept.volume, 20.75, 2)
+
+ def test_constructor(self):
+ with self.assertRaises(TypeError):
+ Solid(foo="bar")
+
+ def test_offset_3d(self):
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).offset_3d(None, 0.1, kind=Kind.TANGENT)
+
+ def test_revolve(self):
+ r = Solid.revolve(
+ Face.make_rect(1, 1, Plane((10, 0, 0))).wire(), 180, axis=Axis.Y
+ )
+ self.assertEqual(len(r.faces()), 6)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py
new file mode 100644
index 0000000..a140f01
--- /dev/null
+++ b/tests/test_direct_api/test_vector.py
@@ -0,0 +1,288 @@
+"""
+build123d imports
+
+name: test_vector.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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.
+
+"""
+
+# Always equal to any other object, to test that __eq__ cooperation is working
+import copy
+import math
+import unittest
+
+from OCP.gp import gp_Vec, gp_XYZ
+from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.topology import Solid, Vertex
+
+
+class AlwaysEqual:
+ def __eq__(self, other):
+ return True
+
+
+class TestVector(unittest.TestCase):
+ """Test the Vector methods"""
+
+ def test_vector_constructors(self):
+ v1 = Vector(1, 2, 3)
+ v2 = Vector((1, 2, 3))
+ v3 = Vector(gp_Vec(1, 2, 3))
+ v4 = Vector([1, 2, 3])
+ v5 = Vector(gp_XYZ(1, 2, 3))
+ v5b = Vector(X=1, Y=2, Z=3)
+ v5c = Vector(v=gp_XYZ(1, 2, 3))
+
+ for v in [v1, v2, v3, v4, v5, v5b, v5c]:
+ self.assertAlmostEqual(v, (1, 2, 3), 4)
+
+ v6 = Vector((1, 2))
+ v7 = Vector([1, 2])
+ v8 = Vector(1, 2)
+ v8b = Vector(X=1, Y=2)
+
+ for v in [v6, v7, v8, v8b]:
+ self.assertAlmostEqual(v, (1, 2, 0), 4)
+
+ v9 = Vector()
+ self.assertAlmostEqual(v9, (0, 0, 0), 4)
+
+ v9.X = 1.0
+ v9.Y = 2.0
+ v9.Z = 3.0
+ self.assertAlmostEqual(v9, (1, 2, 3), 4)
+ self.assertAlmostEqual(Vector(1, 2, 3, 4), (1, 2, 3), 4)
+
+ v10 = Vector(1)
+ v11 = Vector((1,))
+ v12 = Vector([1])
+ v13 = Vector(X=1)
+ for v in [v10, v11, v12, v13]:
+ self.assertAlmostEqual(v, (1, 0, 0), 4)
+
+ vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10))
+ self.assertAlmostEqual(Vector(vertex), (0, 0, 10), 4)
+
+ with self.assertRaises(TypeError):
+ Vector("vector")
+ with self.assertRaises(ValueError):
+ Vector(x=1)
+
+ def test_vector_rotate(self):
+ """Validate vector rotate methods"""
+ vector_x = Vector(1, 0, 1).rotate(Axis.X, 45)
+ vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45)
+ vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45)
+ self.assertAlmostEqual(vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7)
+ self.assertAlmostEqual(vector_y, (math.sqrt(2), 2, 0), 7)
+ self.assertAlmostEqual(vector_z, (0, -math.sqrt(2), 3), 7)
+
+ def test_get_signed_angle(self):
+ """Verify getSignedAngle calculations with and without a provided normal"""
+ a = math.pi / 3
+ v1 = Vector(1, 0, 0)
+ v2 = Vector(math.cos(a), -math.sin(a), 0)
+ d1 = v1.get_signed_angle(v2)
+ d2 = v1.get_signed_angle(v2, Vector(0, 0, 1))
+ self.assertAlmostEqual(d1, a * 180 / math.pi)
+ self.assertAlmostEqual(d2, -a * 180 / math.pi)
+
+ def test_center(self):
+ v = Vector(1, 1, 1)
+ self.assertAlmostEqual(v, v.center())
+
+ def test_dot(self):
+ v1 = Vector(2, 2, 2)
+ v2 = Vector(1, -1, 1)
+ self.assertEqual(2.0, v1.dot(v2))
+
+ def test_vector_add(self):
+ result = Vector(1, 2, 0) + Vector(0, 0, 3)
+ self.assertAlmostEqual(result, (1.0, 2.0, 3.0), 3)
+
+ def test_vector_operators(self):
+ result = Vector(1, 1, 1) + Vector(2, 2, 2)
+ self.assertEqual(Vector(3, 3, 3), result)
+
+ result = Vector(1, 2, 3) - Vector(3, 2, 1)
+ self.assertEqual(Vector(-2, 0, 2), result)
+
+ result = Vector(1, 2, 3) * 2
+ self.assertEqual(Vector(2, 4, 6), result)
+
+ result = 3 * Vector(1, 2, 3)
+ self.assertEqual(Vector(3, 6, 9), result)
+
+ result = Vector(2, 4, 6) / 2
+ self.assertEqual(Vector(1, 2, 3), result)
+
+ self.assertEqual(Vector(-1, -1, -1), -Vector(1, 1, 1))
+
+ self.assertEqual(0, abs(Vector(0, 0, 0)))
+ self.assertEqual(1, abs(Vector(1, 0, 0)))
+ self.assertEqual((1 + 4 + 9) ** 0.5, abs(Vector(1, 2, 3)))
+
+ def test_vector_equals(self):
+ a = Vector(1, 2, 3)
+ b = Vector(1, 2, 3)
+ c = Vector(1, 2, 3.000001)
+ self.assertEqual(a, b)
+ self.assertEqual(a, c)
+ self.assertEqual(a, AlwaysEqual())
+
+ def test_vector_not_equal(self):
+ a = Vector(1, 2, 3)
+ b = Vector(3, 2, 1)
+ self.assertNotEqual(a, b)
+ self.assertNotEqual(a, object())
+
+ def test_vector_distance(self):
+ """
+ Test line distance from plane.
+ """
+ v = Vector(1, 2, 3)
+
+ self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ))
+ self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX))
+ self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY))
+ self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY))
+ self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ))
+ self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX))
+
+ self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ))
+ self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX))
+ self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY))
+ self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY))
+ self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ))
+ self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX))
+
+ def test_vector_project(self):
+ """
+ Test line projection and plane projection methods of Vector
+ """
+ decimal_places = 9
+
+ z_dir = Vector(1, 2, 3)
+ base = Vector(5, 7, 9)
+ x_dir = Vector(1, 0, 0)
+
+ # test passing Plane object
+ point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir))
+ self.assertAlmostEqual(point, (59 / 7, 55 / 7, 51 / 7), decimal_places)
+
+ # test line projection
+ vec = Vector(10, 10, 10)
+ line = Vector(3, 4, 5)
+ angle = math.radians(vec.get_angle(line))
+
+ vecLineProjection = vec.project_to_line(line)
+
+ self.assertAlmostEqual(
+ vecLineProjection.normalized(),
+ line.normalized(),
+ decimal_places,
+ )
+ self.assertAlmostEqual(
+ vec.length * math.cos(angle), vecLineProjection.length, decimal_places
+ )
+
+ def test_vector_not_implemented(self):
+ pass
+
+ def test_vector_special_methods(self):
+ self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)")
+ self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)")
+ self.assertEqual(
+ str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)),
+ "Vector(10, -23.65, 0)",
+ )
+
+ def test_vector_iter(self):
+ self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6)
+
+ def test_reverse(self):
+ self.assertAlmostEqual(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7)
+
+ def test_copy(self):
+ v2 = copy.copy(Vector(1, 2, 3))
+ v3 = copy.deepcopy(Vector(1, 2, 3))
+ self.assertAlmostEqual(v2, (1, 2, 3), 7)
+ self.assertAlmostEqual(v3, (1, 2, 3), 7)
+
+ def test_radd(self):
+ vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)]
+ vector_sum = sum(vectors)
+ self.assertAlmostEqual(vector_sum, (12, 15, 18), 5)
+
+ def test_hash(self):
+ vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)]
+ unique_vectors = list(set(vectors))
+ self.assertEqual(len(vectors), 4)
+ self.assertEqual(len(unique_vectors), 3)
+
+ def test_vector_transform(self):
+ a = Vector(1, 2, 3)
+ pxy = Plane.XY
+ pxy_o1 = Plane.XY.offset(1)
+ self.assertEqual(a.transform(pxy.forward_transform, is_direction=False), a)
+ self.assertEqual(
+ a.transform(pxy.forward_transform, is_direction=True), a.normalized()
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.forward_transform, is_direction=False), Vector(1, 2, 2)
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.forward_transform, is_direction=True), a.normalized()
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.reverse_transform, is_direction=False), Vector(1, 2, 4)
+ )
+ self.assertEqual(
+ a.transform(pxy_o1.reverse_transform, is_direction=True), a.normalized()
+ )
+
+ def test_intersect(self):
+ v1 = Vector(1, 2, 3)
+ self.assertAlmostEqual(v1 & Vector(1, 2, 3), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Vector(0, 0, 0))
+
+ self.assertAlmostEqual(v1 & Location((1, 2, 3)), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Location())
+
+ self.assertAlmostEqual(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Axis.X)
+
+ self.assertAlmostEqual(v1 & Plane((1, 2, 3)), (1, 2, 3), 5)
+ self.assertIsNone(v1 & Plane.XY)
+
+ self.assertAlmostEqual(
+ Vector((v1 & Solid.make_box(2, 4, 5)).vertex()), (1, 2, 3), 5
+ )
+ self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)))
+ self.assertIsNone(
+ Vertex(-10, -10, -10).intersect(Solid.make_box(0.5, 0.5, 0.5))
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_vector_like.py b/tests/test_direct_api/test_vector_like.py
new file mode 100644
index 0000000..4926331
--- /dev/null
+++ b/tests/test_direct_api/test_vector_like.py
@@ -0,0 +1,55 @@
+"""
+build123d imports
+
+name: test_vector_like.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.geometry import Axis, Vector
+from build123d.topology import Vertex
+
+
+class TestVectorLike(unittest.TestCase):
+ """Test typedef"""
+
+ def test_axis_from_vertex(self):
+ axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1))
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
+
+ def test_axis_from_vector(self):
+ axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1))
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
+
+ def test_axis_from_tuple(self):
+ axis = Axis((1, 2, 3), (0, 0, 1))
+ self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
+ self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_vertex.py b/tests/test_direct_api/test_vertex.py
new file mode 100644
index 0000000..434135c
--- /dev/null
+++ b/tests/test_direct_api/test_vertex.py
@@ -0,0 +1,104 @@
+"""
+build123d imports
+
+name: test_vertex.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.geometry import Axis, Vector
+from build123d.topology import Vertex
+
+
+class TestVertex(unittest.TestCase):
+ """Test the extensions to the cadquery Vertex class"""
+
+ def test_basic_vertex(self):
+ v = Vertex()
+ self.assertEqual(0, v.X)
+
+ v = Vertex(1, 1, 1)
+ self.assertEqual(1, v.X)
+ self.assertEqual(Vector, type(v.center()))
+
+ self.assertAlmostEqual(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7)
+ self.assertAlmostEqual(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7)
+ self.assertAlmostEqual(Vector(Vertex((7,))), (7, 0, 0), 7)
+ self.assertAlmostEqual(Vector(Vertex((8, 9))), (8, 9, 0), 7)
+
+ def test_vertex_volume(self):
+ v = Vertex(1, 1, 1)
+ self.assertAlmostEqual(v.volume, 0, 5)
+
+ def test_vertex_add(self):
+ test_vertex = Vertex(0, 0, 0)
+ self.assertAlmostEqual(Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7)
+ self.assertAlmostEqual(
+ Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7
+ )
+ self.assertAlmostEqual(
+ Vector(test_vertex + Vertex(100, -40, 10)),
+ (100, -40, 10),
+ 7,
+ )
+ with self.assertRaises(TypeError):
+ test_vertex + [1, 2, 3]
+
+ def test_vertex_sub(self):
+ test_vertex = Vertex(0, 0, 0)
+ self.assertAlmostEqual(Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7)
+ self.assertAlmostEqual(
+ Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7
+ )
+ self.assertAlmostEqual(
+ Vector(test_vertex - Vertex(100, -40, 10)),
+ (-100, 40, -10),
+ 7,
+ )
+ with self.assertRaises(TypeError):
+ test_vertex - [1, 2, 3]
+
+ def test_vertex_str(self):
+ self.assertEqual(str(Vertex(0, 0, 0)), "Vertex(0.0, 0.0, 0.0)")
+
+ def test_vertex_to_vector(self):
+ self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector)
+ self.assertAlmostEqual(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7)
+
+ def test_vertex_init_error(self):
+ with self.assertRaises(TypeError):
+ Vertex(Axis.Z)
+ with self.assertRaises(ValueError):
+ Vertex(x=1)
+ with self.assertRaises(TypeError):
+ Vertex((Axis.X, Axis.Y, Axis.Z))
+
+ def test_no_intersect(self):
+ with self.assertRaises(NotImplementedError):
+ Vertex(1, 2, 3) & Vertex(5, 6, 7)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_vtk_poly_data.py b/tests/test_direct_api/test_vtk_poly_data.py
new file mode 100644
index 0000000..69d022f
--- /dev/null
+++ b/tests/test_direct_api/test_vtk_poly_data.py
@@ -0,0 +1,87 @@
+"""
+build123d imports
+
+name: test_v_t_k_poly_data.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 build123d.topology import Solid
+from vtkmodules.vtkCommonDataModel import vtkPolyData
+from vtkmodules.vtkFiltersCore import vtkTriangleFilter
+
+
+class TestVTKPolyData(unittest.TestCase):
+ def setUp(self):
+ # Create a simple test object (e.g., a cylinder)
+ self.object_under_test = Solid.make_cylinder(1, 2)
+
+ def test_to_vtk_poly_data(self):
+ # Generate VTK data
+ vtk_data = self.object_under_test.to_vtk_poly_data(
+ tolerance=0.1, angular_tolerance=0.2, normals=True
+ )
+
+ # Verify the result is of type vtkPolyData
+ self.assertIsInstance(vtk_data, vtkPolyData)
+
+ # Further verification can include:
+ # - Checking the number of points, polygons, or cells
+ self.assertGreater(
+ vtk_data.GetNumberOfPoints(), 0, "VTK data should have points."
+ )
+ self.assertGreater(
+ vtk_data.GetNumberOfCells(), 0, "VTK data should have cells."
+ )
+
+ # Optionally, compare the output with a known reference object
+ # (if available) by exporting or analyzing the VTK data
+ known_filter = vtkTriangleFilter()
+ known_filter.SetInputData(vtk_data)
+ known_filter.Update()
+ known_output = known_filter.GetOutput()
+
+ self.assertEqual(
+ vtk_data.GetNumberOfPoints(),
+ known_output.GetNumberOfPoints(),
+ "Number of points in VTK data does not match the expected output.",
+ )
+ self.assertEqual(
+ vtk_data.GetNumberOfCells(),
+ known_output.GetNumberOfCells(),
+ "Number of cells in VTK data does not match the expected output.",
+ )
+
+ def test_empty_shape(self):
+ # Test handling of empty shape
+ empty_object = Solid() # Create an empty object
+ with self.assertRaises(ValueError) as context:
+ empty_object.to_vtk_poly_data()
+
+ self.assertEqual(str(context.exception), "Cannot convert an empty shape")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_wire.py b/tests/test_direct_api/test_wire.py
new file mode 100644
index 0000000..c3295e3
--- /dev/null
+++ b/tests/test_direct_api/test_wire.py
@@ -0,0 +1,221 @@
+"""
+build123d imports
+
+name: test_wire.py
+by: Gumyr
+date: January 22, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import random
+import unittest
+
+import numpy as np
+from build123d.build_enums import Side
+from build123d.geometry import Axis, Color, Location
+from build123d.objects_curve import Polyline, Spline
+from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
+from build123d.topology import Edge, Face, Wire
+
+
+class TestWire(unittest.TestCase):
+ def test_ellipse_arc(self):
+ full_ellipse = Wire.make_ellipse(2, 1)
+ half_ellipse = Wire.make_ellipse(
+ 2, 1, start_angle=0, end_angle=180, closed=True
+ )
+ self.assertAlmostEqual(full_ellipse.area / 2, half_ellipse.area, 5)
+
+ def test_stitch(self):
+ half_ellipse1 = Wire.make_ellipse(
+ 2, 1, start_angle=0, end_angle=180, closed=False
+ )
+ half_ellipse2 = Wire.make_ellipse(
+ 2, 1, start_angle=180, end_angle=360, closed=False
+ )
+ ellipse = half_ellipse1.stitch(half_ellipse2)
+ self.assertEqual(len(ellipse.wires()), 1)
+
+ def test_fillet_2d(self):
+ square = Wire.make_rect(1, 1)
+ squaroid = square.fillet_2d(0.1, square.vertices())
+ self.assertAlmostEqual(
+ squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5
+ )
+
+ def test_chamfer_2d(self):
+ square = Wire.make_rect(1, 1)
+ squaroid = square.chamfer_2d(0.1, 0.1, square.vertices())
+ self.assertAlmostEqual(
+ squaroid.length, 4 * (1 - 2 * 0.1 + 0.1 * math.sqrt(2)), 5
+ )
+
+ def test_chamfer_2d_edge(self):
+ square = Wire.make_rect(1, 1)
+ edge = square.edges().sort_by(Axis.Y)[0]
+ vertex = edge.vertices().sort_by(Axis.X)[0]
+ square = square.chamfer_2d(
+ distance=0.1, distance2=0.2, vertices=[vertex], edge=edge
+ )
+ self.assertAlmostEqual(square.edges().sort_by(Axis.Y)[0].length, 0.9)
+
+ def test_make_convex_hull(self):
+ # overlapping_edges = [
+ # Edge.make_circle(10, end_angle=60),
+ # Edge.make_circle(10, start_angle=30, end_angle=90),
+ # Edge.make_line((-10, 10), (10, -10)),
+ # ]
+ # with self.assertRaises(ValueError):
+ # Wire.make_convex_hull(overlapping_edges)
+
+ adjoining_edges = [
+ Edge.make_circle(10, end_angle=45),
+ Edge.make_circle(10, start_angle=315, end_angle=360),
+ Edge.make_line((-10, 10), (-10, -10)),
+ ]
+ hull_wire = Wire.make_convex_hull(adjoining_edges)
+ self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4)
+
+ # def test_fix_degenerate_edges(self):
+ # # Can't find a way to create one
+ # edge0 = Edge.make_line((0, 0, 0), (1, 0, 0))
+ # edge1 = Edge.make_line(edge0 @ 0, edge0 @ 0 + Vector(0, 1, 0))
+ # edge1a = edge1.trim(0, 1e-7)
+ # edge1b = edge1.trim(1e-7, 1.0)
+ # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0))
+ # wire = Wire([edge0, edge1a, edge1b, edge2])
+ # fixed_wire = wire.fix_degenerate_edges(1e-6)
+ # self.assertEqual(len(fixed_wire.edges()), 2)
+
+ def test_trim(self):
+ e0 = Edge.make_line((0, 0), (1, 0))
+ e1 = Edge.make_line((2, 0), (1, 0))
+ e2 = Edge.make_line((2, 0), (3, 0))
+ w1 = Wire([e0, e1, e2])
+ t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0)))
+ self.assertAlmostEqual(t1.length, 2.1, 5)
+
+ e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
+ # Three edges are created 0->0.5->0.75->1.0
+ o = e.offset_2d(10, side=Side.RIGHT, closed=False)
+ t2 = o.trim(0.1, 0.9)
+ self.assertAlmostEqual(t2.length, o.length * 0.8, 5)
+
+ t3 = o.trim(0.5, 1.0)
+ self.assertAlmostEqual(t3.length, o.length * 0.5, 5)
+
+ t4 = o.trim(0.5, 0.75)
+ self.assertAlmostEqual(t4.length, o.length * 0.25, 5)
+
+ with self.assertRaises(ValueError):
+ o.trim(0.75, 0.25)
+ spline = Spline(
+ (0, 0, 0),
+ (0, 10, 0),
+ tangents=((0, 0, 1), (0, 0, -1)),
+ tangent_scalars=(2, 2),
+ )
+ half = spline.trim(0.5, 1)
+ self.assertAlmostEqual(spline @ 0.5, half @ 0, 4)
+ self.assertAlmostEqual(spline @ 1, half @ 1, 4)
+
+ w = Rectangle(3, 1).wire()
+ t5 = w.trim(0, 0.5)
+ self.assertAlmostEqual(t5.length, 4, 5)
+ t6 = w.trim(0.5, 1)
+ self.assertAlmostEqual(t6.length, 4, 5)
+
+ p = RegularPolygon(10, 20).wire()
+ t7 = p.trim(0.1, 0.2)
+ self.assertAlmostEqual(p.length * 0.1, t7.length, 5)
+
+ c = Circle(10).wire()
+ t8 = c.trim(0.4, 0.9)
+ self.assertAlmostEqual(c.length * 0.5, t8.length, 5)
+
+ def test_param_at_point(self):
+ e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
+ # Three edges are created 0->0.5->0.75->1.0
+ o = e.offset_2d(10, side=Side.RIGHT, closed=False)
+
+ e0 = Edge.make_line((0, 0), (1, 0))
+ e1 = Edge.make_line((2, 0), (1, 0))
+ e2 = Edge.make_line((2, 0), (3, 0))
+ w1 = Wire([e0, e1, e2])
+ for wire in [o, w1]:
+ u_value = random.random()
+ position = wire.position_at(u_value)
+ self.assertAlmostEqual(wire.param_at_point(position), u_value, 4)
+
+ with self.assertRaises(ValueError):
+ o.param_at_point((-1, 1))
+
+ with self.assertRaises(ValueError):
+ w1.param_at_point((20, 20, 20))
+
+ def test_order_edges(self):
+ w1 = Wire(
+ [
+ Edge.make_line((0, 0), (1, 0)),
+ Edge.make_line((1, 1), (1, 0)),
+ Edge.make_line((0, 1), (1, 1)),
+ ]
+ )
+ ordered_edges = w1.order_edges()
+ self.assertFalse(all(e.is_forward for e in w1.edges()))
+ self.assertTrue(all(e.is_forward for e in ordered_edges))
+ self.assertAlmostEqual(ordered_edges[0] @ 0, (0, 0, 0), 5)
+ self.assertAlmostEqual(ordered_edges[1] @ 0, (1, 0, 0), 5)
+ self.assertAlmostEqual(ordered_edges[2] @ 0, (1, 1, 0), 5)
+
+ def test_constructor(self):
+ e0 = Edge.make_line((0, 0), (1, 0))
+ e1 = Edge.make_line((1, 0), (1, 1))
+ w0 = Wire.make_circle(1)
+ w1 = Wire(e0)
+ self.assertTrue(w1.is_valid())
+ w2 = Wire([e0])
+ self.assertAlmostEqual(w2.length, 1, 5)
+ self.assertTrue(w2.is_valid())
+ w3 = Wire([e0, e1])
+ self.assertTrue(w3.is_valid())
+ self.assertAlmostEqual(w3.length, 2, 5)
+ w4 = Wire(w0.wrapped)
+ self.assertTrue(w4.is_valid())
+ w5 = Wire(obj=w0.wrapped)
+ self.assertTrue(w5.is_valid())
+ w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red"))
+ self.assertTrue(w6.is_valid())
+ self.assertEqual(w6.label, "w6")
+ np.testing.assert_allclose(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 1e-5)
+ w7 = Wire(w6)
+ self.assertTrue(w7.is_valid())
+ c0 = Polyline((0, 0), (1, 0), (1, 1))
+ w8 = Wire(c0)
+ self.assertTrue(w8.is_valid())
+ with self.assertRaises(ValueError):
+ Wire(bob="fred")
+
+
+if __name__ == "__main__":
+ unittest.main()
From 50663a21c45df4b26a9d1164e76cf449a161322b Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 22 Jan 2025 15:28:40 -0500
Subject: [PATCH 155/518] Removed redundant test_direct_api.py
---
tests/test_direct_api.py | 4826 --------------------------------------
1 file changed, 4826 deletions(-)
delete mode 100644 tests/test_direct_api.py
diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py
deleted file mode 100644
index 52d8ac8..0000000
--- a/tests/test_direct_api.py
+++ /dev/null
@@ -1,4826 +0,0 @@
-# system modules
-import copy
-import io
-from io import StringIO
-import itertools
-import json
-import math
-import numpy as np
-import os
-import platform
-import pprint
-import random
-import re
-from typing import Optional
-import unittest
-from unittest.mock import patch, MagicMock
-from random import uniform
-from IPython.lib import pretty
-
-from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
-from OCP.BRepGProp import BRepGProp
-from OCP.gp import (
- gp,
- gp_Ax1,
- gp_Ax2,
- gp_Circ,
- gp_Dir,
- gp_Elips,
- gp_EulerSequence,
- gp_Pnt,
- gp_Quaternion,
- gp_Trsf,
- gp_Vec,
- gp_XYZ,
-)
-from OCP.GProp import GProp_GProps
-from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain # Correct import
-
-from vtkmodules.vtkCommonDataModel import vtkPolyData
-from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter
-
-from build123d.build_common import GridLocations, Locations, PolarLocations
-from build123d.build_enums import (
- Align,
- AngularDirection,
- CenterOf,
- Extrinsic,
- GeomType,
- Intrinsic,
- Keep,
- Kind,
- Mode,
- PositionMode,
- Side,
- SortBy,
- Until,
-)
-
-from build123d.build_part import BuildPart
-from build123d.exporters3d import export_brep, export_step, export_stl
-from build123d.operations_part import extrude
-from build123d.operations_sketch import make_face
-from build123d.operations_generic import fillet, add, sweep
-from build123d.objects_part import Box, Cylinder
-from build123d.objects_curve import CenterArc, EllipticalCenterArc, JernArc, Polyline
-from build123d.build_sketch import BuildSketch
-from build123d.build_line import BuildLine
-from build123d.jupyter_tools import to_vtkpoly_string
-from build123d.objects_curve import Spline
-from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
-from build123d.geometry import (
- Axis,
- BoundBox,
- Color,
- Location,
- LocationEncoder,
- Matrix,
- Plane,
- Pos,
- Rot,
- Rotation,
- Vector,
- VectorLike,
-)
-from build123d.importers import import_brep, import_step, import_stl
-from build123d.mesher import Mesher
-from build123d.topology import (
- Compound,
- Edge,
- Face,
- GroupBy,
- Shape,
- ShapeList,
- Shell,
- SkipClean,
- Solid,
- Sketch,
- Vertex,
- Wire,
- edges_to_wires,
- polar,
- new_edges,
- delta,
- unwrap_topods_compound,
-)
-from build123d.jupyter_tools import display
-
-
-# Always equal to any other object, to test that __eq__ cooperation is working
-class AlwaysEqual:
- def __eq__(self, other):
- return True
-
-
-class TestAssembly(unittest.TestCase):
- @staticmethod
- def create_test_assembly() -> Compound:
- box = Solid.make_box(1, 1, 1)
- box.orientation = (45, 45, 0)
- box.label = "box"
- sphere = Solid.make_sphere(1)
- sphere.label = "sphere"
- sphere.position = (1, 2, 3)
- assembly = Compound(label="assembly", children=[box])
- sphere.parent = assembly
- return assembly
-
- def assertTopoEqual(self, actual_topo: str, expected_topo_lines: list[str]):
- actual_topo_lines = actual_topo.splitlines()
- self.assertEqual(len(actual_topo_lines), len(expected_topo_lines))
- for actual_line, expected_line in zip(actual_topo_lines, expected_topo_lines):
- start, end = re.split(r"at 0x[0-9a-f]+,", expected_line, 2, re.I)
- self.assertTrue(actual_line.startswith(start))
- self.assertTrue(actual_line.endswith(end))
-
- def test_attributes(self):
- box = Solid.make_box(1, 1, 1)
- box.label = "box"
- sphere = Solid.make_sphere(1)
- sphere.label = "sphere"
- assembly = Compound(label="assembly", children=[box])
- sphere.parent = assembly
-
- self.assertEqual(len(box.children), 0)
- self.assertEqual(box.label, "box")
- self.assertEqual(box.parent, assembly)
- self.assertEqual(sphere.parent, assembly)
- self.assertEqual(len(assembly.children), 2)
-
- def test_show_topology_compound(self):
- assembly = TestAssembly.create_test_assembly()
- expected = [
- "assembly Compound at 0x7fced0fd1b50, Location(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))",
- "├── box Solid at 0x7fced102d3a0, Location(p=(0.00, 0.00, 0.00), o=(45.00, 45.00, -0.00))",
- "└── sphere Solid at 0x7fced0fd1f10, Location(p=(1.00, 2.00, 3.00), o=(-0.00, 0.00, -0.00))",
- ]
- self.assertTopoEqual(assembly.show_topology("Solid"), expected)
-
- def test_show_topology_shape_location(self):
- assembly = TestAssembly.create_test_assembly()
- expected = [
- "Solid at 0x7f3754501530, Position(1.0, 2.0, 3.0)",
- "└── Shell at 0x7f3754501a70, Position(1.0, 2.0, 3.0)",
- " └── Face at 0x7f3754501030, Position(1.0, 2.0, 3.0)",
- ]
- self.assertTopoEqual(
- assembly.children[1].show_topology("Face", show_center=False), expected
- )
-
- def test_show_topology_shape(self):
- assembly = TestAssembly.create_test_assembly()
- expected = [
- "Solid at 0x7f6279043ab0, Center(1.0, 2.0, 3.0)",
- "└── Shell at 0x7f62790438f0, Center(1.0, 2.0, 3.0)",
- " └── Face at 0x7f62790439f0, Center(1.0, 2.0, 3.0)",
- ]
- self.assertTopoEqual(assembly.children[1].show_topology("Face"), expected)
-
- def test_remove_child(self):
- assembly = TestAssembly.create_test_assembly()
- self.assertEqual(len(assembly.children), 2)
- assembly.children = list(assembly.children)[1:]
- self.assertEqual(len(assembly.children), 1)
-
- def test_do_children_intersect(self):
- (
- overlap,
- pair,
- distance,
- ) = TestAssembly.create_test_assembly().do_children_intersect()
- self.assertFalse(overlap)
- box = Solid.make_box(1, 1, 1)
- box.orientation = (45, 45, 0)
- box.label = "box"
- sphere = Solid.make_sphere(1)
- sphere.label = "sphere"
- sphere.position = (0, 0, 0)
- assembly = Compound(label="assembly", children=[box])
- sphere.parent = assembly
- overlap, pair, distance = assembly.do_children_intersect()
- self.assertTrue(overlap)
-
-
-class TestAxis(unittest.TestCase):
- """Test the Axis class"""
-
- def test_axis_init(self):
- test_axis = Axis((1, 2, 3), (0, 0, 1))
- self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis((1, 2, 3), direction=(0, 0, 1))
- self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1))
- self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4)))
- self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
-
- test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4)))
- self.assertAlmostEqual(test_axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(test_axis.direction, (0, 0, 1), 5)
-
- with self.assertRaises(ValueError):
- Axis("one", "up")
- with self.assertRaises(ValueError):
- Axis(one="up")
-
- def test_axis_from_occt(self):
- occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0))
- test_axis = Axis(occt_axis)
- self.assertAlmostEqual(test_axis.position, (1, 1, 1), 5)
- self.assertAlmostEqual(test_axis.direction, (0, 1, 0), 5)
-
- def test_axis_repr_and_str(self):
- self.assertEqual(repr(Axis.X), "((0.0, 0.0, 0.0),(1.0, 0.0, 0.0))")
- self.assertEqual(str(Axis.Y), "Axis: ((0.0, 0.0, 0.0),(0.0, 1.0, 0.0))")
-
- def test_axis_copy(self):
- x_copy = copy.copy(Axis.X)
- self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5)
- self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5)
- x_copy = copy.deepcopy(Axis.X)
- self.assertAlmostEqual(x_copy.position, (0, 0, 0), 5)
- self.assertAlmostEqual(x_copy.direction, (1, 0, 0), 5)
-
- def test_axis_to_location(self):
- # TODO: Verify this is correct
- x_location = Axis.X.location
- self.assertTrue(isinstance(x_location, Location))
- self.assertAlmostEqual(x_location.position, (0, 0, 0), 5)
- self.assertAlmostEqual(x_location.orientation, (0, 90, 180), 5)
-
- def test_axis_located(self):
- y_axis = Axis.Z.located(Location((0, 0, 1), (-90, 0, 0)))
- self.assertAlmostEqual(y_axis.position, (0, 0, 1), 5)
- self.assertAlmostEqual(y_axis.direction, (0, 1, 0), 5)
-
- def test_axis_to_plane(self):
- x_plane = Axis.X.to_plane()
- self.assertTrue(isinstance(x_plane, Plane))
- self.assertAlmostEqual(x_plane.origin, (0, 0, 0), 5)
- self.assertAlmostEqual(x_plane.z_dir, (1, 0, 0), 5)
-
- def test_axis_is_coaxial(self):
- self.assertTrue(Axis.X.is_coaxial(Axis((0, 0, 0), (1, 0, 0))))
- self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 1), (1, 0, 0))))
- self.assertFalse(Axis.X.is_coaxial(Axis((0, 0, 0), (0, 1, 0))))
-
- def test_axis_is_normal(self):
- self.assertTrue(Axis.X.is_normal(Axis.Y))
- self.assertFalse(Axis.X.is_normal(Axis.X))
-
- def test_axis_is_opposite(self):
- self.assertTrue(Axis.X.is_opposite(Axis((1, 1, 1), (-1, 0, 0))))
- self.assertFalse(Axis.X.is_opposite(Axis.X))
-
- def test_axis_is_parallel(self):
- self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0))))
- self.assertFalse(Axis.X.is_parallel(Axis.Y))
-
- def test_axis_angle_between(self):
- self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5)
- self.assertAlmostEqual(
- Axis.X.angle_between(Axis((1, 1, 1), (-1, 0, 0))), 180, 5
- )
-
- def test_axis_reverse(self):
- self.assertAlmostEqual(Axis.X.reverse().direction, (-1, 0, 0), 5)
-
- def test_axis_reverse_op(self):
- axis = -Axis.X
- self.assertAlmostEqual(axis.direction, (-1, 0, 0), 5)
-
- def test_axis_as_edge(self):
- edge = Edge(Axis.X)
- self.assertTrue(isinstance(edge, Edge))
- common = (edge & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
- self.assertAlmostEqual(common.length, 1, 5)
-
- def test_axis_intersect(self):
- common = (Axis.X.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
- self.assertAlmostEqual(common.length, 1, 5)
-
- common = (Axis.X & Edge.make_line((0, 0, 0), (1, 0, 0))).edge()
- self.assertAlmostEqual(common.length, 1, 5)
-
- intersection = Axis.X & Axis((1, 0, 0), (0, 1, 0))
- self.assertAlmostEqual(intersection, (1, 0, 0), 5)
-
- i = Axis.X & Axis((1, 0, 0), (1, 0, 0))
- self.assertEqual(i, Axis.X)
-
- intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY
- self.assertAlmostEqual(intersection.to_tuple(), (1, 2, 0), 5)
-
- arc = Edge.make_circle(20, start_angle=0, end_angle=180)
- ax0 = Axis((-20, 30, 0), (4, -3, 0))
- intersections = arc.intersect(ax0).vertices().sort_by(Axis.X)
- np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5)
- np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5)
-
- intersections = ax0.intersect(arc).vertices().sort_by(Axis.X)
- np.testing.assert_allclose(tuple(intersections[0]), (-5.6, 19.2, 0), 1e-5)
- np.testing.assert_allclose(tuple(intersections[1]), (20, 0, 0), 1e-5)
-
- i = Axis((0, 0, 1), (1, 1, 1)) & Vector(0.5, 0.5, 1.5)
- self.assertTrue(isinstance(i, Vector))
- self.assertAlmostEqual(i, (0.5, 0.5, 1.5), 5)
- self.assertIsNone(Axis.Y & Vector(2, 0, 0))
-
- l = Edge.make_line((0, 0, 1), (0, 0, 2)) ^ 1
- i: Location = Axis.Z & l
- self.assertTrue(isinstance(i, Location))
- self.assertAlmostEqual(i.position, l.position, 5)
- self.assertAlmostEqual(i.orientation, l.orientation, 5)
-
- self.assertIsNone(Axis.Z & Edge.make_line((0, 0, 1), (1, 0, 0)).location_at(1))
- self.assertIsNone(Axis.Z & Edge.make_line((1, 0, 1), (1, 0, 2)).location_at(1))
-
- # TODO: uncomment when generalized edge to surface intersections are complete
- # non_planar = (
- # Solid.make_cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)
- # )
- # intersections = Axis((0, 0, 5), (1, 0, 0)) & non_planar
-
- # self.assertTrue(len(intersections.vertices(), 2))
- # np.testing.assert_allclose(
- # intersection.vertices()[0].to_tuple(), (-1, 0, 5), 5
- # )
- # np.testing.assert_allclose(
- # intersection.vertices()[1].to_tuple(), (1, 0, 5), 5
- # )
-
- def test_axis_equal(self):
- self.assertEqual(Axis.X, Axis.X)
- self.assertEqual(Axis.Y, Axis.Y)
- self.assertEqual(Axis.Z, Axis.Z)
- self.assertEqual(Axis.X, AlwaysEqual())
-
- def test_axis_not_equal(self):
- self.assertNotEqual(Axis.X, Axis.Y)
- random_obj = object()
- self.assertNotEqual(Axis.X, random_obj)
-
-
-class TestBoundBox(unittest.TestCase):
- def test_basic_bounding_box(self):
- v = Vertex(1, 1, 1)
- v2 = Vertex(2, 2, 2)
- self.assertEqual(BoundBox, type(v.bounding_box()))
- self.assertEqual(BoundBox, type(v2.bounding_box()))
-
- bb1 = v.bounding_box().add(v2.bounding_box())
-
- # OCC uses some approximations
- self.assertAlmostEqual(bb1.size.X, 1.0, 1)
-
- # Test adding to an existing bounding box
- v0 = Vertex(0, 0, 0)
- bb2 = v0.bounding_box().add(v.bounding_box())
-
- bb3 = bb1.add(bb2)
- self.assertAlmostEqual(bb3.size, (2, 2, 2), 7)
-
- bb3 = bb2.add((3, 3, 3))
- self.assertAlmostEqual(bb3.size, (3, 3, 3), 7)
-
- bb3 = bb2.add(Vector(3, 3, 3))
- self.assertAlmostEqual(bb3.size, (3, 3, 3), 7)
-
- # Test 2D bounding boxes
- bb1 = Vertex(1, 1, 0).bounding_box().add(Vertex(2, 2, 0).bounding_box())
- bb2 = Vertex(0, 0, 0).bounding_box().add(Vertex(3, 3, 0).bounding_box())
- bb3 = Vertex(0, 0, 0).bounding_box().add(Vertex(1.5, 1.5, 0).bounding_box())
- # Test that bb2 contains bb1
- self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb1, bb2))
- self.assertEqual(bb2, BoundBox.find_outside_box_2d(bb2, bb1))
- # Test that neither bounding box contains the other
- self.assertIsNone(BoundBox.find_outside_box_2d(bb1, bb3))
-
- # Test creation of a bounding box from a shape - note the low accuracy comparison
- # as the box is a little larger than the shape
- bb1 = BoundBox.from_topo_ds(Solid.make_cylinder(1, 1).wrapped, optimal=False)
- self.assertAlmostEqual(bb1.size, (2, 2, 1), 1)
-
- bb2 = BoundBox.from_topo_ds(
- Solid.make_cylinder(0.5, 0.5).translate((0, 0, 0.1)).wrapped, optimal=False
- )
- self.assertTrue(bb2.is_inside(bb1))
-
- def test_bounding_box_repr(self):
- bb = Solid.make_box(1, 1, 1).bounding_box()
- self.assertEqual(
- repr(bb), "bbox: 0.0 <= x <= 1.0, 0.0 <= y <= 1.0, 0.0 <= z <= 1.0"
- )
-
- def test_center_of_boundbox(self):
- self.assertAlmostEqual(
- Solid.make_box(1, 1, 1).bounding_box().center(),
- (0.5, 0.5, 0.5),
- 5,
- )
-
- def test_combined_center_of_boundbox(self):
- pass
-
- def test_clean_boundbox(self):
- s = Solid.make_sphere(3)
- self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5)
- s.mesh(1e-3)
- self.assertAlmostEqual(s.bounding_box().size, (6, 6, 6), 5)
-
- # def test_to_solid(self):
- # bbox = Solid.make_sphere(1).bounding_box()
- # self.assertAlmostEqual(bbox.min, (-1, -1, -1), 5)
- # self.assertAlmostEqual(bbox.max, (1, 1, 1), 5)
- # self.assertAlmostEqual(bbox.to_solid().volume, 2**3, 5)
-
-
-class TestCadObjects(unittest.TestCase):
- def _make_circle(self):
- circle = gp_Circ(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 2.0)
- return Edge.cast(BRepBuilderAPI_MakeEdge(circle).Edge())
-
- def _make_ellipse(self):
- ellipse = gp_Elips(gp_Ax2(gp_Pnt(1, 2, 3), gp.DZ_s()), 4.0, 2.0)
- return Edge.cast(BRepBuilderAPI_MakeEdge(ellipse).Edge())
-
- def test_edge_wrapper_center(self):
- e = self._make_circle()
-
- self.assertAlmostEqual(e.center(CenterOf.MASS), (1.0, 2.0, 3.0), 3)
-
- def test_edge_wrapper_ellipse_center(self):
- e = self._make_ellipse()
- w = Wire([e])
- self.assertAlmostEqual(Face(w).center(), (1.0, 2.0, 3.0), 3)
-
- def test_edge_wrapper_make_circle(self):
- halfCircleEdge = Edge.make_circle(radius=10, start_angle=0, end_angle=180)
-
- # np.testing.assert_allclose((0.0, 5.0, 0.0), halfCircleEdge.centerOfBoundBox(0.0001),1e-3)
- self.assertAlmostEqual(halfCircleEdge.start_point(), (10.0, 0.0, 0.0), 3)
- self.assertAlmostEqual(halfCircleEdge.end_point(), (-10.0, 0.0, 0.0), 3)
-
- def test_edge_wrapper_make_tangent_arc(self):
- tangent_arc = Edge.make_tangent_arc(
- Vector(1, 1), # starts at 1, 1
- Vector(0, 1), # tangent at start of arc is in the +y direction
- Vector(2, 1), # arc cureturn_values 180 degrees and ends at 2, 1
- )
- self.assertAlmostEqual(tangent_arc.start_point(), (1, 1, 0), 3)
- self.assertAlmostEqual(tangent_arc.end_point(), (2, 1, 0), 3)
- self.assertAlmostEqual(tangent_arc.tangent_at(0), (0, 1, 0), 3)
- self.assertAlmostEqual(tangent_arc.tangent_at(0.5), (1, 0, 0), 3)
- self.assertAlmostEqual(tangent_arc.tangent_at(1), (0, -1, 0), 3)
-
- def test_edge_wrapper_make_ellipse1(self):
- # Check x_radius > y_radius
- x_radius, y_radius = 20, 10
- angle1, angle2 = -75.0, 90.0
- arcEllipseEdge = Edge.make_ellipse(
- x_radius=x_radius,
- y_radius=y_radius,
- plane=Plane.XY,
- start_angle=angle1,
- end_angle=angle2,
- )
-
- start = (
- x_radius * math.cos(math.radians(angle1)),
- y_radius * math.sin(math.radians(angle1)),
- 0.0,
- )
- end = (
- x_radius * math.cos(math.radians(angle2)),
- y_radius * math.sin(math.radians(angle2)),
- 0.0,
- )
- self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
- self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
-
- def test_edge_wrapper_make_ellipse2(self):
- # Check x_radius < y_radius
- x_radius, y_radius = 10, 20
- angle1, angle2 = 0.0, 45.0
- arcEllipseEdge = Edge.make_ellipse(
- x_radius=x_radius,
- y_radius=y_radius,
- plane=Plane.XY,
- start_angle=angle1,
- end_angle=angle2,
- )
-
- start = (
- x_radius * math.cos(math.radians(angle1)),
- y_radius * math.sin(math.radians(angle1)),
- 0.0,
- )
- end = (
- x_radius * math.cos(math.radians(angle2)),
- y_radius * math.sin(math.radians(angle2)),
- 0.0,
- )
- self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
- self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
-
- def test_edge_wrapper_make_circle_with_ellipse(self):
- # Check x_radius == y_radius
- x_radius, y_radius = 20, 20
- angle1, angle2 = 15.0, 60.0
- arcEllipseEdge = Edge.make_ellipse(
- x_radius=x_radius,
- y_radius=y_radius,
- plane=Plane.XY,
- start_angle=angle1,
- end_angle=angle2,
- )
-
- start = (
- x_radius * math.cos(math.radians(angle1)),
- y_radius * math.sin(math.radians(angle1)),
- 0.0,
- )
- end = (
- x_radius * math.cos(math.radians(angle2)),
- y_radius * math.sin(math.radians(angle2)),
- 0.0,
- )
- self.assertAlmostEqual(arcEllipseEdge.start_point(), start, 3)
- self.assertAlmostEqual(arcEllipseEdge.end_point(), end, 3)
-
- def test_face_wrapper_make_rect(self):
- mplane = Face.make_rect(10, 10)
-
- self.assertAlmostEqual(mplane.normal_at(), (0.0, 0.0, 1.0), 3)
-
- # def testCompoundcenter(self):
- # """
- # Tests whether or not a proper weighted center can be found for a compound
- # """
-
- # def cylinders(self, radius, height):
-
- # c = Solid.make_cylinder(radius, height, Vector())
-
- # # Combine all the cylinders into a single compound
- # r = self.eachpoint(lambda loc: c.located(loc), True).combinesolids()
-
- # return r
-
- # Workplane.cyl = cylinders
-
- # # Now test. here we want weird workplane to see if the objects are transformed right
- # s = (
- # Workplane("XY")
- # .rect(2.0, 3.0, for_construction=true)
- # .vertices()
- # .cyl(0.25, 0.5)
- # )
-
- # self.assertEqual(4, len(s.val().solids()))
- # np.testing.assert_allclose((0.0, 0.0, 0.25), s.val().center, 1e-3)
-
- def test_translate(self):
- e = Edge.make_circle(2, Plane((1, 2, 3)))
- e2 = e.translate(Vector(0, 0, 1))
-
- self.assertAlmostEqual(e2.center(CenterOf.MASS), (1.0, 2.0, 4.0), 3)
-
- def test_vertices(self):
- e = Edge.cast(BRepBuilderAPI_MakeEdge(gp_Pnt(0, 0, 0), gp_Pnt(1, 1, 0)).Edge())
- self.assertEqual(2, len(e.vertices()))
-
- def test_edge_wrapper_radius(self):
- # get a radius from a simple circle
- e0 = Edge.make_circle(2.4)
- self.assertAlmostEqual(e0.radius, 2.4)
-
- # radius of an arc
- e1 = Edge.make_circle(
- 1.8, Plane(origin=(5, 6, 7), z_dir=(1, 1, 1)), start_angle=20, end_angle=30
- )
- self.assertAlmostEqual(e1.radius, 1.8)
-
- # test value errors
- e2 = Edge.make_ellipse(10, 20)
- with self.assertRaises(ValueError):
- e2.radius
-
- # radius from a wire
- w0 = Wire.make_circle(10, Plane(origin=(1, 2, 3), z_dir=(-1, 0, 1)))
- self.assertAlmostEqual(w0.radius, 10)
-
- # radius from a wire with multiple edges
- rad = 2.3
- plane = Plane(origin=(7, 8, 0), z_dir=(1, 0.5, 0.1))
- w1 = Wire(
- [
- Edge.make_circle(rad, plane, 0, 10),
- Edge.make_circle(rad, plane, 10, 25),
- Edge.make_circle(rad, plane, 25, 230),
- ]
- )
- self.assertAlmostEqual(w1.radius, rad)
-
- # test value error from wire
- w2 = Wire.make_polygon(
- [
- Vector(-1, 0, 0),
- Vector(0, 1, 0),
- Vector(1, -1, 0),
- ]
- )
- with self.assertRaises(ValueError):
- w2.radius
-
- # (I think) the radius of a wire is the radius of it's first edge.
- # Since this is stated in the docstring better make sure.
- no_rad = Wire(
- [
- Edge.make_line(Vector(0, 0, 0), Vector(0, 1, 0)),
- Edge.make_circle(1.0, start_angle=90, end_angle=270),
- ]
- )
- with self.assertRaises(ValueError):
- no_rad.radius
- yes_rad = Wire(
- [
- Edge.make_circle(1.0, start_angle=90, end_angle=270),
- Edge.make_line(Vector(0, -1, 0), Vector(0, 1, 0)),
- ]
- )
- self.assertAlmostEqual(yes_rad.radius, 1.0)
- many_rad = Wire(
- [
- Edge.make_circle(1.0, start_angle=0, end_angle=180),
- Edge.make_circle(3.0, Plane((2, 0, 0)), start_angle=180, end_angle=359),
- ]
- )
- self.assertAlmostEqual(many_rad.radius, 1.0)
-
-
-class TestCleanMethod(unittest.TestCase):
- def setUp(self):
- # Create a mock object
- self.solid = Solid()
- self.solid.wrapped = MagicMock() # Simulate a valid `wrapped` object
-
- @patch("build123d.topology.shape_core.ShapeUpgrade_UnifySameDomain")
- def test_clean_warning_on_exception(self, mock_shape_upgrade):
- # Mock the upgrader
- mock_upgrader = mock_shape_upgrade.return_value
- mock_upgrader.Build.side_effect = Exception("Mocked Build failure")
-
- # Capture warnings
- with self.assertWarns(Warning) as warn_context:
- self.solid.clean()
-
- # Assert the warning message
- self.assertIn("Unable to clean", str(warn_context.warning))
-
- # Verify the upgrader was constructed with the correct arguments
- mock_shape_upgrade.assert_called_once_with(self.solid.wrapped, True, True, True)
-
- # Verify the Build method was called
- mock_upgrader.Build.assert_called_once()
-
- def test_clean_with_none_wrapped(self):
- # Set `wrapped` to None to simulate the error condition
- self.solid.wrapped = None
-
- # Call clean and ensure it returns self
- result = self.solid.clean()
- self.assertIs(result, self.solid) # Ensure it returns the same object
-
-
-class TestColor(unittest.TestCase):
- def test_name1(self):
- c = Color("blue")
- np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
-
- def test_name2(self):
- c = Color("blue", alpha=0.5)
- np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
-
- def test_name3(self):
- c = Color("blue", 0.5)
- np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
-
- def test_rgb0(self):
- c = Color(0.0, 1.0, 0.0)
- np.testing.assert_allclose(tuple(c), (0, 1, 0, 1), 1e-5)
-
- def test_rgba1(self):
- c = Color(1.0, 1.0, 0.0, 0.5)
- self.assertEqual(c.wrapped.GetRGB().Red(), 1.0)
- self.assertEqual(c.wrapped.GetRGB().Green(), 1.0)
- self.assertEqual(c.wrapped.GetRGB().Blue(), 0.0)
- self.assertEqual(c.wrapped.Alpha(), 0.5)
-
- def test_rgba2(self):
- c = Color(1.0, 1.0, 0.0, alpha=0.5)
- np.testing.assert_allclose(tuple(c), (1, 1, 0, 0.5), 1e-5)
-
- def test_rgba3(self):
- c = Color(red=0.1, green=0.2, blue=0.3, alpha=0.5)
- np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.5), 1e-5)
-
- def test_bad_color_name(self):
- with self.assertRaises(ValueError):
- Color("build123d")
-
- def test_to_tuple(self):
- c = Color("blue", alpha=0.5)
- np.testing.assert_allclose(tuple(c), (0, 0, 1, 0.5), 1e-5)
-
- def test_hex(self):
- c = Color(0x996692)
- np.testing.assert_allclose(
- tuple(c), (0x99 / 0xFF, 0x66 / 0xFF, 0x92 / 0xFF, 1), 5
- )
-
- c = Color(0x006692, 0x80)
- np.testing.assert_allclose(
- tuple(c), (0, 0x66 / 0xFF, 0x92 / 0xFF, 0x80 / 0xFF), 5
- )
-
- c = Color(0x006692, alpha=0x80)
- np.testing.assert_allclose(tuple(c), (0, 102 / 255, 146 / 255, 128 / 255), 1e-5)
-
- c = Color(color_code=0x996692, alpha=0xCC)
- np.testing.assert_allclose(
- tuple(c), (153 / 255, 102 / 255, 146 / 255, 204 / 255), 5
- )
-
- c = Color(0.0, 0.0, 1.0, 1.0)
- np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
-
- c = Color(0, 0, 1, 1)
- np.testing.assert_allclose(tuple(c), (0, 0, 1, 1), 1e-5)
-
- def test_copy(self):
- c = Color(0.1, 0.2, 0.3, alpha=0.4)
- c_copy = copy.copy(c)
- np.testing.assert_allclose(tuple(c_copy), (0.1, 0.2, 0.3, 0.4), 1e-5)
-
- def test_str_repr(self):
- c = Color(1, 0, 0)
- self.assertEqual(str(c), "Color: (1.0, 0.0, 0.0, 1.0) ~ RED")
- self.assertEqual(repr(c), "Color(1.0, 0.0, 0.0, 1.0)")
-
- def test_tuple(self):
- c = Color((0.1,))
- np.testing.assert_allclose(tuple(c), (0.1, 1.0, 1.0, 1.0), 1e-5)
- c = Color((0.1, 0.2))
- np.testing.assert_allclose(tuple(c), (0.1, 0.2, 1.0, 1.0), 1e-5)
- c = Color((0.1, 0.2, 0.3))
- np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 1.0), 1e-5)
- c = Color((0.1, 0.2, 0.3, 0.4))
- np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
- c = Color(color_tuple=(0.1, 0.2, 0.3, 0.4))
- np.testing.assert_allclose(tuple(c), (0.1, 0.2, 0.3, 0.4), 1e-5)
-
-
-class TestCompound(unittest.TestCase):
- def test_make_text(self):
- arc = Edge.make_three_point_arc((-50, 0, 0), (0, 20, 0), (50, 0, 0))
- text = Compound.make_text("test", 10, text_path=arc)
- self.assertEqual(len(text.faces()), 4)
- text = Compound.make_text(
- "test", 10, align=(Align.MAX, Align.MAX), text_path=arc
- )
- self.assertEqual(len(text.faces()), 4)
-
- def test_fuse(self):
- box1 = Solid.make_box(1, 1, 1)
- box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
- combined = Compound([box1]).fuse(box2, glue=True)
- self.assertTrue(combined.is_valid())
- self.assertAlmostEqual(combined.volume, 2, 5)
- fuzzy = Compound([box1]).fuse(box2, tol=1e-6)
- self.assertTrue(fuzzy.is_valid())
- self.assertAlmostEqual(fuzzy.volume, 2, 5)
-
- def test_remove(self):
- box1 = Solid.make_box(1, 1, 1)
- box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
- combined = Compound([box1, box2])
- self.assertTrue(len(combined._remove(box2).solids()), 1)
-
- def test_repr(self):
- simple = Compound([Solid.make_box(1, 1, 1)])
- simple_str = repr(simple).split("0x")[0] + repr(simple).split(", ")[1]
- self.assertEqual(simple_str, "Compound at label()")
-
- assembly = Compound([Solid.make_box(1, 1, 1)])
- assembly.children = [Solid.make_box(1, 1, 1)]
- assembly.label = "test"
- assembly_str = repr(assembly).split("0x")[0] + repr(assembly).split(", l")[1]
- self.assertEqual(assembly_str, "Compound at abel(test), #children(1)")
-
- def test_center(self):
- test_compound = Compound(
- [
- Solid.make_box(2, 2, 2).locate(Location((-1, -1, -1))),
- Solid.make_box(1, 1, 1).locate(Location((8.5, -0.5, -0.5))),
- ]
- )
- self.assertAlmostEqual(test_compound.center(CenterOf.MASS), (1, 0, 0), 5)
- self.assertAlmostEqual(
- test_compound.center(CenterOf.BOUNDING_BOX), (4.25, 0, 0), 5
- )
- with self.assertRaises(ValueError):
- test_compound.center(CenterOf.GEOMETRY)
-
- def test_triad(self):
- triad = Compound.make_triad(10)
- bbox = triad.bounding_box()
- self.assertGreater(bbox.min.X, -10 / 8)
- self.assertLess(bbox.min.X, 0)
- self.assertGreater(bbox.min.Y, -10 / 8)
- self.assertLess(bbox.min.Y, 0)
- self.assertGreater(bbox.min.Y, -10 / 8)
- self.assertAlmostEqual(bbox.min.Z, 0, 4)
- self.assertLess(bbox.size.Z, 12.5)
- self.assertEqual(triad.volume, 0)
-
- def test_volume(self):
- e = Edge.make_line((0, 0), (1, 1))
- self.assertAlmostEqual(e.volume, 0, 5)
-
- f = Face.make_rect(1, 1)
- self.assertAlmostEqual(f.volume, 0, 5)
-
- b = Solid.make_box(1, 1, 1)
- self.assertAlmostEqual(b.volume, 1, 5)
-
- bb = Box(1, 1, 1)
- self.assertAlmostEqual(bb.volume, 1, 5)
-
- c = Compound(children=[e, f, b, bb, b.translate((0, 5, 0))])
- self.assertAlmostEqual(c.volume, 3, 5)
- # N.B. b and bb overlap but still add to Compound volume
-
- def test_constructor(self):
- with self.assertRaises(TypeError):
- Compound(foo="bar")
-
- def test_len(self):
- self.assertEqual(len(Compound()), 0)
- skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
- self.assertEqual(len(skt), 4)
-
- def test_iteration(self):
- skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
- for c1, c2 in itertools.combinations(skt, 2):
- self.assertGreaterEqual((c1.position - c2.position).length, 10)
-
- def test_unwrap(self):
- skt = Sketch() + GridLocations(10, 10, 2, 2) * Circle(1)
- skt2 = Compound(children=[skt])
- self.assertEqual(len(skt2), 1)
- skt3 = skt2.unwrap(fully=False)
- self.assertEqual(len(skt3), 4)
-
- comp1 = Compound().unwrap()
- self.assertEqual(len(comp1), 0)
- comp2 = Compound(children=[Face.make_rect(1, 1)])
- comp3 = Compound(children=[comp2])
- self.assertEqual(len(comp3), 1)
- self.assertTrue(isinstance(next(iter(comp3)), Compound))
- comp4 = comp3.unwrap(fully=True)
- self.assertTrue(isinstance(comp4, Face))
-
- def test_get_top_level_shapes(self):
- base_shapes = Compound(children=PolarLocations(15, 20) * Box(4, 4, 4))
- fls = base_shapes.get_top_level_shapes()
- self.assertTrue(isinstance(fls, ShapeList))
- self.assertEqual(len(fls), 20)
- self.assertTrue(all(isinstance(s, Solid) for s in fls))
-
- b1 = Box(1, 1, 1).solid()
- self.assertEqual(b1.get_top_level_shapes()[0], b1)
-
-
-class TestEdge(unittest.TestCase):
- def test_close(self):
- self.assertAlmostEqual(
- Edge.make_circle(1, end_angle=180).close().length, math.pi + 2, 5
- )
- self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5)
-
- def test_make_half_circle(self):
- half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180)
- self.assertAlmostEqual(half_circle.start_point(), (1, 0, 0), 3)
- self.assertAlmostEqual(half_circle.end_point(), (-1, 0, 0), 3)
-
- def test_make_half_circle2(self):
- half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90)
- self.assertAlmostEqual(half_circle.start_point(), (0, -1, 0), 3)
- self.assertAlmostEqual(half_circle.end_point(), (0, 1, 0), 3)
-
- def test_make_clockwise_half_circle(self):
- half_circle = Edge.make_circle(
- radius=1,
- start_angle=180,
- end_angle=0,
- angular_direction=AngularDirection.CLOCKWISE,
- )
- self.assertAlmostEqual(half_circle.end_point(), (1, 0, 0), 3)
- self.assertAlmostEqual(half_circle.start_point(), (-1, 0, 0), 3)
-
- def test_make_clockwise_half_circle2(self):
- half_circle = Edge.make_circle(
- radius=1,
- start_angle=90,
- end_angle=-90,
- angular_direction=AngularDirection.CLOCKWISE,
- )
- self.assertAlmostEqual(half_circle.start_point(), (0, 1, 0), 3)
- self.assertAlmostEqual(half_circle.end_point(), (0, -1, 0), 3)
-
- def test_arc_center(self):
- self.assertAlmostEqual(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5)
- with self.assertRaises(ValueError):
- Edge.make_line((0, 0, 0), (0, 0, 1)).arc_center
-
- def test_spline_with_parameters(self):
- spline = Edge.make_spline(
- points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 0.4, 1.0]
- )
- self.assertAlmostEqual(spline.end_point(), (2, 0, 0), 5)
- with self.assertRaises(ValueError):
- Edge.make_spline(
- points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], parameters=[0.0, 1.0]
- )
- with self.assertRaises(ValueError):
- Edge.make_spline(
- points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)], tangents=[(1, 1, 0)]
- )
-
- def test_spline_approx(self):
- spline = Edge.make_spline_approx([(0, 0), (1, 1), (2, 1), (3, 0)])
- self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
- spline = Edge.make_spline_approx(
- [(0, 0), (1, 1), (2, 1), (3, 0)], smoothing=(1.0, 5.0, 10.0)
- )
- self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
-
- def test_distribute_locations(self):
- line = Edge.make_line((0, 0, 0), (10, 0, 0))
- locs = line.distribute_locations(3)
- for i, x in enumerate([0, 5, 10]):
- self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5)
- self.assertAlmostEqual(locs[0].orientation, (0, 90, 180), 5)
-
- locs = line.distribute_locations(3, positions_only=True)
- for i, x in enumerate([0, 5, 10]):
- self.assertAlmostEqual(locs[i].position, (x, 0, 0), 5)
- self.assertAlmostEqual(locs[0].orientation, (0, 0, 0), 5)
-
- def test_to_wire(self):
- edge = Edge.make_line((0, 0, 0), (1, 1, 1))
- for end in [0, 1]:
- self.assertAlmostEqual(
- edge.position_at(end),
- edge.to_wire().position_at(end),
- 5,
- )
-
- def test_arc_center2(self):
- edges = [
- Edge.make_circle(1, plane=Plane((1, 2, 3)), end_angle=30),
- Edge.make_ellipse(1, 0.5, plane=Plane((1, 2, 3)), end_angle=30),
- ]
- for edge in edges:
- self.assertAlmostEqual(edge.arc_center, (1, 2, 3), 5)
- with self.assertRaises(ValueError):
- Edge.make_line((0, 0), (1, 1)).arc_center
-
- def test_find_intersection_points(self):
- circle = Edge.make_circle(1)
- line = Edge.make_line((0, -2), (0, 2))
- crosses = circle.find_intersection_points(line)
- for target, actual in zip([(0, 1, 0), (0, -1, 0)], crosses):
- self.assertAlmostEqual(actual, target, 5)
-
- with self.assertRaises(ValueError):
- circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
- with self.assertRaises(ValueError):
- circle.find_intersection_points(Edge.make_line((0, 0, -1), (0, 0, 1)))
-
- self_intersect = Edge.make_spline([(-3, 2), (3, -2), (4, 0), (3, 2), (-3, -2)])
- self.assertAlmostEqual(
- self_intersect.find_intersection_points()[0],
- (-2.6861636507066047, 0, 0),
- 5,
- )
- line = Edge.make_line((1, -2), (1, 2))
- crosses = line.find_intersection_points(Axis.X)
- self.assertAlmostEqual(crosses[0], (1, 0, 0), 5)
-
- with self.assertRaises(ValueError):
- line.find_intersection_points(Plane.YZ)
-
- # def test_intersections_tolerance(self):
-
- # Multiple operands not currently supported
-
- # r1 = ShapeList() + (PolarLocations(1, 4) * Edge.make_line((0, -1), (0, 1)))
- # l1 = Edge.make_line((1, 0), (2, 0))
- # i1 = l1.intersect(*r1)
-
- # r2 = Rectangle(2, 2).edges()
- # l2 = Pos(1) * Edge.make_line((0, 0), (1, 0))
- # i2 = l2.intersect(*r2)
-
- # self.assertEqual(len(i1.vertices()), len(i2.vertices()))
-
- def test_trim(self):
- line = Edge.make_line((-2, 0), (2, 0))
- self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(0), (-1, 0, 0), 5)
- self.assertAlmostEqual(line.trim(0.25, 0.75).position_at(1), (1, 0, 0), 5)
- with self.assertRaises(ValueError):
- line.trim(0.75, 0.25)
-
- def test_trim_to_length(self):
-
- e1 = Edge.make_line((0, 0), (10, 10))
- e1_trim = e1.trim_to_length(0.0, 10)
- self.assertAlmostEqual(e1_trim.length, 10, 5)
-
- e2 = Edge.make_circle(10, start_angle=0, end_angle=90)
- e2_trim = e2.trim_to_length(0.5, 1)
- self.assertAlmostEqual(e2_trim.length, 1, 5)
- self.assertAlmostEqual(
- e2_trim.position_at(0), Vector(10, 0, 0).rotate(Axis.Z, 45), 5
- )
-
- e3 = Edge.make_spline(
- [(0, 10, 0), (-4, 5, 2), (0, 0, 0)], tangents=[(-1, 0), (1, 0)]
- )
- e3_trim = e3.trim_to_length(0, 7)
- self.assertAlmostEqual(e3_trim.length, 7, 5)
-
- a4 = Axis((0, 0, 0), (1, 1, 1))
- e4_trim = Edge(a4).trim_to_length(0.5, 2)
- self.assertAlmostEqual(e4_trim.length, 2, 5)
-
- def test_bezier(self):
- with self.assertRaises(ValueError):
- Edge.make_bezier((1, 1))
- cntl_pnts = [(1, 2, 3)] * 30
- with self.assertRaises(ValueError):
- Edge.make_bezier(*cntl_pnts)
- with self.assertRaises(ValueError):
- Edge.make_bezier((0, 0, 0), (1, 1, 1), weights=[1.0])
-
- bezier = Edge.make_bezier((0, 0), (0, 1), (1, 1), (1, 0))
- bbox = bezier.bounding_box()
- self.assertAlmostEqual(bbox.min, (0, 0, 0), 5)
- self.assertAlmostEqual(bbox.max, (1, 0.75, 0), 5)
-
- def test_mid_way(self):
- mid = Edge.make_mid_way(
- Edge.make_line((0, 0), (0, 1)), Edge.make_line((1, 0), (1, 1)), 0.25
- )
- self.assertAlmostEqual(mid.position_at(0), (0.25, 0, 0), 5)
- self.assertAlmostEqual(mid.position_at(1), (0.25, 1, 0), 5)
-
- def test_distribute_locations2(self):
- with self.assertRaises(ValueError):
- Edge.make_circle(1).distribute_locations(1)
-
- locs = Edge.make_circle(1).distribute_locations(5, positions_only=True)
- for i, loc in enumerate(locs):
- self.assertAlmostEqual(
- loc.position,
- Vector(1, 0, 0).rotate(Axis.Z, i * 90).to_tuple(),
- 5,
- )
- self.assertAlmostEqual(loc.orientation, (0, 0, 0), 5)
-
- def test_find_tangent(self):
- circle = Edge.make_circle(1)
- parm = circle.find_tangent(135)[0]
- self.assertAlmostEqual(
- circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
- )
- line = Edge.make_line((0, 0), (1, 1))
- parm = line.find_tangent(45)[0]
- self.assertAlmostEqual(parm, 0, 5)
- parm = line.find_tangent(0)
- self.assertEqual(len(parm), 0)
-
- def test_param_at_point(self):
- u = Edge.make_circle(1).param_at_point((0, 1))
- self.assertAlmostEqual(u, 0.25, 5)
-
- u = 0.3
- edge = Edge.make_line((0, 0), (34, 56))
- pnt = edge.position_at(u)
- self.assertAlmostEqual(edge.param_at_point(pnt), u, 5)
-
- ca = CenterArc((0, 0), 1, -200, 220).edge()
- for u in [0.3, 1.0]:
- pnt = ca.position_at(u)
- self.assertAlmostEqual(ca.param_at_point(pnt), u, 5)
-
- ea = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270).edge()
- for u in [0.3, 0.9]:
- pnt = ea.position_at(u)
- self.assertAlmostEqual(ea.param_at_point(pnt), u, 5)
-
- with self.assertRaises(ValueError):
- edge.param_at_point((-1, 1))
-
- def test_conical_helix(self):
- helix = Edge.make_helix(1, 4, 1, normal=(-1, 0, 0), angle=10, lefthand=True)
- self.assertAlmostEqual(helix.bounding_box().min.X, -4, 5)
-
- def test_reverse(self):
- e1 = Edge.make_line((0, 0), (1, 1))
- self.assertAlmostEqual(e1 @ 0.1, (0.1, 0.1, 0), 5)
- self.assertAlmostEqual(e1.reversed() @ 0.1, (0.9, 0.9, 0), 5)
-
- e2 = Edge.make_circle(1, start_angle=0, end_angle=180)
- e2r = e2.reversed()
- self.assertAlmostEqual((e2 @ 0.1).X, -(e2r @ 0.1).X, 5)
-
- def test_init(self):
- with self.assertRaises(TypeError):
- Edge(direction=(1, 0, 0))
-
-
-class TestFace(unittest.TestCase):
- def test_make_surface_from_curves(self):
- bottom_edge = Edge.make_circle(radius=1, end_angle=90)
- top_edge = Edge.make_circle(radius=1, plane=Plane((0, 0, 1)), end_angle=90)
- curved = Face.make_surface_from_curves(bottom_edge, top_edge)
- self.assertTrue(curved.is_valid())
- self.assertAlmostEqual(curved.area, math.pi / 2, 5)
- self.assertAlmostEqual(
- curved.normal_at(), (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5
- )
-
- bottom_wire = Wire.make_circle(1)
- top_wire = Wire.make_circle(1, Plane((0, 0, 1)))
- curved = Face.make_surface_from_curves(bottom_wire, top_wire)
- self.assertTrue(curved.is_valid())
- self.assertAlmostEqual(curved.area, 2 * math.pi, 5)
-
- def test_center(self):
- test_face = Face(Wire.make_polygon([(0, 0), (1, 0), (1, 1), (0, 0)]))
- self.assertAlmostEqual(test_face.center(CenterOf.MASS), (2 / 3, 1 / 3, 0), 1)
- self.assertAlmostEqual(
- test_face.center(CenterOf.BOUNDING_BOX),
- (0.5, 0.5, 0),
- 5,
- )
-
- def test_face_volume(self):
- rect = Face.make_rect(1, 1)
- self.assertAlmostEqual(rect.volume, 0, 5)
-
- def test_chamfer_2d(self):
- test_face = Face.make_rect(10, 10)
- test_face = test_face.chamfer_2d(
- distance=1, distance2=2, vertices=test_face.vertices()
- )
- self.assertAlmostEqual(test_face.area, 100 - 4 * 0.5 * 1 * 2)
-
- def test_chamfer_2d_reference(self):
- test_face = Face.make_rect(10, 10)
- edge = test_face.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- test_face = test_face.chamfer_2d(
- distance=1, distance2=2, vertices=[vertex], edge=edge
- )
- self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 9)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 8)
-
- def test_chamfer_2d_reference_inverted(self):
- test_face = Face.make_rect(10, 10)
- edge = test_face.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- test_face = test_face.chamfer_2d(
- distance=2, distance2=1, vertices=[vertex], edge=edge
- )
- self.assertAlmostEqual(test_face.area, 100 - 0.5 * 1 * 2)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.Y)[0].length, 8)
- self.assertAlmostEqual(test_face.edges().sort_by(Axis.X)[0].length, 9)
-
- def test_chamfer_2d_error_checking(self):
- with self.assertRaises(ValueError):
- test_face = Face.make_rect(10, 10)
- edge = test_face.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- other_edge = test_face.edges().sort_by(Axis.Y)[-1]
- test_face = test_face.chamfer_2d(
- distance=1, distance2=2, vertices=[vertex], edge=other_edge
- )
-
- def test_make_rect(self):
- test_face = Face.make_plane()
- self.assertAlmostEqual(test_face.normal_at(), (0, 0, 1), 5)
-
- def test_length_width(self):
- test_face = Face.make_rect(8, 10, Plane.XZ)
- self.assertAlmostEqual(test_face.length, 8, 5)
- self.assertAlmostEqual(test_face.width, 10, 5)
-
- def test_geometry(self):
- box = Solid.make_box(1, 1, 2)
- self.assertEqual(box.faces().sort_by(Axis.Z).last.geometry, "SQUARE")
- self.assertEqual(box.faces().sort_by(Axis.Y).last.geometry, "RECTANGLE")
- with BuildPart() as test:
- with BuildSketch():
- RegularPolygon(1, 3)
- extrude(amount=1)
- self.assertEqual(test.faces().sort_by(Axis.Z).last.geometry, "POLYGON")
-
- def test_is_planar(self):
- self.assertTrue(Face.make_rect(1, 1).is_planar)
- self.assertFalse(
- Solid.make_cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0].is_planar
- )
- # Some of these faces have geom_type BSPLINE but are planar
- mount = Solid.make_loft(
- [
- Rectangle((1 + 16 + 4), 20, align=(Align.MIN, Align.CENTER)).wire(),
- Pos(1, 0, 4)
- * Rectangle(16, 20, align=(Align.MIN, Align.CENTER)).wire(),
- ],
- )
- self.assertTrue(all(f.is_planar for f in mount.faces()))
-
- def test_negate(self):
- square = Face.make_rect(1, 1)
- self.assertAlmostEqual(square.normal_at(), (0, 0, 1), 5)
- flipped_square = -square
- self.assertAlmostEqual(flipped_square.normal_at(), (0, 0, -1), 5)
-
- def test_offset(self):
- bbox = Face.make_rect(2, 2, Plane.XY).offset(5).bounding_box()
- self.assertAlmostEqual(bbox.min, (-1, -1, 5), 5)
- self.assertAlmostEqual(bbox.max, (1, 1, 5), 5)
-
- def test_make_from_wires(self):
- outer = Wire.make_circle(10)
- inners = [
- Wire.make_circle(1).locate(Location((-2, 2, 0))),
- Wire.make_circle(1).locate(Location((2, 2, 0))),
- ]
- happy = Face(outer, inners)
- self.assertAlmostEqual(happy.area, math.pi * (10**2 - 2), 5)
-
- outer = Edge.make_circle(10, end_angle=180).to_wire()
- with self.assertRaises(ValueError):
- Face(outer, inners)
- with self.assertRaises(ValueError):
- Face(Wire.make_circle(10, Plane.XZ), inners)
-
- outer = Wire.make_circle(10)
- inners = [
- Wire.make_circle(1).locate(Location((-2, 2, 0))),
- Edge.make_circle(1, end_angle=180).to_wire().locate(Location((2, 2, 0))),
- ]
- with self.assertRaises(ValueError):
- Face(outer, inners)
-
- def test_sew_faces(self):
- patches = [
- Face.make_rect(1, 1, Plane((x, y, z)))
- for x in range(2)
- for y in range(2)
- for z in range(3)
- ]
- random.shuffle(patches)
- sheets = Face.sew_faces(patches)
- self.assertEqual(len(sheets), 3)
- self.assertEqual(len(sheets[0]), 4)
- self.assertTrue(isinstance(sheets[0][0], Face))
-
- def test_surface_from_array_of_points(self):
- pnts = [
- [
- Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
- for x in range(11)
- ]
- for y in range(11)
- ]
- surface = Face.make_surface_from_array_of_points(pnts)
- bbox = surface.bounding_box()
- self.assertAlmostEqual(bbox.min, (0, 0, -1), 3)
- self.assertAlmostEqual(bbox.max, (10, 10, 2), 2)
-
- def test_bezier_surface(self):
- points = [
- [
- (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
- for x in range(-1, 2)
- ]
- for y in range(-1, 2)
- ]
- surface = Face.make_bezier_surface(points)
- bbox = surface.bounding_box()
- self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3)
- self.assertAlmostEqual(bbox.max, (+1, +1, +1), 1)
- self.assertLess(bbox.max.Z, 1.0)
-
- weights = [
- [2 if x == 0 or y == 0 else 1 for x in range(-1, 2)] for y in range(-1, 2)
- ]
- surface = Face.make_bezier_surface(points, weights)
- bbox = surface.bounding_box()
- self.assertAlmostEqual(bbox.min, (-1, -1, 0), 3)
- self.assertGreater(bbox.max.Z, 1.0)
-
- too_many_points = [
- [
- (x, y, 2 if x == 0 and y == 0 else 1 if x == 0 or y == 0 else 0)
- for x in range(-1, 27)
- ]
- for y in range(-1, 27)
- ]
-
- with self.assertRaises(ValueError):
- Face.make_bezier_surface([[(0, 0)]])
- with self.assertRaises(ValueError):
- Face.make_bezier_surface(points, [[1, 1], [1, 1]])
- with self.assertRaises(ValueError):
- Face.make_bezier_surface(too_many_points)
-
- def test_thicken(self):
- pnts = [
- [
- Vector(x, y, math.cos(math.pi * x / 10) + math.sin(math.pi * y / 10))
- for x in range(11)
- ]
- for y in range(11)
- ]
- surface = Face.make_surface_from_array_of_points(pnts)
- solid = Solid.thicken(surface, 1)
- self.assertAlmostEqual(solid.volume, 101.59, 2)
-
- square = Face.make_rect(10, 10)
- bbox = Solid.thicken(square, 1, normal_override=(0, 0, -1)).bounding_box()
- self.assertAlmostEqual(bbox.min, (-5, -5, -1), 5)
- self.assertAlmostEqual(bbox.max, (5, 5, 0), 5)
-
- def test_make_holes(self):
- radius = 10
- circumference = 2 * math.pi * radius
- hex_diagonal = 4 * (circumference / 10) / 3
- cylinder = Solid.make_cylinder(radius, hex_diagonal * 5)
- cylinder_wall: Face = cylinder.faces().filter_by(GeomType.PLANE, reverse=True)[
- 0
- ]
- with BuildSketch(Plane.XZ.offset(radius)) as hex:
- with Locations((0, hex_diagonal)):
- RegularPolygon(
- hex_diagonal * 0.4, 6, align=(Align.CENTER, Align.CENTER)
- )
- hex_wire_vertical: Wire = hex.sketch.faces()[0].outer_wire()
-
- projected_wire: Wire = hex_wire_vertical.project_to_shape(
- target_object=cylinder, center=(0, 0, hex_wire_vertical.center().Z)
- )[0]
- projected_wires = [
- projected_wire.rotate(Axis.Z, -90 + i * 360 / 10).translate(
- (0, 0, (j + (i % 2) / 2) * hex_diagonal)
- )
- for i in range(5)
- for j in range(4 - i % 2)
- ]
- cylinder_walls_with_holes = cylinder_wall.make_holes(projected_wires)
- self.assertTrue(cylinder_walls_with_holes.is_valid())
- self.assertLess(cylinder_walls_with_holes.area, cylinder_wall.area)
-
- def test_is_inside(self):
- square = Face.make_rect(10, 10)
- self.assertTrue(square.is_inside((1, 1)))
- self.assertFalse(square.is_inside((20, 1)))
-
- def test_import_stl(self):
- torus = Solid.make_torus(10, 1)
- # exporter = Mesher()
- # exporter.add_shape(torus)
- # exporter.write("test_torus.stl")
- export_stl(torus, "test_torus.stl")
- imported_torus = import_stl("test_torus.stl")
- # The torus from stl is tessellated therefore the areas will only be close
- self.assertAlmostEqual(imported_torus.area, torus.area, 0)
- os.remove("test_torus.stl")
-
- def test_is_coplanar(self):
- square = Face.make_rect(1, 1, plane=Plane.XZ)
- self.assertTrue(square.is_coplanar(Plane.XZ))
- self.assertTrue((-square).is_coplanar(Plane.XZ))
- self.assertFalse(square.is_coplanar(Plane.XY))
- surface: Face = Solid.make_sphere(1).faces()[0]
- self.assertFalse(surface.is_coplanar(Plane.XY))
-
- def test_center_location(self):
- square = Face.make_rect(1, 1, plane=Plane.XZ)
- cl = square.center_location
- self.assertAlmostEqual(cl.position, (0, 0, 0), 5)
- self.assertAlmostEqual(Plane(cl).z_dir, Plane.XZ.z_dir, 5)
-
- def test_position_at(self):
- square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1))
- p = square.position_at(0.25, 0.75)
- self.assertAlmostEqual(p, (-0.5, -1.0, 0.5), 5)
-
- def test_location_at(self):
- bottom = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.Z)[0]
- loc = bottom.location_at(0.5, 0.5)
- self.assertAlmostEqual(loc.position, (0.5, 1, 0), 5)
- self.assertAlmostEqual(loc.orientation, (-180, 0, -180), 5)
-
- front = Box(1, 2, 3, align=Align.MIN).faces().filter_by(Axis.X)[0]
- loc = front.location_at(0.5, 0.5, x_dir=(0, 0, 1))
- self.assertAlmostEqual(loc.position, (0.0, 1.0, 1.5), 5)
- self.assertAlmostEqual(loc.orientation, (0, -90, 0), 5)
-
- def test_make_surface(self):
- corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]]
- net_exterior = Wire(
- [
- Edge.make_line(corners[3], corners[1]),
- Edge.make_line(corners[1], corners[0]),
- Edge.make_line(corners[0], corners[2]),
- Edge.make_three_point_arc(
- corners[2],
- (corners[2] + corners[3]) / 2 - Vector(0, 0, 3),
- corners[3],
- ),
- ]
- )
- surface = Face.make_surface(
- net_exterior,
- surface_points=[Vector(0, 0, -5)],
- )
- hole_flat = Wire.make_circle(10)
- hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0]
- surface = Face.make_surface(
- exterior=net_exterior,
- surface_points=[Vector(0, 0, -5)],
- interior_wires=[hole],
- )
- self.assertTrue(surface.is_valid())
- self.assertEqual(surface.geom_type, GeomType.BSPLINE)
- bbox = surface.bounding_box()
- self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -5.113393280136395), 5)
- self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5)
-
- # With no surface point
- surface = Face.make_surface(net_exterior)
- bbox = surface.bounding_box()
- self.assertAlmostEqual(bbox.min, (-50.5, -24.5, -3), 5)
- self.assertAlmostEqual(bbox.max, (50.5, 24.5, 0), 5)
-
- # Exterior Edge
- surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)])
- bbox = surface.bounding_box()
- self.assertAlmostEqual(bbox.min, (-50, -50, -5), 5)
- self.assertAlmostEqual(bbox.max, (50, 50, 0), 5)
-
- def test_make_surface_error_checking(self):
- with self.assertRaises(ValueError):
- Face.make_surface(Edge.make_line((0, 0), (1, 0)))
-
- with self.assertRaises(RuntimeError):
- Face.make_surface([Edge.make_line((0, 0), (1, 0))])
-
- if platform.system() != "Darwin":
- with self.assertRaises(RuntimeError):
- Face.make_surface(
- [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)]
- )
-
- with self.assertRaises(RuntimeError):
- Face.make_surface(
- [Edge.make_circle(50)],
- interior_wires=[Wire.make_circle(5, Plane.XZ)],
- )
-
- def test_sweep(self):
- edge = Edge.make_line((1, 0), (2, 0))
- path = Wire.make_circle(1)
- circle_with_hole = Face.sweep(edge, path)
- self.assertTrue(isinstance(circle_with_hole, Face))
- self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5)
- with self.assertRaises(ValueError):
- Face.sweep(edge, Polyline((0, 0), (0.1, 0), (0.2, 0.1)))
-
- def test_to_arcs(self):
- with BuildSketch() as bs:
- with BuildLine() as bl:
- Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0))
- fillet(bl.vertices(), radius=0.1)
- make_face()
- smooth = bs.faces()[0]
- fragmented = smooth.to_arcs()
- self.assertLess(len(smooth.edges()), len(fragmented.edges()))
-
- def test_outer_wire(self):
- face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
- self.assertAlmostEqual(face.outer_wire().length, 4, 5)
-
- def test_wire(self):
- face = (Face.make_rect(1, 1) - Face.make_rect(0.5, 0.5)).face()
- with self.assertWarns(UserWarning):
- outer = face.wire()
- self.assertAlmostEqual(outer.length, 4, 5)
-
- def test_constructor(self):
- with self.assertRaises(ValueError):
- Face(bob="fred")
-
- def test_normal_at(self):
- face = Face.make_rect(1, 1)
- self.assertAlmostEqual(face.normal_at(0, 0), (0, 0, 1), 5)
- self.assertAlmostEqual(face.normal_at(face.position_at(0, 0)), (0, 0, 1), 5)
- with self.assertRaises(ValueError):
- face.normal_at(0)
- with self.assertRaises(ValueError):
- face.normal_at(center=(0, 0))
- face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
- self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5)
-
-
-class TestFunctions(unittest.TestCase):
- def test_edges_to_wires(self):
- square_edges = Face.make_rect(1, 1).edges()
- rectangle_edges = Face.make_rect(2, 1, Plane((5, 0))).edges()
- wires = edges_to_wires(square_edges + rectangle_edges)
- self.assertEqual(len(wires), 2)
- self.assertAlmostEqual(wires[0].length, 4, 5)
- self.assertAlmostEqual(wires[1].length, 6, 5)
-
- def test_polar(self):
- pnt = polar(1, 30)
- self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5)
- self.assertAlmostEqual(pnt[1], 0.5, 5)
-
- def test_new_edges(self):
- c = Solid.make_cylinder(1, 5)
- s = Solid.make_sphere(2)
- s_minus_c = s - c
- seams = new_edges(c, s, combined=s_minus_c)
- self.assertEqual(len(seams), 1)
- self.assertAlmostEqual(seams[0].radius, 1, 5)
-
- def test_delta(self):
- cyl = Solid.make_cylinder(1, 5)
- sph = Solid.make_sphere(2)
- con = Solid.make_cone(2, 1, 2)
- plug = delta([cyl, sph, con], [sph, con])
- self.assertEqual(len(plug), 1)
- self.assertEqual(plug[0], cyl)
-
- def test_parse_intersect_args(self):
-
- with self.assertRaises(TypeError):
- Vector(1, 1, 1) & ("x", "y", "z")
-
- def test_unwrap_topods_compound(self):
- # Complex Compound
- b1 = Box(1, 1, 1).solid()
- b2 = Box(2, 2, 2).solid()
- c1 = Compound([b1, b2])
- c2 = Compound([b1, c1])
- c3 = Compound([c2])
- c4 = Compound([c3])
- self.assertEqual(c4.wrapped.NbChildren(), 1)
- c5 = Compound(unwrap_topods_compound(c4.wrapped, False))
- self.assertEqual(c5.wrapped.NbChildren(), 2)
-
- # unwrap fully
- c0 = Compound([b1])
- c1 = Compound([c0])
- result = Compound.cast(unwrap_topods_compound(c1.wrapped, True))
- self.assertTrue(isinstance(result, Solid))
-
- # unwrap not fully
- result = Compound.cast(unwrap_topods_compound(c1.wrapped, False))
- self.assertTrue(isinstance(result, Compound))
-
-
-class TestGroupBy(unittest.TestCase):
-
- def setUp(self):
- # Ensure the class variable is in its default state before each test
- self.v = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
-
- def test_str(self):
- self.assertEqual(
- str(self.v),
- f"""[[Vertex(0.0, 0.0, 0.0),
- Vertex(0.0, 1.0, 0.0),
- Vertex(1.0, 0.0, 0.0),
- Vertex(1.0, 1.0, 0.0)],
- [Vertex(0.0, 0.0, 1.0),
- Vertex(0.0, 1.0, 1.0),
- Vertex(1.0, 0.0, 1.0),
- Vertex(1.0, 1.0, 1.0)]]""",
- )
-
- def test_repr(self):
- self.assertEqual(
- repr(self.v),
- "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
- )
-
- def test_pp(self):
- self.assertEqual(
- pprint.pformat(self.v),
- "[[Vertex(0.0, 0.0, 0.0), Vertex(0.0, 1.0, 0.0), Vertex(1.0, 0.0, 0.0), Vertex(1.0, 1.0, 0.0)], [Vertex(0.0, 0.0, 1.0), Vertex(0.0, 1.0, 1.0), Vertex(1.0, 0.0, 1.0), Vertex(1.0, 1.0, 1.0)]]",
- )
-
-
-class TestImportExport(unittest.TestCase):
- def test_import_export(self):
- original_box = Solid.make_box(1, 1, 1)
- export_step(original_box, "test_box.step")
- step_box = import_step("test_box.step")
- self.assertTrue(step_box.is_valid())
- self.assertAlmostEqual(step_box.volume, 1, 5)
- export_brep(step_box, "test_box.brep")
- brep_box = import_brep("test_box.brep")
- self.assertTrue(brep_box.is_valid())
- self.assertAlmostEqual(brep_box.volume, 1, 5)
- os.remove("test_box.step")
- os.remove("test_box.brep")
- with self.assertRaises(FileNotFoundError):
- step_box = import_step("test_box.step")
-
- def test_import_stl(self):
- # export solid
- original_box = Solid.make_box(1, 2, 3)
- exporter = Mesher()
- exporter.add_shape(original_box)
- exporter.write("test.stl")
-
- # import as face
- stl_box = import_stl("test.stl")
- self.assertAlmostEqual(stl_box.position, (0, 0, 0), 5)
-
-
-class TestJupyter(unittest.TestCase):
- def test_repr_javascript(self):
- shape = Solid.make_box(1, 1, 1)
-
- # Test no exception on rendering to js
- js1 = shape._repr_javascript_()
-
- assert "function render" in js1
-
- def test_display_error(self):
- with self.assertRaises(AttributeError):
- display(Vector())
-
- with self.assertRaises(ValueError):
- to_vtkpoly_string("invalid")
-
- with self.assertRaises(ValueError):
- display("invalid")
-
-
-class TestLocation(unittest.TestCase):
- def test_location(self):
- loc0 = Location()
- T = loc0.wrapped.Transformation().TranslationPart()
- np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 0), 1e-6)
- angle = math.degrees(
- loc0.wrapped.Transformation().GetRotation().GetRotationAngle()
- )
- self.assertAlmostEqual(0, angle)
-
- # Tuple
- loc0 = Location((0, 0, 1))
-
- T = loc0.wrapped.Transformation().TranslationPart()
- np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
-
- # List
- loc0 = Location([0, 0, 1])
-
- T = loc0.wrapped.Transformation().TranslationPart()
- np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
-
- # Vector
- loc1 = Location(Vector(0, 0, 1))
-
- T = loc1.wrapped.Transformation().TranslationPart()
- np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
-
- # rotation + translation
- loc2 = Location(Vector(0, 0, 1), Vector(0, 0, 1), 45)
-
- angle = math.degrees(
- loc2.wrapped.Transformation().GetRotation().GetRotationAngle()
- )
- self.assertAlmostEqual(45, angle)
-
- # gp_Trsf
- T = gp_Trsf()
- T.SetTranslation(gp_Vec(0, 0, 1))
- loc3 = Location(T)
-
- self.assertEqual(
- loc1.wrapped.Transformation().TranslationPart().Z(),
- loc3.wrapped.Transformation().TranslationPart().Z(),
- )
-
- # Test creation from the OCP.gp.gp_Trsf object
- loc4 = Location(gp_Trsf())
- np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 0), 1e-7)
- np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7)
-
- # Test creation from Plane and Vector
- loc4 = Location(Plane.XY, (0, 0, 1))
- np.testing.assert_allclose(loc4.to_tuple()[0], (0, 0, 1), 1e-7)
- np.testing.assert_allclose(loc4.to_tuple()[1], (0, 0, 0), 1e-7)
-
- # Test composition
- loc4 = Location((0, 0, 0), Vector(0, 0, 1), 15)
-
- loc5 = loc1 * loc4
- loc6 = loc4 * loc4
- loc7 = loc4**2
-
- T = loc5.wrapped.Transformation().TranslationPart()
- np.testing.assert_allclose((T.X(), T.Y(), T.Z()), (0, 0, 1), 1e-6)
-
- angle5 = math.degrees(
- loc5.wrapped.Transformation().GetRotation().GetRotationAngle()
- )
- self.assertAlmostEqual(15, angle5)
-
- angle6 = math.degrees(
- loc6.wrapped.Transformation().GetRotation().GetRotationAngle()
- )
- self.assertAlmostEqual(30, angle6)
-
- angle7 = math.degrees(
- loc7.wrapped.Transformation().GetRotation().GetRotationAngle()
- )
- self.assertAlmostEqual(30, angle7)
-
- # Test error handling on creation
- with self.assertRaises(TypeError):
- Location("xy_plane")
-
- # Test that the computed rotation matrix and intrinsic euler angles return the same
-
- about_x = uniform(-2 * math.pi, 2 * math.pi)
- about_y = uniform(-2 * math.pi, 2 * math.pi)
- about_z = uniform(-2 * math.pi, 2 * math.pi)
-
- rot_x = gp_Trsf()
- rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), about_x)
- rot_y = gp_Trsf()
- rot_y.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 1, 0)), about_y)
- rot_z = gp_Trsf()
- rot_z.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), about_z)
- loc1 = Location(rot_x * rot_y * rot_z)
-
- q = gp_Quaternion()
- q.SetEulerAngles(
- gp_EulerSequence.gp_Intrinsic_XYZ,
- about_x,
- about_y,
- about_z,
- )
- t = gp_Trsf()
- t.SetRotationPart(q)
- loc2 = Location(t)
-
- np.testing.assert_allclose(loc1.to_tuple()[0], loc2.to_tuple()[0], 1e-6)
- np.testing.assert_allclose(loc1.to_tuple()[1], loc2.to_tuple()[1], 1e-6)
-
- loc1 = Location((1, 2), 34)
- np.testing.assert_allclose(loc1.to_tuple()[0], (1, 2, 0), 1e-6)
- np.testing.assert_allclose(loc1.to_tuple()[1], (0, 0, 34), 1e-6)
-
- rot_angles = (-115.00, 35.00, -135.00)
- loc2 = Location((1, 2, 3), rot_angles)
- np.testing.assert_allclose(loc2.to_tuple()[0], (1, 2, 3), 1e-6)
- np.testing.assert_allclose(loc2.to_tuple()[1], rot_angles, 1e-6)
-
- loc3 = Location(loc2)
- np.testing.assert_allclose(loc3.to_tuple()[0], (1, 2, 3), 1e-6)
- np.testing.assert_allclose(loc3.to_tuple()[1], rot_angles, 1e-6)
-
- def test_location_parameters(self):
- loc = Location((10, 20, 30))
- self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
-
- loc = Location((10, 20, 30), (10, 20, 30))
- self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
- self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
-
- loc = Location((10, 20, 30), (10, 20, 30), Intrinsic.XYZ)
- self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
- self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
-
- loc = Location((10, 20, 30), (30, 20, 10), Extrinsic.ZYX)
- self.assertAlmostEqual(loc.position, (10, 20, 30), 5)
- self.assertAlmostEqual(loc.orientation, (10, 20, 30), 5)
-
- with self.assertRaises(TypeError):
- Location(x=10)
-
- with self.assertRaises(TypeError):
- Location((10, 20, 30), (30, 20, 10), (10, 20, 30))
-
- with self.assertRaises(TypeError):
- Location(Intrinsic.XYZ)
-
- def test_location_repr_and_str(self):
- self.assertEqual(
- repr(Location()), "(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))"
- )
- self.assertEqual(
- str(Location()),
- "Location: (position=(0.00, 0.00, 0.00), orientation=(-0.00, 0.00, -0.00))",
- )
- loc = Location((1, 2, 3), (33, 45, 67))
- self.assertEqual(
- str(loc),
- "Location: (position=(1.00, 2.00, 3.00), orientation=(33.00, 45.00, 67.00))",
- )
-
- def test_location_inverted(self):
- loc = Location(Plane.XZ)
- self.assertAlmostEqual(loc.inverse().orientation, (-90, 0, 0), 6)
-
- def test_set_position(self):
- loc = Location(Plane.XZ)
- loc.position = (1, 2, 3)
- self.assertAlmostEqual(loc.position, (1, 2, 3), 6)
- self.assertAlmostEqual(loc.orientation, (90, 0, 0), 6)
-
- def test_set_orientation(self):
- loc = Location((1, 2, 3), (90, 0, 0))
- loc.orientation = (-90, 0, 0)
- self.assertAlmostEqual(loc.position, (1, 2, 3), 6)
- self.assertAlmostEqual(loc.orientation, (-90, 0, 0), 6)
-
- def test_copy(self):
- loc1 = Location((1, 2, 3), (90, 45, 22.5))
- loc2 = copy.copy(loc1)
- loc3 = copy.deepcopy(loc1)
- self.assertAlmostEqual(loc1.position, loc2.position.to_tuple(), 6)
- self.assertAlmostEqual(loc1.orientation, loc2.orientation.to_tuple(), 6)
- self.assertAlmostEqual(loc1.position, loc3.position.to_tuple(), 6)
- self.assertAlmostEqual(loc1.orientation, loc3.orientation.to_tuple(), 6)
-
- def test_to_axis(self):
- axis = Location((1, 2, 3), (-90, 0, 0)).to_axis()
- self.assertAlmostEqual(axis.position, (1, 2, 3), 6)
- self.assertAlmostEqual(axis.direction, (0, 1, 0), 6)
-
- def test_equal(self):
- loc = Location((1, 2, 3), (4, 5, 6))
- same = Location((1, 2, 3), (4, 5, 6))
-
- self.assertEqual(loc, same)
- self.assertEqual(loc, AlwaysEqual())
-
- def test_not_equal(self):
- loc = Location((1, 2, 3), (40, 50, 60))
- diff_position = Location((3, 2, 1), (40, 50, 60))
- diff_orientation = Location((1, 2, 3), (60, 50, 40))
-
- self.assertNotEqual(loc, diff_position)
- self.assertNotEqual(loc, diff_orientation)
- self.assertNotEqual(loc, object())
-
- def test_neg(self):
- loc = Location((1, 2, 3), (0, 35, 127))
- n_loc = -loc
- self.assertAlmostEqual(n_loc.position, (1, 2, 3), 5)
- self.assertAlmostEqual(n_loc.orientation, (180, -35, -127), 5)
-
- def test_mult_iterable(self):
- locs = Location((1, 2, 0)) * GridLocations(4, 4, 2, 1)
- self.assertAlmostEqual(locs[0].position, (-1, 2, 0), 5)
- self.assertAlmostEqual(locs[1].position, (3, 2, 0), 5)
-
- def test_as_json(self):
- data_dict = {
- "part1": {
- "joint_one": Location((1, 2, 3), (4, 5, 6)),
- "joint_two": Location((7, 8, 9), (10, 11, 12)),
- },
- "part2": {
- "joint_one": Location((13, 14, 15), (16, 17, 18)),
- "joint_two": Location((19, 20, 21), (22, 23, 24)),
- },
- }
-
- # Serializing json with custom Location encoder
- json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder)
-
- # Writing to sample.json
- with open("sample.json", "w") as outfile:
- outfile.write(json_object)
-
- # Reading from sample.json
- with open("sample.json", "r") as infile:
- read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
-
- # Validate locations
- for key, value in read_json.items():
- for k, v in value.items():
- if key == "part1" and k == "joint_one":
- self.assertAlmostEqual(v.position, (1, 2, 3), 5)
- elif key == "part1" and k == "joint_two":
- self.assertAlmostEqual(v.position, (7, 8, 9), 5)
- elif key == "part2" and k == "joint_one":
- self.assertAlmostEqual(v.position, (13, 14, 15), 5)
- elif key == "part2" and k == "joint_two":
- self.assertAlmostEqual(v.position, (19, 20, 21), 5)
- else:
- self.assertTrue(False)
- os.remove("sample.json")
-
- def test_intersection(self):
- e = Edge.make_line((0, 0, 0), (1, 1, 1))
- l0 = e.location_at(0)
- l1 = e.location_at(1)
- self.assertIsNone(l0 & l1)
- self.assertEqual(l1 & l1, l1)
-
- i = l1 & Vector(1, 1, 1)
- self.assertTrue(isinstance(i, Vector))
- self.assertAlmostEqual(i, (1, 1, 1), 5)
-
- i = l1 & Axis((0.5, 0.5, 0.5), (1, 1, 1))
- self.assertTrue(isinstance(i, Location))
- self.assertEqual(i, l1)
-
- p = Plane.XY.rotated((45, 0, 0)).shift_origin((1, 0, 0))
- l = Location((1, 0, 0), (1, 0, 0), 45)
- i = l & p
- self.assertTrue(isinstance(i, Location))
- self.assertAlmostEqual(i.position, (1, 0, 0), 5)
- self.assertAlmostEqual(i.orientation, l.orientation, 5)
-
- b = Solid.make_box(1, 1, 1)
- l = Location((0.5, 0.5, 0.5), (1, 0, 0), 45)
- i = (l & b).vertex()
- self.assertTrue(isinstance(i, Vertex))
- self.assertAlmostEqual(Vector(i), (0.5, 0.5, 0.5), 5)
-
- e1 = Edge.make_line((0, -1), (2, 1))
- e2 = Edge.make_line((0, 1), (2, -1))
- e3 = Edge.make_line((0, 0), (2, 0))
-
- i = e1.intersect(e2, e3)
- self.assertTrue(isinstance(i, Vertex))
- self.assertAlmostEqual(Vector(i), (1, 0, 0), 5)
-
- e4 = Edge.make_line((1, -1), (1, 1))
- e5 = Edge.make_line((2, -1), (2, 1))
- i = e3.intersect(e4, e5)
- self.assertIsNone(i)
-
- self.assertIsNone(b.intersect(b.moved(Pos(X=10))))
-
- # Look for common vertices
- e1 = Edge.make_line((0, 0), (1, 0))
- e2 = Edge.make_line((1, 0), (1, 1))
- e3 = Edge.make_line((1, 0), (2, 0))
- i = e1.intersect(e2)
- self.assertEqual(len(i.vertices()), 1)
- self.assertEqual(tuple(i.vertex()), (1, 0, 0))
- i = e1.intersect(e3)
- self.assertEqual(len(i.vertices()), 1)
- self.assertEqual(tuple(i.vertex()), (1, 0, 0))
-
- # Intersect with plane
- e1 = Edge.make_line((0, 0), (2, 0))
- p1 = Plane.YZ.offset(1)
- i = e1.intersect(p1)
- self.assertEqual(len(i.vertices()), 1)
- self.assertEqual(tuple(i.vertex()), (1, 0, 0))
-
- e2 = Edge.make_line(p1.origin, p1.origin + 2 * p1.x_dir)
- i = e2.intersect(p1)
- self.assertEqual(len(i.vertices()), 2)
- self.assertEqual(len(i.edges()), 1)
- self.assertAlmostEqual(i.edge().length, 2, 5)
-
- with self.assertRaises(ValueError):
- e1.intersect("line")
-
- def test_pos(self):
- with self.assertRaises(TypeError):
- Pos(0, "foo")
- self.assertEqual(Pos(1, 2, 3).position, Vector(1, 2, 3))
- self.assertEqual(Pos((1, 2, 3)).position, Vector(1, 2, 3))
- self.assertEqual(Pos(v=(1, 2, 3)).position, Vector(1, 2, 3))
- self.assertEqual(Pos(X=1, Y=2, Z=3).position, Vector(1, 2, 3))
- self.assertEqual(Pos(Vector(1, 2, 3)).position, Vector(1, 2, 3))
- self.assertEqual(Pos(1, Y=2, Z=3).position, Vector(1, 2, 3))
-
-
-class TestMatrix(unittest.TestCase):
- def test_matrix_creation_and_access(self):
- def matrix_vals(m):
- return [[m[r, c] for c in range(4)] for r in range(4)]
-
- # default constructor creates a 4x4 identity matrix
- m = Matrix()
- identity = [
- [1.0, 0.0, 0.0, 0.0],
- [0.0, 1.0, 0.0, 0.0],
- [0.0, 0.0, 1.0, 0.0],
- [0.0, 0.0, 0.0, 1.0],
- ]
- self.assertEqual(identity, matrix_vals(m))
-
- vals4x4 = [
- [1.0, 0.0, 0.0, 1.0],
- [0.0, 1.0, 0.0, 2.0],
- [0.0, 0.0, 1.0, 3.0],
- [0.0, 0.0, 0.0, 1.0],
- ]
- vals4x4_tuple = tuple(tuple(r) for r in vals4x4)
-
- # test constructor with 16-value input
- m = Matrix(vals4x4)
- self.assertEqual(vals4x4, matrix_vals(m))
- m = Matrix(vals4x4_tuple)
- self.assertEqual(vals4x4, matrix_vals(m))
-
- # test constructor with 12-value input (the last 4 are an implied
- # [0,0,0,1])
- m = Matrix(vals4x4[:3])
- self.assertEqual(vals4x4, matrix_vals(m))
- m = Matrix(vals4x4_tuple[:3])
- self.assertEqual(vals4x4, matrix_vals(m))
-
- # Test 16-value input with invalid values for the last 4
- invalid = [
- [1.0, 0.0, 0.0, 1.0],
- [0.0, 1.0, 0.0, 2.0],
- [0.0, 0.0, 1.0, 3.0],
- [1.0, 2.0, 3.0, 4.0],
- ]
- with self.assertRaises(ValueError):
- Matrix(invalid)
- # Test input with invalid type
- with self.assertRaises(TypeError):
- Matrix("invalid")
- # Test input with invalid size / nested types
- with self.assertRaises(TypeError):
- Matrix([[1, 2, 3, 4], [1, 2, 3], [1, 2, 3, 4]])
- with self.assertRaises(TypeError):
- Matrix([1, 2, 3])
-
- # Invalid sub-type
- with self.assertRaises(TypeError):
- Matrix([[1, 2, 3, 4], "abc", [1, 2, 3, 4]])
-
- # test out-of-bounds access
- m = Matrix()
- with self.assertRaises(IndexError):
- m[0, 4]
- with self.assertRaises(IndexError):
- m[4, 0]
- with self.assertRaises(IndexError):
- m["ab"]
-
- # test __repr__ methods
- m = Matrix(vals4x4)
- mRepr = "Matrix([[1.0, 0.0, 0.0, 1.0],\n [0.0, 1.0, 0.0, 2.0],\n [0.0, 0.0, 1.0, 3.0],\n [0.0, 0.0, 0.0, 1.0]])"
- self.assertEqual(repr(m), mRepr)
- self.assertEqual(str(eval(repr(m))), mRepr)
-
- def test_matrix_functionality(self):
- # Test rotate methods
- def matrix_almost_equal(m, target_matrix):
- for r, row in enumerate(target_matrix):
- for c, target_value in enumerate(row):
- self.assertAlmostEqual(m[r, c], target_value)
-
- root_3_over_2 = math.sqrt(3) / 2
- m_rotate_x_30 = [
- [1, 0, 0, 0],
- [0, root_3_over_2, -1 / 2, 0],
- [0, 1 / 2, root_3_over_2, 0],
- [0, 0, 0, 1],
- ]
- mx = Matrix()
- mx.rotate(Axis.X, math.radians(30))
- matrix_almost_equal(mx, m_rotate_x_30)
-
- m_rotate_y_30 = [
- [root_3_over_2, 0, 1 / 2, 0],
- [0, 1, 0, 0],
- [-1 / 2, 0, root_3_over_2, 0],
- [0, 0, 0, 1],
- ]
- my = Matrix()
- my.rotate(Axis.Y, math.radians(30))
- matrix_almost_equal(my, m_rotate_y_30)
-
- m_rotate_z_30 = [
- [root_3_over_2, -1 / 2, 0, 0],
- [1 / 2, root_3_over_2, 0, 0],
- [0, 0, 1, 0],
- [0, 0, 0, 1],
- ]
- mz = Matrix()
- mz.rotate(Axis.Z, math.radians(30))
- matrix_almost_equal(mz, m_rotate_z_30)
-
- # Test matrix multiply vector
- v = Vector(1, 0, 0)
- self.assertAlmostEqual(mz.multiply(v), (root_3_over_2, 1 / 2, 0), 7)
-
- # Test matrix multiply matrix
- m_rotate_xy_30 = [
- [root_3_over_2, 0, 1 / 2, 0],
- [1 / 4, root_3_over_2, -root_3_over_2 / 2, 0],
- [-root_3_over_2 / 2, 1 / 2, 3 / 4, 0],
- [0, 0, 0, 1],
- ]
- mxy = mx.multiply(my)
- matrix_almost_equal(mxy, m_rotate_xy_30)
-
- # Test matrix inverse
- vals4x4 = [[1, 2, 3, 4], [5, 1, 6, 7], [8, 9, 1, 10], [0, 0, 0, 1]]
- vals4x4_invert = [
- [-53 / 144, 25 / 144, 1 / 16, -53 / 144],
- [43 / 144, -23 / 144, 1 / 16, -101 / 144],
- [37 / 144, 7 / 144, -1 / 16, -107 / 144],
- [0, 0, 0, 1],
- ]
- m = Matrix(vals4x4).inverse()
- matrix_almost_equal(m, vals4x4_invert)
-
- # Test matrix created from transfer function
- rot_x = gp_Trsf()
- θ = math.pi
- rot_x.SetRotation(gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(1, 0, 0)), θ)
- m = Matrix(rot_x)
- rot_x_matrix = [
- [1, 0, 0, 0],
- [0, math.cos(θ), -math.sin(θ), 0],
- [0, math.sin(θ), math.cos(θ), 0],
- [0, 0, 0, 1],
- ]
- matrix_almost_equal(m, rot_x_matrix)
-
- # Test copy
- m2 = copy.copy(m)
- matrix_almost_equal(m2, rot_x_matrix)
- m3 = copy.deepcopy(m)
- matrix_almost_equal(m3, rot_x_matrix)
-
-
-class TestMixin1D(unittest.TestCase):
- """Test the add in methods"""
-
- def test_position_at(self):
- self.assertAlmostEqual(
- Edge.make_line((0, 0, 0), (1, 1, 1)).position_at(0.5),
- (0.5, 0.5, 0.5),
- 5,
- )
- # Not sure what PARAMETER mode returns - but it's in the ballpark
- point = (
- Edge.make_line((0, 0, 0), (1, 1, 1))
- .position_at(0.5, position_mode=PositionMode.PARAMETER)
- .to_tuple()
- )
- self.assertTrue(all([0.0 < v < 1.0 for v in point]))
-
- wire = Wire([Edge.make_line((0, 0, 0), (10, 0, 0))])
- self.assertAlmostEqual(wire.position_at(0.3), (3, 0, 0), 5)
- self.assertAlmostEqual(
- wire.position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
- )
- self.assertAlmostEqual(wire.edge().position_at(0.3), (3, 0, 0), 5)
- self.assertAlmostEqual(
- wire.edge().position_at(3, position_mode=PositionMode.LENGTH), (3, 0, 0), 5
- )
-
- circle_wire = Wire(
- [
- Edge.make_circle(1, start_angle=0, end_angle=180),
- Edge.make_circle(1, start_angle=180, end_angle=360),
- ]
- )
- p1 = circle_wire.position_at(math.pi, position_mode=PositionMode.LENGTH)
- p2 = circle_wire.position_at(math.pi / circle_wire.length)
- self.assertAlmostEqual(p1, (-1, 0, 0), 14)
- self.assertAlmostEqual(p2, (-1, 0, 0), 14)
- self.assertAlmostEqual(p1, p2, 14)
-
- circle_edge = Edge.make_circle(1)
- p3 = circle_edge.position_at(math.pi, position_mode=PositionMode.LENGTH)
- p4 = circle_edge.position_at(math.pi / circle_edge.length)
- self.assertAlmostEqual(p3, (-1, 0, 0), 14)
- self.assertAlmostEqual(p4, (-1, 0, 0), 14)
- self.assertAlmostEqual(p3, p4, 14)
-
- circle = Wire(
- [
- Edge.make_circle(2, start_angle=0, end_angle=180),
- Edge.make_circle(2, start_angle=180, end_angle=360),
- ]
- )
- self.assertAlmostEqual(
- circle.position_at(0.5),
- (-2, 0, 0),
- 5,
- )
- self.assertAlmostEqual(
- circle.position_at(2 * math.pi, position_mode=PositionMode.LENGTH),
- (-2, 0, 0),
- 5,
- )
-
- def test_positions(self):
- e = Edge.make_line((0, 0, 0), (1, 1, 1))
- distances = [i / 4 for i in range(3)]
- pts = e.positions(distances)
- for i, position in enumerate(pts):
- self.assertAlmostEqual(position, (i / 4, i / 4, i / 4), 5)
-
- def test_tangent_at(self):
- self.assertAlmostEqual(
- Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0),
- (-1, 0, 0),
- 5,
- )
- tangent = (
- Edge.make_circle(1, start_angle=0, end_angle=90)
- .tangent_at(0.0, position_mode=PositionMode.PARAMETER)
- .to_tuple()
- )
- self.assertTrue(all([0.0 <= v <= 1.0 for v in tangent]))
-
- self.assertAlmostEqual(
- Edge.make_circle(1, start_angle=0, end_angle=180).tangent_at(
- math.pi / 2, position_mode=PositionMode.LENGTH
- ),
- (-1, 0, 0),
- 5,
- )
-
- def test_tangent_at_point(self):
- circle = Wire(
- [
- Edge.make_circle(1, start_angle=0, end_angle=180),
- Edge.make_circle(1, start_angle=180, end_angle=360),
- ]
- )
- pnt_on_circle = Vector(math.cos(math.pi / 4), math.sin(math.pi / 4))
- tan = circle.tangent_at(pnt_on_circle)
- self.assertAlmostEqual(tan, (-math.sqrt(2) / 2, math.sqrt(2) / 2), 5)
-
- def test_tangent_at_by_length(self):
- circle = Edge.make_circle(1)
- tan = circle.tangent_at(circle.length * 0.5, position_mode=PositionMode.LENGTH)
- self.assertAlmostEqual(tan, (0, -1), 5)
-
- def test_tangent_at_error(self):
- with self.assertRaises(ValueError):
- Edge.make_circle(1).tangent_at("start")
-
- def test_normal(self):
- self.assertAlmostEqual(
- Edge.make_circle(
- 1, Plane(origin=(0, 0, 0), z_dir=(1, 0, 0)), start_angle=0, end_angle=60
- ).normal(),
- (1, 0, 0),
- 5,
- )
- self.assertAlmostEqual(
- Edge.make_ellipse(
- 1,
- 0.5,
- Plane(origin=(0, 0, 0), z_dir=(1, 1, 0)),
- start_angle=0,
- end_angle=90,
- ).normal(),
- (math.sqrt(2) / 2, math.sqrt(2) / 2, 0),
- 5,
- )
- self.assertAlmostEqual(
- Edge.make_spline(
- [
- (1, 0),
- (math.sqrt(2) / 2, math.sqrt(2) / 2),
- (0, 1),
- ],
- tangents=((0, 1, 0), (-1, 0, 0)),
- ).normal(),
- (0, 0, 1),
- 5,
- )
- with self.assertRaises(ValueError):
- Edge.make_line((0, 0, 0), (1, 1, 1)).normal()
-
- def test_center(self):
- c = Edge.make_circle(1, start_angle=0, end_angle=180)
- self.assertAlmostEqual(c.center(), (0, 1, 0), 5)
- self.assertAlmostEqual(
- c.center(CenterOf.MASS),
- (0, 0.6366197723675814, 0),
- 5,
- )
- self.assertAlmostEqual(c.center(CenterOf.BOUNDING_BOX), (0, 0.5, 0), 5)
-
- def test_location_at(self):
- loc = Edge.make_circle(1).location_at(0.25)
- self.assertAlmostEqual(loc.position, (0, 1, 0), 5)
- self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5)
-
- loc = Edge.make_circle(1).location_at(
- math.pi / 2, position_mode=PositionMode.LENGTH
- )
- self.assertAlmostEqual(loc.position, (0, 1, 0), 5)
- self.assertAlmostEqual(loc.orientation, (0, -90, -90), 5)
-
- def test_locations(self):
- locs = Edge.make_circle(1).locations([i / 4 for i in range(4)])
- self.assertAlmostEqual(locs[0].position, (1, 0, 0), 5)
- self.assertAlmostEqual(locs[0].orientation, (-90, 0, -180), 5)
- self.assertAlmostEqual(locs[1].position, (0, 1, 0), 5)
- self.assertAlmostEqual(locs[1].orientation, (0, -90, -90), 5)
- self.assertAlmostEqual(locs[2].position, (-1, 0, 0), 5)
- self.assertAlmostEqual(locs[2].orientation, (90, 0, 0), 5)
- self.assertAlmostEqual(locs[3].position, (0, -1, 0), 5)
- self.assertAlmostEqual(locs[3].orientation, (0, 90, 90), 5)
-
- def test_project(self):
- target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0)))
- circle = Edge.make_circle(1).locate(Location((0, 0, 10)))
- ellipse: Wire = circle.project(target, (0, 0, -1))
- bbox = ellipse.bounding_box()
- self.assertAlmostEqual(bbox.min, (-1, -1, -1), 5)
- self.assertAlmostEqual(bbox.max, (1, 1, 1), 5)
-
- def test_project2(self):
- target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
- square = Wire.make_rect(1, 1, Plane.YZ).locate(Location((10, 0, 0)))
- projections: list[Wire] = square.project(
- target, direction=(-1, 0, 0), closest=False
- )
- self.assertEqual(len(projections), 2)
-
- def test_is_forward(self):
- plate = Box(10, 10, 1) - Cylinder(1, 1)
- hole_edges = plate.edges().filter_by(GeomType.CIRCLE)
- self.assertTrue(hole_edges.sort_by(Axis.Z)[-1].is_forward)
- self.assertFalse(hole_edges.sort_by(Axis.Z)[0].is_forward)
-
- def test_offset_2d(self):
- base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False)
- corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1]
- base_wire = base_wire.fillet_2d(0.4, [corner])
- offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT)
- self.assertTrue(offset_wire.is_closed)
- self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6)
- self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2)
- offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT)
- self.assertAlmostEqual(
- offset_wire_right.edges()
- .filter_by(GeomType.CIRCLE)
- .sort_by(SortBy.RADIUS)[-1]
- .radius,
- 0.5,
- 4,
- )
- h_perimeter = Compound.make_text("h", font_size=10).wire()
- with self.assertRaises(RuntimeError):
- h_perimeter.offset_2d(-1)
-
- # Test for returned Edge - can't find a way to do this
- # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50)
- # self.assertTrue(isinstance(offset_edge, Edge))
- # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
- # self.assertTrue(offset_edge.geom_type == GeomType.CIRCLE)
- # self.assertAlmostEqual(offset_edge.radius, 12, 5)
- # base_edge = Edge.make_line((0, 1), (1, 10))
- # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False)
- # self.assertTrue(isinstance(offset_edge, Edge))
- # self.assertTrue(offset_edge.geom_type == GeomType.LINE)
- # self.assertAlmostEqual(offset_edge.position_at(0).X, 3)
-
- def test_common_plane(self):
- # Straight and circular lines
- l = Edge.make_line((0, 0, 0), (5, 0, 0))
- c = Edge.make_circle(2, Plane.XZ, -90, 90)
- common = l.common_plane(c)
- self.assertAlmostEqual(common.z_dir.X, 0, 5)
- self.assertAlmostEqual(abs(common.z_dir.Y), 1, 5) # the direction isn't known
- self.assertAlmostEqual(common.z_dir.Z, 0, 5)
-
- # Co-axial straight lines
- l1 = Edge.make_line((0, 0), (1, 1))
- l2 = Edge.make_line((0.25, 0.25), (0.75, 0.75))
- common = l1.common_plane(l2)
- # the z_dir isn't know
- self.assertAlmostEqual(common.x_dir.Z, 0, 5)
-
- # Parallel lines
- l1 = Edge.make_line((0, 0), (1, 0))
- l2 = Edge.make_line((0, 1), (1, 1))
- common = l1.common_plane(l2)
- self.assertAlmostEqual(common.z_dir.X, 0, 5)
- self.assertAlmostEqual(common.z_dir.Y, 0, 5)
- self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
-
- # Many lines
- common = Edge.common_plane(*Wire.make_rect(10, 10).edges())
- self.assertAlmostEqual(common.z_dir.X, 0, 5)
- self.assertAlmostEqual(common.z_dir.Y, 0, 5)
- self.assertAlmostEqual(abs(common.z_dir.Z), 1, 5) # the direction isn't known
-
- # Wire and Edges
- c = Wire.make_circle(1, Plane.YZ)
- lines = Wire.make_rect(2, 2, Plane.YZ).edges()
- common = c.common_plane(*lines)
- self.assertAlmostEqual(abs(common.z_dir.X), 1, 5) # the direction isn't known
- self.assertAlmostEqual(common.z_dir.Y, 0, 5)
- self.assertAlmostEqual(common.z_dir.Z, 0, 5)
-
- def test_edge_volume(self):
- edge = Edge.make_line((0, 0), (1, 1))
- self.assertAlmostEqual(edge.volume, 0, 5)
-
- def test_wire_volume(self):
- wire = Wire.make_rect(1, 1)
- self.assertAlmostEqual(wire.volume, 0, 5)
-
-
-class TestMixin3D(unittest.TestCase):
- """Test that 3D add ins"""
-
- def test_chamfer(self):
- box = Solid.make_box(1, 1, 1)
- chamfer_box = box.chamfer(0.1, None, box.edges().sort_by(Axis.Z)[-1:])
- self.assertAlmostEqual(chamfer_box.volume, 1 - 0.005, 5)
-
- def test_chamfer_asym_length(self):
- box = Solid.make_box(1, 1, 1)
- chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:])
- self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
-
- def test_chamfer_asym_length_with_face(self):
- box = Solid.make_box(1, 1, 1)
- face = box.faces().sort_by(Axis.Z)[0]
- edge = [face.edges().sort_by(Axis.Y)[0]]
- chamfer_box = box.chamfer(0.1, 0.2, edge, face=face)
- self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5)
-
- def test_chamfer_too_high_length(self):
- box = Solid.make_box(1, 1, 1)
- face = box.faces
- self.assertRaises(
- ValueError, box.chamfer, 2, None, box.edges().sort_by(Axis.Z)[-1:]
- )
-
- def test_chamfer_edge_not_part_of_face(self):
- box = Solid.make_box(1, 1, 1)
- edge = box.edges().sort_by(Axis.Z)[-1:]
- face = box.faces().sort_by(Axis.Z)[0]
- self.assertRaises(ValueError, box.chamfer, 0.1, None, edge, face=face)
-
- @patch.object(Shape, "is_valid", return_value=False)
- def test_chamfer_invalid_shape_raises_error(self, mock_is_valid):
- box = Solid.make_box(1, 1, 1)
-
- # Assert that ValueError is raised
- with self.assertRaises(ValueError) as chamfer_context:
- max = box.chamfer(0.1, None, box.edges())
-
- # Check the error message
- self.assertEqual(
- str(chamfer_context.exception),
- "Failed creating a chamfer, try a smaller length value(s)",
- )
-
- # Verify is_valid was called
- mock_is_valid.assert_called_once()
-
- def test_hollow(self):
- shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1)
- self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5)
-
- shell_box = Solid.make_box(1, 1, 1)
- shell_box = shell_box.hollow(
- shell_box.faces().filter_by(Axis.Z), thickness=0.1, kind=Kind.INTERSECTION
- )
- self.assertAlmostEqual(shell_box.volume, 1 * 1.2**2 - 1**3, 5)
-
- shell_box = Solid.make_box(1, 1, 1).hollow(
- [], thickness=0.1, kind=Kind.INTERSECTION
- )
- self.assertAlmostEqual(shell_box.volume, 1.2**3 - 1**3, 5)
-
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT)
-
- def test_is_inside(self):
- self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5)))
-
- def test_dprism(self):
- # face
- f = Face.make_rect(0.5, 0.5)
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [f], additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
-
- # face with depth
- f = Face.make_rect(0.5, 0.5)
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [f], depth=0.5, thru_all=False, additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
-
- # face until
- f = Face.make_rect(0.5, 0.5)
- limit = Face.make_rect(1, 1, Plane((0, 0, 0.5)))
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [f], up_to_face=limit, thru_all=False, additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**3, 5)
-
- # wire
- w = Face.make_rect(0.5, 0.5).outer_wire()
- d = Solid.make_box(1, 1, 1, Plane((-0.5, -0.5, 0))).dprism(
- None, [w], additive=False
- )
- self.assertTrue(d.is_valid())
- self.assertAlmostEqual(d.volume, 1 - 0.5**2, 5)
-
- def test_center(self):
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).center(CenterOf.GEOMETRY)
-
- self.assertAlmostEqual(
- Solid.make_box(1, 1, 1).center(CenterOf.BOUNDING_BOX),
- (0.5, 0.5, 0.5),
- 5,
- )
-
-
-class TestPlane(unittest.TestCase):
- """Plane with class properties"""
-
- def test_class_properties(self):
- """Validate
- Name x_dir y_dir z_dir
- ======= ====== ====== ======
- XY +x +y +z
- YZ +y +z +x
- ZX +z +x +y
- XZ +x +z -y
- YX +y +x -z
- ZY +z +y -x
- front +x +z -y
- back -x +z +y
- left -y +z -x
- right +y +z +x
- top +x +y +z
- bottom +x -y -z
- isometric +x+y -x+y+z +x+y-z
- """
- planes = [
- (Plane.XY, (1, 0, 0), (0, 0, 1)),
- (Plane.YZ, (0, 1, 0), (1, 0, 0)),
- (Plane.ZX, (0, 0, 1), (0, 1, 0)),
- (Plane.XZ, (1, 0, 0), (0, -1, 0)),
- (Plane.YX, (0, 1, 0), (0, 0, -1)),
- (Plane.ZY, (0, 0, 1), (-1, 0, 0)),
- (Plane.front, (1, 0, 0), (0, -1, 0)),
- (Plane.back, (-1, 0, 0), (0, 1, 0)),
- (Plane.left, (0, -1, 0), (-1, 0, 0)),
- (Plane.right, (0, 1, 0), (1, 0, 0)),
- (Plane.top, (1, 0, 0), (0, 0, 1)),
- (Plane.bottom, (1, 0, 0), (0, 0, -1)),
- (
- Plane.isometric,
- (1 / 2**0.5, 1 / 2**0.5, 0),
- (1 / 3**0.5, -1 / 3**0.5, 1 / 3**0.5),
- ),
- ]
- for plane, x_dir, z_dir in planes:
- self.assertAlmostEqual(plane.x_dir, x_dir, 5)
- self.assertAlmostEqual(plane.z_dir, z_dir, 5)
-
- def test_plane_init(self):
- # from origin
- o = (0, 0, 0)
- x = (1, 0, 0)
- y = (0, 1, 0)
- z = (0, 0, 1)
- planes = [
- Plane(o),
- Plane(o, x),
- Plane(o, x, z),
- Plane(o, x, z_dir=z),
- Plane(o, x_dir=x, z_dir=z),
- Plane(o, x_dir=x),
- Plane(o, z_dir=z),
- Plane(origin=o, x_dir=x, z_dir=z),
- Plane(origin=o, x_dir=x),
- Plane(origin=o, z_dir=z),
- ]
- for p in planes:
- self.assertAlmostEqual(p.origin, o, 6)
- self.assertAlmostEqual(p.x_dir, x, 6)
- self.assertAlmostEqual(p.y_dir, y, 6)
- self.assertAlmostEqual(p.z_dir, z, 6)
- with self.assertRaises(TypeError):
- Plane()
- with self.assertRaises(TypeError):
- Plane(o, z_dir="up")
-
- # rotated location around z
- loc = Location((0, 0, 0), (0, 0, 45))
- p_from_loc = Plane(loc)
- p_from_named_loc = Plane(location=loc)
- for p in [p_from_loc, p_from_named_loc]:
- self.assertAlmostEqual(p.origin, (0, 0, 0), 6)
- self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
- self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
- self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6)
- self.assertAlmostEqual(loc.position, p.location.position, 6)
- self.assertAlmostEqual(loc.orientation, p.location.orientation, 6)
-
- # rotated location around x and origin <> (0,0,0)
- loc = Location((0, 2, -1), (45, 0, 0))
- p = Plane(loc)
- self.assertAlmostEqual(p.origin, (0, 2, -1), 6)
- self.assertAlmostEqual(p.x_dir, (1, 0, 0), 6)
- self.assertAlmostEqual(p.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
- self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
- self.assertAlmostEqual(loc.position, p.location.position, 6)
- self.assertAlmostEqual(loc.orientation, p.location.orientation, 6)
-
- # from a face
- f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45)))
- p_from_face = Plane(f)
- p_from_named_face = Plane(face=f)
- plane_from_gp_pln = Plane(gp_pln=p_from_face.wrapped)
- p_deep_copy = copy.deepcopy(p_from_face)
- for p in [p_from_face, p_from_named_face, plane_from_gp_pln, p_deep_copy]:
- self.assertAlmostEqual(p.origin, (1, 2, 3), 6)
- self.assertAlmostEqual(p.x_dir, (math.sqrt(2) / 2, 0.5, 0.5), 6)
- self.assertAlmostEqual(p.y_dir, (-math.sqrt(2) / 2, 0.5, 0.5), 6)
- self.assertAlmostEqual(p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
- self.assertAlmostEqual(f.location.position, p.location.position, 6)
- self.assertAlmostEqual(f.location.orientation, p.location.orientation, 6)
-
- # from a face with x_dir
- f = Face.make_rect(1, 2)
- x = (1, 1)
- y = (-1, 1)
- planes = [
- Plane(f, x),
- Plane(f, x_dir=x),
- Plane(face=f, x_dir=x),
- ]
- for p in planes:
- self.assertAlmostEqual(p.origin, (0, 0, 0), 6)
- self.assertAlmostEqual(p.x_dir, Vector(x).normalized(), 6)
- self.assertAlmostEqual(p.y_dir, Vector(y).normalized(), 6)
- self.assertAlmostEqual(p.z_dir, (0, 0, 1), 6)
-
- with self.assertRaises(TypeError):
- Plane(Edge.make_line((0, 0), (0, 1)))
-
- # can be instantiated from planar faces of surface types other than Geom_Plane
- # this loft creates the trapezoid faces of type Geom_BSplineSurface
- lofted_solid = Solid.make_loft(
- [
- Rectangle(3, 1).wire(),
- Pos(0, 0, 1) * Rectangle(1, 1).wire(),
- ]
- )
-
- expected = [
- # Trapezoid face, negative y coordinate
- (
- Axis.X.direction, # plane x_dir
- Axis.Z.direction, # plane y_dir
- -Axis.Y.direction, # plane z_dir
- ),
- # Trapezoid face, positive y coordinate
- (
- -Axis.X.direction,
- Axis.Z.direction,
- Axis.Y.direction,
- ),
- ]
- # assert properties of the trapezoid faces
- for i, f in enumerate(lofted_solid.faces() | Plane.XZ > Axis.Y):
- p = Plane(f)
- f_props = GProp_GProps()
- BRepGProp.SurfaceProperties_s(f.wrapped, f_props)
- self.assertAlmostEqual(p.origin, Vector(f_props.CentreOfMass()), 6)
- self.assertAlmostEqual(p.x_dir, expected[i][0], 6)
- self.assertAlmostEqual(p.y_dir, expected[i][1], 6)
- self.assertAlmostEqual(p.z_dir, expected[i][2], 6)
-
- def test_plane_neg(self):
- p = Plane(
- origin=(1, 2, 3),
- x_dir=Vector(1, 2, 3).normalized(),
- z_dir=Vector(4, 5, 6).normalized(),
- )
- p2 = -p
- self.assertAlmostEqual(p2.origin, p.origin, 6)
- self.assertAlmostEqual(p2.x_dir, p.x_dir, 6)
- self.assertAlmostEqual(p2.z_dir, -p.z_dir, 6)
- self.assertAlmostEqual(p2.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6)
- p3 = p.reverse()
- self.assertAlmostEqual(p3.origin, p.origin, 6)
- self.assertAlmostEqual(p3.x_dir, p.x_dir, 6)
- self.assertAlmostEqual(p3.z_dir, -p.z_dir, 6)
- self.assertAlmostEqual(p3.y_dir, (-p.z_dir).cross(p.x_dir).normalized(), 6)
-
- def test_plane_mul(self):
- p = Plane(origin=(1, 2, 3), x_dir=(1, 0, 0), z_dir=(0, 0, 1))
- p2 = p * Location((1, 2, -1), (0, 0, 45))
- self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
- self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
- self.assertAlmostEqual(p2.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6)
- self.assertAlmostEqual(p2.z_dir, (0, 0, 1), 6)
-
- p2 = p * Location((1, 2, -1), (0, 45, 0))
- self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
- self.assertAlmostEqual(p2.x_dir, (math.sqrt(2) / 2, 0, -math.sqrt(2) / 2), 6)
- self.assertAlmostEqual(p2.y_dir, (0, 1, 0), 6)
- self.assertAlmostEqual(p2.z_dir, (math.sqrt(2) / 2, 0, math.sqrt(2) / 2), 6)
-
- p2 = p * Location((1, 2, -1), (45, 0, 0))
- self.assertAlmostEqual(p2.origin, (2, 4, 2), 6)
- self.assertAlmostEqual(p2.x_dir, (1, 0, 0), 6)
- self.assertAlmostEqual(p2.y_dir, (0, math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
- self.assertAlmostEqual(p2.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6)
- with self.assertRaises(TypeError):
- p2 * Vector(1, 1, 1)
-
- def test_plane_methods(self):
- # Test error checking
- p = Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 0))
- with self.assertRaises(ValueError):
- p.to_local_coords("box")
-
- # Test translation to local coordinates
- local_box = p.to_local_coords(Solid.make_box(1, 1, 1))
- local_box_vertices = [(v.X, v.Y, v.Z) for v in local_box.vertices()]
- target_vertices = [
- (0, -1, 0),
- (0, 0, 0),
- (0, -1, 1),
- (0, 0, 1),
- (1, -1, 0),
- (1, 0, 0),
- (1, -1, 1),
- (1, 0, 1),
- ]
- for i, target_point in enumerate(target_vertices):
- np.testing.assert_allclose(target_point, local_box_vertices[i], 1e-7)
-
- def test_localize_vertex(self):
- vertex = Vertex(random.random(), random.random(), random.random())
- np.testing.assert_allclose(
- Plane.YZ.to_local_coords(vertex).to_tuple(),
- Plane.YZ.to_local_coords(Vector(vertex)).to_tuple(),
- 5,
- )
-
- def test_repr(self):
- self.assertEqual(
- repr(Plane.XY),
- "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))",
- )
-
- def test_shift_origin_axis(self):
- cyl = Cylinder(1, 2, align=Align.MIN)
- top = cyl.faces().sort_by(Axis.Z)[-1]
- pln = Plane(top).shift_origin(Axis.Z)
- with BuildPart() as p:
- add(cyl)
- with BuildSketch(pln):
- with Locations((1, 1)):
- Circle(0.5)
- extrude(amount=-2, mode=Mode.SUBTRACT)
- self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5)
-
- def test_shift_origin_vertex(self):
- box = Box(1, 1, 1, align=Align.MIN)
- front = box.faces().sort_by(Axis.X)[-1]
- pln = Plane(front).shift_origin(
- front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1]
- )
- with BuildPart() as p:
- add(box)
- with BuildSketch(pln):
- with Locations((-0.5, 0.5)):
- Circle(0.5)
- extrude(amount=-1, mode=Mode.SUBTRACT)
- self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5)
-
- def test_shift_origin_vector(self):
- with BuildPart() as p:
- Box(4, 4, 2)
- b = fillet(p.edges().filter_by(Axis.Z), 0.5)
- top = p.faces().sort_by(Axis.Z)[-1]
- ref = (
- top.edges()
- .filter_by(GeomType.CIRCLE)
- .group_by(Axis.X)[-1]
- .sort_by(Axis.Y)[0]
- .arc_center
- )
- pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref)
- with BuildSketch(pln):
- with Locations((0.5, 0.5)):
- Rectangle(2, 2, align=Align.MIN)
- extrude(amount=-1, mode=Mode.SUBTRACT)
- self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5)
-
- def test_shift_origin_error(self):
- with self.assertRaises(ValueError):
- Plane.XY.shift_origin(Vertex(1, 1, 1))
-
- with self.assertRaises(ValueError):
- Plane.XY.shift_origin((1, 1, 1))
-
- with self.assertRaises(ValueError):
- Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0)))
-
- with self.assertRaises(TypeError):
- Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1)))
-
- def test_move(self):
- pln = Plane.XY.move(Location((1, 2, 3)))
- self.assertAlmostEqual(pln.origin, (1, 2, 3), 5)
-
- def test_rotated(self):
- rotated_plane = Plane.XY.rotated((45, 0, 0))
- self.assertAlmostEqual(rotated_plane.x_dir, (1, 0, 0), 5)
- self.assertAlmostEqual(
- rotated_plane.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 5
- )
-
- def test_invalid_plane(self):
- # Test plane creation error handling
- with self.assertRaises(ValueError):
- Plane(origin=(0, 0, 0), x_dir=(0, 0, 0), z_dir=(0, 1, 1))
- with self.assertRaises(ValueError):
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 0))
-
- def test_plane_equal(self):
- # default orientation
- self.assertEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- )
- # moved origin
- self.assertEqual(
- Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(2, 1, -1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- )
- # moved x-axis
- self.assertEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
- )
- # moved z-axis
- self.assertEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
- )
- # __eq__ cooperation
- self.assertEqual(Plane.XY, AlwaysEqual())
-
- def test_plane_not_equal(self):
- # type difference
- for value in [None, 0, 1, "abc"]:
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)), value
- )
- # origin difference
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 1), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- )
- # x-axis difference
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 1, 0), z_dir=(0, 0, 1)),
- )
- # z-axis difference
- self.assertNotEqual(
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 0, 1)),
- Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
- )
-
- def test_to_location(self):
- loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location
- self.assertAlmostEqual(loc.position, (1, 2, 3), 5)
- self.assertAlmostEqual(loc.orientation, (0, 0, 90), 5)
-
- def test_intersect(self):
- self.assertAlmostEqual(
- Plane.XY.intersect(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5
- )
- self.assertIsNone(Plane.XY.intersect(Axis((1, 2, 3), (0, 1, 0))))
-
- self.assertEqual(Plane.XY.intersect(Plane.XZ), Axis.X)
-
- self.assertIsNone(Plane.XY.intersect(Plane.XY.offset(1)))
-
- with self.assertRaises(ValueError):
- Plane.XY.intersect("Plane.XZ")
-
- with self.assertRaises(ValueError):
- Plane.XY.intersect(pln=Plane.XZ)
-
- def test_from_non_planar_face(self):
- flat = Face.make_rect(1, 1)
- pln = Plane(flat)
- self.assertTrue(isinstance(pln, Plane))
- cyl = (
- Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0]
- )
- with self.assertRaises(ValueError):
- pln = Plane(cyl)
-
- def test_plane_intersect(self):
- section = Plane.XY.intersect(Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5)))
- self.assertEqual(len(section.solids()), 0)
- self.assertEqual(len(section.faces()), 1)
- self.assertAlmostEqual(section.face().area, 2)
-
- section = Plane.XY & Solid.make_box(1, 2, 3, Plane.XY.offset(-1.5))
- self.assertEqual(len(section.solids()), 0)
- self.assertEqual(len(section.faces()), 1)
- self.assertAlmostEqual(section.face().area, 2)
-
- self.assertEqual(Plane.XY & Plane.XZ, Axis.X)
- # x_axis_as_edge = Plane.XY & Plane.XZ
- # common = (x_axis_as_edge.intersect(Edge.make_line((0, 0, 0), (1, 0, 0)))).edge()
- # self.assertAlmostEqual(common.length, 1, 5)
-
- i = Plane.XY & Vector(1, 2)
- self.assertTrue(isinstance(i, Vector))
- self.assertAlmostEqual(i, (1, 2, 0), 5)
-
- a = Axis((0, 0, 0), (1, 1, 0))
- i = Plane.XY & a
- self.assertTrue(isinstance(i, Axis))
- self.assertEqual(i, a)
-
- a = Axis((1, 2, -1), (0, 0, 1))
- i = Plane.XY & a
- self.assertTrue(isinstance(i, Vector))
- self.assertAlmostEqual(i, Vector(1, 2, 0), 5)
-
- def test_plane_origin_setter(self):
- pln = Plane.XY
- pln.origin = (1, 2, 3)
- ocp_origin = Vector(pln.wrapped.Location())
- self.assertAlmostEqual(ocp_origin, (1, 2, 3), 5)
-
-
-class TestProjection(unittest.TestCase):
- def test_flat_projection(self):
- sphere = Solid.make_sphere(50)
- projection_direction = Vector(0, -1, 0)
- planar_text_faces = (
- Compound.make_text("Flat", 30, align=(Align.CENTER, Align.CENTER))
- .rotate(Axis.X, 90)
- .faces()
- )
- projected_text_faces = [
- f.project_to_shape(sphere, projection_direction)[0]
- for f in planar_text_faces
- ]
- self.assertEqual(len(projected_text_faces), 4)
-
- def test_multiple_output_wires(self):
- target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2)
- circle = Wire.make_circle(3, Plane.XY.offset(10))
- projection = circle.project_to_shape(target, (0, 0, -1))
- bbox = projection[0].bounding_box()
- self.assertAlmostEqual(bbox.min, (-3, -3, 1), 2)
- self.assertAlmostEqual(bbox.max, (3, 3, 2), 2)
- bbox = projection[1].bounding_box()
- self.assertAlmostEqual(bbox.min, (-3, -3, -2), 2)
- self.assertAlmostEqual(bbox.max, (3, 3, -2), 2)
-
- def test_text_projection(self):
- sphere = Solid.make_sphere(50)
- arch_path = (
- sphere.cut(
- Solid.make_cylinder(
- 80, 100, Plane(origin=(-50, 0, -70), z_dir=(1, 0, 0))
- )
- )
- .edges()
- .sort_by(Axis.Z)[0]
- )
-
- projected_text = sphere.project_faces(
- faces=Compound.make_text("dog", font_size=14),
- path=arch_path,
- start=0.01, # avoid a character spanning the sphere edge
- )
- self.assertEqual(len(projected_text.solids()), 0)
- self.assertEqual(len(projected_text.faces()), 3)
-
- def test_error_handling(self):
- sphere = Solid.make_sphere(50)
- circle = Wire.make_circle(1)
- with self.assertRaises(ValueError):
- circle.project_to_shape(sphere, center=None, direction=None)[0]
-
- def test_project_edge(self):
- projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape(
- Solid.make_box(1, 1, 1), (0, 0, 1)
- )
- self.assertAlmostEqual(projection[0].position_at(1), (1, 0, 0), 5)
- self.assertAlmostEqual(projection[0].position_at(0), (0, 1, 0), 5)
- self.assertAlmostEqual(projection[0].arc_center, (0, 0, 0), 5)
-
- def test_to_axis(self):
- with self.assertRaises(ValueError):
- Edge.make_circle(1, end_angle=30).to_axis()
-
-
-class TestRotation(unittest.TestCase):
- def test_rotation_parameters(self):
- r = Rotation(10, 20, 30)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, 20, Z=30)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, 20, Z=30, ordering=Intrinsic.XYZ)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, Y=20, Z=30)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation((10, 20, 30))
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation(10, 20, 30, Intrinsic.XYZ)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation((30, 20, 10), Extrinsic.ZYX)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- r = Rotation((30, 20, 10), ordering=Extrinsic.ZYX)
- self.assertAlmostEqual(r.orientation, (10, 20, 30), 5)
- with self.assertRaises(TypeError):
- Rotation(x=10)
-
-
-class TestShape(unittest.TestCase):
- """Misc Shape tests"""
-
- def test_mirror(self):
- box_bb = Solid.make_box(1, 1, 1).mirror(Plane.XZ).bounding_box()
- self.assertAlmostEqual(box_bb.min.X, 0, 5)
- self.assertAlmostEqual(box_bb.max.X, 1, 5)
- self.assertAlmostEqual(box_bb.min.Y, -1, 5)
- self.assertAlmostEqual(box_bb.max.Y, 0, 5)
-
- box_bb = Solid.make_box(1, 1, 1).mirror().bounding_box()
- self.assertAlmostEqual(box_bb.min.Z, -1, 5)
- self.assertAlmostEqual(box_bb.max.Z, 0, 5)
-
- def test_compute_mass(self):
- with self.assertRaises(NotImplementedError):
- Shape.compute_mass(Vertex())
-
- def test_combined_center(self):
- objs = [Solid.make_box(1, 1, 1, Plane((x, 0, 0))) for x in [-2, 1]]
- self.assertAlmostEqual(
- Shape.combined_center(objs, center_of=CenterOf.MASS),
- (0, 0.5, 0.5),
- 5,
- )
-
- objs = [Solid.make_sphere(1, Plane((x, 0, 0))) for x in [-2, 1]]
- self.assertAlmostEqual(
- Shape.combined_center(objs, center_of=CenterOf.BOUNDING_BOX),
- (-0.5, 0, 0),
- 5,
- )
- with self.assertRaises(ValueError):
- Shape.combined_center(objs, center_of=CenterOf.GEOMETRY)
-
- def test_shape_type(self):
- self.assertEqual(Vertex().shape_type(), "Vertex")
-
- def test_scale(self):
- self.assertAlmostEqual(Solid.make_box(1, 1, 1).scale(2).volume, 2**3, 5)
-
- def test_fuse(self):
- box1 = Solid.make_box(1, 1, 1)
- box2 = Solid.make_box(1, 1, 1, Plane((1, 0, 0)))
- combined = box1.fuse(box2, glue=True)
- self.assertTrue(combined.is_valid())
- self.assertAlmostEqual(combined.volume, 2, 5)
- fuzzy = box1.fuse(box2, tol=1e-6)
- self.assertTrue(fuzzy.is_valid())
- self.assertAlmostEqual(fuzzy.volume, 2, 5)
-
- def test_faces_intersected_by_axis(self):
- box = Solid.make_box(1, 1, 1, Plane((0, 0, 1)))
- intersected_faces = box.faces_intersected_by_axis(Axis.Z)
- self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[0] in intersected_faces)
- self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces)
-
- def test_split(self):
- shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
- split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM)
- self.assertTrue(isinstance(split_shape, list))
- self.assertEqual(len(split_shape), 2)
- self.assertAlmostEqual(split_shape[0].volume + split_shape[1].volume, 0.25, 5)
- split_shape = shape.split(Plane.XY, keep=Keep.TOP)
- self.assertEqual(len(split_shape.solids()), 1)
- self.assertTrue(isinstance(split_shape, Solid))
- self.assertAlmostEqual(split_shape.volume, 0.5, 5)
-
- s = Solid.make_cone(1, 0.5, 2, Plane.YZ.offset(10))
- tool = Solid.make_sphere(11).rotate(Axis.Z, 90).face()
- s2 = s.split(tool, keep=Keep.TOP)
- self.assertLess(s2.volume, s.volume)
- self.assertGreater(s2.volume, 0.0)
-
- def test_split_by_non_planar_face(self):
- box = Solid.make_box(1, 1, 1)
- tool = Circle(1).wire()
- tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
- top, bottom = box.split(tool_shell, keep=Keep.BOTH)
-
- self.assertFalse(top is None)
- self.assertFalse(bottom is None)
- self.assertGreater(top.volume, bottom.volume)
-
- def test_split_by_shell(self):
- box = Solid.make_box(5, 5, 1)
- tool = Wire.make_rect(4, 4)
- tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
- split = box.split(tool_shell, keep=Keep.TOP)
- inner_vol = 2 * 2
- outer_vol = 5 * 5
- self.assertAlmostEqual(split.volume, outer_vol - inner_vol)
-
- def test_split_keep_all(self):
- shape = Box(1, 1, 1)
- split_shape = shape.split(Plane.XY, keep=Keep.ALL)
- self.assertTrue(isinstance(split_shape, ShapeList))
- self.assertEqual(len(split_shape), 2)
-
- def test_split_edge_by_shell(self):
- edge = Edge.make_line((-5, 0, 0), (5, 0, 0))
- tool = Wire.make_rect(4, 4)
- tool_shell: Shell = Shell.extrude(tool, Vector(0, 0, 1))
- top = edge.split(tool_shell, keep=Keep.TOP)
- self.assertEqual(len(top), 2)
- self.assertAlmostEqual(top[0].length, 3, 5)
-
- def test_split_return_none(self):
- shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5)
- split_shape = shape.split(Plane.XY, keep=Keep.INSIDE)
- self.assertIsNone(split_shape)
-
- def test_split_by_perimeter(self):
- # Test 0 - extract a spherical cap
- target0 = Solid.make_sphere(10).rotate(Axis.Z, 90)
- circle = Plane.YZ.offset(15) * Circle(5).face()
- circle_projected = circle.project_to_shape(target0, (-1, 0, 0))[0]
- circle_outerwire = circle_projected.edge()
- inside0, outside0 = target0.split_by_perimeter(circle_outerwire, Keep.BOTH)
- self.assertLess(inside0.area, outside0.area)
-
- # Test 1 - extract ring of a sphere
- ring = Pos(Z=15) * (Circle(5) - Circle(3)).face()
- ring_projected = ring.project_to_shape(target0, (0, 0, -1))[0]
- ring_outerwire = ring_projected.outer_wire()
- inside1, outside1 = target0.split_by_perimeter(ring_outerwire, Keep.BOTH)
- if isinstance(inside1, list):
- inside1 = Compound(inside1)
- if isinstance(outside1, list):
- outside1 = Compound(outside1)
- self.assertLess(inside1.area, outside1.area)
- self.assertEqual(len(outside1.faces()), 2)
-
- # Test 2 - extract multiple faces
- target2 = Box(1, 10, 10)
- square = Face.make_rect(3, 3, Plane((12, 0, 0), z_dir=(1, 0, 0)))
- square_projected = square.project_to_shape(target2, (-1, 0, 0))[0]
- outside2 = target2.split_by_perimeter(
- square_projected.outer_wire(), Keep.OUTSIDE
- )
- self.assertTrue(isinstance(outside2, Shell))
- inside2 = target2.split_by_perimeter(square_projected.outer_wire(), Keep.INSIDE)
- self.assertTrue(isinstance(inside2, Face))
-
- # Test 4 - invalid inputs
- with self.assertRaises(ValueError):
- _, _ = target2.split_by_perimeter(Edge.make_line((0, 0), (1, 0)), Keep.BOTH)
-
- with self.assertRaises(ValueError):
- _, _ = target2.split_by_perimeter(Edge.make_circle(1), Keep.TOP)
-
- def test_distance(self):
- sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
- sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
- self.assertAlmostEqual(sphere1.distance(sphere2), 8, 5)
-
- def test_distances(self):
- sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0)))
- sphere2 = Solid.make_sphere(1, Plane((5, 0, 0)))
- sphere3 = Solid.make_sphere(1, Plane((-5, 0, 5)))
- distances = [8, 3]
- for i, distance in enumerate(sphere1.distances(sphere2, sphere3)):
- self.assertAlmostEqual(distances[i], distance, 5)
-
- def test_max_fillet(self):
- test_solids = [Solid.make_box(10, 8, 2), Solid.make_cone(5, 3, 8)]
- max_values = [0.96, 3.84]
- for i, test_object in enumerate(test_solids):
- with self.subTest("solids" + str(i)):
- max = test_object.max_fillet(test_object.edges())
- self.assertAlmostEqual(max, max_values[i], 2)
- with self.assertRaises(RuntimeError):
- test_solids[0].max_fillet(
- test_solids[0].edges(), tolerance=1e-6, max_iterations=1
- )
- with self.assertRaises(ValueError):
- box = Solid.make_box(1, 1, 1)
- box.fillet(0.75, box.edges())
- # invalid_object = box.fillet(0.75, box.edges())
- # invalid_object.max_fillet(invalid_object.edges())
-
- @patch.object(Shape, "is_valid", return_value=False)
- def test_max_fillet_invalid_shape_raises_error(self, mock_is_valid):
- box = Solid.make_box(1, 1, 1)
-
- # Assert that ValueError is raised
- with self.assertRaises(ValueError) as max_fillet_context:
- max = box.max_fillet(box.edges())
-
- # Check the error message
- self.assertEqual(str(max_fillet_context.exception), "Invalid Shape")
-
- # Verify is_valid was called
- mock_is_valid.assert_called_once()
-
- def test_locate_bb(self):
- bounding_box = Solid.make_cone(1, 2, 1).bounding_box()
- relocated_bounding_box = Plane.XZ.from_local_coords(bounding_box)
- self.assertAlmostEqual(relocated_bounding_box.min.X, -2, 5)
- self.assertAlmostEqual(relocated_bounding_box.max.X, 2, 5)
- self.assertAlmostEqual(relocated_bounding_box.min.Y, 0, 5)
- self.assertAlmostEqual(relocated_bounding_box.max.Y, -1, 5)
- self.assertAlmostEqual(relocated_bounding_box.min.Z, -2, 5)
- self.assertAlmostEqual(relocated_bounding_box.max.Z, 2, 5)
-
- def test_is_equal(self):
- box = Solid.make_box(1, 1, 1)
- self.assertTrue(box.is_equal(box))
-
- def test_equal(self):
- box = Solid.make_box(1, 1, 1)
- self.assertEqual(box, box)
- self.assertEqual(box, AlwaysEqual())
-
- def test_not_equal(self):
- box = Solid.make_box(1, 1, 1)
- diff = Solid.make_box(1, 2, 3)
- self.assertNotEqual(box, diff)
- self.assertNotEqual(box, object())
-
- def test_tessellate(self):
- box123 = Solid.make_box(1, 2, 3)
- verts, triangles = box123.tessellate(1e-6)
- self.assertEqual(len(verts), 24)
- self.assertEqual(len(triangles), 12)
-
- def test_transformed(self):
- """Validate that transformed works the same as changing location"""
- rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360))
- offset = (uniform(0, 50), uniform(0, 50), uniform(0, 50))
- shape = Solid.make_box(1, 1, 1).transformed(rotation, offset)
- predicted_location = Location(offset) * Rotation(*rotation)
- located_shape = Solid.make_box(1, 1, 1).locate(predicted_location)
- intersect = shape.intersect(located_shape)
- self.assertAlmostEqual(intersect.volume, 1, 5)
-
- def test_position_and_orientation(self):
- box = Solid.make_box(1, 1, 1).locate(Location((1, 2, 3), (10, 20, 30)))
- self.assertAlmostEqual(box.position, (1, 2, 3), 5)
- self.assertAlmostEqual(box.orientation, (10, 20, 30), 5)
-
- def test_distance_to_with_closest_points(self):
- s0 = Solid.make_sphere(1).locate(Location((0, 2.1, 0)))
- s1 = Solid.make_sphere(1)
- distance, pnt0, pnt1 = s0.distance_to_with_closest_points(s1)
- self.assertAlmostEqual(distance, 0.1, 5)
- self.assertAlmostEqual(pnt0, (0, 1.1, 0), 5)
- self.assertAlmostEqual(pnt1, (0, 1, 0), 5)
-
- def test_closest_points(self):
- c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
- c1 = Edge.make_circle(1)
- closest = c0.closest_points(c1)
- self.assertAlmostEqual(closest[0], c0.position_at(0.75).to_tuple(), 5)
- self.assertAlmostEqual(closest[1], c1.position_at(0.25).to_tuple(), 5)
-
- def test_distance_to(self):
- c0 = Edge.make_circle(1).locate(Location((0, 2.1, 0)))
- c1 = Edge.make_circle(1)
- distance = c0.distance_to(c1)
- self.assertAlmostEqual(distance, 0.1, 5)
-
- def test_intersection(self):
- box = Solid.make_box(1, 1, 1)
- intersections = (
- box.intersect(Axis((0.5, 0.5, 4), (0, 0, -1))).vertices().sort_by(Axis.Z)
- )
- self.assertAlmostEqual(Vector(intersections[0]), (0.5, 0.5, 0), 5)
- self.assertAlmostEqual(Vector(intersections[1]), (0.5, 0.5, 1), 5)
-
- def test_clean_error(self):
- """Note that this test is here to alert build123d to changes in bad OCCT clean behavior
- with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this.
- """
- sphere = Solid.make_sphere(1)
- divider = Solid.make_box(0.1, 3, 3, Plane(origin=(-0.05, -1.5, -1.5)))
- positive_half, negative_half = [s.clean() for s in sphere.cut(divider).solids()]
- self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1)
-
- def test_clean_empty(self):
- obj = Solid()
- self.assertIs(obj, obj.clean())
-
- def test_relocate(self):
- box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5)))
- cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0)))
-
- box_with_hole = box.cut(cylinder)
- box_with_hole.relocate(box.location)
-
- self.assertEqual(box.location, box_with_hole.location)
-
- bbox1 = box.bounding_box()
- bbox2 = box_with_hole.bounding_box()
- self.assertAlmostEqual(bbox1.min, bbox2.min, 5)
- self.assertAlmostEqual(bbox1.max, bbox2.max, 5)
-
- def test_project_to_viewport(self):
- # Basic test
- box = Solid.make_box(10, 10, 10)
- visible, hidden = box.project_to_viewport((-20, 20, 20))
- self.assertEqual(len(visible), 9)
- self.assertEqual(len(hidden), 3)
-
- # Contour edges
- cyl = Solid.make_cylinder(2, 10)
- visible, hidden = cyl.project_to_viewport((-20, 20, 20))
- # Note that some edges are broken into two
- self.assertEqual(len(visible), 6)
- self.assertEqual(len(hidden), 2)
-
- # Hidden contour edges
- hole = box - cyl
- visible, hidden = hole.project_to_viewport((-20, 20, 20))
- self.assertEqual(len(visible), 13)
- self.assertEqual(len(hidden), 6)
-
- # Outline edges
- sphere = Solid.make_sphere(5)
- visible, hidden = sphere.project_to_viewport((-20, 20, 20))
- self.assertEqual(len(visible), 1)
- self.assertEqual(len(hidden), 0)
-
- def test_vertex(self):
- v = Edge.make_circle(1).vertex()
- self.assertTrue(isinstance(v, Vertex))
- with self.assertWarns(UserWarning):
- Wire.make_rect(1, 1).vertex()
-
- def test_edge(self):
- e = Edge.make_circle(1).edge()
- self.assertTrue(isinstance(e, Edge))
- with self.assertWarns(UserWarning):
- Wire.make_rect(1, 1).edge()
-
- def test_wire(self):
- w = Wire.make_circle(1).wire()
- self.assertTrue(isinstance(w, Wire))
- with self.assertWarns(UserWarning):
- Solid.make_box(1, 1, 1).wire()
-
- def test_compound(self):
- c = Compound.make_text("hello", 10)
- self.assertTrue(isinstance(c, Compound))
- c2 = Compound.make_text("world", 10)
- with self.assertWarns(UserWarning):
- Compound(children=[c, c2]).compound()
-
- def test_face(self):
- f = Face.make_rect(1, 1)
- self.assertTrue(isinstance(f, Face))
- with self.assertWarns(UserWarning):
- Solid.make_box(1, 1, 1).face()
-
- def test_shell(self):
- s = Solid.make_sphere(1).shell()
- self.assertTrue(isinstance(s, Shell))
- with self.assertWarns(UserWarning):
- extrude(Compound.make_text("two", 10), amount=5).shell()
-
- def test_solid(self):
- s = Solid.make_sphere(1).solid()
- self.assertTrue(isinstance(s, Solid))
- with self.assertWarns(UserWarning):
- Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
-
- def test_manifold(self):
- self.assertTrue(Solid.make_box(1, 1, 1).is_manifold)
- self.assertTrue(Solid.make_box(1, 1, 1).shell().is_manifold)
- self.assertFalse(
- Solid.make_box(1, 1, 1)
- .shell()
- .cut(Solid.make_box(0.5, 0.5, 0.5))
- .is_manifold
- )
- self.assertTrue(
- Compound(
- children=[Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)]
- ).is_manifold
- )
-
- def test_inherit_color(self):
- # Create some objects and assign colors to them
- b = Box(1, 1, 1).locate(Pos(2, 2, 0))
- b.color = Color("blue") # Blue
- c = Cylinder(1, 1).locate(Pos(-2, 2, 0))
- a = Compound(children=[b, c])
- a.color = Color(0, 1, 0)
- # Check that assigned colors stay and iheritance works
- np.testing.assert_allclose(tuple(a.color), (0, 1, 0, 1), 1e-5)
- np.testing.assert_allclose(tuple(b.color), (0, 0, 1, 1), 1e-5)
-
- def test_ocp_section(self):
- # Vertex
- verts, edges = Vertex(1, 2, 0)._ocp_section(Vertex(1, 2, 0))
- self.assertListEqual(verts, []) # ?
- self.assertListEqual(edges, [])
-
- verts, edges = Vertex(1, 2, 0)._ocp_section(Edge.make_line((0, 0), (2, 4)))
- self.assertListEqual(verts, []) # ?
- self.assertListEqual(edges, [])
-
- verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_rect(5, 5))
- np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5)
- self.assertListEqual(edges, [])
-
- verts, edges = Vertex(1, 2, 0)._ocp_section(Face.make_plane(Plane.XY))
- np.testing.assert_allclose(tuple(verts[0]), (1, 2, 0), 1e-5)
- self.assertListEqual(edges, [])
-
- # spline = Spline((-10, 10, -10), (-10, -5, -5), (20, 0, 5))
- # cylinder = Pos(Z=-10) * extrude(Circle(5), 20)
- # cylinder2 = (Rot((0, 90, 0)) * cylinder).face()
- # pln = Plane.XY
- # box1 = Box(10, 10, 10, align=(Align.CENTER, Align.CENTER, Align.MIN))
- # box2 = Pos(Z=-10) * box1
-
- # # vertices, edges = ocp_section(spline, Face.make_rect(1e6, 1e6, pln))
- # vertices1, edges1 = spline.ocp_section(Face.make_plane(pln))
- # print(vertices1, edges1)
-
- # vertices2, edges2 = cylinder.ocp_section(Face.make_plane(pln))
- # print(vertices2, edges2)
-
- # vertices3, edges3 = cylinder2.ocp_section(Face.make_plane(pln))
- # print(vertices3, edges3)
-
- # # vertices4, edges4 = cylinder2.ocp_section(cylinder)
-
- # vertices5, edges5 = box1.ocp_section(Face.make_plane(pln))
- # print(vertices5, edges5)
-
- # vertices6, edges6 = box1.ocp_section(box2.faces().sort_by(Axis.Z)[-1])
-
- def test_copy_attributes_to(self):
- box = Box(1, 1, 1)
- box2 = Box(10, 10, 10)
- box.label = "box"
- box.color = Color("Red")
- box.children = [Box(1, 1, 1), Box(2, 2, 2)]
- box.topo_parent = box2
-
- blank = Compound()
- box.copy_attributes_to(blank)
- self.assertEqual(blank.label, "box")
- self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.color, Color("Red"))))
- self.assertTrue(all(c1 == c2 for c1, c2 in zip(blank.children, box.children)))
- self.assertEqual(blank.topo_parent, box2)
-
- def test_empty_shape(self):
- empty = Solid()
- box = Solid.make_box(1, 1, 1)
- self.assertIsNone(empty.location)
- self.assertIsNone(empty.position)
- self.assertIsNone(empty.orientation)
- self.assertFalse(empty.is_manifold)
- with self.assertRaises(ValueError):
- empty.geom_type
- self.assertIs(empty, empty.fix())
- self.assertEqual(hash(empty), 0)
- self.assertFalse(empty.is_same(Solid()))
- self.assertFalse(empty.is_equal(Solid()))
- self.assertTrue(empty.is_valid())
- empty_bbox = empty.bounding_box()
- self.assertEqual(tuple(empty_bbox.size), (0, 0, 0))
- self.assertIs(empty, empty.mirror(Plane.XY))
- self.assertEqual(Shape.compute_mass(empty), 0)
- self.assertEqual(empty.entities("Face"), [])
- self.assertEqual(empty.area, 0)
- self.assertIs(empty, empty.rotate(Axis.Z, 90))
- translate_matrix = [
- [1.0, 0.0, 0.0, 1.0],
- [0.0, 1.0, 0.0, 2.0],
- [0.0, 0.0, 1.0, 3.0],
- [0.0, 0.0, 0.0, 1.0],
- ]
- self.assertIs(empty, empty.transform_shape(Matrix(translate_matrix)))
- self.assertIs(empty, empty.transform_geometry(Matrix(translate_matrix)))
- with self.assertRaises(ValueError):
- empty.locate(Location())
- empty_loc = Location()
- empty_loc.wrapped = None
- with self.assertRaises(ValueError):
- box.locate(empty_loc)
- with self.assertRaises(ValueError):
- empty.located(Location())
- with self.assertRaises(ValueError):
- box.located(empty_loc)
- with self.assertRaises(ValueError):
- empty.move(Location())
- with self.assertRaises(ValueError):
- box.move(empty_loc)
- with self.assertRaises(ValueError):
- empty.moved(Location())
- with self.assertRaises(ValueError):
- box.moved(empty_loc)
- with self.assertRaises(ValueError):
- empty.relocate(Location())
- with self.assertRaises(ValueError):
- box.relocate(empty_loc)
- with self.assertRaises(ValueError):
- empty.distance_to(Vector(1, 1, 1))
- with self.assertRaises(ValueError):
- empty.distance_to_with_closest_points(Vector(1, 1, 1))
- with self.assertRaises(ValueError):
- empty.distance_to(Vector(1, 1, 1))
- with self.assertRaises(ValueError):
- box.intersect(empty_loc)
- self.assertEqual(empty._ocp_section(Vertex(1, 1, 1)), ([], []))
- self.assertEqual(empty.faces_intersected_by_axis(Axis.Z), ShapeList())
- with self.assertRaises(ValueError):
- empty.split_by_perimeter(Circle(1).wire())
- with self.assertRaises(ValueError):
- empty.distance(Vertex(1, 1, 1))
- with self.assertRaises(ValueError):
- list(empty.distances(Vertex(0, 0, 0), Vertex(1, 1, 1)))
- with self.assertRaises(ValueError):
- list(box.distances(empty, Vertex(1, 1, 1)))
- with self.assertRaises(ValueError):
- empty.mesh(0.001)
- with self.assertRaises(ValueError):
- empty.tessellate(0.001)
- with self.assertRaises(ValueError):
- empty.to_splines()
- empty_axis = Axis((0, 0, 0), (1, 0, 0))
- empty_axis.wrapped = None
- with self.assertRaises(ValueError):
- box.vertices().group_by(empty_axis)
- empty_wire = Wire()
- with self.assertRaises(ValueError):
- box.vertices().group_by(empty_wire)
- with self.assertRaises(ValueError):
- box.vertices().sort_by(empty_axis)
- with self.assertRaises(ValueError):
- box.vertices().sort_by(empty_wire)
-
- def test_empty_selectors(self):
- self.assertEqual(Vertex(1, 1, 1).edges(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).wires(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).faces(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList())
- self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList())
- self.assertIsNone(Vertex(1, 1, 1).edge())
- self.assertIsNone(Vertex(1, 1, 1).wire())
- self.assertIsNone(Vertex(1, 1, 1).face())
- self.assertIsNone(Vertex(1, 1, 1).shell())
- self.assertIsNone(Vertex(1, 1, 1).solid())
- self.assertIsNone(Vertex(1, 1, 1).compound())
-
-
-class TestShapeList(unittest.TestCase):
- """Test ShapeList functionality"""
-
- def assertDunderStrEqual(self, actual: str, expected_lines: list[str]):
- actual_lines = actual.splitlines()
- self.assertEqual(len(actual_lines), len(expected_lines))
- for actual_line, expected_line in zip(actual_lines, expected_lines):
- start, end = re.split(r"at 0x[0-9a-f]+", expected_line, 2, re.I)
- self.assertTrue(actual_line.startswith(start))
- self.assertTrue(actual_line.endswith(end))
-
- def assertDunderReprEqual(self, actual: str, expected: str):
- splitter = r"at 0x[0-9a-f]+"
- actual_split_list = re.split(splitter, actual, 0, re.I)
- expected_split_list = re.split(splitter, expected, 0, re.I)
- for actual_split, expected_split in zip(actual_split_list, expected_split_list):
- self.assertEqual(actual_split, expected_split)
-
- def test_sort_by(self):
- faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA
- self.assertAlmostEqual(faces[-1].area, 2, 5)
-
- def test_filter_by_geomtype(self):
- non_planar_faces = (
- Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True)
- )
- self.assertEqual(len(non_planar_faces), 1)
- self.assertAlmostEqual(non_planar_faces[0].area, 2 * math.pi, 5)
-
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).faces().filter_by("True")
-
- def test_filter_by_axis(self):
- box = Solid.make_box(1, 1, 1)
- self.assertEqual(len(box.faces().filter_by(Axis.X)), 2)
- self.assertEqual(len(box.edges().filter_by(Axis.X)), 4)
- self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0)
-
- def test_filter_by_callable_predicate(self):
- boxes = [Solid.make_box(1, 1, 1) for _ in range(3)]
- boxes[0].label = "A"
- boxes[1].label = "A"
- boxes[2].label = "B"
- shapelist = ShapeList(boxes)
-
- self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2)
- self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1)
-
- def test_first_last(self):
- vertices = (
- Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1)))
- )
- self.assertAlmostEqual(Vector(vertices.last), (1, 1, 1), 5)
- self.assertAlmostEqual(Vector(vertices.first), (0, 0, 0), 5)
-
- def test_group_by(self):
- vertices = Solid.make_box(1, 1, 1).vertices().group_by(Axis.Z)
- self.assertEqual(len(vertices[0]), 4)
-
- edges = Solid.make_box(1, 1, 1).edges().group_by(SortBy.LENGTH)
- self.assertEqual(len(edges[0]), 12)
-
- edges = (
- Solid.make_cone(2, 1, 2)
- .edges()
- .filter_by(GeomType.CIRCLE)
- .group_by(SortBy.RADIUS)
- )
- self.assertEqual(len(edges[0]), 1)
-
- edges = (Solid.make_cone(2, 1, 2).edges() | GeomType.CIRCLE) << SortBy.RADIUS
- self.assertAlmostEqual(edges[0].length, 2 * math.pi, 5)
-
- vertices = Solid.make_box(1, 1, 1).vertices().group_by(SortBy.DISTANCE)
- self.assertAlmostEqual(Vector(vertices[-1][0]), (1, 1, 1), 5)
-
- box = Solid.make_box(1, 1, 2)
- self.assertEqual(len(box.faces().group_by(SortBy.AREA)[0]), 2)
- self.assertEqual(len(box.faces().group_by(SortBy.AREA)[1]), 4)
-
- line = Edge.make_line((0, 0, 0), (1, 1, 2))
- vertices_by_line = box.vertices().group_by(line)
- self.assertEqual(len(vertices_by_line[0]), 1)
- self.assertEqual(len(vertices_by_line[1]), 2)
- self.assertEqual(len(vertices_by_line[2]), 1)
- self.assertEqual(len(vertices_by_line[3]), 1)
- self.assertEqual(len(vertices_by_line[4]), 2)
- self.assertEqual(len(vertices_by_line[5]), 1)
- self.assertAlmostEqual(Vector(vertices_by_line[0][0]), (0, 0, 0), 5)
- self.assertAlmostEqual(Vector(vertices_by_line[-1][0]), (1, 1, 2), 5)
-
- with BuildPart() as boxes:
- with GridLocations(10, 10, 3, 3):
- Box(1, 1, 1)
- with PolarLocations(100, 10):
- Box(1, 1, 2)
- self.assertEqual(len(boxes.solids().group_by(SortBy.VOLUME)[-1]), 10)
- self.assertEqual(len((boxes.solids()) << SortBy.VOLUME), 9)
-
- with self.assertRaises(ValueError):
- boxes.solids().group_by("AREA")
-
- def test_group_by_callable_predicate(self):
- boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
- boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
- for box in boxesA:
- box.label = "A"
- for box in boxesB:
- box.label = "B"
- boxNoLabel = Solid.make_box(1, 1, 1)
-
- shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
- result = shapelist.group_by(lambda shape: shape.label)
-
- self.assertEqual([len(group) for group in result], [1, 3, 2])
-
- def test_group_by_retrieve_groups(self):
- boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
- boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
- for box in boxesA:
- box.label = "A"
- for box in boxesB:
- box.label = "B"
- boxNoLabel = Solid.make_box(1, 1, 1)
-
- shapelist = ShapeList(boxesA + boxesB + [boxNoLabel])
- result = shapelist.group_by(lambda shape: shape.label)
-
- self.assertEqual(len(result.group("")), 1)
- self.assertEqual(len(result.group("A")), 3)
- self.assertEqual(len(result.group("B")), 2)
- self.assertEqual(result.group(""), result[0])
- self.assertEqual(result.group("A"), result[1])
- self.assertEqual(result.group("B"), result[2])
- self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0]))
- self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0]))
- with self.assertRaises(KeyError):
- result.group("C")
-
- def test_group_by_str_repr(self):
- nonagon = RegularPolygon(5, 9)
-
- expected = [
- "[[],",
- " [,",
- " ],",
- " [,",
- " ],",
- " [,",
- " ],",
- " [,",
- " ]]",
- ]
- self.assertDunderStrEqual(str(nonagon.edges().group_by(Axis.X)), expected)
-
- expected_repr = (
- "[[],"
- " [,"
- " ],"
- " [,"
- " ],"
- " [,"
- " ],"
- " [,"
- " ]]"
- )
- self.assertDunderReprEqual(
- repr(nonagon.edges().group_by(Axis.X)), expected_repr
- )
-
- f = io.StringIO()
- p = pretty.PrettyPrinter(f)
- nonagon.edges().group_by(Axis.X)._repr_pretty_(p, cycle=True)
- self.assertEqual(f.getvalue(), "(...)")
-
- def test_distance(self):
- with BuildPart() as box:
- Box(1, 2, 3)
- obj = (-0.2, 0.1, 0.5)
- edges = box.edges().sort_by_distance(obj)
- distances = [Vertex(*obj).distance_to(edge) for edge in edges]
- self.assertTrue(
- all([distances[i] >= distances[i - 1] for i in range(1, len(edges))])
- )
-
- def test_distance_reverse(self):
- with BuildPart() as box:
- Box(1, 2, 3)
- obj = (-0.2, 0.1, 0.5)
- edges = box.edges().sort_by_distance(obj, reverse=True)
- distances = [Vertex(*obj).distance_to(edge) for edge in edges]
- self.assertTrue(
- all([distances[i] <= distances[i - 1] for i in range(1, len(edges))])
- )
-
- def test_distance_equal(self):
- with BuildPart() as box:
- Box(1, 1, 1)
- self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12)
-
- def test_vertices(self):
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- self.assertEqual(len(sl.vertices()), 8)
-
- def test_vertex(self):
- sl = ShapeList([Edge.make_circle(1)])
- np.testing.assert_allclose(sl.vertex().to_tuple(), (1, 0, 0), 1e-5)
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- with self.assertWarns(UserWarning):
- sl.vertex()
- self.assertEqual(len(Edge().vertices()), 0)
-
- def test_edges(self):
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- self.assertEqual(len(sl.edges()), 8)
- self.assertEqual(len(Edge().edges()), 0)
-
- def test_edge(self):
- sl = ShapeList([Edge.make_circle(1)])
- self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5)
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- with self.assertWarns(UserWarning):
- sl.edge()
-
- def test_wires(self):
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- self.assertEqual(len(sl.wires()), 2)
- self.assertEqual(len(Wire().wires()), 0)
-
- def test_wire(self):
- sl = ShapeList([Wire.make_circle(1)])
- self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5)
- sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
- with self.assertWarns(UserWarning):
- sl.wire()
-
- def test_faces(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- self.assertEqual(len(sl.faces()), 9)
- self.assertEqual(len(Face().faces()), 0)
-
- def test_face(self):
- sl = ShapeList(
- [Vertex(1, 1, 1), Edge.make_line((0, 0), (1, 1)), Face.make_rect(2, 1)]
- )
- self.assertAlmostEqual(sl.face().area, 2 * 1, 5)
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.face()
-
- def test_shells(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- self.assertEqual(len(sl.shells()), 2)
- self.assertEqual(len(Shell().shells()), 0)
-
- def test_shell(self):
- sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
- self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5)
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.shell()
-
- def test_solids(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- self.assertEqual(len(sl.solids()), 2)
- self.assertEqual(len(Solid().solids()), 0)
-
- def test_solid(self):
- sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.solid()
- sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)])
- self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5)
-
- def test_compounds(self):
- sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
- self.assertEqual(len(sl.compounds()), 2)
- self.assertEqual(len(Compound().compounds()), 0)
-
- def test_compound(self):
- sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
- with self.assertWarns(UserWarning):
- sl.compound()
- sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)])
- self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5)
-
- def test_equal(self):
- box = Box(1, 1, 1)
- cyl = Cylinder(1, 1)
- sl = ShapeList([box, cyl])
- same = ShapeList([cyl, box])
- self.assertEqual(sl, same)
- self.assertEqual(sl, AlwaysEqual())
-
- def test_not_equal(self):
- sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
- diff = ShapeList([Box(1, 1, 1), Box(1, 2, 3)])
- self.assertNotEqual(sl, diff)
- self.assertNotEqual(sl, object())
-
- def test_center(self):
- self.assertEqual(tuple(ShapeList().center()), (0, 0, 0))
- self.assertEqual(
- tuple(ShapeList(Vertex(i, 0, 0) for i in range(3)).center()), (1, 0, 0)
- )
-
-
-class TestShells(unittest.TestCase):
- def test_shell_init(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- self.assertTrue(box_shell.is_valid())
-
- def test_center(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- self.assertAlmostEqual(box_shell.center(), (0.5, 0.5, 0.5), 5)
-
- def test_manifold_shell_volume(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- self.assertAlmostEqual(box_shell.volume, 1, 5)
-
- def test_nonmanifold_shell_volume(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- nm_shell = Shell(box_faces)
- nm_shell -= nm_shell.faces()[0]
- self.assertAlmostEqual(nm_shell.volume, 0, 5)
-
- def test_constructor(self):
- with self.assertRaises(TypeError):
- Shell(foo="bar")
-
- x_section = Rot(90) * Spline((0, -5), (-3, -2), (-2, 0), (-3, 2), (0, 5))
- surface = sweep(x_section, Circle(5).wire())
- single_face = Shell(surface.face())
- self.assertTrue(single_face.is_valid())
- single_face = Shell(surface.faces())
- self.assertTrue(single_face.is_valid())
-
- def test_sweep(self):
- path_c1 = JernArc((0, 0), (-1, 0), 1, 180)
- path_e = path_c1.edge()
- path_c2 = JernArc((0, 0), (-1, 0), 1, 180) + JernArc((0, 0), (1, 0), 2, -90)
- path_w = path_c2.wire()
- section_e = Circle(0.5).edge()
- section_c2 = Polyline((0, 0), (0.1, 0), (0.2, 0.1))
- section_w = section_c2.wire()
-
- sweep_e_w = Shell.sweep((path_w ^ 0) * section_e, path_w)
- sweep_w_e = Shell.sweep((path_e ^ 0) * section_w, path_e)
- sweep_w_w = Shell.sweep((path_w ^ 0) * section_w, path_w)
- sweep_c2_c1 = Shell.sweep((path_c1 ^ 0) * section_c2, path_c1)
- sweep_c2_c2 = Shell.sweep((path_c2 ^ 0) * section_c2, path_c2)
-
- self.assertEqual(len(sweep_e_w.faces()), 2)
- self.assertEqual(len(sweep_w_e.faces()), 2)
- self.assertEqual(len(sweep_c2_c1.faces()), 2)
- self.assertEqual(len(sweep_w_w.faces()), 3) # 3 with clean, 4 without
- self.assertEqual(len(sweep_c2_c2.faces()), 3) # 3 with clean, 4 without
-
- def test_make_loft(self):
- r = 3
- h = 2
- loft = Shell.make_loft(
- [Wire.make_circle(r, Plane((0, 0, h))), Wire.make_circle(r)]
- )
- self.assertEqual(loft.volume, 0, "A shell has no volume")
- cylinder_area = 2 * math.pi * r * h
- self.assertAlmostEqual(loft.area, cylinder_area)
-
- def test_thicken(self):
- rect = Wire.make_rect(10, 5)
- shell: Shell = Shell.extrude(rect, Vector(0, 0, 3))
- thick = Solid.thicken(shell, 1)
-
- self.assertEqual(isinstance(thick, Solid), True)
- inner_vol = 3 * 10 * 5
- outer_vol = 3 * 12 * 7
- self.assertAlmostEqual(thick.volume, outer_vol - inner_vol)
-
-
-class TestSolid(unittest.TestCase):
- def test_make_solid(self):
- box_faces = Solid.make_box(1, 1, 1).faces()
- box_shell = Shell(box_faces)
- box = Solid(box_shell)
- self.assertAlmostEqual(box.area, 6, 5)
- self.assertAlmostEqual(box.volume, 1, 5)
- self.assertTrue(box.is_valid())
-
- def test_extrude(self):
- v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1))
- self.assertAlmostEqual(v.length, 1, 5)
-
- e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1))
- self.assertAlmostEqual(e.area, 1, 5)
-
- w = Shell.extrude(
- Wire([Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))]),
- (0, 0, 1),
- )
- self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5)
-
- f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1))
- self.assertAlmostEqual(f.volume, 1, 5)
-
- s = Compound.extrude(
- Shell(
- Solid.make_box(1, 1, 1)
- .locate(Location((-2, 1, 0)))
- .faces()
- .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:]
- ),
- (0.1, 0.1, 0.1),
- )
- self.assertAlmostEqual(s.volume, 0.2, 5)
-
- with self.assertRaises(ValueError):
- Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1))
-
- def test_extrude_taper(self):
- a = 1
- rect = Face.make_rect(a, a)
- flipped = -rect
- for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]:
- for taper in [10, -10]:
- offset_amt = -direction.length * math.tan(math.radians(taper))
- for face in [rect, flipped]:
- with self.subTest(
- f"{direction=}, {taper=}, flipped={face==flipped}"
- ):
- taper_solid = Solid.extrude_taper(face, direction, taper)
- # V = 1/3 × h × (a² + b² + ab)
- h = Vector(direction).length
- b = a + 2 * offset_amt
- v = h * (a**2 + b**2 + a * b) / 3
- self.assertAlmostEqual(taper_solid.volume, v, 5)
- bbox = taper_solid.bounding_box()
- size = max(1, b) / 2
- if direction.Z > 0:
- self.assertAlmostEqual(bbox.min, (-size, -size, 0), 1)
- self.assertAlmostEqual(bbox.max, (size, size, h), 1)
- else:
- self.assertAlmostEqual(bbox.min, (-size, -size, -h), 1)
- self.assertAlmostEqual(bbox.max, (size, size, 0), 1)
-
- def test_extrude_taper_with_hole(self):
- rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
- direction = Vector(0, 0, 0.5)
- taper = 10
- taper_solid = Solid.extrude_taper(rect_hole, direction, taper)
- offset_amt = -direction.length * math.tan(math.radians(taper))
- hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
- self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5)
-
- def test_extrude_taper_with_hole_flipped(self):
- rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)])
- direction = Vector(0, 0, 1)
- taper = 10
- taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True)
- taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False)
- hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
- hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1]
- self.assertGreater(hole_t.radius, hole_f.radius)
-
- def test_extrude_taper_oblique(self):
- rect = Face.make_rect(2, 1)
- rect_hole = rect.make_holes([Wire.make_circle(0.25)])
- o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77))
- taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5)
- taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5)
- self.assertAlmostEqual(taper0.volume, taper1.volume, 5)
-
- def test_extrude_linear_with_rotation(self):
- # Face
- base = Face.make_rect(1, 1)
- twist = Solid.extrude_linear_with_rotation(
- base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
- )
- self.assertAlmostEqual(twist.volume, 1, 5)
- top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
- bottom = twist.faces().sort_by(Axis.Z)[0]
- self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
- # Wire
- base = Wire.make_rect(1, 1)
- twist = Solid.extrude_linear_with_rotation(
- base, center=(0, 0, 0), normal=(0, 0, 1), angle=45
- )
- self.assertAlmostEqual(twist.volume, 1, 5)
- top = twist.faces().sort_by(Axis.Z)[-1].rotate(Axis.Z, 45)
- bottom = twist.faces().sort_by(Axis.Z)[0]
- self.assertAlmostEqual(top.translate((0, 0, -1)).intersect(bottom).area, 1, 5)
-
- def test_make_loft(self):
- loft = Solid.make_loft(
- [Wire.make_rect(2, 2), Wire.make_circle(1, Plane((0, 0, 1)))]
- )
- self.assertAlmostEqual(loft.volume, (4 + math.pi) / 2, 1)
-
- with self.assertRaises(ValueError):
- Solid.make_loft([Wire.make_rect(1, 1)])
-
- def test_make_loft_with_vertices(self):
- loft = Solid.make_loft(
- [Vertex(0, 0, -1), Wire.make_rect(1, 1.5), Vertex(0, 0, 1)], True
- )
- self.assertAlmostEqual(loft.volume, 1, 5)
-
- with self.assertRaises(ValueError):
- Solid.make_loft(
- [Wire.make_rect(1, 1), Vertex(0, 0, 1), Wire.make_rect(1, 1)]
- )
-
- with self.assertRaises(ValueError):
- Solid.make_loft([Vertex(0, 0, 1), Vertex(0, 0, 2)])
-
- with self.assertRaises(ValueError):
- Solid.make_loft(
- [
- Vertex(0, 0, 1),
- Wire.make_rect(1, 1),
- Vertex(0, 0, 2),
- Vertex(0, 0, 3),
- ]
- )
-
- def test_extrude_until(self):
- square = Face.make_rect(1, 1)
- box = Solid.make_box(4, 4, 1, Plane((-2, -2, 3)))
- extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST)
- self.assertAlmostEqual(extrusion.volume, 4, 5)
-
- square = Face.make_rect(1, 1)
- box = Solid.make_box(4, 4, 1, Plane((-2, -2, -3)))
- extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.PREVIOUS)
- self.assertAlmostEqual(extrusion.volume, 2, 5)
-
- def test_sweep(self):
- path = Edge.make_spline([(0, 0), (3, 5), (7, -2)])
- section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0))
- area = Face(section).area
- swept = Solid.sweep(section, path)
- self.assertAlmostEqual(swept.volume, path.length * area, 0)
-
- def test_hollow_sweep(self):
- path = Edge.make_line((0, 0, 0), (0, 0, 5))
- section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0]
- swept = Solid.sweep(section, path)
- self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5)
-
- def test_sweep_multi(self):
- f0 = Face.make_rect(1, 1)
- f1 = Pos(X=10) * Circle(1).face()
- path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (0, 0, -1)))
- binormal = Edge.make_line((0, 1), (10, 1))
- swept = Solid.sweep_multi([f0, f1], path, is_frenet=True, binormal=binormal)
- self.assertAlmostEqual(swept.volume, 23.78, 2)
-
- path = Spline((0, 0), (10, 0), tangents=((0, 0, 1), (1, 0, 0)))
- swept = Solid.sweep_multi(
- [f0, f1], path, is_frenet=True, binormal=Vector(5, 0, 1)
- )
- self.assertAlmostEqual(swept.volume, 20.75, 2)
-
- def test_constructor(self):
- with self.assertRaises(TypeError):
- Solid(foo="bar")
-
- def test_offset_3d(self):
- with self.assertRaises(ValueError):
- Solid.make_box(1, 1, 1).offset_3d(None, 0.1, kind=Kind.TANGENT)
-
- def test_revolve(self):
- r = Solid.revolve(
- Face.make_rect(1, 1, Plane((10, 0, 0))).wire(), 180, axis=Axis.Y
- )
- self.assertEqual(len(r.faces()), 6)
-
-
-class TestSkipClean(unittest.TestCase):
- def setUp(self):
- # Ensure the class variable is in its default state before each test
- SkipClean.clean = True
-
- def test_context_manager_sets_clean_false(self):
- # Verify `clean` is initially True
- self.assertTrue(SkipClean.clean)
-
- # Use the context manager
- with SkipClean():
- # Within the context, `clean` should be False
- self.assertFalse(SkipClean.clean)
-
- # After exiting the context, `clean` should revert to True
- self.assertTrue(SkipClean.clean)
-
- def test_exception_handling_does_not_affect_clean(self):
- # Verify `clean` is initially True
- self.assertTrue(SkipClean.clean)
-
- # Use the context manager and raise an exception
- try:
- with SkipClean():
- self.assertFalse(SkipClean.clean)
- raise ValueError("Test exception")
- except ValueError:
- pass
-
- # Ensure `clean` is restored to True after an exception
- self.assertTrue(SkipClean.clean)
-
-
-class TestVector(unittest.TestCase):
- """Test the Vector methods"""
-
- def test_vector_constructors(self):
- v1 = Vector(1, 2, 3)
- v2 = Vector((1, 2, 3))
- v3 = Vector(gp_Vec(1, 2, 3))
- v4 = Vector([1, 2, 3])
- v5 = Vector(gp_XYZ(1, 2, 3))
- v5b = Vector(X=1, Y=2, Z=3)
- v5c = Vector(v=gp_XYZ(1, 2, 3))
-
- for v in [v1, v2, v3, v4, v5, v5b, v5c]:
- self.assertAlmostEqual(v, (1, 2, 3), 4)
-
- v6 = Vector((1, 2))
- v7 = Vector([1, 2])
- v8 = Vector(1, 2)
- v8b = Vector(X=1, Y=2)
-
- for v in [v6, v7, v8, v8b]:
- self.assertAlmostEqual(v, (1, 2, 0), 4)
-
- v9 = Vector()
- self.assertAlmostEqual(v9, (0, 0, 0), 4)
-
- v9.X = 1.0
- v9.Y = 2.0
- v9.Z = 3.0
- self.assertAlmostEqual(v9, (1, 2, 3), 4)
- self.assertAlmostEqual(Vector(1, 2, 3, 4), (1, 2, 3), 4)
-
- v10 = Vector(1)
- v11 = Vector((1,))
- v12 = Vector([1])
- v13 = Vector(X=1)
- for v in [v10, v11, v12, v13]:
- self.assertAlmostEqual(v, (1, 0, 0), 4)
-
- vertex = Vertex(0, 0, 0).moved(Pos(0, 0, 10))
- self.assertAlmostEqual(Vector(vertex), (0, 0, 10), 4)
-
- with self.assertRaises(TypeError):
- Vector("vector")
- with self.assertRaises(ValueError):
- Vector(x=1)
-
- def test_vector_rotate(self):
- """Validate vector rotate methods"""
- vector_x = Vector(1, 0, 1).rotate(Axis.X, 45)
- vector_y = Vector(1, 2, 1).rotate(Axis.Y, 45)
- vector_z = Vector(-1, -1, 3).rotate(Axis.Z, 45)
- self.assertAlmostEqual(vector_x, (1, -math.sqrt(2) / 2, math.sqrt(2) / 2), 7)
- self.assertAlmostEqual(vector_y, (math.sqrt(2), 2, 0), 7)
- self.assertAlmostEqual(vector_z, (0, -math.sqrt(2), 3), 7)
-
- def test_get_signed_angle(self):
- """Verify getSignedAngle calculations with and without a provided normal"""
- a = math.pi / 3
- v1 = Vector(1, 0, 0)
- v2 = Vector(math.cos(a), -math.sin(a), 0)
- d1 = v1.get_signed_angle(v2)
- d2 = v1.get_signed_angle(v2, Vector(0, 0, 1))
- self.assertAlmostEqual(d1, a * 180 / math.pi)
- self.assertAlmostEqual(d2, -a * 180 / math.pi)
-
- def test_center(self):
- v = Vector(1, 1, 1)
- self.assertAlmostEqual(v, v.center())
-
- def test_dot(self):
- v1 = Vector(2, 2, 2)
- v2 = Vector(1, -1, 1)
- self.assertEqual(2.0, v1.dot(v2))
-
- def test_vector_add(self):
- result = Vector(1, 2, 0) + Vector(0, 0, 3)
- self.assertAlmostEqual(result, (1.0, 2.0, 3.0), 3)
-
- def test_vector_operators(self):
- result = Vector(1, 1, 1) + Vector(2, 2, 2)
- self.assertEqual(Vector(3, 3, 3), result)
-
- result = Vector(1, 2, 3) - Vector(3, 2, 1)
- self.assertEqual(Vector(-2, 0, 2), result)
-
- result = Vector(1, 2, 3) * 2
- self.assertEqual(Vector(2, 4, 6), result)
-
- result = 3 * Vector(1, 2, 3)
- self.assertEqual(Vector(3, 6, 9), result)
-
- result = Vector(2, 4, 6) / 2
- self.assertEqual(Vector(1, 2, 3), result)
-
- self.assertEqual(Vector(-1, -1, -1), -Vector(1, 1, 1))
-
- self.assertEqual(0, abs(Vector(0, 0, 0)))
- self.assertEqual(1, abs(Vector(1, 0, 0)))
- self.assertEqual((1 + 4 + 9) ** 0.5, abs(Vector(1, 2, 3)))
-
- def test_vector_equals(self):
- a = Vector(1, 2, 3)
- b = Vector(1, 2, 3)
- c = Vector(1, 2, 3.000001)
- self.assertEqual(a, b)
- self.assertEqual(a, c)
- self.assertEqual(a, AlwaysEqual())
-
- def test_vector_not_equal(self):
- a = Vector(1, 2, 3)
- b = Vector(3, 2, 1)
- self.assertNotEqual(a, b)
- self.assertNotEqual(a, object())
-
- def test_vector_distance(self):
- """
- Test line distance from plane.
- """
- v = Vector(1, 2, 3)
-
- self.assertAlmostEqual(1, v.signed_distance_from_plane(Plane.YZ))
- self.assertAlmostEqual(2, v.signed_distance_from_plane(Plane.ZX))
- self.assertAlmostEqual(3, v.signed_distance_from_plane(Plane.XY))
- self.assertAlmostEqual(-1, v.signed_distance_from_plane(Plane.ZY))
- self.assertAlmostEqual(-2, v.signed_distance_from_plane(Plane.XZ))
- self.assertAlmostEqual(-3, v.signed_distance_from_plane(Plane.YX))
-
- self.assertAlmostEqual(1, v.distance_to_plane(Plane.YZ))
- self.assertAlmostEqual(2, v.distance_to_plane(Plane.ZX))
- self.assertAlmostEqual(3, v.distance_to_plane(Plane.XY))
- self.assertAlmostEqual(1, v.distance_to_plane(Plane.ZY))
- self.assertAlmostEqual(2, v.distance_to_plane(Plane.XZ))
- self.assertAlmostEqual(3, v.distance_to_plane(Plane.YX))
-
- def test_vector_project(self):
- """
- Test line projection and plane projection methods of Vector
- """
- decimal_places = 9
-
- z_dir = Vector(1, 2, 3)
- base = Vector(5, 7, 9)
- x_dir = Vector(1, 0, 0)
-
- # test passing Plane object
- point = Vector(10, 11, 12).project_to_plane(Plane(base, x_dir, z_dir))
- self.assertAlmostEqual(point, (59 / 7, 55 / 7, 51 / 7), decimal_places)
-
- # test line projection
- vec = Vector(10, 10, 10)
- line = Vector(3, 4, 5)
- angle = math.radians(vec.get_angle(line))
-
- vecLineProjection = vec.project_to_line(line)
-
- self.assertAlmostEqual(
- vecLineProjection.normalized(),
- line.normalized(),
- decimal_places,
- )
- self.assertAlmostEqual(
- vec.length * math.cos(angle), vecLineProjection.length, decimal_places
- )
-
- def test_vector_not_implemented(self):
- pass
-
- def test_vector_special_methods(self):
- self.assertEqual(repr(Vector(1, 2, 3)), "Vector(1, 2, 3)")
- self.assertEqual(str(Vector(1, 2, 3)), "Vector(1, 2, 3)")
- self.assertEqual(
- str(Vector(9.99999999999999, -23.649999999999995, -7.37188088351e-15)),
- "Vector(10, -23.65, 0)",
- )
-
- def test_vector_iter(self):
- self.assertEqual(sum([v for v in Vector(1, 2, 3)]), 6)
-
- def test_reverse(self):
- self.assertAlmostEqual(Vector(1, 2, 3).reverse(), (-1, -2, -3), 7)
-
- def test_copy(self):
- v2 = copy.copy(Vector(1, 2, 3))
- v3 = copy.deepcopy(Vector(1, 2, 3))
- self.assertAlmostEqual(v2, (1, 2, 3), 7)
- self.assertAlmostEqual(v3, (1, 2, 3), 7)
-
- def test_radd(self):
- vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9)]
- vector_sum = sum(vectors)
- self.assertAlmostEqual(vector_sum, (12, 15, 18), 5)
-
- def test_hash(self):
- vectors = [Vector(1, 2, 3), Vector(4, 5, 6), Vector(7, 8, 9), Vector(1, 2, 3)]
- unique_vectors = list(set(vectors))
- self.assertEqual(len(vectors), 4)
- self.assertEqual(len(unique_vectors), 3)
-
- def test_vector_transform(self):
- a = Vector(1, 2, 3)
- pxy = Plane.XY
- pxy_o1 = Plane.XY.offset(1)
- self.assertEqual(a.transform(pxy.forward_transform, is_direction=False), a)
- self.assertEqual(
- a.transform(pxy.forward_transform, is_direction=True), a.normalized()
- )
- self.assertEqual(
- a.transform(pxy_o1.forward_transform, is_direction=False), Vector(1, 2, 2)
- )
- self.assertEqual(
- a.transform(pxy_o1.forward_transform, is_direction=True), a.normalized()
- )
- self.assertEqual(
- a.transform(pxy_o1.reverse_transform, is_direction=False), Vector(1, 2, 4)
- )
- self.assertEqual(
- a.transform(pxy_o1.reverse_transform, is_direction=True), a.normalized()
- )
-
- def test_intersect(self):
- v1 = Vector(1, 2, 3)
- self.assertAlmostEqual(v1 & Vector(1, 2, 3), (1, 2, 3), 5)
- self.assertIsNone(v1 & Vector(0, 0, 0))
-
- self.assertAlmostEqual(v1 & Location((1, 2, 3)), (1, 2, 3), 5)
- self.assertIsNone(v1 & Location())
-
- self.assertAlmostEqual(v1 & Axis((1, 2, 3), (1, 0, 0)), (1, 2, 3), 5)
- self.assertIsNone(v1 & Axis.X)
-
- self.assertAlmostEqual(v1 & Plane((1, 2, 3)), (1, 2, 3), 5)
- self.assertIsNone(v1 & Plane.XY)
-
- self.assertAlmostEqual(
- Vector((v1 & Solid.make_box(2, 4, 5)).vertex()), (1, 2, 3), 5
- )
- self.assertIsNone(v1.intersect(Solid.make_box(0.5, 0.5, 0.5)))
- self.assertIsNone(
- Vertex(-10, -10, -10).intersect(Solid.make_box(0.5, 0.5, 0.5))
- )
-
-
-class TestVectorLike(unittest.TestCase):
- """Test typedef"""
-
- def test_axis_from_vertex(self):
- axis = Axis(Vertex(1, 2, 3), Vertex(0, 0, 1))
- self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
-
- def test_axis_from_vector(self):
- axis = Axis(Vector(1, 2, 3), Vector(0, 0, 1))
- self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
-
- def test_axis_from_tuple(self):
- axis = Axis((1, 2, 3), (0, 0, 1))
- self.assertAlmostEqual(axis.position, (1, 2, 3), 5)
- self.assertAlmostEqual(axis.direction, (0, 0, 1), 5)
-
-
-class TestVertex(unittest.TestCase):
- """Test the extensions to the cadquery Vertex class"""
-
- def test_basic_vertex(self):
- v = Vertex()
- self.assertEqual(0, v.X)
-
- v = Vertex(1, 1, 1)
- self.assertEqual(1, v.X)
- self.assertEqual(Vector, type(v.center()))
-
- self.assertAlmostEqual(Vector(Vertex(Vector(1, 2, 3))), (1, 2, 3), 7)
- self.assertAlmostEqual(Vector(Vertex((4, 5, 6))), (4, 5, 6), 7)
- self.assertAlmostEqual(Vector(Vertex((7,))), (7, 0, 0), 7)
- self.assertAlmostEqual(Vector(Vertex((8, 9))), (8, 9, 0), 7)
-
- def test_vertex_volume(self):
- v = Vertex(1, 1, 1)
- self.assertAlmostEqual(v.volume, 0, 5)
-
- def test_vertex_add(self):
- test_vertex = Vertex(0, 0, 0)
- self.assertAlmostEqual(Vector(test_vertex + (100, -40, 10)), (100, -40, 10), 7)
- self.assertAlmostEqual(
- Vector(test_vertex + Vector(100, -40, 10)), (100, -40, 10), 7
- )
- self.assertAlmostEqual(
- Vector(test_vertex + Vertex(100, -40, 10)),
- (100, -40, 10),
- 7,
- )
- with self.assertRaises(TypeError):
- test_vertex + [1, 2, 3]
-
- def test_vertex_sub(self):
- test_vertex = Vertex(0, 0, 0)
- self.assertAlmostEqual(Vector(test_vertex - (100, -40, 10)), (-100, 40, -10), 7)
- self.assertAlmostEqual(
- Vector(test_vertex - Vector(100, -40, 10)), (-100, 40, -10), 7
- )
- self.assertAlmostEqual(
- Vector(test_vertex - Vertex(100, -40, 10)),
- (-100, 40, -10),
- 7,
- )
- with self.assertRaises(TypeError):
- test_vertex - [1, 2, 3]
-
- def test_vertex_str(self):
- self.assertEqual(str(Vertex(0, 0, 0)), "Vertex(0.0, 0.0, 0.0)")
-
- def test_vertex_to_vector(self):
- self.assertIsInstance(Vector(Vertex(0, 0, 0)), Vector)
- self.assertAlmostEqual(Vector(Vertex(0, 0, 0)), (0.0, 0.0, 0.0), 7)
-
- def test_vertex_init_error(self):
- with self.assertRaises(TypeError):
- Vertex(Axis.Z)
- with self.assertRaises(ValueError):
- Vertex(x=1)
- with self.assertRaises(TypeError):
- Vertex((Axis.X, Axis.Y, Axis.Z))
-
- def test_no_intersect(self):
- with self.assertRaises(NotImplementedError):
- Vertex(1, 2, 3) & Vertex(5, 6, 7)
-
-
-class TestVTKPolyData(unittest.TestCase):
- def setUp(self):
- # Create a simple test object (e.g., a cylinder)
- self.object_under_test = Solid.make_cylinder(1, 2)
-
- def test_to_vtk_poly_data(self):
- # Generate VTK data
- vtk_data = self.object_under_test.to_vtk_poly_data(
- tolerance=0.1, angular_tolerance=0.2, normals=True
- )
-
- # Verify the result is of type vtkPolyData
- self.assertIsInstance(vtk_data, vtkPolyData)
-
- # Further verification can include:
- # - Checking the number of points, polygons, or cells
- self.assertGreater(
- vtk_data.GetNumberOfPoints(), 0, "VTK data should have points."
- )
- self.assertGreater(
- vtk_data.GetNumberOfCells(), 0, "VTK data should have cells."
- )
-
- # Optionally, compare the output with a known reference object
- # (if available) by exporting or analyzing the VTK data
- known_filter = vtkTriangleFilter()
- known_filter.SetInputData(vtk_data)
- known_filter.Update()
- known_output = known_filter.GetOutput()
-
- self.assertEqual(
- vtk_data.GetNumberOfPoints(),
- known_output.GetNumberOfPoints(),
- "Number of points in VTK data does not match the expected output.",
- )
- self.assertEqual(
- vtk_data.GetNumberOfCells(),
- known_output.GetNumberOfCells(),
- "Number of cells in VTK data does not match the expected output.",
- )
-
- def test_empty_shape(self):
- # Test handling of empty shape
- empty_object = Solid() # Create an empty object
- with self.assertRaises(ValueError) as context:
- empty_object.to_vtk_poly_data()
-
- self.assertEqual(str(context.exception), "Cannot convert an empty shape")
-
-
-class TestWire(unittest.TestCase):
- def test_ellipse_arc(self):
- full_ellipse = Wire.make_ellipse(2, 1)
- half_ellipse = Wire.make_ellipse(
- 2, 1, start_angle=0, end_angle=180, closed=True
- )
- self.assertAlmostEqual(full_ellipse.area / 2, half_ellipse.area, 5)
-
- def test_stitch(self):
- half_ellipse1 = Wire.make_ellipse(
- 2, 1, start_angle=0, end_angle=180, closed=False
- )
- half_ellipse2 = Wire.make_ellipse(
- 2, 1, start_angle=180, end_angle=360, closed=False
- )
- ellipse = half_ellipse1.stitch(half_ellipse2)
- self.assertEqual(len(ellipse.wires()), 1)
-
- def test_fillet_2d(self):
- square = Wire.make_rect(1, 1)
- squaroid = square.fillet_2d(0.1, square.vertices())
- self.assertAlmostEqual(
- squaroid.length, 4 * (1 - 2 * 0.1) + 2 * math.pi * 0.1, 5
- )
-
- def test_chamfer_2d(self):
- square = Wire.make_rect(1, 1)
- squaroid = square.chamfer_2d(0.1, 0.1, square.vertices())
- self.assertAlmostEqual(
- squaroid.length, 4 * (1 - 2 * 0.1 + 0.1 * math.sqrt(2)), 5
- )
-
- def test_chamfer_2d_edge(self):
- square = Wire.make_rect(1, 1)
- edge = square.edges().sort_by(Axis.Y)[0]
- vertex = edge.vertices().sort_by(Axis.X)[0]
- square = square.chamfer_2d(
- distance=0.1, distance2=0.2, vertices=[vertex], edge=edge
- )
- self.assertAlmostEqual(square.edges().sort_by(Axis.Y)[0].length, 0.9)
-
- def test_make_convex_hull(self):
- # overlapping_edges = [
- # Edge.make_circle(10, end_angle=60),
- # Edge.make_circle(10, start_angle=30, end_angle=90),
- # Edge.make_line((-10, 10), (10, -10)),
- # ]
- # with self.assertRaises(ValueError):
- # Wire.make_convex_hull(overlapping_edges)
-
- adjoining_edges = [
- Edge.make_circle(10, end_angle=45),
- Edge.make_circle(10, start_angle=315, end_angle=360),
- Edge.make_line((-10, 10), (-10, -10)),
- ]
- hull_wire = Wire.make_convex_hull(adjoining_edges)
- self.assertAlmostEqual(Face(hull_wire).area, 319.9612, 4)
-
- # def test_fix_degenerate_edges(self):
- # # Can't find a way to create one
- # edge0 = Edge.make_line((0, 0, 0), (1, 0, 0))
- # edge1 = Edge.make_line(edge0 @ 0, edge0 @ 0 + Vector(0, 1, 0))
- # edge1a = edge1.trim(0, 1e-7)
- # edge1b = edge1.trim(1e-7, 1.0)
- # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0))
- # wire = Wire([edge0, edge1a, edge1b, edge2])
- # fixed_wire = wire.fix_degenerate_edges(1e-6)
- # self.assertEqual(len(fixed_wire.edges()), 2)
-
- def test_trim(self):
- e0 = Edge.make_line((0, 0), (1, 0))
- e1 = Edge.make_line((2, 0), (1, 0))
- e2 = Edge.make_line((2, 0), (3, 0))
- w1 = Wire([e0, e1, e2])
- t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0)))
- self.assertAlmostEqual(t1.length, 2.1, 5)
-
- e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
- # Three edges are created 0->0.5->0.75->1.0
- o = e.offset_2d(10, side=Side.RIGHT, closed=False)
- t2 = o.trim(0.1, 0.9)
- self.assertAlmostEqual(t2.length, o.length * 0.8, 5)
-
- t3 = o.trim(0.5, 1.0)
- self.assertAlmostEqual(t3.length, o.length * 0.5, 5)
-
- t4 = o.trim(0.5, 0.75)
- self.assertAlmostEqual(t4.length, o.length * 0.25, 5)
-
- with self.assertRaises(ValueError):
- o.trim(0.75, 0.25)
- spline = Spline(
- (0, 0, 0),
- (0, 10, 0),
- tangents=((0, 0, 1), (0, 0, -1)),
- tangent_scalars=(2, 2),
- )
- half = spline.trim(0.5, 1)
- self.assertAlmostEqual(spline @ 0.5, half @ 0, 4)
- self.assertAlmostEqual(spline @ 1, half @ 1, 4)
-
- w = Rectangle(3, 1).wire()
- t5 = w.trim(0, 0.5)
- self.assertAlmostEqual(t5.length, 4, 5)
- t6 = w.trim(0.5, 1)
- self.assertAlmostEqual(t6.length, 4, 5)
-
- p = RegularPolygon(10, 20).wire()
- t7 = p.trim(0.1, 0.2)
- self.assertAlmostEqual(p.length * 0.1, t7.length, 5)
-
- c = Circle(10).wire()
- t8 = c.trim(0.4, 0.9)
- self.assertAlmostEqual(c.length * 0.5, t8.length, 5)
-
- def test_param_at_point(self):
- e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
- # Three edges are created 0->0.5->0.75->1.0
- o = e.offset_2d(10, side=Side.RIGHT, closed=False)
-
- e0 = Edge.make_line((0, 0), (1, 0))
- e1 = Edge.make_line((2, 0), (1, 0))
- e2 = Edge.make_line((2, 0), (3, 0))
- w1 = Wire([e0, e1, e2])
- for wire in [o, w1]:
- u_value = random.random()
- position = wire.position_at(u_value)
- self.assertAlmostEqual(wire.param_at_point(position), u_value, 4)
-
- with self.assertRaises(ValueError):
- o.param_at_point((-1, 1))
-
- with self.assertRaises(ValueError):
- w1.param_at_point((20, 20, 20))
-
- def test_order_edges(self):
- w1 = Wire(
- [
- Edge.make_line((0, 0), (1, 0)),
- Edge.make_line((1, 1), (1, 0)),
- Edge.make_line((0, 1), (1, 1)),
- ]
- )
- ordered_edges = w1.order_edges()
- self.assertFalse(all(e.is_forward for e in w1.edges()))
- self.assertTrue(all(e.is_forward for e in ordered_edges))
- self.assertAlmostEqual(ordered_edges[0] @ 0, (0, 0, 0), 5)
- self.assertAlmostEqual(ordered_edges[1] @ 0, (1, 0, 0), 5)
- self.assertAlmostEqual(ordered_edges[2] @ 0, (1, 1, 0), 5)
-
- def test_constructor(self):
- e0 = Edge.make_line((0, 0), (1, 0))
- e1 = Edge.make_line((1, 0), (1, 1))
- w0 = Wire.make_circle(1)
- w1 = Wire(e0)
- self.assertTrue(w1.is_valid())
- w2 = Wire([e0])
- self.assertAlmostEqual(w2.length, 1, 5)
- self.assertTrue(w2.is_valid())
- w3 = Wire([e0, e1])
- self.assertTrue(w3.is_valid())
- self.assertAlmostEqual(w3.length, 2, 5)
- w4 = Wire(w0.wrapped)
- self.assertTrue(w4.is_valid())
- w5 = Wire(obj=w0.wrapped)
- self.assertTrue(w5.is_valid())
- w6 = Wire(obj=w0.wrapped, label="w6", color=Color("red"))
- self.assertTrue(w6.is_valid())
- self.assertEqual(w6.label, "w6")
- np.testing.assert_allclose(tuple(w6.color), (1.0, 0.0, 0.0, 1.0), 1e-5)
- w7 = Wire(w6)
- self.assertTrue(w7.is_valid())
- c0 = Polyline((0, 0), (1, 0), (1, 1))
- w8 = Wire(c0)
- self.assertTrue(w8.is_valid())
- with self.assertRaises(ValueError):
- Wire(bob="fred")
-
-
-if __name__ == "__main__":
- unittest.main()
From 94fdd97a551385bbb768dc319cf72f27c8f8e628 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 22 Jan 2025 20:04:42 -0500
Subject: [PATCH 156/518] Updating doc: separating key concepts, adding
OpenSCAD section
---
docs/OpenSCAD.rst | 149 +++++++++++++
docs/index.rst | 2 +
docs/key_concepts.rst | 301 +-------------------------
docs/key_concepts_builder.rst | 393 ++++++++++++++++++++++++++++++++++
4 files changed, 547 insertions(+), 298 deletions(-)
create mode 100644 docs/OpenSCAD.rst
create mode 100644 docs/key_concepts_builder.rst
diff --git a/docs/OpenSCAD.rst b/docs/OpenSCAD.rst
new file mode 100644
index 0000000..5d2d0d2
--- /dev/null
+++ b/docs/OpenSCAD.rst
@@ -0,0 +1,149 @@
+Transitioning from OpenSCAD
+===========================
+
+Welcome to build123d! If you're familiar with OpenSCAD, you'll notice key differences in
+how models are constructed. This guide is designed to help you adapt your design approach
+and understand the fundamental differences in modeling philosophies. While OpenSCAD relies
+heavily on Constructive Solid Geometry (CSG) to combine primitive 3D shapes like cubes and
+spheres, build123d encourages a more flexible and efficient workflow based on building
+lower-dimensional objects.
+
+Why Transition to build123d?
+----------------------------
+
+Transitioning to build123d allows you to harness a modern and efficient approach to 3D modeling.
+By starting with lower-dimensional objects and leveraging powerful transformation tools, you can
+create precise, complex designs with ease. This workflow emphasizes modularity and maintainability,
+enabling quick modifications and reducing computational complexity.
+
+Moving Beyond Constructive Solid Geometry (CSG)
+-----------------------------------------------
+
+OpenSCAD's modeling paradigm heavily relies on Constructive Solid Geometry (CSG) to build
+models by combining and subtracting 3D solids. While build123d supports similar operations,
+its design philosophy encourages a fundamentally different, often more efficient approach:
+starting with lower-dimensional entities like faces and edges and then transforming them
+into solids.
+
+### Why Transition Away from CSG?
+
+CSG is a powerful method for creating 3D models, but it has limitations when dealing with
+complex designs. build123d’s approach offers several advantages:
+
+- **Simplified Complexity Management**:
+ Working with 2D profiles and faces instead of directly manipulating 3D solids simplifies
+ your workflow. In large models, the number of operations on solids can grow exponentially,
+ making it difficult to manage and debug. Building with 2D profiles helps keep designs
+ modular and organized.
+
+- **Improved Robustness**:
+ Operations on 2D profiles are inherently less computationally intensive and
+ less error-prone than equivalent operations on 3D solids. This robustness ensures smoother
+ workflows and reduces the likelihood of failing operations in complex models.
+
+- **Enhanced Efficiency**:
+ Constructing models from 2D profiles using operations like **extruding**, **lofting**,
+ **sweeping**, or **revolving** is computationally faster. These methods also provide
+ greater design flexibility, enabling you to create intricate forms with ease.
+
+- **Better Precision and Control**:
+ Starting with 2D profiles allows for more precise geometric control. Constraints, dimensions,
+ and relationships between entities can be established more effectively in 2D, ensuring a solid
+ foundation for your 3D design.
+
+Using a More Traditional CAD Design Workflow
+--------------------------------------------
+
+Most industry-standard CAD packages recommend starting with a sketch (a 2D object) and
+transforming it into a 3D model—a design philosophy that is central to build123d.
+
+In build123d, the design process typically begins with defining the outline of an object.
+This might involve creating a complex 1D object using **BuildLine**, which provides tools
+for constructing intricate wireframe geometries. The next step involves converting these
+1D objects into 2D sketches using **BuildSketch**, which offers a wide range of 2D primitives
+and advanced capabilities, such as:
+
+- **make_face**: Converts a 1D **BuildLine** object into a planar 2D face.
+- **make_hull**: Generates a convex hull from a 1D **BuildLine** object.
+
+Once a 2D profile is created, it can be transformed into 3D objects in a **BuildPart** context
+using operations such as:
+
+- **Extrusion**: Extends a 2D profile along a straight path to create a 3D shape.
+- **Revolution**: Rotates a 2D profile around an axis to form a symmetrical 3D object.
+- **Lofting**: Connects multiple 2D profiles along a path to create smooth transitions
+ between shapes.
+- **Sweeping**: Moves a 2D profile along a defined path to create a 3D form.
+
+### Refining the Model
+
+After creating the initial 3D shape, you can refine the model by adding details or making
+modifications using build123d's advanced features, such as:
+
+- **Fillets and Chamfers**: Smooth or bevel edges to enhance the design.
+- **Boolean Operations**: Combine, subtract, or intersect 3D shapes to achieve the desired
+ geometry.
+
+### Example Comparison
+
+To illustrate the advantages of this approach, compare a simple model in OpenSCAD and
+build123d:
+
+**OpenSCAD Approach**
+
+.. code-block:: openscad
+
+ // A basic cylinder with a hole
+ difference() {
+ cylinder(r=10, h=20);
+ translate([0, 0, 5]) cylinder(r=5, h=20);
+ }
+
+**build123d Approach**
+
+.. code-block:: python
+
+ from build123d import *
+
+ # In Builder mode
+ with BuildPart() as cylinder_with_hole:
+ with BuildSketch():
+ Circle(10)
+ extrude(amount=20)
+ with BuildSketch(cylinder_with_hole.faces().sort_by(Axis.Z).last):
+ Circle(5)
+ extrude(amount=-15, mode=Mode.SUBTRACT)
+
+ # In Algebra mode
+ cyl = extrude(Circle(10), 20)
+ cyl -= extrude(Plane(cyl.faces().sort_by(Axis.Z)[-1]) * Circle
+
+
+This approach emphasizes creating a 2D profile (such as the **Circle**) and then applying a
+3D operation (like **extrude**) to achieve the desired result. Topological features of the
+part under construction are extracted and used as references for adding further details.
+
+Tips for Transitioning
+----------------------
+
+- **Think in Lower Dimensions**: Begin with 1D curves or 2D sketches as the foundation
+ and progressively build upwards into 3D shapes.
+
+- **Leverage Topological References**: Use build123d's powerful selector system to
+ reference features of existing objects for creating new ones. For example, apply
+ inside or outside fillets and chamfers to vertices and edges of an existing part
+ with precision.
+
+- **Explore the Documentation**: Dive into build123d’s comprehensive API documentation
+ to unlock its full potential and discover advanced features.
+
+By shifting your design mindset from solid-based CSG to a profile-driven approach, you
+can fully harness build123d's capabilities to create precise, efficient, and complex models.
+Welcome aboard, and happy designing!
+
+Conclusion
+----------
+While OpenSCAD and build123d share the goal of empowering users to create parametric 3D
+models, their approaches differ significantly. Embracing build123d’s workflow of building
+with lower-dimensional objects and applying extrusion, lofting, sweeping, or revolution
+will unlock its full potential and lead to better design outcomes.
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index f5bf239..7a76273 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -106,7 +106,9 @@ Table Of Contents
introduction.rst
installation.rst
key_concepts.rst
+ key_concepts_builder.rst
key_concepts_algebra.rst
+ OpenSCAD.rst
introductory_examples.rst
tutorials.rst
objects.rst
diff --git a/docs/key_concepts.rst b/docs/key_concepts.rst
index e8a7756..1b2f11b 100644
--- a/docs/key_concepts.rst
+++ b/docs/key_concepts.rst
@@ -1,14 +1,6 @@
-###########################
-Key Concepts (builder mode)
-###########################
-
-There are two primary APIs provided by build123d: builder and algebra. The builder
-API may be easier for new users as it provides some assistance and shortcuts; however,
-if you know what a Quaternion is you might prefer the algebra API which allows
-CAD objects to be created in the style of mathematical equations. Both API can
-be mixed in the same model with the exception that the algebra API can't be used
-from within a builder context. As with music, there is no "best" genre or API,
-use the one you prefer or both if you like.
+############
+Key Concepts
+############
The following key concepts will help new users understand build123d quickly.
@@ -120,118 +112,6 @@ topology of a shape as shown here for a unit cube:
Users of build123d will often reference topological objects as part of the
process of creating the object as described below.
-Builders
-========
-
-The three builders, ``BuildLine``, ``BuildSketch``, and ``BuildPart`` are tools to create
-new objects - not the objects themselves. Each of the objects and operations applicable
-to these builders create objects of the standard CadQuery Direct API, most commonly
-``Compound`` objects. This is opposed to CadQuery's Fluent API which creates objects
-of the ``Workplane`` class which frequently needed to be converted back to base
-class for further processing.
-
-One can access the objects created by these builders by referencing the appropriate
-instance variable. For example:
-
-.. code-block:: python
-
- with BuildPart() as my_part:
- ...
-
- show_object(my_part.part)
-
-.. code-block:: python
-
- with BuildSketch() as my_sketch:
- ...
-
- show_object(my_sketch.sketch)
-
-.. code-block:: python
-
- with BuildLine() as my_line:
- ...
-
- show_object(my_line.line)
-
-Implicit Builder Instance Variables
-===================================
-
-One might expect to have to reference a builder's instance variable when using
-objects or operations that impact that builder like this:
-
-.. code-block:: python
-
- with BuildPart() as part_builder:
- Box(part_builder, 10,10,10)
-
-Instead, build123d determines from the scope of the object or operation which
-builder it applies to thus eliminating the need for the user to provide this
-information - as follows:
-
-.. code-block:: python
-
- with BuildPart() as part_builder:
- Box(10,10,10)
- with BuildSketch() as sketch_builder:
- Circle(2)
-
-In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle``
-is in the scope of ``sketch_builder``.
-
-Workplanes
-==========
-
-As build123d is a 3D CAD package one must be able to position objects anywhere. As one
-frequently will work in the same plane for a sequence of operations, the first parameter(s)
-of the builders is a (sequence of) workplane(s) which is (are) used
-to aid in the location of features. The default workplane in most cases is the ``Plane.XY``
-where a tuple of numbers represent positions on the x and y axes. However workplanes can
-be generated on any plane which allows users to put a workplane where they are working
-and then work in local 2D coordinate space.
-
-
-.. code-block:: python
-
- with BuildPart(Plane.XY) as example:
- ... # a 3D-part
- with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[0]) as bottom:
- ...
- with BuildSketch(Plane.XZ) as vertical:
- ...
- with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top:
- ...
-
-When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a
-default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the
-normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ``) sketch.
-All objects or operations within the scope of a workplane will automatically be orientated with
-respect to this plane so the user only has to work with local coordinates.
-
-As shown above, workplanes can be created from faces as well. The ``top`` sketch is
-positioned on top of ``example`` by selecting its faces and finding the one with the greatest z value.
-
-One is not limited to a single workplane at a time. In the following example all six
-faces of the first box are used to define workplanes which are then used to position
-rotated boxes.
-
-.. code-block:: python
-
- import build123d as bd
-
- with bd.BuildPart() as bp:
- bd.Box(3, 3, 3)
- with bd.BuildSketch(*bp.faces()):
- bd.Rectangle(1, 2, rotation=45)
- bd.extrude(amount=0.1)
-
-This is the result:
-
-.. image:: boxes_on_faces.svg
- :align: center
-
-.. _location_context_link:
-
Location
========
@@ -286,182 +166,7 @@ There are also four methods that are used to change the location of objects:
Locations can be combined with the ``*`` operator and have their direction flipped with
the ``-`` operator.
-Locations Context
-=================
-
-When positioning objects or operations within a builder Location Contexts are used. These
-function in a very similar was to the builders in that they create a context where one or
-more locations are active within a scope. For example:
-
-.. code-block:: python
-
- with BuildPart():
- with Locations((0,10),(0,-10)):
- Box(1,1,1)
- with GridLocations(x_spacing=5, y_spacing=5, x_count=2, y_count=2):
- Sphere(1)
- Cylinder(1,1)
-
-In this example ``Locations`` creates two positions on the current workplane at (0,10) and (0,-10).
-Since ``Box`` is within the scope of ``Locations``, two boxes are created at these locations. The
-``GridLocations`` context creates four positions which apply to the ``Sphere``. The ``Cylinder`` is
-out of the scope of ``GridLocations`` but in the scope of ``Locations`` so two cylinders are created.
-
-Note that these contexts are creating Location objects not just simple points. The difference
-isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within
-its scope - much as the hour and minute indicator on an analogue clock.
-
-Also note that the locations are local to the current location(s) - i.e. ``Locations`` can be
-nested. It's easy for a user to retrieve the global locations:
-
-.. code-block:: python
-
- with Locations(Plane.XY, Plane.XZ):
- locs = GridLocations(1, 1, 2, 2)
- for l in locs:
- print(l)
-
-.. code-block::
-
- Location(p=(-0.50,-0.50,0.00), o=(0.00,-0.00,0.00))
- Location(p=(-0.50,0.50,0.00), o=(0.00,-0.00,0.00))
- Location(p=(0.50,-0.50,0.00), o=(0.00,-0.00,0.00))
- Location(p=(0.50,0.50,0.00), o=(0.00,-0.00,0.00))
- Location(p=(-0.50,-0.00,-0.50), o=(90.00,-0.00,0.00))
- Location(p=(-0.50,0.00,0.50), o=(90.00,-0.00,0.00))
- Location(p=(0.50,0.00,-0.50), o=(90.00,-0.00,0.00))
- Location(p=(0.50,0.00,0.50), o=(90.00,-0.00,0.00))
-
-
-Operation Inputs
-================
-
-When one is operating on an existing object, e.g. adding a fillet to a part,
-an iterable of objects is often required (often a ShapeList).
-
-Here is the definition of :meth:`~operations_generic.fillet` to help illustrate:
-
-.. code-block:: python
-
- def fillet(
- objects: Union[Union[Edge, Vertex], Iterable[Union[Edge, Vertex]]],
- radius: float,
- ):
-
-To use this fillet operation, an edge or vertex or iterable of edges or
-vertices must be provided followed by a fillet radius with or without the keyword as follows:
-
-.. code-block:: python
-
- with BuildPart() as pipes:
- Box(10, 10, 10, rotation=(10, 20, 30))
- ...
- fillet(pipes.edges(Select.LAST), radius=0.2)
-
-Here the fillet accepts the iterable ShapeList of edges from the last operation of
-the ``pipes`` builder and a radius is provided as a keyword argument.
-
-Combination Modes
-=================
-
-Almost all objects or operations have a ``mode`` parameter which is defined by the
-``Mode`` Enum class as follows:
-
-.. code-block:: python
-
- class Mode(Enum):
- ADD = auto()
- SUBTRACT = auto()
- INTERSECT = auto()
- REPLACE = auto()
- PRIVATE = auto()
-
-The ``mode`` parameter describes how the user would like the object or operation to
-interact with the object within the builder. For example, ``Mode.ADD`` will
-integrate a new object(s) in with an existing ``part``. Note that a part doesn't
-necessarily have to be a single object so multiple distinct objects could be added
-resulting is multiple objects stored as a ``Compound`` object. As one might expect
-``Mode.SUBTRACT``, ``Mode.INTERSECT``, and ``Mode.REPLACE`` subtract, intersect, or replace
-(from) the builder's object. ``Mode.PRIVATE`` instructs the builder that this object
-should not be combined with the builder's object in any way.
-
-Most commonly, the default ``mode`` is ``Mode.ADD`` but this isn't always true.
-For example, the ``Hole`` classes use a default ``Mode.SUBTRACT`` as they remove
-a volume from the part under normal circumstances. However, the ``mode`` used in
-the ``Hole`` classes can be specified as ``Mode.ADD`` or ``Mode.INTERSECT`` to
-help in inspection or debugging.
-
Selectors
=========
.. include:: selectors.rst
-
-Using Locations & Rotating Objects
-==================================
-
-build123d stores points (to be specific ``Location`` (s)) internally to be used as
-positions for the placement of new objects. By default, a single location
-will be created at the origin of the given workplane such that:
-
-.. code-block:: python
-
- with BuildPart() as pipes:
- Box(10, 10, 10, rotation=(10, 20, 30))
-
-will create a single 10x10x10 box centered at (0,0,0) - by default objects are
-centered. One can create multiple objects by pushing points prior to creating
-objects as follows:
-
-.. code-block:: python
-
- with BuildPart() as pipes:
- with Locations((-10, -10, -10), (10, 10, 10)):
- Box(10, 10, 10, rotation=(10, 20, 30))
-
-which will create two boxes.
-
-To orient a part, a ``rotation`` parameter is available on ``BuildSketch``` and
-``BuildPart`` APIs. When working in a sketch, the rotation is a single angle in
-degrees so the parameter is a float. When working on a part, the rotation is
-a three dimensional ``Rotation`` object of the form
-``Rotation(, , )`` although a simple three tuple of
-floats can be used as input. As 3D rotations are not cumulative, one can
-combine rotations with the `*` operator like this:
-``Rotation(10, 20, 30) * Rotation(0, 90, 0)`` to generate any desired rotation.
-
-.. hint::
- Experts Only
-
- ``Locations`` will accept ``Location`` objects for input which allows one
- to specify both the position and orientation. However, the orientation
- is often determined by the ``Plane`` that an object was created on.
- ``Rotation`` is a subclass of ``Location`` and therefore will also accept
- a position component.
-
-Builder's Pending Objects
-=========================
-
-When a builder exits, it will push the object created back to its parent if
-there was one. Here is an example:
-
-.. code-block:: python
-
- height, width, thickness, f_rad = 60, 80, 20, 10
-
- with BuildPart() as pillow_block:
- with BuildSketch() as plan:
- Rectangle(width, height)
- fillet(plan.vertices(), radius=f_rad)
- extrude(amount=thickness)
-
-``BuildSketch`` exits after the ``fillet`` operation and when doing so it transfers
-the sketch to the ``pillow_block`` instance of ``BuildPart`` as the internal instance variable
-``pending_faces``. This allows the ``extrude`` operation to be immediately invoked as it
-extrudes these pending faces into ``Solid`` objects. Likewise, ``loft`` would take all of the
-``pending_faces`` and attempt to create a single ``Solid`` object from them.
-
-Normally the user will not need to interact directly with pending objects; however,
-one can see pending Edges and Faces with ``.pending_edges`` and
-``.pending_faces`` attributes. In the above example, by adding a
-``print(pillow_block.pending_faces)`` prior to the ``extrude(amount=thickness)`` the
-pending ``Face`` from the ``BuildSketch`` will be displayed.
diff --git a/docs/key_concepts_builder.rst b/docs/key_concepts_builder.rst
new file mode 100644
index 0000000..20370f3
--- /dev/null
+++ b/docs/key_concepts_builder.rst
@@ -0,0 +1,393 @@
+###########################
+Key Concepts (builder mode)
+###########################
+
+There are two primary APIs provided by build123d: builder and algebra. The builder
+API may be easier for new users as it provides some assistance and shortcuts; however,
+if you know what a Quaternion is you might prefer the algebra API which allows
+CAD objects to be created in the style of mathematical equations. Both API can
+be mixed in the same model with the exception that the algebra API can't be used
+from within a builder context. As with music, there is no "best" genre or API,
+use the one you prefer or both if you like.
+
+The following key concepts will help new users understand build123d quickly.
+
+Understanding the Builder Paradigm
+==================================
+
+The **Builder** paradigm in build123d provides a powerful and intuitive way to construct
+complex geometric models. At its core, the Builder works like adding a column of numbers
+on a piece of paper: a running "total" is maintained internally as each new object is
+added or modified. This approach simplifies the process of constructing models by breaking
+it into smaller, incremental steps.
+
+How the Builder Works
+----------------------
+
+When using a Builder (such as **BuildLine**, **BuildSketch**, or **BuildPart**), the
+following principles apply:
+
+1. **Running Total**:
+ - The Builder maintains an internal "total," which represents the current state of
+ the object being built.
+ - Each operation updates this total by combining the new object with the existing one.
+
+2. **Combination Modes**:
+ - Just as numbers in a column may have a `+` or `-` sign to indicate addition or
+ subtraction, Builders use **modes** to control how each object is combined with
+ the current total.
+ - Common modes include:
+
+ - **ADD**: Adds the new object to the current total.
+ - **SUBTRACT**: Removes the new object from the current total.
+ - **INTERSECT**: Keeps only the overlapping regions of the new object and the current total.
+ - **REPLACE**: Entirely replace the running total.
+ - **PRIVATE**: Don't change the running total at all.
+
+ - The mode can be set dynamically for each operation, allowing for flexible and precise modeling.
+
+3. **Extracting the Result**:
+ - At the end of the building process, the final object is accessed through the
+ Builder's attributes, such as ``.line``, ``.sketch``, or ``.part``, depending on
+ the Builder type.
+ - For example:
+
+ - **BuildLine**: Use ``.line`` to retrieve the final wireframe geometry.
+ - **BuildSketch**: Use ``.sketch`` to extract the completed 2D profile.
+ - **BuildPart**: Use ``.part`` to obtain the 3D solid.
+
+Example Workflow
+-----------------
+
+Here is an example of using a Builder to create a simple part:
+
+.. code-block:: python
+
+ from build123d import *
+
+ # Using BuildPart to create a 3D model
+ with BuildPart() as example_part:
+ with BuildSketch() as base_sketch:
+ Rectangle(20, 20)
+ extrude(amount=10) # Create a base block
+ with BuildSketch(Plane(example_part.faces().sort_by(Axis.Z).last)) as cut_sketch:
+ Circle(5)
+ extrude(amount=-5, mode=Mode.SUBTRACT) # Subtract a cylinder
+
+ # Access the final part
+ result_part = example_part.part
+
+Key Concepts
+------------
+
+- **Incremental Construction**:
+ Builders allow you to build objects step-by-step, maintaining clarity and modularity.
+
+- **Dynamic Mode Switching**:
+ The **mode** parameter gives you precise control over how each operation modifies
+ the current total.
+
+- **Seamless Extraction**:
+ The Builder paradigm simplifies the retrieval of the final object, ensuring that you
+ always have access to the most up-to-date result.
+
+Analogy: Adding Numbers on Paper
+--------------------------------
+
+Think of the Builder as a running tally when adding numbers on a piece of paper:
+
+- Each number represents an operation or object.
+- The ``+`` or ``-`` sign corresponds to the **ADD** or **SUBTRACT** mode.
+- At the end, the total is the sum of all operations, which you can retrieve by referencing
+ the Builder’s output.
+
+By adopting this approach, build123d ensures a natural, intuitive workflow for constructing
+2D and 3D models.
+
+Builders
+========
+
+The three builders, ``BuildLine``, ``BuildSketch``, and ``BuildPart`` are tools to create
+new objects - not the objects themselves. Each of the objects and operations applicable
+to these builders create objects of the standard CadQuery Direct API, most commonly
+``Compound`` objects. This is opposed to CadQuery's Fluent API which creates objects
+of the ``Workplane`` class which frequently needed to be converted back to base
+class for further processing.
+
+One can access the objects created by these builders by referencing the appropriate
+instance variable. For example:
+
+.. code-block:: python
+
+ with BuildPart() as my_part:
+ ...
+
+ show_object(my_part.part)
+
+.. code-block:: python
+
+ with BuildSketch() as my_sketch:
+ ...
+
+ show_object(my_sketch.sketch)
+
+.. code-block:: python
+
+ with BuildLine() as my_line:
+ ...
+
+ show_object(my_line.line)
+
+Implicit Builder Instance Variables
+===================================
+
+One might expect to have to reference a builder's instance variable when using
+objects or operations that impact that builder like this:
+
+.. code-block:: python
+
+ with BuildPart() as part_builder:
+ Box(part_builder, 10,10,10)
+
+Instead, build123d determines from the scope of the object or operation which
+builder it applies to thus eliminating the need for the user to provide this
+information - as follows:
+
+.. code-block:: python
+
+ with BuildPart() as part_builder:
+ Box(10,10,10)
+ with BuildSketch() as sketch_builder:
+ Circle(2)
+
+In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle``
+is in the scope of ``sketch_builder``.
+
+Workplanes
+==========
+
+As build123d is a 3D CAD package one must be able to position objects anywhere. As one
+frequently will work in the same plane for a sequence of operations, the first parameter(s)
+of the builders is a (sequence of) workplane(s) which is (are) used
+to aid in the location of features. The default workplane in most cases is the ``Plane.XY``
+where a tuple of numbers represent positions on the x and y axes. However workplanes can
+be generated on any plane which allows users to put a workplane where they are working
+and then work in local 2D coordinate space.
+
+
+.. code-block:: python
+
+ with BuildPart(Plane.XY) as example:
+ ... # a 3D-part
+ with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[0]) as bottom:
+ ...
+ with BuildSketch(Plane.XZ) as vertical:
+ ...
+ with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top:
+ ...
+
+When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a
+default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the
+normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ``) sketch.
+All objects or operations within the scope of a workplane will automatically be orientated with
+respect to this plane so the user only has to work with local coordinates.
+
+As shown above, workplanes can be created from faces as well. The ``top`` sketch is
+positioned on top of ``example`` by selecting its faces and finding the one with the greatest z value.
+
+One is not limited to a single workplane at a time. In the following example all six
+faces of the first box are used to define workplanes which are then used to position
+rotated boxes.
+
+.. code-block:: python
+
+ import build123d as bd
+
+ with bd.BuildPart() as bp:
+ bd.Box(3, 3, 3)
+ with bd.BuildSketch(*bp.faces()):
+ bd.Rectangle(1, 2, rotation=45)
+ bd.extrude(amount=0.1)
+
+This is the result:
+
+.. image:: boxes_on_faces.svg
+ :align: center
+
+.. _location_context_link:
+
+Locations Context
+=================
+
+When positioning objects or operations within a builder Location Contexts are used. These
+function in a very similar was to the builders in that they create a context where one or
+more locations are active within a scope. For example:
+
+.. code-block:: python
+
+ with BuildPart():
+ with Locations((0,10),(0,-10)):
+ Box(1,1,1)
+ with GridLocations(x_spacing=5, y_spacing=5, x_count=2, y_count=2):
+ Sphere(1)
+ Cylinder(1,1)
+
+In this example ``Locations`` creates two positions on the current workplane at (0,10) and (0,-10).
+Since ``Box`` is within the scope of ``Locations``, two boxes are created at these locations. The
+``GridLocations`` context creates four positions which apply to the ``Sphere``. The ``Cylinder`` is
+out of the scope of ``GridLocations`` but in the scope of ``Locations`` so two cylinders are created.
+
+Note that these contexts are creating Location objects not just simple points. The difference
+isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within
+its scope - much as the hour and minute indicator on an analogue clock.
+
+Also note that the locations are local to the current location(s) - i.e. ``Locations`` can be
+nested. It's easy for a user to retrieve the global locations:
+
+.. code-block:: python
+
+ with Locations(Plane.XY, Plane.XZ):
+ locs = GridLocations(1, 1, 2, 2)
+ for l in locs:
+ print(l)
+
+.. code-block::
+
+ Location(p=(-0.50,-0.50,0.00), o=(0.00,-0.00,0.00))
+ Location(p=(-0.50,0.50,0.00), o=(0.00,-0.00,0.00))
+ Location(p=(0.50,-0.50,0.00), o=(0.00,-0.00,0.00))
+ Location(p=(0.50,0.50,0.00), o=(0.00,-0.00,0.00))
+ Location(p=(-0.50,-0.00,-0.50), o=(90.00,-0.00,0.00))
+ Location(p=(-0.50,0.00,0.50), o=(90.00,-0.00,0.00))
+ Location(p=(0.50,0.00,-0.50), o=(90.00,-0.00,0.00))
+ Location(p=(0.50,0.00,0.50), o=(90.00,-0.00,0.00))
+
+
+Operation Inputs
+================
+
+When one is operating on an existing object, e.g. adding a fillet to a part,
+an iterable of objects is often required (often a ShapeList).
+
+Here is the definition of :meth:`~operations_generic.fillet` to help illustrate:
+
+.. code-block:: python
+
+ def fillet(
+ objects: Union[Union[Edge, Vertex], Iterable[Union[Edge, Vertex]]],
+ radius: float,
+ ):
+
+To use this fillet operation, an edge or vertex or iterable of edges or
+vertices must be provided followed by a fillet radius with or without the keyword as follows:
+
+.. code-block:: python
+
+ with BuildPart() as pipes:
+ Box(10, 10, 10, rotation=(10, 20, 30))
+ ...
+ fillet(pipes.edges(Select.LAST), radius=0.2)
+
+Here the fillet accepts the iterable ShapeList of edges from the last operation of
+the ``pipes`` builder and a radius is provided as a keyword argument.
+
+Combination Modes
+=================
+
+Almost all objects or operations have a ``mode`` parameter which is defined by the
+``Mode`` Enum class as follows:
+
+.. code-block:: python
+
+ class Mode(Enum):
+ ADD = auto()
+ SUBTRACT = auto()
+ INTERSECT = auto()
+ REPLACE = auto()
+ PRIVATE = auto()
+
+The ``mode`` parameter describes how the user would like the object or operation to
+interact with the object within the builder. For example, ``Mode.ADD`` will
+integrate a new object(s) in with an existing ``part``. Note that a part doesn't
+necessarily have to be a single object so multiple distinct objects could be added
+resulting is multiple objects stored as a ``Compound`` object. As one might expect
+``Mode.SUBTRACT``, ``Mode.INTERSECT``, and ``Mode.REPLACE`` subtract, intersect, or replace
+(from) the builder's object. ``Mode.PRIVATE`` instructs the builder that this object
+should not be combined with the builder's object in any way.
+
+Most commonly, the default ``mode`` is ``Mode.ADD`` but this isn't always true.
+For example, the ``Hole`` classes use a default ``Mode.SUBTRACT`` as they remove
+a volume from the part under normal circumstances. However, the ``mode`` used in
+the ``Hole`` classes can be specified as ``Mode.ADD`` or ``Mode.INTERSECT`` to
+help in inspection or debugging.
+
+
+Using Locations & Rotating Objects
+==================================
+
+build123d stores points (to be specific ``Location`` (s)) internally to be used as
+positions for the placement of new objects. By default, a single location
+will be created at the origin of the given workplane such that:
+
+.. code-block:: python
+
+ with BuildPart() as pipes:
+ Box(10, 10, 10, rotation=(10, 20, 30))
+
+will create a single 10x10x10 box centered at (0,0,0) - by default objects are
+centered. One can create multiple objects by pushing points prior to creating
+objects as follows:
+
+.. code-block:: python
+
+ with BuildPart() as pipes:
+ with Locations((-10, -10, -10), (10, 10, 10)):
+ Box(10, 10, 10, rotation=(10, 20, 30))
+
+which will create two boxes.
+
+To orient a part, a ``rotation`` parameter is available on ``BuildSketch``` and
+``BuildPart`` APIs. When working in a sketch, the rotation is a single angle in
+degrees so the parameter is a float. When working on a part, the rotation is
+a three dimensional ``Rotation`` object of the form
+``Rotation(, , )`` although a simple three tuple of
+floats can be used as input. As 3D rotations are not cumulative, one can
+combine rotations with the `*` operator like this:
+``Rotation(10, 20, 30) * Rotation(0, 90, 0)`` to generate any desired rotation.
+
+.. hint::
+ Experts Only
+
+ ``Locations`` will accept ``Location`` objects for input which allows one
+ to specify both the position and orientation. However, the orientation
+ is often determined by the ``Plane`` that an object was created on.
+ ``Rotation`` is a subclass of ``Location`` and therefore will also accept
+ a position component.
+
+Builder's Pending Objects
+=========================
+
+When a builder exits, it will push the object created back to its parent if
+there was one. Here is an example:
+
+.. code-block:: python
+
+ height, width, thickness, f_rad = 60, 80, 20, 10
+
+ with BuildPart() as pillow_block:
+ with BuildSketch() as plan:
+ Rectangle(width, height)
+ fillet(plan.vertices(), radius=f_rad)
+ extrude(amount=thickness)
+
+``BuildSketch`` exits after the ``fillet`` operation and when doing so it transfers
+the sketch to the ``pillow_block`` instance of ``BuildPart`` as the internal instance variable
+``pending_faces``. This allows the ``extrude`` operation to be immediately invoked as it
+extrudes these pending faces into ``Solid`` objects. Likewise, ``loft`` would take all of the
+``pending_faces`` and attempt to create a single ``Solid`` object from them.
+
+Normally the user will not need to interact directly with pending objects; however,
+one can see pending Edges and Faces with ``.pending_edges`` and
+``.pending_faces`` attributes. In the above example, by adding a
+``print(pillow_block.pending_faces)`` prior to the ``extrude(amount=thickness)`` the
+pending ``Face`` from the ``BuildSketch`` will be displayed.
From 97eff88585d34c4d8b4f416ab5911040363de618 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Wed, 22 Jan 2025 22:32:38 -0600
Subject: [PATCH 157/518] mypy.ini -> exclude OCP-stubs
---
mypy.ini | 3 +++
1 file changed, 3 insertions(+)
diff --git a/mypy.ini b/mypy.ini
index 2960c49..b4b1a7b 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -17,6 +17,9 @@ ignore_missing_imports = True
[mypy-OCP.*]
ignore_missing_imports = True
+[mypy-OCP-stubs.*]
+ignore_missing_imports = True
+
[mypy-ocpsvg.*]
ignore_missing_imports = True
From bd1ad47ac5310eb9079f105f6c4250deb85df9c0 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 23 Jan 2025 07:39:10 -0600
Subject: [PATCH 158/518] Update mypy.ini
---
mypy.ini | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/mypy.ini b/mypy.ini
index b4b1a7b..c9bab94 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -17,7 +17,7 @@ ignore_missing_imports = True
[mypy-OCP.*]
ignore_missing_imports = True
-[mypy-OCP-stubs.*]
+[mypy-ocp-stubs.*]
ignore_missing_imports = True
[mypy-ocpsvg.*]
From f077d72819529cee0dfefb58dc6ee2ee624fa1ae Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 23 Jan 2025 09:53:15 -0500
Subject: [PATCH 159/518] Added sort_by lambda Issue#485
---
src/build123d/topology/shape_core.py | 12 ++++++++++--
tests/test_direct_api/test_shape_list.py | 14 ++++++++++++++
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index b30e596..42fb793 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -2624,7 +2624,9 @@ class ShapeList(list[T]):
return ShapeList([s for shape in self for s in shape.solids()]) # type: ignore
def sort_by(
- self, sort_by: Axis | Edge | Wire | SortBy = Axis.Z, reverse: bool = False
+ self,
+ sort_by: Axis | Callable[[T], K] | Edge | Wire | SortBy = Axis.Z,
+ reverse: bool = False,
) -> ShapeList[T]:
"""sort by
@@ -2639,7 +2641,11 @@ class ShapeList(list[T]):
ShapeList: sorted list of objects
"""
- if isinstance(sort_by, Axis):
+ if callable(sort_by):
+ # If a callable is provided, use it directly as the key
+ objects = sorted(self, key=sort_by, reverse=reverse)
+
+ elif isinstance(sort_by, Axis):
if sort_by.wrapped is None:
raise ValueError("Cannot sort by an empty axis")
assert sort_by.location is not None
@@ -2702,6 +2708,8 @@ class ShapeList(list[T]):
key=lambda obj: obj.volume, # type: ignore
reverse=reverse,
)
+ else:
+ raise ValueError("Invalid sort_by criteria provided")
return ShapeList(objects)
diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py
index 45ec7eb..98139d8 100644
--- a/tests/test_direct_api/test_shape_list.py
+++ b/tests/test_direct_api/test_shape_list.py
@@ -79,6 +79,20 @@ class TestShapeList(unittest.TestCase):
faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA
self.assertAlmostEqual(faces[-1].area, 2, 5)
+ def test_sort_by_lambda(self):
+ c = Solid.make_cone(2, 1, 2)
+ flat_faces = c.faces().filter_by(GeomType.PLANE)
+ sorted_flat_faces = flat_faces.sort_by(lambda f: f.area)
+ smallest = sorted_flat_faces[0]
+ largest = sorted_flat_faces[-1]
+
+ self.assertAlmostEqual(smallest.area, math.pi * 1**2, 5)
+ self.assertAlmostEqual(largest.area, math.pi * 2**2, 5)
+
+ def test_sort_by_invalid(self):
+ with self.assertRaises(ValueError):
+ Solid.make_box(1, 1, 1).faces().sort_by(">Z")
+
def test_filter_by_geomtype(self):
non_planar_faces = (
Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True)
From 4f392c534a058c152ba04660613c32bd50dff1c4 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 23 Jan 2025 10:00:40 -0500
Subject: [PATCH 160/518] Exclude cadquery-ocp-stubs
---
mypy.ini | 3 +++
1 file changed, 3 insertions(+)
diff --git a/mypy.ini b/mypy.ini
index 2960c49..b014dd5 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -8,6 +8,9 @@ ignore_missing_imports = True
[mypy-build123d.topology.jupyter_tools.*]
ignore_missing_imports = True
+[mypy-cadquery-ocp-stubs.*]
+ignore_missing_imports = True
+
[mypy-IPython.*]
ignore_missing_imports = True
From 0625c77e4e355819a4b3a470e0ac50c29cc08e3a Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 23 Jan 2025 10:40:26 -0500
Subject: [PATCH 161/518] Updating sort_by docstring
---
src/build123d/topology/shape_core.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index 42fb793..d266f01 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -2634,9 +2634,15 @@ class ShapeList(list[T]):
objects.
Args:
- sort_by (SortBy, optional): sort criteria. Defaults to SortBy.Z.
+ sort_by (Axis | Callable[[T], K] | Edge | Wire | SortBy, optional): sort criteria.
+ Defaults to Axis.Z.
reverse (bool, optional): flip order of sort. Defaults to False.
+ Raises:
+ ValueError: Cannot sort by an empty axis
+ ValueError: Cannot sort by an empty object
+ ValueError: Invalid sort_by criteria provided
+
Returns:
ShapeList: sorted list of objects
"""
From 22611e1554c0a4877df8cfd264bdbfe8a355345e Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 23 Jan 2025 10:09:14 -0600
Subject: [PATCH 162/518] pyproject.toml -> move cadquery-ocp-stubs from
[development] to [stubs] and exclude from [all] (optional dependencies)
---
pyproject.toml | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 9daf205..4a71828 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -60,7 +60,6 @@ ocp_vscode = [
# development dependencies
development = [
- "cadquery-ocp-stubs >= 7.8, < 7.9",
"wheel",
"pytest",
"pytest-cov",
@@ -69,6 +68,11 @@ development = [
"black",
]
+# typing stubs for the OCP CAD kernel
+stubs = [
+ "cadquery-ocp-stubs >= 7.8, < 7.9",
+]
+
# dependency to run the pytest benchmarks
benchmark = [
"pytest-benchmark",
@@ -90,6 +94,7 @@ all = [
"build123d[development]",
"build123d[benchmark]",
"build123d[docs]",
+ # "build123d[stubs]", # excluded for now as mypy fails
]
[tool.setuptools.packages.find]
From 1740f388a5747df31cdf812c87026748218b5a81 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 23 Jan 2025 10:09:56 -0600
Subject: [PATCH 163/518] mypy.ini -> remove stubs exclusion attempt
---
mypy.ini | 3 ---
1 file changed, 3 deletions(-)
diff --git a/mypy.ini b/mypy.ini
index 1faebf7..b014dd5 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -20,9 +20,6 @@ ignore_missing_imports = True
[mypy-OCP.*]
ignore_missing_imports = True
-[mypy-ocp-stubs.*]
-ignore_missing_imports = True
-
[mypy-ocpsvg.*]
ignore_missing_imports = True
From 4aee76f6c03e26dd412b45e4880c0430438e4185 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 24 Jan 2025 11:08:57 -0500
Subject: [PATCH 164/518] Added Edge.is_interior property Issue #816
---
src/build123d/topology/__init__.py | 14 ++++-
src/build123d/topology/one_d.py | 73 +++++++++++++++++++++++++-
tests/test_direct_api/test_edge.py | 12 ++++-
tests/test_topo_explore.py | 84 ++++++++++++++++++++++++++++++
4 files changed, 179 insertions(+), 4 deletions(-)
diff --git a/src/build123d/topology/__init__.py b/src/build123d/topology/__init__.py
index abee547..2bf01d3 100644
--- a/src/build123d/topology/__init__.py
+++ b/src/build123d/topology/__init__.py
@@ -51,8 +51,16 @@ from .utils import (
find_max_dimension,
)
from .zero_d import Vertex, topo_explore_common_vertex
-from .one_d import Edge, Wire, Mixin1D, edges_to_wires, topo_explore_connected_edges
-from .two_d import Face, Shell, Mixin2D,sort_wires_by_build_order
+from .one_d import (
+ Edge,
+ Wire,
+ Mixin1D,
+ edges_to_wires,
+ topo_explore_connected_edges,
+ offset_topods_face,
+ topo_explore_connected_faces,
+)
+from .two_d import Face, Shell, Mixin2D, sort_wires_by_build_order
from .three_d import Solid, Mixin3D
from .composite import Compound, Curve, Sketch, Part
@@ -79,7 +87,9 @@ __all__ = [
"Edge",
"Wire",
"edges_to_wires",
+ "offset_topods_face",
"topo_explore_connected_edges",
+ "topo_explore_connected_faces",
"Face",
"Shell",
"sort_wires_by_build_order",
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index b5a3acf..366cf51 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -66,7 +66,11 @@ from scipy.spatial import ConvexHull
import OCP.TopAbs as ta
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve
-from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Splitter
+from OCP.BRepAlgoAPI import (
+ BRepAlgoAPI_Common,
+ BRepAlgoAPI_Section,
+ BRepAlgoAPI_Splitter,
+)
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_DisconnectedWire,
BRepBuilderAPI_EmptyWire,
@@ -80,6 +84,7 @@ from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d
from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepLib import BRepLib, BRepLib_FindSurface
+from OCP.BRepOffset import BRepOffset_MakeOffset
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepProj import BRepProj_Projection
@@ -134,6 +139,7 @@ from OCP.TopoDS import (
TopoDS,
TopoDS_Compound,
TopoDS_Edge,
+ TopoDS_Face,
TopoDS_Shape,
TopoDS_Shell,
TopoDS_Wire,
@@ -223,6 +229,34 @@ class Mixin1D(Shape):
raise ValueError("Can't determine direction of empty Edge or Wire")
return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD
+ @property
+ def is_interior(self) -> bool:
+ """
+ Check if the edge is an interior edge.
+
+ An interior edge lies between surfaces that are part of the body (internal
+ to the geometry) and does not form part of the exterior boundary.
+
+ Returns:
+ bool: True if the edge is an interior edge, False otherwise.
+ """
+ # Find the faces connected to this edge and offset them
+ topods_face_pair = topo_explore_connected_faces(self)
+ offset_face_pair = [
+ offset_topods_face(f, self.length / 100) for f in topods_face_pair
+ ]
+
+ # Intersect the offset faces
+ sectionor = BRepAlgoAPI_Section(
+ offset_face_pair[0], offset_face_pair[1], PerformNow=False
+ )
+ sectionor.Build()
+ face_intersection_result = sectionor.Shape()
+
+ # If an edge was created the faces intersect and the edge is interior
+ explorer = TopExp_Explorer(face_intersection_result, ta.TopAbs_EDGE)
+ return explorer.More()
+
@property
def length(self) -> float:
"""Edge or Wire length"""
@@ -3004,6 +3038,15 @@ def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> ShapeList[Wire]:
return wires
+def offset_topods_face(face: TopoDS_Face, amount: float) -> TopoDS_Shape:
+ """Offset a topods_face"""
+ offsetor = BRepOffset_MakeOffset()
+ offsetor.Initialize(face, Offset=amount, Tol=TOLERANCE)
+ offsetor.MakeOffsetShape()
+
+ return offsetor.Shape()
+
+
def topo_explore_connected_edges(
edge: Edge, parent: Shape | None = None
) -> ShapeList[Edge]:
@@ -3029,3 +3072,31 @@ def topo_explore_connected_edges(
connected_edges.add(topods_edge)
return ShapeList(Edge(e) for e in connected_edges)
+
+
+def topo_explore_connected_faces(
+ edge: Edge, parent: Shape | None = None
+) -> list[TopoDS_Face]:
+ """Given an edge extracted from a Shape, return the topods_faces connected to it"""
+
+ parent = parent if parent is not None else edge.topo_parent
+ if parent is None:
+ raise ValueError("edge has no valid parent")
+
+ # make a edge --> faces mapping
+ edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
+ TopExp.MapShapesAndAncestors_s(
+ parent.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map
+ )
+
+ # Query the map
+ faces = []
+ if edge_face_map.Contains(edge.wrapped):
+ face_list = edge_face_map.FindFromKey(edge.wrapped)
+ for face in face_list:
+ faces.append(TopoDS.Face_s(face))
+
+ if len(faces) != 2:
+ raise RuntimeError("Invalid # of faces connected to this edge")
+
+ return faces
diff --git a/tests/test_direct_api/test_edge.py b/tests/test_direct_api/test_edge.py
index 3589ec0..3eb2da5 100644
--- a/tests/test_direct_api/test_edge.py
+++ b/tests/test_direct_api/test_edge.py
@@ -29,9 +29,11 @@ license:
import math
import unittest
-from build123d.build_enums import AngularDirection
+from build123d.build_enums import AngularDirection, GeomType, Transition
from build123d.geometry import Axis, Plane, Vector
from build123d.objects_curve import CenterArc, EllipticalCenterArc
+from build123d.objects_sketch import Circle, Rectangle, RegularPolygon
+from build123d.operations_generic import sweep
from build123d.topology import Edge
@@ -284,6 +286,14 @@ class TestEdge(unittest.TestCase):
with self.assertRaises(TypeError):
Edge(direction=(1, 0, 0))
+ def test_is_interior(self):
+ path = RegularPolygon(5, 5).face().outer_wire()
+ profile = path.location_at(0) * (Circle(0.6) & Rectangle(2, 1))
+ target = sweep(profile, path, transition=Transition.RIGHT)
+ inside_edges = target.edges().filter_by(lambda e: e.is_interior)
+ self.assertEqual(len(inside_edges), 5)
+ self.assertTrue(all(e.geom_type == GeomType.ELLIPSE for e in inside_edges))
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_topo_explore.py b/tests/test_topo_explore.py
index 75b4e3a..8524795 100644
--- a/tests/test_topo_explore.py
+++ b/tests/test_topo_explore.py
@@ -1,6 +1,11 @@
from typing import Optional
import unittest
+from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace
+from OCP.GProp import GProp_GProps
+from OCP.BRepGProp import BRepGProp
+from OCP.gp import gp_Pnt, gp_Pln
+from OCP.TopoDS import TopoDS_Face, TopoDS_Shape
from build123d.build_enums import SortBy
from build123d.objects_part import Box
@@ -12,8 +17,11 @@ from build123d.geometry import (
from build123d.topology import (
Edge,
Face,
+ Shell,
Wire,
+ offset_topods_face,
topo_explore_connected_edges,
+ topo_explore_connected_faces,
topo_explore_common_vertex,
)
@@ -78,6 +86,17 @@ class TestTopoExplore(DirectApiTestCase):
connected_edges = topo_explore_connected_edges(face.edges()[0])
self.assertEqual(len(connected_edges), 1)
+ def test_topo_explore_connected_edges_errors(self):
+ # No parent case
+ with self.assertRaises(ValueError):
+ topo_explore_connected_edges(Edge())
+
+ # Null edge case
+ null_edge = Wire.make_rect(1, 1).edges()[0]
+ null_edge.wrapped = None
+ with self.assertRaises(ValueError):
+ topo_explore_connected_edges(null_edge)
+
def test_topo_explore_common_vertex(self):
triangle = Face(
Wire(
@@ -98,5 +117,70 @@ class TestTopoExplore(DirectApiTestCase):
)
+class TestOffsetTopodsFace(unittest.TestCase):
+ def setUp(self):
+ # Create a simple planar face for testing
+ self.face = Face.make_rect(1, 1).wrapped
+
+ def get_face_center(self, face: TopoDS_Face) -> tuple:
+ """Calculate the center of a face"""
+ props = GProp_GProps()
+ BRepGProp.SurfaceProperties_s(face, props)
+ center = props.CentreOfMass()
+ return (center.X(), center.Y(), center.Z())
+
+ def test_offset_topods_face(self):
+ # Offset the face by a positive amount
+ offset_amount = 1.0
+ original_center = self.get_face_center(self.face)
+ offset_shape = offset_topods_face(self.face, offset_amount)
+ offset_center = self.get_face_center(offset_shape)
+ self.assertIsInstance(offset_shape, TopoDS_Shape)
+ self.assertAlmostEqual(Vector(0, 0, 1), offset_center)
+
+ # Offset the face by a negative amount
+ offset_amount = -1.0
+ offset_shape = offset_topods_face(self.face, offset_amount)
+ offset_center = self.get_face_center(offset_shape)
+ self.assertIsInstance(offset_shape, TopoDS_Shape)
+ self.assertAlmostEqual(Vector(0, 0, -1), offset_center)
+
+ def test_offset_topods_face_zero(self):
+ # Offset the face by zero amount
+ offset_amount = 0.0
+ original_center = self.get_face_center(self.face)
+ offset_shape = offset_topods_face(self.face, offset_amount)
+ offset_center = self.get_face_center(offset_shape)
+ self.assertIsInstance(offset_shape, TopoDS_Shape)
+ self.assertAlmostEqual(Vector(original_center), offset_center)
+
+
+class TestTopoExploreConnectedFaces(unittest.TestCase):
+ def setUp(self):
+ # Create a shell with 4 faces
+ walls = Shell.extrude(Wire.make_rect(1, 1), (0, 0, 1))
+ diagonal = Axis((0, 0, 0), (1, 1, 0))
+
+ # Extract the edge that is connected to two faces
+ self.connected_edge = walls.edges().filter_by(Axis.Z).sort_by(diagonal)[-1]
+
+ # Create an edge that is only connected to one face
+ self.unconnected_edge = Face.make_rect(1, 1).edges()[0]
+
+ def test_topo_explore_connected_faces(self):
+ # Add the edge to the faces
+ faces = topo_explore_connected_faces(self.connected_edge)
+ self.assertEqual(len(faces), 2)
+
+ def test_topo_explore_connected_faces_invalid(self):
+ # Test with an edge that is not connected to two faces
+ with self.assertRaises(RuntimeError):
+ topo_explore_connected_faces(self.unconnected_edge)
+
+ # No parent case
+ with self.assertRaises(ValueError):
+ topo_explore_connected_faces(Edge())
+
+
if __name__ == "__main__":
unittest.main()
From d2d3580fc8d9eab01757891d2bd3a9d6514d2ec7 Mon Sep 17 00:00:00 2001
From: Victor Poughon
Date: Thu, 19 Dec 2024 11:58:54 +0100
Subject: [PATCH 165/518] jupyter_tools: move template to separate file
---
src/build123d/jupyter_tools.py | 151 ++-----------------------------
src/build123d/template_render.js | 137 ++++++++++++++++++++++++++++
2 files changed, 143 insertions(+), 145 deletions(-)
create mode 100644 src/build123d/template_render.js
diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py
index 0fd9f64..830f603 100644
--- a/src/build123d/jupyter_tools.py
+++ b/src/build123d/jupyter_tools.py
@@ -25,156 +25,17 @@ license:
# pylint: disable=no-name-in-module
from json import dumps
+import os
+from string import Template
from typing import Any, Dict, List
from IPython.display import Javascript
from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter
DEFAULT_COLOR = [1, 0.8, 0, 1]
-TEMPLATE_RENDER = """
-
-function render(data, parent_element, ratio){{
-
- // Initial setup
- const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance();
- const renderer = vtk.Rendering.Core.vtkRenderer.newInstance({{ background: [1, 1, 1 ] }});
- renderWindow.addRenderer(renderer);
-
- // iterate over all children children
- for (var el of data){{
- var trans = el.position;
- var rot = el.orientation;
- var rgba = el.color;
- var shape = el.shape;
-
- // load the inline data
- var reader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance();
- const textEncoder = new TextEncoder();
- reader.parseAsArrayBuffer(textEncoder.encode(shape));
-
- // setup actor,mapper and add
- const mapper = vtk.Rendering.Core.vtkMapper.newInstance();
- mapper.setInputConnection(reader.getOutputPort());
- mapper.setResolveCoincidentTopologyToPolygonOffset();
- mapper.setResolveCoincidentTopologyPolygonOffsetParameters(0.5,100);
-
- const actor = vtk.Rendering.Core.vtkActor.newInstance();
- actor.setMapper(mapper);
-
- // set color and position
- actor.getProperty().setColor(rgba.slice(0,3));
- actor.getProperty().setOpacity(rgba[3]);
-
- actor.rotateZ(rot[2]*180/Math.PI);
- actor.rotateY(rot[1]*180/Math.PI);
- actor.rotateX(rot[0]*180/Math.PI);
-
- actor.setPosition(trans);
-
- renderer.addActor(actor);
-
- }};
-
- renderer.resetCamera();
-
- const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
- renderWindow.addView(openglRenderWindow);
-
- // Add output to the "parent element"
- var container;
- var dims;
-
- if(typeof(parent_element.appendChild) !== "undefined"){{
- container = document.createElement("div");
- parent_element.appendChild(container);
- dims = parent_element.getBoundingClientRect();
- }}else{{
- container = parent_element.append("").children("div:last-child").get(0);
- dims = parent_element.get(0).getBoundingClientRect();
- }};
-
- openglRenderWindow.setContainer(container);
-
- // handle size
- if (ratio){{
- openglRenderWindow.setSize(dims.width, dims.width*ratio);
- }}else{{
- openglRenderWindow.setSize(dims.width, dims.height);
- }};
-
- // Interaction setup
- const interact_style = vtk.Interaction.Style.vtkInteractorStyleManipulator.newInstance();
-
- const manips = {{
- rot: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRotateManipulator.newInstance(),
- pan: vtk.Interaction.Manipulators.vtkMouseCameraTrackballPanManipulator.newInstance(),
- zoom1: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
- zoom2: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
- roll: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRollManipulator.newInstance(),
- }};
-
- manips.zoom1.setControl(true);
- manips.zoom2.setScrollEnabled(true);
- manips.roll.setShift(true);
- manips.pan.setButton(2);
-
- for (var k in manips){{
- interact_style.addMouseManipulator(manips[k]);
- }};
-
- const interactor = vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
- interactor.setView(openglRenderWindow);
- interactor.initialize();
- interactor.bindEvents(container);
- interactor.setInteractorStyle(interact_style);
-
- // Orientation marker
-
- const axes = vtk.Rendering.Core.vtkAnnotatedCubeActor.newInstance();
- axes.setXPlusFaceProperty({{text: '+X'}});
- axes.setXMinusFaceProperty({{text: '-X'}});
- axes.setYPlusFaceProperty({{text: '+Y'}});
- axes.setYMinusFaceProperty({{text: '-Y'}});
- axes.setZPlusFaceProperty({{text: '+Z'}});
- axes.setZMinusFaceProperty({{text: '-Z'}});
-
- const orientationWidget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget.newInstance({{
- actor: axes,
- interactor: interactor }});
- orientationWidget.setEnabled(true);
- orientationWidget.setViewportCorner(vtk.Interaction.Widgets.vtkOrientationMarkerWidget.Corners.BOTTOM_LEFT);
- orientationWidget.setViewportSize(0.2);
-
-}};
-"""
-
-TEMPLATE = (
- TEMPLATE_RENDER
- + """
-
-new Promise(
- function(resolve, reject)
- {{
- if (typeof(require) !== "undefined" ){{
- require.config({{
- "paths": {{"vtk": "https://unpkg.com/vtk"}},
- }});
- require(["vtk"], resolve, reject);
- }} else if ( typeof(vtk) === "undefined" ){{
- var script = document.createElement("script");
- script.onload = resolve;
- script.onerror = reject;
- script.src = "https://unpkg.com/vtk.js";
- document.head.appendChild(script);
- }} else {{ resolve() }};
- }}
-).then(() => {{
- var parent_element = {element};
- var data = {data};
- render(data, parent_element, {ratio});
-}});
-"""
-)
+dir_path = os.path.dirname(os.path.realpath(__file__))
+with open(os.path.join(dir_path, "template_render.js"), encoding="utf-8") as f:
+ TEMPLATE_JS = f.read()
def to_vtkpoly_string(
@@ -229,6 +90,6 @@ def display(shape: Any) -> Javascript:
"orientation": [0, 0, 0],
}
)
- code = TEMPLATE.format(data=dumps(payload), element="element", ratio=0.5)
+ code = Template(TEMPLATE_JS).substitute(data=dumps(payload), element="element", ratio=0.5)
return Javascript(code)
diff --git a/src/build123d/template_render.js b/src/build123d/template_render.js
new file mode 100644
index 0000000..8d287f5
--- /dev/null
+++ b/src/build123d/template_render.js
@@ -0,0 +1,137 @@
+function render(data, parent_element, ratio){
+
+ // Initial setup
+ const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance();
+ const renderer = vtk.Rendering.Core.vtkRenderer.newInstance({ background: [1, 1, 1 ] });
+ renderWindow.addRenderer(renderer);
+
+ // iterate over all children children
+ for (var el of data){
+ var trans = el.position;
+ var rot = el.orientation;
+ var rgba = el.color;
+ var shape = el.shape;
+
+ // load the inline data
+ var reader = vtk.IO.XML.vtkXMLPolyDataReader.newInstance();
+ const textEncoder = new TextEncoder();
+ reader.parseAsArrayBuffer(textEncoder.encode(shape));
+
+ // setup actor,mapper and add
+ const mapper = vtk.Rendering.Core.vtkMapper.newInstance();
+ mapper.setInputConnection(reader.getOutputPort());
+ mapper.setResolveCoincidentTopologyToPolygonOffset();
+ mapper.setResolveCoincidentTopologyPolygonOffsetParameters(0.5,100);
+
+ const actor = vtk.Rendering.Core.vtkActor.newInstance();
+ actor.setMapper(mapper);
+
+ // set color and position
+ actor.getProperty().setColor(rgba.slice(0,3));
+ actor.getProperty().setOpacity(rgba[3]);
+
+ actor.rotateZ(rot[2]*180/Math.PI);
+ actor.rotateY(rot[1]*180/Math.PI);
+ actor.rotateX(rot[0]*180/Math.PI);
+
+ actor.setPosition(trans);
+
+ renderer.addActor(actor);
+
+ };
+
+ renderer.resetCamera();
+
+ const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
+ renderWindow.addView(openglRenderWindow);
+
+ // Add output to the "parent element"
+ var container;
+ var dims;
+
+ if(typeof(parent_element.appendChild) !== "undefined"){
+ container = document.createElement("div");
+ parent_element.appendChild(container);
+ dims = parent_element.getBoundingClientRect();
+ }else{
+ container = parent_element.append("").children("div:last-child").get(0);
+ dims = parent_element.get(0).getBoundingClientRect();
+ };
+
+ openglRenderWindow.setContainer(container);
+
+ // handle size
+ if (ratio){
+ openglRenderWindow.setSize(dims.width, dims.width*ratio);
+ }else{
+ openglRenderWindow.setSize(dims.width, dims.height);
+ };
+
+ // Interaction setup
+ const interact_style = vtk.Interaction.Style.vtkInteractorStyleManipulator.newInstance();
+
+ const manips = {
+ rot: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRotateManipulator.newInstance(),
+ pan: vtk.Interaction.Manipulators.vtkMouseCameraTrackballPanManipulator.newInstance(),
+ zoom1: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
+ zoom2: vtk.Interaction.Manipulators.vtkMouseCameraTrackballZoomManipulator.newInstance(),
+ roll: vtk.Interaction.Manipulators.vtkMouseCameraTrackballRollManipulator.newInstance(),
+ };
+
+ manips.zoom1.setControl(true);
+ manips.zoom2.setScrollEnabled(true);
+ manips.roll.setShift(true);
+ manips.pan.setButton(2);
+
+ for (var k in manips){
+ interact_style.addMouseManipulator(manips[k]);
+ };
+
+ const interactor = vtk.Rendering.Core.vtkRenderWindowInteractor.newInstance();
+ interactor.setView(openglRenderWindow);
+ interactor.initialize();
+ interactor.bindEvents(container);
+ interactor.setInteractorStyle(interact_style);
+
+ // Orientation marker
+
+ const axes = vtk.Rendering.Core.vtkAnnotatedCubeActor.newInstance();
+ axes.setXPlusFaceProperty({text: '+X'});
+ axes.setXMinusFaceProperty({text: '-X'});
+ axes.setYPlusFaceProperty({text: '+Y'});
+ axes.setYMinusFaceProperty({text: '-Y'});
+ axes.setZPlusFaceProperty({text: '+Z'});
+ axes.setZMinusFaceProperty({text: '-Z'});
+
+ const orientationWidget = vtk.Interaction.Widgets.vtkOrientationMarkerWidget.newInstance({
+ actor: axes,
+ interactor: interactor });
+ orientationWidget.setEnabled(true);
+ orientationWidget.setViewportCorner(vtk.Interaction.Widgets.vtkOrientationMarkerWidget.Corners.BOTTOM_LEFT);
+ orientationWidget.setViewportSize(0.2);
+
+};
+
+
+new Promise(
+ function(resolve, reject)
+ {
+ if (typeof(require) !== "undefined" ){
+ require.config({
+ "paths": {"vtk": "https://unpkg.com/vtk"},
+ });
+ require(["vtk"], resolve, reject);
+ } else if ( typeof(vtk) === "undefined" ){
+ var script = document.createElement("script");
+ script.onload = resolve;
+ script.onerror = reject;
+ script.src = "https://unpkg.com/vtk.js";
+ document.head.appendChild(script);
+ } else { resolve() };
+ }
+).then(() => {
+ // element, data and ratio are templated by python
+ var parent_element = $element;
+ var data = $data;
+ render(data, parent_element, $ratio);
+});
From 5204b763eab65363eb6640f896d5fc4d1a1afa20 Mon Sep 17 00:00:00 2001
From: Victor Poughon
Date: Sun, 19 Jan 2025 12:49:55 +0100
Subject: [PATCH 166/518] jupyter_tools: fix async render issue using a unique
id div
---
src/build123d/jupyter_tools.py | 16 ++++++++++------
src/build123d/template_render.js | 27 ++++++++++-----------------
src/build123d/topology/shape_core.py | 6 +++---
3 files changed, 23 insertions(+), 26 deletions(-)
diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py
index 830f603..0b3884c 100644
--- a/src/build123d/jupyter_tools.py
+++ b/src/build123d/jupyter_tools.py
@@ -28,7 +28,7 @@ from json import dumps
import os
from string import Template
from typing import Any, Dict, List
-from IPython.display import Javascript
+from IPython.display import HTML
from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter
DEFAULT_COLOR = [1, 0.8, 0, 1]
@@ -65,8 +65,8 @@ def to_vtkpoly_string(
return writer.GetOutputString()
-def display(shape: Any) -> Javascript:
- """display
+def shape_to_html(shape: Any) -> HTML:
+ """shape_to_html
Args:
shape (Shape): object to display
@@ -75,7 +75,7 @@ def display(shape: Any) -> Javascript:
ValueError: not a valid Shape
Returns:
- Javascript: code
+ HTML: html code
"""
payload: list[dict[str, Any]] = []
@@ -90,6 +90,10 @@ def display(shape: Any) -> Javascript:
"orientation": [0, 0, 0],
}
)
- code = Template(TEMPLATE_JS).substitute(data=dumps(payload), element="element", ratio=0.5)
- return Javascript(code)
+ # A new div with a unique id, plus the JS code templated with the id
+ div_id = "shape-" + str(id(shape))
+ code = Template(TEMPLATE_JS).substitute(data=dumps(payload), div_id=div_id, ratio=0.5)
+ html = HTML(f"")
+
+ return html
diff --git a/src/build123d/template_render.js b/src/build123d/template_render.js
index 8d287f5..afc7bd3 100644
--- a/src/build123d/template_render.js
+++ b/src/build123d/template_render.js
@@ -1,4 +1,4 @@
-function render(data, parent_element, ratio){
+function render(data, div_id, ratio){
// Initial setup
const renderWindow = vtk.Rendering.Core.vtkRenderWindow.newInstance();
@@ -45,18 +45,9 @@ function render(data, parent_element, ratio){
const openglRenderWindow = vtk.Rendering.OpenGL.vtkRenderWindow.newInstance();
renderWindow.addView(openglRenderWindow);
- // Add output to the "parent element"
- var container;
- var dims;
-
- if(typeof(parent_element.appendChild) !== "undefined"){
- container = document.createElement("div");
- parent_element.appendChild(container);
- dims = parent_element.getBoundingClientRect();
- }else{
- container = parent_element.append("").children("div:last-child").get(0);
- dims = parent_element.get(0).getBoundingClientRect();
- };
+ // Get the div container
+ const container = document.getElementById(div_id);
+ const dims = container.parentElement.getBoundingClientRect();
openglRenderWindow.setContainer(container);
@@ -130,8 +121,10 @@ new Promise(
} else { resolve() };
}
).then(() => {
- // element, data and ratio are templated by python
- var parent_element = $element;
- var data = $data;
- render(data, parent_element, $ratio);
+ // data, div_id and ratio are templated by python
+ const div_id = "$div_id";
+ const data = $data;
+ const ratio = $ratio;
+
+ render(data, div_id, ratio);
});
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index d266f01..7ebeaae 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -2117,12 +2117,12 @@ class Shape(NodeMixin, Generic[TOPODS]):
return (vertices, edges)
- def _repr_javascript_(self):
+ def _repr_html_(self):
"""Jupyter 3D representation support"""
- from build123d.jupyter_tools import display
+ from build123d.jupyter_tools import shape_to_html
- return display(self)._repr_javascript_()
+ return shape_to_html(self)._repr_html_()
class Comparable(ABC):
From f8d86e172269de1014029d053e9e37a051d7bbe2 Mon Sep 17 00:00:00 2001
From: Victor Poughon
Date: Fri, 24 Jan 2025 19:04:35 +0100
Subject: [PATCH 167/518] test: update test_jupyter.py
---
tests/test_direct_api/test_jupyter.py | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/tests/test_direct_api/test_jupyter.py b/tests/test_direct_api/test_jupyter.py
index 51053a4..7ae4074 100644
--- a/tests/test_direct_api/test_jupyter.py
+++ b/tests/test_direct_api/test_jupyter.py
@@ -29,28 +29,28 @@ license:
import unittest
from build123d.geometry import Vector
-from build123d.jupyter_tools import to_vtkpoly_string, display
+from build123d.jupyter_tools import to_vtkpoly_string, shape_to_html
from build123d.topology import Solid
class TestJupyter(unittest.TestCase):
- def test_repr_javascript(self):
+ def test_repr_html(self):
shape = Solid.make_box(1, 1, 1)
- # Test no exception on rendering to js
- js1 = shape._repr_javascript_()
+ # Test no exception on rendering to html
+ html1 = shape._repr_html_()
- assert "function render" in js1
+ assert "function render" in html1
def test_display_error(self):
with self.assertRaises(AttributeError):
- display(Vector())
+ shape_to_html(Vector())
with self.assertRaises(ValueError):
to_vtkpoly_string("invalid")
with self.assertRaises(ValueError):
- display("invalid")
+ shape_to_html("invalid")
if __name__ == "__main__":
From edf1dbdaa1fdfa0fcf7ae811a8b55c023fa65a2f Mon Sep 17 00:00:00 2001
From: Victor Poughon
Date: Tue, 21 Jan 2025 18:39:40 +0100
Subject: [PATCH 168/518] jupyter_tools: use uuid for unique shape id
---
src/build123d/jupyter_tools.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py
index 0b3884c..1299a73 100644
--- a/src/build123d/jupyter_tools.py
+++ b/src/build123d/jupyter_tools.py
@@ -26,6 +26,7 @@ license:
# pylint: disable=no-name-in-module
from json import dumps
import os
+import uuid
from string import Template
from typing import Any, Dict, List
from IPython.display import HTML
@@ -92,7 +93,7 @@ def shape_to_html(shape: Any) -> HTML:
)
# A new div with a unique id, plus the JS code templated with the id
- div_id = "shape-" + str(id(shape))
+ div_id = 'shape-' + uuid.uuid4().hex[:8]
code = Template(TEMPLATE_JS).substitute(data=dumps(payload), div_id=div_id, ratio=0.5)
html = HTML(f"")
From 0da16cf7e148775348879433e0805497c40746ba Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 24 Jan 2025 15:19:15 -0500
Subject: [PATCH 169/518] Improving OpenSCAD example
---
docs/OpenSCAD.rst | 103 +++++++++++++++++++++++++++++---------
docs/assets/AngleIron.png | Bin 0 -> 6848 bytes
2 files changed, 78 insertions(+), 25 deletions(-)
create mode 100644 docs/assets/AngleIron.png
diff --git a/docs/OpenSCAD.rst b/docs/OpenSCAD.rst
index 5d2d0d2..899cee9 100644
--- a/docs/OpenSCAD.rst
+++ b/docs/OpenSCAD.rst
@@ -25,7 +25,8 @@ its design philosophy encourages a fundamentally different, often more efficient
starting with lower-dimensional entities like faces and edges and then transforming them
into solids.
-### Why Transition Away from CSG?
+Why Transition Away from CSG?
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
CSG is a powerful method for creating 3D models, but it has limitations when dealing with
complex designs. build123d’s approach offers several advantages:
@@ -75,7 +76,8 @@ using operations such as:
between shapes.
- **Sweeping**: Moves a 2D profile along a defined path to create a 3D form.
-### Refining the Model
+Refining the Model
+^^^^^^^^^^^^^^^^^^
After creating the initial 3D shape, you can refine the model by adding details or making
modifications using build123d's advanced features, such as:
@@ -84,44 +86,87 @@ modifications using build123d's advanced features, such as:
- **Boolean Operations**: Combine, subtract, or intersect 3D shapes to achieve the desired
geometry.
-### Example Comparison
+Example Comparison
+^^^^^^^^^^^^^^^^^^
To illustrate the advantages of this approach, compare a simple model in OpenSCAD and
-build123d:
+build123d of a piece of angle iron:
**OpenSCAD Approach**
.. code-block:: openscad
- // A basic cylinder with a hole
+ $fn = 100; // Increase the resolution for smooth fillets
+
+ // Dimensions
+ length = 100; // 10 cm long
+ width = 30; // 3 cm wide
+ thickness = 4; // 4 mm thick
+ fillet = 5; // 5 mm fillet radius
+ delta = 0.001; // a small number
+
+ // Create the angle iron
difference() {
- cylinder(r=10, h=20);
- translate([0, 0, 5]) cylinder(r=5, h=20);
+ // Outer shape
+ cube([width, length, width], center = false);
+ // Inner shape
+ union() {
+ translate([thickness+fillet,-delta,thickness+fillet])
+ rotate([-90,0,0])
+ cylinder(length+2*delta, fillet,fillet);
+ translate([thickness,-delta,thickness+fillet])
+ cube([width-thickness,length+2*delta,width-fillet],center=false);
+ translate([thickness+fillet,-delta,thickness])
+ cube([width-fillet,length+2*delta,width-thickness],center=false);
+
+ }
}
**build123d Approach**
.. code-block:: python
- from build123d import *
-
- # In Builder mode
- with BuildPart() as cylinder_with_hole:
- with BuildSketch():
- Circle(10)
- extrude(amount=20)
- with BuildSketch(cylinder_with_hole.faces().sort_by(Axis.Z).last):
- Circle(5)
- extrude(amount=-15, mode=Mode.SUBTRACT)
-
- # In Algebra mode
- cyl = extrude(Circle(10), 20)
- cyl -= extrude(Plane(cyl.faces().sort_by(Axis.Z)[-1]) * Circle
+ # Builder mode
+ with BuildPart() as angle_iron:
+ with BuildSketch() as profile:
+ Rectangle(3 * CM, 4 * MM, align=Align.MIN)
+ Rectangle(4 * MM, 3 * CM, align=Align.MIN)
+ extrude(amount=10 * CM)
+ fillet(angle_iron.edges().filter_by(lambda e: e.is_interior), 5 * MM)
-This approach emphasizes creating a 2D profile (such as the **Circle**) and then applying a
-3D operation (like **extrude**) to achieve the desired result. Topological features of the
-part under construction are extracted and used as references for adding further details.
+.. code-block:: python
+
+ # Algebra mode
+ profile = Rectangle(3 * CM, 4 * MM, align=Align.MIN)
+ profile += Rectangle(4 * MM, 3 * CM, align=Align.MIN)
+ angle_iron = extrude(profile, 10 * CM)
+ angle_iron = fillet(angle_iron.edges().filter_by(lambda e: e.is_interior), 5 * MM)
+
+.. image:: ./assets/AngleIron.png
+
+OpenSCAD and build123d offer distinct paradigms for creating 3D models, as demonstrated
+by the angle iron example. OpenSCAD relies on Constructive Solid Geometry (CSG) operations,
+combining and subtracting 3D shapes like cubes and cylinders. Fillets are approximated by
+manually adding high-resolution cylinders, making adjustments cumbersome and less precise.
+This static approach can handle simple models but becomes challenging for complex or iterative designs.
+
+In contrast, build123d emphasizes a profile-driven workflow. It starts with a 2D sketch,
+defining the geometry’s outline, which is then extruded or otherwise transformed into a
+3D model. Features like fillets are applied dynamically by querying topological elements,
+such as edges, using intuitive filtering methods. This approach ensures precision and
+flexibility, making changes straightforward without the need for manual repositioning or realignment.
+
+The build123d methodology is computationally efficient, leveraging mathematical precision
+for features like fillets. By separating the design into manageable steps—sketching, extruding,
+and refining—it aligns with traditional CAD practices and enhances readability, modularity,
+and maintainability. Unlike OpenSCAD, build123d’s dynamic querying of topological features
+allows for easy updates and adjustments, making it better suited for modern, complex, and
+iterative design workflows.
+
+In summary, build123d’s sketch-based paradigm and topological querying capabilities provide
+superior precision, flexibility, and efficiency compared to OpenSCAD’s static, CSG-centric
+approach, making it a better choice for robust and adaptable CAD modeling.
Tips for Transitioning
----------------------
@@ -134,9 +179,17 @@ Tips for Transitioning
inside or outside fillets and chamfers to vertices and edges of an existing part
with precision.
+- **Operational Equivalency and Beyond**: Build123d provides equivalents to almost all
+ features available in OpenSCAD, with the exception of the 3D **minkowski** operation.
+ However, a 2D equivalent, **make_hull**, is available in build123d. Beyond operational
+ equivalency, build123d offers a wealth of additional functionality, including advanced
+ features like topological queries, dynamic filtering, and robust tools for creating complex
+ geometries. By exploring build123d's extensive operations, you can unlock new possibilities
+ and take your designs far beyond the capabilities of OpenSCAD.
+
- **Explore the Documentation**: Dive into build123d’s comprehensive API documentation
to unlock its full potential and discover advanced features.
-
+
By shifting your design mindset from solid-based CSG to a profile-driven approach, you
can fully harness build123d's capabilities to create precise, efficient, and complex models.
Welcome aboard, and happy designing!
diff --git a/docs/assets/AngleIron.png b/docs/assets/AngleIron.png
new file mode 100644
index 0000000000000000000000000000000000000000..1a734f9159fea24f00aadbb1a50802d41c8d09f0
GIT binary patch
literal 6848
zcmeAS@N?(olHy`uVBq!ia0y~yU_8mdz&L?}je&vT=)T#03=9k`#ZI0f92^`RH5@4&
z3=9mCC9V-A!TD(=<%vb94C#6Kxv9Fv$wjHDdBqv|CGVN{+c7XO=zJW@xT@ViL)Jdm6{K{x^_l2;<5yE$oY2j~vMWbG
z;Y4ZwA*M%1yJLz@DOPXdF?rwC?v*f+JB8nRRi8k^CzH&tRj%h1P9B^oQ&qJr{l?YH
zSNFeKyYBpRrOF%jTpOOQzxTa%*{fqOU%t#_U|3(d_zgEhLrA9z!xI5dh7*dD7$!KY
zFeQ8Tu#@SSFl>Q#o{_*i{d+XmX>sDT8S>mY@xpLK!kBQ$E
zqI&aB?^97IR66PPrD|_S$LFV0S-o4Ec{zrZDI5PFyo#)f{W7Q;geMJ;EG9A4NR9n*2Z1
z-fFJj&FPFPZYsjhIAs~03Y~P3JjJLn-$QMgmj4R@_XT_|MuMT12%lapPwV6Jja^piQq|7&!2Y!W#Y?A?)3aP
zykGmbQNxjrCtXW_we+Rye>?Z@RT=}sXQ7j#D^KlQJe!f>jK9kCkf|0?woD@Glek}f
ztr8T>V`JEG)TM~At%%{3qYAf2sYZH1kulqGP^4{1m?qBWk5~dUlb_Lz7T~AVEbt;
z&DUR*lVT0$?4v-#+Id^&v7G4hUJNkA{<^DI`3_?>&R8|+g%g|s?
zuvV2ksdeO{uoc6$_@IerHtB5AF`mNgAT+5n<&?8!^JGxodBhQ5?CJ7pvcH|>|KA5a
zjjR5Dp6}}7lAUhxJ$;MR=lx}0ZeG71@aL27iTcPFpBY!|yQF;c_18t!&sBDn`|16)
zsop1iys-M;`Iqn4{kmkcka@u~ul^@D{{5XE{_k_u{`LRQ`n{W
zPCr^#zW>c>2BFD5D&}j8e?R#p&KQuLKc$wDVS*OCU^H^y{b2nRh7GkYMO|+y|2@%W
z_zEjQ-Cs^TvuVv6Z-$1IUi~R9d&<167(5(>CbBe$APUQG@=;(<~~Te^O$QNPujcyl_V!%5`gdZLSp_4d0y3=LnV$epa)AOB62
z;XuezcOix#Rg{u`$+xn5J3z9XLRf17&$nOWcbY00%=K_`DY2TnNuSYU0!kHO%mo|R(($5fcWOM-1O<3JfGVgbqJQ(tPVHl15K^3E2`;Z$7`)U_e96)v
zv((M#^rjd0EHyt1F(^!*ic+hs+&b6%{q$N+heOz%6jXh)Jo4sr1{E(AMWtPL^FU10
zcmP$FMO|gd5#Ll9CNNCgqg1^w7{ug2b}1+nimeS8E`|Q`+Eo2~2{etUFf8!`M`}>E
zb^aaw-$D&7h=hWpR-Pi~`8`jS^={sFP>u?zQejjGg{Pz^qBlQ-VyeNV%YJ_$L6Pl~!-`M3_`Qy-O;TjT%y
zdVhK!i$Y?3z&BP0$$5O=9-Tgz=kn#$J`sh)ut`@=T|RiPQsMnpzQ_0fUoHpLn2AcB
zxiM?xlq`GC9_$l
zJ8%2$lc$-r98_*vxjw4+^nYJRNBxe2tC?51sN9tDXRj6%d>*}e^P`HXQ&kLl56W(H
zx)*S+v{7cQ3gfcPla^dM2C5<2iW;U0dupvbvGcKxxzeM#+uJ_=`1rWu!-Ioe>xEWW
zfn!IQfA`@{`pq({dmcyZHtdQ2zi#IuZI*4F#-3WqpT5hUKQEgv!u_PTqE>y|-)k@8
zyQ*xruVB^__MG&+WP|wsmpA*BlpZC1Pk6U*j_2h^7XK1=8}@v^-M)HfM8fJDd8(1G
zZ$zE_o%nlgV$Hi4p&d>tp6ApaTU^WQ*%tG6pNfH}is!M*2jgU9%^$zmv2&w^U)A%q
z3q6>oPbpKInHm`x@7ttSe}ChNvkYoyt;**d-}^1mrtVM0KYf;M^A5^-nOaUUt~73t
zW_>j6_oXK73nz8M8#r#SowB5-_;{t=^S|E~>V`j<*9+Wowim5k>HlKKM%(9opEJzcJ}$o7T=C__MCk?2y&Wn$)$-XsY^dH)
z>TK!S>-@4t=#{gI`j`DTJ3BhU9^am{eo4$@3%`F}?yC*&>{>X-b&K@jANTeDN7sD2
znVuTYwSn=RiskfM0q2DBqgV6ax9*r|MlBgSf9B_BLBsF8KTR?>q~J=p|b`(jvv~TC1nl*1laLc3kz|w4TeI`}57)
zK0bWsSo5#q%l=$u+YPcg+w*oF{OId!sa+;Do!QciZ|U3c8*!%9i`3
zF^_Uj&Ja7c`d075rEjnMEf?mOvnkNFUZFAZj??7AIWng-qcorQi9h3fvZO3oLVjPl
zKs;aD#}|R|Itv%M6m^4~c-+Eo-^#lZ#}2>C2>q$OvBgX1r0#?$ndueLMq*u$r&J&SF}j)XsxF
z92Z+yI#Q1M*xfd4y}H;!@0;cFbp1TjwvXp`vkM9uTI3x&zq!M`UvBRHb(ded9Qo32
z+pV!?zDHO~@$swL#(kH!NFDxhz1+TkdHR1_cNd;Gp2shCNdB#}m$R>nc`!T9obTP9
zXT1d%Bf70al_qsoTixjHD0sKS>9tMyncCMf`f~f9v~TCrudB%Z^7+{8@5)M#UM85=
zeSCfY*xl&%i+|aFZj{_#7xeA!m)X)WwYlH2zU=z9{qJjitE86|=Qpj}AYS`cX??nN
zN6Mi&*6XgVdKB?wbJvl=u$KCdXY(Jw?7q9B`gM231xdH;=eIk4+?JgG>+|)xOM7R2
zzxBW7rTx}@=AVm}v+X#My*d2n$5^v>pWAGm?#%4>P|Gmtt_Elu2~=XYxQVfq|(d@SI)^EE!=oM
z(xh*)ebu+5?|*WPrdou}-EscsFRo*@N{=r4+sB@M-j%`x&b8idp^q&d&tUjgTAqE~~gpa1XUd9E17C8;+z
zxIa1m_h$Frif>6@7*7?K>TCE-SQBBi*Pk
ztl!`+q%ZXG&C!a$jWg^0T#62deQfEwGu^5@=9O<-k%I5ytB;=;Jl-z-J8$A0xR0&|
zoO`fC(*3w^VT&ZF@7B9cPQCNVtfi1lvE;n$`pyqeuD$xVd%sQrYk<7#qZ{>C_5ZBa
zcI|jFi>EJt-rYU70?w^ckv*Qh?|1-T%=??&doQ+pFWkIk>f_L*u12Rfo%%n&OwQSI
zb@o
zl-;GQLDuCW$B){jhdNspO69RJT=z)SNS9OE*|NNOuZ7j@ARom(?>)Mbp7X86Bu*8+
zwtW$UXuf?cz1%Kv=4oAq{6$CEkWYR)rfZP(+Z`HwkeH(0LZe|pAQW&K2OQ3Z`u*KOcFq|bKWLK)$cnxJI64b*j8ru~{zR#4FW_}LEoKfAZq
z?(>K%EV-(<-@U1!bWM^{hBMY@j!r$KS@7e!vcGw5IlI78hg~sS5Fi?
zwVlbU4YzFNG7kBh#&C1B&ZB3)?BZ3{SA0;{e{B9g`w@Fv(X^_`vl$&`ZF|Tmn|wE~
zJa@kCq+l6Y^P`0)&8G?;x8OBrZpgMM*I9PO!%upOX7u-So?qtG?Emn4m7K@(z72Pe
z6dEpd-E{x`^siTqdk_D8dNJ=#wtU?0szRNAmajV=H=lFdQugY*bxeV*v*qcHcdcB%
zpYuHOK7IePSA{(*u3cL-o6(^#RQ{A_qlcg2t)jX6CV^7m@c_P@b7s6xr}(NYzp?+n
zQI-F_y4=%SUp4v^W|z*h;Pabh_#^gm0AI}EosVC2ru3bRXB9mDXg(+&-DR%3tEB(9
zdNFU0{qYZet4{5ZJYe-G<15EyUcbC!4|Zf~r1!cQ9j`dOb=AT(mq6WEjdKC#(mI~-
z@c(;~`ej~-_N36R$4~lso*t+HC(W&Kwsx{_j!tjB+i~L2V|MxVZ>wF3+O*cHt(H38
zu{wJ7*FWrX>mJ|vc*tVH@87>aJ~-ICwQjmc+~cj)JJpT_@a1ez*Z<}weDAn#;hc`g
z$K^|}SDn9RIC0Ohe}AWcJUO{uNO0nntRm^QBAbQGX|rw0Yi_K4UmT#nEi74i^>I%n
z%l0eh)K+iVTBX@{PBu|*`r`_LwQjcgJ0sF$j=L1`t(@Z
zZGY7ql}X+|PVWC>W%qmi;(BKtp;KD9Yo$KH_=W6Y}&mJ@I
zOev@_?(3U9Rr1u#_Nh;}O7iCW+^xDht=Qe!(qOK7Nz95z0q1O7id@y4D@*S5w0HaL
zRTFbx?SA4pr>yerEnBU6J=O(0|K?fqEC0W{&9Bw(kH^>jOsv^aZE&BLo#&}Ts>CVD
zo8Lf@CROw2;`NU|y0<_6tS*1#_uIoerkh`wd;pYUXPmd_dwXodwdmEFwhNPmU5b2<
zl|OrFE1A3hUbcK(T%ShShpyJ_S9LPGF(lFKHhV;A4tvFrT%d;R~P7K^T*oOk~Fu8Z0E=l|dOU%mT6y13#y
zozpuXcPyN9wBw1GgnLQo`tFXEhH+CX7u%`5J6%_1`&h3}oad>IpUTZ0N*ncl{n9#S
z3mWY)tiMzJ_vY=lr*E|F;D?r9>UxiEyevto-=F<;2gAe`%eC6?Uh`DD{C#9sw{7u4
zndg106(((X`by;co1cDLk|Sl+L^S@#f{fIvzjk
zP}vZAi?v>UGnd+V+5bK==3Xj{LZ*_(`|V%3ZNIJids3FW3Zu~DJBG4KoD(<-efm9^
zrkoMBx}-XZLAA)ZVN0iq1E<>K9sip*RQXKtV9J?Ld^}6gvw>IOc$Sc7gQ=9v{hq~-
zmo1#r1>zY_39eP*+>ivz;!(C?Gd!5qbQB+dt2~Lp`%_EbTh&Pn-XB`}-l|Pv;Qr9k
zS0&)tz%x}X#Gbq@x^eA9+xwk+Roi^#S{F_AVA`tQ_e9Wh!8V(P&9A43oqY4t&t*^T%XHWC
zn%3LrW;1mgPC0Dj)tR#WcCSwNk5}f?>;GNNSNiq$->-e!6I#0W#64Ij#q%`4MRQ`z
z)&Clsb)s*--mFut`)Ko*c^%eE&g@j~Uh!pS%EfBcWhMDXOWFRdR$9NcD*be)^63>N
zDy!G5YTRBG|L^OgeU_k}$O$EvNlvSzzH_Yp9=d0rR-^fp!>jIC*xK6a{#I(Jc^$V`
zd}7s|YJWH3&vX6McimlgRoPqEGeNhDZO4h~PjT1Zf6raJDF`%la!=zo69dv13(EKr
iInyTS^D4*xvu_M)y3Bw4cP9e_1B0ilpUXO@geCyJlJx@s
literal 0
HcmV?d00001
From 9f5b4eaa67b91e37bce1ba6082dee6255a6ce463 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 24 Jan 2025 19:50:16 -0500
Subject: [PATCH 170/518] Added section on moving shapes
---
docs/index.rst | 1 +
docs/moving_objects.rst | 130 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 131 insertions(+)
create mode 100644 docs/moving_objects.rst
diff --git a/docs/index.rst b/docs/index.rst
index 7a76273..beec8ec 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -108,6 +108,7 @@ Table Of Contents
key_concepts.rst
key_concepts_builder.rst
key_concepts_algebra.rst
+ moving_objects.rst
OpenSCAD.rst
introductory_examples.rst
tutorials.rst
diff --git a/docs/moving_objects.rst b/docs/moving_objects.rst
new file mode 100644
index 0000000..088973c
--- /dev/null
+++ b/docs/moving_objects.rst
@@ -0,0 +1,130 @@
+Moving Objects
+==============
+
+In build123d, there are several methods to move objects. These methods vary
+based on the mode of operation and provide flexibility for object placement
+and orientation. Below, we outline the three main approaches to moving objects:
+builder mode, algebra mode, and direct manipulation methods.
+
+Builder Mode
+------------
+In builder mode, object locations are defined before the objects themselves are
+created. This approach ensures that objects are positioned correctly during the
+construction process. The following tools are commonly used to specify locations:
+
+1. :class:`~build_common.Locations` Use this to define a specific location for the objects within the `with` block.
+2. :class:`~build_common.GridLocations` Arrange objects in a grid pattern.
+3. :class:`~build_common.PolarLocations` Position objects in a circular pattern.
+4. :class:`~build_common.HexLocations` Arrange objects in a hexagonal grid.
+
+.. note::
+ The location(s) of an object must be defined prior to its creation when using builder mode.
+
+Example:
+
+.. code-block:: python
+
+ with Locations((10, 20, 30)):
+ Box(5, 5, 5)
+
+Algebra Mode
+------------
+In algebra mode, object movement is expressed using algebraic operations. The
+:class:`~geometry.Pos` function, short for Position, represents a location, which can be combined
+with objects or planes to define placement.
+
+1. ``Pos() * shape``: Applies a position to a shape.
+2. ``Plane() * Pos() * shape``: Combines a plane with a position and applies it to a shape.
+
+Rotation is an important concept in this mode. A :class:`~geometry.Rotation` represents a location
+with orientation values set, which can be used to define a new location or modify
+an existing one.
+
+Example:
+
+.. code-block:: python
+
+ rotated_box = Rotation(45, 0, 0) * box
+
+Direct Manipulation Methods
+---------------------------
+The following methods allow for direct manipulation of a shape's location and orientation
+after it has been created. These methods offer a mix of absolute and relative transformations.
+
+Position
+^^^^^^^^
+- **Absolute Position:** Set the position directly.
+
+.. code-block:: python
+
+ shape.position = (x, y, z)
+
+- **Relative Position:** Adjust the position incrementally.
+
+.. code-block:: python
+
+ shape.position += (x, y, z)
+ shape.position -= (x, y, z)
+
+
+Orientation
+^^^^^^^^^^^
+- **Absolute Orientation:** Set the orientation directly.
+
+.. code-block:: python
+
+ shape.orientation = (X, Y, Z)
+
+- **Relative Orientation:** Adjust the orientation incrementally.
+
+.. code-block:: python
+
+ shape.orientation += (X, Y, Z)
+ shape.orientation -= (X, Y, Z)
+
+Movement Methods
+^^^^^^^^^^^^^^^^
+- **Relative Move:**
+
+.. code-block:: python
+
+ shape.move(Location)
+
+- **Relative Move of Copy:**
+
+.. code-block:: python
+
+ relocated_shape = shape.moved(Location)
+
+- **Absolute Move:**
+
+.. code-block:: python
+
+ shape.locate(Location)
+
+- **Absolute Move of Copy:**
+
+.. code-block:: python
+
+ relocated_shape = shape.located(Location)
+
+
+Transformation a.k.a. Translation and Rotation
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. note::
+ These methods don't work in the same way as the previous methods in that they don't just change
+ the object's internal :class:`~geometry.Location` but transform the base object itself which
+ is quite slow and potentially problematic.
+
+- **Translation:** Move a shape relative to its current position.
+
+.. code-block:: python
+
+ relocated_shape = shape.translate(x, y, z)
+
+- **Rotation:** Rotate a shape around a specified axis by a given angle.
+
+.. code-block:: python
+
+ rotated_shape = shape.rotate(Axis, angle_in_degrees)
From c47c81a89304b46019d12dafc170d7f4bb29b1ba Mon Sep 17 00:00:00 2001
From: snoyer
Date: Sat, 25 Jan 2025 12:45:47 +0400
Subject: [PATCH 171/518] allow to filter and group by property
---
src/build123d/topology/shape_core.py | 11 +++++++++--
tests/test_direct_api/test_shape_list.py | 17 +++++++++++++++++
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index 7ebeaae..0344da6 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -2351,7 +2351,7 @@ class ShapeList(list[T]):
def filter_by(
self,
- filter_by: ShapePredicate | Axis | Plane | GeomType,
+ filter_by: ShapePredicate | Axis | Plane | GeomType | property,
reverse: bool = False,
tolerance: float = 1e-5,
) -> ShapeList[T]:
@@ -2446,6 +2446,8 @@ class ShapeList(list[T]):
# convert input to callable predicate
if callable(filter_by):
predicate = filter_by
+ elif isinstance(filter_by, property):
+ predicate = filter_by.__get__
elif isinstance(filter_by, Axis):
predicate = axis_parallel_predicate(filter_by, tolerance=tolerance)
elif isinstance(filter_by, Plane):
@@ -2524,7 +2526,9 @@ class ShapeList(list[T]):
def group_by(
self,
- group_by: Callable[[Shape], K] | Axis | Edge | Wire | SortBy = Axis.Z,
+ group_by: (
+ Callable[[Shape], K] | Axis | Edge | Wire | SortBy | property
+ ) = Axis.Z,
reverse=False,
tol_digits=6,
) -> GroupBy[T, K]:
@@ -2594,6 +2598,9 @@ class ShapeList(list[T]):
elif callable(group_by):
key_f = group_by
+ elif isinstance(group_by, property):
+ key_f = group_by.__get__
+
else:
raise ValueError(f"Unsupported group_by function: {group_by}")
diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py
index 98139d8..4892e5e 100644
--- a/tests/test_direct_api/test_shape_list.py
+++ b/tests/test_direct_api/test_shape_list.py
@@ -89,6 +89,12 @@ class TestShapeList(unittest.TestCase):
self.assertAlmostEqual(smallest.area, math.pi * 1**2, 5)
self.assertAlmostEqual(largest.area, math.pi * 2**2, 5)
+ def test_sort_by_property(self):
+ box1 = Box(2, 2, 2)
+ box2 = Box(2, 2, 2).translate((1, 1, 1))
+ assert len((box1 + box2).edges().filter_by(Edge.is_interior)) == 6
+ assert len((box1 - box2).edges().filter_by(Edge.is_interior)) == 3
+
def test_sort_by_invalid(self):
with self.assertRaises(ValueError):
Solid.make_box(1, 1, 1).faces().sort_by(">Z")
@@ -187,6 +193,17 @@ class TestShapeList(unittest.TestCase):
self.assertEqual([len(group) for group in result], [1, 3, 2])
+ def test_group_by_property(self):
+ box1 = Box(2, 2, 2)
+ box2 = Box(2, 2, 2).translate((1, 1, 1))
+ g1 = (box1 + box2).edges().group_by(Edge.is_interior)
+ assert len(g1.group(True)) == 6
+ assert len(g1.group(False)) == 24
+
+ g2 = (box1 - box2).edges().group_by(Edge.is_interior)
+ assert len(g2.group(True)) == 3
+ assert len(g2.group(False)) == 18
+
def test_group_by_retrieve_groups(self):
boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)]
boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)]
From 89a09f38decb5e9dc65b10a42d9e07e1b1b0e6a6 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Sat, 25 Jan 2025 12:52:54 +0400
Subject: [PATCH 172/518] appease mypy
---
src/build123d/topology/shape_core.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index 0344da6..c506b86 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -2447,7 +2447,10 @@ class ShapeList(list[T]):
if callable(filter_by):
predicate = filter_by
elif isinstance(filter_by, property):
- predicate = filter_by.__get__
+
+ def predicate(obj):
+ return filter_by.__get__(obj)
+
elif isinstance(filter_by, Axis):
predicate = axis_parallel_predicate(filter_by, tolerance=tolerance)
elif isinstance(filter_by, Plane):
From 677e47fedc3113d53a69ccffb90288a3c9f4438d Mon Sep 17 00:00:00 2001
From: Robin Vobruba
Date: Mon, 27 Jan 2025 08:26:46 +0100
Subject: [PATCH 173/518] README: Adds links
---
README.md | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 8bb86b6..38033e7 100644
--- a/README.md
+++ b/README.md
@@ -17,13 +17,13 @@
[](https://pepy.tech/project/build123d)
[](https://pypi.org/project/build123d/)
-Build123d is a python-based, parametric, boundary representation (BREP) modeling framework for 2D and 3D CAD. It's built on the Open Cascade geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as FreeCAD and SolidWorks.
+Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks.
-Build123d could be considered as an evolution of [CadQuery](https://cadquery.readthedocs.io/en/latest/index.html) where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc.
+Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc.
The documentation for **build123d** can found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html).
-There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with CadQuery) where you can ask for help in the build123d channel.
+There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with [CadQuery]) where you can ask for help in the build123d channel.
The recommended method for most users is to install **build123d** is:
```
@@ -54,3 +54,8 @@ python3 -m pip install -e .
```
Further installation instructions are available (e.g. Poetry) see the [installation section on readthedocs](https://build123d.readthedocs.io/en/latest/installation.html).
+
+[BREP]: https://en.wikipedia.org/wiki/Boundary_representation
+[CadQuery]: https://cadquery.readthedocs.io/en/latest/index.html
+[FreeCAD]: https://www.freecad.org/
+[Open Cascade]: https://dev.opencascade.org/
From 6d5aaa4bea90e439af17e49fc1e55181b7d4335e Mon Sep 17 00:00:00 2001
From: Robin Vobruba
Date: Mon, 27 Jan 2025 08:28:48 +0100
Subject: [PATCH 174/518] README: Shorten a bit
... and make compatible with the original Markdown standard,
where more empty lines are required
---
README.md | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 38033e7..d0987f1 100644
--- a/README.md
+++ b/README.md
@@ -26,17 +26,21 @@ The documentation for **build123d** can found at [readthedocs](https://build123d
There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with [CadQuery]) where you can ask for help in the build123d channel.
The recommended method for most users is to install **build123d** is:
+
```
pip install build123d
```
To get the latest non-released version of **build123d** one can install from GitHub using one of the following two commands:
-In Linux/MacOS, use the following command:
+Linux/MacOS:
+
```
python3 -m pip install git+https://github.com/gumyr/build123d
```
-In Windows, use the following command:
+
+Windows:
+
```
python -m pip install git+https://github.com/gumyr/build123d
```
@@ -46,7 +50,8 @@ If you receive errors about conflicting dependencies, you can retry the installa
python3 -m pip install --upgrade pip
```
-Development install
+Development install:
+
```
git clone https://github.com/gumyr/build123d.git
cd build123d
From 8ddfee219d9876e021371a8ab18e9ad7096f5b9f Mon Sep 17 00:00:00 2001
From: Robin Vobruba
Date: Mon, 27 Jan 2025 08:30:34 +0100
Subject: [PATCH 175/518] README: Fixes two minor language issues
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index d0987f1..91d23f1 100644
--- a/README.md
+++ b/README.md
@@ -21,11 +21,11 @@ Build123d is a python-based, parametric, [boundary representation (BREP)][BREP]
Build123d could be considered as an evolution of [CadQuery] where the somewhat restrictive Fluent API (method chaining) is replaced with stateful context managers - e.g. `with` blocks - thus enabling the full python toolbox: for loops, references to objects, object sorting and filtering, etc.
-The documentation for **build123d** can found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html).
+The documentation for **build123d** can be found at [readthedocs](https://build123d.readthedocs.io/en/latest/index.html).
There is a [***Discord***](https://discord.com/invite/Bj9AQPsCfx) server (shared with [CadQuery]) where you can ask for help in the build123d channel.
-The recommended method for most users is to install **build123d** is:
+The recommended method for most users to install **build123d** is:
```
pip install build123d
From bdd11a92501dfc712cddbcf9fa2238b171c55f74 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Tue, 28 Jan 2025 07:00:40 +0400
Subject: [PATCH 176/518] add property support to `sort_by`
---
src/build123d/topology/shape_core.py | 5 ++++-
tests/test_direct_api/test_shape_list.py | 16 ++++++++++++----
2 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index c506b86..46310ca 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -2635,7 +2635,7 @@ class ShapeList(list[T]):
def sort_by(
self,
- sort_by: Axis | Callable[[T], K] | Edge | Wire | SortBy = Axis.Z,
+ sort_by: Axis | Callable[[T], K] | Edge | Wire | SortBy | property = Axis.Z,
reverse: bool = False,
) -> ShapeList[T]:
"""sort by
@@ -2661,6 +2661,9 @@ class ShapeList(list[T]):
# If a callable is provided, use it directly as the key
objects = sorted(self, key=sort_by, reverse=reverse)
+ elif isinstance(sort_by, property):
+ objects = sorted(self, key=sort_by.__get__, reverse=reverse)
+
elif isinstance(sort_by, Axis):
if sort_by.wrapped is None:
raise ValueError("Cannot sort by an empty axis")
diff --git a/tests/test_direct_api/test_shape_list.py b/tests/test_direct_api/test_shape_list.py
index 4892e5e..c1c967f 100644
--- a/tests/test_direct_api/test_shape_list.py
+++ b/tests/test_direct_api/test_shape_list.py
@@ -90,10 +90,12 @@ class TestShapeList(unittest.TestCase):
self.assertAlmostEqual(largest.area, math.pi * 2**2, 5)
def test_sort_by_property(self):
- box1 = Box(2, 2, 2)
- box2 = Box(2, 2, 2).translate((1, 1, 1))
- assert len((box1 + box2).edges().filter_by(Edge.is_interior)) == 6
- assert len((box1 - box2).edges().filter_by(Edge.is_interior)) == 3
+ box1 = Box(1, 1, 1)
+ box2 = Box(2, 2, 2)
+ box3 = Box(3, 3, 3)
+ unsorted_boxes = ShapeList([box2, box3, box1])
+ assert unsorted_boxes.sort_by(Solid.volume) == [box1, box2, box3]
+ assert unsorted_boxes.sort_by(Solid.volume, reverse=True) == [box3, box2, box1]
def test_sort_by_invalid(self):
with self.assertRaises(ValueError):
@@ -125,6 +127,12 @@ class TestShapeList(unittest.TestCase):
self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2)
self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1)
+ def test_filter_by_property(self):
+ box1 = Box(2, 2, 2)
+ box2 = Box(2, 2, 2).translate((1, 1, 1))
+ assert len((box1 + box2).edges().filter_by(Edge.is_interior)) == 6
+ assert len((box1 - box2).edges().filter_by(Edge.is_interior)) == 3
+
def test_first_last(self):
vertices = (
Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1)))
From 8fe3ec18af0649e8276d7bad4aee3ebe54f2c0d2 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 28 Jan 2025 21:52:04 -0500
Subject: [PATCH 177/518] Added Shape static_moments, matrix_of_inertia,
principal_properties and radius_of_gyration properties method
---
src/build123d/topology/shape_core.py | 126 +++++++++++++++++
tests/test_direct_api/test_mass_properties.py | 130 ++++++++++++++++++
2 files changed, 256 insertions(+)
create mode 100644 tests/test_direct_api/test_mass_properties.py
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index 46310ca..b2d645f 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -449,6 +449,48 @@ class Shape(NodeMixin, Generic[TOPODS]):
if self.wrapped is not None:
self.wrapped.Location(value.wrapped)
+ @property
+ def matrix_of_inertia(self) -> list[list[float]]:
+ """
+ Compute the inertia matrix (moment of inertia tensor) of the shape.
+
+ The inertia matrix represents how the mass of the shape is distributed
+ with respect to its reference frame. It is a 3×3 symmetric tensor that
+ describes the resistance of the shape to rotational motion around
+ different axes.
+
+ Returns:
+ list[list[float]]: A 3×3 nested list representing the inertia matrix.
+ The elements of the matrix are given as:
+
+ | Ixx Ixy Ixz |
+ | Ixy Iyy Iyz |
+ | Ixz Iyz Izz |
+
+ where:
+ - Ixx, Iyy, Izz are the moments of inertia about the X, Y, and Z axes.
+ - Ixy, Ixz, Iyz are the products of inertia.
+
+ Example:
+ >>> obj = MyShape()
+ >>> obj.matrix_of_inertia
+ [[1000.0, 50.0, 0.0],
+ [50.0, 1200.0, 0.0],
+ [0.0, 0.0, 300.0]]
+
+ Notes:
+ - The inertia matrix is computed relative to the shape's center of mass.
+ - It is commonly used in structural analysis, mechanical simulations,
+ and physics-based motion calculations.
+ """
+ properties = GProp_GProps()
+ BRepGProp.VolumeProperties_s(self.wrapped, properties)
+ inertia_matrix = properties.MatrixOfInertia()
+ matrix = []
+ for i in range(3):
+ matrix.append([inertia_matrix.Value(i + 1, j + 1) for j in range(3)])
+ return matrix
+
@property
def orientation(self) -> Vector | None:
"""Get the orientation component of this Shape's Location"""
@@ -479,6 +521,59 @@ class Shape(NodeMixin, Generic[TOPODS]):
loc.position = Vector(value)
self.location = loc
+ @property
+ def principal_properties(self) -> list[tuple[Vector, float]]:
+ """
+ Compute the principal moments of inertia and their corresponding axes.
+
+ Returns:
+ list[tuple[Vector, float]]: A list of tuples, where each tuple contains:
+ - A `Vector` representing the axis of inertia.
+ - A `float` representing the moment of inertia for that axis.
+
+ Example:
+ >>> obj = MyShape()
+ >>> obj.principal_properties
+ [(Vector(1, 0, 0), 1200.0),
+ (Vector(0, 1, 0), 1000.0),
+ (Vector(0, 0, 1), 300.0)]
+ """
+ properties = GProp_GProps()
+ BRepGProp.VolumeProperties_s(self.wrapped, properties)
+ principal_props = properties.PrincipalProperties()
+ principal_moments = principal_props.Moments()
+ return [
+ (Vector(principal_props.FirstAxisOfInertia()), principal_moments[0]),
+ (Vector(principal_props.SecondAxisOfInertia()), principal_moments[1]),
+ (Vector(principal_props.ThirdAxisOfInertia()), principal_moments[2]),
+ ]
+
+ @property
+ def static_moments(self) -> tuple[float, float, float]:
+ """
+ Compute the static moments (first moments of mass) of the shape.
+
+ The static moments represent the weighted sum of the coordinates
+ with respect to the mass distribution, providing insight into the
+ center of mass and mass distribution of the shape.
+
+ Returns:
+ tuple[float, float, float]: The static moments (Mx, My, Mz),
+ where:
+ - Mx is the first moment of mass about the YZ plane.
+ - My is the first moment of mass about the XZ plane.
+ - Mz is the first moment of mass about the XY plane.
+
+ Example:
+ >>> obj = MyShape()
+ >>> obj.static_moments
+ (150.0, 200.0, 50.0)
+
+ """
+ properties = GProp_GProps()
+ BRepGProp.VolumeProperties_s(self.wrapped, properties)
+ return properties.StaticMoments()
+
# ---- Class Methods ----
@classmethod
@@ -1486,6 +1581,37 @@ class Shape(NodeMixin, Generic[TOPODS]):
return ShapeList(projected_faces)
+ def radius_of_gyration(self, axis: Axis) -> float:
+ """
+ Compute the radius of gyration of the shape about a given axis.
+
+ The radius of gyration represents the distance from the axis at which the entire
+ mass of the shape could be concentrated without changing its moment of inertia.
+ It provides insight into how mass is distributed relative to the axis and is
+ useful in structural analysis, rotational dynamics, and mechanical simulations.
+
+ Args:
+ axis (Axis): The axis about which the radius of gyration is computed.
+ The axis should be defined in the same coordinate system
+ as the shape.
+
+ Returns:
+ float: The radius of gyration in the same units as the shape's dimensions.
+
+ Example:
+ >>> obj = MyShape()
+ >>> axis = Axis((0, 0, 0), (0, 0, 1))
+ >>> obj.radius_of_gyration(axis)
+ 5.47
+
+ Notes:
+ - The radius of gyration is computed based on the shape’s mass properties.
+ - It is useful for evaluating structural stability and rotational behavior.
+ """
+ properties = GProp_GProps()
+ BRepGProp.VolumeProperties_s(self.wrapped, properties)
+ return properties.RadiusOfGyration(axis.wrapped)
+
def relocate(self, loc: Location):
"""Change the location of self while keeping it geometrically similar
diff --git a/tests/test_direct_api/test_mass_properties.py b/tests/test_direct_api/test_mass_properties.py
new file mode 100644
index 0000000..df7d80c
--- /dev/null
+++ b/tests/test_direct_api/test_mass_properties.py
@@ -0,0 +1,130 @@
+"""
+build123d tests
+
+name: test_mass_properties.py
+by: Gumyr
+date: January 28, 2025
+
+desc:
+ This python module contains tests for shape properties.
+
+license:
+
+ Copyright 2025 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 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 build123d.objects_part import Box, Cylinder, Sphere
+from build123d.geometry import Align, Axis
+from build123d import Sphere, Align, Axis
+from math import pi
+
+
+class TestMassProperties(unittest.TestCase):
+
+ def test_sphere(self):
+ r = 2 # Sphere radius
+ sphere = Sphere(r)
+
+ # Expected mass properties
+ volume = (4 / 3) * pi * r**3
+ expected_static_moments = (0, 0, 0) # COM at (0,0,0)
+ expected_inertia = (2 / 5) * volume * r**2 # Ixx = Iyy = Izz
+
+ # Test static moments (should be zero if centered at origin)
+ self.assertAlmostEqual(
+ sphere.static_moments[0], expected_static_moments[0], places=5
+ )
+ self.assertAlmostEqual(
+ sphere.static_moments[1], expected_static_moments[1], places=5
+ )
+ self.assertAlmostEqual(
+ sphere.static_moments[2], expected_static_moments[2], places=5
+ )
+
+ # Test matrix of inertia (diagonal and equal for a sphere)
+ inertia_matrix = sphere.matrix_of_inertia
+ self.assertAlmostEqual(inertia_matrix[0][0], expected_inertia, places=5)
+ self.assertAlmostEqual(inertia_matrix[1][1], expected_inertia, places=5)
+ self.assertAlmostEqual(inertia_matrix[2][2], expected_inertia, places=5)
+
+ # Test principal properties (should match matrix of inertia)
+ principal_axes, principal_moments = zip(*sphere.principal_properties)
+ self.assertAlmostEqual(principal_moments[0], expected_inertia, places=5)
+ self.assertAlmostEqual(principal_moments[1], expected_inertia, places=5)
+ self.assertAlmostEqual(principal_moments[2], expected_inertia, places=5)
+
+ # Test radius of gyration (should be sqrt(2/5) * r)
+ expected_radius_of_gyration = (2 / 5) ** 0.5 * r
+ self.assertAlmostEqual(
+ sphere.radius_of_gyration(Axis.X), expected_radius_of_gyration, places=5
+ )
+
+ def test_cube(self):
+ side = 2
+ cube = Box(side, side, side, align=Align.CENTER)
+
+ # Expected values
+ volume = side**3
+ expected_static_moments = (0, 0, 0) # Centered
+ expected_inertia = (1 / 6) * volume * side**2 # Ixx = Iyy = Izz
+
+ # Test inertia matrix (should be diagonal)
+ inertia_matrix = cube.matrix_of_inertia
+ self.assertAlmostEqual(inertia_matrix[0][0], expected_inertia, places=5)
+ self.assertAlmostEqual(inertia_matrix[1][1], expected_inertia, places=5)
+ self.assertAlmostEqual(inertia_matrix[2][2], expected_inertia, places=5)
+
+ # Test principal moments (should be equal)
+ principal_axes, principal_moments = zip(*cube.principal_properties)
+ self.assertAlmostEqual(principal_moments[0], expected_inertia, places=5)
+ self.assertAlmostEqual(principal_moments[1], expected_inertia, places=5)
+ self.assertAlmostEqual(principal_moments[2], expected_inertia, places=5)
+
+ # Test radius of gyration (should be sqrt(1/6) * side)
+ expected_radius_of_gyration = (1 / 6) ** 0.5 * side
+ self.assertAlmostEqual(
+ cube.radius_of_gyration(Axis.X), expected_radius_of_gyration, places=5
+ )
+
+ def test_cylinder(self):
+ r, h = 2, 5
+ cylinder = Cylinder(r, h, align=Align.CENTER)
+
+ # Expected values
+ volume = pi * r**2 * h
+ expected_inertia_xx = (1 / 12) * volume * (3 * r**2 + h**2) # Ixx = Iyy
+ expected_inertia_zz = (1 / 2) * volume * r**2 # Iz about Z-axis
+
+ # Test principal moments (should align with Z)
+ principal_axes, principal_moments = zip(*cylinder.principal_properties)
+ self.assertAlmostEqual(principal_moments[0], expected_inertia_xx, places=5)
+ self.assertAlmostEqual(principal_moments[1], expected_inertia_xx, places=5)
+ self.assertAlmostEqual(principal_moments[2], expected_inertia_zz, places=5)
+
+ # Test radius of gyration (should be sqrt(I/m))
+ expected_radius_x = (expected_inertia_xx / volume) ** 0.5
+ expected_radius_z = (expected_inertia_zz / volume) ** 0.5
+ self.assertAlmostEqual(
+ cylinder.radius_of_gyration(Axis.X), expected_radius_x, places=5
+ )
+ self.assertAlmostEqual(
+ cylinder.radius_of_gyration(Axis.Z), expected_radius_z, places=5
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
From 98e989debc5c5dc6e9720c45053513df01961e01 Mon Sep 17 00:00:00 2001
From: Bernhard
Date: Wed, 29 Jan 2025 09:39:37 +0100
Subject: [PATCH 178/518] encapsulate VTK in a separate file
---
novtk.sh | 4 +
src/build123d/jupyter_tools.py | 31 +---
src/build123d/topology/shape_core.py | 63 ---------
src/build123d/vtk_tools.py | 149 ++++++++++++++++++++
tests/test_direct_api/test_jupyter.py | 5 +-
tests/test_direct_api/test_vtk_poly_data.py | 7 +-
6 files changed, 162 insertions(+), 97 deletions(-)
create mode 100755 novtk.sh
create mode 100644 src/build123d/vtk_tools.py
diff --git a/novtk.sh b/novtk.sh
new file mode 100755
index 0000000..825d027
--- /dev/null
+++ b/novtk.sh
@@ -0,0 +1,4 @@
+uv pip install --no-deps ocpsvg svgelements cadquery_ocp_novtk
+gsed -i '/cadquery-ocp >/d; /ocpsvg/d' pyproject.toml
+uv pip install -e .
+git checkout pyproject.toml
diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py
index 1299a73..ce05a73 100644
--- a/src/build123d/jupyter_tools.py
+++ b/src/build123d/jupyter_tools.py
@@ -28,9 +28,9 @@ from json import dumps
import os
import uuid
from string import Template
-from typing import Any, Dict, List
+from typing import Any
from IPython.display import HTML
-from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter
+from build123d.vtk_tools import to_vtkpoly_string
DEFAULT_COLOR = [1, 0.8, 0, 1]
@@ -39,33 +39,6 @@ with open(os.path.join(dir_path, "template_render.js"), encoding="utf-8") as f:
TEMPLATE_JS = f.read()
-def to_vtkpoly_string(
- shape: Any, tolerance: float = 1e-3, angular_tolerance: float = 0.1
-) -> str:
- """to_vtkpoly_string
-
- Args:
- shape (Shape): object to convert
- tolerance (float, optional): Defaults to 1e-3.
- angular_tolerance (float, optional): Defaults to 0.1.
-
- Raises:
- ValueError: not a valid Shape
-
- Returns:
- str: vtkpoly str
- """
- if not hasattr(shape, "wrapped"):
- raise ValueError(f"Type {type(shape)} is not supported")
-
- writer = vtkXMLPolyDataWriter()
- writer.SetWriteToOutputString(True)
- writer.SetInputData(shape.to_vtk_poly_data(tolerance, angular_tolerance, True))
- writer.Write()
-
- return writer.GetOutputString()
-
-
def shape_to_html(shape: Any) -> HTML:
"""shape_to_html
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index b2d645f..228dce5 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -68,7 +68,6 @@ from collections.abc import Callable, Iterable, Iterator
import OCP.GeomAbs as ga
import OCP.TopAbs as ta
from IPython.lib.pretty import pretty, RepresentationPrinter
-from OCP.Aspect import Aspect_TOL_SOLID
from OCP.BOPAlgo import BOPAlgo_GlueEnum
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
@@ -104,10 +103,6 @@ from OCP.GProp import GProp_GProps
from OCP.Geom import Geom_Line
from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf
from OCP.GeomLib import GeomLib_IsPlanarSurface
-from OCP.IVtkOCC import IVtkOCC_Shape, IVtkOCC_ShapeMesher
-from OCP.IVtkVTK import IVtkVTK_ShapeData
-from OCP.Prs3d import Prs3d_IsoAspect
-from OCP.Quantity import Quantity_Color
from OCP.ShapeAnalysis import ShapeAnalysis_Curve
from OCP.ShapeCustom import ShapeCustom, ShapeCustom_RestrictionParameters
from OCP.ShapeFix import ShapeFix_Shape
@@ -152,8 +147,6 @@ from build123d.geometry import (
from typing_extensions import Self
from typing import Literal
-from vtkmodules.vtkCommonDataModel import vtkPolyData
-from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter
if TYPE_CHECKING: # pragma: no cover
@@ -1938,62 +1931,6 @@ class Shape(NodeMixin, Generic[TOPODS]):
return self.__class__.cast(result)
- def to_vtk_poly_data(
- self,
- tolerance: float | None = None,
- angular_tolerance: float | None = None,
- normals: bool = False,
- ) -> vtkPolyData:
- """Convert shape to vtkPolyData
-
- Args:
- tolerance: float:
- angular_tolerance: float: (Default value = 0.1)
- normals: bool: (Default value = True)
-
- Returns: data object in VTK consisting of points, vertices, lines, and polygons
- """
- if self.wrapped is None:
- raise ValueError("Cannot convert an empty shape")
-
- vtk_shape = IVtkOCC_Shape(self.wrapped)
- shape_data = IVtkVTK_ShapeData()
- shape_mesher = IVtkOCC_ShapeMesher()
-
- drawer = vtk_shape.Attributes()
- drawer.SetUIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0))
- drawer.SetVIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0))
-
- if tolerance:
- drawer.SetDeviationCoefficient(tolerance)
-
- if angular_tolerance:
- drawer.SetDeviationAngle(angular_tolerance)
-
- shape_mesher.Build(vtk_shape, shape_data)
-
- vtk_poly_data = shape_data.getVtkPolyData()
-
- # convert to triangles and split edges
- t_filter = vtkTriangleFilter()
- t_filter.SetInputData(vtk_poly_data)
- t_filter.Update()
-
- return_value = t_filter.GetOutput()
-
- # compute normals
- if normals:
- n_filter = vtkPolyDataNormals()
- n_filter.SetComputePointNormals(True)
- n_filter.SetComputeCellNormals(True)
- n_filter.SetFeatureAngle(360)
- n_filter.SetInputData(return_value)
- n_filter.Update()
-
- return_value = n_filter.GetOutput()
-
- return return_value
-
def transform_geometry(self, t_matrix: Matrix) -> Self:
"""Apply affine transform
diff --git a/src/build123d/vtk_tools.py b/src/build123d/vtk_tools.py
new file mode 100644
index 0000000..9d22185
--- /dev/null
+++ b/src/build123d/vtk_tools.py
@@ -0,0 +1,149 @@
+"""
+build123d topology
+
+name: vtk_tools.py
+by: Gumyr
+date: January 07, 2025
+
+desc:
+
+This module defines the foundational classes and methods for the build123d CAD library, enabling
+detailed geometric operations and 3D modeling capabilities. It provides a hierarchy of classes
+representing various geometric entities like vertices, edges, wires, faces, shells, solids, and
+compounds. These classes are designed to work seamlessly with the OpenCascade Python bindings,
+leveraging its robust CAD kernel.
+
+Key Features:
+- **Shape Base Class:** Implements core functionalities such as transformations (rotation,
+ translation, scaling), geometric queries, and boolean operations (cut, fuse, intersect).
+- **Custom Utilities:** Includes helper classes like `ShapeList` for advanced filtering, sorting,
+ and grouping of shapes, and `GroupBy` for organizing shapes by specific criteria.
+- **Type Safety:** Extensive use of Python typing features ensures clarity and correctness in type
+ handling.
+- **Advanced Geometry:** Supports operations like finding intersections, computing bounding boxes,
+ projecting faces, and generating triangulated meshes.
+
+The module is designed for extensibility, enabling developers to build complex 3D assemblies and
+perform detailed CAD operations programmatically while maintaining a clean and structured API.
+
+license:
+
+ Copyright 2025 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.
+
+"""
+
+from typing import Any
+import warnings
+
+from OCP.Aspect import Aspect_TOL_SOLID
+from OCP.Prs3d import Prs3d_IsoAspect
+from OCP.Quantity import Quantity_Color
+
+HAS_VTK = True
+try:
+ from OCP.IVtkOCC import IVtkOCC_Shape, IVtkOCC_ShapeMesher
+ from OCP.IVtkVTK import IVtkVTK_ShapeData
+ from vtkmodules.vtkCommonDataModel import vtkPolyData
+ from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter
+ from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter
+except ImportError:
+ HAS_VTK = False
+
+
+def to_vtk_poly_data(
+ obj,
+ tolerance: float | None = None,
+ angular_tolerance: float | None = None,
+ normals: bool = False,
+) -> "vtkPolyData":
+ """Convert shape to vtkPolyData
+
+ Args:
+ tolerance: float:
+ angular_tolerance: float: (Default value = 0.1)
+ normals: bool: (Default value = True)
+
+ Returns: data object in VTK consisting of points, vertices, lines, and polygons
+ """
+ if not HAS_VTK:
+ warnings.warn("VTK not supported", stacklevel=2)
+
+ if obj.wrapped is None:
+ raise ValueError("Cannot convert an empty shape")
+
+ vtk_shape = IVtkOCC_Shape(obj.wrapped)
+ shape_data = IVtkVTK_ShapeData()
+ shape_mesher = IVtkOCC_ShapeMesher()
+
+ drawer = vtk_shape.Attributes()
+ drawer.SetUIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0))
+ drawer.SetVIsoAspect(Prs3d_IsoAspect(Quantity_Color(), Aspect_TOL_SOLID, 1, 0))
+
+ if tolerance:
+ drawer.SetDeviationCoefficient(tolerance)
+
+ if angular_tolerance:
+ drawer.SetDeviationAngle(angular_tolerance)
+
+ shape_mesher.Build(vtk_shape, shape_data)
+
+ vtk_poly_data = shape_data.getVtkPolyData()
+
+ # convert to triangles and split edges
+ t_filter = vtkTriangleFilter()
+ t_filter.SetInputData(vtk_poly_data)
+ t_filter.Update()
+
+ return_value = t_filter.GetOutput()
+
+ # compute normals
+ if normals:
+ n_filter = vtkPolyDataNormals()
+ n_filter.SetComputePointNormals(True)
+ n_filter.SetComputeCellNormals(True)
+ n_filter.SetFeatureAngle(360)
+ n_filter.SetInputData(return_value)
+ n_filter.Update()
+
+ return_value = n_filter.GetOutput()
+
+ return return_value
+
+
+def to_vtkpoly_string(
+ shape: Any, tolerance: float = 1e-3, angular_tolerance: float = 0.1
+) -> str:
+ """to_vtkpoly_string
+
+ Args:
+ shape (Shape): object to convert
+ tolerance (float, optional): Defaults to 1e-3.
+ angular_tolerance (float, optional): Defaults to 0.1.
+
+ Raises:
+ ValueError: not a valid Shape
+
+ Returns:
+ str: vtkpoly str
+ """
+ if not hasattr(shape, "wrapped"):
+ raise ValueError(f"Type {type(shape)} is not supported")
+
+ writer = vtkXMLPolyDataWriter()
+ writer.SetWriteToOutputString(True)
+ writer.SetInputData(to_vtk_poly_data(shape, tolerance, angular_tolerance, True))
+ writer.Write()
+
+ return writer.GetOutputString()
diff --git a/tests/test_direct_api/test_jupyter.py b/tests/test_direct_api/test_jupyter.py
index 7ae4074..765159d 100644
--- a/tests/test_direct_api/test_jupyter.py
+++ b/tests/test_direct_api/test_jupyter.py
@@ -29,7 +29,8 @@ license:
import unittest
from build123d.geometry import Vector
-from build123d.jupyter_tools import to_vtkpoly_string, shape_to_html
+from build123d.jupyter_tools import shape_to_html
+from build123d.vtk_tools import to_vtkpoly_string
from build123d.topology import Solid
@@ -43,7 +44,7 @@ class TestJupyter(unittest.TestCase):
assert "function render" in html1
def test_display_error(self):
- with self.assertRaises(AttributeError):
+ with self.assertRaises(TypeError):
shape_to_html(Vector())
with self.assertRaises(ValueError):
diff --git a/tests/test_direct_api/test_vtk_poly_data.py b/tests/test_direct_api/test_vtk_poly_data.py
index 69d022f..b3ba166 100644
--- a/tests/test_direct_api/test_vtk_poly_data.py
+++ b/tests/test_direct_api/test_vtk_poly_data.py
@@ -29,6 +29,7 @@ license:
import unittest
from build123d.topology import Solid
+from build123d.vtk_tools import to_vtk_poly_data
from vtkmodules.vtkCommonDataModel import vtkPolyData
from vtkmodules.vtkFiltersCore import vtkTriangleFilter
@@ -40,8 +41,8 @@ class TestVTKPolyData(unittest.TestCase):
def test_to_vtk_poly_data(self):
# Generate VTK data
- vtk_data = self.object_under_test.to_vtk_poly_data(
- tolerance=0.1, angular_tolerance=0.2, normals=True
+ vtk_data = to_vtk_poly_data(
+ self.object_under_test, tolerance=0.1, angular_tolerance=0.2, normals=True
)
# Verify the result is of type vtkPolyData
@@ -78,7 +79,7 @@ class TestVTKPolyData(unittest.TestCase):
# Test handling of empty shape
empty_object = Solid() # Create an empty object
with self.assertRaises(ValueError) as context:
- empty_object.to_vtk_poly_data()
+ to_vtk_poly_data(empty_object)
self.assertEqual(str(context.exception), "Cannot convert an empty shape")
From ee2a3724e9b34f0c0bffa910a8cf0c4603fd42da Mon Sep 17 00:00:00 2001
From: Bernhard
Date: Wed, 29 Jan 2025 09:41:15 +0100
Subject: [PATCH 179/518] remove the install script for novtk
---
novtk.sh | 4 ----
1 file changed, 4 deletions(-)
delete mode 100755 novtk.sh
diff --git a/novtk.sh b/novtk.sh
deleted file mode 100755
index 825d027..0000000
--- a/novtk.sh
+++ /dev/null
@@ -1,4 +0,0 @@
-uv pip install --no-deps ocpsvg svgelements cadquery_ocp_novtk
-gsed -i '/cadquery-ocp >/d; /ocpsvg/d' pyproject.toml
-uv pip install -e .
-git checkout pyproject.toml
From b8dcad3bcbaf9c788446fbe40189297352db8a9a Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 31 Jan 2025 16:06:09 -0500
Subject: [PATCH 180/518] Added Axis.is_skew and tested for is_skew in
Axis.intersect
---
src/build123d/geometry.py | 81 ++++++++++++++++++++----------
tests/test_direct_api/test_axis.py | 23 +++++++++
2 files changed, 78 insertions(+), 26 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 370435c..6ac8309 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -780,6 +780,43 @@ class Axis(metaclass=AxisMeta):
"""
return self.wrapped.IsParallel(other.wrapped, angular_tolerance * (pi / 180))
+ def is_skew(self, other: Axis, tolerance: float = 1e-5) -> bool:
+ """are axes skew
+
+ Returns True if this axis and another axis are skew, meaning they are neither
+ parallel nor coplanar. Two axes are skew if they do not lie in the same plane
+ and never intersect.
+
+ Mathematically, this means:
+ - The axes are **not parallel** (the cross product of their direction vectors
+ is nonzero).
+ - The axes are **not coplanar** (the vector between their positions is not
+ aligned with the plane spanned by their directions).
+
+ If either condition is false (i.e., the axes are parallel or coplanar), they are
+ not skew.
+
+ Args:
+ other (Axis): axis to compare to
+ tolerance (float, optional): max deviation. Defaults to 1e-5.
+
+ Returns:
+ bool: axes are skew
+ """
+ if self.is_parallel(other, tolerance):
+ # If parallel, check if they are coincident
+ parallel_offset = (self.position - other.position).cross(self.direction)
+ # True if distinct, False if coincident
+ return parallel_offset.length > tolerance
+
+ # Compute the determinant
+ coplanarity = (self.position - other.position).dot(
+ self.direction.cross(other.direction)
+ )
+
+ # If determinant is near zero, they are coplanar; otherwise, they are skew
+ return abs(coplanarity) > tolerance
+
def angle_between(self, other: Axis) -> float:
"""calculate angle between axes
@@ -830,37 +867,29 @@ class Axis(metaclass=AxisMeta):
if axis is not None:
if self.is_coaxial(axis):
return self
- else:
- # Extract points and directions to numpy arrays
- p1 = np.array([*self.position])
- d1 = np.array([*self.direction])
- p2 = np.array([*axis.position])
- d2 = np.array([*axis.direction])
- # Compute the cross product of directions
- cross_d1_d2 = np.cross(d1, d2)
- cross_d1_d2_norm = np.linalg.norm(cross_d1_d2)
+ if self.is_skew(axis):
+ return None
- if cross_d1_d2_norm < TOLERANCE:
- # The directions are parallel
- return None
+ # Extract points and directions to numpy arrays
+ p1 = np.array([*self.position])
+ d1 = np.array([*self.direction])
+ p2 = np.array([*axis.position])
+ d2 = np.array([*axis.direction])
- # Solve the system of equations to find the intersection
- system_of_equations = np.array([d1, -d2, cross_d1_d2]).T
- origin_diff = p2 - p1
- try:
- t1, t2, _ = np.linalg.solve(system_of_equations, origin_diff)
- except np.linalg.LinAlgError:
- return None # The lines do not intersect
+ # Solve the system of equations to find the intersection
+ system_of_equations = np.array([d1, -d2, np.cross(d1, d2)]).T
+ origin_diff = p2 - p1
+ t1, t2, _ = np.linalg.lstsq(system_of_equations, origin_diff, rcond=None)[0]
- # Calculate the intersection point
- intersection_point = p1 + t1 * d1
- return Vector(*intersection_point)
+ # Calculate the intersection point
+ intersection_point = p1 + t1 * d1
+ return Vector(*intersection_point)
- elif plane is not None:
+ if plane is not None:
return plane.intersect(self)
- elif vector is not None:
+ if vector is not None:
# Create a vector from the origin to the point
vec_to_point = vector - self.position
@@ -872,7 +901,7 @@ class Axis(metaclass=AxisMeta):
if vector == projected_vec:
return vector
- elif location is not None:
+ if location is not None:
# Find the "direction" of the location
location_dir = Plane(location).z_dir
@@ -883,7 +912,7 @@ class Axis(metaclass=AxisMeta):
):
return location
- elif shape is not None:
+ if shape is not None:
return shape.intersect(self)
diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py
index 658e273..49144c4 100644
--- a/tests/test_direct_api/test_axis.py
+++ b/tests/test_direct_api/test_axis.py
@@ -123,6 +123,26 @@ class TestAxis(unittest.TestCase):
self.assertTrue(Axis.X.is_parallel(Axis((1, 1, 1), (1, 0, 0))))
self.assertFalse(Axis.X.is_parallel(Axis.Y))
+ def test_axis_is_skew(self):
+ self.assertTrue(Axis.X.is_skew(Axis((0, 1, 1), (0, 0, 1))))
+ self.assertFalse(Axis.X.is_skew(Axis.Y))
+
+ def test_axis_is_skew(self):
+ # Skew Axes
+ self.assertTrue(Axis.X.is_skew(Axis((0, 1, 1), (0, 0, 1))))
+
+ # Perpendicular but intersecting
+ self.assertFalse(Axis.X.is_skew(Axis.Y))
+
+ # Parallel coincident axes
+ self.assertFalse(Axis.X.is_skew(Axis.X))
+
+ # Parallel but distinct axes
+ self.assertTrue(Axis.X.is_skew(Axis((0, 1, 0), (1, 0, 0))))
+
+ # Coplanar but not intersecting
+ self.assertTrue(Axis((0, 0, 0), (1, 1, 0)).is_skew(Axis((0, 1, 0), (1, 1, 0))))
+
def test_axis_angle_between(self):
self.assertAlmostEqual(Axis.X.angle_between(Axis.Y), 90, 5)
self.assertAlmostEqual(
@@ -155,6 +175,9 @@ class TestAxis(unittest.TestCase):
i = Axis.X & Axis((1, 0, 0), (1, 0, 0))
self.assertEqual(i, Axis.X)
+ # Skew case
+ self.assertIsNone(Axis.X.intersect(Axis((0, 1, 1), (0, 0, 1))))
+
intersection = Axis((1, 2, 3), (0, 0, 1)) & Plane.XY
self.assertAlmostEqual(intersection.to_tuple(), (1, 2, 0), 5)
From 0e3dbbe15b68e43f42c53de3341b9c87732edeb9 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sat, 1 Feb 2025 09:35:03 -0500
Subject: [PATCH 181/518] Making Axis friendly to sub-classing
---
src/build123d/geometry.py | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 6ac8309..418d98c 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -558,17 +558,17 @@ class AxisMeta(type):
@property
def X(cls) -> Axis:
"""X Axis"""
- return Axis((0, 0, 0), (1, 0, 0))
+ return cls((0, 0, 0), (1, 0, 0))
@property
def Y(cls) -> Axis:
"""Y Axis"""
- return Axis((0, 0, 0), (0, 1, 0))
+ return cls((0, 0, 0), (0, 1, 0))
@property
def Z(cls) -> Axis:
"""Z Axis"""
- return Axis((0, 0, 0), (0, 0, 1))
+ return cls((0, 0, 0), (0, 0, 1))
class Axis(metaclass=AxisMeta):
@@ -690,7 +690,7 @@ class Axis(metaclass=AxisMeta):
def __str__(self) -> str:
"""Display self"""
- return f"Axis: ({self.position.to_tuple()},{self.direction.to_tuple()})"
+ return f"{type(self).__name__}: ({self.position.to_tuple()},{self.direction.to_tuple()})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Axis):
@@ -833,7 +833,7 @@ class Axis(metaclass=AxisMeta):
def reverse(self) -> Axis:
"""Return a copy of self with the direction reversed"""
- return Axis(self.wrapped.Reversed())
+ return type(self)(self.wrapped.Reversed())
def __neg__(self) -> Axis:
"""Flip direction operator -"""
From b14c187ca2ec623ec90116dbd40e3f973aa8c81a Mon Sep 17 00:00:00 2001
From: Luz Paz
Date: Sun, 2 Feb 2025 09:08:01 -0500
Subject: [PATCH 182/518] Fix various typos
Found with `codespell -q 3 -L parm,parms,re-use`
---
docs/algebra_definition.rst | 2 +-
docs/joints.rst | 2 +-
docs/key_concepts_algebra.rst | 2 +-
docs/tips.rst | 2 +-
docs/tutorial_design.rst | 2 +-
src/build123d/build_sketch.py | 2 +-
src/build123d/drafting.py | 2 +-
src/build123d/geometry.py | 2 +-
src/build123d/mesher.py | 2 +-
src/build123d/topology/one_d.py | 2 +-
tests/test_pack.py | 4 ++--
tools/refactor_topo.py | 2 +-
12 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/docs/algebra_definition.rst b/docs/algebra_definition.rst
index ae98eb5..e87c42c 100644
--- a/docs/algebra_definition.rst
+++ b/docs/algebra_definition.rst
@@ -61,7 +61,7 @@ with :math:`B^3 \subset C^3, B^2 \subset C^2` and :math:`B^1 \subset C^1`
* This definition also includes that neither ``-`` nor ``&`` are commutative.
-Locations, planes and location arithmentic
+Locations, planes and location arithmetic
---------------------------------------------
**Set definitions:**
diff --git a/docs/joints.rst b/docs/joints.rst
index 3ffaaab..e6a7e7c 100644
--- a/docs/joints.rst
+++ b/docs/joints.rst
@@ -196,7 +196,7 @@ is found within a rod end as shown here:
:emphasize-lines: 40-44,51,53
Note how limits are defined during the instantiation of the ball joint when ensures that the pin or bolt
-within the rod end does not interfer with the rod end itself. The ``connect_to`` sets the three angles
+within the rod end does not interfere with the rod end itself. The ``connect_to`` sets the three angles
(only two are significant in this example).
.. autoclass:: BallJoint
diff --git a/docs/key_concepts_algebra.rst b/docs/key_concepts_algebra.rst
index b76a1df..655b6c7 100644
--- a/docs/key_concepts_algebra.rst
+++ b/docs/key_concepts_algebra.rst
@@ -60,7 +60,7 @@ The generic forms of object placement are:
location * alg_compound
2. Placement on the ``plane`` and then moved relative to the ``plane`` by ``location``
-(the location is relative to the local corrdinate system of the plane).
+(the location is relative to the local coordinate system of the plane).
.. code-block:: python
diff --git a/docs/tips.rst b/docs/tips.rst
index 8bd9d7f..ad5c299 100644
--- a/docs/tips.rst
+++ b/docs/tips.rst
@@ -271,7 +271,7 @@ reoriented, all ``BuildLine`` instances within the scope of ``BuildSketch`` shou
on the default ``Plane.XY``.
***************************************************************
-Don't Builders inherit workplane/coordinate sytems when nested
+Don't Builders inherit workplane/coordinate systems when nested
***************************************************************
Some users expect that nested Builders will inherit the workplane or coordinate system from
diff --git a/docs/tutorial_design.rst b/docs/tutorial_design.rst
index 250de88..3950399 100644
--- a/docs/tutorial_design.rst
+++ b/docs/tutorial_design.rst
@@ -106,7 +106,7 @@ For solid or prismatic shapes, extrude the 2D profiles along the necessary axis.
also combine multiple extrusions by intersecting or unionizing them to form complex shapes.
Use the resulting geometry as sub-parts if needed.
-*The next step in implmenting our design in build123d is to convert the above sketch into
+*The next step in implementing our design in build123d is to convert the above sketch into
a part by extruding it as shown in this code:*
.. code-block:: python
diff --git a/src/build123d/build_sketch.py b/src/build123d/build_sketch.py
index b42721e..7509000 100644
--- a/src/build123d/build_sketch.py
+++ b/src/build123d/build_sketch.py
@@ -50,7 +50,7 @@ class BuildSketch(Builder):
Note that all sketch construction is done within sketch_local on Plane.XY.
When objects are added to the sketch they must be coplanar to Plane.XY,
usually handled automatically but may need user input for Edges and Wires
- since their construction plane isn't alway able to be determined.
+ since their construction plane isn't always able to be determined.
Args:
workplanes (Union[Face, Plane, Location], optional): objects converted to
diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py
index 2c995ab..b1388dd 100644
--- a/src/build123d/drafting.py
+++ b/src/build123d/drafting.py
@@ -227,7 +227,7 @@ class Draft:
"""Convert a raw number to a unit of measurement string based on the class settings"""
def simplify_fraction(numerator: int, denominator: int) -> tuple[int, int]:
- """Mathematically simplify a fraction given a numerator and demoninator"""
+ """Mathematically simplify a fraction given a numerator and denominator"""
greatest_common_demoninator = gcd(numerator, denominator)
return (
int(numerator / greatest_common_demoninator),
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 418d98c..2f3b626 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -1134,7 +1134,7 @@ class Color:
@overload
def __init__(self, color_code: int, alpha: int = 0xFF):
- """Color from a hexidecimal color code with an optional alpha value
+ """Color from a hexadecimal color code with an optional alpha value
Args:
color_code (hexidecimal int): 0xRRGGBB
diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py
index f4ba23c..de102a1 100644
--- a/src/build123d/mesher.py
+++ b/src/build123d/mesher.py
@@ -242,7 +242,7 @@ class Mesher:
return meta_data_contents
def get_meta_data_by_key(self, name_space: str, name: str) -> dict:
- """Retrive the metadata value and type for the provided name space and name"""
+ """Retrieve the metadata value and type for the provided name space and name"""
meta_data_group = self.model.GetMetaDataGroup()
meta_data_contents = {}
meta_data = meta_data_group.GetMetaDataByKey(name_space, name)
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index 366cf51..e412478 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -997,7 +997,7 @@ class Mixin1D(Shape):
split_result = unwrap_topods_compound(split_result, True)
# For speed the user may just want all the objects which they
- # can sort more efficiently then the generic algoritm below
+ # can sort more efficiently then the generic algorithm below
if keep == Keep.ALL:
return ShapeList(
self.__class__.cast(part)
diff --git a/tests/test_pack.py b/tests/test_pack.py
index 56f64f7..1d733d5 100644
--- a/tests/test_pack.py
+++ b/tests/test_pack.py
@@ -39,7 +39,7 @@ class TestPack(unittest.TestCase):
test_boxes = [
Box(random.randint(1, 20), random.randint(1, 20), 1) for _ in range(50)
]
- # Not raising in this call shows successfull non-overlap.
+ # Not raising in this call shows successful non-overlap.
packed = pack(test_boxes, 1)
self.assertEqual(
"bbox: 0.0 <= x <= 94.0, 0.0 <= y <= 86.0, -0.5 <= z <= 0.5",
@@ -54,7 +54,7 @@ class TestPack(unittest.TestCase):
widths = [random.randint(2, 20) for _ in range(50)]
heights = [random.randint(1, width - 1) for width in widths]
inputs = [SlotOverall(width, height) for width, height in zip(widths, heights)]
- # Not raising in this call shows successfull non-overlap.
+ # Not raising in this call shows successful non-overlap.
packed = pack(inputs, 1)
bb = (Sketch() + packed).bounding_box()
self.assertEqual(bb.min, Vector(0, 0, 0))
diff --git a/tools/refactor_topo.py b/tools/refactor_topo.py
index 8aadbb1..be56a28 100644
--- a/tools/refactor_topo.py
+++ b/tools/refactor_topo.py
@@ -7,7 +7,7 @@ date: Dec 05, 2024
desc:
This python script refactors the very large topology.py module into several
- files based on the topological heirarchical order:
+ files based on the topological hierarchical order:
+ shape_core.py - base classes Shape, ShapeList
+ utils.py - utility classes & functions
+ zero_d.py - Vertex
From c728124b3b8503d5460fe0f6bd855e3e2b0e4655 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 4 Feb 2025 09:58:40 -0500
Subject: [PATCH 183/518] Adding Face.remove_holes and Face.total_area property
---
src/build123d/topology/two_d.py | 41 +++++++++++++++++++++++++++++-
tests/test_direct_api/test_face.py | 35 +++++++++++++++++++++++++
2 files changed, 75 insertions(+), 1 deletion(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 4d24e58..bf4e1a6 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -73,7 +73,7 @@ from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d
from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakePipeShell
-from OCP.BRepTools import BRepTools
+from OCP.BRepTools import BRepTools, BRepTools_ReShape
from OCP.GProp import GProp_GProps
from OCP.Geom import Geom_BezierSurface, Geom_Surface
from OCP.GeomAPI import GeomAPI_PointsToBSplineSurface, GeomAPI_ProjectPointOnSurf
@@ -405,6 +405,23 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
result = face_vertices[-1].X - face_vertices[0].X
return result
+ @property
+ def total_area(self) -> float:
+ """
+ Calculate the total surface area of the face, including the areas of any holes.
+
+ This property returns the overall area of the face as if the inner boundaries (holes)
+ were filled in.
+
+ Returns:
+ float: The total surface area, including the area of holes. Returns 0.0 if
+ the face is empty.
+ """
+ if self.wrapped is None:
+ return 0.0
+
+ return self.remove_holes().area
+
@property
def volume(self) -> float:
"""volume - the volume of this Face, which is always zero"""
@@ -1201,6 +1218,28 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
projected_shapes.append(shape)
return projected_shapes
+ def remove_holes(self) -> Face:
+ """remove_holes
+
+ Remove all of the holes from this face.
+
+ Returns:
+ Face: A new Face instance identical to the original but without any holes.
+ """
+ if self.wrapped is None:
+ raise ValueError("Cannot remove holes from an empty face")
+
+ if not (inner_wires := self.inner_wires()):
+ return self
+
+ holeless = copy.deepcopy(self)
+ reshaper = BRepTools_ReShape()
+ for hole_wire in inner_wires:
+ reshaper.Remove(hole_wire.wrapped)
+ modified_shape = downcast(reshaper.Apply(self.wrapped))
+ holeless.wrapped = modified_shape
+ return holeless
+
def to_arcs(self, tolerance: float = 1e-3) -> Face:
"""to_arcs
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index 3092833..6f16eca 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -447,6 +447,41 @@ class TestFace(unittest.TestCase):
face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5)
+ def test_remove_holes(self):
+ # Planar test
+ frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face()
+ filled = frame.remove_holes()
+ self.assertEqual(len(frame.inner_wires()), 1)
+ self.assertEqual(len(filled.inner_wires()), 0)
+ self.assertAlmostEqual(frame.area, 0.75, 5)
+ self.assertAlmostEqual(filled.area, 1.0, 5)
+
+ # Errors
+ frame.wrapped = None
+ with self.assertRaises(ValueError):
+ frame.remove_holes()
+
+ # No holes
+ rect = Face.make_rect(1, 1)
+ self.assertEqual(rect, rect.remove_holes())
+
+ # Non-planar test
+ cyl_face = (
+ (Cylinder(1, 3) - Cylinder(0.5, 3, rotation=(90, 0, 0)))
+ .faces()
+ .sort_by(Face.area)[-1]
+ )
+ filled = cyl_face.remove_holes()
+ self.assertEqual(len(cyl_face.inner_wires()), 2)
+ self.assertEqual(len(filled.inner_wires()), 0)
+ self.assertTrue(cyl_face.area < filled.area)
+ self.assertAlmostEqual(cyl_face.total_area, filled.area, 5)
+
+ def test_total_area(self):
+ frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face()
+ frame.wrapped = None
+ self.assertAlmostEqual(frame.total_area, 0.0, 5)
+
if __name__ == "__main__":
unittest.main()
From 05ed5fd8e105dc9cfc5d598447aaea9a8fb30e16 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 4 Feb 2025 13:51:56 -0500
Subject: [PATCH 184/518] Added OrientedBoundBox to geometry and
Shape.oriented_bounding_box
---
src/build123d/__init__.py | 1 +
src/build123d/geometry.py | 158 ++++++++++++++++
src/build123d/topology/shape_core.py | 13 +-
.../test_oriented_bound_box.py | 179 ++++++++++++++++++
4 files changed, 350 insertions(+), 1 deletion(-)
create mode 100644 tests/test_direct_api/test_oriented_bound_box.py
diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py
index a1f59ca..57b82f5 100644
--- a/src/build123d/__init__.py
+++ b/src/build123d/__init__.py
@@ -127,6 +127,7 @@ __all__ = [
"Wedge",
# Direct API Classes
"BoundBox",
+ "OrientedBoundBox",
"Rotation",
"Rot",
"Pos",
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 418d98c..784dc1d 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -1724,6 +1724,164 @@ class LocationEncoder(json.JSONEncoder):
return obj
+class OrientedBoundBox:
+ """
+ An Oriented Bounding Box
+
+ This class computes the oriented bounding box for a given build123d shape.
+ It exposes properties such as the center, principal axis directions, the
+ extents along these axes, and the full diagonal length of the box.
+ """
+
+ def __init__(self, shape: Bnd_OBB | Shape):
+ """
+ Create an oriented bounding box from either a precomputed Bnd_OBB or
+ a build123d Shape (which wraps a TopoDS_Shape).
+
+ Args:
+ shape (Bnd_OBB | Shape): Either a precomputed Bnd_OBB or a build123d shape
+ from which to compute the oriented bounding box.
+ """
+ if isinstance(shape, Bnd_OBB):
+ obb = shape
+ else:
+ obb = Bnd_OBB()
+ # Compute the oriented bounding box for the shape.
+ BRepBndLib.AddOBB_s(shape.wrapped, obb, True)
+ self.wrapped = obb
+
+ @property
+ def diagonal(self) -> float:
+ """
+ The full length of the body diagonal of the oriented bounding box,
+ which represents the maximum size of the object.
+
+ Returns:
+ float: The diagonal length.
+ """
+ if self.wrapped is None:
+ return 0.0
+ return self.wrapped.SquareExtent() ** 0.5
+
+ @property
+ def plane(self) -> Plane:
+ """
+ The oriented coordinate system of the bounding box.
+
+ Returns:
+ Plane: The coordinate system defined by the center and primary
+ (X) and tertiary (Z) directions of the bounding box.
+ """
+ return Plane(
+ origin=self.center(), x_dir=self.x_direction, z_dir=self.z_direction
+ )
+
+ @property
+ def size(self) -> Vector:
+ """
+ The full extents of the bounding box along its primary axes.
+
+ Returns:
+ Vector: The oriented size (full dimensions) of the box.
+ """
+ return (
+ Vector(self.wrapped.XHSize(), self.wrapped.YHSize(), self.wrapped.ZHSize())
+ * 2.0
+ )
+
+ @property
+ def x_direction(self) -> Vector:
+ """
+ The primary (X) direction of the oriented bounding box.
+
+ Returns:
+ Vector: The X direction as a unit vector.
+ """
+ x_direction_xyz = self.wrapped.XDirection()
+ coords = [getattr(x_direction_xyz, attr)() for attr in ("X", "Y", "Z")]
+ return Vector(*coords)
+
+ @property
+ def y_direction(self) -> Vector:
+ """
+ The secondary (Y) direction of the oriented bounding box.
+
+ Returns:
+ Vector: The Y direction as a unit vector.
+ """
+ y_direction_xyz = self.wrapped.YDirection()
+ coords = [getattr(y_direction_xyz, attr)() for attr in ("X", "Y", "Z")]
+ return Vector(*coords)
+
+ @property
+ def z_direction(self) -> Vector:
+ """
+ The tertiary (Z) direction of the oriented bounding box.
+
+ Returns:
+ Vector: The Z direction as a unit vector.
+ """
+ z_direction_xyz = self.wrapped.ZDirection()
+ coords = [getattr(z_direction_xyz, attr)() for attr in ("X", "Y", "Z")]
+ return Vector(*coords)
+
+ def center(self) -> Vector:
+ """
+ Compute and return the center point of the oriented bounding box.
+
+ Returns:
+ Vector: The center point of the box.
+ """
+ center_xyz = self.wrapped.Center()
+ coords = [getattr(center_xyz, attr)() for attr in ("X", "Y", "Z")]
+ return Vector(*coords)
+
+ def is_completely_inside(self, other: OrientedBoundBox) -> bool:
+ """
+ Determine whether the given oriented bounding box is entirely contained
+ within this bounding box.
+
+ This method checks that every point of 'other' lies strictly within the
+ boundaries of this box, according to the tolerance criteria inherent to the
+ underlying OCCT implementation.
+
+ Args:
+ other (OrientedBoundBox): The bounding box to test for containment.
+
+ Raises:
+ ValueError: If the 'other' bounding box has an uninitialized (null) underlying geometry.
+
+ Returns:
+ bool: True if 'other' is completely inside this bounding box; otherwise, False.
+ """
+ if other.wrapped is None:
+ raise ValueError("Can't compare to a null obb")
+ return self.wrapped.IsCompletelyInside(other.wrapped)
+
+ def is_outside(self, point: Vector) -> bool:
+ """
+ Determine whether a given point lies entirely outside this oriented bounding box.
+
+ A point is considered outside if it is neither inside the box nor on its surface,
+ based on the criteria defined by the OCCT implementation.
+
+ Args:
+ point (Vector): The point to test.
+
+ Raises:
+ ValueError: If the point's underlying geometry is not set (null).
+
+ Returns:
+ bool: True if the point is completely outside the bounding box; otherwise, False.
+ """
+ if point.wrapped is None:
+ raise ValueError("Can't compare to a null point")
+ return self.wrapped.IsOut(point.to_pnt())
+
+ def __repr__(self) -> str:
+ return f"OrientedBoundBox(center={self.center()}, size={self.size}, plane={self.plane})"
+
+
class Rotation(Location):
"""Subclass of Location used only for object rotation
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index 228dce5..d61d0de 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -98,7 +98,7 @@ from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepTools import BRepTools
-from OCP.Bnd import Bnd_Box
+from OCP.Bnd import Bnd_Box, Bnd_OBB
from OCP.GProp import GProp_GProps
from OCP.Geom import Geom_Line
from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf
@@ -139,6 +139,7 @@ from build123d.geometry import (
Color,
Location,
Matrix,
+ OrientedBoundBox,
Plane,
Vector,
VectorLike,
@@ -1503,6 +1504,16 @@ class Shape(NodeMixin, Generic[TOPODS]):
shape_copy.wrapped = tcast(TOPODS, downcast(self.wrapped.Moved(loc.wrapped)))
return shape_copy
+ def oriented_bounding_box(self) -> OrientedBoundBox:
+ """Create an oriented bounding box for this Shape.
+
+ Returns:
+ OrientedBoundBox: A box oriented and sized to contain this Shape
+ """
+ if self.wrapped is None:
+ return OrientedBoundBox(Bnd_OBB())
+ return OrientedBoundBox(self)
+
def project_faces(
self,
faces: list[Face] | Compound,
diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py
new file mode 100644
index 0000000..25bbc2f
--- /dev/null
+++ b/tests/test_direct_api/test_oriented_bound_box.py
@@ -0,0 +1,179 @@
+"""
+build123d tests
+
+name: test_oriented_bound_box.py
+by: Gumyr
+date: February 4, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 math
+import re
+import unittest
+
+from build123d.geometry import Axis, OrientedBoundBox, Pos, Rot, Vector
+from build123d.topology import Face, Solid
+
+
+class TestOrientedBoundBox(unittest.TestCase):
+ def test_size_and_diagonal(self):
+ # Create a unit cube (with one corner at the origin).
+ cube = Solid.make_box(1, 1, 1)
+ obb = OrientedBoundBox(cube)
+
+ # The size property multiplies half-sizes by 2. For a unit cube, expect (1, 1, 1).
+ size = obb.size
+ self.assertAlmostEqual(size.X, 1.0, places=6)
+ self.assertAlmostEqual(size.Y, 1.0, places=6)
+ self.assertAlmostEqual(size.Z, 1.0, places=6)
+
+ # The full body diagonal should be sqrt(1^2+1^2+1^2) = sqrt(3).
+ expected_diag = math.sqrt(3)
+ self.assertAlmostEqual(obb.diagonal, expected_diag, places=6)
+
+ obb.wrapped = None
+ self.assertAlmostEqual(obb.diagonal, 0.0, places=6)
+
+ def test_center(self):
+ # For a cube made at the origin, the center should be at (0.5, 0.5, 0.5)
+ cube = Solid.make_box(1, 1, 1)
+ obb = OrientedBoundBox(cube)
+ center = obb.center()
+ self.assertAlmostEqual(center.X, 0.5, places=6)
+ self.assertAlmostEqual(center.Y, 0.5, places=6)
+ self.assertAlmostEqual(center.Z, 0.5, places=6)
+
+ def test_directions_are_unit_vectors(self):
+ # Create a rotated cube so the direction vectors are non-trivial.
+ cube = Rot(45, 45, 0) * Solid.make_box(1, 1, 1)
+ obb = OrientedBoundBox(cube)
+
+ # Check that each primary direction is a unit vector.
+ for direction in (obb.x_direction, obb.y_direction, obb.z_direction):
+ self.assertAlmostEqual(direction.length, 1.0, places=6)
+
+ def test_is_outside(self):
+ # For a unit cube, test a point inside and a point clearly outside.
+ cube = Solid.make_box(1, 1, 1)
+ obb = OrientedBoundBox(cube)
+
+ # Use the cube's center as an "inside" test point.
+ center = obb.center()
+ self.assertFalse(obb.is_outside(center))
+
+ # A point far away should be outside.
+ outside_point = Vector(10, 10, 10)
+ self.assertTrue(obb.is_outside(outside_point))
+
+ outside_point._wrapped = None
+ with self.assertRaises(ValueError):
+ obb.is_outside(outside_point)
+
+ def test_is_completely_inside(self):
+ # Create a larger cube and a smaller cube that is centered within it.
+ large_cube = Solid.make_box(2, 2, 2)
+ small_cube = Solid.make_box(1, 1, 1)
+ # Translate the small cube by (0.5, 0.5, 0.5) so its center is at (1,1,1),
+ # which centers it within the 2x2x2 cube (whose center is also at (1,1,1)).
+ small_cube = Pos(0.5, 0.5, 0.5) * small_cube
+
+ large_obb = OrientedBoundBox(large_cube)
+ small_obb = OrientedBoundBox(small_cube)
+
+ # The small box should be completely inside the larger box.
+ self.assertTrue(large_obb.is_completely_inside(small_obb))
+ # Conversely, the larger box cannot be completely inside the smaller one.
+ self.assertFalse(small_obb.is_completely_inside(large_obb))
+
+ large_obb.wrapped = None
+ with self.assertRaises(ValueError):
+ small_obb.is_completely_inside(large_obb)
+
+ def test_init_from_bnd_obb(self):
+ # Test that constructing from an already computed Bnd_OBB works as expected.
+ cube = Solid.make_box(1, 1, 1)
+ obb1 = OrientedBoundBox(cube)
+ # Create a new instance by passing the underlying wrapped object.
+ obb2 = OrientedBoundBox(obb1.wrapped)
+
+ # Compare diagonal, size, and center.
+ self.assertAlmostEqual(obb1.diagonal, obb2.diagonal, places=6)
+ size1 = obb1.size
+ size2 = obb2.size
+ self.assertAlmostEqual(size1.X, size2.X, places=6)
+ self.assertAlmostEqual(size1.Y, size2.Y, places=6)
+ self.assertAlmostEqual(size1.Z, size2.Z, places=6)
+ center1 = obb1.center()
+ center2 = obb2.center()
+ self.assertAlmostEqual(center1.X, center2.X, places=6)
+ self.assertAlmostEqual(center1.Y, center2.Y, places=6)
+ self.assertAlmostEqual(center1.Z, center2.Z, places=6)
+
+ def test_plane(self):
+ rect = Rot(Z=10) * Face.make_rect(1, 2)
+ obb = rect.oriented_bounding_box()
+ pln = obb.plane
+ self.assertAlmostEqual(
+ abs(pln.x_dir.dot(Vector(0, 1, 0).rotate(Axis.Z, 10))), 1.0, places=6
+ )
+ self.assertAlmostEqual(abs(pln.z_dir.dot(Vector(0, 0, 1))), 1.0, places=6)
+
+ def test_repr(self):
+ # Create a simple unit cube OBB.
+ obb = OrientedBoundBox(Solid.make_box(1, 1, 1))
+ rep = repr(obb)
+
+ # Check that the repr string contains expected substrings.
+ self.assertIn("OrientedBoundBox(center=Vector(", rep)
+ self.assertIn("size=Vector(", rep)
+ self.assertIn("plane=Plane(", rep)
+
+ # Use a regular expression to extract numbers.
+ pattern = (
+ r"OrientedBoundBox\(center=Vector\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), "
+ r"size=Vector\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), "
+ r"plane=Plane\(o=\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), "
+ r"x=\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\), "
+ r"z=\((?P[-\d\.]+), (?P[-\d\.]+), (?P[-\d\.]+)\)\)\)"
+ )
+ m = re.match(pattern, rep)
+ self.assertIsNotNone(
+ m, "The __repr__ string did not match the expected format."
+ )
+
+ # Convert extracted strings to floats.
+ center = Vector(
+ float(m.group("c0")), float(m.group("c1")), float(m.group("c2"))
+ )
+ size = Vector(float(m.group("s0")), float(m.group("s1")), float(m.group("s2")))
+ # For a unit cube, we expect the center to be (0.5, 0.5, 0.5)
+ self.assertAlmostEqual(center.X, 0.5, places=6)
+ self.assertAlmostEqual(center.Y, 0.5, places=6)
+ self.assertAlmostEqual(center.Z, 0.5, places=6)
+ # And the full size to be approximately (1, 1, 1) (floating-point values may vary slightly).
+ self.assertAlmostEqual(size.X, 1.0, places=6)
+ self.assertAlmostEqual(size.Y, 1.0, places=6)
+ self.assertAlmostEqual(size.Z, 1.0, places=6)
+
+
+if __name__ == "__main__":
+ unittest.main()
From 72bbc433f0da7db322d9fefb65b395268e5a2742 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 4 Feb 2025 18:39:44 -0500
Subject: [PATCH 185/518] Relaxing OOB orientation test as inconsistent across
platforms
---
src/build123d/geometry.py | 3 +++
tests/test_direct_api/test_oriented_bound_box.py | 7 ++++---
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 784dc1d..edb0c91 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -1731,6 +1731,9 @@ class OrientedBoundBox:
This class computes the oriented bounding box for a given build123d shape.
It exposes properties such as the center, principal axis directions, the
extents along these axes, and the full diagonal length of the box.
+
+ Note: The axes of the oriented bounding box are arbitary and may not be
+ consistent across platforms or time.
"""
def __init__(self, shape: Bnd_OBB | Shape):
diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py
index 25bbc2f..643b174 100644
--- a/tests/test_direct_api/test_oriented_bound_box.py
+++ b/tests/test_direct_api/test_oriented_bound_box.py
@@ -129,12 +129,13 @@ class TestOrientedBoundBox(unittest.TestCase):
self.assertAlmostEqual(center1.Z, center2.Z, places=6)
def test_plane(self):
+ """Orientation of plan may not be consistent across platforms"""
rect = Rot(Z=10) * Face.make_rect(1, 2)
obb = rect.oriented_bounding_box()
pln = obb.plane
- self.assertAlmostEqual(
- abs(pln.x_dir.dot(Vector(0, 1, 0).rotate(Axis.Z, 10))), 1.0, places=6
- )
+ # self.assertAlmostEqual(
+ # abs(pln.x_dir.dot(Vector(0, 1, 0).rotate(Axis.Z, 10))), 1.0, places=6
+ # )
self.assertAlmostEqual(abs(pln.z_dir.dot(Vector(0, 0, 1))), 1.0, places=6)
def test_repr(self):
From 4e42ccb1966cd7f1fa8f0ceba4ca0ae1ff18a6fc Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 6 Feb 2025 10:29:31 -0500
Subject: [PATCH 186/518] Adding OrientedBoundBox.corners and
Face.axes_of_symmetry
---
src/build123d/geometry.py | 51 +++++++-
src/build123d/topology/two_d.py | 101 ++++++++++++++-
tests/test_direct_api/test_face.py | 69 +++++++++-
.../test_oriented_bound_box.py | 119 +++++++++++++++++-
4 files changed, 334 insertions(+), 6 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index edb0c91..f2ca73c 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -35,11 +35,12 @@ from __future__ import annotations
# too-many-arguments, too-many-locals, too-many-public-methods,
# too-many-statements, too-many-instance-attributes, too-many-branches
import copy as copy_module
+import itertools
import json
import logging
import numpy as np
-from math import degrees, pi, radians
+from math import degrees, pi, radians, isclose
from typing import Any, overload, TypeAlias, TYPE_CHECKING
from collections.abc import Iterable, Sequence
@@ -1753,6 +1754,44 @@ class OrientedBoundBox:
BRepBndLib.AddOBB_s(shape.wrapped, obb, True)
self.wrapped = obb
+ @property
+ def corners(self) -> list[Vector]:
+ """
+ Compute and return the unique corner points of the oriented bounding box
+ in the coordinate system defined by the OBB's plane.
+
+ For degenerate shapes (e.g. a line or a planar face), only the unique
+ points are returned. For 2D shapes the corners are returned in an order
+ that allows a polygon to be directly created from them.
+
+ Returns:
+ list[Vector]: The unique corner points.
+ """
+
+ # Build a dictionary keyed by a tuple indicating if each axis is degenerate.
+ orders = {
+ # Straight line cases
+ (True, True, False): [(1, 1, 1), (1, 1, -1)],
+ (True, False, True): [(1, 1, 1), (1, -1, 1)],
+ (False, True, True): [(1, 1, 1), (-1, 1, 1)],
+ # Planar face cases
+ (True, False, False): [(1, 1, 1), (1, 1, -1), (1, -1, -1), (1, -1, 1)],
+ (False, True, False): [(1, 1, 1), (1, 1, -1), (-1, 1, -1), (-1, 1, 1)],
+ (False, False, True): [(1, 1, 1), (1, -1, 1), (-1, -1, 1), (-1, 1, 1)],
+ # 3D object case
+ (False, False, False): [
+ (x, y, z) for x, y, z in itertools.product((-1, 1), (-1, 1), (-1, 1))
+ ],
+ }
+ hs = self.size * 0.5
+ order = orders[(hs.X < TOLERANCE, hs.Y < TOLERANCE, hs.Z < TOLERANCE)]
+ local_corners = [
+ Vector(sx * hs.X, sy * hs.Y, sz * hs.Z) for sx, sy, sz in order
+ ]
+ corners = [self.plane.from_local_coords(c) for c in local_corners]
+
+ return corners
+
@property
def diagonal(self) -> float:
"""
@@ -1766,6 +1805,16 @@ class OrientedBoundBox:
return 0.0
return self.wrapped.SquareExtent() ** 0.5
+ @property
+ def location(self) -> Location:
+ """
+ The Location of the center of the oriented bounding box.
+
+ Returns:
+ Location: center location
+ """
+ return Location(self.plane)
+
@property
def plane(self) -> Plane:
"""
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index bf4e1a6..9b56973 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -93,12 +93,13 @@ from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid
from OCP.gce import gce_MakeLin
from OCP.gp import gp_Pnt, gp_Vec
-from build123d.build_enums import CenterOf, GeomType, SortBy, Transition
+from build123d.build_enums import CenterOf, GeomType, Keep, SortBy, Transition
from build123d.geometry import (
TOLERANCE,
Axis,
Color,
Location,
+ OrientedBoundBox,
Plane,
Vector,
VectorLike,
@@ -354,6 +355,104 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
# ---- Properties ----
+ @property
+ def axes_of_symmetry(self) -> list[Axis]:
+ """Computes and returns the axes of symmetry for a planar face.
+
+ The method determines potential symmetry axes by analyzing the face’s
+ geometry:
+ - It first validates that the face is non-empty and planar.
+ - For faces with inner wires (holes), it computes the centroid of the
+ holes and the face's overall center (COG).
+ If the holes' centroid significantly deviates from the COG (beyond
+ a specified tolerance), the symmetry axis is taken along the line
+ connecting these points; otherwise, each hole’s center is used to
+ generate a candidate axis.
+ - For faces without holes, candidate directions are derived by sampling
+ midpoints along the outer wire's edges.
+ If curved edges are present, additional candidate directions are
+ obtained from an oriented bounding box (OBB) constructed around the
+ face.
+
+ For each candidate direction, the face is split by a plane (defined
+ using the candidate direction and the face’s normal). The top half of the face
+ is then mirrored across this plane, and if the area of the intersection between
+ the mirrored half and the bottom half matches the bottom half’s area within a
+ small tolerance, the direction is accepted as an axis of symmetry.
+
+ Returns:
+ list[Axis]: A list of Axis objects, each defined by the face's
+ center and a direction vector, representing the symmetry axes of
+ the face.
+
+ Raises:
+ ValueError: If the face or its underlying representation is empty.
+ ValueError: If the face is not planar.
+ """
+
+ if self.wrapped is None:
+ raise ValueError("Can't determine axes_of_symmetry of empty face")
+
+ if not self.is_planar_face:
+ raise ValueError("axes_of_symmetry only supports for planar faces")
+
+ cog = self.center()
+ normal = self.normal_at()
+ shape_inner_wires = self.inner_wires()
+ if shape_inner_wires:
+ hole_faces = [Face(w) for w in shape_inner_wires]
+ holes_centroid = Face.combined_center(hole_faces)
+ # If the holes aren't centered on the cog the axis of symmetry must be
+ # through the cog and hole centroid
+ if abs(holes_centroid - cog) > TOLERANCE:
+ cross_dirs = [(holes_centroid - cog).normalized()]
+ else:
+ # There may be an axis of symmetry through the center of the holes
+ cross_dirs = [(f.center() - cog).normalized() for f in hole_faces]
+ else:
+ curved_edges = (
+ self.outer_wire().edges().filter_by(GeomType.LINE, reverse=True)
+ )
+ shape_edges = self.outer_wire().edges()
+ if curved_edges:
+ obb = OrientedBoundBox(self)
+ corners = obb.corners
+ obb_edges = ShapeList(
+ [Edge.make_line(corners[i], corners[(i + 1) % 4]) for i in range(4)]
+ )
+ mid_points = [
+ e @ p for e in shape_edges + obb_edges for p in [0.0, 0.5, 1.0]
+ ]
+ else:
+ mid_points = [e @ p for e in shape_edges for p in [0.0, 0.5, 1.0]]
+ cross_dirs = [(mid_point - cog).normalized() for mid_point in mid_points]
+
+ symmetry_dirs: set[Vector] = set()
+ for cross_dir in cross_dirs:
+ # Split the face by the potential axis and flip the top
+ split_plane = Plane(
+ origin=cog,
+ x_dir=cross_dir,
+ z_dir=cross_dir.cross(normal),
+ )
+ top, bottom = self.split(split_plane, keep=Keep.BOTH)
+ top_flipped = top.mirror(split_plane)
+
+ # Are the top/bottom the same?
+ if abs(bottom.intersect(top_flipped).area - bottom.area) < TOLERANCE:
+ # If this axis isn't in the set already add it
+ if not symmetry_dirs:
+ symmetry_dirs.add(cross_dir)
+ else:
+ opposite = any(
+ d.dot(cross_dir) < -1 + TOLERANCE for d in symmetry_dirs
+ )
+ if not opposite:
+ symmetry_dirs.add(cross_dir)
+
+ symmetry_axes = [Axis(cog, d) for d in symmetry_dirs]
+ return symmetry_axes
+
@property
def center_location(self) -> Location:
"""Location at the center of face"""
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index 6f16eca..d1904e0 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -32,7 +32,7 @@ import platform
import random
import unittest
-from build123d.build_common import Locations
+from build123d.build_common import Locations, PolarLocations
from build123d.build_enums import Align, CenterOf, GeomType
from build123d.build_line import BuildLine
from build123d.build_part import BuildPart
@@ -42,7 +42,13 @@ from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.importers import import_stl
from build123d.objects_curve import Polyline
from build123d.objects_part import Box, Cylinder
-from build123d.objects_sketch import Rectangle, RegularPolygon
+from build123d.objects_sketch import (
+ Circle,
+ Ellipse,
+ Rectangle,
+ RegularPolygon,
+ Triangle,
+)
from build123d.operations_generic import fillet
from build123d.operations_part import extrude
from build123d.operations_sketch import make_face
@@ -482,6 +488,65 @@ class TestFace(unittest.TestCase):
frame.wrapped = None
self.assertAlmostEqual(frame.total_area, 0.0, 5)
+ def test_axes_of_symmetry(self):
+ # Empty shape
+ shape = Face.make_rect(1, 1)
+ shape.wrapped = None
+ with self.assertRaises(ValueError):
+ shape.axes_of_symmetry
+
+ # Non planar
+ shape = Solid.make_cylinder(1, 2).faces().filter_by(GeomType.CYLINDER)[0]
+ with self.assertRaises(ValueError):
+ shape.axes_of_symmetry
+
+ # Test a variety of shapes
+ shapes = [
+ Rectangle(1, 1),
+ Rectangle(1, 2, align=Align.MIN),
+ Rectangle(1, 2, rotation=10),
+ Rectangle(1, 2, align=Align.MIN) - Pos(0.5, 0.75) * Circle(0.2),
+ (Rectangle(1, 2, align=Align.MIN) - Pos(0.5, 0.75) * Circle(0.2)).rotate(
+ Axis.Z, 10
+ ),
+ Triangle(a=1, b=0.5, C=90),
+ Circle(2) - Pos(0.1) * Rectangle(0.5, 0.5),
+ Circle(2) - Pos(0.1, 0.1) * Rectangle(0.5, 0.5),
+ Circle(2) - (Pos(0.1, 0.1) * PolarLocations(1, 3)) * Circle(0.3),
+ Circle(2) - (Pos(0.5) * PolarLocations(1, 3)) * Circle(0.3),
+ Circle(2) - PolarLocations(1, 3) * Circle(0.3),
+ Ellipse(1, 2, rotation=10),
+ ]
+ shape_dir = [
+ [(-1, 1), (-1, 0), (-1, -1), (0, -1)],
+ [(-1, 0), (0, -1)],
+ [Vector(-1, 0).rotate(Axis.Z, 10), Vector(0, -1).rotate(Axis.Z, 10)],
+ [(0, -1)],
+ [Vector(0, -1).rotate(Axis.Z, 10)],
+ [],
+ [(1, 0)],
+ [(1, 1)],
+ [],
+ [(1, 0)],
+ [
+ (1, 0),
+ Vector(1, 0).rotate(Axis.Z, 120),
+ Vector(1, 0).rotate(Axis.Z, 240),
+ ],
+ [Vector(1, 0).rotate(Axis.Z, 10), Vector(0, 1).rotate(Axis.Z, 10)],
+ ]
+
+ for i, shape in enumerate(shapes):
+ test_face: Face = shape.face()
+ cog = test_face.center()
+ axes = test_face.axes_of_symmetry
+ target_axes = [Axis(cog, d) for d in shape_dir[i]]
+ self.assertEqual(len(target_axes), len(axes))
+ axes_dirs = sorted(tuple(a.direction) for a in axes)
+ target_dirs = sorted(tuple(a.direction) for a in target_axes)
+ self.assertTrue(all(a == t) for a, t in zip(axes_dirs, target_dirs))
+ self.assertTrue(all(a.position == cog) for a in axes)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py
index 643b174..4ed6a6c 100644
--- a/tests/test_direct_api/test_oriented_bound_box.py
+++ b/tests/test_direct_api/test_oriented_bound_box.py
@@ -30,8 +30,10 @@ import math
import re
import unittest
-from build123d.geometry import Axis, OrientedBoundBox, Pos, Rot, Vector
-from build123d.topology import Face, Solid
+from build123d.geometry import Location, OrientedBoundBox, Plane, Pos, Rot, Vector
+from build123d.topology import Edge, Face, Solid
+from build123d.objects_part import Box
+from build123d.objects_sketch import Polygon
class TestOrientedBoundBox(unittest.TestCase):
@@ -175,6 +177,119 @@ class TestOrientedBoundBox(unittest.TestCase):
self.assertAlmostEqual(size.Y, 1.0, places=6)
self.assertAlmostEqual(size.Z, 1.0, places=6)
+ def test_rotated_cube_corners(self):
+ # Create a cube of size 2x2x2 rotated by 45 degrees around each axis.
+ rotated_cube = Rot(45, 45, 45) * Box(2, 2, 2)
+
+ # Compute the oriented bounding box.
+ obb = OrientedBoundBox(rotated_cube)
+ corners = obb.corners
+
+ # There should be eight unique corners.
+ self.assertEqual(len(corners), 8)
+
+ # The center of the cube should be at or near the origin.
+ center = obb.center()
+
+ # For a cube with full side lengths 2, the half-size is 1,
+ # so the distance from the center to any corner is sqrt(1^2 + 1^2 + 1^2) = sqrt(3).
+ expected_distance = math.sqrt(3)
+
+ # Verify that each corner is at the expected distance from the center.
+ for corner in corners:
+ distance = (corner - center).length
+ self.assertAlmostEqual(distance, expected_distance, places=6)
+
+ def test_planar_face_corners(self):
+ """
+ Test that a planar face returns four unique corner points.
+ """
+ # Create a square face of size 2x2 (centered at the origin).
+ face = Face.make_rect(2, 2)
+ # Compute the oriented bounding box from the face.
+ obb = OrientedBoundBox(face)
+ corners = obb.corners
+
+ # Convert each Vector to a tuple (rounded for tolerance reasons)
+ unique_points = {
+ (round(pt.X, 6), round(pt.Y, 6), round(pt.Z, 6)) for pt in corners
+ }
+ # For a planar (2D) face, we expect 4 unique corners.
+ self.assertEqual(
+ len(unique_points),
+ 4,
+ f"Expected 4 unique corners for a planar face but got {len(unique_points)}",
+ )
+ # Check orientation
+ for pln in [Plane.XY, Plane.XZ, Plane.YZ]:
+ rect = Face.make_rect(1, 2, pln)
+ obb = OrientedBoundBox(rect)
+ corners = obb.corners
+ poly = Polygon(*corners, align=None)
+ self.assertAlmostEqual(rect.intersect(poly).area, rect.area, 5)
+
+ for face in Box(1, 2, 3).faces():
+ obb = OrientedBoundBox(face)
+ corners = obb.corners
+ poly = Polygon(*corners, align=None)
+ self.assertAlmostEqual(face.intersect(poly).area, face.area, 5)
+
+ def test_line_corners(self):
+ """
+ Test that a straight line returns two unique endpoints.
+ """
+ # Create a straight line from (0, 0, 0) to (1, 0, 0).
+ line = Edge.make_line(Vector(0, 0, 0), Vector(1, 0, 0))
+ # Compute the oriented bounding box from the line.
+ obb = OrientedBoundBox(line)
+ corners = obb.corners
+
+ # Convert each Vector to a tuple (rounded for tolerance)
+ unique_points = {
+ (round(pt.X, 6), round(pt.Y, 6), round(pt.Z, 6)) for pt in corners
+ }
+ # For a line, we expect only 2 unique endpoints.
+ self.assertEqual(
+ len(unique_points),
+ 2,
+ f"Expected 2 unique corners for a line but got {len(unique_points)}",
+ )
+ # Check orientation
+ for end in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]:
+ line = Edge.make_line((0, 0, 0), end)
+ obb = OrientedBoundBox(line)
+ corners = obb.corners
+ self.assertEqual(len(corners), 2)
+ self.assertTrue(Vector(end) in corners)
+
+ def test_location(self):
+ # Create a unit cube.
+ cube = Solid.make_box(1, 1, 1)
+ obb = OrientedBoundBox(cube)
+
+ # Get the location property (constructed from the plane).
+ loc = obb.location
+
+ # Check that loc is a Location instance.
+ self.assertIsInstance(loc, Location)
+
+ # Compare the location's origin with the oriented bounding box center.
+ center = obb.center()
+ self.assertAlmostEqual(loc.position.X, center.X, places=6)
+ self.assertAlmostEqual(loc.position.Y, center.Y, places=6)
+ self.assertAlmostEqual(loc.position.Z, center.Z, places=6)
+
+ # Optionally, if the Location preserves the plane's orientation,
+ # check that the x and z directions match those of the obb's plane.
+ plane = obb.plane
+ self.assertAlmostEqual(loc.x_axis.direction.X, plane.x_dir.X, places=6)
+ self.assertAlmostEqual(loc.x_axis.direction.Y, plane.x_dir.Y, places=6)
+ self.assertAlmostEqual(loc.x_axis.direction.Z, plane.x_dir.Z, places=6)
+
+ self.assertAlmostEqual(loc.z_axis.direction.X, plane.z_dir.X, places=6)
+ self.assertAlmostEqual(loc.z_axis.direction.Y, plane.z_dir.Y, places=6)
+ self.assertAlmostEqual(loc.z_axis.direction.Z, plane.z_dir.Z, places=6)
+
if __name__ == "__main__":
unittest.main()
From 6e0af24b21e3bf1553dcdec329ef3d04ef27d120 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 6 Feb 2025 10:48:28 -0500
Subject: [PATCH 187/518] Fixing OOB test on Macs
---
tests/test_direct_api/test_oriented_bound_box.py | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/tests/test_direct_api/test_oriented_bound_box.py b/tests/test_direct_api/test_oriented_bound_box.py
index 4ed6a6c..bcdb566 100644
--- a/tests/test_direct_api/test_oriented_bound_box.py
+++ b/tests/test_direct_api/test_oriented_bound_box.py
@@ -30,7 +30,7 @@ import math
import re
import unittest
-from build123d.geometry import Location, OrientedBoundBox, Plane, Pos, Rot, Vector
+from build123d.geometry import Axis, Location, OrientedBoundBox, Plane, Pos, Rot, Vector
from build123d.topology import Edge, Face, Solid
from build123d.objects_part import Box
from build123d.objects_sketch import Polygon
@@ -131,14 +131,17 @@ class TestOrientedBoundBox(unittest.TestCase):
self.assertAlmostEqual(center1.Z, center2.Z, places=6)
def test_plane(self):
- """Orientation of plan may not be consistent across platforms"""
+ # Note: Orientation of plan may not be consistent across platforms
rect = Rot(Z=10) * Face.make_rect(1, 2)
obb = rect.oriented_bounding_box()
pln = obb.plane
- # self.assertAlmostEqual(
- # abs(pln.x_dir.dot(Vector(0, 1, 0).rotate(Axis.Z, 10))), 1.0, places=6
- # )
self.assertAlmostEqual(abs(pln.z_dir.dot(Vector(0, 0, 1))), 1.0, places=6)
+ self.assertTrue(
+ any(
+ abs(d.dot(Vector(1, 0).rotate(Axis.Z, 10))) > 0.999
+ for d in [pln.x_dir, pln.y_dir, pln.z_dir]
+ )
+ )
def test_repr(self):
# Create a simple unit cube OBB.
From 2247ea9303d505348e6b2ed8f3b34891ee9777ba Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 6 Feb 2025 11:01:38 -0500
Subject: [PATCH 188/518] Renaming Face.total_area to area_without_holes &
Face.remove_holes to without_holes
---
src/build123d/topology/two_d.py | 40 +++++++++++++++---------------
tests/test_direct_api/test_face.py | 16 ++++++------
2 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 9b56973..38b33da 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -505,7 +505,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return result
@property
- def total_area(self) -> float:
+ def area_without_holes(self) -> float:
"""
Calculate the total surface area of the face, including the areas of any holes.
@@ -519,7 +519,7 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
if self.wrapped is None:
return 0.0
- return self.remove_holes().area
+ return self.without_holes().area
@property
def volume(self) -> float:
@@ -1317,8 +1317,24 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
projected_shapes.append(shape)
return projected_shapes
- def remove_holes(self) -> Face:
- """remove_holes
+ def to_arcs(self, tolerance: float = 1e-3) -> Face:
+ """to_arcs
+
+ Approximate planar face with arcs and straight line segments.
+
+ Args:
+ tolerance (float, optional): Approximation tolerance. Defaults to 1e-3.
+
+ Returns:
+ Face: approximated face
+ """
+ if self.wrapped is None:
+ raise ValueError("Cannot approximate an empty shape")
+
+ return self.__class__.cast(BRepAlgo.ConvertFace_s(self.wrapped, tolerance))
+
+ def without_holes(self) -> Face:
+ """without_holes
Remove all of the holes from this face.
@@ -1339,22 +1355,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
holeless.wrapped = modified_shape
return holeless
- def to_arcs(self, tolerance: float = 1e-3) -> Face:
- """to_arcs
-
- Approximate planar face with arcs and straight line segments.
-
- Args:
- tolerance (float, optional): Approximation tolerance. Defaults to 1e-3.
-
- Returns:
- Face: approximated face
- """
- if self.wrapped is None:
- raise ValueError("Cannot approximate an empty shape")
-
- return self.__class__.cast(BRepAlgo.ConvertFace_s(self.wrapped, tolerance))
-
def wire(self) -> Wire:
"""Return the outerwire, generate a warning if inner_wires present"""
if self.inner_wires():
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index d1904e0..a46a895 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -453,10 +453,10 @@ class TestFace(unittest.TestCase):
face = Cylinder(1, 1).faces().filter_by(GeomType.CYLINDER)[0]
self.assertAlmostEqual(face.normal_at(0, 1), (1, 0, 0), 5)
- def test_remove_holes(self):
+ def test_without_holes(self):
# Planar test
frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face()
- filled = frame.remove_holes()
+ filled = frame.without_holes()
self.assertEqual(len(frame.inner_wires()), 1)
self.assertEqual(len(filled.inner_wires()), 0)
self.assertAlmostEqual(frame.area, 0.75, 5)
@@ -465,11 +465,11 @@ class TestFace(unittest.TestCase):
# Errors
frame.wrapped = None
with self.assertRaises(ValueError):
- frame.remove_holes()
+ frame.without_holes()
# No holes
rect = Face.make_rect(1, 1)
- self.assertEqual(rect, rect.remove_holes())
+ self.assertEqual(rect, rect.without_holes())
# Non-planar test
cyl_face = (
@@ -477,16 +477,16 @@ class TestFace(unittest.TestCase):
.faces()
.sort_by(Face.area)[-1]
)
- filled = cyl_face.remove_holes()
+ filled = cyl_face.without_holes()
self.assertEqual(len(cyl_face.inner_wires()), 2)
self.assertEqual(len(filled.inner_wires()), 0)
self.assertTrue(cyl_face.area < filled.area)
- self.assertAlmostEqual(cyl_face.total_area, filled.area, 5)
+ self.assertAlmostEqual(cyl_face.area_without_holes, filled.area, 5)
- def test_total_area(self):
+ def test_area_without_holes(self):
frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face()
frame.wrapped = None
- self.assertAlmostEqual(frame.total_area, 0.0, 5)
+ self.assertAlmostEqual(frame.area_without_holes, 0.0, 5)
def test_axes_of_symmetry(self):
# Empty shape
From e7aee388c618df82496c15e025068f36771a814e Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 6 Feb 2025 11:09:22 -0500
Subject: [PATCH 189/518] Reordering properties in module
---
src/build123d/topology/two_d.py | 34 ++++++++++++++++-----------------
1 file changed, 17 insertions(+), 17 deletions(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 38b33da..91af897 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -355,6 +355,23 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
# ---- Properties ----
+ @property
+ def area_without_holes(self) -> float:
+ """
+ Calculate the total surface area of the face, including the areas of any holes.
+
+ This property returns the overall area of the face as if the inner boundaries (holes)
+ were filled in.
+
+ Returns:
+ float: The total surface area, including the area of holes. Returns 0.0 if
+ the face is empty.
+ """
+ if self.wrapped is None:
+ return 0.0
+
+ return self.without_holes().area
+
@property
def axes_of_symmetry(self) -> list[Axis]:
"""Computes and returns the axes of symmetry for a planar face.
@@ -504,23 +521,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
result = face_vertices[-1].X - face_vertices[0].X
return result
- @property
- def area_without_holes(self) -> float:
- """
- Calculate the total surface area of the face, including the areas of any holes.
-
- This property returns the overall area of the face as if the inner boundaries (holes)
- were filled in.
-
- Returns:
- float: The total surface area, including the area of holes. Returns 0.0 if
- the face is empty.
- """
- if self.wrapped is None:
- return 0.0
-
- return self.without_holes().area
-
@property
def volume(self) -> float:
"""volume - the volume of this Face, which is always zero"""
From ce7d7d94c69a6dfc99aeba7d544ca08105d9ce55 Mon Sep 17 00:00:00 2001
From: luzpaz
Date: Thu, 6 Feb 2025 20:35:11 +0000
Subject: [PATCH 190/518] Fix source typos Found via `codespell -q 3 -w -L
parm,parms,re-use,substract`
---
src/build123d/drafting.py | 12 ++++++------
src/build123d/geometry.py | 6 +++---
2 files changed, 9 insertions(+), 9 deletions(-)
diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py
index b1388dd..176e6e6 100644
--- a/src/build123d/drafting.py
+++ b/src/build123d/drafting.py
@@ -228,10 +228,10 @@ class Draft:
def simplify_fraction(numerator: int, denominator: int) -> tuple[int, int]:
"""Mathematically simplify a fraction given a numerator and denominator"""
- greatest_common_demoninator = gcd(numerator, denominator)
+ greatest_common_denominator = gcd(numerator, denominator)
return (
- int(numerator / greatest_common_demoninator),
- int(denominator / greatest_common_demoninator),
+ int(numerator / greatest_common_denominator),
+ int(denominator / greatest_common_denominator),
)
if display_units is None:
@@ -258,15 +258,15 @@ class Draft:
return_value = f"{measurement}{unit_str}{tolerance_str}"
else:
whole_part = floor(number / IN)
- (numerator, demoninator) = simplify_fraction(
+ (numerator, denominator) = simplify_fraction(
round((number / IN - whole_part) * self.fractional_precision),
self.fractional_precision,
)
if whole_part == 0:
- return_value = f"{numerator}/{demoninator}{unit_str}{tolerance_str}"
+ return_value = f"{numerator}/{denominator}{unit_str}{tolerance_str}"
else:
return_value = (
- f"{whole_part} {numerator}/{demoninator}{unit_str}{tolerance_str}"
+ f"{whole_part} {numerator}/{denominator}{unit_str}{tolerance_str}"
)
return return_value
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index fa2a019..2f04499 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -1138,8 +1138,8 @@ class Color:
"""Color from a hexadecimal color code with an optional alpha value
Args:
- color_code (hexidecimal int): 0xRRGGBB
- alpha (hexidecimal int): 0x00 <= alpha as hex <= 0xFF
+ color_code (hexadecimal int): 0xRRGGBB
+ alpha (hexadecimal int): 0x00 <= alpha as hex <= 0xFF
"""
def __init__(self, *args, **kwargs):
@@ -1733,7 +1733,7 @@ class OrientedBoundBox:
It exposes properties such as the center, principal axis directions, the
extents along these axes, and the full diagonal length of the box.
- Note: The axes of the oriented bounding box are arbitary and may not be
+ Note: The axes of the oriented bounding box are arbitrary and may not be
consistent across platforms or time.
"""
From b64807fccc3cb643d9358ea105dcf8b3c1389472 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 6 Feb 2025 21:16:14 -0600
Subject: [PATCH 191/518] add oriented bounding box support to
Solid.from_bounding_box and tests
---
src/build123d/topology/three_d.py | 9 +++++++--
tests/test_direct_api/test_solid.py | 22 +++++++++++++++++++++-
2 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py
index c776614..5595888 100644
--- a/src/build123d/topology/three_d.py
+++ b/src/build123d/topology/three_d.py
@@ -95,6 +95,7 @@ from build123d.geometry import (
BoundBox,
Color,
Location,
+ OrientedBoundBox,
Plane,
Vector,
VectorLike,
@@ -949,9 +950,13 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]):
return result
@classmethod
- def from_bounding_box(cls, bbox: BoundBox) -> Solid:
+ def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid:
"""A box of the same dimensions and location"""
- return Solid.make_box(*bbox.size).locate(Location(bbox.min))
+ if isinstance(bbox, BoundBox):
+ return Solid.make_box(*bbox.size).locate(Location(bbox.min))
+ else:
+ moved_plane = Plane(Location(-bbox.size / 2)).move(bbox.location)
+ return Solid.make_box(*bbox.size, plane=moved_plane)
@classmethod
def make_box(
diff --git a/tests/test_direct_api/test_solid.py b/tests/test_direct_api/test_solid.py
index 2c67642..df44353 100644
--- a/tests/test_direct_api/test_solid.py
+++ b/tests/test_direct_api/test_solid.py
@@ -30,7 +30,15 @@ import math
import unittest
from build123d.build_enums import GeomType, Kind, Until
-from build123d.geometry import Axis, Location, Plane, Pos, Vector
+from build123d.geometry import (
+ Axis,
+ BoundBox,
+ Location,
+ OrientedBoundBox,
+ Plane,
+ Pos,
+ Vector,
+)
from build123d.objects_curve import Spline
from build123d.objects_sketch import Circle, Rectangle
from build123d.topology import Compound, Edge, Face, Shell, Solid, Vertex, Wire
@@ -233,6 +241,18 @@ class TestSolid(unittest.TestCase):
)
self.assertEqual(len(r.faces()), 6)
+ def test_from_bounding_box(self):
+ cyl = Solid.make_cylinder(0.001, 10).locate(Location(Plane.isometric))
+ cyl2 = Solid.make_cylinder(1, 10).locate(Location(Plane.isometric))
+
+ rbb = Solid.from_bounding_box(cyl.bounding_box())
+ obb = Solid.from_bounding_box(cyl.oriented_bounding_box())
+ obb2 = Solid.from_bounding_box(cyl2.oriented_bounding_box())
+
+ self.assertAlmostEqual(rbb.volume, (10**3) * (3**0.5) / 9, 0)
+ self.assertTrue(rbb.volume > obb.volume)
+ self.assertAlmostEqual(obb2.volume, 40, 4)
+
if __name__ == "__main__":
unittest.main()
From 8b53e1ab3cf833328130f37e69b6bcf3a6fc5cfc Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 6 Feb 2025 21:40:25 -0600
Subject: [PATCH 192/518] fix: mypy type checking in Solid.from_bounding_box
---
src/build123d/topology/three_d.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py
index 5595888..ee92394 100644
--- a/src/build123d/topology/three_d.py
+++ b/src/build123d/topology/three_d.py
@@ -955,8 +955,10 @@ class Solid(Mixin3D, Shape[TopoDS_Solid]):
if isinstance(bbox, BoundBox):
return Solid.make_box(*bbox.size).locate(Location(bbox.min))
else:
- moved_plane = Plane(Location(-bbox.size / 2)).move(bbox.location)
- return Solid.make_box(*bbox.size, plane=moved_plane)
+ moved_plane: Plane = Plane(Location(-bbox.size / 2)).move(bbox.location)
+ return Solid.make_box(
+ bbox.size.X, bbox.size.Y, bbox.size.Z, plane=moved_plane
+ )
@classmethod
def make_box(
From f22f54af5f4ce5e648d1932d6872cbe572b3d5ad Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sat, 8 Feb 2025 14:46:13 -0500
Subject: [PATCH 193/518] Covering Face.axes_of_symmetry corner cases
---
src/build123d/topology/two_d.py | 40 +++++-
tests/test_direct_api/test_face.py | 211 ++++++++++++++++++++++++++++-
2 files changed, 245 insertions(+), 6 deletions(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 91af897..b5ae7fe 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -57,7 +57,7 @@ from __future__ import annotations
import copy
import warnings
-from typing import Any, Tuple, Union, overload, TYPE_CHECKING
+from typing import Any, overload, TYPE_CHECKING
from collections.abc import Iterable, Sequence
@@ -93,6 +93,7 @@ from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape
from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid
from OCP.gce import gce_MakeLin
from OCP.gp import gp_Pnt, gp_Vec
+
from build123d.build_enums import CenterOf, GeomType, Keep, SortBy, Transition
from build123d.geometry import (
TOLERANCE,
@@ -406,7 +407,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
ValueError: If the face or its underlying representation is empty.
ValueError: If the face is not planar.
"""
-
if self.wrapped is None:
raise ValueError("Can't determine axes_of_symmetry of empty face")
@@ -452,12 +452,42 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
x_dir=cross_dir,
z_dir=cross_dir.cross(normal),
)
+ # Split by plane
top, bottom = self.split(split_plane, keep=Keep.BOTH)
- top_flipped = top.mirror(split_plane)
+
+ if type(top) != type(bottom): # exit early if not same
+ continue
+
+ if top is None or bottom is None: # Impossible to actually happen?
+ continue
+
+ top_list = ShapeList(top if isinstance(top, list) else [top])
+ bottom_list = ShapeList(bottom if isinstance(top, list) else [bottom])
+
+ if len(top_list) != len(bottom_list): # exit early unequal length
+ continue
+
+ bottom_list = bottom_list.sort_by(Axis(cog, cross_dir))
+ top_flipped_list = ShapeList(
+ f.mirror(split_plane) for f in top_list
+ ).sort_by(Axis(cog, cross_dir))
+
+ bottom_area = sum(f.area for f in bottom_list)
+ intersect_area = 0.0
+ for flipped_face, bottom_face in zip(top_flipped_list, bottom_list):
+ intersection = flipped_face.intersect(bottom_face)
+ if intersection is None or isinstance(intersection, list):
+ intersect_area = -1.0
+ break
+ else:
+ assert isinstance(intersection, Face)
+ intersect_area += intersection.area
+
+ if intersect_area == -1.0:
+ continue
# Are the top/bottom the same?
- if abs(bottom.intersect(top_flipped).area - bottom.area) < TOLERANCE:
- # If this axis isn't in the set already add it
+ if abs(intersect_area - bottom_area) < TOLERANCE:
if not symmetry_dirs:
symmetry_dirs.add(cross_dir)
else:
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index a46a895..159e979 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -40,7 +40,7 @@ from build123d.build_sketch import BuildSketch
from build123d.exporters3d import export_stl
from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.importers import import_stl
-from build123d.objects_curve import Polyline
+from build123d.objects_curve import Line, Polyline, Spline, ThreePointArc
from build123d.objects_part import Box, Cylinder
from build123d.objects_sketch import (
Circle,
@@ -547,6 +547,215 @@ class TestFace(unittest.TestCase):
self.assertTrue(all(a == t) for a, t in zip(axes_dirs, target_dirs))
self.assertTrue(all(a.position == cog) for a in axes)
+ # Fast abort code paths
+ s1 = Spline(
+ (0.0293923441471, 1.9478225275438),
+ (0.0293923441471, 1.2810839877038),
+ (0, -0.0521774724562),
+ (0.0293923441471, -1.3158620329962),
+ (0.0293923441471, -1.9478180575162),
+ )
+ l1 = Line(s1 @ 1, s1 @ 0)
+ self.assertEqual(len(Face(Wire([s1, l1])).axes_of_symmetry), 0)
+
+ with BuildSketch() as skt:
+ with BuildLine():
+ Line(
+ (-13.186467340991, 2.3737403364651),
+ (-5.1864673409911, 2.3737403364651),
+ )
+ Line(
+ (-13.186467340991, 2.3737403364651),
+ (-13.186467340991, -2.4506956262169),
+ )
+ ThreePointArc(
+ (-13.186467340991, -2.4506956262169),
+ (-13.479360559805, -3.1578024074034),
+ (-14.186467340991, -3.4506956262169),
+ )
+ Line(
+ (-17.186467340991, -3.4506956262169),
+ (-14.186467340991, -3.4506956262169),
+ )
+ ThreePointArc(
+ (-17.186467340991, -3.4506956262169),
+ (-17.893574122178, -3.1578024074034),
+ (-18.186467340991, -2.4506956262169),
+ )
+ Line(
+ (-18.186467340991, 7.6644400497781),
+ (-18.186467340991, -2.4506956262169),
+ )
+ Line(
+ (-51.186467340991, 7.6644400497781),
+ (-18.186467340991, 7.6644400497781),
+ )
+ Line(
+ (-51.186467340991, 7.6644400497781),
+ (-51.186467340991, -5.5182296356389),
+ )
+ Line(
+ (-51.186467340991, -5.5182296356389),
+ (-33.186467340991, -5.5182296356389),
+ )
+ Line(
+ (-33.186467340991, -5.5182296356389),
+ (-33.186467340991, -5.3055423052429),
+ )
+ Line(
+ (-33.186467340991, -5.3055423052429),
+ (53.813532659009, -5.3055423052429),
+ )
+ Line(
+ (53.813532659009, -5.3055423052429),
+ (53.813532659009, -5.7806956262169),
+ )
+ Line(
+ (66.813532659009, -5.7806956262169),
+ (53.813532659009, -5.7806956262169),
+ )
+ Line(
+ (66.813532659009, -2.7217530775369),
+ (66.813532659009, -5.7806956262169),
+ )
+ Line(
+ (54.813532659009, -2.7217530775369),
+ (66.813532659009, -2.7217530775369),
+ )
+ Line(
+ (54.813532659009, 7.6644400497781),
+ (54.813532659009, -2.7217530775369),
+ )
+ Line(
+ (38.813532659009, 7.6644400497781),
+ (54.813532659009, 7.6644400497781),
+ )
+ Line(
+ (38.813532659009, 7.6644400497781),
+ (38.813532659009, -2.4506956262169),
+ )
+ ThreePointArc(
+ (38.813532659009, -2.4506956262169),
+ (38.520639440195, -3.1578024074034),
+ (37.813532659009, -3.4506956262169),
+ )
+ Line(
+ (37.813532659009, -3.4506956262169),
+ (34.813532659009, -3.4506956262169),
+ )
+ ThreePointArc(
+ (34.813532659009, -3.4506956262169),
+ (34.106425877822, -3.1578024074034),
+ (33.813532659009, -2.4506956262169),
+ )
+ Line(
+ (33.813532659009, 2.3737403364651),
+ (33.813532659009, -2.4506956262169),
+ )
+ Line(
+ (25.813532659009, 2.3737403364651),
+ (33.813532659009, 2.3737403364651),
+ )
+ Line(
+ (25.813532659009, 2.3737403364651),
+ (25.813532659009, -2.4506956262169),
+ )
+ ThreePointArc(
+ (25.813532659009, -2.4506956262169),
+ (25.520639440195, -3.1578024074034),
+ (24.813532659009, -3.4506956262169),
+ )
+ Line(
+ (24.813532659009, -3.4506956262169),
+ (21.813532659009, -3.4506956262169),
+ )
+ ThreePointArc(
+ (21.813532659009, -3.4506956262169),
+ (21.106425877822, -3.1578024074034),
+ (20.813532659009, -2.4506956262169),
+ )
+ Line(
+ (20.813532659009, 2.3737403364651),
+ (20.813532659009, -2.4506956262169),
+ )
+ Line(
+ (12.813532659009, 2.3737403364651),
+ (20.813532659009, 2.3737403364651),
+ )
+ Line(
+ (12.813532659009, 2.3737403364651),
+ (12.813532659009, -2.4506956262169),
+ )
+ ThreePointArc(
+ (12.813532659009, -2.4506956262169),
+ (12.520639440195, -3.1578024074034),
+ (11.813532659009, -3.4506956262169),
+ )
+ Line(
+ (8.8135326590089, -3.4506956262169),
+ (11.813532659009, -3.4506956262169),
+ )
+ ThreePointArc(
+ (8.8135326590089, -3.4506956262169),
+ (8.1064258778223, -3.1578024074034),
+ (7.8135326590089, -2.4506956262169),
+ )
+ Line(
+ (7.8135326590089, 2.3737403364651),
+ (7.8135326590089, -2.4506956262169),
+ )
+ Line(
+ (-0.1864673409911, 2.3737403364651),
+ (7.8135326590089, 2.3737403364651),
+ )
+ Line(
+ (-0.1864673409911, 2.3737403364651),
+ (-0.1864673409911, -2.4506956262169),
+ )
+ ThreePointArc(
+ (-0.1864673409911, -2.4506956262169),
+ (-0.4793605598046, -3.1578024074034),
+ (-1.1864673409911, -3.4506956262169),
+ )
+ Line(
+ (-4.1864673409911, -3.4506956262169),
+ (-1.1864673409911, -3.4506956262169),
+ )
+ ThreePointArc(
+ (-4.1864673409911, -3.4506956262169),
+ (-4.8935741221777, -3.1578024074034),
+ (-5.1864673409911, -2.4506956262169),
+ )
+ Line(
+ (-5.1864673409911, 2.3737403364651),
+ (-5.1864673409911, -2.4506956262169),
+ )
+ make_face()
+ self.assertEqual(len(skt.face().axes_of_symmetry), 0)
+
+
+class TestAxesOfSymmetrySplitNone(unittest.TestCase):
+ def test_split_returns_none(self):
+ # Create a rectangle face for testing.
+ rect = Rectangle(10, 5).face()
+
+ # Monkey-patch the split method to simulate the degenerate case:
+ # Force split to return (None, rect) for any splitting plane.
+ original_split = Face.split # Save the original split method.
+ Face.split = lambda self, plane, keep: (None, None)
+
+ # Call axes_of_symmetry. With our patch, every candidate axis is skipped,
+ # so we expect no symmetry axes to be found.
+ axes = rect.axes_of_symmetry
+
+ # Verify that the result is an empty list.
+ self.assertEqual(
+ axes, [], "Expected no symmetry axes when split returns None for one half."
+ )
+
+ # Restore the original split method (cleanup).
+ Face.split = original_split
+
if __name__ == "__main__":
unittest.main()
From f6f916725e360b96d957fd2d4ba8016f1f4cdbd3 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 11 Feb 2025 11:33:11 -0500
Subject: [PATCH 194/518] Fixed handling of wrapped object
---
src/build123d/topology/utils.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py
index c876ba2..c1bbb1e 100644
--- a/src/build123d/topology/utils.py
+++ b/src/build123d/topology/utils.py
@@ -424,7 +424,7 @@ def unwrapped_shapetype(obj: Shape) -> TopAbs_ShapeEnum:
if len(shapetypes) == 1:
result = shapetypes.pop()
else:
- result = shapetype(obj)
+ result = shapetype(obj.wrapped)
else:
result = shapetype(obj.wrapped)
return result
From fd44037ef612302cf766a4b0f69fd236c0d13625 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 12 Feb 2025 19:28:25 -0500
Subject: [PATCH 195/518] Adding Face radius and rotational_axis properties
---
src/build123d/topology/two_d.py | 16 ++++++++++++++++
tests/test_direct_api/test_face.py | 21 ++++++++++++++++++++-
2 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index b5ae7fe..38a2b95 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -551,6 +551,22 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
result = face_vertices[-1].X - face_vertices[0].X
return result
+ @property
+ def radius(self) -> None | float:
+ """Return the radius of a cylinder or sphere, otherwise None"""
+ if self.geom_type in [GeomType.CYLINDER, GeomType.SPHERE]:
+ return self.geom_adaptor().Radius()
+ else:
+ return None
+
+ @property
+ def rotational_axis(self) -> None | Axis:
+ """Get the rotational axis of a cylinder"""
+ if self.geom_type == GeomType.CYLINDER:
+ return Axis(self.geom_adaptor().Cylinder().Axis())
+ else:
+ return None
+
@property
def volume(self) -> float:
"""volume - the volume of this Face, which is always zero"""
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index 159e979..e3a9f6b 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -41,7 +41,7 @@ from build123d.exporters3d import export_stl
from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.importers import import_stl
from build123d.objects_curve import Line, Polyline, Spline, ThreePointArc
-from build123d.objects_part import Box, Cylinder
+from build123d.objects_part import Box, Cylinder, Sphere
from build123d.objects_sketch import (
Circle,
Ellipse,
@@ -733,6 +733,25 @@ class TestFace(unittest.TestCase):
make_face()
self.assertEqual(len(skt.face().axes_of_symmetry), 0)
+ def test_radius_property(self):
+ c = Cylinder(1.5, 2).faces().filter_by(GeomType.CYLINDER)[0]
+ s = Sphere(3).faces().filter_by(GeomType.SPHERE)[0]
+ b = Box(1, 1, 1).faces()[0]
+ self.assertAlmostEqual(c.radius, 1.5, 5)
+ self.assertAlmostEqual(s.radius, 3, 5)
+ self.assertIsNone(b.radius)
+
+ def test_rotational_axis_property(self):
+ c = (
+ Cylinder(1.5, 2, rotation=(90, 0, 0))
+ .faces()
+ .filter_by(GeomType.CYLINDER)[0]
+ )
+ s = Sphere(3).faces().filter_by(GeomType.SPHERE)[0]
+ self.assertAlmostEqual(c.rotational_axis.direction, (0, -1, 0), 5)
+ self.assertAlmostEqual(c.rotational_axis.position, (0, 1, 0), 5)
+ self.assertIsNone(s.rotational_axis)
+
class TestAxesOfSymmetrySplitNone(unittest.TestCase):
def test_split_returns_none(self):
From 36e795857411e302641e7eca9686d44041fa5d54 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 12 Feb 2025 20:55:26 -0500
Subject: [PATCH 196/518] Covering Face properties corner case
---
src/build123d/topology/two_d.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 38a2b95..c9869ba 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -75,7 +75,7 @@ from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakePipeShell
from OCP.BRepTools import BRepTools, BRepTools_ReShape
from OCP.GProp import GProp_GProps
-from OCP.Geom import Geom_BezierSurface, Geom_Surface
+from OCP.Geom import Geom_BezierSurface, Geom_Surface, Geom_RectangularTrimmedSurface
from OCP.GeomAPI import GeomAPI_PointsToBSplineSurface, GeomAPI_ProjectPointOnSurf
from OCP.GeomAbs import GeomAbs_C0
from OCP.Precision import Precision
@@ -554,7 +554,10 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
@property
def radius(self) -> None | float:
"""Return the radius of a cylinder or sphere, otherwise None"""
- if self.geom_type in [GeomType.CYLINDER, GeomType.SPHERE]:
+ if (
+ self.geom_type in [GeomType.CYLINDER, GeomType.SPHERE]
+ and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface
+ ):
return self.geom_adaptor().Radius()
else:
return None
@@ -562,7 +565,10 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
@property
def rotational_axis(self) -> None | Axis:
"""Get the rotational axis of a cylinder"""
- if self.geom_type == GeomType.CYLINDER:
+ if (
+ self.geom_type == GeomType.CYLINDER
+ and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface
+ ):
return Axis(self.geom_adaptor().Cylinder().Axis())
else:
return None
From bcda4788672fd11706d380002fac9cca9e24f30d Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 14 Feb 2025 13:43:55 -0500
Subject: [PATCH 197/518] Adding a citation
---
CITATION.cff | 16 ++++++++++++++++
Citation.md | 19 +++++++++++++++++++
README.md | 2 ++
pyproject.toml | 1 +
4 files changed, 38 insertions(+)
create mode 100644 CITATION.cff
create mode 100644 Citation.md
diff --git a/CITATION.cff b/CITATION.cff
new file mode 100644
index 0000000..29770bf
--- /dev/null
+++ b/CITATION.cff
@@ -0,0 +1,16 @@
+cff-version: 1.2.0
+message: "If you use build123d in your research, please cite it using the following information."
+title: "build123d: A Python-based parametric CAD library"
+version: "0.9.1"
+doi: "10.5281/zenodo.14872323"
+authors:
+ - name: "Roger Maitland"
+ affiliation: "Independent Developer"
+date-released: "2024-02-14"
+repository-code: "https://github.com/gumyr/build123d"
+license: "Apache-2.0"
+keywords:
+ - CAD
+ - Python
+ - OpenCascade
+ - Parametric Design
diff --git a/Citation.md b/Citation.md
new file mode 100644
index 0000000..bb4781a
--- /dev/null
+++ b/Citation.md
@@ -0,0 +1,19 @@
+# Citation
+
+If you use **build123d** in your research, please cite:
+
+Roger Maitland. **"build123d: A Python-based parametric CAD library"**. Version 0.9.1, 2025.
+DOI: [10.5281/zenodo.14872323](https://doi.org/10.5281/zenodo.14872323)
+Source Code: [GitHub](https://github.com/gumyr/build123d)
+
+## BibTeX Entry
+
+```bibtex
+@software{build123d,
+ author = {Roger Maitland},
+ title = {build123d: A Python-based parametric CAD library},
+ year = {2025},
+ version = {0.9.1},
+ doi = {10.5281/zenodo.14872323},
+ url = {https://github.com/gumyr/build123d}
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 91d23f1..818210d 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@
[](https://pepy.tech/project/build123d)
[](https://pepy.tech/project/build123d)
[](https://pypi.org/project/build123d/)
+[](https://doi.org/10.5281/zenodo.14872322)
+
Build123d is a python-based, parametric, [boundary representation (BREP)][BREP] modeling framework for 2D and 3D CAD. It's built on the [Open Cascade] geometric kernel and allows for the creation of complex models using a simple and intuitive python syntax. Build123d can be used to create models for 3D printing, CNC machining, laser cutting, and other manufacturing processes. Models can be exported to a wide variety of popular CAD tools such as [FreeCAD] and SolidWorks.
diff --git a/pyproject.toml b/pyproject.toml
index 4a71828..ebf63d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -51,6 +51,7 @@ dependencies = [
"Homepage" = "https://github.com/gumyr/build123d"
"Documentation" = "https://build123d.readthedocs.io/en/latest/index.html"
"Bug Tracker" = "https://github.com/gumyr/build123d/issues"
+"Citation" = "https://doi.org/10.5281/zenodo.14872323"
[project.optional-dependencies]
# enable the optional ocp_vscode visualization package
From 838933be3729900869a49ae47de9530aa467dc40 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Fri, 14 Feb 2025 13:45:38 -0500
Subject: [PATCH 198/518] Fixing citation date
---
CITATION.cff | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CITATION.cff b/CITATION.cff
index 29770bf..6db4126 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -6,7 +6,7 @@ doi: "10.5281/zenodo.14872323"
authors:
- name: "Roger Maitland"
affiliation: "Independent Developer"
-date-released: "2024-02-14"
+date-released: "2025-02-14"
repository-code: "https://github.com/gumyr/build123d"
license: "Apache-2.0"
keywords:
From e0e5d0d36804f8d2fc993c1b5497ec248f34c348 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Sat, 15 Feb 2025 19:32:43 +0400
Subject: [PATCH 199/518] make `Axis.position` and `Axis.direction` properties
---
src/build123d/geometry.py | 25 +++++++++++++++----------
tests/test_direct_api/test_axis.py | 21 +++++++++++++++++++++
2 files changed, 36 insertions(+), 10 deletions(-)
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 2f04499..ec331bf 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -661,16 +661,21 @@ class Axis(metaclass=AxisMeta):
gp_Dir(*tuple(direction_vector.normalized())),
)
- self.position = Vector(
- self.wrapped.Location().X(),
- self.wrapped.Location().Y(),
- self.wrapped.Location().Z(),
- ) #: Axis origin
- self.direction = Vector(
- self.wrapped.Direction().X(),
- self.wrapped.Direction().Y(),
- self.wrapped.Direction().Z(),
- ) #: Axis direction
+ @property
+ def position(self):
+ return Vector(self.wrapped.Location())
+
+ @position.setter
+ def position(self, position: VectorLike):
+ self.wrapped.SetLocation(Vector(position).to_pnt())
+
+ @property
+ def direction(self):
+ return Vector(self.wrapped.Direction())
+
+ @direction.setter
+ def direction(self, direction: VectorLike):
+ self.wrapped.SetDirection(Vector(direction).to_dir())
@property
def location(self) -> Location:
diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py
index 49144c4..ed422d6 100644
--- a/tests/test_direct_api/test_axis.py
+++ b/tests/test_direct_api/test_axis.py
@@ -230,6 +230,27 @@ class TestAxis(unittest.TestCase):
random_obj = object()
self.assertNotEqual(Axis.X, random_obj)
+ def test_position_property(self):
+ axis = Axis.X
+ axis.position = 1, 2, 3
+ self.assertAlmostEqual(axis.position, (1, 2, 3))
+
+ axis.position += 1, 2, 3
+ self.assertAlmostEqual(axis.position, (2, 4, 6))
+
+ self.assertAlmostEqual(Axis(axis.wrapped).position, (2, 4, 6))
+
+ def test_direction_property(self):
+ axis = Axis.X
+ axis.direction = 1, 2, 3
+ self.assertAlmostEqual(axis.direction, Vector(1, 2, 3).normalized())
+
+ axis.direction += 5, 3, 1
+ expected = (Vector(1, 2, 3).normalized() + Vector(5, 3, 1)).normalized()
+ self.assertAlmostEqual(axis.direction, expected)
+
+ self.assertAlmostEqual(Axis(axis.wrapped).direction, expected)
+
if __name__ == "__main__":
unittest.main()
From 39b4fc20dc1be807de1747195db95abcf41a35e0 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 17 Feb 2025 19:53:05 -0500
Subject: [PATCH 200/518] Ported PR #875 to refactored topology
---
src/build123d/topology/two_d.py | 13 ++++++++-----
tests/test_direct_api/test_shells.py | 6 ++++++
2 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index c9869ba..6536b52 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -62,7 +62,7 @@ from typing import Any, overload, TYPE_CHECKING
from collections.abc import Iterable, Sequence
import OCP.TopAbs as ta
-from OCP.BRep import BRep_Tool
+from OCP.BRep import BRep_Tool, BRep_Builder
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.BRepAlgo import BRepAlgo
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
@@ -1453,10 +1453,13 @@ class Shell(Mixin2D, Shape[TopoDS_Shell]):
obj = obj_list[0]
if isinstance(obj, Face):
- builder = BRepBuilderAPI_MakeShell(
- BRepAdaptor_Surface(obj.wrapped).Surface().Surface()
- )
- obj = builder.Shape()
+ if obj.wrapped is None:
+ raise ValueError(f"Can't create a Shell from empty Face")
+ builder = BRep_Builder()
+ shell = TopoDS_Shell()
+ builder.MakeShell(shell)
+ builder.Add(shell, obj.wrapped)
+ obj = shell
elif isinstance(obj, Iterable):
obj = _sew_topods_faces([f.wrapped for f in obj])
diff --git a/tests/test_direct_api/test_shells.py b/tests/test_direct_api/test_shells.py
index d78de7f..6c9023b 100644
--- a/tests/test_direct_api/test_shells.py
+++ b/tests/test_direct_api/test_shells.py
@@ -29,6 +29,7 @@ license:
import math
import unittest
+from build123d.build_enums import GeomType
from build123d.geometry import Plane, Rot, Vector
from build123d.objects_curve import JernArc, Polyline, Spline
from build123d.objects_sketch import Circle
@@ -42,6 +43,11 @@ class TestShells(unittest.TestCase):
box_shell = Shell(box_faces)
self.assertTrue(box_shell.is_valid())
+ def test_shell_init_single_face(self):
+ face = Solid.make_cone(1, 0, 2).faces().filter_by(GeomType.CONE).first
+ shell = Shell(face)
+ self.assertTrue(shell.is_valid())
+
def test_center(self):
box_faces = Solid.make_box(1, 1, 1).faces()
box_shell = Shell(box_faces)
From 0208621fd770e9b95917c71e14fd4659aeb7912b Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 19 Feb 2025 11:20:06 -0500
Subject: [PATCH 201/518] Adding Face.radii, Face.is_circular_convex,
Face.is_circular_concave, rename Face.rotational_axis to
Face.axis_of_rotation
---
src/build123d/topology/two_d.py | 99 ++++++++++++++++++++++++++----
tests/test_direct_api/test_face.py | 90 +++++++++++++++++++++++++--
2 files changed, 172 insertions(+), 17 deletions(-)
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 6536b52..353fc39 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -373,6 +373,20 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return self.without_holes().area
+ @property
+ def axis_of_rotation(self) -> None | Axis:
+ """Get the rotational axis of a cylinder or torus"""
+ if type(self.geom_adaptor()) == Geom_RectangularTrimmedSurface:
+ return None
+
+ if self.geom_type == GeomType.CYLINDER:
+ return Axis(self.geom_adaptor().Cylinder().Axis())
+
+ if self.geom_type == GeomType.TORUS:
+ return Axis(self.geom_adaptor().Torus().Axis())
+
+ return None
+
@property
def axes_of_symmetry(self) -> list[Axis]:
"""Computes and returns the axes of symmetry for a planar face.
@@ -535,6 +549,69 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
return result
+ @property
+ def _curvature_sign(self) -> float:
+ """
+ Compute the signed dot product between the face normal and the vector from the
+ underlying geometry's reference point to the face center.
+
+ For a cylinder, the reference is the cylinder’s axis position.
+ For a sphere, it is the sphere’s center.
+ For a torus, we derive a reference point on the central circle.
+
+ Returns:
+ float: The signed value; positive indicates convexity, negative indicates concavity.
+ Returns 0 if the geometry type is unsupported.
+ """
+ if self.geom_type == GeomType.CYLINDER:
+ axis = self.axis_of_rotation
+ if axis is None:
+ raise ValueError("Can't find curvature of empty object")
+ return self.normal_at().dot(self.center() - axis.position)
+
+ elif self.geom_type == GeomType.SPHERE:
+ loc = self.location # The sphere's center
+ if loc is None:
+ raise ValueError("Can't find curvature of empty object")
+ return self.normal_at().dot(self.center() - loc.position)
+
+ elif self.geom_type == GeomType.TORUS:
+ # Here we assume that for a torus the rotational axis can be converted to a plane,
+ # and we then define the central (or core) circle using the first value of self.radii.
+ axis = self.axis_of_rotation
+ if axis is None or self.radii is None:
+ raise ValueError("Can't find curvature of empty object")
+ loc = Location(axis.to_plane())
+ axis_circle = Edge.make_circle(self.radii[0]).locate(loc)
+ _, pnt_on_axis_circle, _ = axis_circle.distance_to_with_closest_points(
+ self.center()
+ )
+ return self.normal_at().dot(self.center() - pnt_on_axis_circle)
+
+ return 0.0
+
+ @property
+ def is_circular_convex(self) -> bool:
+ """
+ Determine whether a given face is convex relative to its underlying geometry
+ for supported geometries: cylinder, sphere, torus.
+
+ Returns:
+ bool: True if convex; otherwise, False.
+ """
+ return self._curvature_sign > TOLERANCE
+
+ @property
+ def is_circular_concave(self) -> bool:
+ """
+ Determine whether a given face is concave relative to its underlying geometry
+ for supported geometries: cylinder, sphere, torus.
+
+ Returns:
+ bool: True if concave; otherwise, False.
+ """
+ return self._curvature_sign < -TOLERANCE
+
@property
def is_planar(self) -> bool:
"""Is the face planar even though its geom_type may not be PLANE"""
@@ -551,6 +628,17 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
result = face_vertices[-1].X - face_vertices[0].X
return result
+ @property
+ def radii(self) -> None | tuple[float, float]:
+ """Return the major and minor radii of a torus otherwise None"""
+ if self.geom_type == GeomType.TORUS:
+ return (
+ self.geom_adaptor().MajorRadius(),
+ self.geom_adaptor().MinorRadius(),
+ )
+
+ return None
+
@property
def radius(self) -> None | float:
"""Return the radius of a cylinder or sphere, otherwise None"""
@@ -562,17 +650,6 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
else:
return None
- @property
- def rotational_axis(self) -> None | Axis:
- """Get the rotational axis of a cylinder"""
- if (
- self.geom_type == GeomType.CYLINDER
- and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface
- ):
- return Axis(self.geom_adaptor().Cylinder().Axis())
- else:
- return None
-
@property
def volume(self) -> float:
"""volume - the volume of this Face, which is always zero"""
diff --git a/tests/test_direct_api/test_face.py b/tests/test_direct_api/test_face.py
index e3a9f6b..4b0557c 100644
--- a/tests/test_direct_api/test_face.py
+++ b/tests/test_direct_api/test_face.py
@@ -32,6 +32,8 @@ import platform
import random
import unittest
+from unittest.mock import patch, PropertyMock
+from OCP.Geom import Geom_RectangularTrimmedSurface
from build123d.build_common import Locations, PolarLocations
from build123d.build_enums import Align, CenterOf, GeomType
from build123d.build_line import BuildLine
@@ -41,7 +43,7 @@ from build123d.exporters3d import export_stl
from build123d.geometry import Axis, Location, Plane, Pos, Vector
from build123d.importers import import_stl
from build123d.objects_curve import Line, Polyline, Spline, ThreePointArc
-from build123d.objects_part import Box, Cylinder, Sphere
+from build123d.objects_part import Box, Cylinder, Sphere, Torus
from build123d.objects_sketch import (
Circle,
Ellipse,
@@ -49,7 +51,7 @@ from build123d.objects_sketch import (
RegularPolygon,
Triangle,
)
-from build123d.operations_generic import fillet
+from build123d.operations_generic import fillet, offset
from build123d.operations_part import extrude
from build123d.operations_sketch import make_face
from build123d.topology import Edge, Face, Solid, Wire
@@ -741,16 +743,92 @@ class TestFace(unittest.TestCase):
self.assertAlmostEqual(s.radius, 3, 5)
self.assertIsNone(b.radius)
- def test_rotational_axis_property(self):
+ def test_axis_of_rotation_property(self):
c = (
Cylinder(1.5, 2, rotation=(90, 0, 0))
.faces()
.filter_by(GeomType.CYLINDER)[0]
)
s = Sphere(3).faces().filter_by(GeomType.SPHERE)[0]
- self.assertAlmostEqual(c.rotational_axis.direction, (0, -1, 0), 5)
- self.assertAlmostEqual(c.rotational_axis.position, (0, 1, 0), 5)
- self.assertIsNone(s.rotational_axis)
+ self.assertAlmostEqual(c.axis_of_rotation.direction, (0, -1, 0), 5)
+ self.assertAlmostEqual(c.axis_of_rotation.position, (0, 1, 0), 5)
+ self.assertIsNone(s.axis_of_rotation)
+
+ @patch.object(
+ Face,
+ "geom_adaptor",
+ return_value=Geom_RectangularTrimmedSurface(
+ Face.make_rect(1, 1).geom_adaptor(), 0.0, 1.0, True
+ ),
+ )
+ def test_axis_of_rotation_property_error(self, mock_is_valid):
+ c = (
+ Cylinder(1.5, 2, rotation=(90, 0, 0))
+ .faces()
+ .filter_by(GeomType.CYLINDER)[0]
+ )
+ self.assertIsNone(c.axis_of_rotation)
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ def test_is_convex_concave(self):
+
+ with BuildPart() as open_box:
+ Box(20, 20, 5)
+ offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
+ fillet(open_box.edges(), 0.5)
+
+ outside_fillets = open_box.faces().filter_by(Face.is_circular_convex)
+ inside_fillets = open_box.faces().filter_by(Face.is_circular_concave)
+ self.assertEqual(len(outside_fillets), 28)
+ self.assertEqual(len(inside_fillets), 12)
+
+ @patch.object(
+ Face, "axis_of_rotation", new_callable=PropertyMock, return_value=None
+ )
+ def test_is_convex_concave_error0(self, mock_is_valid):
+ with BuildPart() as open_box:
+ Box(20, 20, 5)
+ offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
+ fillet(open_box.edges(), 0.5)
+
+ with self.assertRaises(ValueError):
+ open_box.faces().filter_by(Face.is_circular_convex)
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ @patch.object(Face, "radii", new_callable=PropertyMock, return_value=None)
+ def test_is_convex_concave_error1(self, mock_is_valid):
+ with BuildPart() as open_box:
+ Box(20, 20, 5)
+ offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
+ fillet(open_box.edges(), 0.5)
+
+ with self.assertRaises(ValueError):
+ open_box.faces().filter_by(Face.is_circular_convex)
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ @patch.object(Face, "location", new_callable=PropertyMock, return_value=None)
+ def test_is_convex_concave_error2(self, mock_is_valid):
+ with BuildPart() as open_box:
+ Box(20, 20, 5)
+ offset(amount=-2, openings=open_box.faces().sort_by(Axis.Z)[-1])
+ fillet(open_box.edges(), 0.5)
+
+ with self.assertRaises(ValueError):
+ open_box.faces().filter_by(Face.is_circular_convex)
+
+ # Verify is_valid was called
+ mock_is_valid.assert_called_once()
+
+ def test_radii(self):
+ t = Torus(5, 1).face()
+ self.assertAlmostEqual(t.radii, (5, 1), 5)
+ s = Sphere(1).face()
+ self.assertIsNone(s.radii)
class TestAxesOfSymmetrySplitNone(unittest.TestCase):
From 2d84e6ebdf64dad42130614e514d65a07a500254 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Wed, 19 Feb 2025 18:20:46 -0500
Subject: [PATCH 202/518] Limiting ocpsvg version
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index ebf63d6..2a96c45 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ dependencies = [
"ezdxf >= 1.1.0, < 2",
"ipython >= 8.0.0, < 9",
"py-lib3mf >= 2.3.1",
- "ocpsvg >= 0.4",
+ "ocpsvg >= 0.4, < 0.5",
"trianglesolver",
]
From 40cf1437ed47cbe98a1f1385d99497b927ff242d Mon Sep 17 00:00:00 2001
From: gumyr
Date: Thu, 20 Feb 2025 11:14:26 -0500
Subject: [PATCH 203/518] Updated edges() to use WireExplorer when appropriate
Issue #864
---
src/build123d/topology/one_d.py | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index e412478..018800b 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -88,7 +88,7 @@ from OCP.BRepOffset import BRepOffset_MakeOffset
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepProj import BRepProj_Projection
-from OCP.BRepTools import BRepTools
+from OCP.BRepTools import BRepTools, BRepTools_WireExplorer
from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse
from OCP.GCPnts import GCPnts_AbscissaPoint
from OCP.GProp import GProp_GProps
@@ -480,10 +480,20 @@ class Mixin1D(Shape):
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this Shape"""
- edge_list = Shape.get_shape_list(self, "Edge")
- return edge_list.filter_by(
- lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
- )
+ if isinstance(self, Wire):
+ # The WireExplorer is a tool to explore the edges of a wire in a connection order.
+ explorer = BRepTools_WireExplorer(self.wrapped)
+
+ edge_list: ShapeList[Edge] = ShapeList()
+ while explorer.More():
+ edge_list.append(Edge(explorer.Current()))
+ explorer.Next()
+ return edge_list
+ else:
+ edge_list = Shape.get_shape_list(self, "Edge")
+ return edge_list.filter_by(
+ lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
+ )
def end_point(self) -> Vector:
"""The end point of this edge.
From 8e4aa3370deaab114a3cccad72e56001297b3c95 Mon Sep 17 00:00:00 2001
From: snoyer
Date: Fri, 21 Feb 2025 20:28:06 +0400
Subject: [PATCH 204/518] add `align` parameter to `import_svg`
---
pyproject.toml | 2 +-
src/build123d/importers.py | 19 ++++++++++++++++---
tests/test_importers.py | 36 ++++++++++++++++++++++++++++++++++--
3 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 2a96c45..b5aa333 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,7 @@ dependencies = [
"ezdxf >= 1.1.0, < 2",
"ipython >= 8.0.0, < 9",
"py-lib3mf >= 2.3.1",
- "ocpsvg >= 0.4, < 0.5",
+ "ocpsvg >= 0.5, < 0.6",
"trianglesolver",
]
diff --git a/src/build123d/importers.py b/src/build123d/importers.py
index 0fa4bb8..73be5a7 100644
--- a/src/build123d/importers.py
+++ b/src/build123d/importers.py
@@ -70,7 +70,8 @@ from OCP.XCAFDoc import (
from ocpsvg import ColorAndLabel, import_svg_document
from svgpathtools import svg2paths
-from build123d.geometry import Color, Location
+from build123d.build_enums import Align
+from build123d.geometry import Color, Location, Vector, to_align_offset
from build123d.topology import (
Compound,
Edge,
@@ -337,6 +338,7 @@ def import_svg(
svg_file: str | Path | TextIO,
*,
flip_y: bool = True,
+ align: Align | tuple[Align, Align] | None = Align.MIN,
ignore_visibility: bool = False,
label_by: Literal["id", "class", "inkscape:label"] | str = "id",
is_inkscape_label: bool | None = None, # TODO remove for `1.0` release
@@ -346,6 +348,8 @@ def import_svg(
Args:
svg_file (Union[str, Path, TextIO]): svg file
flip_y (bool, optional): flip objects to compensate for svg orientation. Defaults to True.
+ align (Align | tuple[Align, Align] | None, optional): alignment of the SVG's viewbox,
+ if None, the viewbox's origin will be at `(0,0,0)`. Defaults to Align.MIN.
ignore_visibility (bool, optional): Defaults to False.
label_by (str, optional): XML attribute to use for imported shapes' `label` property.
Defaults to "id".
@@ -368,12 +372,18 @@ def import_svg(
label_by = re.sub(
r"^inkscape:(.+)", r"{http://www.inkscape.org/namespaces/inkscape}\1", label_by
)
- for face_or_wire, color_and_label in import_svg_document(
+ imported = import_svg_document(
svg_file,
flip_y=flip_y,
ignore_visibility=ignore_visibility,
metadata=ColorAndLabel.Label_by(label_by),
- ):
+ )
+
+ doc_xy = Vector(imported.viewbox.x, imported.viewbox.y)
+ doc_wh = Vector(imported.viewbox.width, imported.viewbox.height)
+ offset = to_align_offset(doc_xy, doc_xy + doc_wh, align)
+
+ for face_or_wire, color_and_label in imported:
if isinstance(face_or_wire, TopoDS_Wire):
shape = Wire(face_or_wire)
elif isinstance(face_or_wire, TopoDS_Face):
@@ -381,6 +391,9 @@ def import_svg(
else: # should not happen
raise ValueError(f"unexpected shape type: {type(face_or_wire).__name__}")
+ if offset.X != 0 or offset.Y != 0: # avoid copying if we don't need to
+ shape = shape.translate(offset)
+
if shape.wrapped:
shape.color = Color(*color_and_label.color_for(shape.wrapped))
shape.label = color_and_label.label
diff --git a/tests/test_importers.py b/tests/test_importers.py
index 0fa7088..f246ef3 100644
--- a/tests/test_importers.py
+++ b/tests/test_importers.py
@@ -16,10 +16,10 @@ from build123d.importers import (
import_step,
import_stl,
)
-from build123d.geometry import Pos
+from build123d.geometry import Pos, Vector
from build123d.exporters import ExportSVG
from build123d.exporters3d import export_brep, export_step
-from build123d.build_enums import GeomType
+from build123d.build_enums import Align, GeomType
class ImportSVG(unittest.TestCase):
@@ -116,6 +116,38 @@ class ImportSVG(unittest.TestCase):
self.assertEqual(str(svg[1].color), str(Color(1, 0, 0, 1)))
self.assertEqual(str(svg[2].color), str(Color(0, 0, 0, 1)))
+ def test_import_svg_origin(self):
+ svg_src = (
+ '"
+ )
+
+ svg = import_svg(StringIO(svg_src), align=None, flip_y=False)
+ self.assertAlmostEqual(svg[0].bounding_box().center(), Vector(2.0, +3.0))
+
+ svg = import_svg(StringIO(svg_src), align=None, flip_y=True)
+ self.assertAlmostEqual(svg[0].bounding_box().center(), Vector(2.0, -3.0))
+
+ def test_import_svg_align(self):
+ svg_src = (
+ '"
+ )
+
+ svg = import_svg(StringIO(svg_src), align=Align.MIN, flip_y=False)
+ self.assertAlmostEqual(svg[0].bounding_box().min, Vector(0.0, 0.0))
+
+ svg = import_svg(StringIO(svg_src), align=Align.MIN, flip_y=True)
+ self.assertAlmostEqual(svg[0].bounding_box().min, Vector(0, 0))
+
+ svg = import_svg(StringIO(svg_src), align=Align.MAX, flip_y=False)
+ self.assertAlmostEqual(svg[0].bounding_box().max, Vector(0.0, 0.0))
+
+ svg = import_svg(StringIO(svg_src), align=Align.MAX, flip_y=True)
+ self.assertAlmostEqual(svg[0].bounding_box().max, Vector(0, 0))
+
class ImportBREP(unittest.TestCase):
def test_bad_filename(self):
From aeb6b32b6594b51209a70ee1922ff63a00080dc1 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Sat, 22 Feb 2025 10:55:46 -0500
Subject: [PATCH 205/518] Fixing examples, PR #910
---
docs/tutorial_lego.rst | 32 +++----
.../build123d_customizable_logo_algebra.py | 2 +-
examples/build123d_logo_algebra.py | 2 +-
examples/custom_sketch_objects_algebra.py | 2 +-
examples/lego.py | 94 +++++++++++--------
examples/platonic_solids.py | 2 +-
examples/projection.py | 4 +-
examples/projection_algebra.py | 4 +-
examples/roller_coaster_algebra.py | 6 +-
src/build123d/operations_generic.py | 2 +-
10 files changed, 83 insertions(+), 67 deletions(-)
diff --git a/docs/tutorial_lego.rst b/docs/tutorial_lego.rst
index 168b3d0..0ac2ab4 100644
--- a/docs/tutorial_lego.rst
+++ b/docs/tutorial_lego.rst
@@ -21,7 +21,7 @@ The dimensions of the Lego block follow. A key parameter is ``pip_count``, the l
of the Lego blocks in pips. This parameter must be at least 2.
.. literalinclude:: ../examples/lego.py
- :lines: 29, 32-45
+ :lines: 30,31, 34-47
********************
Step 2: Part Builder
@@ -31,7 +31,7 @@ The Lego block will be created by the ``BuildPart`` builder as it's a discrete t
dimensional part; therefore, we'll instantiate a ``BuildPart`` with the name ``lego``.
.. literalinclude:: ../examples/lego.py
- :lines: 47
+ :lines: 49
**********************
Step 3: Sketch Builder
@@ -43,7 +43,7 @@ object. As this sketch will be part of the lego part, we'll create a sketch bui
in the context of the part builder as follows:
.. literalinclude:: ../examples/lego.py
- :lines: 47-49
+ :lines: 49-51
:emphasize-lines: 3
@@ -59,7 +59,7 @@ of the Lego block. The following step is going to refer to this rectangle, so it
be assigned the identifier ``perimeter``.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51
+ :lines: 49-53
:emphasize-lines: 5
Once the ``Rectangle`` object is created the sketch appears as follows:
@@ -76,7 +76,7 @@ hollowed out. This will be done with the ``Offset`` operation which is going to
create a new object from ``perimeter``.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61
+ :lines: 49-53,58-64
:emphasize-lines: 7-12
The first parameter to ``Offset`` is the reference object. The ``amount`` is a
@@ -104,7 +104,7 @@ objects are in the scope of a location context (``GridLocations`` in this case)
that defined multiple points, multiple rectangles are created.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61,65-69
+ :lines: 49-53,58-64,69-73
:emphasize-lines: 13-17
Here we can see that the first ``GridLocations`` creates two positions which causes
@@ -125,8 +125,8 @@ To convert the internal grid to ridges, the center needs to be removed. This wil
with another ``Rectangle``.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61,65-69,74-78
- :emphasize-lines: 17-22
+ :lines: 49-53,58-64,69-73,78-83
+ :emphasize-lines: 18-23
The ``Rectangle`` is subtracted from the sketch to leave the ridges as follows:
@@ -142,8 +142,8 @@ Lego blocks use a set of internal hollow cylinders that the pips push against
to hold two blocks together. These will be created with ``Circle``.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61,65-69,74-76,82-87
- :emphasize-lines: 21-26
+ :lines: 49-53,58-64,69-73,78-83,88-93
+ :emphasize-lines: 24-29
Here another ``GridLocations`` is used to position the centers of the circles. Note
that since both ``Circle`` objects are in the scope of the location context, both
@@ -162,8 +162,8 @@ Now that the sketch is complete it needs to be extruded into the three dimension
wall object.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61,65-69,74-76,82-87,91-92
- :emphasize-lines: 27-28
+ :lines: 49-53,58-64,69-73,78-83,88-93,98-99
+ :emphasize-lines: 30-31
Note how the ``Extrude`` operation is no longer in the ``BuildSketch`` scope and has returned
back into the ``BuildPart`` scope. This causes ``BuildSketch`` to exit and transfer the
@@ -183,8 +183,8 @@ Now that the walls are complete, the top of the block needs to be added. Althoug
could be done with another sketch, we'll add a box to the top of the walls.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61,65-69,74-76,82-87,91-92,100-108
- :emphasize-lines: 29-37
+ :lines: 49-53,58-64,69-73,78-83,88-93,98-99,110-118
+ :emphasize-lines: 32-40
To position the top, we'll describe the top center of the lego walls with a ``Locations`` context.
To determine the height we'll extract that from the
@@ -211,8 +211,8 @@ The final step is to add the pips to the top of the Lego block. To do this we'll
a new workplane on top of the block where we can position the pips.
.. literalinclude:: ../examples/lego.py
- :lines: 47-51,55-61,65-69,74-76,82-87,91-92,100-108,116-124
- :emphasize-lines: 38-46
+ :lines: 49-53,58-64,69-73,78-83,88-93,98-99,110-118,129-137
+ :emphasize-lines: 41-49
In this case, the workplane is created from the top Face of the Lego block by using the
``faces`` method and then sorted vertically and taking the top one ``sort_by(Axis.Z)[-1]``.
diff --git a/examples/build123d_customizable_logo_algebra.py b/examples/build123d_customizable_logo_algebra.py
index 4492e48..29bd81c 100644
--- a/examples/build123d_customizable_logo_algebra.py
+++ b/examples/build123d_customizable_logo_algebra.py
@@ -41,7 +41,7 @@ l2 = Line(
(logo_width, -font_height * 0.1),
(logo_width, -ext_line_length - font_height * 0.1),
)
-extension_lines = l1 + l2
+extension_lines = Curve() + (l1 + l2)
extension_lines += Pos(*(l1 @ 0.5)) * arrow_left
extension_lines += (Pos(*(l2 @ 0.5)) * Rot(Z=180)) * arrow_left
extension_lines += Line(l1 @ 0.5, l1 @ 0.5 + Vector(dim_line_length, 0))
diff --git a/examples/build123d_logo_algebra.py b/examples/build123d_logo_algebra.py
index 7c6d65b..f5d732a 100644
--- a/examples/build123d_logo_algebra.py
+++ b/examples/build123d_logo_algebra.py
@@ -37,7 +37,7 @@ l2 = Line(
(logo_width, -font_height * 0.1),
(logo_width, -ext_line_length - font_height * 0.1),
)
-extension_lines = l1 + l2
+extension_lines = Curve() + (l1 + l2)
extension_lines += Pos(*(l1 @ 0.5)) * arrow_left
extension_lines += (Pos(*(l2 @ 0.5)) * Rot(Z=180)) * arrow_left
extension_lines += Line(l1 @ 0.5, l1 @ 0.5 + Vector(dim_line_length, 0))
diff --git a/examples/custom_sketch_objects_algebra.py b/examples/custom_sketch_objects_algebra.py
index c67a3f8..a61c4c6 100644
--- a/examples/custom_sketch_objects_algebra.py
+++ b/examples/custom_sketch_objects_algebra.py
@@ -33,7 +33,7 @@ class Spade(Sketch):
b1 = Bezier(b0 @ 1, (242, -72), (114, -168), (11, -105))
b2 = Bezier(b1 @ 1, (31, -174), (42, -179), (53, -198))
l0 = Line(b2 @ 1, (0, -198))
- spade = l0 + b0 + b1 + b2
+ spade = b0 + b1 + b2 + l0
spade += mirror(spade, Plane.YZ)
spade = make_face(spade)
spade = scale(spade, height / spade.bounding_box().size.Y)
diff --git a/examples/lego.py b/examples/lego.py
index cbd7cad..88cb1a4 100644
--- a/examples/lego.py
+++ b/examples/lego.py
@@ -26,9 +26,11 @@ license:
See the License for the specific language governing permissions and
limitations under the License.
"""
-from build123d import *
-from ocp_vscode import *
+from build123d import *
+from ocp_vscode import show_object
+
+GEN_DOCS = False
pip_count = 6
lego_unit_size = 8
@@ -49,9 +51,10 @@ with BuildPart() as lego:
with BuildSketch() as plan:
# Start with a Rectangle the size of the block
perimeter = Rectangle(width=block_length, height=block_width)
- exporter = ExportSVG(scale=6)
- exporter.add_shape(plan.sketch)
- exporter.write("assets/lego_step4.svg")
+ if GEN_DOCS:
+ exporter = ExportSVG(scale=6)
+ exporter.add_shape(plan.sketch)
+ exporter.write("assets/lego_step4.svg")
# Subtract an offset to create the block walls
offset(
perimeter,
@@ -59,44 +62,51 @@ with BuildPart() as lego:
kind=Kind.INTERSECTION,
mode=Mode.SUBTRACT,
)
- exporter = ExportSVG(scale=6)
- exporter.add_shape(plan.sketch)
- exporter.write("assets/lego_step5.svg")
+ if GEN_DOCS:
+ exporter = ExportSVG(scale=6)
+ exporter.add_shape(plan.sketch)
+ exporter.write("assets/lego_step5.svg")
# Add a grid of lengthwise and widthwise bars
with GridLocations(x_spacing=0, y_spacing=lego_unit_size, x_count=1, y_count=2):
Rectangle(width=block_length, height=ridge_width)
with GridLocations(lego_unit_size, 0, pip_count, 1):
Rectangle(width=ridge_width, height=block_width)
- exporter = ExportSVG(scale=6)
- exporter.add_shape(plan.sketch)
- exporter.write("assets/lego_step6.svg")
+ if GEN_DOCS:
+ exporter = ExportSVG(scale=6)
+ exporter.add_shape(plan.sketch)
+ exporter.write("assets/lego_step6.svg")
# Substract a rectangle leaving ribs on the block walls
Rectangle(
block_length - 2 * (wall_thickness + ridge_depth),
block_width - 2 * (wall_thickness + ridge_depth),
mode=Mode.SUBTRACT,
)
- exporter = ExportSVG(scale=6)
- exporter.add_shape(plan.sketch)
- exporter.write("assets/lego_step7.svg")
+ if GEN_DOCS:
+ exporter = ExportSVG(scale=6)
+ exporter.add_shape(plan.sketch)
+ exporter.write("assets/lego_step7.svg")
# Add a row of hollow circles to the center
with GridLocations(
x_spacing=lego_unit_size, y_spacing=0, x_count=pip_count - 1, y_count=1
):
Circle(radius=support_outer_diameter / 2)
Circle(radius=support_inner_diameter / 2, mode=Mode.SUBTRACT)
- exporter = ExportSVG(scale=6)
- exporter.add_shape(plan.sketch)
- exporter.write("assets/lego_step8.svg")
+ if GEN_DOCS:
+ exporter = ExportSVG(scale=6)
+ exporter.add_shape(plan.sketch)
+ exporter.write("assets/lego_step8.svg")
# Extrude this base sketch to the height of the walls
extrude(amount=base_height - wall_thickness)
- visible, hidden = lego.part.project_to_viewport((-5, -30, 50))
- exporter = ExportSVG(scale=6)
- exporter.add_layer("Visible")
- exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT)
- exporter.add_shape(visible, layer="Visible")
- exporter.add_shape(hidden, layer="Hidden")
- exporter.write("assets/lego_step9.svg")
+ if GEN_DOCS:
+ visible, hidden = lego.part.project_to_viewport((-5, -30, 50))
+ exporter = ExportSVG(scale=6)
+ exporter.add_layer("Visible")
+ exporter.add_layer(
+ "Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT
+ )
+ exporter.add_shape(visible, layer="Visible")
+ exporter.add_shape(hidden, layer="Hidden")
+ exporter.write("assets/lego_step9.svg")
# Create a box on the top of the walls
with Locations((0, 0, lego.vertices().sort_by(Axis.Z)[-1].Z)):
# Create the top of the block
@@ -106,13 +116,16 @@ with BuildPart() as lego:
height=wall_thickness,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
- visible, hidden = lego.part.project_to_viewport((-5, -30, 50))
- exporter = ExportSVG(scale=6)
- exporter.add_layer("Visible")
- exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT)
- exporter.add_shape(visible, layer="Visible")
- exporter.add_shape(hidden, layer="Hidden")
- exporter.write("assets/lego_step10.svg")
+ if GEN_DOCS:
+ visible, hidden = lego.part.project_to_viewport((-5, -30, 50))
+ exporter = ExportSVG(scale=6)
+ exporter.add_layer("Visible")
+ exporter.add_layer(
+ "Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT
+ )
+ exporter.add_shape(visible, layer="Visible")
+ exporter.add_shape(hidden, layer="Hidden")
+ exporter.write("assets/lego_step10.svg")
# Create a workplane on the top of the block
with BuildPart(lego.faces().sort_by(Axis.Z)[-1]):
# Create a grid of pips
@@ -122,14 +135,17 @@ with BuildPart() as lego:
height=pip_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
- visible, hidden = lego.part.project_to_viewport((-100, -100, 50))
- exporter = ExportSVG(scale=6)
- exporter.add_layer("Visible")
- exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT)
- exporter.add_shape(visible, layer="Visible")
- exporter.add_shape(hidden, layer="Hidden")
- exporter.write("assets/lego.svg")
+ if GEN_DOCS:
+ visible, hidden = lego.part.project_to_viewport((-100, -100, 50))
+ exporter = ExportSVG(scale=6)
+ exporter.add_layer("Visible")
+ exporter.add_layer(
+ "Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT
+ )
+ exporter.add_shape(visible, layer="Visible")
+ exporter.add_shape(hidden, layer="Hidden")
+ exporter.write("assets/lego.svg")
assert abs(lego.part.volume - 3212.187337781355) < 1e-3
-show_object(lego.part.wrapped, name="lego")
+show_object(lego.part, name="lego")
diff --git a/examples/platonic_solids.py b/examples/platonic_solids.py
index c862690..0d527f4 100644
--- a/examples/platonic_solids.py
+++ b/examples/platonic_solids.py
@@ -118,7 +118,7 @@ class PlatonicSolid(BasePartObject):
platonic_faces.append(Face(Wire.make_polygon(corner_vertices)))
# Create the solid from the Faces
- platonic_solid = Solid.make_solid(Shell.make_shell(platonic_faces)).clean()
+ platonic_solid = Solid(Shell(platonic_faces)).clean()
# By definition, all vertices are the same distance from the origin so
# scale proportionally to this distance
diff --git a/examples/projection.py b/examples/projection.py
index dedc0ca..b5c3b7b 100644
--- a/examples/projection.py
+++ b/examples/projection.py
@@ -37,7 +37,7 @@ projection_direction = Vector(0, 1, 0)
square = Face.make_rect(20, 20, Plane.ZX.offset(-80))
square_projected = square.project_to_shape(sphere, projection_direction)
-square_solids = Compound([f.thicken(2) for f in square_projected])
+square_solids = Compound([Solid.thicken(f, 2) for f in square_projected])
projection_beams = [
Solid.make_loft(
[
@@ -75,7 +75,7 @@ text = Compound.make_text(
font_size=15,
align=(Align.MIN, Align.CENTER),
)
-projected_text = sphere.project_faces(text, path=arch_path)
+projected_text = Sketch(sphere.project_faces(text, path=arch_path))
# Example 1
show_object(sphere, name="sphere_solid", options={"alpha": 0.8})
diff --git a/examples/projection_algebra.py b/examples/projection_algebra.py
index 14ed71d..3e0ece2 100644
--- a/examples/projection_algebra.py
+++ b/examples/projection_algebra.py
@@ -9,7 +9,7 @@ projection_direction = Vector(0, 1, 0)
square = Plane.ZX.offset(-80) * Rectangle(20, 20)
square_projected = square.faces()[0].project_to_shape(sphere, projection_direction)
-square_solids = Part() + [f.thicken(2) for f in square_projected]
+square_solids = Part() + [Solid.thicken(f, 2) for f in square_projected]
face = square.faces()[0]
projection_beams = loft([face, Pos(0, 160, 0) * face])
@@ -39,7 +39,7 @@ text = Text(
font_size=15,
align=(Align.MIN, Align.CENTER),
)
-projected_text = sphere.project_faces(text.faces(), path=arch_path)
+projected_text = Sketch(sphere.project_faces(text.faces(), path=arch_path))
# Example 1
show_object(sphere, name="sphere_solid", options={"alpha": 0.8})
diff --git a/examples/roller_coaster_algebra.py b/examples/roller_coaster_algebra.py
index db2fa6b..67c21a3 100644
--- a/examples/roller_coaster_algebra.py
+++ b/examples/roller_coaster_algebra.py
@@ -1,4 +1,5 @@
from build123d import *
+from ocp_vscode import show_object
powerup = Spline(
(0, 0, 0),
@@ -10,11 +11,10 @@ powerup = Spline(
corner = RadiusArc(powerup @ 1, (100, 60, 0), -30)
screw = Helix(75, 150, 15, center=(75, 40, 15), direction=(-1, 0, 0))
-roller_coaster = powerup + corner + screw
+roller_coaster = Curve() + (powerup + corner + screw)
roller_coaster += Spline(corner @ 1, screw @ 0, tangents=(corner % 1, screw % 0))
roller_coaster += Spline(
screw @ 1, (-100, 30, 10), powerup @ 0, tangents=(screw % 1, powerup % 0)
)
-if "show_object" in locals():
- show_object(roller_coaster)
+show_object(roller_coaster)
diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py
index 80b0f00..c2b2e50 100644
--- a/src/build123d/operations_generic.py
+++ b/src/build123d/operations_generic.py
@@ -821,7 +821,7 @@ def project(
elif isinstance(context, BuildLine):
projected_shapes.extend(projection)
else: # BuildPart
- projected_shapes.append(projection[0])
+ projected_shapes.extend(projection.faces())
projected_points: ShapeList[Vector] = ShapeList()
for pnt in point_list:
From 80097a9227cf2b092e0c309fca825ae7d5f09f05 Mon Sep 17 00:00:00 2001
From: Ami Fischman
Date: Fri, 21 Feb 2025 23:41:52 -0800
Subject: [PATCH 206/518] Add a new TestCase that asserts that examples exit
successfully.
Examples changes that were necessary:
- loft.py: failed on macos (only) because of (seemingly) over-precise
floating-point accuracy assertion. Loosened the tolerance, and
expressed it as a multiple of the expected value.
> AssertionError: delta=0.002982314711971412 is greater than tolerance=0.001; got=1306.3375467197516, want=1306.3405290344635
- packed_boxes.py: only emit output files when GEN_DOCS is
True (mimicking lego.py).
---
examples/loft.py | 6 ++-
examples/packed_boxes.py | 5 ++-
tests/test_examples.py | 86 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+), 2 deletions(-)
create mode 100644 tests/test_examples.py
diff --git a/examples/loft.py b/examples/loft.py
index 1204672..a92ad0b 100644
--- a/examples/loft.py
+++ b/examples/loft.py
@@ -41,7 +41,11 @@ with BuildPart() as art:
top_bottom = art.faces().filter_by(GeomType.PLANE)
offset(openings=top_bottom, amount=0.5)
-assert abs(art.part.volume - 1306.3405290344635) < 1e-3
+want = 1306.3405290344635
+got = art.part.volume
+delta = abs(got - want)
+tolerance = want * 1e-5
+assert delta < tolerance, f"{delta=} is greater than {tolerance=}; {got=}, {want=}"
show(art, names=["art"])
# [End]
diff --git a/examples/packed_boxes.py b/examples/packed_boxes.py
index f037d2c..e4eacbd 100644
--- a/examples/packed_boxes.py
+++ b/examples/packed_boxes.py
@@ -12,6 +12,8 @@ import operator
import random
import build123d as bd
+GEN_DOCS = False
+
random.seed(123456)
test_boxes = [bd.Box(random.randint(1, 20), random.randint(1, 20), random.randint(1, 5))
for _ in range(50)]
@@ -28,7 +30,8 @@ def export_svg(parts, name):
exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=bd.LineType.ISO_DOT)
exporter.add_shape(visible, layer="Visible")
exporter.add_shape(hidden, layer="Hidden")
- exporter.write(f"../docs/assets/{name}.svg")
+ if GEN_DOCS:
+ exporter.write(f"../docs/assets/{name}.svg")
export_svg(test_boxes, "packed_boxes_input")
export_svg(packed, "packed_boxes_output")
diff --git a/tests/test_examples.py b/tests/test_examples.py
new file mode 100644
index 0000000..e342f0d
--- /dev/null
+++ b/tests/test_examples.py
@@ -0,0 +1,86 @@
+"""
+build123d Example tests
+
+name: test_examples.py
+by: fischman
+date: February 21 2025
+
+desc: Unit tests for the build123d examples, ensuring they don't raise.
+"""
+
+from pathlib import Path
+
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+_examples_dir = Path(os.path.abspath(os.path.dirname(__file__))).parent / "examples"
+
+_MOCK_OCP_VSCODE_CONTENTS = """
+from pathlib import Path
+
+import re
+import sys
+from unittest.mock import Mock
+mock_module = Mock()
+mock_module.show = Mock()
+mock_module.show_object = Mock()
+mock_module.show_all = Mock()
+sys.modules["ocp_vscode"] = mock_module
+"""
+
+
+def generate_example_test(path: Path):
+ """Generate and return a function to test the example at `path`."""
+ name = path.name
+
+ def assert_example_does_not_raise(self):
+ with tempfile.TemporaryDirectory(
+ prefix=f"build123d_test_examples_{name}"
+ ) as tmpdir:
+ # More examples emit output files than read input files,
+ # so default to running with a temporary directory to
+ # avoid cluttering the git working directory. For
+ # examples that want to read assets from the examples
+ # directory, use that. If an example is added in the
+ # future that wants to both read assets from the examples
+ # directory and write output files, deal with it then.
+ cwd = tmpdir if 'benchy' not in path.name else _examples_dir
+ mock_ocp_vscode = Path(tmpdir) / "_mock_ocp_vscode.py"
+ with open(mock_ocp_vscode, "w", encoding="utf-8") as f:
+ f.write(_MOCK_OCP_VSCODE_CONTENTS)
+ got = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ f"exec(open(r'{mock_ocp_vscode}').read()); exec(open(r'{path}').read())",
+ ],
+ capture_output=True,
+ cwd=cwd,
+ check=False,
+ )
+ self.assertEqual(
+ 0, got.returncode, f"stdout/stderr: {got.stdout} / {got.stderr}"
+ )
+
+ return assert_example_does_not_raise
+
+
+class TestExamples(unittest.TestCase):
+ """Tests build123d examples."""
+
+
+for example in sorted(_examples_dir.iterdir()):
+ if example.name.startswith("_") or not example.name.endswith(".py"):
+ continue
+ setattr(
+ TestExamples,
+ f"test_{example.name.replace('.', '_')}",
+ generate_example_test(example),
+ )
+
+if __name__ == "__main__":
+ unittest.main()
From bda0a6a719501829f2a5427a04f4cca38da5c826 Mon Sep 17 00:00:00 2001
From: Ami Fischman
Date: Sat, 22 Feb 2025 10:35:50 -0800
Subject: [PATCH 207/518] Run tests in parallel by default, and update
CONTRIBUTING.md to recommend this flow.
test_mesher.py was reusing the same filename across tests which meant
that when running in parallel tests would stomp on each
other. Robustified by having each test use a distinct file name.
---
.github/workflows/test.yml | 2 +-
CONTRIBUTING.md | 6 +++---
pyproject.toml | 1 +
tests/test_mesher.py | 42 +++++++++++++++++++++++++-------------
4 files changed, 33 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b636f04..f35c15f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -23,4 +23,4 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: test
run: |
- python -m pytest
+ python -m pytest -n auto
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 115577a..c1344c3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -3,10 +3,10 @@ tests, ensure they build and pass, and ensure that `pylint` and `mypy`
are happy with your code.
- Install `pip` following their [documentation](https://pip.pypa.io/en/stable/installation/).
-- Install development dependencies: `pip install pylint pytest mypy sphinx black`
-- Install docs dependencies: `pip install -r docs/requirements.txt` (might need to comment out the build123d line in that file)
+- Install development dependencies: `pip install -e .[development]`
+- Install docs dependencies: `pip install -e .[docs]`
- Install `build123d` in editable mode from current dir: `pip install -e .`
-- Run tests with: `python -m pytest`
+- Run tests with: `python -m pytest -n auto`
- Build docs with: `cd docs && make html`
- Check added files' style with: `pylint `
- Check added files' type annotations with: `mypy `
diff --git a/pyproject.toml b/pyproject.toml
index b5aa333..167ed01 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -64,6 +64,7 @@ development = [
"wheel",
"pytest",
"pytest-cov",
+ "pytest-xdist",
"pylint",
"mypy",
"black",
diff --git a/tests/test_mesher.py b/tests/test_mesher.py
index 9547d08..65edaff 100644
--- a/tests/test_mesher.py
+++ b/tests/test_mesher.py
@@ -2,7 +2,8 @@ import unittest, uuid
from packaging.specifiers import SpecifierSet
from pathlib import Path
from os import fsdecode, fsencode
-import time
+import sys
+import tempfile
import pytest
@@ -18,6 +19,12 @@ from build123d.geometry import Axis, Color, Location, Vector, VectorLike
from build123d.mesher import Mesher
+def temp_3mf_file():
+ caller = sys._getframe(1)
+ prefix = f"build123d_{caller.f_locals.get('self').__class__.__name__}_{caller.f_code.co_name}"
+ return tempfile.mktemp(suffix=".3mf", prefix=prefix)
+
+
class DirectApiTestCase(unittest.TestCase):
def assertTupleAlmostEquals(
self,
@@ -47,11 +54,12 @@ class TestProperties(unittest.TestCase):
def test_units(self):
for unit in Unit:
+ filename = temp_3mf_file()
exporter = Mesher(unit=unit)
exporter.add_shape(Solid.make_box(1, 1, 1))
- exporter.write("test.3mf")
+ exporter.write(filename)
importer = Mesher()
- _shape = importer.read("test.3mf")
+ _shape = importer.read(filename)
self.assertEqual(unit, importer.model_unit)
def test_vertex_and_triangle_counts(self):
@@ -73,9 +81,10 @@ class TestMetaData(unittest.TestCase):
exporter.add_shape(Solid.make_box(1, 1, 1))
exporter.add_meta_data("test_space", "test0", "some data", "str", True)
exporter.add_meta_data("test_space", "test1", "more data", "str", True)
- exporter.write("test.3mf")
+ filename = temp_3mf_file()
+ exporter.write(filename)
importer = Mesher()
- _shape = importer.read("test.3mf")
+ _shape = importer.read(filename)
imported_meta_data: list[dict] = importer.get_meta_data()
self.assertEqual(imported_meta_data[0]["name_space"], "test_space")
self.assertEqual(imported_meta_data[0]["name"], "test0")
@@ -90,9 +99,10 @@ class TestMetaData(unittest.TestCase):
exporter = Mesher()
exporter.add_shape(Solid.make_box(1, 1, 1))
exporter.add_code_to_metadata()
- exporter.write("test.3mf")
+ filename = temp_3mf_file()
+ exporter.write(filename)
importer = Mesher()
- _shape = importer.read("test.3mf")
+ _shape = importer.read(filename)
source_code = importer.get_meta_data_by_key("build123d", "test_mesher.py")
self.assertEqual(len(source_code), 2)
self.assertEqual(source_code["type"], "python")
@@ -118,9 +128,10 @@ class TestMeshProperties(unittest.TestCase):
part_number=str(mesh_type.value),
uuid_value=test_uuid,
)
- exporter.write("test.3mf")
+ filename = temp_3mf_file()
+ exporter.write(filename)
importer = Mesher()
- shape = importer.read("test.3mf")
+ shape = importer.read(filename)
self.assertEqual(shape[0].label, name)
self.assertEqual(importer.mesh_count, 1)
properties = importer.get_mesh_properties()
@@ -141,9 +152,10 @@ class TestAddShape(DirectApiTestCase):
red_shape.color = Color("red")
red_shape.label = "red"
exporter.add_shape([blue_shape, red_shape])
- exporter.write("test.3mf")
+ filename = temp_3mf_file()
+ exporter.write(filename)
importer = Mesher()
- box, cone = importer.read("test.3mf")
+ box, cone = importer.read(filename)
self.assertVectorAlmostEquals(box.bounding_box().size, (1, 1, 1), 2)
self.assertVectorAlmostEquals(box.bounding_box().size, (1, 1, 1), 2)
self.assertEqual(len(box.clean().faces()), 6)
@@ -158,9 +170,10 @@ class TestAddShape(DirectApiTestCase):
cone = Solid.make_cone(1, 0, 2).locate(Location((0, -1, 0)))
shape_assembly = Compound([box, cone])
exporter.add_shape(shape_assembly)
- exporter.write("test.3mf")
+ filename = temp_3mf_file()
+ exporter.write(filename)
importer = Mesher()
- shapes = importer.read("test.3mf")
+ shapes = importer.read(filename)
self.assertEqual(importer.mesh_count, 2)
@@ -216,7 +229,8 @@ class TestImportDegenerateTriangles(unittest.TestCase):
"format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"]
)
def test_pathlike_mesher(tmp_path, format):
- path = format(tmp_path / "test.3mf")
+ filename = temp_3mf_file()
+ path = format(tmp_path / filename)
exporter, importer = Mesher(), Mesher()
exporter.add_shape(Solid.make_box(1, 1, 1))
exporter.write(path)
From 1d8980441798fd35984eccc44e78f0520e04ea07 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Mon, 24 Feb 2025 15:36:58 -0500
Subject: [PATCH 208/518] Added GeomEncoder for JSON deprecating
LocationEncoder
---
src/build123d/__init__.py | 1 +
src/build123d/geometry.py | 79 +++++++++++++++++++++-
tests/test_direct_api/test_json.py | 92 ++++++++++++++++++++++++++
tests/test_direct_api/test_location.py | 6 +-
4 files changed, 173 insertions(+), 5 deletions(-)
create mode 100644 tests/test_direct_api/test_json.py
diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py
index 57b82f5..197fb43 100644
--- a/src/build123d/__init__.py
+++ b/src/build123d/__init__.py
@@ -150,6 +150,7 @@ __all__ = [
"Compound",
"Location",
"LocationEncoder",
+ "GeomEncoder",
"Joint",
"RigidJoint",
"RevoluteJoint",
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index ec331bf..455e0dc 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -39,11 +39,11 @@ import itertools
import json
import logging
import numpy as np
-
-from math import degrees, pi, radians, isclose
-from typing import Any, overload, TypeAlias, TYPE_CHECKING
+import warnings
from collections.abc import Iterable, Sequence
+from math import degrees, pi, radians, isclose
+from typing import Any, overload, TypeAlias, TYPE_CHECKING
import OCP.TopAbs as TopAbs_ShapeEnum
@@ -1252,6 +1252,68 @@ class Color:
return f"Color{str(tuple(self))}"
+class GeomEncoder(json.JSONEncoder):
+ """
+ A JSON encoder for build123d geometry objects.
+
+ This class extends ``json.JSONEncoder`` to provide custom serialization for
+ geometry objects such as Axis, Color, Location, Plane, and Vector. It converts
+ each geometry object into a dictionary containing exactly one key that identifies
+ the geometry type (e.g. ``"Axis"``, ``"Vector"``, etc.), paired with a tuple or
+ list that represents the underlying data. Any other object types are handled by
+ the standard encoder.
+
+ The inverse decoding is performed by the ``geometry_hook`` static method, which
+ expects the dictionary to have precisely one key from the known geometry types.
+ It then uses a class registry (``CLASS_REGISTRY``) to look up and instantiate
+ the appropriate class with the provided values.
+
+ **Usage Example**::
+
+ import json
+
+ # Suppose we have some geometry objects:
+ axis = Axis(position=(0, 0, 0), direction=(1, 0, 0))
+ vector = Vector(0.0, 1.0, 2.0)
+
+ data = {
+ "my_axis": axis,
+ "my_vector": vector
+ }
+
+ # Encode them to JSON:
+ encoded_data = json.dumps(data, cls=GeomEncoder, indent=4)
+
+ # Decode them back:
+ decoded_data = json.loads(encoded_data, object_hook=GeomEncoder.geometry_hook)
+
+ """
+
+ def default(self, obj):
+ """Return a JSON-serializable representation of a known geometry object."""
+ if isinstance(obj, Axis):
+ return {"Axis": (tuple(obj.position), tuple(obj.direction))}
+ elif isinstance(obj, Color):
+ return {"Color": obj.to_tuple()}
+ if isinstance(obj, Location):
+ return {"Location": obj.to_tuple()}
+ elif isinstance(obj, Plane):
+ return {"Plane": (tuple(obj.origin), tuple(obj.x_dir), tuple(obj.z_dir))}
+ elif isinstance(obj, Vector):
+ return {"Vector": tuple(obj)}
+ else:
+ # Let the base class default method raise the TypeError
+ return super().default(obj)
+
+ @staticmethod
+ def geometry_hook(json_dict):
+ """Convert dictionaries back into geometry objects for decoding."""
+ if len(json_dict.items()) != 1:
+ raise ValueError(f"Invalid geometry json object {json_dict}")
+ for key, value in json_dict.items():
+ return CLASS_REGISTRY[key](*value)
+
+
class Location:
"""Location in 3D space. Depending on usage can be absolute or relative.
@@ -1714,6 +1776,7 @@ class LocationEncoder(json.JSONEncoder):
def default(self, o: Location) -> dict:
"""Return a serializable object"""
+ warnings.warn("Use GeomEncoder instead", DeprecationWarning, stacklevel=2)
if not isinstance(o, Location):
raise TypeError("Only applies to Location objects")
return {"Location": o.to_tuple()}
@@ -1725,6 +1788,7 @@ class LocationEncoder(json.JSONEncoder):
Example:
read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
"""
+ warnings.warn("Use GeomEncoder instead", DeprecationWarning, stacklevel=2)
if "Location" in obj:
obj = Location(*[[float(f) for f in v] for v in obj["Location"]])
return obj
@@ -2890,6 +2954,15 @@ class Plane(metaclass=PlaneMeta):
return shape.intersect(self)
+CLASS_REGISTRY = {
+ "Axis": Axis,
+ "Color": Color,
+ "Location": Location,
+ "Plane": Plane,
+ "Vector": Vector,
+}
+
+
def to_align_offset(
min_point: VectorLike,
max_point: VectorLike,
diff --git a/tests/test_direct_api/test_json.py b/tests/test_direct_api/test_json.py
new file mode 100644
index 0000000..e253148
--- /dev/null
+++ b/tests/test_direct_api/test_json.py
@@ -0,0 +1,92 @@
+"""
+build123d tests
+
+name: test_json.py
+by: Gumyr
+date: February 24, 2025
+
+desc:
+ This python module contains tests for the build123d project.
+
+license:
+
+ Copyright 2025 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 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 json
+import os
+import unittest
+from build123d.geometry import (
+ Axis,
+ Color,
+ GeomEncoder,
+ Location,
+ LocationEncoder,
+ Matrix,
+ Plane,
+ Rotation,
+ Vector,
+)
+
+
+class TestGeomEncode(unittest.TestCase):
+
+ def test_as_json(self):
+
+ a_json = json.dumps(Axis.Y, cls=GeomEncoder)
+ axis = json.loads(a_json, object_hook=GeomEncoder.geometry_hook)
+ self.assertEqual(Axis.Y, axis)
+
+ c_json = json.dumps(Color("red"), cls=GeomEncoder)
+ color = json.loads(c_json, object_hook=GeomEncoder.geometry_hook)
+ self.assertEqual(Color("red").to_tuple(), color.to_tuple())
+
+ loc = Location((0, 1, 2), (4, 8, 16))
+ l_json = json.dumps(loc, cls=GeomEncoder)
+ loc_json = json.loads(l_json, object_hook=GeomEncoder.geometry_hook)
+ self.assertAlmostEqual(loc.position, loc_json.position, 5)
+ self.assertAlmostEqual(loc.orientation, loc_json.orientation, 5)
+
+ with self.assertWarnsRegex(DeprecationWarning, "Use GeomEncoder instead"):
+ loc_legacy = json.loads(l_json, object_hook=LocationEncoder.location_hook)
+ self.assertAlmostEqual(loc.position, loc_legacy.position, 5)
+ self.assertAlmostEqual(loc.orientation, loc_legacy.orientation, 5)
+
+ p_json = json.dumps(Plane.XZ, cls=GeomEncoder)
+ plane = json.loads(p_json, object_hook=GeomEncoder.geometry_hook)
+ self.assertEqual(Plane.XZ, plane)
+
+ rot = Rotation((0, 1, 4))
+ r_json = json.dumps(rot, cls=GeomEncoder)
+ rotation = json.loads(r_json, object_hook=GeomEncoder.geometry_hook)
+ self.assertAlmostEqual(rot.position, rotation.position, 5)
+ self.assertAlmostEqual(rot.orientation, rotation.orientation, 5)
+
+ v_json = json.dumps(Vector(1, 2, 4), cls=GeomEncoder)
+ vector = json.loads(v_json, object_hook=GeomEncoder.geometry_hook)
+ self.assertEqual(Vector(1, 2, 4), vector)
+
+ def test_as_json_error(self):
+ with self.assertRaises(TypeError):
+ json.dumps(Matrix(), cls=GeomEncoder)
+
+ v_json = '{"Vector": [1.0, 2.0, 4.0], "Color": [0, 0, 0, 0]}'
+ with self.assertRaises(ValueError):
+ json.loads(v_json, object_hook=GeomEncoder.geometry_hook)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py
index 95fd94c..11ca288 100644
--- a/tests/test_direct_api/test_location.py
+++ b/tests/test_direct_api/test_location.py
@@ -290,7 +290,8 @@ class TestLocation(unittest.TestCase):
}
# Serializing json with custom Location encoder
- json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder)
+ with self.assertWarnsRegex(DeprecationWarning, "Use GeomEncoder instead"):
+ json_object = json.dumps(data_dict, indent=4, cls=LocationEncoder)
# Writing to sample.json
with open("sample.json", "w") as outfile:
@@ -298,7 +299,8 @@ class TestLocation(unittest.TestCase):
# Reading from sample.json
with open("sample.json") as infile:
- read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
+ with self.assertWarnsRegex(DeprecationWarning, "Use GeomEncoder instead"):
+ read_json = json.load(infile, object_hook=LocationEncoder.location_hook)
# Validate locations
for key, value in read_json.items():
From 4d8dfe16a88959eacdc23d427fe59c7ef8852797 Mon Sep 17 00:00:00 2001
From: Ami Fischman
Date: Mon, 24 Feb 2025 15:58:25 -0800
Subject: [PATCH 209/518] Avoid deepcopy'ing Shape.topo_parent.
Speeds up benchy example from 27s to 5.5s.
---
src/build123d/topology/shape_core.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index d61d0de..934a193 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -866,7 +866,10 @@ class Shape(NodeMixin, Generic[TOPODS]):
if self.wrapped is not None:
memo[id(self.wrapped)] = downcast(BRepBuilderAPI_Copy(self.wrapped).Shape())
for key, value in self.__dict__.items():
- setattr(result, key, copy.deepcopy(value, memo))
+ if key == 'topo_parent':
+ result.topo_parent = value
+ else:
+ setattr(result, key, copy.deepcopy(value, memo))
if key == "joints":
for joint in result.joints.values():
joint.parent = result
From 485bfa1f871c897ce5c6476efe5093cb5aea4982 Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 27 Feb 2025 15:54:13 -0600
Subject: [PATCH 210/518] pyproject.toml -> switch to the official lib3mf
release on pypi
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 167ed01..0c93d5d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,7 @@ dependencies = [
"anytree >= 2.8.0, < 3",
"ezdxf >= 1.1.0, < 2",
"ipython >= 8.0.0, < 9",
- "py-lib3mf >= 2.3.1",
+ "lib3mf >= 2.4.1",
"ocpsvg >= 0.5, < 0.6",
"trianglesolver",
]
From 01cf1082b6c4deac085fd205acc72d070b5989bb Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 27 Feb 2025 15:59:43 -0600
Subject: [PATCH 211/518] Update mesher.py -> use lib3mf instead of py_lib3mf
---
src/build123d/mesher.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py
index de102a1..5fb9a54 100644
--- a/src/build123d/mesher.py
+++ b/src/build123d/mesher.py
@@ -109,7 +109,7 @@ from OCP.TopAbs import TopAbs_ShapeEnum
from OCP.TopExp import TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Compound
-from py_lib3mf import Lib3MF
+from lib3mf import Lib3MF
from build123d.build_enums import MeshType, Unit
from build123d.geometry import TOLERANCE, Color
From acb22b5dec3a565a6aabae6b51ef8707bf41ef1e Mon Sep 17 00:00:00 2001
From: jdegenstein
Date: Thu, 27 Feb 2025 16:08:10 -0600
Subject: [PATCH 212/518] mypy.ini -> change py_lib3mf to lib3mf
---
mypy.ini | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/mypy.ini b/mypy.ini
index b014dd5..e6a7c3d 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -41,5 +41,5 @@ ignore_missing_imports = True
[mypy-setuptools_scm.*]
ignore_missing_imports = True
-[mypy-py_lib3mf.*]
+[mypy-lib3mf.*]
ignore_missing_imports = True
From dce0c5dc1b808d5539fb44805d57a48c972a53ef Mon Sep 17 00:00:00 2001
From: mingmingrr
Date: Fri, 28 Feb 2025 22:47:53 -0500
Subject: [PATCH 213/518] Update type signature for component getters
---
src/build123d/build_common.py | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py
index fc6e350..755414a 100644
--- a/src/build123d/build_common.py
+++ b/src/build123d/build_common.py
@@ -50,7 +50,7 @@ import functools
from abc import ABC, abstractmethod
from itertools import product
from math import sqrt, cos, pi
-from typing import Any, cast, overload, Type, TypeVar
+from typing import Any, cast, overload, Protocol, Type, TypeVar
from collections.abc import Callable, Iterable
from typing_extensions import Self
@@ -1341,11 +1341,16 @@ class WorkplaneList:
# Type variable representing the return type of the wrapped function
T2 = TypeVar("T2")
+T2_covar = TypeVar("T2_covar", covariant=True)
+
+
+class ContextComponentGetter(Protocol[T2_covar]):
+ def __call__(self, select: Select = Select.ALL) -> T2_covar: ...
def __gen_context_component_getter(
- func: Callable[[Builder, Select], T2]
-) -> Callable[[Select], T2]:
+ func: Callable[[Builder, Select], T2],
+) -> ContextComponentGetter[T2]:
"""
Wraps a Builder method to automatically provide the Builder context.
@@ -1360,7 +1365,7 @@ def __gen_context_component_getter(
a `Select` instance as its second argument.
Returns:
- Callable[[Select], T2]: A callable that takes only a `Select` argument and
+ ContextComponentGetter[T2]: A callable that takes only a `Select` argument and
internally retrieves the Builder context to call the original method.
Raises:
From 8ba128c273e83a19d62ab474a427ed9eee155ad1 Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 4 Mar 2025 09:51:22 -0500
Subject: [PATCH 214/518] Improved to remove face duplicates
---
src/build123d/topology/one_d.py | 18 +++++++++---------
tests/test_topo_explore.py | 4 ----
2 files changed, 9 insertions(+), 13 deletions(-)
diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py
index 018800b..08c1cd4 100644
--- a/src/build123d/topology/one_d.py
+++ b/src/build123d/topology/one_d.py
@@ -133,6 +133,7 @@ from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import (
TopTools_HSequenceOfShape,
TopTools_IndexedDataMapOfShapeListOfShape,
+ TopTools_IndexedMapOfShape,
TopTools_ListOfShape,
)
from OCP.TopoDS import (
@@ -3099,14 +3100,13 @@ def topo_explore_connected_faces(
parent.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map
)
- # Query the map
- faces = []
+ # Query the map and select only unique faces
+ unique_face_map = TopTools_IndexedMapOfShape()
+ unique_faces = []
if edge_face_map.Contains(edge.wrapped):
- face_list = edge_face_map.FindFromKey(edge.wrapped)
- for face in face_list:
- faces.append(TopoDS.Face_s(face))
+ for face in edge_face_map.FindFromKey(edge.wrapped):
+ unique_face_map.Add(face)
+ for i in range(unique_face_map.Extent()):
+ unique_faces.append(TopoDS.Face_s(unique_face_map(i + 1)))
- if len(faces) != 2:
- raise RuntimeError("Invalid # of faces connected to this edge")
-
- return faces
+ return unique_faces
diff --git a/tests/test_topo_explore.py b/tests/test_topo_explore.py
index 8524795..5c686df 100644
--- a/tests/test_topo_explore.py
+++ b/tests/test_topo_explore.py
@@ -173,10 +173,6 @@ class TestTopoExploreConnectedFaces(unittest.TestCase):
self.assertEqual(len(faces), 2)
def test_topo_explore_connected_faces_invalid(self):
- # Test with an edge that is not connected to two faces
- with self.assertRaises(RuntimeError):
- topo_explore_connected_faces(self.unconnected_edge)
-
# No parent case
with self.assertRaises(ValueError):
topo_explore_connected_faces(Edge())
From 10a466f6453ef99c4c485d02985e5357f3f3076f Mon Sep 17 00:00:00 2001
From: gumyr
Date: Tue, 4 Mar 2025 10:01:49 -0500
Subject: [PATCH 215/518] Improving geometry eq/hash to enable sets
---
src/build123d/build_common.py | 3 +-
src/build123d/geometry.py | 57 +++++++++++++++++++++++---
src/build123d/topology/shape_core.py | 2 +-
src/build123d/topology/two_d.py | 5 ++-
tests/test_direct_api/test_axis.py | 13 ++++++
tests/test_direct_api/test_location.py | 17 ++++++++
tests/test_direct_api/test_plane.py | 15 +++++++
tests/test_direct_api/test_vector.py | 10 +++++
8 files changed, 113 insertions(+), 9 deletions(-)
diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py
index 755414a..947885d 100644
--- a/src/build123d/build_common.py
+++ b/src/build123d/build_common.py
@@ -1350,7 +1350,8 @@ class ContextComponentGetter(Protocol[T2_covar]):
def __gen_context_component_getter(
func: Callable[[Builder, Select], T2],
-) -> ContextComponentGetter[T2]:
+ # ) -> ContextComponentGetter[T2]:
+) -> Callable[[Select], T2]:
"""
Wraps a Builder method to automatically provide the Builder context.
diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py
index 455e0dc..434b028 100644
--- a/src/build123d/geometry.py
+++ b/src/build123d/geometry.py
@@ -42,7 +42,7 @@ import numpy as np
import warnings
from collections.abc import Iterable, Sequence
-from math import degrees, pi, radians, isclose
+from math import degrees, log10, pi, radians, isclose
from typing import Any, overload, TypeAlias, TYPE_CHECKING
import OCP.TopAbs as TopAbs_ShapeEnum
@@ -88,6 +88,7 @@ logging.getLogger("build123d").addHandler(logging.NullHandler())
logger = logging.getLogger("build123d")
TOLERANCE = 1e-6
+TOL_DIGITS = abs(int(log10(TOLERANCE)))
TOL = 1e-2
DEG2RAD = pi / 180.0
RAD2DEG = 180 / pi
@@ -445,11 +446,17 @@ class Vector:
"""Vectors equal operator =="""
if not isinstance(other, Vector):
return NotImplemented
- return self.wrapped.IsEqual(other.wrapped, 0.00001, 0.00001)
+ return self.wrapped.IsEqual(other.wrapped, TOLERANCE, TOLERANCE)
def __hash__(self) -> int:
"""Hash of Vector"""
- return hash((round(self.X, 6), round(self.Y, 6), round(self.Z, 6)))
+ return hash(
+ (
+ round(self.X, TOL_DIGITS - 1),
+ round(self.Y, TOL_DIGITS - 1),
+ round(self.Z, TOL_DIGITS - 1),
+ )
+ )
def __copy__(self) -> Vector:
"""Return copy of self"""
@@ -690,6 +697,16 @@ class Axis(metaclass=AxisMeta):
"""Return deepcopy of self"""
return Axis(self.position, self.direction)
+ def __hash__(self) -> int:
+ """Hash of Axis"""
+ return hash(
+ (
+ round(v, TOL_DIGITS - 1)
+ for vector in [self.position, self.direction]
+ for v in vector
+ )
+ )
+
def __repr__(self) -> str:
"""Display self"""
return f"({self.position.to_tuple()},{self.direction.to_tuple()})"
@@ -1660,7 +1677,25 @@ class Location:
radians(other.orientation.Y),
radians(other.orientation.Z),
)
- return self.position == other.position and quaternion1.IsEqual(quaternion2)
+ # Test quaternions with tolerance
+ q_values = [
+ [get_value() for get_value in (q.X, q.Y, q.Z, q.W)]
+ for q in (quaternion1, quaternion2)
+ ]
+ quaternion_eq = all(
+ isclose(v1, v2, abs_tol=TOLERANCE) for v1, v2 in zip(*q_values)
+ )
+ return self.position == other.position and quaternion_eq
+
+ def __hash__(self) -> int:
+ """Hash of Location"""
+ return hash(
+ (
+ round(v, TOL_DIGITS - 1)
+ for vector in [self.position, self.orientation]
+ for v in vector
+ )
+ )
def __neg__(self) -> Location:
"""Flip the orientation without changing the position operator -"""
@@ -2563,8 +2598,8 @@ class Plane(metaclass=PlaneMeta):
return NotImplemented
# equality tolerances
- eq_tolerance_origin = 1e-6
- eq_tolerance_dot = 1e-6
+ eq_tolerance_origin = TOLERANCE
+ eq_tolerance_dot = TOLERANCE
return (
# origins are the same
@@ -2575,6 +2610,16 @@ class Plane(metaclass=PlaneMeta):
and abs(self.x_dir.dot(other.x_dir) - 1) < eq_tolerance_dot
)
+ def __hash__(self) -> int:
+ """Hash of Plane"""
+ return hash(
+ (
+ round(v, TOL_DIGITS - 1)
+ for vector in [self.origin, self.x_dir, self.z_dir]
+ for v in vector
+ )
+ )
+
def __neg__(self) -> Plane:
"""Reverse z direction of plane operator -"""
return Plane(self.origin, self.x_dir, -self.z_dir)
diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py
index 934a193..1cf8ac1 100644
--- a/src/build123d/topology/shape_core.py
+++ b/src/build123d/topology/shape_core.py
@@ -866,7 +866,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
if self.wrapped is not None:
memo[id(self.wrapped)] = downcast(BRepBuilderAPI_Copy(self.wrapped).Shape())
for key, value in self.__dict__.items():
- if key == 'topo_parent':
+ if key == "topo_parent":
result.topo_parent = value
else:
setattr(result, key, copy.deepcopy(value, memo))
diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py
index 353fc39..05ba89a 100644
--- a/src/build123d/topology/two_d.py
+++ b/src/build123d/topology/two_d.py
@@ -563,7 +563,10 @@ class Face(Mixin2D, Shape[TopoDS_Face]):
float: The signed value; positive indicates convexity, negative indicates concavity.
Returns 0 if the geometry type is unsupported.
"""
- if self.geom_type == GeomType.CYLINDER:
+ if (
+ self.geom_type == GeomType.CYLINDER
+ and type(self.geom_adaptor()) != Geom_RectangularTrimmedSurface
+ ):
axis = self.axis_of_rotation
if axis is None:
raise ValueError("Can't find curvature of empty object")
diff --git a/tests/test_direct_api/test_axis.py b/tests/test_direct_api/test_axis.py
index ed422d6..bdc921e 100644
--- a/tests/test_direct_api/test_axis.py
+++ b/tests/test_direct_api/test_axis.py
@@ -230,6 +230,19 @@ class TestAxis(unittest.TestCase):
random_obj = object()
self.assertNotEqual(Axis.X, random_obj)
+ def test_set(self):
+ a0 = Axis((0, 1, 2), (3, 4, 5))
+ for i in range(1, 8):
+ for j in range(1, 8):
+ a1 = Axis(
+ (a0.position.X + 1.0 / (10**i), a0.position.Y, a0.position.Z),
+ (a0.direction.X + 1.0 / (10**j), a0.direction.Y, a0.direction.Z),
+ )
+ if a0 == a1:
+ self.assertEqual(len(set([a0, a1])), 1)
+ else:
+ self.assertEqual(len(set([a0, a1])), 2)
+
def test_position_property(self):
axis = Axis.X
axis.position = 1, 2, 3
diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py
index 11ca288..baf640e 100644
--- a/tests/test_direct_api/test_location.py
+++ b/tests/test_direct_api/test_location.py
@@ -266,6 +266,23 @@ class TestLocation(unittest.TestCase):
self.assertNotEqual(loc, diff_orientation)
self.assertNotEqual(loc, object())
+ def test_set(self):
+ l0 = Location((0, 1, 2), (3, 4, 5))
+ for i in range(1, 8):
+ for j in range(1, 8):
+ l1 = Location(
+ (l0.position.X + 1.0 / (10**i), l0.position.Y, l0.position.Z),
+ (
+ l0.orientation.X + 1.0 / (10**j),
+ l0.orientation.Y,
+ l0.orientation.Z,
+ ),
+ )
+ if l0 == l1:
+ self.assertEqual(len(set([l0, l1])), 1)
+ else:
+ self.assertEqual(len(set([l0, l1])), 2)
+
def test_neg(self):
loc = Location((1, 2, 3), (0, 35, 127))
n_loc = -loc
diff --git a/tests/test_direct_api/test_plane.py b/tests/test_direct_api/test_plane.py
index 3a2f899..df4f31b 100644
--- a/tests/test_direct_api/test_plane.py
+++ b/tests/test_direct_api/test_plane.py
@@ -408,6 +408,21 @@ class TestPlane(unittest.TestCase):
Plane(origin=(0, 0, 0), x_dir=(1, 0, 0), z_dir=(0, 1, 1)),
)
+ def test_set(self):
+ p0 = Plane((0, 1, 2), (3, 4, 5), (6, 7, 8))
+ for i in range(1, 8):
+ for j in range(1, 8):
+ for k in range(1, 8):
+ p1 = Plane(
+ (p0.origin.X + 1.0 / (10**i), p0.origin.Y, p0.origin.Z),
+ (p0.x_dir.X + 1.0 / (10**j), p0.x_dir.Y, p0.x_dir.Z),
+ (p0.z_dir.X + 1.0 / (10**k), p0.z_dir.Y, p0.z_dir.Z),
+ )
+ if p0 == p1:
+ self.assertEqual(len(set([p0, p1])), 1)
+ else:
+ self.assertEqual(len(set([p0, p1])), 2)
+
def test_to_location(self):
loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location
self.assertAlmostEqual(loc.position, (1, 2, 3), 5)
diff --git a/tests/test_direct_api/test_vector.py b/tests/test_direct_api/test_vector.py
index a140f01..521c47c 100644
--- a/tests/test_direct_api/test_vector.py
+++ b/tests/test_direct_api/test_vector.py
@@ -156,6 +156,16 @@ class TestVector(unittest.TestCase):
self.assertNotEqual(a, b)
self.assertNotEqual(a, object())
+ def test_vector_sets(self):
+ # Check that equal and hash work the same way to enable sets
+ a = Vector(1, 2, 3)
+ for i in range(1, 8):
+ v = Vector(a.X + 1.0 / (10**i), a.Y, a.Z)
+ if v == a:
+ self.assertEqual(len(set([a, v])), 1)
+ else:
+ self.assertEqual(len(set([a, v])), 2)
+
def test_vector_distance(self):
"""
Test line distance from plane.
From 008530646165b932364c312101b3ca2564400ea3 Mon Sep 17 00:00:00 2001
From: Ami Fischman
Date: Sat, 22 Feb 2025 22:22:54 -0800
Subject: [PATCH 216/518] TooTallToby tutorials: unbreak and test.
- Unbreak the three broken tutorials (fixes #848)
- This involved a rewrite of PPP-01-10 because I already had my own
solution to that one and I couldn't easily tell what was going
wrong with the previous solution.
- Add assertions to all the tutorials so that non-raising means success
- Add the TTT examples to `test_examples.py` added recently for #909
- Also added sympy to development dependencies since one of the TTT
examples uses it.
---
docs/assets/ttt/ttt-23-02-02-sm_hanger.py | 9 +-
docs/assets/ttt/ttt-23-t-24-curved_support.py | 10 +-
docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py | 9 +-
docs/assets/ttt/ttt-ppp0101.py | 101 ++++++++-------
docs/assets/ttt/ttt-ppp0102.py | 106 ++++++++-------
docs/assets/ttt/ttt-ppp0103.py | 74 ++++++-----
docs/assets/ttt/ttt-ppp0104.py | 121 +++++++++---------
docs/assets/ttt/ttt-ppp0105.py | 68 +++++-----
docs/assets/ttt/ttt-ppp0106.py | 110 ++++++++--------
docs/assets/ttt/ttt-ppp0107.py | 111 ++++++++--------
docs/assets/ttt/ttt-ppp0108.py | 101 ++++++++-------
docs/assets/ttt/ttt-ppp0109.py | 118 +++++++++--------
docs/assets/ttt/ttt-ppp0110.py | 111 ++++++++--------
pyproject.toml | 9 +-
tests/test_examples.py | 4 +-
15 files changed, 576 insertions(+), 486 deletions(-)
diff --git a/docs/assets/ttt/ttt-23-02-02-sm_hanger.py b/docs/assets/ttt/ttt-23-02-02-sm_hanger.py
index b523175..309f61f 100644
--- a/docs/assets/ttt/ttt-23-02-02-sm_hanger.py
+++ b/docs/assets/ttt/ttt-23-02-02-sm_hanger.py
@@ -92,6 +92,13 @@ with BuildPart() as sm_hanger:
mirror(about=Plane.YZ)
mirror(about=Plane.XZ)
-print(f"Mass: {sm_hanger.part.volume*7800*1e-6:0.1f} g")
+got_mass = sm_hanger.part.volume*7800*1e-6
+want_mass = 1028
+tolerance = 10
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.1f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
+
+assert abs(got_mass - 1028) < 10, f'{got_mass=}, want=1028, tolerance=10'
show(sm_hanger)
diff --git a/docs/assets/ttt/ttt-23-t-24-curved_support.py b/docs/assets/ttt/ttt-23-t-24-curved_support.py
index 26b469b..d54f6ee 100644
--- a/docs/assets/ttt/ttt-23-t-24-curved_support.py
+++ b/docs/assets/ttt/ttt-23-t-24-curved_support.py
@@ -27,7 +27,7 @@ equations = [
(yl8 - 50) / (55 / 2 - xl8) - tan(radians(8)), # 8 degree slope
]
# There are two solutions but we want the 2nd one
-solution = sympy.solve(equations, dict=True)[1]
+solution = {k: float(v) for k,v in sympy.solve(equations, dict=True)[1].items()}
# Create the critical points
c30 = Vector(x30, solution[y30])
@@ -58,5 +58,11 @@ with BuildPart() as curved_support:
with Locations((0, 125)):
Hole(20 / 2)
-print(curved_support.part.volume * 7800e-6)
+got_mass = curved_support.part.volume * 7800e-6
+want_mass = 1294
+delta = abs(got_mass - want_mass)
+tolerance = 3
+print(f"Mass: {got_mass:0.1f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
+
show(curved_support)
diff --git a/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py b/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py
index a54d77c..60c0fce 100644
--- a/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py
+++ b/docs/assets/ttt/ttt-24-SPO-06-Buffer_Stand.py
@@ -45,6 +45,13 @@ with BuildPart() as p:
mirror(about=Plane.YZ)
part = scale(p.part, IN)
-print(f"\npart weight = {part.volume*7800e-6/LB:0.2f} lbs")
+
+
+got_mass = part.volume*7800e-6/LB
+want_mass = 3.923
+tolerance = 0.02
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.1f} lbs")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
show(p)
diff --git a/docs/assets/ttt/ttt-ppp0101.py b/docs/assets/ttt/ttt-ppp0101.py
index 8a78180..efd0a27 100644
--- a/docs/assets/ttt/ttt-ppp0101.py
+++ b/docs/assets/ttt/ttt-ppp0101.py
@@ -1,47 +1,54 @@
-"""
-Too Tall Toby Party Pack 01-01 Bearing Bracket
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-with BuildPart() as p:
- with BuildSketch() as s:
- Rectangle(115, 50)
- with Locations((5 / 2, 0)):
- SlotOverall(90, 12, mode=Mode.SUBTRACT)
- extrude(amount=15)
-
- with BuildSketch(Plane.XZ.offset(50 / 2)) as s3:
- with Locations((-115 / 2 + 26, 15)):
- SlotOverall(42 + 2 * 26 + 12, 2 * 26, rotation=90)
- zz = extrude(amount=-12)
- split(bisect_by=Plane.XY)
- edgs = p.part.edges().filter_by(Axis.Y).group_by(Axis.X)[-2]
- fillet(edgs, 9)
-
- with Locations(zz.faces().sort_by(Axis.Y)[0]):
- with Locations((42 / 2 + 6, 0)):
- CounterBoreHole(24 / 2, 34 / 2, 4)
- mirror(about=Plane.XZ)
-
- with BuildSketch() as s4:
- RectangleRounded(115, 50, 6)
- extrude(amount=80, mode=Mode.INTERSECT)
- # fillet does not work right, mode intersect is safer
-
- with BuildSketch(Plane.YZ) as s4:
- with BuildLine() as bl:
- l1 = Line((0, 0), (18 / 2, 0))
- l2 = PolarLine(l1 @ 1, 8, 60, length_mode=LengthMode.VERTICAL)
- l3 = Line(l2 @ 1, (0, 8))
- mirror(about=Plane.YZ)
- make_face()
- extrude(amount=115/2, both=True, mode=Mode.SUBTRACT)
-
-show_object(p)
-print(f"\npart mass = {p.part.volume*densa:0.2f}")
+"""
+Too Tall Toby Party Pack 01-01 Bearing Bracket
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+with BuildPart() as p:
+ with BuildSketch() as s:
+ Rectangle(115, 50)
+ with Locations((5 / 2, 0)):
+ SlotOverall(90, 12, mode=Mode.SUBTRACT)
+ extrude(amount=15)
+
+ with BuildSketch(Plane.XZ.offset(50 / 2)) as s3:
+ with Locations((-115 / 2 + 26, 15)):
+ SlotOverall(42 + 2 * 26 + 12, 2 * 26, rotation=90)
+ zz = extrude(amount=-12)
+ split(bisect_by=Plane.XY)
+ edgs = p.part.edges().filter_by(Axis.Y).group_by(Axis.X)[-2]
+ fillet(edgs, 9)
+
+ with Locations(zz.faces().sort_by(Axis.Y)[0]):
+ with Locations((42 / 2 + 6, 0)):
+ CounterBoreHole(24 / 2, 34 / 2, 4)
+ mirror(about=Plane.XZ)
+
+ with BuildSketch() as s4:
+ RectangleRounded(115, 50, 6)
+ extrude(amount=80, mode=Mode.INTERSECT)
+ # fillet does not work right, mode intersect is safer
+
+ with BuildSketch(Plane.YZ) as s4:
+ with BuildLine() as bl:
+ l1 = Line((0, 0), (18 / 2, 0))
+ l2 = PolarLine(l1 @ 1, 8, 60, length_mode=LengthMode.VERTICAL)
+ l3 = Line(l2 @ 1, (0, 8))
+ mirror(about=Plane.YZ)
+ make_face()
+ extrude(amount=115/2, both=True, mode=Mode.SUBTRACT)
+
+show_object(p)
+
+
+got_mass = p.part.volume*densa
+want_mass = 797.15
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0102.py b/docs/assets/ttt/ttt-ppp0102.py
index df1df56..9670314 100644
--- a/docs/assets/ttt/ttt-ppp0102.py
+++ b/docs/assets/ttt/ttt-ppp0102.py
@@ -1,49 +1,57 @@
-"""
-Too Tall Toby Party Pack 01-02 Post Cap
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-
-# TTT Party Pack 01: PPP0102, mass(abs) = 43.09g
-with BuildPart() as p:
- with BuildSketch(Plane.XZ) as sk1:
- Rectangle(49, 48 - 8, align=(Align.CENTER, Align.MIN))
- Rectangle(9, 48, align=(Align.CENTER, Align.MIN))
- with Locations((9 / 2, 40)):
- Ellipse(20, 8)
- split(bisect_by=Plane.YZ)
- revolve(axis=Axis.Z)
-
- with BuildSketch(Plane.YZ.offset(-15)) as xc1:
- with Locations((0, 40 / 2 - 17)):
- Ellipse(10 / 2, 4 / 2)
- with BuildLine(Plane.XZ) as l1:
- CenterArc((-15, 40 / 2), 17, 90, 180)
- sweep(path=l1)
-
- fillet(p.edges().filter_by(GeomType.CIRCLE, reverse=True).group_by(Axis.X)[0], 1)
-
- with BuildLine(mode=Mode.PRIVATE) as lc1:
- PolarLine(
- (42 / 2, 0), 37, 94, length_mode=LengthMode.VERTICAL
- ) # construction line
-
- pts = [
- (0, 0),
- (42 / 2, 0),
- ((lc1.line @ 1).X, (lc1.line @ 1).Y),
- (0, (lc1.line @ 1).Y),
- ]
- with BuildSketch(Plane.XZ) as sk2:
- Polygon(*pts, align=None)
- fillet(sk2.vertices().group_by(Axis.X)[1], 3)
- revolve(axis=Axis.Z, mode=Mode.SUBTRACT)
-
-show(p)
-print(f"\npart mass = {p.part.volume*densa:0.2f}")
+"""
+Too Tall Toby Party Pack 01-02 Post Cap
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+
+# TTT Party Pack 01: PPP0102, mass(abs) = 43.09g
+with BuildPart() as p:
+ with BuildSketch(Plane.XZ) as sk1:
+ Rectangle(49, 48 - 8, align=(Align.CENTER, Align.MIN))
+ Rectangle(9, 48, align=(Align.CENTER, Align.MIN))
+ with Locations((9 / 2, 40)):
+ Ellipse(20, 8)
+ split(bisect_by=Plane.YZ)
+ revolve(axis=Axis.Z)
+
+ with BuildSketch(Plane.YZ.offset(-15)) as xc1:
+ with Locations((0, 40 / 2 - 17)):
+ Ellipse(10 / 2, 4 / 2)
+ with BuildLine(Plane.XZ) as l1:
+ CenterArc((-15, 40 / 2), 17, 90, 180)
+ sweep(path=l1)
+
+ fillet(p.edges().filter_by(GeomType.CIRCLE, reverse=True).group_by(Axis.X)[0], 1)
+
+ with BuildLine(mode=Mode.PRIVATE) as lc1:
+ PolarLine(
+ (42 / 2, 0), 37, 94, length_mode=LengthMode.VERTICAL
+ ) # construction line
+
+ pts = [
+ (0, 0),
+ (42 / 2, 0),
+ ((lc1.line @ 1).X, (lc1.line @ 1).Y),
+ (0, (lc1.line @ 1).Y),
+ ]
+ with BuildSketch(Plane.XZ) as sk2:
+ Polygon(*pts, align=None)
+ fillet(sk2.vertices().group_by(Axis.X)[1], 3)
+ revolve(axis=Axis.Z, mode=Mode.SUBTRACT)
+
+show(p)
+
+
+got_mass = p.part.volume*densc
+want_mass = 43.09
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
+
diff --git a/docs/assets/ttt/ttt-ppp0103.py b/docs/assets/ttt/ttt-ppp0103.py
index b021b9b..1465232 100644
--- a/docs/assets/ttt/ttt-ppp0103.py
+++ b/docs/assets/ttt/ttt-ppp0103.py
@@ -1,34 +1,40 @@
-"""
-Too Tall Toby Party Pack 01-03 C Clamp Base
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-
-with BuildPart() as ppp0103:
- with BuildSketch() as sk1:
- RectangleRounded(34 * 2, 95, 18)
- with Locations((0, -2)):
- RectangleRounded((34 - 16) * 2, 95 - 18 - 14, 7, mode=Mode.SUBTRACT)
- with Locations((-34 / 2, 0)):
- Rectangle(34, 95, 0, mode=Mode.SUBTRACT)
- extrude(amount=16)
- with BuildSketch(Plane.XZ.offset(-95 / 2)) as cyl1:
- with Locations((0, 16 / 2)):
- Circle(16 / 2)
- extrude(amount=18)
- with BuildSketch(Plane.XZ.offset(95 / 2 - 14)) as cyl2:
- with Locations((0, 16 / 2)):
- Circle(16 / 2)
- extrude(amount=23)
- with Locations(Plane.XZ.offset(95 / 2 + 9)):
- with Locations((0, 16 / 2)):
- CounterSinkHole(5.5 / 2, 11.2 / 2, None, 90)
-
-show(ppp0103)
-print(f"\npart mass = {ppp0103.part.volume*densb:0.2f}")
+"""
+Too Tall Toby Party Pack 01-03 C Clamp Base
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+
+with BuildPart() as ppp0103:
+ with BuildSketch() as sk1:
+ RectangleRounded(34 * 2, 95, 18)
+ with Locations((0, -2)):
+ RectangleRounded((34 - 16) * 2, 95 - 18 - 14, 7, mode=Mode.SUBTRACT)
+ with Locations((-34 / 2, 0)):
+ Rectangle(34, 95, 0, mode=Mode.SUBTRACT)
+ extrude(amount=16)
+ with BuildSketch(Plane.XZ.offset(-95 / 2)) as cyl1:
+ with Locations((0, 16 / 2)):
+ Circle(16 / 2)
+ extrude(amount=18)
+ with BuildSketch(Plane.XZ.offset(95 / 2 - 14)) as cyl2:
+ with Locations((0, 16 / 2)):
+ Circle(16 / 2)
+ extrude(amount=23)
+ with Locations(Plane.XZ.offset(95 / 2 + 9)):
+ with Locations((0, 16 / 2)):
+ CounterSinkHole(5.5 / 2, 11.2 / 2, None, 90)
+
+show(ppp0103)
+
+got_mass = ppp0103.part.volume*densb
+want_mass = 96.13
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0104.py b/docs/assets/ttt/ttt-ppp0104.py
index 685b483..88361fa 100644
--- a/docs/assets/ttt/ttt-ppp0104.py
+++ b/docs/assets/ttt/ttt-ppp0104.py
@@ -1,57 +1,64 @@
-"""
-Too Tall Toby Party Pack 01-04 Angle Bracket
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-d1, d2, d3 = 38, 26, 16
-h1, h2, h3, h4 = 20, 8, 7, 23
-w1, w2, w3 = 80, 10, 5
-f1, f2, f3 = 4, 10, 5
-sloth1, sloth2 = 18, 12
-slotw1, slotw2 = 17, 14
-
-with BuildPart() as p:
- with BuildSketch() as s:
- Circle(d1 / 2)
- extrude(amount=h1)
- with BuildSketch(Plane.XY.offset(h1)) as s2:
- Circle(d2 / 2)
- extrude(amount=h2)
- with BuildSketch(Plane.YZ) as s3:
- Rectangle(d1 + 15, h3, align=(Align.CENTER, Align.MIN))
- extrude(amount=w1 - d1 / 2)
- # fillet workaround \/
- ped = p.part.edges().group_by(Axis.Z)[2].filter_by(GeomType.CIRCLE)
- fillet(ped, f1)
- with BuildSketch(Plane.YZ) as s3a:
- Rectangle(d1 + 15, 15, align=(Align.CENTER, Align.MIN))
- Rectangle(d1, 15, mode=Mode.SUBTRACT, align=(Align.CENTER, Align.MIN))
- extrude(amount=w1 - d1 / 2, mode=Mode.SUBTRACT)
- # end fillet workaround /\
- with BuildSketch() as s4:
- Circle(d3 / 2)
- extrude(amount=h1 + h2, mode=Mode.SUBTRACT)
- with BuildSketch() as s5:
- with Locations((w1 - d1 / 2 - w2 / 2, 0)):
- Rectangle(w2, d1)
- extrude(amount=-h4)
- fillet(p.part.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[-1], f2)
- fillet(p.part.edges().group_by(Axis.X)[-4].sort_by(Axis.Z)[-2], f3)
- pln = Plane.YZ.offset(w1 - d1 / 2)
- with BuildSketch(pln) as s6:
- with Locations((0, -h4)):
- SlotOverall(slotw1 * 2, sloth1, 90)
- extrude(amount=-w3, mode=Mode.SUBTRACT)
- with BuildSketch(pln) as s6b:
- with Locations((0, -h4)):
- SlotOverall(slotw2 * 2, sloth2, 90)
- extrude(amount=-w2, mode=Mode.SUBTRACT)
-
-show(p)
-print(f"\npart mass = {p.part.volume*densa:0.2f}")
+"""
+Too Tall Toby Party Pack 01-04 Angle Bracket
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+d1, d2, d3 = 38, 26, 16
+h1, h2, h3, h4 = 20, 8, 7, 23
+w1, w2, w3 = 80, 10, 5
+f1, f2, f3 = 4, 10, 5
+sloth1, sloth2 = 18, 12
+slotw1, slotw2 = 17, 14
+
+with BuildPart() as p:
+ with BuildSketch() as s:
+ Circle(d1 / 2)
+ extrude(amount=h1)
+ with BuildSketch(Plane.XY.offset(h1)) as s2:
+ Circle(d2 / 2)
+ extrude(amount=h2)
+ with BuildSketch(Plane.YZ) as s3:
+ Rectangle(d1 + 15, h3, align=(Align.CENTER, Align.MIN))
+ extrude(amount=w1 - d1 / 2)
+ # fillet workaround \/
+ ped = p.part.edges().group_by(Axis.Z)[2].filter_by(GeomType.CIRCLE)
+ fillet(ped, f1)
+ with BuildSketch(Plane.YZ) as s3a:
+ Rectangle(d1 + 15, 15, align=(Align.CENTER, Align.MIN))
+ Rectangle(d1, 15, mode=Mode.SUBTRACT, align=(Align.CENTER, Align.MIN))
+ extrude(amount=w1 - d1 / 2, mode=Mode.SUBTRACT)
+ # end fillet workaround /\
+ with BuildSketch() as s4:
+ Circle(d3 / 2)
+ extrude(amount=h1 + h2, mode=Mode.SUBTRACT)
+ with BuildSketch() as s5:
+ with Locations((w1 - d1 / 2 - w2 / 2, 0)):
+ Rectangle(w2, d1)
+ extrude(amount=-h4)
+ fillet(p.part.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[-1], f2)
+ fillet(p.part.edges().group_by(Axis.X)[-4].sort_by(Axis.Z)[-2], f3)
+ pln = Plane.YZ.offset(w1 - d1 / 2)
+ with BuildSketch(pln) as s6:
+ with Locations((0, -h4)):
+ SlotOverall(slotw1 * 2, sloth1, 90)
+ extrude(amount=-w3, mode=Mode.SUBTRACT)
+ with BuildSketch(pln) as s6b:
+ with Locations((0, -h4)):
+ SlotOverall(slotw2 * 2, sloth2, 90)
+ extrude(amount=-w2, mode=Mode.SUBTRACT)
+
+show(p)
+
+
+got_mass = p.part.volume*densa
+want_mass = 310
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0105.py b/docs/assets/ttt/ttt-ppp0105.py
index bf5c020..f599736 100644
--- a/docs/assets/ttt/ttt-ppp0105.py
+++ b/docs/assets/ttt/ttt-ppp0105.py
@@ -1,30 +1,38 @@
-"""
-Too Tall Toby Party Pack 01-05 Paste Sleeve
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-with BuildPart() as p:
- with BuildSketch() as s:
- SlotOverall(45, 38)
- offset(amount=3)
- with BuildSketch(Plane.XY.offset(133 - 30)) as s2:
- SlotOverall(60, 4)
- offset(amount=3)
- loft()
-
- with BuildSketch() as s3:
- SlotOverall(45, 38)
- with BuildSketch(Plane.XY.offset(133 - 30)) as s4:
- SlotOverall(60, 4)
- loft(mode=Mode.SUBTRACT)
-
- extrude(p.part.faces().sort_by(Axis.Z)[0], amount=30)
-
-show(p)
-print(f"\npart mass = {p.part.volume*densc:0.2f}")
+"""
+Too Tall Toby Party Pack 01-05 Paste Sleeve
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+with BuildPart() as p:
+ with BuildSketch() as s:
+ SlotOverall(45, 38)
+ offset(amount=3)
+ with BuildSketch(Plane.XY.offset(133 - 30)) as s2:
+ SlotOverall(60, 4)
+ offset(amount=3)
+ loft()
+
+ with BuildSketch() as s3:
+ SlotOverall(45, 38)
+ with BuildSketch(Plane.XY.offset(133 - 30)) as s4:
+ SlotOverall(60, 4)
+ loft(mode=Mode.SUBTRACT)
+
+ extrude(p.part.faces().sort_by(Axis.Z)[0], amount=30)
+
+show(p)
+
+
+got_mass = p.part.volume*densc
+want_mass = 57.08
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
+
diff --git a/docs/assets/ttt/ttt-ppp0106.py b/docs/assets/ttt/ttt-ppp0106.py
index 38aef2a..596a47b 100644
--- a/docs/assets/ttt/ttt-ppp0106.py
+++ b/docs/assets/ttt/ttt-ppp0106.py
@@ -1,52 +1,58 @@
-"""
-Too Tall Toby Party Pack 01-06 Bearing Jig
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-r1, r2, r3, r4, r5 = 30 / 2, 13 / 2, 12 / 2, 10, 6 # radii used
-x1 = 44 # lengths used
-y1, y2, y3, y4, y_tot = 36, 36 - 22 / 2, 22 / 2, 42, 69 # widths used
-
-with BuildSketch(Location((0, -r1, y3))) as sk_body:
- with BuildLine() as l:
- c1 = Line((r1, 0), (r1, y_tot), mode=Mode.PRIVATE) # construction line
- m1 = Line((0, y_tot), (x1 / 2, y_tot))
- m2 = JernArc(m1 @ 1, m1 % 1, r4, -90 - 45)
- m3 = IntersectingLine(m2 @ 1, m2 % 1, c1)
- m4 = Line(m3 @ 1, (r1, r1))
- m5 = JernArc(m4 @ 1, m4 % 1, r1, -90)
- m6 = Line(m5 @ 1, m1 @ 0)
- mirror(make_face(l.line), Plane.YZ)
- fillet(sk_body.vertices().group_by(Axis.Y)[1], 12)
- with Locations((x1 / 2, y_tot - 10), (-x1 / 2, y_tot - 10)):
- Circle(r2, mode=Mode.SUBTRACT)
- # Keyway
- with Locations((0, r1)):
- Circle(r3, mode=Mode.SUBTRACT)
- Rectangle(4, 3 + 6, align=(Align.CENTER, Align.MIN), mode=Mode.SUBTRACT)
-
-with BuildPart() as p:
- Box(200, 200, 22) # Oversized plate
- # Cylinder underneath
- Cylinder(r1, y2, align=(Align.CENTER, Align.CENTER, Align.MAX))
- fillet(p.edges(Select.NEW), r5) # Weld together
- extrude(sk_body.sketch, amount=-y1, mode=Mode.INTERSECT) # Cut to shape
- # Remove slot
- with Locations((0, y_tot - r1 - y4, 0)):
- Box(
- y_tot,
- y_tot,
- 10,
- align=(Align.CENTER, Align.MIN, Align.CENTER),
- mode=Mode.SUBTRACT,
- )
-
-show(p)
-print(f"\npart mass = {p.part.volume*densa:0.2f}")
-print(p.part.bounding_box().size)
+"""
+Too Tall Toby Party Pack 01-06 Bearing Jig
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+r1, r2, r3, r4, r5 = 30 / 2, 13 / 2, 12 / 2, 10, 6 # radii used
+x1 = 44 # lengths used
+y1, y2, y3, y4, y_tot = 36, 36 - 22 / 2, 22 / 2, 42, 69 # widths used
+
+with BuildSketch(Location((0, -r1, y3))) as sk_body:
+ with BuildLine() as l:
+ c1 = Line((r1, 0), (r1, y_tot), mode=Mode.PRIVATE) # construction line
+ m1 = Line((0, y_tot), (x1 / 2, y_tot))
+ m2 = JernArc(m1 @ 1, m1 % 1, r4, -90 - 45)
+ m3 = IntersectingLine(m2 @ 1, m2 % 1, c1)
+ m4 = Line(m3 @ 1, (r1, r1))
+ m5 = JernArc(m4 @ 1, m4 % 1, r1, -90)
+ m6 = Line(m5 @ 1, m1 @ 0)
+ mirror(make_face(l.line), Plane.YZ)
+ fillet(sk_body.vertices().group_by(Axis.Y)[1], 12)
+ with Locations((x1 / 2, y_tot - 10), (-x1 / 2, y_tot - 10)):
+ Circle(r2, mode=Mode.SUBTRACT)
+ # Keyway
+ with Locations((0, r1)):
+ Circle(r3, mode=Mode.SUBTRACT)
+ Rectangle(4, 3 + 6, align=(Align.CENTER, Align.MIN), mode=Mode.SUBTRACT)
+
+with BuildPart() as p:
+ Box(200, 200, 22) # Oversized plate
+ # Cylinder underneath
+ Cylinder(r1, y2, align=(Align.CENTER, Align.CENTER, Align.MAX))
+ fillet(p.edges(Select.NEW), r5) # Weld together
+ extrude(sk_body.sketch, amount=-y1, mode=Mode.INTERSECT) # Cut to shape
+ # Remove slot
+ with Locations((0, y_tot - r1 - y4, 0)):
+ Box(
+ y_tot,
+ y_tot,
+ 10,
+ align=(Align.CENTER, Align.MIN, Align.CENTER),
+ mode=Mode.SUBTRACT,
+ )
+
+show(p)
+
+
+got_mass = p.part.volume*densa
+want_mass = 328.02
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0107.py b/docs/assets/ttt/ttt-ppp0107.py
index ca13804..1ddc680 100644
--- a/docs/assets/ttt/ttt-ppp0107.py
+++ b/docs/assets/ttt/ttt-ppp0107.py
@@ -1,52 +1,59 @@
-"""
-Too Tall Toby Party Pack 01-07 Flanged Hub
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-with BuildPart() as p:
- with BuildSketch() as s:
- Circle(130 / 2)
- extrude(amount=8)
- with BuildSketch(Plane.XY.offset(8)) as s2:
- Circle(84 / 2)
- extrude(amount=25 - 8)
- with BuildSketch(Plane.XY.offset(25)) as s3:
- Circle(35 / 2)
- extrude(amount=52 - 25)
- with BuildSketch() as s4:
- Circle(73 / 2)
- extrude(amount=18, mode=Mode.SUBTRACT)
- pln2 = p.part.faces().sort_by(Axis.Z)[5]
- with BuildSketch(Plane.XY.offset(52)) as s5:
- Circle(20 / 2)
- extrude(amount=-52, mode=Mode.SUBTRACT)
- fillet(
- p.part.edges()
- .filter_by(GeomType.CIRCLE)
- .sort_by(Axis.Z)[2:-2]
- .sort_by(SortBy.RADIUS)[1:],
- 3,
- )
- pln = Plane(pln2)
- pln.origin = pln.origin + Vector(20 / 2, 0, 0)
- pln = pln.rotated((0, 45, 0))
- pln = pln.offset(-25 + 3 + 0.10)
- with BuildSketch(pln) as s6:
- Rectangle((73 - 35) / 2 * 1.414 + 5, 3)
- zz = extrude(amount=15, taper=-20 / 2, mode=Mode.PRIVATE)
- zz2 = split(zz, bisect_by=Plane.XY.offset(25), mode=Mode.PRIVATE)
- zz3 = split(zz2, bisect_by=Plane.YZ.offset(35 / 2 - 1), mode=Mode.PRIVATE)
- with PolarLocations(0, 3):
- add(zz3)
- with Locations(Plane.XY.offset(8)):
- with PolarLocations(107.95 / 2, 6):
- CounterBoreHole(6 / 2, 13 / 2, 4)
-
-show(p)
-print(f"\npart mass = {p.part.volume*densb:0.2f}")
+"""
+Too Tall Toby Party Pack 01-07 Flanged Hub
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+with BuildPart() as p:
+ with BuildSketch() as s:
+ Circle(130 / 2)
+ extrude(amount=8)
+ with BuildSketch(Plane.XY.offset(8)) as s2:
+ Circle(84 / 2)
+ extrude(amount=25 - 8)
+ with BuildSketch(Plane.XY.offset(25)) as s3:
+ Circle(35 / 2)
+ extrude(amount=52 - 25)
+ with BuildSketch() as s4:
+ Circle(73 / 2)
+ extrude(amount=18, mode=Mode.SUBTRACT)
+ pln2 = p.part.faces().sort_by(Axis.Z)[5]
+ with BuildSketch(Plane.XY.offset(52)) as s5:
+ Circle(20 / 2)
+ extrude(amount=-52, mode=Mode.SUBTRACT)
+ fillet(
+ p.part.edges()
+ .filter_by(GeomType.CIRCLE)
+ .sort_by(Axis.Z)[2:-2]
+ .sort_by(SortBy.RADIUS)[1:],
+ 3,
+ )
+ pln = Plane(pln2)
+ pln.origin = pln.origin + Vector(20 / 2, 0, 0)
+ pln = pln.rotated((0, 45, 0))
+ pln = pln.offset(-25 + 3 + 0.10)
+ with BuildSketch(pln) as s6:
+ Rectangle((73 - 35) / 2 * 1.414 + 5, 3)
+ zz = extrude(amount=15, taper=-20 / 2, mode=Mode.PRIVATE)
+ zz2 = split(zz, bisect_by=Plane.XY.offset(25), mode=Mode.PRIVATE)
+ zz3 = split(zz2, bisect_by=Plane.YZ.offset(35 / 2 - 1), mode=Mode.PRIVATE)
+ with PolarLocations(0, 3):
+ add(zz3)
+ with Locations(Plane.XY.offset(8)):
+ with PolarLocations(107.95 / 2, 6):
+ CounterBoreHole(6 / 2, 13 / 2, 4)
+
+show(p)
+
+
+got_mass = p.part.volume*densb
+want_mass = 372.99
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0108.py b/docs/assets/ttt/ttt-ppp0108.py
index e79d1f9..60280c5 100644
--- a/docs/assets/ttt/ttt-ppp0108.py
+++ b/docs/assets/ttt/ttt-ppp0108.py
@@ -1,47 +1,54 @@
-"""
-Too Tall Toby Party Pack 01-08 Tie Plate
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-with BuildPart() as p:
- with BuildSketch() as s1:
- Rectangle(188 / 2 - 33, 162, align=(Align.MIN, Align.CENTER))
- with Locations((188 / 2 - 33, 0)):
- SlotOverall(190, 33 * 2, rotation=90)
- mirror(about=Plane.YZ)
- with GridLocations(188 - 2 * 33, 190 - 2 * 33, 2, 2):
- Circle(29 / 2, mode=Mode.SUBTRACT)
- Circle(84 / 2, mode=Mode.SUBTRACT)
- extrude(amount=16)
-
- with BuildPart() as p2:
- with BuildSketch(Plane.XZ) as s2:
- with BuildLine() as l1:
- l1 = Polyline(
- (222 / 2 + 14 - 40 - 40, 0),
- (222 / 2 + 14 - 40, -35 + 16),
- (222 / 2 + 14, -35 + 16),
- (222 / 2 + 14, -35 + 16 + 30),
- (222 / 2 + 14 - 40 - 40, -35 + 16 + 30),
- close=True,
- )
- make_face()
- with Locations((222 / 2, -35 + 16 + 14)):
- Circle(11 / 2, mode=Mode.SUBTRACT)
- extrude(amount=20 / 2, both=True)
- with BuildSketch() as s3:
- with Locations(l1 @ 0):
- Rectangle(40 + 40, 8, align=(Align.MIN, Align.CENTER))
- with Locations((40, 0)):
- Rectangle(40, 20, align=(Align.MIN, Align.CENTER))
- extrude(amount=30, both=True, mode=Mode.INTERSECT)
- mirror(about=Plane.YZ)
-
-show(p)
-print(f"\npart mass = {p.part.volume*densa:0.2f}")
+"""
+Too Tall Toby Party Pack 01-08 Tie Plate
+"""
+
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+with BuildPart() as p:
+ with BuildSketch() as s1:
+ Rectangle(188 / 2 - 33, 162, align=(Align.MIN, Align.CENTER))
+ with Locations((188 / 2 - 33, 0)):
+ SlotOverall(190, 33 * 2, rotation=90)
+ mirror(about=Plane.YZ)
+ with GridLocations(188 - 2 * 33, 190 - 2 * 33, 2, 2):
+ Circle(29 / 2, mode=Mode.SUBTRACT)
+ Circle(84 / 2, mode=Mode.SUBTRACT)
+ extrude(amount=16)
+
+ with BuildPart() as p2:
+ with BuildSketch(Plane.XZ) as s2:
+ with BuildLine() as l1:
+ l1 = Polyline(
+ (222 / 2 + 14 - 40 - 40, 0),
+ (222 / 2 + 14 - 40, -35 + 16),
+ (222 / 2 + 14, -35 + 16),
+ (222 / 2 + 14, -35 + 16 + 30),
+ (222 / 2 + 14 - 40 - 40, -35 + 16 + 30),
+ close=True,
+ )
+ make_face()
+ with Locations((222 / 2, -35 + 16 + 14)):
+ Circle(11 / 2, mode=Mode.SUBTRACT)
+ extrude(amount=20 / 2, both=True)
+ with BuildSketch() as s3:
+ with Locations(l1 @ 0):
+ Rectangle(40 + 40, 8, align=(Align.MIN, Align.CENTER))
+ with Locations((40, 0)):
+ Rectangle(40, 20, align=(Align.MIN, Align.CENTER))
+ extrude(amount=30, both=True, mode=Mode.INTERSECT)
+ mirror(about=Plane.YZ)
+
+show(p)
+
+
+got_mass = p.part.volume*densa
+want_mass = 3387.06
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0109.py b/docs/assets/ttt/ttt-ppp0109.py
index 3426846..b00b0bc 100644
--- a/docs/assets/ttt/ttt-ppp0109.py
+++ b/docs/assets/ttt/ttt-ppp0109.py
@@ -1,56 +1,62 @@
-"""
-Too Tall Toby Party Pack 01-09 Corner Tie
-"""
-
-from math import sqrt
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-with BuildPart() as ppp109:
- with BuildSketch() as one:
- Rectangle(69, 75, align=(Align.MAX, Align.CENTER))
- fillet(one.vertices().group_by(Axis.X)[0], 17)
- extrude(amount=13)
- centers = [
- arc.arc_center
- for arc in ppp109.edges().filter_by(GeomType.CIRCLE).group_by(Axis.Z)[-1]
- ]
- with Locations(*centers):
- CounterBoreHole(radius=8 / 2, counter_bore_radius=15 / 2, counter_bore_depth=4)
-
- with BuildSketch(Plane.YZ) as two:
- with Locations((0, 45)):
- Circle(15)
- with BuildLine() as bl:
- c = Line((75 / 2, 0), (75 / 2, 60), mode=Mode.PRIVATE)
- u = two.edge().find_tangent(75 / 2 + 90)[0] # where is the slope 75/2?
- l1 = IntersectingLine(
- two.edge().position_at(u), -two.edge().tangent_at(u), other=c
- )
- Line(l1 @ 0, (0, 45))
- Polyline((0, 0), c @ 0, l1 @ 1)
- mirror(about=Plane.YZ)
- make_face()
- with Locations((0, 45)):
- Circle(12 / 2, mode=Mode.SUBTRACT)
- extrude(amount=-13)
-
- with BuildSketch(Plane((0, 0, 0), x_dir=(1, 0, 0), z_dir=(1, 0, 1))) as three:
- Rectangle(45 * 2 / sqrt(2) - 37.5, 75, align=(Align.MIN, Align.CENTER))
- with Locations(three.edges().sort_by(Axis.X)[-1].center()):
- Circle(37.5)
- Circle(33 / 2, mode=Mode.SUBTRACT)
- split(bisect_by=Plane.YZ)
- extrude(amount=6)
- f = ppp109.faces().filter_by(Axis((0, 0, 0), (-1, 0, 1)))[0]
- # extrude(f, until=Until.NEXT) # throws a warning
- extrude(f, amount=10)
- fillet(ppp109.edge(Select.NEW), 16)
-
-
-show(ppp109)
-print(f"\npart mass = {ppp109.part.volume*densb:0.2f}")
+"""
+Too Tall Toby Party Pack 01-09 Corner Tie
+"""
+
+from math import sqrt
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+with BuildPart() as ppp109:
+ with BuildSketch() as one:
+ Rectangle(69, 75, align=(Align.MAX, Align.CENTER))
+ fillet(one.vertices().group_by(Axis.X)[0], 17)
+ extrude(amount=13)
+ centers = [
+ arc.arc_center
+ for arc in ppp109.edges().filter_by(GeomType.CIRCLE).group_by(Axis.Z)[-1]
+ ]
+ with Locations(*centers):
+ CounterBoreHole(radius=8 / 2, counter_bore_radius=15 / 2, counter_bore_depth=4)
+
+ with BuildSketch(Plane.YZ) as two:
+ with Locations((0, 45)):
+ Circle(15)
+ with BuildLine() as bl:
+ c = Line((75 / 2, 0), (75 / 2, 60), mode=Mode.PRIVATE)
+ u = two.edge().find_tangent(75 / 2 + 90)[0] # where is the slope 75/2?
+ l1 = IntersectingLine(
+ two.edge().position_at(u), -two.edge().tangent_at(u), other=c
+ )
+ Line(l1 @ 0, (0, 45))
+ Polyline((0, 0), c @ 0, l1 @ 1)
+ mirror(about=Plane.YZ)
+ make_face()
+ with Locations((0, 45)):
+ Circle(12 / 2, mode=Mode.SUBTRACT)
+ extrude(amount=-13)
+
+ with BuildSketch(Plane((0, 0, 0), x_dir=(1, 0, 0), z_dir=(1, 0, 1))) as three:
+ Rectangle(45 * 2 / sqrt(2) - 37.5, 75, align=(Align.MIN, Align.CENTER))
+ with Locations(three.edges().sort_by(Axis.X)[-1].center()):
+ Circle(37.5)
+ Circle(33 / 2, mode=Mode.SUBTRACT)
+ split(bisect_by=Plane.YZ)
+ extrude(amount=6)
+ f = ppp109.faces().filter_by(Axis((0, 0, 0), (-1, 0, 1)))[0]
+ # extrude(f, until=Until.NEXT) # throws a warning
+ extrude(f, amount=10)
+ fillet(ppp109.edge(Select.NEW), 16)
+
+
+show(ppp109)
+
+got_mass = ppp109.part.volume*densb
+want_mass = 307.23
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.2f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
diff --git a/docs/assets/ttt/ttt-ppp0110.py b/docs/assets/ttt/ttt-ppp0110.py
index f62c56f..9b3458f 100644
--- a/docs/assets/ttt/ttt-ppp0110.py
+++ b/docs/assets/ttt/ttt-ppp0110.py
@@ -1,52 +1,59 @@
-"""
-Too Tall Toby Party Pack 01-10 Light Cap
-"""
-
-from build123d import *
-from ocp_vscode import *
-
-densa = 7800 / 1e6 # carbon steel density g/mm^3
-densb = 2700 / 1e6 # aluminum alloy
-densc = 1020 / 1e6 # ABS
-
-with BuildPart() as p:
- with BuildSketch(Plane.YZ.rotated((90, 0, 0))) as s:
- with BuildLine() as l:
- n2 = JernArc((0, 46), (1, 0), 40, -90)
- n3 = Line(n2 @ 1, n2 @ 0)
- make_face()
-
- with BuildLine() as l2:
- m1 = Line((0, 0), (42, 0))
- m2 = Line((0, 0.01), (42, 0.01))
- m3 = Line(m1 @ 0, m2 @ 0)
- m4 = Line(m1 @ 1, m2 @ 1)
- make_face()
- make_hull()
- extrude(amount=100 / 2)
- revolve(s.sketch, axis=Axis.Y.reverse(), revolution_arc=-90)
- mirror(about=Plane(p.part.faces().sort_by(Axis.X)[-1]))
- mirror(about=Plane.XY)
-
-with BuildPart() as p2:
- add(p.part)
- offset(amount=-8)
-
-with BuildPart() as pzzz:
- add(p2.part)
- split(bisect_by=Plane.XZ.offset(46 - 16), keep=Keep.BOTTOM)
- fillet(pzzz.part.faces().filter_by(Axis.Y).sort_by(Axis.Y)[0].edges(), 12)
-
-with BuildPart() as p3:
- with BuildSketch(Plane.XZ) as s2:
- add(p.part.faces().sort_by(Axis.Y)[-1])
- offset(amount=-8)
- loft([p2.part.faces().sort_by(Axis.Y)[-5], s2.sketch.faces()[0]])
-
-with BuildPart() as ppp0110:
- add(p.part)
- add(pzzz.part, mode=Mode.SUBTRACT)
- add(p3.part, mode=Mode.SUBTRACT)
-
-show(ppp0110)
-print(f"\npart mass = {ppp0110.part.volume*densc:0.2f}")
+"""
+Too Tall Toby Party Pack 01-10 Light Cap
+"""
+
+from math import sqrt, asin, pi
+from build123d import *
+from ocp_vscode import *
+
+densa = 7800 / 1e6 # carbon steel density g/mm^3
+densb = 2700 / 1e6 # aluminum alloy
+densc = 1020 / 1e6 # ABS
+
+# The smaller cross-section is defined as having R40, height 46,
+# and base width 84, so clearly it's not entirely a half-circle or
+# similar; the base's extreme points need to connect via tangents
+# to the R40 arc centered 6mm above the baseline.
+#
+# Compute the angle of the tangent line (working with the
+# left/negativeX side, given symmetry) by observing the tangent
+# point (T), the circle's center (O), and the baseline's edge (P)
+# form a right triangle, so:
+
+OT=40
+OP=sqrt((-84/2)**2+(-6)**2)
+TP=sqrt(OP**2-40**2)
+OPT_degrees = asin(OT/OP) * 180/pi
+# Correct for the fact that OP isn't horizontal.
+OP_to_X_axis_degrees = asin(6/OP) * 180/pi
+left_tangent_degrees = OPT_degrees + OP_to_X_axis_degrees
+left_tangent_length = TP
+with BuildPart() as outer:
+ with BuildSketch(Plane.XZ) as sk:
+ with BuildLine():
+ l1 = PolarLine(start=(-84/2, 0), length=left_tangent_length, angle=left_tangent_degrees)
+ l2 = TangentArc(l1@1, (0, 46), tangent=l1%1)
+ l3 = offset(amount=-8, side=Side.RIGHT, closed=False, mode=Mode.ADD)
+ l4 = Line(l1@0, l3@1)
+ l5 = Line(l3@0, l2@1)
+ l6 = Line(l3@0, (0, 46-16))
+ l7 = IntersectingLine(start=l6@1, direction=(-1,0), other=l3)
+ make_face()
+ revolve(axis=Axis.Z)
+sk = sk.sketch & Plane.XZ*Rectangle(1000, 1000, align=[Align.CENTER, Align.MIN])
+positive_Z = Box(100, 100, 100, align=[Align.CENTER, Align.MIN, Align.MIN])
+p = outer.part & positive_Z
+cross_section = sk + mirror(sk, about=Plane.YZ)
+p += extrude(cross_section, amount=50)
+p += mirror(p, about=Plane.XZ.offset(50))
+p += fillet(p.edges().filter_by(GeomType.LINE).filter_by(Axis.Y).group_by(Axis.Z)[-1], radius=8)
+ppp0110 = p
+
+got_mass = ppp0110.volume*densc
+want_mass = 211.30
+tolerance = 1
+delta = abs(got_mass - want_mass)
+print(f"Mass: {got_mass:0.1f} g")
+assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}'
+
+show(ppp0110)
diff --git a/pyproject.toml b/pyproject.toml
index 0c93d5d..7dac117 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -61,13 +61,14 @@ ocp_vscode = [
# development dependencies
development = [
- "wheel",
+ "black",
+ "mypy",
+ "pylint",
"pytest",
"pytest-cov",
"pytest-xdist",
- "pylint",
- "mypy",
- "black",
+ "sympy",
+ "wheel",
]
# typing stubs for the OCP CAD kernel
diff --git a/tests/test_examples.py b/tests/test_examples.py
index e342f0d..5db4bf9 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -18,6 +18,7 @@ import unittest
_examples_dir = Path(os.path.abspath(os.path.dirname(__file__))).parent / "examples"
+_ttt_dir = Path(os.path.abspath(os.path.dirname(__file__))).parent / "docs/assets/ttt"
_MOCK_OCP_VSCODE_CONTENTS = """
from pathlib import Path
@@ -72,8 +73,7 @@ def generate_example_test(path: Path):
class TestExamples(unittest.TestCase):
"""Tests build123d examples."""
-
-for example in sorted(_examples_dir.iterdir()):
+for example in sorted(list(_examples_dir.iterdir()) + list(_ttt_dir.iterdir())):
if example.name.startswith("_") or not example.name.endswith(".py"):
continue
setattr(
From 5a79f264f331f584b0035b97db649e5bf4a4fa6d Mon Sep 17 00:00:00 2001
From: Ami Fischman