"""visualize/projection.py の仕様ベーステスト．

仕様出典:
- docs/04_SPEC/SPEC_03_基準設計の物理構成.md（カメラ（センサ）節）
- docs/04_SPEC/SPEC_02_シーン定義仕様.md（sensor orthographic 節）
- src/sm_tias_lightsim/scene/sensors.py（look_at + scale 規約）
- src/sm_tias_lightsim/visualize/projection.py（投影規約 docstring）
- docs/01_GUIDE/GUIDE_08_テスト方針.md

テスト方針:
- PyVista 実描画は使用しない（投影・正規化・CLI のロジックを純関数レベルで重点検証）
- 投影関数（project_luminance_to_vertices・find_eval_board_mesh_id）は純関数のため
  Mitsuba 不要・高速（slow マーカーなし）
- ファイル I/O は tmp_path フィクスチャで実ファイルを使う（GUIDE_08）

テスト対象:
    find_eval_board_mesh_id(scene_config) -> str
    project_luminance_to_vertices(vertices_m, luminance_map, sensor, *, clip_low, clip_high) -> np.ndarray (N_v,) float32
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pytest

from sm_tias_lightsim.scene.schema import (
    FilmConfig,
    GeometryConfig,
    MeshConfig,
    SceneConfig,
    SensorConfig,
)
from sm_tias_lightsim.visualize.projection import (
    compute_face_camera_mask,
    compute_orthographic_uv,
    find_eval_board_mesh_id,
    project_luminance_to_vertices,
)


# ---------------------------------------------------------------------------
# ヘルパ: SPEC_03 準拠の baseline orthographic センサを構築する
# ---------------------------------------------------------------------------

def _make_baseline_sensor(
    *,
    position=(0.0, 0.0, -50.0),  # mm
    target=(0.0, 0.0, 0.0),       # mm
    up=(0.0, 1.0, 0.0),
    ortho_scale=(50.0, 52.0),     # mm
    width=512,
    height=512,
) -> SensorConfig:
    """SPEC_03「カメラ（センサ）」節に準拠した orthographic SensorConfig を返す．

    SPEC_03:
        position = (0, 0, -50) mm
        target   = (0, 0, 0)
        up       = (0, 1, 0)
        ortho_scale = (50, 52) mm
    """
    return SensorConfig(
        type="orthographic",
        position=position,
        target=target,
        up=up,
        fov_deg=None,
        ortho_scale=ortho_scale,
        film=FilmConfig(width=width, height=height),
    )


def _make_minimal_scene_config_with_mesh_ids(mesh_ids: list[str]) -> SceneConfig:
    """指定した id のメッシュ一覧を含む最小 SceneConfig を返す．

    geometry.meshes に mesh_ids の順で MeshConfig を追加する（path は仮値）．
    """
    meshes = [
        {"id": mid, "path": f"meshes/{mid}.stl", "unit": "mm", "material": "white"}
        for mid in mesh_ids
    ]
    return SceneConfig.model_validate({
        "name": "test",
        "geometry": {"meshes": meshes},
        "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
        "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                    "intensity": 1.0, "profile": "lambertian"}],
        "sensor": {
            "type": "orthographic",
            "position": [0.0, 0.0, -50.0],
            "target": [0.0, 0.0, 0.0],
            "up": [0.0, 1.0, 0.0],
            "ortho_scale": [50.0, 52.0],
            "film": {"width": 512, "height": 512},
        },
        "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
    })


# ===========================================================================
# 1. find_eval_board_mesh_id — 評価板メッシュ特定
# ===========================================================================


class TestFindEvalBoardMeshId:
    """find_eval_board_mesh_id の仕様ベーステスト．

    仕様（projection.py docstring）:
      優先順位:
        1. id == "evaluation_board" のメッシュ
        2. geometry.meshes が空でなければ先頭メッシュを返す（フォールバック）
      Raises ValueError: geometry.meshes が空の場合
    """

    def test_returns_evaluation_board_when_present(self):
        """id='evaluation_board' のメッシュがあれば、それが返ること．

        仕様: 優先順位 1 — id == 'evaluation_board'
        """
        config = _make_minimal_scene_config_with_mesh_ids(["housing", "evaluation_board", "sensor"])
        result = find_eval_board_mesh_id(config)
        assert result == "evaluation_board"

    def test_returns_evaluation_board_when_it_is_the_only_mesh(self):
        """id='evaluation_board' のメッシュのみの場合も正しく返ること．"""
        config = _make_minimal_scene_config_with_mesh_ids(["evaluation_board"])
        result = find_eval_board_mesh_id(config)
        assert result == "evaluation_board"

    def test_returns_evaluation_board_even_if_not_first(self):
        """id='evaluation_board' が先頭でなくても正しく返ること．"""
        config = _make_minimal_scene_config_with_mesh_ids(["housing", "diffuser", "evaluation_board"])
        result = find_eval_board_mesh_id(config)
        assert result == "evaluation_board"

    def test_fallback_returns_first_mesh_when_no_evaluation_board(self):
        """id='evaluation_board' がない場合、先頭メッシュの id が返ること（フォールバック）．

        仕様: フォールバック — 先頭メッシュを返す
        """
        config = _make_minimal_scene_config_with_mesh_ids(["housing", "diffuser"])
        result = find_eval_board_mesh_id(config)
        assert result == "housing"

    def test_fallback_returns_first_mesh_single_entry(self):
        """単一メッシュで id='evaluation_board' でない場合、そのメッシュ id が返ること．"""
        config = _make_minimal_scene_config_with_mesh_ids(["my_mesh"])
        result = find_eval_board_mesh_id(config)
        assert result == "my_mesh"

    def test_returns_string_type(self):
        """戻り値が str 型であること．"""
        config = _make_minimal_scene_config_with_mesh_ids(["evaluation_board"])
        result = find_eval_board_mesh_id(config)
        assert isinstance(result, str)


# ===========================================================================
# 2. project_luminance_to_vertices — 投影の正しさ（SPEC_03 baseline センサ設定）
# ===========================================================================


class TestOrthographicProjectionBaseline:
    """SPEC_03 baseline センサ設定での orthographic 投影を検証する．

    SPEC_03 センサ設定:
        position = (0, 0, -50) mm → m で (0, 0, -0.05)
        target   = (0, 0, 0)
        up       = (0, 1, 0)
        ortho_scale = (50, 52) mm → sx=0.05 m, sy=0.052 m

    look_at 基底（projection.py docstring に準拠）:
        forward = normalize(target - pos) = (0, 0, 1)
        right   = normalize(cross(up, forward)) = normalize((1,0,0)) = (1,0,0)
        cam_up  = cross(forward, right) = (0,0,1) × (1,0,0) = (0,-1,0)
        ただし Mitsuba は渡した up ベクトル (0,1,0) を直接使う規約のため
        cam_up の実質的な役割は up=(0,1,0) が担う

    画像座標変換規約（projection.py docstring）:
        clip_x = world_x / sx_m      (right 方向)
        clip_y = world_y / sy_m      (cam_up 方向; up=(0,1,0) なので +Y が clip_y+)
        col = (clip_x + 1) / 2 * W  (clip_x=-1 → col=0, clip_x=+1 → col=W)
        row = (1 - clip_y) / 2 * H  (clip_y=+1 → row=0, clip_y=-1 → row=H)
    """

    @pytest.fixture(autouse=True)
    def setup_sensor_and_map(self):
        """SPEC_03 準拠の baseline センサと uniform 輝度マップを設定する．"""
        self.sensor = _make_baseline_sensor()
        self.H = self.sensor.film.height   # 512
        self.W = self.sensor.film.width    # 512
        self.sx_m = self.sensor.ortho_scale[0] * 0.001  # 0.05
        self.sy_m = self.sensor.ortho_scale[1] * 0.001  # 0.052
        # 一様な輝度マップ（値 1.0）— サンプリング位置によらず同じ値を返す
        self.uniform_map = np.ones((self.H, self.W), dtype=np.float64)

    def test_center_vertex_maps_to_image_center(self):
        """ワールド原点 (0, 0, 0) が画像中心 (row=H/2, col=W/2) に写ること．

        仕様（SPEC_03）: target = (0,0,0) が撮影面の中心
        仕様（projection.py）: clip_x=0, clip_y=0 → col=W/2, row=H/2
        一様マップなので値 = 1.0
        """
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor)
        assert result.shape == (1,)
        assert float(result[0]) == pytest.approx(1.0, abs=1e-5)

    def test_right_edge_vertex_maps_to_right_column(self):
        """ワールド座標 (sx_m, 0, 0) が画像右端列 (col ≈ W) に写ること．

        仕様（projection.py）: clip_x = sx_m / sx_m = 1.0 → col = (1+1)/2*W = W
        境界クランプで col = W-1 になる
        """
        vertices_m = np.array([[self.sx_m, 0.0, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor)
        # 一様マップなので値は 1.0（境界クランプが効いても値は変わらない）
        assert float(result[0]) == pytest.approx(1.0, abs=1e-5)

    def test_left_edge_vertex_maps_to_left_column(self):
        """ワールド座標 (-sx_m, 0, 0) が画像左端列 (col=0) に写ること．

        仕様（projection.py）: clip_x = -sx_m / sx_m = -1.0 → col = 0
        """
        vertices_m = np.array([[-self.sx_m, 0.0, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor)
        assert float(result[0]) == pytest.approx(1.0, abs=1e-5)

    def test_top_edge_vertex_maps_to_top_row(self):
        """ワールド座標 (0, sy_m, 0) が画像上端行 (row=0) に写ること．

        仕様（projection.py）: clip_y = sy_m / sy_m = 1.0 → row = (1-1)/2*H = 0
        SPEC_03: up=(0,1,0) なので +Y 方向が画像上端
        """
        vertices_m = np.array([[0.0, self.sy_m, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor)
        assert float(result[0]) == pytest.approx(1.0, abs=1e-5)

    def test_bottom_edge_vertex_maps_to_bottom_row(self):
        """ワールド座標 (0, -sy_m, 0) が画像下端行 (row=H) に写ること．

        仕様（projection.py）: clip_y = -1.0 → row = (1-(-1))/2*H = H
        境界クランプで row = H-1 になる
        """
        vertices_m = np.array([[0.0, -self.sy_m, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(vertices_m, self.uniform_map, self.sensor)
        assert float(result[0]) == pytest.approx(1.0, abs=1e-5)

    def test_out_of_range_vertex_is_boundary_clamped(self):
        """撮影範囲外の頂点が境界値でクランプされること（例外は投げない）．

        仕様（projection.py）: センサ外の頂点は最近傍クリップ（境界値で折り返す）で補完する
        """
        # ortho_scale の 2 倍外側
        far_outside = np.array([[self.sx_m * 2.0, self.sy_m * 2.0, 0.0]], dtype=np.float64)
        # 例外が発生しないことを確認
        result = project_luminance_to_vertices(far_outside, self.uniform_map, self.sensor)
        assert result.shape == (1,)
        # 一様マップでは境界クランプ後も 1.0
        assert float(result[0]) == pytest.approx(1.0, abs=1e-5)

    def test_known_world_coordinate_maps_to_expected_pixel_gradient_map(self):
        """X 方向線形勾配マップで、既知ワールド座標が期待輝度値に対応すること．

        設定:
          - 輝度マップ: col 方向線形グラジェント（col=0 で 0.0, col=W-1 で 1.0）
          - テスト頂点: (0, 0, 0) → col=W/2 → 輝度 ≈ 0.5
                        (-sx_m, 0, 0) → col=0 → 輝度 ≈ 0.0
                        (+sx_m, 0, 0) → col=W → クランプ → col=W-1 → 輝度 ≈ 1.0

        仕様（projection.py）:
          clip_x = world_x / sx_m
          col = (clip_x + 1) / 2 * W
        """
        H, W = 100, 100
        # col 方向線形グラジェント: lum[row, col] = col / (W-1)
        cols = np.linspace(0.0, 1.0, W)
        gradient_map = np.tile(cols, (H, 1))  # (H, W)

        sensor = _make_baseline_sensor(width=W, height=H)

        # テスト頂点
        sx_m = sensor.ortho_scale[0] * 0.001
        sy_m = sensor.ortho_scale[1] * 0.001
        vertices_m = np.array([
            [0.0,    0.0, 0.0],  # 中心 → col=W/2 → 輝度 = 0.5
            [-sx_m,  0.0, 0.0],  # 左端 → col=0   → 輝度 = 0.0
            [sx_m,   0.0, 0.0],  # 右端 → col=W   → クランプ col=W-1 → 輝度 ≈ 1.0
        ], dtype=np.float64)

        result = project_luminance_to_vertices(
            vertices_m, gradient_map, sensor, clip_low=0.0, clip_high=100.0
        )
        assert result.shape == (3,)
        # 中心
        assert float(result[0]) == pytest.approx(0.5, abs=0.02)
        # 左端（col=0）
        assert float(result[1]) == pytest.approx(0.0, abs=0.02)
        # 右端クランプ（col=W-1）
        assert float(result[2]) == pytest.approx(1.0, abs=0.02)

    def test_y_direction_linear_gradient_map(self):
        """Y 方向線形勾配マップで、ワールド Y 座標が期待輝度に対応すること．

        仕様（projection.py）:
          clip_y = world_y / sy_m
          row = (1 - clip_y) / 2 * H
          row=0（上端）は clip_y=+1 → world_y = +sy_m
          row=H（下端）は clip_y=-1 → world_y = -sy_m

        輝度マップ: row 方向線形 (row=0: 1.0, row=H-1: 0.0) = Y上方向が明るい
        """
        H, W = 100, 100
        rows = np.linspace(1.0, 0.0, H)  # row=0 → 1.0, row=H-1 → 0.0
        gradient_map = np.tile(rows[:, np.newaxis], (1, W))  # (H, W)

        sensor = _make_baseline_sensor(width=W, height=H)
        sy_m = sensor.ortho_scale[1] * 0.001

        vertices_m = np.array([
            [0.0,  sy_m,  0.0],  # 上端 → row=0   → 輝度 ≈ 1.0
            [0.0, -sy_m,  0.0],  # 下端 → row=H-1 → 輝度 ≈ 0.0
            [0.0,  0.0,   0.0],  # 中心 → row=H/2 → 輝度 ≈ 0.5
        ], dtype=np.float64)

        result = project_luminance_to_vertices(
            vertices_m, gradient_map, sensor, clip_low=0.0, clip_high=100.0
        )
        assert result.shape == (3,)
        assert float(result[0]) == pytest.approx(1.0, abs=0.02)
        assert float(result[1]) == pytest.approx(0.0, abs=0.02)
        assert float(result[2]) == pytest.approx(0.5, abs=0.02)


# ===========================================================================
# 3. bilinear サンプリングの正確さ
# ===========================================================================


class TestBilinearSampling:
    """bilinear 補間の数値正確さを検証する．

    仕様（projection.py）:
      bilinear サンプリング（範囲外は境界でクランプ）
      整数格子点では元値一致
      線形勾配では中点が厳密に補間値に一致
    """

    def test_integer_grid_points_return_exact_values(self):
        """整数格子点に対応する頂点の補間値が元の輝度値と一致すること．

        仕様: bilinear 補間の整数格子点では元値と一致する
        """
        # 4x4 マップ: 各画素に固有の値を設定
        lum_map = np.array([
            [10.0, 20.0, 30.0, 40.0],
            [50.0, 60.0, 70.0, 80.0],
            [90.0, 100.0, 110.0, 120.0],
            [130.0, 140.0, 150.0, 160.0],
        ], dtype=np.float64)
        H, W = 4, 4

        sensor = _make_baseline_sensor(width=W, height=H)
        sx_m = sensor.ortho_scale[0] * 0.001
        sy_m = sensor.ortho_scale[1] * 0.001

        # pixel (row=0, col=0) に対応するワールド座標
        # col=0 → clip_x = -1.0 → world_x = -sx_m
        # row=0 → clip_y = +1.0 → world_y = +sy_m
        world_x_00 = -sx_m
        world_y_00 = +sy_m

        vertices_m = np.array([[world_x_00, world_y_00, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(
            vertices_m, lum_map, sensor, clip_low=0.0, clip_high=100.0
        )
        # pixel (0, 0) の値 = 10.0
        assert float(result[0]) == pytest.approx(10.0, abs=1.0)

    def test_midpoint_bilinear_interpolation_on_linear_gradient(self):
        """線形勾配マップの整数格子点中間で補間値が解析値と一致すること．

        設定:
          lum_map = 2D 線形グラジェント:
            lum[r, c] = r * W + c (0 〜 H*W-1)
          中間点は bilinear 補間の平均値になる
        """
        H, W = 8, 8
        lum_map = np.arange(H * W, dtype=np.float64).reshape(H, W)

        sensor = _make_baseline_sensor(width=W, height=H)
        sx_m = sensor.ortho_scale[0] * 0.001
        sy_m = sensor.ortho_scale[1] * 0.001

        # pixel (row=1, col=1) に対応するワールド座標を計算
        # col=1 → clip_x = (2*1/W) - 1 = 2/8 - 1 = -0.75 → world_x = -0.75 * sx_m
        # row=1 → clip_y = 1 - (2*1/H) = 1 - 2/8 = 0.75  → world_y = 0.75 * sy_m
        clip_x = (2.0 * 1 / W) - 1.0  # -0.75
        clip_y = 1.0 - (2.0 * 1 / H)  # 0.75
        world_x = clip_x * sx_m
        world_y = clip_y * sy_m

        vertices_m = np.array([[world_x, world_y, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(
            vertices_m, lum_map, sensor, clip_low=0.0, clip_high=100.0
        )
        # pixel (row=1, col=1) の値 = 1*8 + 1 = 9.0
        assert float(result[0]) == pytest.approx(9.0, abs=1.0)

    def test_channel_first_luminance_uses_first_channel(self):
        """(H, W, C) 形式の輝度マップで最初のチャネルが使われること．

        仕様（projection.py）: C チャネルある場合は最初のチャネルを使用する
        """
        H, W = 4, 4
        # チャネル 0: 全画素 1.0, チャネル 1: 全画素 999.0
        lum_3ch = np.zeros((H, W, 2), dtype=np.float64)
        lum_3ch[:, :, 0] = 1.0
        lum_3ch[:, :, 1] = 999.0

        sensor = _make_baseline_sensor(width=W, height=H)
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)

        result = project_luminance_to_vertices(
            vertices_m, lum_3ch, sensor, clip_low=0.0, clip_high=100.0
        )
        # 最初のチャネル（1.0）が使われること
        assert float(result[0]) == pytest.approx(1.0, abs=1e-4)


# ===========================================================================
# 4. percentile クリップの正確さ
# ===========================================================================


class TestPercentileClip:
    """percentile クリップが正しく機能することを検証する．

    仕様（projection.py）:
      percentile クリップで高ダイナミックレンジ潰れを防止
      p_low = np.percentile(sampled, clip_low)
      p_high = np.percentile(sampled, clip_high)
      sampled_clipped = np.clip(sampled, p_low, p_high)
    """

    @pytest.fixture(autouse=True)
    def setup_sensor(self):
        """テスト用センサを設定する（小さな解像度）．"""
        self.H, self.W = 10, 10
        self.sensor = _make_baseline_sensor(width=self.W, height=self.H)
        self.sx_m = self.sensor.ortho_scale[0] * 0.001
        self.sy_m = self.sensor.ortho_scale[1] * 0.001

    def test_p1_p99_clip_reduces_extreme_values(self):
        """p1–p99 クリップで極値が除去されること（戻り値の min/max が期待範囲内）．

        仕様（projection.py）:
          p_low  = np.percentile(sampled, clip_low)
          p_high = np.percentile(sampled, clip_high)
          sampled_clipped = np.clip(sampled, p_low, p_high)

        検証方針:
          - クリップ後の最大値は「クリップ前サンプリング値の p99」と一致すること
          - クリップ後の最小値は「クリップ前サンプリング値の p1」と一致すること
          - 戻り値が float32 であること
        """
        # 0〜100 の均等輝度マップ (10x10)
        lum_map = np.linspace(0.0, 100.0, self.H * self.W).reshape(self.H, self.W)

        # 頂点をグリッド状に配置してすべてのピクセルにサンプリング
        xs = np.linspace(-self.sx_m * 0.95, self.sx_m * 0.95, self.W)
        ys = np.linspace(-self.sy_m * 0.95, self.sy_m * 0.95, self.H)
        xv, yv = np.meshgrid(xs, ys)
        vertices_m = np.stack([xv.ravel(), yv.ravel(), np.zeros(self.H * self.W)], axis=1)

        result = project_luminance_to_vertices(
            vertices_m, lum_map, self.sensor, clip_low=1.0, clip_high=99.0
        )
        assert result.shape == (self.H * self.W,)
        # 戻り値は float32
        assert result.dtype == np.float32

        # クリップ後の値は元輝度マップの最大値（100.0）を超えないこと
        # （クリップにより極値が押さえられる仕様）
        assert float(result.max()) <= 100.0 + 1e-3, (
            f"クリップ後の最大値が元マップ最大値を超えている: max={float(result.max()):.4f}"
        )
        assert float(result.min()) >= 0.0 - 1e-3, (
            f"クリップ後の最小値が元マップ最小値を下回っている: min={float(result.min()):.4f}"
        )
        # clip_low=1, clip_high=99 のとき: 結果は元のフル範囲より狭くなること
        # (元: ~0〜100, クリップ後: p1〜p99 の範囲に収束)
        full_range = 100.0  # 元マップ最大 - 最小
        clipped_range = float(result.max()) - float(result.min())
        assert clipped_range < full_range, (
            f"クリップ後の range がクリップ前より小さいこと: clipped={clipped_range:.4f}, full={full_range:.4f}"
        )

    def test_clip_low_0_clip_high_100_preserves_range(self):
        """clip_low=0, clip_high=100 のときクリップが実質無効（全範囲が保存）．"""
        lum_map = np.linspace(1.0, 10.0, self.H * self.W).reshape(self.H, self.W)

        # 中心頂点のみ
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
        result = project_luminance_to_vertices(
            vertices_m, lum_map, self.sensor, clip_low=0.0, clip_high=100.0
        )
        # 例外が発生しないこと
        assert result.shape == (1,)
        assert np.isfinite(float(result[0]))


# ===========================================================================
# 5. 入力バリデーション — ValueError
# ===========================================================================


class TestProjectionValidation:
    """project_luminance_to_vertices の入力バリデーションを検証する．

    仕様（projection.py）:
        Raises:
            ValueError: sensor.type が 'orthographic' でない場合
            ValueError: sensor.ortho_scale が None の場合
            ValueError: sensor position と target が一致している場合
    """

    def test_raises_value_error_for_perspective_sensor(self):
        """perspective センサを渡すと ValueError が発生すること．

        仕様: sensor.type が 'orthographic' でない場合 ValueError
        """
        perspective_sensor = SensorConfig(
            type="perspective",
            position=(0.0, 0.0, -50.0),
            target=(0.0, 0.0, 0.0),
            up=(0.0, 1.0, 0.0),
            fov_deg=60.0,
            film=FilmConfig(width=512, height=512),
        )
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
        lum_map = np.ones((512, 512), dtype=np.float64)

        with pytest.raises(ValueError, match="orthographic"):
            project_luminance_to_vertices(vertices_m, lum_map, perspective_sensor)

    def test_raises_value_error_for_position_equals_target(self):
        """position == target のとき ValueError が発生すること．

        仕様: sensor position と target が一致している場合 ValueError
        """
        # position == target の orthographic センサ
        sensor = SensorConfig(
            type="orthographic",
            position=(0.0, 0.0, 0.0),
            target=(0.0, 0.0, 0.0),  # position と同じ
            up=(0.0, 1.0, 0.0),
            ortho_scale=(50.0, 52.0),
            film=FilmConfig(width=512, height=512),
        )
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
        lum_map = np.ones((512, 512), dtype=np.float64)

        with pytest.raises(ValueError):
            project_luminance_to_vertices(vertices_m, lum_map, sensor)

    def test_returns_float32_array(self):
        """戻り値が float32 の ndarray であること．

        仕様（projection.py）: per-vertex 輝度スカラ配列 (N_v,) float32
        """
        sensor = _make_baseline_sensor()
        vertices_m = np.array([[0.0, 0.0, 0.0], [0.01, 0.0, 0.0]], dtype=np.float64)
        lum_map = np.ones((512, 512), dtype=np.float64)

        result = project_luminance_to_vertices(vertices_m, lum_map, sensor)

        assert isinstance(result, np.ndarray)
        assert result.dtype == np.float32
        assert result.ndim == 1
        assert result.shape == (2,)

    def test_multiple_vertices_returns_correct_shape(self):
        """複数頂点 (N_v, 3) に対して (N_v,) の配列が返ること．"""
        sensor = _make_baseline_sensor()
        N_v = 50
        vertices_m = np.zeros((N_v, 3), dtype=np.float64)
        lum_map = np.ones((512, 512), dtype=np.float64)

        result = project_luminance_to_vertices(vertices_m, lum_map, sensor)
        assert result.shape == (N_v,)


# ===========================================================================
# 6. CLI --render / --clip-percentile のパース（純パーズ層テスト）
# ===========================================================================


class TestCliArgParsing:
    """sm-visualize CLI 引数パーズの仕様ベーステスト．

    仕様（cli/visualize.py）:
        --render PATH    luminance.npy のパスを受け取る
        --clip-percentile LOW HIGH    既定: [1.0, 99.0]
        --render 指定時は --no-bake が暗黙で有効（no_bake=True）
    """

    def test_parse_render_arg_sets_render_path(self, tmp_path):
        """--render PATH が正しくパーズされること．"""
        from sm_tias_lightsim.cli.visualize import _parse_args

        npy_path = tmp_path / "luminance.npy"
        args = _parse_args(["--scene", "scene.yaml", "--render", str(npy_path)])
        assert args.render == npy_path

    def test_parse_render_none_by_default(self):
        """--render を指定しない場合、args.render が None であること．"""
        from sm_tias_lightsim.cli.visualize import _parse_args

        args = _parse_args(["--scene", "scene.yaml"])
        assert args.render is None

    def test_parse_clip_percentile_default_is_1_99(self):
        """--clip-percentile を省略したとき既定値 [1.0, 99.0] が設定されること．

        仕様（cli/visualize.py）: --clip-percentile 既定 1 99
        """
        from sm_tias_lightsim.cli.visualize import _parse_args

        args = _parse_args(["--scene", "scene.yaml"])
        assert args.clip_percentile == pytest.approx([1.0, 99.0])

    def test_parse_clip_percentile_custom_values(self):
        """--clip-percentile LOW HIGH がカスタム値でパーズされること．"""
        from sm_tias_lightsim.cli.visualize import _parse_args

        args = _parse_args(["--scene", "scene.yaml", "--clip-percentile", "5", "95"])
        assert args.clip_percentile[0] == pytest.approx(5.0)
        assert args.clip_percentile[1] == pytest.approx(95.0)

    def test_render_path_implies_no_bake_in_main_logic(self, tmp_path):
        """--render 指定時に main() 内で no_bake=True になることを確認する．

        仕様（cli/visualize.py）:
            no_bake: bool = args.no_bake or (render_path is not None)
        """
        from sm_tias_lightsim.cli.visualize import _parse_args

        npy_path = tmp_path / "luminance.npy"
        args = _parse_args(["--scene", "scene.yaml", "--render", str(npy_path)])
        # --render 指定時、args.no_bake は False だが render_path は None でない
        # main() 内で no_bake = args.no_bake or (render_path is not None) = True
        assert args.no_bake is False
        assert args.render is not None
        # main() 相当の論理
        render_path = args.render
        no_bake = args.no_bake or (render_path is not None)
        assert no_bake is True

    def test_missing_scene_raises_system_exit(self):
        """--scene を渡さないと SystemExit が発生すること．"""
        from sm_tias_lightsim.cli.visualize import _parse_args

        with pytest.raises(SystemExit) as exc_info:
            _parse_args([])
        assert exc_info.value.code != 0

    def test_render_nonexistent_file_returns_1(self, tmp_path):
        """--render で存在しない .npy ファイルを指定すると return 1 であること．

        仕様（cli/visualize.py）: luminance.npy が見つからない → return 1
        """
        from sm_tias_lightsim.cli.visualize import main

        # 最小 YAML と存在しない npy
        import yaml
        scene_yaml = tmp_path / "scene.yaml"
        scene_data = {
            "name": "test",
            "geometry": {"meshes": [{"id": "mesh", "path": "mesh.stl", "unit": "mm", "material": "white"}]},
            "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "orthographic",
                "position": [0.0, 0.0, -50.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "ortho_scale": [50.0, 52.0],
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8")
        nonexistent_npy = tmp_path / "nonexistent_luminance.npy"

        result = main(["--scene", str(scene_yaml), "--render", str(nonexistent_npy), "--off-screen"])
        assert result == 1


# ===========================================================================
# 7. --render CLI 統合テスト（Mitsuba 不要）
# ===========================================================================


class TestCliRenderIntegration:
    """--render フラグで luminance.npy を読み込む統合テスト．

    仕様（cli/visualize.py）:
        --render 指定時は sm-visualize 自身はレンダリングを実行しない（LLVM 不要）
    """

    @pytest.mark.slow
    def test_render_flag_loads_npy_and_creates_screenshot(self, tmp_path):
        """--render で luminance.npy を読み込み、--screenshot で PNG が生成されること．

        仕様: luminance.npy 読み込み後、show_scene（投影モード）で screenshot を保存する
        Mitsuba のレンダリング（LLVM）は実行しない
        """
        import yaml

        from sm_tias_lightsim.cli.visualize import main

        # 最小シーン YAML（evaluation_board メッシュが必要）
        # minimal.yaml の sphere STL を流用し、evaluation_board id を付ける
        sphere_stl = (
            Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl"
        )
        scene_data = {
            "name": "render_test",
            "geometry": {"meshes": [
                {"id": "evaluation_board", "path": str(sphere_stl), "unit": "mm", "material": "white"}
            ]},
            "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "orthographic",
                "position": [0.0, 0.0, -50.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "ortho_scale": [50.0, 52.0],
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        scene_yaml = tmp_path / "scene_render.yaml"
        scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8")

        # 合成輝度マップ（64x64）を作成
        lum_npy = tmp_path / "luminance.npy"
        np.save(str(lum_npy), np.random.default_rng(42).random((64, 64)).astype(np.float32))

        screenshot_path = tmp_path / "render_out.png"
        result = main([
            "--scene", str(scene_yaml),
            "--render", str(lum_npy),
            "--screenshot", str(screenshot_path),
        ])
        assert result == 0
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_render_flag_does_not_run_bake(self, tmp_path, capsys):
        """--render 指定時に「照度ベイクを開始します」が stdout に出ないこと．

        仕様: --render 指定時は直接ベイクをスキップ（LLVM 不要）
        """
        import yaml

        from sm_tias_lightsim.cli.visualize import main

        sphere_stl = (
            Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl"
        )
        scene_data = {
            "name": "no_bake_test",
            "geometry": {"meshes": [
                {"id": "evaluation_board", "path": str(sphere_stl), "unit": "mm", "material": "white"}
            ]},
            "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "orthographic",
                "position": [0.0, 0.0, -50.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "ortho_scale": [50.0, 52.0],
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        scene_yaml = tmp_path / "scene.yaml"
        scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8")

        lum_npy = tmp_path / "luminance.npy"
        np.save(str(lum_npy), np.ones((64, 64), dtype=np.float32))

        screenshot_path = tmp_path / "out.png"
        main([
            "--scene", str(scene_yaml),
            "--render", str(lum_npy),
            "--screenshot", str(screenshot_path),
        ])
        captured = capsys.readouterr()
        assert "照度ベイクを開始します" not in captured.out


# ===========================================================================
# 8. show_scene 後方互換 — luminance_map=None
# ===========================================================================


class TestShowSceneBackwardCompat:
    """show_scene(..., luminance_map=None) の後方互換を検証する．

    仕様（viewer.py docstring）:
        luminance_map が None の場合、投影モードを使わない（既存挙動を保持）
        show_scene の既存引数（irradiance, off_screen, screenshot, clip_low/high）は
        そのまま動作すること
    """

    @pytest.mark.slow
    def test_luminance_map_none_does_not_crash(self, tmp_path):
        """luminance_map=None で show_scene が例外を投げないこと．"""
        import yaml

        from sm_tias_lightsim.scene.schema import load_scene_yaml
        from sm_tias_lightsim.visualize.viewer import show_scene

        sphere_stl = (
            Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl"
        )
        scene_data = {
            "name": "compat_test",
            "geometry": {"meshes": [
                {"id": "sphere", "path": str(sphere_stl), "unit": "mm", "material": "white"}
            ]},
            "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "perspective",
                "position": [0.0, 0.0, 200.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "fov_deg": 60.0,
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        scene_yaml = tmp_path / "scene.yaml"
        scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8")
        config = load_scene_yaml(scene_yaml)
        project_root = tmp_path

        screenshot_path = tmp_path / "compat_out.png"
        # luminance_map=None（省略相当）— 後方互換
        show_scene(
            config,
            project_root,
            irradiance=None,
            luminance_map=None,
            screenshot=screenshot_path,
        )
        assert screenshot_path.exists()
        assert screenshot_path.stat().st_size > 0

    @pytest.mark.slow
    def test_show_scene_new_signature_with_clip_percentile(self, tmp_path):
        """clip_low / clip_high 引数が受け付けられること（新引数の後方互換）．"""
        import yaml

        from sm_tias_lightsim.scene.schema import load_scene_yaml
        from sm_tias_lightsim.visualize.viewer import show_scene

        sphere_stl = (
            Path(__file__).parent.parent / "fixtures" / "meshes" / "sphere.stl"
        )
        scene_data = {
            "name": "clip_test",
            "geometry": {"meshes": [
                {"id": "sphere", "path": str(sphere_stl), "unit": "mm", "material": "white"}
            ]},
            "materials": {"white": {"type": "diffuse", "reflectance": 0.9}},
            "lights": [{"id": "led", "type": "point", "position": [0.0, 0.0, 100.0],
                        "intensity": 1.0, "profile": "lambertian"}],
            "sensor": {
                "type": "perspective",
                "position": [0.0, 0.0, 200.0],
                "target": [0.0, 0.0, 0.0],
                "up": [0.0, 1.0, 0.0],
                "fov_deg": 60.0,
                "film": {"width": 64, "height": 64},
            },
            "render": {"variant": "scalar_rgb", "integrator": "path", "max_depth": 2, "spp": 1},
        }
        scene_yaml = tmp_path / "scene.yaml"
        scene_yaml.write_text(yaml.dump(scene_data), encoding="utf-8")
        config = load_scene_yaml(scene_yaml)
        project_root = tmp_path

        screenshot_path = tmp_path / "clip_out.png"
        # clip_low / clip_high 引数を明示的に指定
        show_scene(
            config,
            project_root,
            irradiance=None,
            luminance_map=None,
            screenshot=screenshot_path,
            clip_low=5.0,
            clip_high=95.0,
        )
        assert screenshot_path.exists()


# ===========================================================================
# 9. compute_orthographic_uv — UV テクスチャ座標の仕様検証
# ===========================================================================


class TestComputeOrthographicUv:
    """compute_orthographic_uv の仕様ベーステスト．

    仕様（projection.py docstring）:
        u = (clip_x + 1) / 2  ∈ [0, 1]（画像左=u=0, 右=u=1）
        v = (1 + clip_y) / 2  ∈ [0, 1]（VTK 原点は左下=v=0, 上=v=1）

    SPEC_03「画像↔物理の向き（検証済み）」:
        画像右 = 物理 X+  → u 大 = X+
        画像上 = 物理 Y+  → v 大 = Y+（VTK の v=1-row/H であるため Y+ が v 大）
    """

    @pytest.fixture(autouse=True)
    def setup_sensor(self):
        """SPEC_03 準拠の baseline センサを設定する．"""
        self.sensor = _make_baseline_sensor()
        self.sx_m = self.sensor.ortho_scale[0] * 0.001  # 0.05
        self.sy_m = self.sensor.ortho_scale[1] * 0.001  # 0.052
        self.lum_shape = (512, 512)

    def test_returns_float32_uv_array(self):
        """戻り値が (N_v, 2) float32 配列であること．

        仕様: UV テクスチャ座標 (N_v, 2) float32
        """
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert isinstance(uv, np.ndarray)
        assert uv.dtype == np.float32
        assert uv.shape == (1, 2)

    def test_center_vertex_maps_to_uv_05_05(self):
        """ワールド原点 (0,0,0) が UV = (0.5, 0.5)（中心）に写ること．

        仕様: clip_x=0 → u=(0+1)/2=0.5, clip_y=0 → v=(1+0)/2=0.5
        """
        vertices_m = np.array([[0.0, 0.0, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 0]) == pytest.approx(0.5, abs=1e-5), "u=0.5（中心）"
        assert float(uv[0, 1]) == pytest.approx(0.5, abs=1e-5), "v=0.5（中心）"

    def test_positive_x_vertex_maps_to_u_larger_than_05(self):
        """物理 X+ (world_x > 0) が u > 0.5 に写ること．

        SPEC_03: 画像右 = 物理 X+ → u 大 = X+
        """
        vertices_m = np.array([[self.sx_m * 0.5, 0.0, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 0]) > 0.5, f"X+ の頂点は u > 0.5 であること: u={float(uv[0,0]):.4f}"

    def test_positive_y_vertex_maps_to_v_larger_than_05(self):
        """物理 Y+ (world_y > 0) が v > 0.5 に写ること．

        SPEC_03: 画像上 = 物理 Y+, VTK v=1 が上 → Y+ で v 大
        """
        vertices_m = np.array([[0.0, self.sy_m * 0.5, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 1]) > 0.5, f"Y+ の頂点は v > 0.5 であること: v={float(uv[0,1]):.4f}"

    def test_x_plus_boundary_maps_to_u_1(self):
        """物理 X+ 端 (world_x = +sx_m) が u=1.0 に写ること．

        仕様: clip_x=+1 → u=(1+1)/2=1.0 → クランプで 1.0 固定
        """
        vertices_m = np.array([[self.sx_m, 0.0, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 0]) == pytest.approx(1.0, abs=1e-5), "X+ 端は u=1.0"

    def test_x_minus_boundary_maps_to_u_0(self):
        """物理 X- 端 (world_x = -sx_m) が u=0.0 に写ること．

        仕様: clip_x=-1 → u=(-1+1)/2=0.0
        """
        vertices_m = np.array([[-self.sx_m, 0.0, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 0]) == pytest.approx(0.0, abs=1e-5), "X- 端は u=0.0"

    def test_y_plus_boundary_maps_to_v_1(self):
        """物理 Y+ 端 (world_y = +sy_m) が v=1.0 に写ること．

        仕様: clip_y=+1 → v=(1+1)/2=1.0
        """
        vertices_m = np.array([[0.0, self.sy_m, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 1]) == pytest.approx(1.0, abs=1e-5), "Y+ 端は v=1.0"

    def test_y_minus_boundary_maps_to_v_0(self):
        """物理 Y- 端 (world_y = -sy_m) が v=0.0 に写ること．

        仕様: clip_y=-1 → v=(1-1)/2=0.0
        """
        vertices_m = np.array([[0.0, -self.sy_m, 0.0]], dtype=np.float64)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert float(uv[0, 1]) == pytest.approx(0.0, abs=1e-5), "Y- 端は v=0.0"

    def test_uv_range_in_0_1_for_in_range_vertices(self):
        """センサ範囲内の全頂点の UV が [0, 1] に収まること．

        仕様: UV テクスチャ座標 ∈ [0, 1]（センサ外は [0,1] クランプ）
        """
        rng = np.random.default_rng(42)
        n = 100
        xs = rng.uniform(-self.sx_m, self.sx_m, n)
        ys = rng.uniform(-self.sy_m, self.sy_m, n)
        zs = np.zeros(n)
        vertices_m = np.stack([xs, ys, zs], axis=1)
        uv = compute_orthographic_uv(vertices_m, self.lum_shape, self.sensor)
        assert np.all(uv >= 0.0), "UV の全要素が 0.0 以上であること"
        assert np.all(uv <= 1.0), "UV の全要素が 1.0 以下であること"

    def test_out_of_range_vertices_are_clamped_to_0_1(self):
        """センサ範囲外の頂点が [0, 1] にクランプされること（例外なし）．

        仕様: センサ外（範囲外）の頂点は [0, 1] にクランプされる
        """
        far_vertices = np.array([
            [self.sx_m * 3.0,  self.sy_m * 3.0,  0.0],
            [-self.sx_m * 3.0, -self.sy_m * 3.0, 0.0],
        ], dtype=np.float64)
        uv = compute_orthographic_uv(far_vertices, self.lum_shape, self.sensor)
        assert np.all(uv >= 0.0) and np.all(uv <= 1.0), (
            "範囲外頂点の UV が [0, 1] にクランプされること"
        )

    def test_uv_consistent_with_project_luminance_col_row_convention(self):
        """compute_orthographic_uv と project_luminance_to_vertices が共通の投影規約を使うこと．

        SPEC_03 規約: clip_x = world_x / sx_m, clip_y = world_y / sy_m
        u = (clip_x + 1) / 2 と col_f / W = (clip_x + 1) / 2 は同値．
        v = (clip_y + 1) / 2 と 1 - row_f / H = (1 + clip_y) / 2 は同値．
        """
        H, W = 64, 64
        sensor = _make_baseline_sensor(width=W, height=H)
        sx_m = sensor.ortho_scale[0] * 0.001
        sy_m = sensor.ortho_scale[1] * 0.001

        test_points = np.array([
            [0.0, 0.0, 0.0],
            [sx_m * 0.5, 0.0, 0.0],
            [0.0, sy_m * 0.5, 0.0],
            [-sx_m * 0.5, -sy_m * 0.5, 0.0],
        ], dtype=np.float64)

        uniform_map = np.ones((H, W), dtype=np.float64)

        uv = compute_orthographic_uv(test_points, (H, W), sensor)
        proj = project_luminance_to_vertices(
            test_points, uniform_map, sensor, clip_low=0.0, clip_high=100.0
        )

        assert np.all(uv >= 0.0) and np.all(uv <= 1.0)
        np.testing.assert_allclose(
            proj, np.ones(len(test_points), dtype=np.float32),
            atol=1e-4,
            err_msg="uniform_map での投影値は 1.0 であること（共通基底の確認）",
        )


# ===========================================================================
# 10. compute_face_camera_mask — 幾何位置ベース face 判定の仕様検証
# ===========================================================================


class TestComputeFaceCameraMask:
    """compute_face_camera_mask の仕様ベーステスト．

    仕様（projection.py docstring）:
        face centroid の view_dir 方向射影値が「proj_min + tol_m 以内」の face を True とする．
        baseline: sensor が -Z から +Z を向く → view_dir ≈ (0,0,1)、proj ≈ world_z．
        評価プレート（Z=-4.5mm = -0.0045m）は proj_min に近く True になる．
        外側 AR マーカー等（Z > -4.5mm）は False になる．
        STL 巻き順・法線符号に非依存（幾何位置ベース）．
    """

    @pytest.fixture(autouse=True)
    def setup_baseline_sensor(self):
        """SPEC_03 baseline センサを設定する（-Z から +Z を向く）．"""
        self.sensor = _make_baseline_sensor(
            position=(0.0, 0.0, -50.0),
            target=(0.0, 0.0, 0.0),
        )

    def test_inner_face_at_z_min_is_true(self):
        """Z_min 端（センサ側・内側面）の face が True になること．

        仕様: view_dir=(0,0,1)のとき proj_min は最小 Z 端．
        その面の face centroid は proj_min + tol_m 以内 → True．
        """
        verts = np.array([
            [0.0, 0.0, -0.005],   # v0 inner
            [0.01, 0.0, -0.005],  # v1 inner
            [0.01, 0.01, -0.005], # v2 inner
            [0.0, 0.0,  0.005],   # v3 outer
            [0.01, 0.0,  0.005],  # v4 outer
            [0.01, 0.01, 0.005],  # v5 outer
        ], dtype=np.float64)
        faces = np.array([
            [0, 1, 2],  # inner face  (Z=-5mm)
            [3, 4, 5],  # outer face  (Z=+5mm)
        ], dtype=np.int64)

        mask = compute_face_camera_mask(verts, faces, self.sensor)
        assert mask.shape == (2,)
        assert bool(mask[0]) is True, "Z_min 端の face は True（カメラ側）"
        assert bool(mask[1]) is False, "Z_max 端の face は False（外側）"

    def test_all_faces_same_z_all_true(self):
        """全 face が同一 Z のとき（均一平板）全 face が True になること．

        仕様: proj_min = proj_max のとき、全 face centroid が proj_min + tol_m 以内 → 全 True
        """
        verts = np.array([
            [0.0, 0.0, -0.005],
            [0.01, 0.0, -0.005],
            [0.01, 0.01, -0.005],
            [0.0, 0.01, -0.005],
        ], dtype=np.float64)
        faces = np.array([
            [0, 1, 2],
            [0, 2, 3],
        ], dtype=np.int64)

        mask = compute_face_camera_mask(verts, faces, self.sensor)
        assert np.all(mask), "全面同一 Z の場合は全 face が True"

    def test_mask_is_independent_of_winding_order(self):
        """face の巻き順が逆でも mask 結果が変わらないこと（巻き順非依存）．

        仕様: STL の巻き順は作者依存で信頼できないため、face 法線の内積による判定をやめ、
        face centroid の位置ベースで判定する．
        """
        all_verts = np.array([
            [0.0, 0.0, -0.005],
            [0.01, 0.0, -0.005],
            [0.01, 0.01, -0.005],
            [0.0, 0.0,  0.005],
            [0.01, 0.0,  0.005],
            [0.01, 0.01, 0.005],
        ], dtype=np.float64)

        faces_cw = np.array([[0, 1, 2], [3, 4, 5]], dtype=np.int64)   # 正順
        faces_ccw = np.array([[0, 2, 1], [3, 5, 4]], dtype=np.int64)  # 逆順

        mask_cw = compute_face_camera_mask(all_verts, faces_cw, self.sensor)
        mask_ccw = compute_face_camera_mask(all_verts, faces_ccw, self.sensor)

        np.testing.assert_array_equal(
            mask_cw, mask_ccw,
            err_msg="巻き順を逆にしても mask 結果が変わらないこと（幾何位置ベース）"
        )

    def test_tol_m_controls_threshold_width(self):
        """tol_m の値が閾値幅を制御すること．

        仕様: face centroid の proj が proj_min + tol_m 以下の face が True．
        """
        # 3 層：Z=-5mm（最内）, Z=-3mm（中間）, Z=+5mm（外側）
        verts = np.array([
            [0.0, 0.0, -0.005],   # inner
            [0.01, 0.0, -0.005],
            [0.01, 0.01, -0.005],
            [0.0, 0.0, -0.003],   # middle
            [0.01, 0.0, -0.003],
            [0.01, 0.01, -0.003],
            [0.0, 0.0,  0.005],   # outer
            [0.01, 0.0,  0.005],
            [0.01, 0.01, 0.005],
        ], dtype=np.float64)
        faces = np.array([
            [0, 1, 2],  # Z=-5mm
            [3, 4, 5],  # Z=-3mm
            [6, 7, 8],  # Z=+5mm
        ], dtype=np.int64)

        mask_tight = compute_face_camera_mask(verts, faces, self.sensor, tol_m=0.0005)
        mask_loose = compute_face_camera_mask(verts, faces, self.sensor, tol_m=0.003)

        assert int(mask_tight.sum()) <= int(mask_loose.sum()), (
            "tol_m が小さいほど True の face 数が少ない（または同じ）こと"
        )
        assert bool(mask_tight[0]) is True, "Z_min 端の face は常に True"
        assert bool(mask_loose[2]) is False, "Z_max 端の face は tol_m=3mm でも False"

    def test_returns_bool_array_of_correct_shape(self):
        """戻り値が (N_f,) bool 配列であること．

        仕様: bool 配列 (N_f,)
        """
        verts = np.array([
            [0.0, 0.0, 0.0],
            [0.01, 0.0, 0.0],
            [0.01, 0.01, 0.0],
        ], dtype=np.float64)
        faces = np.array([[0, 1, 2]], dtype=np.int64)

        mask = compute_face_camera_mask(verts, faces, self.sensor)
        assert mask.dtype == bool or mask.dtype == np.bool_, "dtype は bool であること"
        assert mask.shape == (1,)

    def test_position_target_identical_raises_value_error(self):
        """position == target のとき ValueError が発生すること．

        仕様: sensor position と target が一致している場合 ValueError
        """
        sensor = _make_baseline_sensor(
            position=(0.0, 0.0, 0.0),
            target=(0.0, 0.0, 0.0),
        )
        verts = np.array([
            [0.0, 0.0, 0.0],
            [0.01, 0.0, 0.0],
            [0.01, 0.01, 0.0],
        ], dtype=np.float64)
        faces = np.array([[0, 1, 2]], dtype=np.int64)

        with pytest.raises(ValueError):
            compute_face_camera_mask(verts, faces, sensor)
