"""geometry/subdivide.py の仕様ベーステスト.
仕様出典:
- src/sm_tias_lightsim/geometry/subdivide.py (subdivide_mesh_to_max_edge docstring)
- CLAUDE.md 実装サマリー(--bake-subdivide 機能)
テスト方針(GUIDE_08 準拠):
- 純関数の単体テスト(pyvista / Mitsuba 不要)
- trimesh 依存のため trimesh をインポートしてテスト
- 決定論性・dtype・インデックス整合性を必ず検証する
"""
from __future__ import annotations
import numpy as np
import pytest
from sm_tias_lightsim.geometry.subdivide import subdivide_mesh_to_max_edge
# ===========================================================================
# フィクスチャ
# ===========================================================================
@pytest.fixture
def unit_triangle_m():
"""1 m × 1 m の正三角形(m 単位,辺長 ≈1 m)."""
vertices = np.array(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]],
dtype=np.float64,
)
faces = np.array([[0, 1, 2]], dtype=np.int64)
return vertices, faces
@pytest.fixture
def small_triangle_m():
"""辺長 5 mm(0.005 m)の小さな三角形(m 単位)."""
vertices = np.array(
[[0.0, 0.0, 0.0], [0.005, 0.0, 0.0], [0.0, 0.005, 0.0]],
dtype=np.float64,
)
faces = np.array([[0, 1, 2]], dtype=np.int64)
return vertices, faces
@pytest.fixture
def quad_mesh_m():
"""2 つの大きな三角形で構成される四角形メッシュ(各辺 1 m)."""
vertices = np.array(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
dtype=np.float64,
)
faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64)
return vertices, faces
# ===========================================================================
# 1. エラー処理
# ===========================================================================
class TestSubdivideErrors:
"""max_edge バリデーションのテスト."""
def test_max_edge_zero_raises_value_error(self, unit_triangle_m):
"""max_edge=0 のとき ValueError が発生すること."""
verts, faces = unit_triangle_m
with pytest.raises(ValueError, match="正の値"):
subdivide_mesh_to_max_edge(verts, faces, 0.0)
def test_max_edge_negative_raises_value_error(self, unit_triangle_m):
"""max_edge<0 のとき ValueError が発生すること."""
verts, faces = unit_triangle_m
with pytest.raises(ValueError, match="正の値"):
subdivide_mesh_to_max_edge(verts, faces, -1.0)
def test_max_edge_very_small_negative_raises(self, unit_triangle_m):
"""max_edge が微小負値(-1e-15)でも ValueError が発生すること."""
verts, faces = unit_triangle_m
with pytest.raises(ValueError):
subdivide_mesh_to_max_edge(verts, faces, -1e-15)
# ===========================================================================
# 2. 分割の基本動作
# ===========================================================================
class TestSubdivideBasicBehavior:
"""分割の基本動作テスト."""
def test_large_triangle_face_count_increases(self, unit_triangle_m):
"""辺長 1 m の三角形を max_edge=0.3 m で分割すると face 数が増えること."""
verts, faces = unit_triangle_m
new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
assert len(new_faces) > 1, f"face 数が増えること: {len(new_faces)}"
def test_small_triangle_not_subdivided(self, small_triangle_m):
"""辺長 5 mm の三角形を max_edge=10 mm で分割すると face 数が変わらないこと."""
verts, faces = small_triangle_m
new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.010)
# すでに辺長が max_edge 以下なので分割なし
assert len(new_faces) == 1
def test_more_subdivision_more_faces(self, unit_triangle_m):
"""max_edge を小さくするほど face 数が多くなること."""
verts, faces = unit_triangle_m
_, faces_coarse = subdivide_mesh_to_max_edge(verts, faces, 0.5)
_, faces_fine = subdivide_mesh_to_max_edge(verts, faces, 0.2)
assert len(faces_fine) >= len(faces_coarse), (
f"細かい分割の方が face 数が多いこと: coarse={len(faces_coarse)}, fine={len(faces_fine)}"
)
def test_quad_mesh_both_faces_subdivided(self, quad_mesh_m):
"""2 面のメッシュで両面が分割されること(face 数 > 2)."""
verts, faces = quad_mesh_m
new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
assert len(new_faces) > 2
# ===========================================================================
# 3. 出力の整合性
# ===========================================================================
class TestSubdivideOutputIntegrity:
"""出力配列の整合性テスト."""
def test_output_vertices_dtype_float64(self, unit_triangle_m):
"""出力頂点配列の dtype が float64 であること."""
verts, faces = unit_triangle_m
new_verts, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3)
assert new_verts.dtype == np.float64
def test_output_faces_dtype_int64(self, unit_triangle_m):
"""出力面配列の dtype が int64 であること."""
verts, faces = unit_triangle_m
_, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
assert new_faces.dtype == np.int64
def test_vertices_shape_is_n_by_3(self, unit_triangle_m):
"""出力頂点の shape が (N_v', 3) であること."""
verts, faces = unit_triangle_m
new_verts, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3)
assert new_verts.ndim == 2
assert new_verts.shape[1] == 3
def test_faces_shape_is_n_by_3(self, unit_triangle_m):
"""出力面の shape が (N_f', 3) であること."""
verts, faces = unit_triangle_m
_, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
assert new_faces.ndim == 2
assert new_faces.shape[1] == 3
def test_face_indices_in_vertex_bounds(self, unit_triangle_m):
"""出力面のインデックスが頂点配列の範囲内であること."""
verts, faces = unit_triangle_m
new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces, 0.3)
n_verts = len(new_verts)
assert new_faces.min() >= 0, f"最小インデックス >= 0: {new_faces.min()}"
assert new_faces.max() < n_verts, (
f"最大インデックス < n_verts: {new_faces.max()} < {n_verts}"
)
def test_float32_input_vertices_work(self, unit_triangle_m):
"""float32 の頂点配列を渡しても動作し、float64 で返ること(内部でキャスト)."""
verts, faces = unit_triangle_m
verts_f32 = verts.astype(np.float32)
new_verts, new_faces = subdivide_mesh_to_max_edge(verts_f32, faces, 0.3)
assert new_verts.dtype == np.float64 # 内部で float64 にキャスト
def test_int32_input_faces_work(self, unit_triangle_m):
"""int32 の面配列を渡しても動作し、int64 で返ること(内部でキャスト)."""
verts, faces = unit_triangle_m
faces_i32 = faces.astype(np.int32)
new_verts, new_faces = subdivide_mesh_to_max_edge(verts, faces_i32, 0.3)
assert new_faces.dtype == np.int64
# ===========================================================================
# 4. 決定論性
# ===========================================================================
class TestSubdivideDeterminism:
"""同一入力に対して常に同一の出力を返すこと(決定論性)."""
def test_same_input_same_output_vertices(self, unit_triangle_m):
"""同一入力を 2 回渡すと同一の頂点を返すこと."""
verts, faces = unit_triangle_m
v1, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3)
v2, _ = subdivide_mesh_to_max_edge(verts, faces, 0.3)
np.testing.assert_array_equal(v1, v2)
def test_same_input_same_output_faces(self, unit_triangle_m):
"""同一入力を 2 回渡すと同一の面を返すこと."""
verts, faces = unit_triangle_m
_, f1 = subdivide_mesh_to_max_edge(verts, faces, 0.3)
_, f2 = subdivide_mesh_to_max_edge(verts, faces, 0.3)
np.testing.assert_array_equal(f1, f2)
def test_different_max_edge_different_output(self, unit_triangle_m):
"""異なる max_edge では異なる出力を返すこと."""
verts, faces = unit_triangle_m
_, f1 = subdivide_mesh_to_max_edge(verts, faces, 0.5)
_, f2 = subdivide_mesh_to_max_edge(verts, faces, 0.2)
# face 数が異なるか、または同じでも配列が異なること
assert not (len(f1) == len(f2) and np.array_equal(f1, f2))
# ===========================================================================
# 5. geometry パッケージの公開 API
# ===========================================================================
class TestGeometryPackageExport:
"""geometry パッケージからの公開 API テスト."""
def test_import_from_geometry_package(self):
"""geometry パッケージから subdivide_mesh_to_max_edge をインポートできること."""
from sm_tias_lightsim.geometry import subdivide_mesh_to_max_edge as f
assert callable(f)
def test_geometry_all_includes_subdivide(self):
"""geometry パッケージの __all__ に subdivide_mesh_to_max_edge が含まれること."""
import sm_tias_lightsim.geometry as geo
assert "subdivide_mesh_to_max_edge" in geo.__all__