diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 404512d943..5197941ed9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,13 +269,6 @@ jobs: pip install -e ../ifctester --no-deps cd ../ifctester && make test || fail "ifctester" make build-ids-docs || fail "build-ids-docs" - # Run mathutils related tests at the end to ensure no other code is relying on mathutils. - # mathutils only has pre-built wheels for Python 3.13+; skip on older versions. - cd ../ifcopenshell-python - if python -c "import sys; sys.exit(0 if sys.version_info >= (3, 13) else 1)"; then - pip install mathutils - make test-mathutils || fail "mathutils" - fi if [ $ERROR -ne 0 ]; then echo "One or more tests failed"; exit 1; diff --git a/src/bonsai/test/tool/test_shape_builder.py b/src/bonsai/test/tool/test_shape_builder.py new file mode 100644 index 0000000000..f1dc235d99 --- /dev/null +++ b/src/bonsai/test/tool/test_shape_builder.py @@ -0,0 +1,129 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult , @Andrej730 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +""" +These tests actually belong to the `ifcopenshell.util.shape_builder` test suite +(ifcopenshell-python/test/util/test_shape_builder.py) but live here because they need +`mathutils`. + +`mathutils` is available from PyPI either as part of `bpy` or `mathutils` package, +but those are not widely used and there were issues in the past with these going out of date. +Testing against them would also require separately building ifcopenshell for targeted Blender's Python version +(usually for tests we use the minimal Python version supported instead). + +Therefore we just test it against the real `mathutils` inside Blender. +""" + +from math import radians + +import numpy as np +from ifcopenshell.util.shape_builder import ( + V, + is_x, + np_angle, + np_angle_signed, + np_intersect_line_line, + np_matrix_to_euler, + np_normal, + np_rotation_matrix, +) + +from test.bim.bootstrap import NewFile + + +class TestMathutilsCompatibleMethods(NewFile): + def test_np_rotation_matrix(self): + from mathutils import Matrix, Vector + + # 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)) + + def test_np_matrix_to_euler(self): + from mathutils import Euler + + # Test 3x3. + rot = Euler((0.5, 0.5, 0.5)).to_matrix() + assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) + + rot = rot.to_4x4() + assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) + + # Ensure support scaled matrices. + rot = Euler((0.5, 0.5, 0.5)).to_matrix() + rot.col[0] *= 2 + assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) + + 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) diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 83fe17c84d..3d3c8005e6 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -71,16 +71,12 @@ build-urls: .PHONY: test test: - pytest --import-mode=importlib -p no:pytest-blender test --ignore=test/util/test_shape_builder.py + pytest --import-mode=importlib -p no:pytest-blender test .PHONY: test-parallel test-parallel: @NPROCS=$$(nproc 2>/dev/null || sysctl -n hw.ncpu); \ - pytest --import-mode=importlib -p no:pytest-blender -n $$NPROCS test --ignore=test/util/test_shape_builder.py - -.PHONY: test-mathutils -test-mathutils: - pytest --import-mode=importlib -p no:pytest-blender test/util/test_shape_builder.py + pytest --import-mode=importlib -p no:pytest-blender -n $$NPROCS test .PHONY: license diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/running_tests.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/running_tests.rst index 7692b3371f..2e8e1fac7f 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/running_tests.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/running_tests.rst @@ -31,11 +31,10 @@ This runs: .. code-block:: bash - pytest -p no:pytest-blender test --ignore=test/util/test_shape_builder.py + pytest -p no:pytest-blender test The ``pytest-blender`` plugin is disabled because these are IfcOpenShell-Python -tests, not Bonsai Blender tests. The shape builder tests are split into a -separate target because they require Blender's ``mathutils`` package. +tests, not Bonsai Blender tests. Parallel tests -------------- @@ -53,12 +52,9 @@ This automatically uses the available CPU count and runs the same tests as Shape builder tests ------------------- -The shape builder tests require ``mathutils``. Run them separately: - -.. code-block:: bash - - pip install mathutils - make test-mathutils +Most of ``ifcopenshell.util.shape_builder`` is covered here, but the tests +that check its ``mathutils``-compatible helpers live in the Bonsai test suite instead, +since they need Blender's ``mathutils`` package. See ``src/bonsai/test/tool/test_shape_builder.py``. Running individual tests ------------------------ diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index d024ffc13a..f28d134338 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -from math import degrees, radians, sqrt +from math import degrees, sqrt from typing import Any, Union import numpy as np @@ -31,11 +31,6 @@ from ifcopenshell.util.shape_builder import ( arc_to_polyline_points, is_x, np_angle, - np_angle_signed, - np_intersect_line_line, - np_matrix_to_euler, - np_normal, - np_rotation_matrix, np_to_3d, polygonal_face_set_to_faceted_brep, ) @@ -147,89 +142,6 @@ class TestPolygonalFaceSetToFacetedBrep(test.bootstrap.IFC4): polygonal_face_set_to_faceted_brep(face_set) -class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): - def test_np_rotation_matrix(self): - from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] - - # 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)) - - def test_np_matrix_to_euler(self): - from mathutils import Euler # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] - - # Test 3x3. - rot = Euler((0.5, 0.5, 0.5)).to_matrix() - assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) - - rot = rot.to_4x4() - assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) - - # Ensure support scaled matrices. - rot = Euler((0.5, 0.5, 0.5)).to_matrix() - rot.col[0] *= 2 - assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) - - def test_np_angle(self): - from mathutils import Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] - - 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 # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] - - 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 # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] - - 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): builder = ShapeBuilder(self.file)