Common unittests, removed VertexList

This commit is contained in:
Roger Maitland 2022-07-27 14:25:57 -04:00
parent 81214ee86d
commit f82b94121b
8 changed files with 390 additions and 103 deletions

View file

@ -122,3 +122,4 @@ a fully custom selection:
One can sort by all of the following attributes:
.. autoclass:: build_common.SortBy
:noindex:

View file

@ -168,6 +168,57 @@ extrudes these pending faces into `Solid` objects. Likewise, `Loft` will take al
Normally the user will not need to interact directly with pending objects.
***********
Shape Lists
***********
The builders include methods to extract Edges, Faces, Solids, or Vertices from the objects
they are building. These methods are as follows:
+-------------+---------+---------+----------+------------+
| Size | Edges | Faces | Solids | Vertices |
+=============+=========+=========+==========+============+
| BuildLine | edges() | | | vertices() |
+-------------+---------+---------+----------+------------+
| BuildSketch | edges() | faces() | | vertices() |
+-------------+---------+---------+----------+------------+
| BuildPart | edges() | edges() | solids() | vertices() |
+-------------+---------+---------+----------+------------+
All of these methods return objects in a subclass of `list`, a `ShapeList` with some custom
filtering and sorting methods predefined described as follows.
.. autoclass:: build_common.ShapeList
:members:
:exclude-members: axis_map
The filter and sort parameters use the following Enums (numeric values are meaningless):
.. autoclass:: build_common.Axis
:members:
.. autoclass:: build_common.SortBy
:members:
.. autoclass:: build_common.Type
:members:
It is important to note that standard list methods such as `sorted` or `filtered` can
be used to easily build complex selectors beyond what is available with the predefined
sorts and filters. Here is an example of a custom filters:
.. code-block:: python
with BuildSketch() as din:
...
outside_vertices = filter(
lambda v: (v.Y == 0.0 or v.Y == height)
and -overall_width / 2 < v.X < overall_width / 2,
din.vertices(),
)
*************************************
Multiple Work Planes - BuildPart Only
*************************************

View file

