mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -08:00
945 lines
37 KiB
Python
945 lines
37 KiB
Python
"""
|
|
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 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
|
|
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 JernArc, Line, Polyline, Spline, ThreePointArc
|
|
from build123d.objects_part import Box, Cone, Cylinder, Sphere, Torus
|
|
from build123d.objects_sketch import (
|
|
Circle,
|
|
Ellipse,
|
|
Polygon,
|
|
Rectangle,
|
|
RegularPolygon,
|
|
Text,
|
|
Triangle,
|
|
)
|
|
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, Shell, Solid, Wire
|
|
from OCP.GeomAPI import GeomAPI_ExtremaCurveCurve
|
|
|
|
|
|
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)
|
|
|
|
def test_without_holes(self):
|
|
# Planar test
|
|
frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face()
|
|
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)
|
|
self.assertAlmostEqual(filled.area, 1.0, 5)
|
|
|
|
# Errors
|
|
frame.wrapped = None
|
|
with self.assertRaises(ValueError):
|
|
frame.without_holes()
|
|
|
|
# No holes
|
|
rect = Face.make_rect(1, 1)
|
|
self.assertEqual(rect, rect.without_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.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.area_without_holes, filled.area, 5)
|
|
|
|
def test_area_without_holes(self):
|
|
frame = (Rectangle(1, 1) - Rectangle(0.5, 0.5)).face()
|
|
frame.wrapped = None
|
|
self.assertAlmostEqual(frame.area_without_holes, 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)
|
|
|
|
# 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)
|
|
|
|
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_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.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)
|
|
|
|
def test_wrap(self):
|
|
surfaces = [
|
|
part.faces().filter_by(GeomType.PLANE, reverse=True)[0]
|
|
for part in (Cylinder(5, 10), Sphere(5), Cone(5, 2, 10))
|
|
]
|
|
inner = PolarLocations(1, 5, -18).local_locations
|
|
outer = PolarLocations(3, 5, -18 + 36).local_locations
|
|
points = [p.position for pair in zip(inner, outer) for p in pair]
|
|
star = (Polygon(*points, align=Align.NONE) - Circle(0.5)).face()
|
|
planar_edge = Edge.make_line((0, 0), (3, 3))
|
|
planar_wire = Wire([planar_edge, Edge.make_line(planar_edge @ 1, (3, 0))])
|
|
for surface in surfaces:
|
|
with self.subTest(surface=surface):
|
|
target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0))
|
|
|
|
wrapped_face: Face = surface.wrap(star, target)
|
|
self.assertTrue(isinstance(wrapped_face, Face))
|
|
self.assertFalse(wrapped_face.is_planar_face)
|
|
self.assertTrue(wrapped_face.inner_wires())
|
|
|
|
wrapped_edge = surface.wrap(planar_edge, target)
|
|
self.assertTrue(wrapped_edge.geom_type == GeomType.BSPLINE)
|
|
self.assertAlmostEqual(planar_edge.length, wrapped_edge.length, 2)
|
|
self.assertAlmostEqual(wrapped_edge @ 0, target.position, 5)
|
|
|
|
wrapped_wire = surface.wrap(planar_wire, target)
|
|
self.assertAlmostEqual(planar_wire.length, wrapped_wire.length, 2)
|
|
self.assertAlmostEqual(wrapped_wire @ 0, target.position, 5)
|
|
|
|
with self.assertRaises(TypeError):
|
|
surface.wrap(Solid.make_box(1, 1, 1), target)
|
|
|
|
@patch.object(GeomAPI_ExtremaCurveCurve, "NbExtrema", return_value=0)
|
|
def test_wrap_intersect_error(self, mock_is_valid):
|
|
surface = Cone(5, 2, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
|
|
target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0))
|
|
inner = PolarLocations(1, 5, -18).local_locations
|
|
outer = PolarLocations(3, 5, -18 + 36).local_locations
|
|
points = [p.position for pair in zip(inner, outer) for p in pair]
|
|
star = (Polygon(*points, align=Align.NONE) - Circle(0.5)).face()
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
surface.wrap(star.outer_wire(), target)
|
|
|
|
@patch.object(Wire, "is_valid", return_value=False)
|
|
def test_wrap_invalid_wire(self, mock_is_valid):
|
|
surface = Cone(5, 2, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0]
|
|
target = surface.location_at(0.5, 0.5, x_dir=(1, 0, 0))
|
|
inner = PolarLocations(1, 5, -18).local_locations
|
|
outer = PolarLocations(3, 5, -18 + 36).local_locations
|
|
points = [p.position for pair in zip(inner, outer) for p in pair]
|
|
star = (Polygon(*points, align=Align.NONE) - Circle(0.5)).face()
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
surface.wrap(star, target)
|
|
|
|
def test_wrap_faces(self):
|
|
sphere = Solid.make_sphere(50, angle1=-90).face()
|
|
surface = sphere.face()
|
|
path: Edge = (
|
|
sphere.cut(
|
|
Solid.make_cylinder(80, 100, Plane.YZ).locate(Location((-50, 0, -70)))
|
|
)
|
|
.edges()
|
|
.sort_by(Axis.Z)[0]
|
|
.reversed()
|
|
)
|
|
text = Text(txt="ei", font_size=15, align=(Align.MIN, Align.CENTER))
|
|
wrapped_faces = surface.wrap_faces(text.faces(), path, 0.2)
|
|
self.assertEqual(len(wrapped_faces), 3)
|
|
self.assertTrue(all(not f.is_planar_face for f in wrapped_faces))
|
|
|
|
def test_revolve(self):
|
|
l1 = Edge.make_line((3, 0), (3, 2))
|
|
revolved = Face.revolve(l1, 360, Axis.Y)
|
|
self.assertTrue(isinstance(revolved, Face))
|
|
self.assertAlmostEqual(revolved.area, 2 * math.pi * 3 * 2, 5)
|
|
|
|
l2 = JernArc(l1 @ 1, l1 % 1, 1, 90)
|
|
w1 = Wire([l1, l2])
|
|
revolved = Shell.revolve(w1, 180, Axis.Y)
|
|
self.assertTrue(isinstance(revolved, Shell))
|
|
self.assertAlmostEqual(revolved.edges().sort_by(Axis.Y)[-1].radius, 2, 5)
|
|
|
|
|
|
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()
|