build123d/tests/test_build_sketch.py
gumyr 26caed754c
Some checks are pending
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Run type checker / typecheck (3.10) (push) Waiting to run
Run type checker / typecheck (3.13) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-14, 3.10) (push) Waiting to run
tests / tests (macos-14, 3.13) (push) Waiting to run
tests / tests (macos-15-intel, 3.10) (push) Waiting to run
tests / tests (macos-15-intel, 3.13) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.13) (push) Waiting to run
tests / tests (windows-latest, 3.10) (push) Waiting to run
tests / tests (windows-latest, 3.13) (push) Waiting to run
Removing make_face changes keeping BoundBox.extent
2025-11-20 13:31:25 -05:00

563 lines
21 KiB
Python

"""
build123d BuildSketch tests
name: build_sketch_tests.py
by: Gumyr
date: July 28th 2022
desc: Unit tests for the build123d build_sketch 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 math import atan2, degrees, pi, sqrt
import pytest
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 TestAlign(unittest.TestCase):
def test_align(self):
with BuildSketch() as align:
Rectangle(1, 1, align=(Align.MIN, Align.MAX))
bbox = align.sketch.bounding_box()
self.assertGreaterEqual(bbox.min.X, 0)
self.assertLessEqual(bbox.max.X, 1)
self.assertGreaterEqual(bbox.min.Y, -1)
self.assertLessEqual(bbox.max.Y, 0)
class TestBuildSketch(unittest.TestCase):
"""Test the BuildSketch Builder derived class"""
def test_obj_name(self):
with BuildSketch() as test:
Rectangle(10, 10)
self.assertEqual(test._obj_name, "sketch")
def test_select_vertices(self):
"""Test vertices()"""
with BuildSketch() as test:
Rectangle(10, 10)
self.assertEqual(len(test.vertices()), 4)
Rectangle(5, 20, align=(Align.CENTER, Align.MIN))
self.assertEqual(len(test.vertices(Select.LAST)), 4)
def test_select_edges(self):
"""Test edges()"""
with BuildSketch() as test:
Rectangle(10, 10)
self.assertEqual(len(test.edges()), 4)
Rectangle(5, 20, align=(Align.CENTER, Align.MIN))
self.assertEqual(len(test.edges(Select.LAST)), 5)
def test_select_faces(self):
"""Test faces()"""
with BuildSketch() as test:
Rectangle(10, 10)
with Locations((0, 20)):
Rectangle(5, 5)
self.assertEqual(len(test.faces()), 2)
self.assertEqual(len(test.faces(Select.LAST)), 1)
def test_consolidate_edges(self):
with BuildSketch() as test:
with BuildLine():
l1 = Line((0, 0), (10, 0))
Line(l1 @ 1, (10, 10))
self.assertTupleAlmostEquals(test.consolidate_edges() @ 1, (10, 10, 0), 5)
def test_mode_intersect(self):
with BuildSketch() as test:
Circle(10)
Rectangle(10, 10, align=(Align.MIN, Align.MIN), mode=Mode.INTERSECT)
self.assertAlmostEqual(test.sketch.area, 25 * pi, 5)
def test_mode_replace(self):
with BuildSketch() as test:
Circle(10)
Rectangle(10, 10, align=(Align.MIN, Align.MIN), mode=Mode.REPLACE)
self.assertAlmostEqual(test.sketch.area, 100, 5)
class TestBuildOnPlanes(unittest.TestCase):
def test_plane_xz(self):
with BuildSketch(Plane.XZ) as sketch_builder:
with BuildLine(Plane.XZ) as line_builder:
Polyline((0, 0), (0, 8), (2, 8), (2, 2), (6, 2), (6, 0), (0, 0))
make_face()
self.assertTrue(sketch_builder.sketch.faces()[0].is_coplanar(Plane.XZ))
with BuildSketch(Plane.XZ) as sketch_builder:
Rectangle(1, 1)
self.assertTrue(sketch_builder.sketch.faces()[0].is_coplanar(Plane.XZ))
def test_not_coplanar(self):
with BuildSketch() as coplanar:
add([Face.make_rect(1, 1, Plane.XY.offset(1))])
self.assertTrue(coplanar.sketch.faces()[0].is_coplanar(Plane.XY))
with BuildSketch() as coplanar:
add([Face.make_rect(1, 1, Plane.XZ)])
self.assertTrue(coplanar.sketch.faces()[0].is_coplanar(Plane.XY))
def test_changing_geometry(self):
with BuildSketch() as s:
Rectangle(1, 2)
scale(by=(2, 1, 0))
self.assertAlmostEqual(s.sketch.area, 4, 5)
class TestUpSideDown(unittest.TestCase):
def test_flip_face(self):
f1 = Face(
Wire.make_polygon([(1, 0), (1.5, 0.5), (1, 2), (3, 1), (2, 0), (1, 0)])
)
f1 = (
fillet(f1.vertices()[2:4], 0.2)
- Pos(1.8, 1.2, 0) * Rectangle(0.2, 0.4)
- Pos(2, 0.5, 0) * Circle(0.2)
).faces()[0]
self.assertTrue(f1.normal_at().Z < 0) # Up-side-down
f2 = Face(Wire.make_polygon([(1, 0), (1.5, -1), (2, -1), (2, 0), (1, 0)]))
self.assertTrue(f2.normal_at().Z > 0) # Right-side-up
with BuildSketch() as flip_test:
add(f1)
add(f2)
self.assertEqual(len(flip_test.faces()), 1) # Face flip and combined
def test_make_hull_flipped(self):
with BuildSketch() as base_plan:
Circle(55 / 2)
with Locations((0, 125)):
Circle(30 / 2)
base_hull = make_hull(mode=Mode.PRIVATE)
for face in base_hull.faces():
self.assertTrue(face.normal_at().Z > 0)
def test_make_face_flipped(self):
wire = Wire.make_polygon([(0, 0), (1, 1), (2, 0)])
sketch = make_face(wire.edges())
self.assertTrue(sketch.faces()[0].normal_at().Z > 0)
class TestBuildSketchExceptions(unittest.TestCase):
"""Test exception handling"""
def test_invalid_subtract(self):
with self.assertRaises(RuntimeError):
with BuildSketch():
Circle(10, mode=Mode.SUBTRACT)
def test_invalid_intersect(self):
with self.assertRaises(RuntimeError):
with BuildSketch():
Circle(10, mode=Mode.INTERSECT)
def test_invalid_selector(self):
with self.assertRaises(NotImplementedError):
with BuildSketch() as bs:
Circle(1)
bs.solids()
class TestBuildSketchObjects(unittest.TestCase):
"""Test the 2d sketch objects"""
def test_circle(self):
with BuildSketch() as test:
c = Circle(20)
self.assertEqual(c.radius, 20)
self.assertEqual(c.align, (Align.CENTER, Align.CENTER))
self.assertEqual(c.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, pi * 20**2, 5)
self.assertEqual(c.faces()[0].normal_at(), Vector(0, 0, 1))
def test_ellipse(self):
with BuildSketch() as test:
e = Ellipse(20, 10)
self.assertEqual(e.x_radius, 20)
self.assertEqual(e.y_radius, 10)
self.assertEqual(e.rotation, 0)
self.assertEqual(e.align, (Align.CENTER, Align.CENTER))
self.assertEqual(e.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, pi * 20 * 10, 5)
self.assertEqual(e.faces()[0].normal_at(), Vector(0, 0, 1))
def test_polygon(self):
with BuildSketch() as test:
p = Polygon((0, 0), (1, 0), (0, 1), (0, 0))
self.assertEqual(len(p.pts), 4)
self.assertEqual(p.rotation, 0)
self.assertEqual(p.align, (Align.CENTER, Align.CENTER))
self.assertEqual(p.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, 0.5, 5)
self.assertEqual(p.faces()[0].normal_at(), Vector(0, 0, 1))
# test iterable input
points_nervure = [(0.0, 0.0), (10.0, 0.0), (0.0, 5.0)]
riri = Polygon(points_nervure, align=Align.NONE)
self.assertEqual(len(riri.vertices()), 3)
def test_rectangle(self):
with BuildSketch() as test:
r = Rectangle(20, 10)
self.assertEqual(r.width, 20)
self.assertEqual(r.rectangle_height, 10)
self.assertEqual(r.rotation, 0)
self.assertEqual(r.align, (Align.CENTER, Align.CENTER))
self.assertEqual(r.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, 20 * 10, 5)
self.assertEqual(r.faces()[0].normal_at(), Vector(0, 0, 1))
def test_rectangle_rounded(self):
with BuildSketch() as test:
r = RectangleRounded(20, 10, 1)
self.assertEqual(r.width, 20)
self.assertEqual(r.rectangle_height, 10)
self.assertEqual(r.rotation, 0)
self.assertEqual(r.radius, 1)
self.assertEqual(r.align, (Align.CENTER, Align.CENTER))
self.assertEqual(r.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, 20 * 10 - 4 * 1**2 + pi * 1**2, 5)
self.assertEqual(r.faces()[0].normal_at(), Vector(0, 0, 1))
with self.assertRaises(ValueError):
with BuildSketch() as test:
r = RectangleRounded(20, 10, 5)
with self.assertRaises(ValueError):
with BuildSketch() as test:
r = RectangleRounded(10, 20, 5)
def test_regular_polygon(self):
with BuildSketch() as test:
r = RegularPolygon(2, 6)
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.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, (3 * sqrt(3) / 2) * 2**2, 5)
self.assertTupleAlmostEquals(test.sketch.faces()[0].normal_at(), (0, 0, 1), 5)
self.assertAlmostEqual(r.apothem, 2 * sqrt(3) / 2)
def test_regular_polygon_minor_radius(self):
with BuildSketch() as test:
r = RegularPolygon(0.5, 3, False)
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.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(), (0, 0, 1), 5)
def test_regular_polygon_align(self):
with BuildSketch() as align:
RegularPolygon(2, 5, align=(Align.MIN, Align.MAX))
bbox = align.sketch.bounding_box()
self.assertAlmostEqual(bbox.min.X, 0, 8)
self.assertLessEqual(bbox.max.X, 4)
self.assertGreaterEqual(bbox.min.Y, -4)
self.assertLessEqual(bbox.max.Y, 1e-5)
with BuildSketch() as align:
RegularPolygon(2, 5, align=None)
self.assertLessEqual(
Vector(align.vertices().sort_by_distance(other=(0, 0, 0))[-1]).length, 2
)
def test_regular_polygon_matches_polar(self):
for side_count in range(3, 10):
with BuildSketch():
regular_poly = RegularPolygon(1, side_count)
poly_pts = [Vector(v) for v in regular_poly.vertices()]
polar_pts = [p.position for p in PolarLocations(1, side_count)]
for poly_pt, polar_pt in zip(poly_pts, polar_pts):
self.assertTupleAlmostEquals(poly_pt, polar_pt, 5)
def test_regular_polygon_min_sides(self):
with self.assertRaises(ValueError):
with BuildSketch():
RegularPolygon(1, 2)
def test_slot_arc(self):
with BuildSketch() as test:
with BuildLine(mode=Mode.PRIVATE) as l:
RadiusArc((0, 0), (5, 0), radius=4)
s = SlotArc(arc=l.edges()[0], height=1, rotation=45)
self.assertEqual(type(s.arc), Edge)
self.assertEqual(s.slot_height, 1)
self.assertEqual(s.rotation, 45)
self.assertEqual(s.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, 6.186450426893698, 5)
self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1))
def test_slot_center_point(self):
with BuildSketch() as test:
s = SlotCenterPoint((0, 0), (2, 0), 2)
self.assertTupleAlmostEquals(s.slot_center, (0, 0, 0), 5)
self.assertTupleAlmostEquals(s.point, (2, 0, 0), 5)
self.assertEqual(s.slot_height, 2)
self.assertEqual(s.rotation, 0)
self.assertEqual(s.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, pi + 4 * 2, 5)
self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1))
def test_slot_center_to_center(self):
height = 2
with BuildSketch() as test:
s = SlotCenterToCenter(4, height)
self.assertEqual(s.center_separation, 4)
self.assertEqual(s.slot_height, height)
self.assertEqual(s.rotation, 0)
self.assertEqual(s.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, pi + 4 * height, 5)
self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1))
# Circle degenerate
s1 = SlotCenterToCenter(0, height)
self.assertTrue(len(s1.edges()) == 1)
self.assertEqual(s1.edge().geom_type, GeomType.CIRCLE)
self.assertAlmostEqual(s1.edge().radius, height / 2)
def test_slot_overall(self):
height = 2
with BuildSketch() as test:
s = SlotOverall(6, height)
self.assertEqual(s.width, 6)
self.assertEqual(s.slot_height, height)
self.assertEqual(s.rotation, 0)
self.assertEqual(s.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, pi + 4 * height, 5)
self.assertEqual(s.faces()[0].normal_at(), Vector(0, 0, 1))
# Circle degenerat
s1 = SlotOverall(2, height)
self.assertTrue(len(s1.edges()) == 1)
self.assertEqual(s1.edge().geom_type, GeomType.CIRCLE)
self.assertAlmostEqual(s1.edge().radius, height / 2)
def test_text(self):
with BuildSketch() as test:
t = Text("test", 2)
self.assertEqual(t.txt, "test")
self.assertEqual(t.font_size, 2)
self.assertEqual(t.font, "Arial")
self.assertIsNone(t.font_path)
self.assertEqual(t.font_style, FontStyle.REGULAR)
self.assertEqual(t.text_align, (TextAlign.CENTER, TextAlign.CENTER))
self.assertIsNone(t.align)
self.assertIsNone(t.text_path)
self.assertEqual(t.position_on_path, 0)
self.assertEqual(t.rotation, 0)
self.assertEqual(t.mode, Mode.ADD)
self.assertEqual(len(test.sketch.faces()), 4)
self.assertEqual(t.faces()[0].normal_at(), Vector(0, 0, 1))
with self.assertRaises(ValueError):
Text("test", 2, text_align=(TextAlign.BOTTOM, TextAlign.BOTTOM))
with self.assertRaises(ValueError):
Text("test", 2, text_align=(TextAlign.LEFT, TextAlign.LEFT))
def test_trapezoid(self):
with BuildSketch() as test:
t = Trapezoid(6, 2, 63.434948823)
self.assertEqual(t.width, 6)
self.assertEqual(t.trapezoid_height, 2)
self.assertEqual(t.left_side_angle, 63.434948823)
self.assertEqual(t.right_side_angle, 63.434948823)
self.assertEqual(t.rotation, 0)
self.assertEqual(t.mode, Mode.ADD)
self.assertAlmostEqual(test.sketch.area, 2 * (6 + 4) / 2, 5)
self.assertEqual(t.faces()[0].normal_at(), Vector(0, 0, 1))
with self.assertRaises(ValueError):
with BuildSketch() as test:
Trapezoid(6, 2, 30)
with self.assertRaises(ValueError):
with BuildSketch() as test:
Trapezoid(6, 2, 150)
with BuildSketch() as test:
t = Trapezoid(12, 8, 135, 90)
self.assertEqual(t.width, 12)
self.assertEqual(t.trapezoid_height, 8)
self.assertEqual(t.left_side_angle, 135)
self.assertEqual(t.right_side_angle, 90)
self.assertAlmostEqual(test.sketch.area, 8 * (12 + 4) / 2, 5)
def test_triangle(self):
tri = Triangle(a=3, b=4, c=5, align=Align.MIN)
self.assertAlmostEqual(tri.area, (3 * 4) / 2, 5)
self.assertAlmostEqual(tri.A, degrees(atan2(3, 4)), 5)
self.assertAlmostEqual(tri.B, degrees(atan2(4, 3)), 5)
self.assertAlmostEqual(tri.C, 90, 5)
self.assertAlmostEqual(tri.a, 3, 5)
self.assertAlmostEqual(tri.b, 4, 5)
self.assertAlmostEqual(tri.c, 5, 5)
self.assertAlmostEqual(tri.edge_a.length, 3, 5)
self.assertAlmostEqual(tri.edge_b.length, 4, 5)
self.assertAlmostEqual(tri.edge_c.length, 5, 5)
self.assertTupleAlmostEquals(tri.vertex_A, (3, 4, 0), 5)
self.assertTupleAlmostEquals(tri.vertex_B, (0, 0, 0), 5)
self.assertTupleAlmostEquals(tri.vertex_C, (3, 0, 0), 5)
self.assertEqual(tri.vertex_A.topo_parent, tri)
self.assertEqual(tri.vertex_B.topo_parent, tri)
self.assertEqual(tri.vertex_C.topo_parent, tri)
tri = Triangle(c=5, C=90, a=3)
self.assertAlmostEqual(tri.area, (3 * 4) / 2, 5)
with self.assertRaises(ValueError):
Triangle(A=90, B=45, C=45)
with self.assertRaises(AssertionError):
Triangle(a=10, b=4, c=4)
def test_offset(self):
"""Test normal and error cases"""
with BuildSketch() as test:
Circle(10)
offset(amount=1, objects=test.faces()[0])
self.assertAlmostEqual(test.edges()[0].radius, 11)
with self.assertRaises(RuntimeError):
with BuildSketch() as test:
offset(amount=1, objects=Location(Vector()))
def test_add_multiple(self):
"""Test adding multiple items"""
with BuildSketch() as test:
with Locations((-10, 0), (10, 0)):
Circle(1)
self.assertAlmostEqual(sum([f.area for f in test.faces()]), 2 * pi, 5)
def test_make_face(self):
with self.assertRaises(ValueError):
with BuildSketch():
make_face()
with self.assertRaises(ValueError):
make_face()
def test_make_hull(self):
"""Test hull from pending edges and passed edges"""
with BuildSketch() as test:
with BuildLine():
CenterArc((0, 0), 1, 0, 360)
CenterArc((1, 1.5), 0.5, 0, 360)
Line((0.0, 2), (-1, 3.0))
make_hull()
self.assertAlmostEqual(test.sketch.area, 7.2582, 4)
with BuildSketch() as test:
with Locations((-10, 0)):
Circle(10)
with Locations((10, 0)):
Circle(7)
make_hull(test.edges())
self.assertAlmostEqual(test.sketch.area, 577.8808, 4)
with self.assertRaises(ValueError):
with BuildSketch():
make_hull()
with self.assertRaises(ValueError):
make_hull()
def test_trace(self):
with BuildSketch() as test:
with BuildLine():
Line((0, 0), (10, 0))
trace(line_width=1)
self.assertEqual(len(test.faces()), 1)
self.assertAlmostEqual(test.sketch.area, 10, 5)
with self.assertRaises(ValueError):
trace()
line = Polyline((0, 0), (10, 10), (20, 10))
test = trace(line, 4)
self.assertEqual(len(test.faces()), 1)
def test_full_round(self):
with BuildSketch() as test:
trap = Trapezoid(0.5, 1, 90 - 8)
full_round(test.edges().sort_by(Axis.Y)[-1])
self.assertLess(test.face().area, trap.face().area)
with self.assertRaises(ValueError):
full_round(test.edges().sort_by(Axis.Y))
with self.assertRaises(ValueError):
full_round(trap.edges().sort_by(Axis.X)[-1])
with self.assertRaises(ValueError):
full_round(Edge.make_line((0, 0), (1, 0)))
l1 = Edge.make_spline([(-1, 0), (1, 0)], tangents=((0, -8), (0, 8)), scale=True)
l2 = Edge.make_line(l1 @ 0, l1 @ 1)
face = Face(Wire([l1, l2]))
with self.assertRaises(ValueError):
full_round(face.edges()[0])
positive = full_round(trap.edges().sort_by(SortBy.LENGTH)[0])
negative = full_round(trap.edges().sort_by(SortBy.LENGTH)[0], invert=True)
self.assertLess(negative.face().area, positive.face().area)
rect = Rectangle(34, 10)
convex_rect = full_round((rect.edges() << Axis.X)[0])
concave_rect = full_round((rect.edges() << Axis.X)[0], invert=True)
self.assertLess(convex_rect.area, rect.area)
self.assertLess(concave_rect.area, convex_rect.area)
tri = Triangle(a=10, b=10, c=10)
tri_round = full_round(tri.edges().sort_by(Axis.X)[0])
self.assertLess(tri_round.area, tri.area)
# Test flipping the face
flipped = -Face.make_rect(34, 10)
rounded = full_round((flipped.edges() << Axis.X)[0]).face()
self.assertEqual(flipped.normal_at(), rounded.normal_at())
@pytest.mark.parametrize(
"slot,args",
[
(SlotOverall, (9, 10)),
(SlotCenterToCenter, (-1, 10)),
(SlotCenterPoint, ((0, 0, 0), (0, 0, 0), 10)),
],
)
def test_invalid_slots(slot, args):
with pytest.raises(ValueError):
slot(*args)
if __name__ == "__main__":
unittest.main(failfast=True)