diff --git "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" index 80a499e..305bef6 100644 --- "a/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" +++ "b/docs/04_SPEC/SPEC_02_\343\202\267\343\203\274\343\203\263\345\256\232\347\276\251\344\273\225\346\247\230.md" @@ -103,9 +103,17 @@ - id: led_ring_segment type: area - shape: rectangle + shape: rectangle # rectangle | disk center: [0, 0, 20] - size: [10, 10] # mm + size: [10, 10] # mm(shape=rectangle で必須) + direction: [0, 0, -1] + radiance: [50, 50, 50] + + - id: led_disk # 円形面光源(軸対称の理想化光源等,TEST_11 で追加) + type: area + shape: disk + center: [0, 0, -142] + radius: 1.766 # mm(shape=disk で必須,正値) direction: [0, 0, -1] radiance: [50, 50, 50] @@ -123,7 +131,7 @@ | type | Mitsuba 対応 emitter | 用途 | 主要パラメータ | | --- | --- | --- | --- | | `point` | `point` | 点光源(LED 単体モデル等) | `position`, `intensity`, `profile` | -| `area` | `area`(rectangle 形状) | 面光源(矩形) | `center`, `size`, `direction`, `radiance` | +| `area` | `area`(rectangle / disk 形状) | 面光源(矩形または円板.`shape` に応じて `size` / `radius` が必須) | `center`, `size` または `radius`, `direction`, `radiance` | | `directional` | `directional` | 平行光源(無限遠光源) | `direction`, `irradiance` | | `spot` | `spot` | スポット光源(円錐配光・LED 指向性モデル,Step 8-10 で追加) | `position`, `direction`, `cutoff_angle`, `beam_width`, `intensity` | diff --git a/scripts/show_led_geometry.py b/scripts/show_led_geometry.py index 278000b..d7bb2b6 100644 --- a/scripts/show_led_geometry.py +++ b/scripts/show_led_geometry.py @@ -36,7 +36,8 @@ def _led_polydata(light, *, buggy: bool) -> pv.PolyData: """LED の発光面 PolyData を返す. - fixed: light.size どおりの実サイズ長方形(修正後). + fixed: shape=rectangle は light.size どおりの実サイズ長方形, + shape=disk は light.radius の実サイズ円板. buggy: 修正前の変換が実際に作っていた形=幅 light.size[0] × 高さ 2000mm の縦ストリップ (全 LED が direction=(0,0,-1)/up=(0,1,0) のため local X→world X・local Y→world Y で Y 方向に伸びる).忠実に 4 隅から構成する. @@ -45,6 +46,14 @@ direction = np.array(light.direction, dtype=float) direction = direction / np.linalg.norm(direction) if not buggy: + if getattr(light, "shape", None) == "disk": + return pv.Disc( + center=center.tolist(), + inner=0.0, + outer=light.radius, + normal=direction.tolist(), + c_res=64, + ) return pv.Plane( center=center.tolist(), direction=direction.tolist(), @@ -159,10 +168,12 @@ scene_config = load_scene_yaml(args.scene) mode = "BUGGY (2m strip)" if args.buggy else "FIXED" - print( - f"[{mode}] LED 数: {len(scene_config.lights)} size(mm): {scene_config.lights[0].size} " - f"direction: {scene_config.lights[0].direction}" - ) + first = scene_config.lights[0] + if getattr(first, "shape", None) == "disk": + dims = f"radius(mm): {first.radius}" + else: + dims = f"size(mm): {first.size}" + print(f"[{mode}] LED 数: {len(scene_config.lights)} {dims} direction: {first.direction}") if args.screenshot: plotter = _build_plotter(scene_config, off_screen=True, buggy=args.buggy) diff --git a/src/sm_tias_lightsim/scene/lights.py b/src/sm_tias_lightsim/scene/lights.py index d37d83b..e9c91bc 100644 --- a/src/sm_tias_lightsim/scene/lights.py +++ b/src/sm_tias_lightsim/scene/lights.py @@ -19,18 +19,20 @@ def _build_area_light_transform( center: tuple[float, float, float], - size: tuple[float, float], + half_extents: tuple[float, float], direction: tuple[float, float, float], position_scale: float, ) -> mi.ScalarTransform4f: - """AreaLight の中心・サイズ・方向から Mitsuba の to_world Transform を組み立てる. + """AreaLight の中心・半幅・方向から Mitsuba の to_world Transform を組み立てる. - Mitsuba の rectangle 形状は XZ 平面上の -1〜+1 の正方形(Y 軸正方向が前面). + Mitsuba の rectangle / disk 形状はどちらも XY 平面上の単位図形 + (rectangle は ±1 の正方形,disk は半径 1 の円)で法線が +Z. direction ベクトルが面の法線方向を指すように look_at を構成する. Args: center: 面中心位置(元単位). - size: 面サイズ (width, height)(元単位). + half_extents: X / Y 方向の半幅 (hx, hy)(元単位). + rectangle は (width/2, height/2),disk は (radius, radius) を渡す. direction: 放射方向ベクトル(単位ベクトルでなくても可). position_scale: 座標スケール係数(mm→m なら 0.001). @@ -38,7 +40,7 @@ Mitsuba ScalarTransform4f. """ cx, cy, cz = [c * position_scale for c in center] - sw, sh = size[0] * position_scale, size[1] * position_scale + hx, hy = half_extents[0] * position_scale, half_extents[1] * position_scale dx, dy, dz = direction norm = (dx**2 + dy**2 + dz**2) ** 0.5 @@ -60,8 +62,8 @@ transform = mi.ScalarTransform4f().look_at(origin=origin, target=target, up=up) - # サイズに合わせてスケール(rectangle は ±1 なので half-size でスケール) - scale = mi.ScalarTransform4f().scale([sw / 2.0, sh / 2.0, 1.0]) + # 単位図形(±1 / 半径 1)を半幅でスケールして実寸に合わせる + scale = mi.ScalarTransform4f().scale([hx, hy, 1.0]) return transform @ scale @@ -77,7 +79,8 @@ Returns: Mitsuba scene dict のキーに使える dict. - PointLight → {"type": "point", "position": [...], "intensity": {...}} - - AreaLight → {"type": "rectangle", "to_world": ..., "emitter": {"type": "area", ...}} + - AreaLight → {"type": "rectangle" | "disk", "to_world": ..., + "emitter": {"type": "area", ...}} - DirectionalLight → {"type": "directional", "direction": [...], "irradiance": {...}} Raises: @@ -95,14 +98,19 @@ } if isinstance(light, AreaLight): + # 半幅: rectangle は size の半分,disk は radius(スキーマで必須検証済み) + if light.shape == "disk": + half_extents = (light.radius, light.radius) + else: + half_extents = (light.size[0] / 2.0, light.size[1] / 2.0) to_world = _build_area_light_transform( center=light.center, - size=light.size, + half_extents=half_extents, direction=light.direction, position_scale=position_scale, ) return { - "type": "rectangle", + "type": light.shape, "to_world": to_world, "emitter": { "type": "area", diff --git a/src/sm_tias_lightsim/scene/schema.py b/src/sm_tias_lightsim/scene/schema.py index 4403901..6eee4fa 100644 --- a/src/sm_tias_lightsim/scene/schema.py +++ b/src/sm_tias_lightsim/scene/schema.py @@ -214,16 +214,38 @@ class AreaLight(_FrozenBase): - """面光源(矩形).""" + """面光源(矩形または円板). + + shape="rectangle" は `size`(幅×高さ),shape="disk" は `radius` で + 発光面の大きさを指定する(どちらも該当フィールドが必須). + """ id: str = Field(..., description="シーン内ユニーク ID") type: Literal["area"] - shape: Literal["rectangle"] = Field(..., description="面形状") + shape: Literal["rectangle", "disk"] = Field(..., description="面形状") center: Vec3 = Field(..., description="中心位置 [mm]") - size: tuple[float, float] = Field(..., description="サイズ [mm, mm]") + size: tuple[float, float] | None = Field( + None, description="矩形サイズ [mm, mm](shape=rectangle で必須)" + ) + radius: float | None = Field(None, description="円板半径 [mm](shape=disk で必須,正値)") direction: Vec3 = Field(..., description="放射方向ベクトル") radiance: float | Vec3 = Field(..., description="放射輝度(スカラまたは RGB タプル)") + @model_validator(mode="after") + def _check_shape_params(self) -> AreaLight: + """shape に応じた必須フィールド(size / radius)の整合を検証する.""" + if self.shape == "rectangle" and self.size is None: + msg = f"AreaLight '{self.id}': shape=rectangle には size が必須です" + raise ValueError(msg) + if self.shape == "disk": + if self.radius is None: + msg = f"AreaLight '{self.id}': shape=disk には radius が必須です" + raise ValueError(msg) + if self.radius <= 0: + msg = f"AreaLight '{self.id}': radius は正値を指定してください({self.radius})" + raise ValueError(msg) + return self + class DirectionalLight(_FrozenBase): """平行光源(無限遠光源).""" diff --git a/src/sm_tias_lightsim/visualize/viewer.py b/src/sm_tias_lightsim/visualize/viewer.py index 089c44e..09b28ed 100644 --- a/src/sm_tias_lightsim/visualize/viewer.py +++ b/src/sm_tias_lightsim/visualize/viewer.py @@ -1907,19 +1907,28 @@ elif isinstance(light, AreaLight): center_m = [c * _MM_TO_M for c in light.center] - size_m = [light.size[0] * _MM_TO_M, light.size[1] * _MM_TO_M] dir_vec = np.array(light.direction, dtype=np.float64) dir_norm = np.linalg.norm(dir_vec) if dir_norm > _DIR_NORM_EPSILON: dir_vec = dir_vec / dir_norm - plane = pv.Plane( - center=center_m, - direction=dir_vec.tolist(), - i_size=size_m[0], - j_size=size_m[1], - ) + if light.shape == "disk": + plane = pv.Disc( + center=center_m, + inner=0.0, + outer=light.radius * _MM_TO_M, + normal=dir_vec.tolist(), + c_res=64, + ) + else: + size_m = [light.size[0] * _MM_TO_M, light.size[1] * _MM_TO_M] + plane = pv.Plane( + center=center_m, + direction=dir_vec.tolist(), + i_size=size_m[0], + j_size=size_m[1], + ) plotter.add_mesh(plane, color="orange", opacity=0.7, label=f"AreaLight:{light.id}") arrow = pv.Arrow( diff --git a/tests/scene/test_lights.py b/tests/scene/test_lights.py index 447a825..860579a 100644 --- a/tests/scene/test_lights.py +++ b/tests/scene/test_lights.py @@ -310,3 +310,55 @@ ) result = build_light_dict(light, position_scale=0.001) assert result["irradiance"]["value"] == pytest.approx([1.0, 0.8, 0.6]) + + +# --------------------------------------------------------------------------- +# AreaLight (disk) +# --------------------------------------------------------------------------- + + +def test_area_light_disk_shape_type_is_disk(): + """shape=disk の AreaLight から生成される dict の type が "disk" であること. + + 仕様: AreaLight → {"type": "rectangle" | "disk", ...} (SPEC_01 scene/lights 責務) + """ + light = AreaLight( + id="led_disk", + type="area", + shape="disk", + center=(0.0, 0.0, -142.0), + radius=1.766, + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + result = build_light_dict(light, position_scale=0.001) + assert result["type"] == "disk" + assert "emitter" in result + assert result["emitter"]["type"] == "area" + + +def test_area_light_disk_to_world_scales_by_radius(): + """disk 光源の to_world が X/Y 軸を radius,法線軸を 1 でスケールすること. + + Mitsuba の disk は半径 1 の単位円板なので,to_world の 3×3 部の列ノルムが + (radius, radius, 1) × position_scale 相当になることを検証する + (TEST_08 のスケール軸バグ再発防止と同種の実寸検証.load_dict は使わない). + """ + light = AreaLight( + id="led_disk", + type="area", + shape="disk", + center=(0.0, 0.0, 0.0), + radius=5.0, + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + result = build_light_dict(light, position_scale=0.001) + m = result["to_world"].matrix + col_norms = sorted( + (m[0, j] ** 2 + m[1, j] ** 2 + m[2, j] ** 2) ** 0.5 for j in range(3) + ) + # 半径 5mm → 0.005m の列が 2 本,法線軸(スケール 1.0)の列が 1 本 + assert col_norms[0] == pytest.approx(0.005, rel=1e-6) + assert col_norms[1] == pytest.approx(0.005, rel=1e-6) + assert col_norms[2] == pytest.approx(1.0, rel=1e-6) diff --git a/tests/scene/test_schema.py b/tests/scene/test_schema.py index 9eed135..ad2f562 100644 --- a/tests/scene/test_schema.py +++ b/tests/scene/test_schema.py @@ -589,3 +589,66 @@ config = SceneConfig.model_validate(valid_minimal_dict) with pytest.raises(ValidationError): config.name = "modified" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# AreaLight の shape 別バリデーション(rectangle / disk) +# --------------------------------------------------------------------------- + + +def test_area_light_disk_valid(): + """shape=disk は radius 指定で正常に構築できること. + + 仕様: AreaLight shape=disk には radius が必須 (SPEC_02 lights セクション) + """ + light = AreaLight( + id="led_disk", + type="area", + shape="disk", + center=(0.0, 0.0, -142.0), + radius=1.766, + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + assert light.radius == pytest.approx(1.766) + assert light.size is None + + +def test_area_light_disk_requires_radius(): + """shape=disk で radius が無いとき ValidationError が発生すること.""" + with pytest.raises(ValidationError): + AreaLight( + id="led_disk", + type="area", + shape="disk", + center=(0.0, 0.0, -142.0), + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + + +def test_area_light_disk_rejects_nonpositive_radius(): + """shape=disk で radius が 0 以下のとき ValidationError が発生すること.""" + with pytest.raises(ValidationError): + AreaLight( + id="led_disk", + type="area", + shape="disk", + center=(0.0, 0.0, -142.0), + radius=0.0, + direction=(0.0, 0.0, -1.0), + radiance=50.0, + ) + + +def test_area_light_rectangle_requires_size(): + """shape=rectangle で size が無いとき ValidationError が発生すること.""" + with pytest.raises(ValidationError): + AreaLight( + id="led_area", + type="area", + shape="rectangle", + center=(0.0, 0.0, 20.0), + direction=(0.0, 0.0, -1.0), + radiance=50.0, + )