"""projection.py compute_inward_face_mask の仕様ベーステスト (Phase 2).
仕様出典:
- src/sm_tias_lightsim/visualize/projection.py (compute_inward_face_mask docstring)
dot(face_normal, scene_center - face_centroid) > 0 で内向き True
巻き順依存ヒューリスティック (--no-cull-outward で回避可)
縮退面はゼロ法線 → ゼロ内積 → False (外向き扱い)
テスト対象:
compute_inward_face_mask(vertices_m, faces, scene_center) -> bool (N_f,)
テスト方針(testing.md):
- 純関数のため Mitsuba 不要・高速 (slow マーカーなし)
- 合成ジオメトリで巻き順と中心が既知のケースを検証
- 縮退面・境界ケースを明示テスト
"""
from __future__ import annotations
import numpy as np
from sm_tias_lightsim.visualize.projection import compute_inward_face_mask
# ===========================================================================
# 1. 基本的な内向き/外向き判定
# ===========================================================================
class TestComputeInwardFaceMaskBasic:
"""compute_inward_face_mask の基本的な内向き/外向き判定.
仕様:
dot(face_normal, scene_center - face_centroid) > 0 → 内向き True
dot <= 0 → 外向き False
"""
def test_face_pointing_toward_center_is_inward(self):
"""シーン中心を向く face が内向き True になること.
セットアップ:
単一の三角形。法線が +Z 方向(巻き順: v0→v1→v2 で +Z 法線)
face 重心 ≈ origin
scene_center = (0, 0, 1.0)(+Z 方向 = 法線と同じ方向)
→ to_center = center - centroid = (0,0,1) 方向
→ dot(+Z, +Z) > 0 → 内向き True
"""
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)
# face の法線は +Z 方向 (右手則: (v1-v0) x (v2-v0))
# (1,0,0) x (0.5,1,0) = (0*0-0*1, 0*0.5-1*0, 1*1-0*0.5) = (0,0,1) → +Z
scene_center = np.array([0.5, 0.333, 1.0]) # face 重心の +Z 側
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert mask.shape == (1,)
assert bool(mask[0]) is True, (
"シーン中心が法線方向にある場合、face は内向き True であること"
)
def test_face_pointing_away_from_center_is_outward(self):
"""シーン中心と逆方向を向く face が外向き False になること.
セットアップ:
単一の三角形(+Z 法線)
scene_center = (0, 0, -1.0)(-Z 方向 = 法線と逆方向)
→ to_center = -Z 方向
→ dot(+Z, -Z) < 0 → 外向き False
"""
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)
scene_center = np.array([0.5, 0.333, -1.0]) # -Z 側
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert bool(mask[0]) is False, (
"シーン中心が法線の逆方向にある場合、face は外向き False であること"
)
def test_box_inner_faces_are_inward(self):
"""シーン中心を囲む箱の内面(中心向き)が全て内向き True になること.
合成ジオメトリ: Z 軸方向に 2 枚の板(上下)を配置
上板(Z=+1)は下向き法線(-Z)→ 中心 Z=0 が -Z 側 → 内向き
下板(Z=-1)は上向き法線(+Z)→ 中心 Z=0 が +Z 側 → 内向き
ポイント: 巻き順を厳密に制御して正しい法線を作る
"""
# 上板(Z=+1):下向き法線 -Z を作る巻き順 v0→v2→v1
# cross((v2-v0), (v1-v0)) は上と逆方向、ここでは cross(v1-v0, v2-v0) = -Z が必要
# v0=(−1,−1,1), v1=(1,−1,1), v2=(0,1,1) の場合:
# (v1-v0)=(2,0,0), (v2-v0)=(1,2,0)
# cross = (0*0-0*2, 0*1-2*0, 2*2-0*1) = (0,0,4) → +Z
# 逆順: v0=(−1,−1,1), v1=(0,1,1), v2=(1,−1,1)
# (v1-v0)=(1,2,0), (v2-v0)=(2,0,0)
# cross = (2*0-0*0, 0*2-1*0, 1*0-2*2) = (0,0,-4) → -Z ✓
vertices = np.array([
# 上板: Z=+1(頂点 0,1,2 — 法線 -Z 向き)
[-1.0, -1.0, 1.0], # 0
[ 0.0, 1.0, 1.0], # 1
[ 1.0, -1.0, 1.0], # 2
# 下板: Z=-1(頂点 3,4,5 — 法線 +Z 向き)
[-1.0, -1.0, -1.0], # 3
[ 1.0, -1.0, -1.0], # 4
[ 0.0, 1.0, -1.0], # 5
], dtype=np.float64)
faces = np.array([
[0, 1, 2], # 上板: -Z 法線
[3, 4, 5], # 下板: +Z 法線
], dtype=np.int64)
# 確認: 上板法線が -Z であること
v0, v1, v2 = vertices[0], vertices[1], vertices[2]
n_top = np.cross(v1 - v0, v2 - v0)
assert n_top[2] < 0, f"上板の法線は -Z であるべき: {n_top}"
# 確認: 下板法線が +Z であること
v0, v1, v2 = vertices[3], vertices[4], vertices[5]
n_bot = np.cross(v1 - v0, v2 - v0)
assert n_bot[2] > 0, f"下板の法線は +Z であるべき: {n_bot}"
scene_center = np.array([0.0, 0.0, 0.0])
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert bool(mask[0]) is True, "上板(-Z 法線)はシーン中心(Z=0)側を向く → 内向き True"
assert bool(mask[1]) is True, "下板(+Z 法線)はシーン中心(Z=0)側を向く → 内向き True"
# ===========================================================================
# 2. 巻き順依存テスト
# ===========================================================================
class TestComputeInwardFaceMaskWindingOrder:
"""巻き順依存の仕様確認テスト.
仕様(docstring):
face 法線は STL の巻き順から cross(v1-v0, v2-v0) で算出するため
巻き順依存のヒューリスティック.
巻き順が逆なら内向き/外向きが反転する可能性がある.
"""
def test_reversed_winding_flips_inward_outward(self):
"""巻き順を逆にすると内向き/外向きが反転すること(巻き順依存の仕様確認).
仕様: face 法線は STL の巻き順依存 → 巻き順が揃っていない STL では
内向き/外向きが反転する可能性がある(ヒューリスティック).
"""
# 頂点 0=(0,0,0), 1=(1,0,0), 2=(0.5,1,0)
# 正巻き: [0,1,2] → cross((1,0,0),(0.5,1,0)) = (0,0,1) = +Z 法線
# 逆巻き: [0,2,1] → cross((0.5,1,0),(1,0,0)) = (0,0,-1) = -Z 法線
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_cw = np.array([[0, 1, 2]], dtype=np.int64) # +Z 法線
faces_ccw = np.array([[0, 2, 1]], dtype=np.int64) # -Z 法線
# scene_center が +Z 側にある場合
scene_center = np.array([0.5, 0.333, 1.0])
mask_cw = compute_inward_face_mask(vertices, faces_cw, scene_center)
mask_ccw = compute_inward_face_mask(vertices, faces_ccw, scene_center)
# 正巻き: +Z 法線 → center(+Z) に向く → 内向き True
assert bool(mask_cw[0]) is True
# 逆巻き: -Z 法線 → center(+Z) と逆向き → 外向き False
assert bool(mask_ccw[0]) is False
# 内向き/外向きが逆転していること(仕様: 巻き順依存のヒューリスティック)
assert bool(mask_cw[0]) != bool(mask_ccw[0]), (
"巻き順を逆にすると内向き/外向きが反転すること(巻き順依存の仕様)"
)
def test_winding_independent_geometry_is_not_guaranteed(self):
"""巻き順非依存性は保証されないことをドキュメントする(仕様注記).
compute_face_camera_mask とは異なり、compute_inward_face_mask は
巻き順依存である(仕様 docstring に明記).
このテストは「巻き順が変わると結果が変わる」という仕様を確認する.
"""
vertices = np.array([
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.5, 1.0, 0.0],
], dtype=np.float64)
scene_center = np.array([0.5, 0.333, 0.5]) # +Z 側
# 正順: +Z 法線 → 内向き True
mask_forward = compute_inward_face_mask(vertices, np.array([[0, 1, 2]]), scene_center)
# 逆順: -Z 法線 → 外向き False
mask_reverse = compute_inward_face_mask(vertices, np.array([[0, 2, 1]]), scene_center)
# 巻き順で結果が異なること(ヒューリスティックの証明)
assert not np.array_equal(mask_forward, mask_reverse), (
"巻き順依存のヒューリスティック: 巻き順変更で mask が変わること"
)
# ===========================================================================
# 3. 縮退面(ゼロ面積 face)の扱い
# ===========================================================================
class TestComputeInwardFaceMaskDegenerate:
"""縮退面(ゼロ面積 face)の扱いを検証する.
仕様(baker.py _compute_face_centroids_and_normals 参照):
norms = np.where(norms == 0.0, 1.0, norms)
→ ゼロ面積の face では raw_normals = (0,0,0) → normalized = (0,0,0)
compute_inward_face_mask では:
dot(zeros, to_center) = 0.0
inward_mask = dot > 0.0 = False → 外向き扱い
"""
def test_degenerate_face_is_outward(self):
"""縮退面(3頂点が同じ点)は外向き False になること.
仕様: ゼロ法線の face は dot = 0 → False(外向き扱い).
"""
# 3点が同一 → ゼロ面積
vertices = np.array([
[0.5, 0.5, 0.0], # 同じ点
[0.5, 0.5, 0.0],
[0.5, 0.5, 0.0],
], dtype=np.float64)
faces = np.array([[0, 1, 2]], dtype=np.int64)
scene_center = np.array([0.5, 0.5, 1.0]) # +Z 側
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert mask.shape == (1,)
# 縮退面: 法線 = ゼロベクトル → dot = 0 → False
assert bool(mask[0]) is False, (
"縮退面(ゼロ面積)は dot=0 → 外向き False になること"
)
def test_degenerate_face_collinear_is_outward(self):
"""3頂点が一直線(1D 縮退)の face は外向き False になること."""
# 3点が一直線上
vertices = np.array([
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
], dtype=np.float64)
faces = np.array([[0, 1, 2]], dtype=np.int64)
scene_center = np.array([1.0, 1.0, 0.0])
mask = compute_inward_face_mask(vertices, faces, scene_center)
# 一直線 → cross = (0,0,0) → dot = 0 → False
assert bool(mask[0]) is False, (
"一直線の 3頂点(1D 縮退)は dot=0 → 外向き False になること"
)
# ===========================================================================
# 4. 戻り値の型・形状
# ===========================================================================
class TestComputeInwardFaceMaskReturnType:
"""compute_inward_face_mask の戻り値の型・形状を検証する."""
def test_returns_bool_array_of_n_faces(self):
"""戻り値が (N_f,) bool 配列であること.
仕様: bool 配列 (N_f,)
"""
vertices = np.array([
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.5, 1.0, 0.0],
[0.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[0.5, 1.0, 1.0],
], dtype=np.float64)
faces = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int64)
scene_center = np.array([0.5, 0.5, 0.5])
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert isinstance(mask, np.ndarray)
assert mask.shape == (2,)
assert mask.dtype == np.bool_ or mask.dtype == bool
def test_single_face_returns_shape_1(self):
"""単一 face のとき (1,) の配列が返ること."""
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)
scene_center = np.array([0.5, 0.333, 1.0])
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert mask.shape == (1,)
def test_many_faces_returns_correct_shape(self):
"""N_f 個の face に対して (N_f,) の配列が返ること."""
n_v = 12
n_f = 4
rng = np.random.default_rng(42)
vertices = rng.standard_normal((n_v, 3))
# 4 つの三角形(頂点インデックスが重複しない範囲で)
faces = np.array([
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[9, 10, 11],
], dtype=np.int64)
scene_center = np.zeros(3)
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert mask.shape == (n_f,)
# ===========================================================================
# 5. 解析解検証: 既知の dot 値から内向き/外向きを確認
# ===========================================================================
class TestComputeInwardFaceMaskAnalytical:
"""解析解と数値一致テスト.
仕様: dot(face_normal, scene_center - face_centroid) > 0 → 内向き True
"""
def test_analytical_dot_positive_is_inward(self):
"""dot > 0(解析値)の場合 True が返ること.
構成:
face: vertices=(0,0,0), (1,0,0), (0.5,1,0)
→ normal = (0,0,1) (+Z)
→ centroid = (0.5, 0.333, 0)
scene_center = (0.5, 0.333, 2.0)
→ to_center = (0, 0, 2.0)
→ dot(+Z, +Z*2) = 2.0 > 0 → True
"""
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)
# 解析: normal = (0,0,1), centroid = (0.5, 1/3, 0)
centroid = np.array([0.5, 1.0 / 3.0, 0.0])
scene_center = centroid + np.array([0.0, 0.0, 2.0]) # to_center = +Z*2
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert bool(mask[0]) is True
def test_analytical_dot_exactly_zero_is_outward(self):
"""dot == 0(scene_center が face 平面上)のとき False(外向き)であること.
仕様: inward_mask = dot > 0.0(ゼロ以下は外向き)
"""
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)
# scene_center が face 平面(Z=0)上 → to_center の Z 成分 = 0
# dot((0,0,1), (dx,dy,0)) = 0
centroid = np.array([0.5, 1.0 / 3.0, 0.0])
scene_center = centroid + np.array([1.0, 0.5, 0.0]) # to_center は XY 面内
mask = compute_inward_face_mask(vertices, faces, scene_center)
# dot = 0 → False(外向き扱い)
assert bool(mask[0]) is False, (
"dot=0(scene_center が face 平面上)のとき外向き False であること"
)
def test_multiple_faces_mixed_inward_outward(self):
"""複数 face で内向き/外向きが混在するケースを解析検証する.
構成: 3 枚の三角形(+Z, -Z, +X 法線)
scene_center = (0, 0, 0)
各 face が内向き/外向きのどちらかを明示的に計算して確認する.
"""
# face 0: z=+1, 法線 -Z (下向き) → centroid=(0.5,0.5,1), to_center=(-, -, -1)
# dot(-Z, -Z*1) = 1 > 0 → 内向き True
# face 1: z=-1, 法線 +Z (上向き) → centroid=(0.5,0.5,-1), to_center=(-, -, +1)
# dot(+Z, +Z*1) = 1 > 0 → 内向き True
# face 2: x=+1, 法線 -X (内向き?) → centroid=(1,0.5,0.5), to_center=(-1, -, -)
# dot(-X, -X*1) = 1 > 0 → 内向き True
# シンプルに: +Z 法線 face を scene_center より遠い側に置く → to_center と法線が逆
vertices = np.array([
# face 0: z=-1, 法線 +Z (to_center の z 成分 = 0-(-1) = +1 → dot > 0 → True)
[0.0, 0.0, -1.0],
[1.0, 0.0, -1.0],
[0.5, 1.0, -1.0],
# face 1: z=+1, 法線 +Z (to_center の z 成分 = 0-1 = -1 → dot < 0 → False)
[0.0, 0.0, 1.0],
[1.0, 0.0, 1.0],
[0.5, 1.0, 1.0],
], dtype=np.float64)
faces = np.array([
[0, 1, 2], # z=-1, 法線 +Z
[3, 4, 5], # z=+1, 法線 +Z
], dtype=np.int64)
scene_center = np.array([0.5, 0.333, 0.0])
# 解析確認: face 0 centroid = (0.5, 1/3, -1)
# to_center = (0, 0, 0) - (0.5, 1/3, -1) = (-0.5 ≈, -0.333 ≈, +1)
# normal = (0,0,1)
# dot = 0*(-0.5) + 0*(-1/3) + 1*1 = 1 > 0 → True
# face 1 centroid = (0.5, 1/3, +1)
# to_center = (0,0,0) - (0.5, 1/3, 1) = (-0.5, -1/3, -1)
# normal = (0,0,1)
# dot = 0 + 0 + 1*(-1) = -1 < 0 → False
mask = compute_inward_face_mask(vertices, faces, scene_center)
assert bool(mask[0]) is True, "face 0 (z=-1, +Z 法線): scene_center(z=0) 側 → 内向き True"
assert bool(mask[1]) is False, (
"face 1 (z=+1, +Z 法線): scene_center(z=0) と逆側 → 外向き False"
)