diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 81045bf1fe..ab7c3700b3 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -91,6 +91,10 @@ def np_normalized(v: VectorType) -> np.ndarray: return np.divide(v, np.linalg.norm(v)) +def np_lerp(a: VectorType, b: VectorType, t: float) -> np.ndarray: + return a + np.subtract(b, a) * t + + def np_to_3d(v: VectorType, z: float = 0.0) -> np.ndarray: """Convert 2D/4D vector to 3D.""" l = len(v) @@ -125,6 +129,16 @@ def np_angle(a: VectorType, b: VectorType) -> float: return np.arccos(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) +def np_angle_signed(a: VectorType, b: VectorType) -> float: + """Get signed angle between 2D vectors in radians (clockwise is positive). + Designed to work similar to `Vector.angle_signed`. + """ + assert len(a) == 2 and len(b) == 2, "Only 2D vectors are supported." + det = a[1] * b[0] - a[0] * b[1] + dot = np.dot(a, b) + return np.arctan2(det, dot) + + def np_rotation_matrix( angle: float, size: int, axis: Optional[Union[Literal["X", "Y", "Z"], VectorType]] = None ) -> np.ndarray: @@ -165,6 +179,52 @@ def np_rotation_matrix( return matrix +def np_normal(vectors: SequenceOfVectors) -> np.ndarray: + """Normal of 3D Polygon. + + Designed to work similar to `mathutils.geometry.normal`. + """ + assert len(vectors) == 3, "3 vectors required" + # TODO: can be optimized? + verts_np = np.array(vectors[:3]) + v0, v1, v2 = verts_np[:3] + edge1 = v1 - v0 + edge2 = v2 - v0 + normal = np.cross(edge1, edge2) + norm = np.linalg.norm(normal) + return normal / norm + + +def np_intersect_line_line( + v1: VectorType, v2: VectorType, v3: VectorType, v4: VectorType +) -> tuple[np.ndarray, np.ndarray]: + """Get 2 closest points on each line. + First line - (v1, v2). Second line - (v3, v4). + + Designed to work similar to `mathutils.geometry.intersect_line_line`. + """ + # TODO: could be optimized? + d1 = np.subtract(v2, v1) + d2 = np.subtract(v4, v3) + + # Cross product of the directions + cross_d1_d2 = np.cross(d1, d2) + cross_d1_d2_norm: float = np.linalg.norm(cross_d1_d2) + + # Check if the lines are parallel. + if is_x(cross_d1_d2_norm, 0): + raise ValueError("Lines are parallel and do not intersect uniquely.") + + r = np.subtract(v3, v1) + t = np.dot(np.cross(r, d2), cross_d1_d2) / (cross_d1_d2_norm**2) + u = np.dot(np.cross(r, d1), cross_d1_d2) / (cross_d1_d2_norm**2) + + # Closest points on each line + point_on_line1 = v1 + t * d1 + point_on_line2 = v3 + u * d2 + return point_on_line1, point_on_line2 + + # 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 diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 650311565b..019fc50d3a 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -20,13 +20,23 @@ import pytest import test.bootstrap import ifcopenshell.api import numpy as np -from ifcopenshell.util.shape_builder import ShapeBuilder, is_x, np_rotation_matrix, np_to_3d, np_angle, V +from ifcopenshell.util.shape_builder import ( + ShapeBuilder, + is_x, + np_rotation_matrix, + np_to_3d, + np_angle, + V, + np_angle_signed, + np_normal, + np_intersect_line_line, +) from math import degrees, radians from typing import Any, Union -class TestNumpyRotationMatrix(test.bootstrap.IFC4): - def test_run(self): +class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): + def test_np_rotation_matrix(self): from mathutils import Matrix, Vector # 2D. @@ -47,6 +57,51 @@ class TestNumpyRotationMatrix(test.bootstrap.IFC4): 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)) + def test_np_angle(self): + from mathutils import Vector + + v1, v2 = (1, 0, 0), (0, 1, 0) + angle = np_angle(v1, v2) + assert is_x(angle, Vector(v1).angle(Vector(v2))) + assert is_x(angle, radians(90)) + + v1, v2 = v1[:2], v2[:2] + angle = np_angle_signed(v1, v2) + assert is_x(angle, Vector(v1).angle_signed(Vector(v2))) + assert is_x(angle, -radians(90)) + + v1, v2 = (0, 1, 0), (1, 0, 0) + angle = np_angle(v1, v2) + assert is_x(angle, Vector(v1).angle(Vector(v2))) + assert is_x(angle, radians(90)) + + v1, v2 = v1[:2], v2[:2] + angle = np_angle_signed(v1, v2) + assert is_x(angle, Vector(v1).angle_signed(Vector(v2))) + assert is_x(angle, radians(90)) + + def test_np_normal(self): + import mathutils.geometry + + vectors = (0, 0, 0), (1, 0, 0), (0, 1, 0) + n = mathutils.geometry.normal(vectors) + assert np.allclose(n, np_normal(vectors)) + assert np.allclose(n, (0, 0, 1)) + + vectors = (0, 0, 0), (0, 1, 0), (1, 0, 0) + n = mathutils.geometry.normal(vectors) + assert np.allclose(n, np_normal(vectors)) + assert np.allclose(n, (0, 0, -1)) + + def test_np_intersect_line_line(self): + import mathutils.geometry + + p1, p2 = [0, 0, 0], [1, 1, 1] + q1, q2 = [0, 1, 0], [1, 0, 1] + expected = mathutils.geometry.intersect_line_line(tuple(p1), tuple(p2), tuple(q1), tuple(q2)) + result = np_intersect_line_line(p1, p2, q1, q2) + assert np.allclose(expected, result) + class TestRectangle(test.bootstrap.IFC4): def test_get_rectangle_coords(self):