diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5547684..76e1292 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,21 +1,18 @@ name: Tests on: - push: - branches: [main] - paths-ignore: - - 'docs/**' - - '*.md' pull_request: branches: [main] paths-ignore: - 'docs/**' - '*.md' + workflow_dispatch: jobs: test-cpp: name: C++ Tests runs-on: trueform-linux + environment: tests steps: - uses: actions/checkout@v4 @@ -31,6 +28,7 @@ jobs: test-python: name: Python Tests runs-on: trueform-linux + environment: tests steps: - uses: actions/checkout@v4 diff --git a/docs/content/py/2.modules/4.geometry.md b/docs/content/py/2.modules/4.geometry.md index a9c60c1..1ee4bbf 100644 --- a/docs/content/py/2.modules/4.geometry.md +++ b/docs/content/py/2.modules/4.geometry.md @@ -127,9 +127,11 @@ sv_flipped = tf.signed_volume((faces[:, ::-1], points)) # -24.0 | **Function** | **Input** | **Description** | |--------------|-----------|-----------------| -| `area` | `ndarray`, `Polygon`, `Mesh`, or tuple | Area of polygon or total surface area | -| `volume` | `Mesh` or tuple (3D only) | Absolute volume of closed mesh | -| `signed_volume` | `Mesh` or tuple (3D only) | Signed volume (orientation-dependent) | +| `area` | `ndarray`, `Polygon`, `Mesh`, or `(faces, points)` tuple | Area of polygon or total surface area | +| `volume` | `Mesh` or `(faces, points)` tuple (3D only) | Absolute volume of closed mesh | +| `signed_volume` | `Mesh` or `(faces, points)` tuple (3D only) | Signed volume (orientation-dependent) | + +Tuple inputs accept both `ndarray` and `OffsetBlockedArray` for faces. ::note{icon="i-lucide-info"} Volume functions require 3D meshes. Signed volume is positive when face normals point outward (CCW winding). diff --git a/python/src/trueform/_geometry/measurements.py b/python/src/trueform/_geometry/measurements.py index c24bc13..9d59843 100644 --- a/python/src/trueform/_geometry/measurements.py +++ b/python/src/trueform/_geometry/measurements.py @@ -17,7 +17,7 @@ def signed_volume( - data: Union[Mesh, Tuple[np.ndarray, np.ndarray]] + data: Union[Mesh, Tuple[np.ndarray, np.ndarray], Tuple[OffsetBlockedArray, np.ndarray]] ) -> float: """ Compute signed volume of a closed 3D mesh. @@ -31,6 +31,7 @@ def signed_volume( data : Mesh or tuple - Mesh: tf.Mesh object (must be 3D) - (faces, points): Tuple with face indices and point coordinates + - (OffsetBlockedArray, points): Dynamic polygon mesh Returns ------- @@ -54,7 +55,7 @@ def signed_volume( def volume( - data: Union[Mesh, Tuple[np.ndarray, np.ndarray]] + data: Union[Mesh, Tuple[np.ndarray, np.ndarray], Tuple[OffsetBlockedArray, np.ndarray]] ) -> float: """ Compute volume of a closed 3D mesh. @@ -66,6 +67,7 @@ def volume( data : Mesh or tuple - Mesh: tf.Mesh object (must be 3D) - (faces, points): Tuple with face indices and point coordinates + - (OffsetBlockedArray, points): Dynamic polygon mesh Returns ------- diff --git a/python/tests/test_measurements.py b/python/tests/test_measurements.py index 273443f..2aef4bc 100644 --- a/python/tests/test_measurements.py +++ b/python/tests/test_measurements.py @@ -388,6 +388,151 @@ def test_volume_requires_3d(): tf.volume((faces, points)) +# ============================================================================== +# Dynamic Mesh (OffsetBlockedArray) Tests +# ============================================================================== + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_area_dynamic_mesh_tuple(dtype, index_dtype): + """Area works with (OffsetBlockedArray, points) tuple.""" + # Create a simple mesh: two triangles forming a square + offsets = np.array([0, 3, 6], dtype=index_dtype) + data = np.array([0, 1, 2, 0, 2, 3], dtype=index_dtype) + dyn_faces = tf.OffsetBlockedArray(offsets, data) + + points = np.array([ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0] + ], dtype=dtype) + + computed = tf.area((dyn_faces, points)) + expected = 1.0 # Unit square + np.testing.assert_allclose(computed, expected, rtol=1e-5) + + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_volume_dynamic_mesh_tuple(dtype, index_dtype): + """Volume works with (OffsetBlockedArray, points) tuple.""" + # Create box mesh and convert to dynamic + faces, points = tf.make_box_mesh( + 2.0, 3.0, 4.0, dtype=dtype, index_dtype=index_dtype) + + # Convert to OffsetBlockedArray + dyn_faces = tf.as_offset_blocked(faces) + + computed = tf.volume((dyn_faces, points)) + expected = 2.0 * 3.0 * 4.0 + np.testing.assert_allclose(computed, expected, rtol=1e-5) + + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_signed_volume_dynamic_mesh_tuple(dtype, index_dtype): + """Signed volume works with (OffsetBlockedArray, points) tuple.""" + # Create box mesh and convert to dynamic + faces, points = tf.make_box_mesh( + 2.0, 3.0, 4.0, dtype=dtype, index_dtype=index_dtype) + + # Convert to OffsetBlockedArray + dyn_faces = tf.as_offset_blocked(faces) + + computed = tf.signed_volume((dyn_faces, points)) + expected = 2.0 * 3.0 * 4.0 + np.testing.assert_allclose(computed, expected, rtol=1e-5) + assert computed > 0, "Outward normals should give positive volume" + + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_area_dynamic_mesh_object(dtype, index_dtype): + """Area works with Mesh object created from OffsetBlockedArray.""" + faces, points = tf.make_box_mesh( + 2.0, 3.0, 4.0, dtype=dtype, index_dtype=index_dtype) + dyn_faces = tf.as_offset_blocked(faces) + mesh = tf.Mesh(dyn_faces, points) + + computed = tf.area(mesh) + expected = 2 * (2.0 * 3.0 + 3.0 * 4.0 + 2.0 * 4.0) + np.testing.assert_allclose(computed, expected, rtol=1e-5) + + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_volume_dynamic_mesh_object(dtype, index_dtype): + """Volume works with Mesh object created from OffsetBlockedArray.""" + faces, points = tf.make_box_mesh( + 2.0, 3.0, 4.0, dtype=dtype, index_dtype=index_dtype) + dyn_faces = tf.as_offset_blocked(faces) + mesh = tf.Mesh(dyn_faces, points) + + computed = tf.volume(mesh) + expected = 2.0 * 3.0 * 4.0 + np.testing.assert_allclose(computed, expected, rtol=1e-5) + + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_signed_volume_dynamic_mesh_object(dtype, index_dtype): + """Signed volume works with Mesh object created from OffsetBlockedArray.""" + faces, points = tf.make_box_mesh( + 2.0, 3.0, 4.0, dtype=dtype, index_dtype=index_dtype) + dyn_faces = tf.as_offset_blocked(faces) + mesh = tf.Mesh(dyn_faces, points) + + computed = tf.signed_volume(mesh) + expected = 2.0 * 3.0 * 4.0 + np.testing.assert_allclose(computed, expected, rtol=1e-5) + assert computed > 0, "Outward normals should give positive volume" + + +@pytest.mark.parametrize("dtype", REAL_DTYPES) +def test_area_dynamic_mesh_mixed_ngons(dtype): + """Area works with mixed n-gon dynamic mesh.""" + # Triangle + quad + pentagon (all in z=0 plane) + offsets = np.array([0, 3, 7, 12], dtype=np.int32) + data = np.array([ + 0, 1, 2, # triangle + 3, 4, 5, 6, # quad + 7, 8, 9, 10, 11 # pentagon + ], dtype=np.int32) + dyn_faces = tf.OffsetBlockedArray(offsets, data) + + # Triangle: vertices at (0,0), (1,0), (0.5, 0.866) -> area ≈ 0.433 + # Quad: unit square -> area = 1.0 + # Pentagon: regular pentagon with r=1 -> area ≈ 2.377 + points = np.array([ + # Triangle (equilateral, side=1) + [0, 0, 0], + [1, 0, 0], + [0.5, np.sqrt(3)/2, 0], + # Quad (unit square) + [2, 0, 0], + [3, 0, 0], + [3, 1, 0], + [2, 1, 0], + # Pentagon (regular, r=1) + [5 + np.cos(0), np.sin(0), 0], + [5 + np.cos(2*np.pi/5), np.sin(2*np.pi/5), 0], + [5 + np.cos(4*np.pi/5), np.sin(4*np.pi/5), 0], + [5 + np.cos(6*np.pi/5), np.sin(6*np.pi/5), 0], + [5 + np.cos(8*np.pi/5), np.sin(8*np.pi/5), 0], + ], dtype=dtype) + + computed = tf.area((dyn_faces, points)) + + # Manual calculation + triangle_area = np.sqrt(3) / 4 # equilateral triangle side=1 + quad_area = 1.0 + pentagon_area = (5/2) * 1**2 * np.sin(2*np.pi/5) # regular pentagon r=1 + + expected = triangle_area + quad_area + pentagon_area + np.testing.assert_allclose(computed, expected, rtol=1e-4) + + # ============================================================================== # 2D Mesh Area Tests # ============================================================================== diff --git a/python/tests/test_triangulated.py b/python/tests/test_triangulated.py index 730b7a8..359c8fe 100644 --- a/python/tests/test_triangulated.py +++ b/python/tests/test_triangulated.py @@ -293,6 +293,31 @@ def test_dynamic_2d(dtype): assert points.shape == (4, 2), f"Expected (4, 2), got {points.shape}" +@pytest.mark.parametrize("dtype", REAL_DTYPES) +@pytest.mark.parametrize("index_dtype", INDEX_DTYPES) +def test_dynamic_mesh_object(dtype, index_dtype): + """Triangulate Mesh object created from OffsetBlockedArray.""" + offsets = np.array([0, 4, 8], dtype=index_dtype) + data = np.array([0, 1, 2, 3, 1, 4, 5, 2], dtype=index_dtype) + dyn_faces = tf.OffsetBlockedArray(offsets, data) + + points_in = np.array([ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + [2, 0, 0], + [2, 1, 0] + ], dtype=dtype) + + mesh = tf.Mesh(dyn_faces, points_in) + faces, points = tf.triangulated(mesh) + + # 2 quads -> 4 triangles + assert faces.shape == (4, 3), f"Expected (4, 3), got {faces.shape}" + assert points.shape == (6, 3), f"Expected (6, 3), got {points.shape}" + + # ============================================================================== # Correctness Tests # ==============================================================================