diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index a182c6a145..794cc32143 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -65,6 +65,58 @@ def np_normalized(v: VectorType) -> np.ndarray: return np.divide(v, np.linalg.norm(v)) +def np_to_3d(v: VectorType) -> np.ndarray: + """Convert 2D vector to 3D.""" + return np.hstack((v, (0.0,))) + + +def np_to_4x4(matrix_3x3: np.ndarray) -> np.ndarray: + """Convert 3x3 matrix to 4x4.""" + matrix_4x4 = np.pad(matrix_3x3, ((0, 1), (0, 1))) + matrix_4x4[3, 3] = 1 + return matrix_4x4 + + +def np_rotation_matrix( + angle: float, size: int, axis: Optional[Union[Literal["X", "Y", "Z"], VectorType]] = None +) -> np.ndarray: + """Get rotation matrix. Designed to be similar to mathutils Matrix.Rotation but to use numpy. + + :param float: Rotation angle, in radians. + :param size: Matrix size ([2;4]). + :param axis: Rotation axis. + For 2x2 matrices Z assumed by default and argument can be omitted, + for 3x3/4x4 matrices could be either axis literal + or a rotation axis presented as a vector. + :return: Rotation matrix. + """ + if not (2 <= size <= 4): + raise ValueError(f"Size must be [2;4], got {size}.") + + cos_theta: float = np.cos(angle) + sin_theta: float = np.sin(angle) + if size == 2: + return np.array([[cos_theta, -sin_theta], [sin_theta, cos_theta]]) + + assert axis, "For non-2D matrices 'axis' argument is not optional." + if isinstance(axis, str): + if axis == "X": + matrix = np.array([[1, 0, 0], [0, cos_theta, -sin_theta], [0, sin_theta, cos_theta]]) + elif axis == "Y": + matrix = np.array([[cos_theta, 0, sin_theta], [0, 1, 0], [-sin_theta, 0, cos_theta]]) + elif axis == "Z": + matrix = np.array([[cos_theta, -sin_theta, 0], [sin_theta, cos_theta, 0], [0, 0, 1]]) + else: + # Assume axis is a vector. + axis = axis / np.linalg.norm(axis) + # Rodrigues' rotation formula. + K = np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]]) + matrix = cos_theta * np.eye(3) + (1 - cos_theta) * np.outer(axis, axis) + sin_theta * K + if size == 4: + return np_to_4x4(matrix) + return matrix + + # Note: using ShapeBuilder try not to reuse IFC elements in the process # otherwise you might run into situation where builder.mirror or other operation # is applied twice during one run to the same element @@ -445,57 +497,62 @@ class ShapeBuilder: return processed_objects if multiple_objects else processed_objects[0] def rotate_2d_point( - self, point_2d: Vector, angle=90, pivot_point: Vector = Vector((0.0, 0.0)).freeze(), counter_clockwise=False - ) -> Vector: - # > angle - in degrees - # < rotated Vector - - angle_rad = angle / 180 * pi * (1 if counter_clockwise else -1) - relative_point = point_2d - pivot_point - relative_point = Matrix.Rotation(angle_rad, 2, "Z") @ relative_point - point_2d = relative_point + pivot_point - return point_2d + self, + point_2d: VectorType, + angle: float = 90.0, + pivot_point: VectorType = (0.0, 0.0), + counter_clockwise: bool = False, + ) -> np.ndarray: + angle_rad = radians(angle) * (1 if counter_clockwise else -1) + relative_point = np.array(point_2d) - pivot_point + relative_point = np_rotation_matrix(angle_rad, 2) @ relative_point + final_point = relative_point + pivot_point + return final_point def rotate( self, - curve_or_item: Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]], - angle: float = 90, - pivot_point: Vector = Vector((0.0, 0.0)).freeze(), + curve_or_item: Union[ifcopenshell.entity_instance, Sequence[ifcopenshell.entity_instance]], + angle: float = 90.0, + pivot_point: VectorType = (0.0, 0.0), counter_clockwise: bool = False, create_copy: bool = False, ) -> Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: - # > curve_or_item - could be a list of curves or items - # > angle - in degrees - # < returns rotated object + """Rotate curve/representaiton item/representation. + + :param curve_or_item: A single item to rotate or a sequence of them. + :param angle: Rotation angle, in degrees. + :param pivot_point: Rotation pivot point. + :param counter_clockwise: Whether rotation is counter-clockwise. + :param create_copy: Whether to rotate the provided item or it's copy. + :return: Rotated curve/representaiton item/representation or a sequence of them. + """ multiple_objects = isinstance(curve_or_item, collections.abc.Iterable) if not multiple_objects: curve_or_item = [curve_or_item] - processed_objects = [] + processed_objects: list[ifcopenshell.entity_instance] = [] for c in curve_or_item: if create_copy: c = ifcopenshell.util.element.copy_deep(self.file, c) if c.is_a() in ("IfcIndexedPolyCurve", "IfcPolyline"): original_coords = self.get_polyline_coords(c) - coords = [ - self.rotate_2d_point(Vector(co), angle, pivot_point, counter_clockwise) for co in original_coords - ] + coords = [self.rotate_2d_point(co, angle, pivot_point, counter_clockwise) for co in original_coords] self.set_polyline_coords(c, coords) elif c.is_a("IfcCircle"): - base_position = Vector(c.Position.Location.Coordinates) + base_position = c.Position.Location.Coordinates new_position = self.rotate_2d_point(base_position, angle, pivot_point, counter_clockwise) - c.Position.Location.Coordinates = new_position + c.Position.Location.Coordinates = ifc_safe_vector_type(new_position) elif c.is_a("IfcExtrudedAreaSolid"): # TODO: add support for Z-axis too - base_position = Vector(c.Position.Location.Coordinates) - new_position = self.rotate_2d_point(base_position.to_2d(), angle, pivot_point, counter_clockwise) - new_position = new_position.to_3d() - new_position.z = base_position.z - c.Position.Location.Coordinates = new_position + base_position = c.Position.Location.Coordinates + new_position = self.rotate_2d_point(base_position[:2], angle, pivot_point, counter_clockwise) + new_position = np_to_3d(new_position) + new_position[2] = base_position[2] + c.Position.Location.Coordinates = ifc_safe_vector_type(new_position) # TODO: add inner axis too and test it self.rotate(c.SweptArea.OuterCurve, angle, pivot_point, counter_clockwise) diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 0164315c0a..e99e87d23d 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -20,9 +20,30 @@ import pytest import test.bootstrap import ifcopenshell.api import numpy as np -from ifcopenshell.util.shape_builder import ShapeBuilder, V, is_x +from ifcopenshell.util.shape_builder import ShapeBuilder, V, is_x, np_rotation_matrix from math import degrees, radians, tan -from mathutils import Vector +from mathutils import Vector, Matrix + + +class TestNumpyRotationMatrix(test.bootstrap.IFC4): + def test_run(self): + # 2D. + assert np.allclose(Matrix.Rotation(radians(45), 2), np_rotation_matrix(radians(45), 2)) + assert np.allclose(Matrix.Rotation(radians(45), 2, "Z"), np_rotation_matrix(radians(45), 2, "Z")) + + # 3D. + assert np.allclose(Matrix.Rotation(radians(45), 3, "X"), np_rotation_matrix(radians(45), 3, "X")) + assert np.allclose(Matrix.Rotation(radians(45), 3, "Y"), np_rotation_matrix(radians(45), 3, "Y")) + assert np.allclose(Matrix.Rotation(radians(45), 3, "Z"), np_rotation_matrix(radians(45), 3, "Z")) + rotation_vector_args = radians(45), 3, Vector((1, 1, 1)).normalized() + assert np.allclose(Matrix.Rotation(*rotation_vector_args), np_rotation_matrix(*rotation_vector_args)) + + # Size 4. + assert np.allclose(Matrix.Rotation(radians(45), 4, "X"), np_rotation_matrix(radians(45), 4, "X")) + assert np.allclose(Matrix.Rotation(radians(45), 4, "Y"), np_rotation_matrix(radians(45), 4, "Y")) + assert np.allclose(Matrix.Rotation(radians(45), 4, "Z"), np_rotation_matrix(radians(45), 4, "Z")) + rotation_vector_args = radians(45), 4, Vector((1, 1, 1)).normalized() + assert np.allclose(Matrix.Rotation(*rotation_vector_args), np_rotation_matrix(*rotation_vector_args)) class TestRectangle(test.bootstrap.IFC4):