diff --git a/examples/operation/connector.py b/examples/operation/connector.py new file mode 100644 index 00000000..a1da5a6e --- /dev/null +++ b/examples/operation/connector.py @@ -0,0 +1,24 @@ +import os + +import numpy as np + +import classy_blocks as cb + +box_1 = cb.Box([-1, -1, -1], [1, 1, 1]) +box_2 = box_1.copy().rotate(np.pi / 4, [1, 1, 1], [0, 0, 0]).translate([4, 2, 0]) + +for i in range(3): + box_1.chop(i, count=10) + box_2.chop(i, count=10) + + +connector = cb.Connector(box_1, box_2) +connector.chop(2, count=10) + +mesh = cb.Mesh() +mesh.add(box_1) +mesh.add(box_2) +mesh.add(connector) +mesh.set_default_patch("walls", "wall") + +mesh.write(os.path.join("..", "case", "system", "blockMeshDict"), debug_path="debug.vtk") diff --git a/src/classy_blocks/__init__.py b/src/classy_blocks/__init__.py index a93e4580..3cabd6d6 100644 --- a/src/classy_blocks/__init__.py +++ b/src/classy_blocks/__init__.py @@ -4,6 +4,7 @@ from .construct.edges import Angle, Arc, OnCurve, Origin, PolyLine, Project, Spline from .construct.flat.face import Face from .construct.operations.box import Box +from .construct.operations.connector import Connector from .construct.operations.extrude import Extrude from .construct.operations.loft import Loft from .construct.operations.revolve import Revolve @@ -46,6 +47,7 @@ "Revolve", "Box", "Wedge", + "Connector", # construct shapes "Elbow", "Frustum", diff --git a/src/classy_blocks/construct/operations/connector.py b/src/classy_blocks/construct/operations/connector.py new file mode 100644 index 00000000..d3e1718a --- /dev/null +++ b/src/classy_blocks/construct/operations/connector.py @@ -0,0 +1,86 @@ +from typing import List + +import numpy as np + +from classy_blocks.construct.flat.face import Face +from classy_blocks.construct.operations.operation import Operation +from classy_blocks.modify.reorient.viewpoint import ViewpointReorienter +from classy_blocks.util import functions as f + + +class FacePair: + def __init__(self, face_1: Face, face_2: Face): + self.face_1 = face_1 + self.face_2 = face_2 + + @property + def distance(self) -> float: + """Returns distance between two faces' centers""" + return f.norm(self.face_1.center - self.face_2.center) + + @property + def alignment(self) -> float: + """Returns a scalar number that is a measure of how well the + two faces are aligned, a.k.a. how well their normals align""" + vconn = f.unit_vector(self.face_2.center - self.face_1.center) + return np.dot(vconn, self.face_1.normal) ** 3 + np.dot(-vconn, self.face_2.normal) ** 3 + + +class Connector(Operation): + """A normal Loft but automatically finds and reorders appropriate faces between + two arbitrary given blocks. + + The recipe is as follows: + 1. Find a pair of faces whose normals are most nicely aligned + 2. Create a loft that connects them + 3. Reorder the loft so that is is properly oriented + + The following limitations apply: + "Closest faces" might be an ill-defined term; for example, + imagine two boxes: + ___ + | 2 | + |___| + ___ + | 1 | + |___| + + Here, multiple different faces can be found. + + Reordering relies on ViewpointReorienter; see the documentation on that + for its limitations. + + Resulting loft will have the bottom face coincident with operation_1 + and top face with operation_2. + Axis 2 is always between the two operations but axes 0 and 1 + depend on positions of operations and is not exactly defined. + To somewhat alleviate this confusion it is + recommended to chop operation 1 or 2 in axes 0 and 1 and + only provide chopping for axis 2 of connector.""" + + def __init__(self, operation_1: Operation, operation_2: Operation): + self.operation_1 = operation_1 + self.operation_2 = operation_2 + + all_pairs: List[FacePair] = [] + for orient_1, face_1 in operation_1.get_all_faces().items(): + if orient_1 in ("bottom", "left", "front"): + face_1.invert() + for orient_2, face_2 in operation_2.get_all_faces().items(): + if orient_2 in ("bottom", "left", "front"): + face_2.invert() + all_pairs.append(FacePair(face_1, face_2)) + + all_pairs.sort(key=lambda pair: pair.distance) + all_pairs = all_pairs[:9] + all_pairs.sort(key=lambda pair: pair.alignment) + + start_face = all_pairs[-1].face_1 + end_face = all_pairs[-1].face_2 + + super().__init__(start_face, end_face) + + viewpoint = operation_1.center + 2 * (operation_1.top_face.center - operation_1.bottom_face.center) + ceiling = operation_1.center + 2 * (operation_2.center - operation_1.center) + reorienter = ViewpointReorienter(viewpoint, ceiling) + reorienter.reorient(self) diff --git a/src/classy_blocks/construct/operations/operation.py b/src/classy_blocks/construct/operations/operation.py index 14d4af24..10f26c93 100644 --- a/src/classy_blocks/construct/operations/operation.py +++ b/src/classy_blocks/construct/operations/operation.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional, TypeVar, Union +from typing import Dict, List, Optional, TypeVar, Union, get_args import numpy as np @@ -8,8 +8,9 @@ from classy_blocks.construct.flat.face import Face from classy_blocks.construct.point import Point from classy_blocks.grading.chop import Chop -from classy_blocks.types import AxisType, NPPointType, OrientType, ProjectToType +from classy_blocks.types import AxisType, NPPointType, OrientType, PointType, ProjectToType from classy_blocks.util import constants +from classy_blocks.util import functions as f from classy_blocks.util.constants import SIDES_MAP from classy_blocks.util.frame import Frame from classy_blocks.util.tools import edge_map @@ -216,9 +217,35 @@ def get_patches_at_corner(self, corner: int) -> set: def get_face(self, side: OrientType) -> Face: """Returns a new Face on specified side of the Operation. Warning: bottom, left and front faces must be inverted prior - to using them for a loft/extrude etc.""" + to using them for a loft/extrude etc (they point inside the operation by default).""" return Face([self.point_array[i] for i in constants.FACE_MAP[side]]) + def get_all_faces(self) -> Dict[OrientType, Face]: + """Returns a list of all faces""" + return {orient: self.get_face(orient) for orient in get_args(OrientType)} + + def get_closest_face(self, point: PointType) -> Face: + """Returns a Face that has a center nearest to given point""" + point = np.array(point) + faces = list(self.get_all_faces().values()) + centers = [f.norm(point - face.center) for face in faces] + + return faces[np.argmin(centers)] + + def get_normal_face(self, point: PointType) -> Face: + """Returns a Face that has normal closest to + vector that connects returned face and 'point' (viewer).""" + point = np.array(point) + faces = self.get_all_faces() + orients: List[OrientType] = ["bottom", "left", "front"] + + for orient in orients: + faces[orient].invert() + face_list = list(faces.values()) + + dotps = [np.dot(f.unit_vector(point - face.center), face.normal) for face in face_list] + return face_list[np.argmax(dotps)] + @property def patch_names(self) -> Dict[OrientType, str]: """Returns patches names on sides where they are specified""" diff --git a/src/classy_blocks/construct/shapes/cylinder.py b/src/classy_blocks/construct/shapes/cylinder.py index 2983458b..ef975fe6 100644 --- a/src/classy_blocks/construct/shapes/cylinder.py +++ b/src/classy_blocks/construct/shapes/cylinder.py @@ -4,7 +4,7 @@ from classy_blocks.base import transforms as tr from classy_blocks.base.exceptions import CylinderCreationError -from classy_blocks.construct.flat.sketches.disk import Disk +from classy_blocks.construct.flat.sketches.disk import Disk, HalfDisk from classy_blocks.construct.shapes.rings import ExtrudedRing from classy_blocks.construct.shapes.round import RoundSolidShape from classy_blocks.types import PointType @@ -12,14 +12,17 @@ from classy_blocks.util.constants import TOL -class Cylinder(RoundSolidShape): - """A Cylinder. +class SemiCylinder(RoundSolidShape): + """Half of a cylinder; it is constructed from + given point and axis in a positive sense - right-hand rule. Args: axis_point_1: position of start face axis_point_2: position of end face radius_point_1: defines starting point and radius""" + sketch_class = HalfDisk + def __init__(self, axis_point_1: PointType, axis_point_2: PointType, radius_point_1: PointType): axis_point_1 = np.asarray(axis_point_1) axis = np.asarray(axis_point_2) - axis_point_1 @@ -33,7 +36,18 @@ def __init__(self, axis_point_1: PointType, axis_point_2: PointType, radius_poin transform_2: List[tr.Transformation] = [tr.Translation(axis)] - super().__init__(Disk(axis_point_1, radius_point_1, axis), transform_2, None) + super().__init__(self.sketch_class(axis_point_1, radius_point_1, axis), transform_2, None) + + +class Cylinder(SemiCylinder): + """A Cylinder. + + Args: + axis_point_1: position of start face + axis_point_2: position of end face + radius_point_1: defines starting point and radius""" + + sketch_class = Disk @classmethod def chain(cls, source: RoundSolidShape, length: float, start_face: bool = False) -> "Cylinder": diff --git a/src/classy_blocks/modify/reorient/viewpoint.py b/src/classy_blocks/modify/reorient/viewpoint.py index 455b526b..2b9d695b 100644 --- a/src/classy_blocks/modify/reorient/viewpoint.py +++ b/src/classy_blocks/modify/reorient/viewpoint.py @@ -34,7 +34,7 @@ def flip(self): """Flips the triangle so that its normal points the other way""" self.points = np.flip(self.points, axis=0) - def orient(self, hull_center: NPPointType): + def orient(self, hull_center: NPPointType) -> None: """Flips the triangle around (if needed) so that normal always points away from the provided hull center""" if np.dot(self.center - hull_center, self.normal) < 0: @@ -135,6 +135,12 @@ def _make_triangles(self, points: NPPointListType) -> List[Triangle]: def _get_normals(self, center: NPPointType) -> Dict[OrientType, NPVectorType]: v_observer = f.unit_vector(np.array(self.observer) - center) v_ceiling = f.unit_vector(np.array(self.ceiling) - center) + + # correct ceiling so that it's always at right angle with observer + correction = np.dot(v_ceiling, v_observer) * v_observer + v_ceiling -= correction + v_ceiling = f.unit_vector(v_ceiling) + v_left = f.unit_vector(np.cross(v_observer, v_ceiling)) return { diff --git a/tests/test_construct/test_operation/test_box.py b/tests/test_construct/test_operation/test_box.py index e7545834..ce909406 100644 --- a/tests/test_construct/test_operation/test_box.py +++ b/tests/test_construct/test_operation/test_box.py @@ -11,62 +11,14 @@ class BoxTests(unittest.TestCase): @parameterized.expand( ( - ( - [ - 1, - 1, - 1, - ], - ), - ( - [ - -1, - 1, - 1, - ], - ), - ( - [ - -1, - -1, - 1, - ], - ), - ( - [ - 1, - -1, - 1, - ], - ), - ( - [ - 1, - 1, - -1, - ], - ), - ( - [ - -1, - 1, - -1, - ], - ), - ( - [ - -1, - -1, - -1, - ], - ), - ( - [ - 1, - -1, - -1, - ], - ), + ([1, 1, 1],), + ([-1, 1, 1],), + ([-1, -1, 1],), + ([1, -1, 1],), + ([1, 1, -1],), + ([-1, 1, -1],), + ([-1, -1, -1],), + ([1, -1, -1],), ) ) def test_create_box(self, diagonal_point): diff --git a/tests/test_construct/test_operation/test_connector.py b/tests/test_construct/test_operation/test_connector.py new file mode 100644 index 00000000..77a10343 --- /dev/null +++ b/tests/test_construct/test_operation/test_connector.py @@ -0,0 +1,38 @@ +import unittest + +import numpy as np + +from classy_blocks.construct.operations.box import Box +from classy_blocks.construct.operations.connector import Connector + + +class ConnectorTests(unittest.TestCase): + def setUp(self): + # basic box, center at origin + self.box_1 = Box([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]) + # a 'nicely' positioned box + self.box_2 = self.box_1.copy().rotate(np.pi / 2, [0, 0, 1], [0, 0, 0]).translate([2, 0, 0]) + # an ugly box a.k.a. border case + # self.box_3 + + def test_create_normal(self): + _ = Connector(self.box_1, self.box_2) + + def test_create_inverted(self): + _ = Connector(self.box_2, self.box_1) + + def test_direction(self): + connector = Connector(self.box_1, self.box_2) + + box_vector = self.box_2.center - self.box_1.center + connector_vector = connector.top_face.center - connector.bottom_face.center + + self.assertGreater(np.dot(box_vector, connector_vector), 0) + + def test_direction_inverted(self): + connector = Connector(self.box_2, self.box_1) + + box_vector = self.box_2.center - self.box_1.center + connector_vector = connector.top_face.center - connector.bottom_face.center + + self.assertLess(np.dot(box_vector, connector_vector), 0) diff --git a/tests/test_util/test_imports.py b/tests/test_util/test_imports.py index b25402ee..2d56d77c 100644 --- a/tests/test_util/test_imports.py +++ b/tests/test_util/test_imports.py @@ -33,6 +33,7 @@ def test_import_operations(self): _ = cb.Extrude _ = cb.Revolve _ = cb.Wedge + _ = cb.Connector def test_import_shapes(self): """Import Shapes"""