@ -19,7 +19,6 @@ __all__ = [
"Type",
"Rotation",
"ShapeList",
"VertexList",
"Builder",
"Add",
"BoundingBox",

View file

@ -57,23 +57,29 @@ Edge.__mod__ = __mod__custom
Wire.__matmul__ = __matmul__custom
Wire.__mod__ = __mod__custom
context_stack = []
# context_stack = []
#
# ENUMs
#
class Select(Enum):
"""Selector scope - all or last operation"""
ALL = auto()
LAST = auto()
class Kind(Enum):
"""Offset corner transition"""
ARC = auto()
INTERSECTION = auto()
TANGENT = auto()
class Keep(Enum):
"""Split options"""
TOP = auto()
BOTTOM = auto()
BOTH = auto()
@ -90,6 +96,8 @@ class Mode(Enum):
class Transition(Enum):
"""Sweep discontinuity handling option"""
RIGHT = auto()
ROUND = auto()
TRANSFORMED = auto()
@ -104,7 +112,7 @@ class FontStyle(Enum):
class Halign(Enum):
"""Horizontal Alignment"""
"""Text Horizontal Alignment"""
CENTER = auto()
LEFT = auto()
@ -112,7 +120,7 @@ class Halign(Enum):
class Valign(Enum):
"""Vertical Alignment"""
"""Text Vertical Alignment"""
CENTER = auto()
TOP = auto()
@ -120,17 +128,23 @@ class Valign(Enum):
class Until(Enum):
"""Extude limit"""
NEXT = auto()
LAST = auto()
class Axis(Enum):
"""One of the three dimensions"""
X = auto()
Y = auto()
Z = auto()
class SortBy(Enum):
"""Sorting criteria"""
X = auto()
Y = auto()
Z = auto()
@ -142,6 +156,8 @@ class SortBy(Enum):
class Type(Enum):
"""CAD object type"""
PLANE = auto()
CYLINDER = auto()
CONE = auto()
@ -164,6 +180,8 @@ class Type(Enum):
# DirectAPI Classes
#
class Rotation(Location):
"""Subclass of Location used only for object rotation"""
def __init__(self, about_x: float = 0, about_y: float = 0, about_z: float = 0):
self.about_x = about_x
self.about_y = about_y
@ -179,10 +197,13 @@ class Rotation(Location):
super().__init__(Location(rx * ry * rz).wrapped)
#:TypeVar("RotationLike"): Three tuple of angles about x, y, z or Rotation
RotationLike = Union[tuple[float, float, float], Rotation]
class ShapeList(list):
"""Subclass of list with custom filter and sort methods appropriate to CAD"""
axis_map = {
Axis.X: ((1, 0, 0), (-1, 0, 0)),
Axis.Y: ((0, 1, 0), (0, -1, 0)),
@ -190,9 +211,21 @@ class ShapeList(list):
}
def __init_subclass__(cls) -> None:
return super().__init_subclass__()
super().__init_subclass__()
def filter_by_axis(self, axis: Axis, tolerance=1e-5):
"""filter by axis
Filter objects of type planar Face or linear Edge by their normal or tangent
(respectively) and sort the results by the given axis.
Args:
axis (Axis): axis to filter and sort by
tolerance (_type_, optional): maximum deviation from axis. Defaults to 1e-5.
Returns:
ShapeList: sublist of Faces or Edges
"""
planar_faces = filter(
lambda o: isinstance(o, Face) and o.geomType() == "PLANE", self
@ -218,12 +251,10 @@ class ShapeList(list):
list(
filter(
lambda o: (
o.tangentAt(None) - Vector(*ShapeList.axis_map[axis][0])
o.tangentAt(0) - Vector(*ShapeList.axis_map[axis][0])
).Length
<= tolerance
or (
o.tangentAt(o.Center()) - Vector(*ShapeList.axis_map[axis][1])
).Length
or (o.tangentAt(0) - Vector(*ShapeList.axis_map[axis][1])).Length
<= tolerance,
linear_edges,
)
@ -245,6 +276,20 @@ class ShapeList(list):
max: float,
inclusive: tuple[bool, bool] = (True, True),
):
"""filter by position
Filter and sort objects by the position of their centers along given axis.
min and max values can be inclusive or exclusive depending on the inclusive tuple.
Args:
axis (Axis): axis to sort by
min (float): minimum value
max (float): maximum value
inclusive (tuple[bool, bool], optional): include min,max values. Defaults to (True, True).
Returns:
ShapeList: filtered object list
"""
if axis == Axis.X:
if inclusive == (True, True):
result = filter(lambda o: min <= o.Center().x <= max, self)
@ -254,6 +299,7 @@ class ShapeList(list):
result = filter(lambda o: min < o.Center().x <= max, self)
elif inclusive == (False, False):
result = filter(lambda o: min < o.Center().x < max, self)
result = sorted(result, key=lambda obj: obj.Center().x)
elif axis == Axis.Y:
if inclusive == (True, True):
result = filter(lambda o: min <= o.Center().y <= max, self)
@ -263,6 +309,7 @@ class ShapeList(list):
result = filter(lambda o: min < o.Center().y <= max, self)
elif inclusive == (False, False):
result = filter(lambda o: min < o.Center().y < max, self)
result = sorted(result, key=lambda obj: obj.Center().y)
elif axis == Axis.Z:
if inclusive == (True, True):
result = filter(lambda o: min <= o.Center().z <= max, self)
@ -272,6 +319,7 @@ class ShapeList(list):
result = filter(lambda o: min < o.Center().z <= max, self)
elif inclusive == (False, False):
result = filter(lambda o: min < o.Center().z < max, self)
result = sorted(result, key=lambda obj: obj.Center().z)
return ShapeList(result)
@ -279,135 +327,83 @@ class ShapeList(list):
self,
type: Type,
):
"""filter by type
Filter the objects by the provided type. Note that not all types apply to all
objects.
Args:
type (Type): type to sort by
Returns:
ShapeList: filtered list of objects
"""
result = filter(lambda o: o.geomType() == type.name, self)
return ShapeList(result)
def sort_by(self, sort_by: SortBy = SortBy.Z, reverse: bool = False):
"""sort by
Sort objects by provided criteria. Note that not all sort_by criteria apply to all
objects.
Args:
sort_by (SortBy, optional): sort criteria. Defaults to SortBy.Z.
reverse (bool, optional): flip order of sort. Defaults to False.
Returns:
ShapeList: sorted list of objects
"""
if sort_by == SortBy.X:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Center().x,
reverse=reverse,
)
elif sort_by == SortBy.Y:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Center().y,
reverse=reverse,
)
elif sort_by == SortBy.Z:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Center().z,
reverse=reverse,
)
elif sort_by == SortBy.LENGTH:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Length(),
reverse=reverse,
)
elif sort_by == SortBy.RADIUS:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.radius(),
reverse=reverse,
)
elif sort_by == SortBy.DISTANCE:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Center().Length,
reverse=reverse,
)
elif sort_by == SortBy.AREA:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Area(),
reverse=reverse,
)
elif sort_by == SortBy.VOLUME:
obj = sorted(
objects = sorted(
self,
key=lambda obj: obj.Volume(),
reverse=reverse,
)
else:
raise ValueError(f"Unable to sort shapes by {sort_by}")
return ShapeList(obj)
class VertexList(list):
def __init_subclass__(cls) -> None:
return super().__init_subclass__()
def filter_by_position(
self,
axis: Axis,
min: float,
max: float,
inclusive: tuple[bool, bool] = (True, True),
):
if axis == Axis.X:
if inclusive == (True, True):
result = filter(lambda v: min <= v.X <= max, self)
elif inclusive == (True, False):
result = filter(lambda v: min <= v.X < max, self)
elif inclusive == (False, True):
result = filter(lambda v: min < v.X <= max, self)
elif inclusive == (False, False):
result = filter(lambda v: min < v.X < max, self)
elif axis == Axis.Y:
if inclusive == (True, True):
result = filter(lambda v: min <= v.Y <= max, self)
elif inclusive == (True, False):
result = filter(lambda v: min <= v.Y < max, self)
elif inclusive == (False, True):
result = filter(lambda v: min < v.Y <= max, self)
elif inclusive == (False, False):
result = filter(lambda v: min < v.Y < max, self)
elif axis == Axis.Z:
if inclusive == (True, True):
result = filter(lambda v: min <= v.Z <= max, self)
elif inclusive == (True, False):
result = filter(lambda v: min <= v.Z < max, self)
elif inclusive == (False, True):
result = filter(lambda v: min < v.Z <= max, self)
elif inclusive == (False, False):
result = filter(lambda v: min < v.Z < max, self)
return VertexList(result)
def sort_by(self, sort_by: SortBy = SortBy.Z, reverse: bool = False):
if sort_by == SortBy.X:
vertices = sorted(
self,
key=lambda obj: obj.X,
reverse=reverse,
)
elif sort_by == SortBy.Y:
vertices = sorted(
self,
key=lambda obj: obj.Y,
reverse=reverse,
)
elif sort_by == SortBy.Z:
vertices = sorted(
self,
key=lambda obj: obj.Z,
reverse=reverse,
)
elif sort_by == SortBy.DISTANCE:
vertices = sorted(
self,
key=lambda obj: obj.toVector().Length,
reverse=reverse,
)
else:
raise ValueError(f"Unable to sort vertices by {sort_by}")
return VertexList(vertices)
return ShapeList(objects)
class Builder(ABC):

View file

@ -74,7 +74,7 @@ class BuildLine(Builder):
self.line = []
super().__init__(mode)
def vertices(self, select: Select = Select.ALL) -> VertexList[Vertex]:
def vertices(self, select: Select = Select.ALL) -> ShapeList[Vertex]:
"""Return Vertices from Line
Return either all or the vertices created during the last operation.
@ -91,7 +91,7 @@ class BuildLine(Builder):
vertex_list.extend(edge.Vertices())
elif select == Select.LAST:
vertex_list = self.last_vertices
return VertexList(set(vertex_list))
return ShapeList(set(vertex_list))
def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]:
"""Return Edges from Line

View file

@ -111,7 +111,7 @@ class BuildPart(Builder):
self.last_solids = []
super().__init__(mode)
def vertices(self, select: Select = Select.ALL) -> VertexList[Vertex]:
def vertices(self, select: Select = Select.ALL) -> ShapeList[Vertex]:
"""Return Vertices from Part
Return either all or the vertices created during the last operation.
@ -128,7 +128,7 @@ class BuildPart(Builder):
vertex_list.extend(edge.Vertices())
elif select == Select.LAST:
vertex_list = self.last_vertices
return VertexList(set(vertex_list))
return ShapeList(set(vertex_list))
def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]:
"""Return Edges from Part

View file

@ -77,13 +77,13 @@ class BuildSketch(Builder):
return self.sketch
def __init__(self, mode: Mode = Mode.ADD):
self.sketch = None
self.sketch: Compound = None
self.pending_edges: list[Edge] = []
self.locations: list[Location] = [Location(Vector())]
self.last_faces = []
super().__init__(mode)
def vertices(self, select: Select = Select.ALL) -> VertexList[Vertex]:
def vertices(self, select: Select = Select.ALL) -> ShapeList[Vertex]:
"""Return Vertices from Sketch
Return either all or the vertices created during the last operation.
@ -100,7 +100,7 @@ class BuildSketch(Builder):
vertex_list.extend(edge.Vertices())
elif select == Select.LAST:
vertex_list = self.last_vertices
return VertexList(set(vertex_list))
return ShapeList(set(vertex_list))
def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]:
"""Return Edges from Sketch
@ -331,7 +331,6 @@ class FilletSketch(Compound):
super().__init__(new_sketch.wrapped)
class Offset(Compound):
"""Sketch Operation: Offset
@ -375,13 +374,11 @@ class Offset(Compound):
super().__init__(Compound.makeCompound(new_faces).wrapped)
#
# Objects
#
class Circle(Compound):
"""Sketch Object: Circle

243
tests/build_common_tests.py Normal file
View file

@ -0,0 +1,243 @@
"""
build123d common tests
name: build_common_tests.py
by: Gumyr
date: July 25th 2022
desc: Unit tests for the build123d common module
license:
Copyright 2022 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 cadquery import Edge, Wire, Solid
from build123d import *
def _assertTupleAlmostEquals(self, expected, actual, places, msg=None):
"""Check Tuples"""
for i, j in zip(actual, expected):
self.assertAlmostEqual(i, j, places, msg=msg)
unittest.TestCase.assertTupleAlmostEquals = _assertTupleAlmostEquals
class TestCommonOperations(unittest.TestCase):
"""Test custom operators"""
def test_matmul(self):
self.assertTupleAlmostEquals(
(Edge.makeLine((0, 0, 0), (1, 1, 1)) @ 0.5).toTuple(), (0.5, 0.5, 0.5), 5
)
def test_mod(self):
self.assertTupleAlmostEquals(
(Wire.makeCircle(10, (0, 0, 0), (0, 0, 1)) % 0.5).toTuple(), (0, -1, 0), 5
)
class TestRotation(unittest.TestCase):
"""Test the Rotation derived class of Location"""
def test_init(self):
thirty_by_three = Rotation(30, 30, 30)
box_vertices = Solid.makeBox(1, 1, 1).moved(thirty_by_three).Vertices()
self.assertTupleAlmostEquals(
box_vertices[0].toTuple(), (0.5, -0.4330127, 0.75), 5
)
self.assertTupleAlmostEquals(box_vertices[1].toTuple(), (0.0, 0.0, 0.0), 7)
self.assertTupleAlmostEquals(
box_vertices[2].toTuple(), (0.0669872, 0.191987, 1.399519), 5
)
self.assertTupleAlmostEquals(
box_vertices[3].toTuple(), (-0.4330127, 0.625, 0.6495190), 5
)
self.assertTupleAlmostEquals(
box_vertices[4].toTuple(), (1.25, 0.2165063, 0.625), 5
)
self.assertTupleAlmostEquals(
box_vertices[5].toTuple(), (0.75, 0.649519, -0.125), 5
)
self.assertTupleAlmostEquals(
box_vertices[6].toTuple(), (0.816987, 0.841506, 1.274519), 5
)
self.assertTupleAlmostEquals(
box_vertices[7].toTuple(), (0.3169872, 1.2745190, 0.52451905), 5
)
class TestShapeList(unittest.TestCase):
"""Test the ShapeList derived class"""
def test_filter_by_axis(self):
"""test the filter and sorting of Faces and Edges by axis"""
with BuildPart() as test:
Box(1, 1, 1)
for axis in [Axis.X, Axis.Y, Axis.Z]:
with self.subTest(axis=axis):
faces = test.faces().filter_by_axis(axis)
edges = test.edges().filter_by_axis(axis)
self.assertTrue(isinstance(faces, list))
self.assertTrue(isinstance(faces, ShapeList))
self.assertEqual(len(faces), 2)
self.assertTrue(isinstance(edges, list))
self.assertTrue(isinstance(edges, ShapeList))
self.assertEqual(len(edges), 4)
if axis == Axis.X:
self.assertLessEqual(faces[0].Center().x, faces[1].Center().x)
self.assertLessEqual(edges[0].Center().x, edges[-1].Center().x)
elif axis == Axis.Y:
self.assertLessEqual(faces[0].Center().y, faces[1].Center().y)
self.assertLessEqual(edges[0].Center().y, edges[-1].Center().y)
elif axis == Axis.Z:
self.assertLessEqual(faces[0].Center().z, faces[1].Center().z)
self.assertLessEqual(edges[0].Center().z, edges[-1].Center().z)
def test_filter_by_position(self):
"""test the filter and sorting of Faces and Edges by position"""
with BuildPart() as test:
Box(2, 2, 2)
for axis in [Axis.X, Axis.Y, Axis.Z]:
for inclusive in [
(True, True),
(True, False),
(False, True),
(False, False),
]:
with self.subTest(axis=axis, inclusive=inclusive):
faces = test.faces().filter_by_position(axis, -1, 1, inclusive)
edges = test.edges().filter_by_position(axis, -1, 1, inclusive)
self.assertTrue(isinstance(faces, list))
self.assertTrue(isinstance(faces, ShapeList))
self.assertEqual(len(faces), sum(inclusive) + 4)
self.assertTrue(isinstance(edges, list))
self.assertTrue(isinstance(edges, ShapeList))
self.assertEqual(len(edges), 4 * sum(inclusive) + 4)
if axis == Axis.X:
self.assertLessEqual(
faces[0].Center().x, faces[-1].Center().x
)
self.assertLessEqual(
edges[0].Center().x, edges[-1].Center().x
)
elif axis == Axis.Y:
self.assertLessEqual(
faces[0].Center().y, faces[-1].Center().y
)
self.assertLessEqual(
edges[0].Center().y, edges[-1].Center().y
)
elif axis == Axis.Z:
self.assertLessEqual(
faces[0].Center().z, faces[-1].Center().z
)
self.assertLessEqual(
edges[0].Center().z, edges[-1].Center().z
)
def test_filter_by_type(self):
"""test the filter and sorting by type"""
with BuildPart() as test:
Box(2, 2, 2)
objects = test.faces()
objects.extend(test.edges())
self.assertEqual(len(objects.filter_by_type(Type.PLANE)), 6)
self.assertEqual(len(objects.filter_by_type(Type.LINE)), 12)
def test_sort_by_type(self):
"""test sorting by different attributes"""
with self.subTest(sort_by=SortBy.AREA):
with BuildPart() as test:
Wedge(1, 1, 1, 0, 0, 0.5, 0.5)
faces = test.faces().sort_by(SortBy.AREA)
self.assertEqual(faces[0].Area(), 0.25)
self.assertEqual(faces[-1].Area(), 1)
with self.subTest(sort_by=SortBy.LENGTH):
with BuildPart() as test:
Wedge(1, 1, 1, 0, 0, 0.5, 0.5)
edges = test.edges().sort_by(SortBy.LENGTH)
self.assertEqual(edges[0].Length(), 0.5)
self.assertAlmostEqual(edges[-1].Length(), 1.2247448713915892, 7)
with self.subTest(sort_by=SortBy.DISTANCE):
with BuildPart() as test:
Box(1, 1, 1, centered=(False, True, True))
faces = test.faces().sort_by(SortBy.DISTANCE)
self.assertAlmostEqual(faces[0].Center().Length, 0, 7)
self.assertAlmostEqual(faces[-1].Center().Length, 1, 7)
with self.subTest(sort_by=SortBy.VOLUME):
with BuildPart() as test:
Box(1, 1, 1)
PushPoints((0, 0, 10))
Box(2, 2, 2)
solids = test.solids().sort_by(SortBy.VOLUME)
self.assertAlmostEqual(solids[0].Volume(), 1, 7)
self.assertAlmostEqual(solids[-1].Volume(), 8, 7)
with self.subTest(sort_by=SortBy.RADIUS):
with BuildPart() as test:
Cone(1, 0.5, 2)
edges = test.edges().filter_by_type(Type.CIRCLE).sort_by(SortBy.RADIUS)
self.assertEqual(edges[0].radius(), 0.5)
self.assertEqual(edges[-1].radius(), 1)
with self.subTest(sort_by=SortBy.X):
with BuildPart() as test:
Box(1, 1, 1)
edges = test.edges().sort_by(SortBy.X)
self.assertEqual(edges[0].Center().x, -0.5)
self.assertEqual(edges[-1].Center().x, 0.5)
with self.subTest(sort_by=SortBy.Y):
with BuildPart() as test:
Box(1, 1, 1)
edges = test.edges().sort_by(SortBy.Y)
self.assertEqual(edges[0].Center().y, -0.5)
self.assertEqual(edges[-1].Center().y, 0.5)
with self.subTest(sort_by=SortBy.Z):
with BuildPart() as test:
Box(1, 1, 1)
edges = test.edges().sort_by(SortBy.Z)
self.assertEqual(edges[0].Center().z, -0.5)
self.assertEqual(edges[-1].Center().z, 0.5)
class TestBuilder(unittest.TestCase):
"""Test the Builder base class"""
def test_exit(self):
"""test transferring objects to parent"""
with BuildPart() as outer:
with BuildSketch() as inner:
Circle(1)
self.assertEqual(len(outer.pending_faces), 1)
with BuildSketch() as inner:
with BuildLine():
CenterArc((0, 0), 1, 0, 360)
BuildFace()
self.assertEqual(len(outer.pending_faces), 1)
if __name__ == "__main__":
unittest.main()