Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -31,6 +28,7 @@ jobs:
test-python:
name: Python Tests
runs-on: trueform-linux
environment: tests

steps:
- uses: actions/checkout@v4
Expand Down
8 changes: 5 additions & 3 deletions docs/content/py/2.modules/4.geometry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 4 additions & 2 deletions python/src/trueform/_geometry/measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
-------
Expand All @@ -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.
Expand All @@ -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
-------
Expand Down
145 changes: 145 additions & 0 deletions python/tests/test_measurements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ==============================================================================
Expand Down
25 changes: 25 additions & 0 deletions python/tests/test_triangulated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ==============================================================================
Expand Down