diff --git "a/configs/meshes/\347\220\203\343\203\255\343\203\225\343\203\210_r100.stl" "b/configs/meshes/\347\220\203\343\203\255\343\203\225\343\203\210_r100.stl" new file mode 100644 index 0000000..7b25787 --- /dev/null +++ "b/configs/meshes/\347\220\203\343\203\255\343\203\225\343\203\210_r100.stl" Binary files differ diff --git "a/configs/meshes/\347\220\203\346\256\273_r100.stl" "b/configs/meshes/\347\220\203\346\256\273_r100.stl" new file mode 100644 index 0000000..ad9f217 --- /dev/null +++ "b/configs/meshes/\347\220\203\346\256\273_r100.stl" Binary files differ diff --git "a/configs/meshes/\347\220\203\346\256\273_r100_fit.stl" "b/configs/meshes/\347\220\203\346\256\273_r100_fit.stl" new file mode 100644 index 0000000..106c785 --- /dev/null +++ "b/configs/meshes/\347\220\203\346\256\273_r100_fit.stl" Binary files differ diff --git a/configs/scene_sphere_r100.yaml b/configs/scene_sphere_r100.yaml new file mode 100644 index 0000000..9e03cb7 --- /dev/null +++ b/configs/scene_sphere_r100.yaml @@ -0,0 +1,93 @@ +# TEST_11 球体筐体実験: 幾何レバーの理想極限(完全閉球 + 完全間接照明) +# +# 現行筐体(前側/後側/カバー/ホルダ/スマホ)をすべて外し, +# 評価板だけを現行と同位置に残して内半径 100mm の閉じた球殻で囲む. +# 光源は 1 灯のみ.軸上 z=-90(-Z 極壁から 50mm 手前)から壁向き (0,0,-1) に発光し, +# 評価板への直接光は幾何的にゼロ(間接照明思想の理想化.板は発光面の裏側). +# ※ 壁 5mm 手前だと直接照射キャップが半径 31mm と小さく 1/r^2 特異も生じて +# path tracer の分散が実用不能なレベルになるため(spp=64 で相対ノイズ 89%), +# 壁から 50mm に離して NEE 可視領域を球面積の 25% に拡大した. +# ptracer は orthographic センサ非対応(sample_direction 未実装)で使えない. +# +# 生成メッシュ: scripts/generate_sphere_housing.py +# uv run python scripts/generate_sphere_housing.py --radius 100 --thickness 3 --center 0 0 -40 +# 内包チェック: 評価板の最遠角 (49.5, 51, 12.5) は中心 (0,0,-40) から 88.4mm < 100mm +# +# 材料・センサ・レンダ設定は TEST_10 確定条件(blend w0.05_a0.1・spp=1024・d=16)を踏襲. +# radiance は現行 per-LED 校正値をそのまま使用(形状指標はスケール不変). +name: sphere_r100_single +description: > + 球体筐体実験(TEST_11).内半径 100mm の完全閉球殻 + 評価板(現行同位置)のみ. + 単灯を -Z 極付近に壁向きで置き,漏れゼロ・完全間接の理想積分球条件で + 照明均一性の幾何的上限を測る. +geometry: + meshes: + - id: sphere_housing + path: meshes/球殻_r100.stl + unit: mm + material: printer_white + - id: evaluation_board + path: meshes/照明ムラ補正ボード.stl + unit: mm + material: printer_white +materials: + printer_white: + type: blendbsdf + weight: 0.05 # specular 比(TEST_10 確定: 形を壊さない最弱の光沢) + components: + - type: diffuse + reflectance: + - 0.85 + - 0.85 + - 0.85 + - type: roughconductor + alpha: 0.1 + distribution: ggx +lights: +- id: led_pole_single + type: area + shape: rectangle + center: + - 0.0 + - 0.0 + - -90.0 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +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 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 16 + # TEST_10 基準は spp=1024 だが,本シーンは単灯・完全間接で per-pixel 分散が大きく + # spp=64 実測 σ=9.3% → 1024 でも 2.3% 残る.CoV 汚染を 1% 未満に抑えるため 8192 とする + # (unbiased なので比較の公平性は損なわれない.spp=64 で 17 秒 → 8192 で約 36 分) + spp: 8192 diff --git a/configs/scene_sphere_r100_fit.yaml b/configs/scene_sphere_r100_fit.yaml new file mode 100644 index 0000000..73fc727 --- /dev/null +++ b/configs/scene_sphere_r100_fit.yaml @@ -0,0 +1,89 @@ +# TEST_11 球体筐体実験・改訂版: 評価板が球に「すっぽり嵌まる」配置 +# +# scene_sphere_r100.yaml(板が球内に浮く配置)からの変更点: +# - 球中心を (0,0,-40) → (0,0,-92) へ +Z 側に移動(メッシュ 球殻_r100_fit.stl) +# 板の裏面 z=-4.5 での球断面円半径が 48.4mm となり,板(半幅 X 49.5 / Y 51mm)が +# 断面を隙間なく塞ぐ=キャビティは「球のお椀 + 評価板のフタ」になる +# (X 辺 1.1mm / Y 辺 2.6mm のオーバーラップ.四隅は球壁を貫通して外に出るが +# メッシュ相互貫入は Mitsuba で許容される) +# - LED を z=-90 → z=-142 へ移動(新しい -Z 極 z=-192 から 50mm 手前,壁向き維持) +# +# 幾何的帰結(レンダ画像に現れる想定): +# - 板の縁を回り込む光の経路が消える(板の裏 +Z 側は球殻とのみ接する閉ポケット) +# - orthographic 画像の四隅(板裏面の断面円 48.4mm より外側)はセンサ光線が +# 先に球内壁に当たるため板ではなく壁が写る +# - 球の +Z キャップ(z=-4.5〜+8)は板の実体・楕円窓穴の内部に埋没する +name: sphere_r100_fit_single +description: > + 球体筐体実験(TEST_11 改訂).内半径 100mm の閉球殻を中心 (0,0,-92) に置き, + 評価板(現行同位置)が球断面のフタとして隙間なく嵌まる配置. + 単灯を -Z 極付近に壁向きで置き,漏れゼロ・完全間接の理想積分球条件を維持する. +geometry: + meshes: + - id: sphere_housing + path: meshes/球殻_r100_fit.stl + unit: mm + material: printer_white + - id: evaluation_board + path: meshes/照明ムラ補正ボード.stl + unit: mm + material: printer_white +materials: + printer_white: + type: blendbsdf + weight: 0.05 # specular 比(TEST_10 確定: 形を壊さない最弱の光沢) + components: + - type: diffuse + reflectance: + - 0.85 + - 0.85 + - 0.85 + - type: roughconductor + alpha: 0.1 + distribution: ggx +lights: +- id: led_pole_single + type: area + shape: rectangle + center: + - 0.0 + - 0.0 + - -142.0 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +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 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 16 + # 単灯・完全間接の分散対策(scene_sphere_r100.yaml と同じ根拠) + spp: 8192 diff --git a/configs/scene_sphere_r100_loft.yaml b/configs/scene_sphere_r100_loft.yaml new file mode 100644 index 0000000..585e0f9 --- /dev/null +++ b/configs/scene_sphere_r100_loft.yaml @@ -0,0 +1,89 @@ +# TEST_11 球体筐体実験・ロフト一体化版: 評価板の輪郭と球を遷移スカートで接続 +# +# scene_sphere_r100_fit.yaml(球を板に貫入させる配置)からの変更点: +# - 筐体メッシュを 球殻_r100_fit.stl → 球ロフト_r100.stl に変更 +# (scripts/generate_sphere_loft_housing.py で生成.板輪郭の 1.5mm 内側の矩形リムを +# z=0 に置き=板の実体に 4.5mm 食い込むシール,z=-40 の球断面円 85.4mm まで +# Hermite ブレンドのロフトで接続,以深は球 r=100・中心 (0,0,-92) のお椀) +# - 板の四隅の貫通・縁の隙間が両方とも解消され,キャビティは +# 「球お椀+ロフトスカート+評価板のフタ」の一体構造になる +# - orthographic 画像はほぼ全面が板(四隅の壁写り込みが fit 版より減る) +# +# LED 単灯 (0,0,-142)・壁向き(直接光ゼロ)・材料 blend w0.05_a0.1・spp=8192 は +# fit 版と同一. +name: sphere_r100_loft_single +description: > + 球体筐体実験(TEST_11 ロフト一体化版).球 r=100mm のお椀と評価板輪郭を + ロフト遷移で一体化した閉キャビティ.評価板(現行同位置)がフタとして + 隙間なく嵌まる.単灯を -Z 極付近に壁向きで置き,漏れゼロ・完全間接の + 理想積分球条件を維持する. +geometry: + meshes: + - id: sphere_housing + path: meshes/球ロフト_r100.stl + unit: mm + material: printer_white + - id: evaluation_board + path: meshes/照明ムラ補正ボード.stl + unit: mm + material: printer_white +materials: + printer_white: + type: blendbsdf + weight: 0.05 # specular 比(TEST_10 確定: 形を壊さない最弱の光沢) + components: + - type: diffuse + reflectance: + - 0.85 + - 0.85 + - 0.85 + - type: roughconductor + alpha: 0.1 + distribution: ggx +lights: +- id: led_pole_single + type: area + shape: rectangle + center: + - 0.0 + - 0.0 + - -142.0 + size: + - 2.8 + - 3.5 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +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 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 16 + # TEST_09/10 系と条件統一(ユーザー判断: 比較の公平性 > 単灯の MC ノイズ低減) + # 参考: 単灯・完全間接のため残留ノイズは spp=1024 で約 2%(CoV への汚染は +0.002 程度) + spp: 1024 diff --git a/configs/scene_sphere_r100_loft_disk.yaml b/configs/scene_sphere_r100_loft_disk.yaml new file mode 100644 index 0000000..33cc54a --- /dev/null +++ b/configs/scene_sphere_r100_loft_disk.yaml @@ -0,0 +1,83 @@ +# TEST_11 球体筐体実験・円形光源版: 光源も軸対称に理想化 +# +# scene_sphere_r100_loft.yaml(矩形 LED 2.8×3.5mm)からの変更点: +# - 発光面を shape: rectangle → shape: disk(半径 1.7662mm)に変更 +# 面積 9.8mm² を保存(π×1.7662² ≈ 2.8×3.5)=同じ radiance で全光束も不変 +# - 矩形 LED は実機由来のアーティファクトで系の軸対称性をわずかに破る最後の +# 要素だったため,板・球・ロフトと同軸の円板にして完全な軸対称セットアップにする +# +# 位置 (0,0,-142)・向き (0,0,-1)=壁向き(評価板への直接光ゼロ)・radiance・ +# 筐体メッシュ(球ロフト一体)・材料・センサ・レンダ設定(spp=1024・d=16)は +# loft 版と同一. +name: sphere_r100_loft_disk +description: > + 球体筐体実験(TEST_11 円形光源版).球お椀+板輪郭ロフト一体キャビティに, + 矩形 LED と同面積の円板単灯(半径 1.7662mm)を軸上に壁向きで置いた + 完全軸対称の理想積分球条件. +geometry: + meshes: + - id: sphere_housing + path: meshes/球ロフト_r100.stl + unit: mm + material: printer_white + - id: evaluation_board + path: meshes/照明ムラ補正ボード.stl + unit: mm + material: printer_white +materials: + printer_white: + type: blendbsdf + weight: 0.05 # specular 比(TEST_10 確定: 形を壊さない最弱の光沢) + components: + - type: diffuse + reflectance: + - 0.85 + - 0.85 + - 0.85 + - type: roughconductor + alpha: 0.1 + distribution: ggx +lights: +- id: led_pole_disk + type: area + shape: disk + center: + - 0.0 + - 0.0 + - -142.0 + radius: 1.7662 # 矩形 LED 2.8×3.5mm と同面積 9.8mm² の円板 + direction: + - 0.0 + - 0.0 + - -1.0 + radiance: + - 2807.2469840551203 + - 2807.2469840551203 + - 2807.2469840551203 +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 + rfilter: gaussian +render: + variant: llvm_ad_rgb + integrator: path + max_depth: 16 + # TEST_09/10 系と条件統一(比較の公平性 > 単灯の MC ノイズ低減) + spp: 1024 diff --git "a/docs/06_TEST/TEST_11_\347\220\203\344\275\223\347\255\220\344\275\223\345\256\237\351\250\223.md" "b/docs/06_TEST/TEST_11_\347\220\203\344\275\223\347\255\220\344\275\223\345\256\237\351\250\223.md" new file mode 100644 index 0000000..cc8d165 --- /dev/null +++ "b/docs/06_TEST/TEST_11_\347\220\203\344\275\223\347\255\220\344\275\223\345\256\237\351\250\223.md" @@ -0,0 +1,130 @@ +# 球体筐体実験 (Spherical Housing Experiment) + +> ⚠️ **BEFORE FIX 注記**: 本ドキュメントの数値はすべて**シェーディング法線アーティファクト +> ([TEST_12](TEST_12_シェーディング法線アーティファクトの発見と修正.md),2026-08-18 修正)込み**で +> 測定されたものである.本実験の完全軸対称化がこのバグの発見につながった.修正後の再測定では +> **現行筐体 CoV 0.0610 / 球ロフト 0.0628 と両者ほぼ同等**(実機 0.0695)であり, +> 「幾何理想極限でも CoV 床 ≈0.12」「球で現行比 -30% 改善」という本文の結論は**撤回**する. +> 現行筐体はすでに理想積分球に近い混合性能を持つ,が修正後の結論. +> 実験設計・経緯・修正前の相対比較の記録として本文を保持する. + +## 背景と目的 (Background & Purpose) + +[TEST_10](TEST_10_後側ロフト変形w1p4実験.md) で,ロフト重み(対称なフレア拡大)は体積 +66% まで振っても全灯分布に実質不感と確定した.本実験は幾何レバーを**極端側に振り切る**: 実機筐体の構成(前側/後側/カバー/ホルダ/スマホ)をすべて外し,評価板だけを共通土台として残して**完全に閉じた球殻**で囲み,**単灯・完全間接照明**という理想積分球条件で照明均一性がどこまで到達するかの**上限(upper bound)**を測る. + +- 球(積分球)は拡散反射による光の混合が理論上最も効く形状であり,漏れゼロ・対称・完全間接の組は「幾何+閉じ方の理想極限」に相当する +- この上限が実機(CoV 0.0695・mean/max 0.899)に届くかどうかで,「実機との残差のうち幾何で説明できる範囲」を切り分けられる +- 光源が 1 灯のため,灯間 NCC(非対称相補性)は本実験の検証対象外 + +## シーン構成の事実記録 (Scene Facts) + +| 要素 | 内容 | +| --- | --- | +| 球殻 | `configs/meshes/球殻_r100.stl`.内半径 100mm・厚 3mm・中心 (0, 0, -40).`scripts/generate_sphere_housing.py` で生成(icosphere subdiv=6 の内外 2 面 163,840 faces,watertight,体積 388,361mm³ で理論値と一致=内面法線反転の正当性確認済み).開口なしの完全閉球 | +| 評価板 | `照明ムラ補正ボード.stl` を現行と同位置・同材料のまま内包(最遠角 (49.5, 51, 12.5) は中心から 88.4mm < 100mm) | +| 光源 | **1 灯のみ**.現行と同じ 2.8×3.5mm area LED を軸上 (0, 0, -90)(-Z 極壁から 50mm 手前)に置き,向き (0, 0, -1) =壁向き.発光半球が後方壁側を向くため**評価板への直接光は幾何的にゼロ** | +| 材料 | 球殻・評価板とも blend w0.05_a0.1(TEST_10 確定条件) | +| radiance | 2807.247 W/(m²·sr)(現行 per-LED 校正値をそのまま使用.形状指標はスケール不変) | +| センサ | 現行と完全同一(orthographic Z=-50・ortho_scale (50, 52)・512²) | +| レンダ | path・d=16・**spp=8192**(下記「分散対策の経緯」参照).llvm_ad_rgb で約 36 分 | +| config / 出力 | `configs/scene_sphere_r100.yaml` → `output/sphere_r100/` | + +### 分散対策の経緯(LED 位置と spp) + +当初案「壁 5mm 手前 (z=-135)」は path tracer の分散が実用不能だった(spp=64 で相対ノイズ σ=89%.直接照射キャップが半径 31mm と小さく NEE がほぼ外れる+壁 5mm の 1/r² 特異で firefly).対策として: + +- **LED を z=-90(壁から 50mm)へ移動**: NEE 可視領域が球面積の 25% に拡大し σ=9.3%@spp64 まで 10 倍改善.「評価板への直接光ゼロ」は厳密に維持される(板は発光面の裏側) +- **spp=1024 → 8192**: 残留ノイズ実測 σ=0.79%(CoV 0.135 への汚染は二乗和で +0.02% と無視可能).unbiased なので比較の公平性は不変 +- Mitsuba 3.8 の `ptracer`(光源側追跡)は orthographic センサ非対応(`Endpoint::sample_direction(): not implemented`)で本用途には使えない + +## 結果 (Results) + +実行日: 2026-08-17.数値は `output/sphere_r100/{summary.csv,metrics.csv}`,比較図は `output/_slide_panels/sphere_r100_{shape_roi,relative}.png`.評価は TEST_10 と同一パイプライン(`eval_baseline_dng_target_shape.yaml`・SIM_ROI・mean アンカー 207.82). + +| 指標 | 現行筐体 全灯 18 灯 (full_base) | **球体 r100 単灯** | 実機 全灯 18 灯 | +| --- | --- | --- | --- | +| CoV (ROI) | 0.1717 | **0.1350** | 0.0695 | +| peak/mean (ROI) | 1.491 | **1.323** | 1.155 | +| top/bot (ROI) | 0.878 | **1.021** | 0.919 | +| C/P ratio | 1.328 | 1.353 | 1.091 | +| gradient [%] | 24.72 | 26.10 | 8.35 | +| angular_ratio | 0.352 | **0.126** | — | +| centroid offset (norm) | 0.043 | **0.005** | — | +| NCC 対実機 (ROI) | 0.744 | **0.460** | 1.0 | +| mean/max(相対輝度) | 0.671 | **0.756** | 0.899 | +| min/max(相対輝度) | 0.241 | **0.531** | 0.660 | + +分布の形: 中央明・周辺減光の滑らかな軸対称ドーム.断面プロファイルに不連続なし(最大段差 <1% =ノイズレベル,楕円窓縁の段差は生じない).周辺減光は Y 方向(-38%)が X 方向(-15%)より強く,これは楕円窓・ROI 形状を含む評価板系の共通特徴(3 セル同一条件のため比較は成立). + +## 読み取れること (Findings) + +1. **幾何の理想極限でも CoV 0.135 まで**(現行比 -21%,mean/max 0.671→0.756).漏れゼロ・完全対称・完全間接の積分球ですら実機の 0.0695 / 0.899 には遠く及ばない.**筐体幾何の追い込みだけではシミュ上の均一性を実機水準まで持っていけない**ことの上限実証. +2. **形は質的に変わる**: Y 軸単調勾配(angular_ratio 0.352・centroid offset 0.043)→ 中心対称ドーム(0.126・0.005,top/bot 1.02).幾何+光源配置の対称化で「分布の非対称性」はほぼ消せる=幾何レバーは「形の対称性」には強く効く. +3. しかし **NCC 対実機はむしろ低下**(0.744 → 0.460).実機の形(Y 勾配+斜め構造)は「理想混合の形」とは構造的に別物であり,実機マップを幾何の理想化で近づけることはできない.実機の平坦さは ISP レンズシェーディング補正(SPEC_03 既知差分 #6)等の非幾何要因が本命,という Step 8-14 の推定と整合する. +4. **残る CoV 0.135 の物理源**: R=0.85 の有限反射率では「未混合の一次照射成分」が消えない.一次照射キャップ(-Z 極周辺)を評価板の各点が異なる見込み角で見るため,cos 依存のドーム状落ち込みが残る.積分球の理想(壁面ポートに試料を置く)と違い,板がキャビティ内に浮いた面である限りこの成分は幾何では消せない.改善余地はむしろ反射率 R↑(未混合成分の相対比↓)や光源多灯化・対称配置側にある. + +## 設計への示唆 (Implications) + +- 球体筐体は「対称できれいな形」を作るが,均一性の絶対量では現行筐体比 2 割改善にとどまる.**実機再設計の優先レバーとしては弱い**(製作性の悪さに見合わない). +- TEST_09/10 から続く結論がさらに強化された: 実機との残差(絶対均一性・非対称相補性)は**筐体幾何の外**にある.非対称幾何(漏れ・開口の非対称)の検証は TEST_09 実験4(幾何の漏れ点検)として別途残っている. +- 本実験で単灯・高 spp・幾何スクリプト生成のワークフロー(`generate_sphere_housing.py` → シーン YAML → `build_sphere_panel.py`)が整ったため,仮想幾何の理想化実験は今後低コストで追加できる(例: 半径スイープ,R=0.92 球,多灯球面配置). + +## 改訂 — 評価板嵌め込み・ロフト一体化版 (Revision: Loft-blended Housing) + +初版(上記)は評価板が球内に**浮いた**配置で,板の縁と球壁の間の隙間を光が回り込めた.ユーザー指摘「評価板がすっぽり嵌まるように置きたい」を受けて 2 段階で改訂した(2026-08-18): + +1. **fit 版**(`configs/scene_sphere_r100_fit.yaml`・`球殻_r100_fit.stl`): 球中心を (0,0,-92) へ移動し板裏面で断面円 48.4mm =板が断面を塞ぐ配置.ただし矩形と円の不整合で四隅が球壁を貫通・ROI 四隅に壁が写る.3D 確認段階でロフト版に置き換え(**レンダ未実施**) +2. **ロフト一体化版**(`configs/scene_sphere_r100_loft.yaml`・`球ロフト_r100.stl`): Fusion のロフトに相当する「板輪郭矩形 → 球断面円」の遷移スカートをスクリプト生成(`scripts/generate_sphere_loft_housing.py`.板輪郭 1.5mm 内側のリムを z=0=板実体に 4.5mm 食い込む高さに置いてシール,z=-40 の断面円 85.4mm へ Hermite ブレンド=球側接線連続,以深は球 r=100・中心 (0,0,-92) のお椀.watertight 縫合).LED は新 -Z 極(z=-192)の 50mm 手前 z=-142・壁向き(直接光ゼロ維持) + +あわせて 2 つの運用変更(ユーザー指示): + +- **レンダ前の 3D 配置確認を必須化**(`sm-visualize --no-bake` / `show_led_geometry.py` で確認画像を提示 → OK 後にレンダ.CLAUDE.md 必須ルールに追記) +- **spp を 8192 → 1024 に統一**(TEST_09/10 系との条件統一を優先.残留ノイズ実測 2.3%,CoV への汚染 +0.002 で解釈に影響なし) + +### 結果(ロフト一体化版) + +実行日: 2026-08-18.数値は `output/sphere_r100_loft/{summary.csv,metrics.csv}`,比較図は `output/_slide_panels/sphere_loft_{shape_roi,relative}.png`(1×4 パネルに拡張). + +| 指標 | 現行筐体 全灯 | 球体 浮き配置 単灯 | **球ロフト一体 単灯** | 実機 全灯 | +| --- | --- | --- | --- | --- | +| CoV (ROI) | 0.1717 | 0.1350 | **0.1204** | 0.0695 | +| peak/mean (ROI) | 1.491 | 1.323 | **1.392** | 1.155 | +| top/bot (ROI) | 0.878 | 1.021 | **1.020** | 0.919 | +| C/P ratio | 1.328 | 1.353 | **1.310** | 1.091 | +| gradient [%] | 24.72 | 26.10 | **23.66** | 8.35 | +| angular_ratio | 0.352 | 0.126 | **0.110** | — | +| NCC 対実機 (ROI) | 0.744 | 0.460 | **0.445** | 1.0 | +| mean/max(相対輝度) | 0.671 | 0.756 | **0.718** | 0.899 | +| min/max(相対輝度) | 0.241 | 0.531 | **0.502** | 0.660 | + +### 読み取れること(改訂版) + +1. **縁のシール+一体化で CoV 0.135 → 0.120**(現行筐体比 -30%).板縁の隙間への回り込み・ROI 内の壁写り込みが消えた効果で,均一性の幾何上限がさらに更新された. +2. ただし **peak/mean は 1.323 → 1.392 と微増**(中央の明帯がやや細く尖る.mean/max も 0.756 → 0.718).くびれ(ロフトスカート)が板の周縁を覆う立体角を狭め,周辺照度が相対的に下がったためと解釈できる=**キャビティを閉じるほど CoV は下がるが中央集中はわずかに強まる**トレードオフ. +3. 対称性はさらに向上(angular_ratio 0.110・centroid offset 0.005・top/bot 1.02). +4. **結論は初版と不変**: 幾何+閉じ方の理想極限を突き詰めても CoV ≈ 0.12 が床で,実機 0.0695(ISP LSC 込み)には届かない.NCC 対実機 0.445 =実機の形とは別物のまま.残差の本命は非幾何要因(LSC 等)という結論がむしろ強化された. + +## 追補 — 円形光源版(完全軸対称化)(Addendum: Disk Emitter) + +ロフト一体化版の光源は矩形 LED(2.8×3.5mm)のままで,系の軸対称性を破る最後の要素だった.ユーザー指示により発光面を**同面積(9.8mm²)の円板(半径 1.7662mm)**に置換し,幾何・光源とも完全軸対称の構成にした(2026-08-18). + +- スキーマ拡張: area 光源に `shape: disk`(`radius` 必須)を追加(`rectangle`+`size` は後方互換).SPEC_02 更新,スケール実寸検証を含むテスト追加 +- config: `configs/scene_sphere_r100_loft_disk.yaml` → `output/sphere_r100_loft_disk/`.発光面形状以外はロフト版と完全同一(位置 (0,0,-142)・壁向き・radiance・spp=1024・d=16) + +### 結果(円形光源版) + +| 指標 | 球ロフト 矩形 LED | 球ロフト 円形光源 | +| --- | --- | --- | +| CoV (ROI) | 0.12044 | 0.12047 | +| peak/mean (ROI) | 1.3923 | 1.3931 | +| angular_ratio | 0.1098 | 0.1098 | +| centroid offset (norm) | 0.00472 | 0.00472 | +| NCC 対実機 (ROI) | 0.4452 | 0.4453 | + +**全指標が MC ノイズ(σ≈2.3%)内で完全一致**.完全間接キャビティでは発光面の形(矩形 vs 円板)は結果に影響しない=「Lambertian 拡散壁の多重反射が光源形状を洗い流す」(TEST_09 §追加観察)の再確認であり,矩形 LED の非軸対称性は残差要因ではないことが確定した.以後,**円形光源版(完全軸対称)を球体筐体実験の正準構成**とする. + +## 参照 (References) + +- [TEST_10_後側ロフト変形w1p4実験](TEST_10_後側ロフト変形w1p4実験.md): 幾何レバー(対称変形)の不感性 +- [TEST_02_シミュレーション実行記録](TEST_02_シミュレーション実行記録.md): 出力索引・再現コマンド +- [SPEC_03_基準設計の物理構成](../04_SPEC/SPEC_03_基準設計の物理構成.md): 既知差分 #6(ISP レンズシェーディング) diff --git a/scripts/build_sphere_panel.py b/scripts/build_sphere_panel.py new file mode 100644 index 0000000..eb5388a --- /dev/null +++ b/scripts/build_sphere_panel.py @@ -0,0 +1,186 @@ +"""TEST_11/12 球体筐体実験(面法線修正後)の形比較パネル生成. + +セル = 現行筐体全灯(面法線修正)/ 球ロフト円形光源(面法線修正)/ 実機全灯 の 1×3. +TEST_10 と同じ流儀: シミュは SIM_ROI 抽出,実機はフル解像度のまま, +全セルを共通解像度へ resize して比較する. +TEST_12 のシェーディング法線バグ修正前の出力(output/sphere_r100{,_loft,_loft_disk} 等)は +法線条件が異なるため本パネルには混ぜない. + +生成するファイル: + output/sphere_r100_loft_disk_fix/summary.csv -- 形指標(peak/mean・top/bot・CoV・NCC) + output/_slide_panels/sphere_fix_shape_roi.png -- 1×3 形比較パネル(独立 1–99% 正規化) + output/_slide_panels/sphere_fix_relative.png -- 相対輝度(max=1.0・共通スケール) + +使い方: + uv run python scripts/build_sphere_panel.py + +前提: + - output/full_base_fix/luminance.npy(現行筐体全灯,configs/_tmp_fn_full_base.yaml) + - output/sphere_r100_loft_disk_fix/luminance.npy(球ロフト円形光源) + - lightsim/luminance_maps/full_MiniTIAS_QM_20260601_124500.npy(実機全灯) +""" + +from __future__ import annotations + +import csv +import sys +from pathlib import Path + +import matplotlib +import matplotlib.pyplot as plt +import numpy as np +from skimage.transform import resize + +from sm_tias_lightsim.evaluation.roi_presets import SIM_ROI, extract_roi_xywh +from sm_tias_lightsim.evaluation.shape_metrics import calc_ncc + +matplotlib.rcParams["font.family"] = ["Meiryo", "Yu Gothic", "DejaVu Sans"] + +ROOT = Path(__file__).resolve().parents[1] +SUMMARY_CSV = ROOT / "output" / "sphere_r100_loft_disk_fix" / "summary.csv" +SHAPE_PANEL_PATH = ROOT / "output" / "_slide_panels" / "sphere_fix_shape_roi.png" +REL_PANEL_PATH = ROOT / "output" / "_slide_panels" / "sphere_fix_relative.png" + +# (ラベル, luminance.npy パス, シミュか否か).シミュのみ SIM_ROI を抽出する +# TEST_12 面法線修正後の出力のみで比較する(修正前の出力とはシェーディング法線が +# 異なり混在比較は不可.修正前の記録は TEST_11 の表を参照) +CELLS = [ + ( + "現行筐体 全灯 18 灯(面法線修正)", + ROOT / "output" / "full_base_fix" / "luminance.npy", + True, + ), + ( + "球ロフト 円形光源(面法線修正)", + ROOT / "output" / "sphere_r100_loft_disk_fix" / "luminance.npy", + True, + ), + ( + "実機 全灯 18 灯", + ROOT / "lightsim" / "luminance_maps" / "full_MiniTIAS_QM_20260601_124500.npy", + False, + ), +] + +CELL_SHAPE = (450, 464) +VMIN_PERCENTILE = 1.0 +VMAX_PERCENTILE = 99.0 + + +def _load_luminance(path: Path) -> np.ndarray: + """luminance.npy を読み込み 2D 輝度にする(3D RGB は Rec.709 変換).""" + arr = np.load(path) + if arr.ndim == 3: + arr = 0.2126 * arr[..., 0] + 0.7152 * arr[..., 1] + 0.0722 * arr[..., 2] + return arr.astype(np.float64) + + +def _metrics(img: np.ndarray, real: np.ndarray | None) -> dict[str, float]: + """TEST_09/10/11 共通の形指標(peak/mean・top/bot・CoV・NCC 対実機)を算出する.""" + h = img.shape[0] + m = { + "peak_mean": float(img.max() / img.mean()), + "top_bot": float(img[: h // 2].mean() / img[h // 2 :].mean()), + "cov": float(img.std() / img.mean()), + } + if real is not None: + m["ncc"] = float(calc_ncc(resize(img, real.shape, anti_aliasing=True), real)) + else: + m["ncc"] = float("nan") + return m + + +def main() -> int: + """パネルと summary CSV を生成するメインエントリポイント. + + Returns: + 終了コード(成功なら 0,入力不足なら 1). + """ + real = _load_luminance(CELLS[-1][1]) + loaded: list[tuple[str, np.ndarray, dict[str, float]]] = [] + for label, path, is_sim in CELLS: + if not path.exists(): + print(f"エラー: {path} が見つかりません", file=sys.stderr) + return 1 + img = _load_luminance(path) + if is_sim: + img = extract_roi_xywh(img, SIM_ROI) + m = _metrics(img, real if is_sim else None) + if not is_sim: + m["ncc"] = 1.0 + loaded.append((label, img, m)) + + SUMMARY_CSV.parent.mkdir(parents=True, exist_ok=True) + with SUMMARY_CSV.open("w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(["label", "peak_mean", "top_bot", "cov_roi", "ncc_vs_real"]) + for label, _, m in loaded: + writer.writerow( + [ + label, + f"{m['peak_mean']:.4f}", + f"{m['top_bot']:.4f}", + f"{m['cov']:.4f}", + f"{m['ncc']:.4f}", + ] + ) + print(f"summary 保存: {SUMMARY_CSV}") + + # 形比較パネル(各セル独立 1–99% 正規化) + n = len(loaded) + fig, axes = plt.subplots(1, n, figsize=(n * 4.2, 4.6), constrained_layout=True) + for ax, (label, img, m) in zip(axes, loaded, strict=True): + disp = resize(img / img.mean(), CELL_SHAPE, anti_aliasing=True) + ax.imshow( + disp, + cmap="hot", + vmin=float(np.percentile(disp, VMIN_PERCENTILE)), + vmax=float(np.percentile(disp, VMAX_PERCENTILE)), + aspect="equal", + ) + ax.set_title( + f"{label}\np/m={m['peak_mean']:.3f} t/b={m['top_bot']:.3f} NCC={m['ncc']:.3f}", + fontsize=10, + pad=6, + ) + ax.set_axis_off() + fig.suptitle( + "TEST_11 球体筐体 — blend w0.05_a0.1, d=16, ROI 揃え\n" + "各セル独立 1–99% 正規化(形の比較専用)・全セル共通解像度", + fontsize=12, + ) + SHAPE_PANEL_PATH.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(SHAPE_PANEL_PATH, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f"パネル保存: {SHAPE_PANEL_PATH}") + + # 相対輝度(各セル max=1.0,全セル共通スケール).実機も含めて絶対均一性を並べる. + # max は resize 後(MC ノイズ平滑化後)に取る(TEST_10 の流儀を踏襲) + rel_cells = [] + for label, img, _ in loaded: + disp = resize(img, CELL_SHAPE, anti_aliasing=True) + rel_cells.append((label, disp / disp.max())) + vmin = min(float(r.min()) for _, r in rel_cells) + fig2, axes2 = plt.subplots(1, n, figsize=(n * 4.4, 5.0), constrained_layout=True) + for ax, (label, rel) in zip(axes2, rel_cells, strict=True): + im = ax.imshow(rel, cmap="hot", vmin=vmin, vmax=1.0, aspect="equal") + ax.set_title( + f"{label}\nmean/max={rel.mean():.3f} min/max={rel.min():.3f}", + fontsize=10, + pad=6, + ) + ax.set_axis_off() + fig2.colorbar(im, ax=axes2, shrink=0.85, label="相対輝度(max=1.0)") + fig2.suptitle( + "TEST_11 球体筐体 — 相対輝度(各セル max=1.0,全セル共通スケール)\n" + "完全均一なら全面 1.0.mean/max・min/max が 1 に近いほど均一", + fontsize=12, + ) + fig2.savefig(REL_PANEL_PATH, dpi=150, bbox_inches="tight") + plt.close(fig2) + print(f"パネル保存: {REL_PANEL_PATH}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_sphere_housing.py b/scripts/generate_sphere_housing.py new file mode 100644 index 0000000..d3f8b7d --- /dev/null +++ b/scripts/generate_sphere_housing.py @@ -0,0 +1,123 @@ +# 球体筐体実験(TEST_11)用の球殻メッシュ生成スクリプト. +# +# 現行筐体(前側/後側)を理想化した「完全閉球」の白色キャビティに置き換え, +# 幾何レバーの理想極限(漏れゼロの積分球)で照明均一性の上限を測る実験に使う. +# 評価板(照明ムラ補正ボード)を内包できる中心・半径を引数で指定する. +"""球殻(integrating sphere 状の閉じた白キャビティ)STL を生成する. + +内半径 r の icosphere(法線を内向きに反転)と外半径 r+t の icosphere を +連結した watertight な厚み付き殻を作り,バイナリ STL として出力する. +単位は mm(プロジェクトのシーン YAML 規約に合わせる). + +使用例: + uv run python scripts/generate_sphere_housing.py \ + --radius 100 --thickness 3 --center 0 0 -40 \ + --output configs/meshes/球殻_r100.stl +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +import trimesh + + +def build_sphere_shell( + radius: float, + thickness: float, + center: tuple[float, float, float], + subdivisions: int, +) -> trimesh.Trimesh: + """厚み付き球殻メッシュを生成する. + + 内面(半径 radius)は法線をキャビティ側(中心向き)に反転させ, + 外面(半径 radius + thickness)は外向きのまま連結する. + 2 つの閉曲面から成るため全体として watertight な実体殻になる. + + Args: + radius: 内半径(mm).キャビティの半径. + thickness: 殻厚(mm). + center: 球中心座標 (x, y, z)(mm). + subdivisions: icosphere の分割回数.6 で辺長 ≈ radius * 0.03 程度. + + Returns: + 球殻メッシュ(mm 単位). + + Raises: + ValueError: radius または thickness が正でない場合. + """ + if radius <= 0: + raise ValueError(f"内半径は正の値を指定してください: {radius}") + if thickness <= 0: + raise ValueError(f"殻厚は正の値を指定してください: {thickness}") + + inner = trimesh.creation.icosphere(subdivisions=subdivisions, radius=radius) + # 内面の法線は実体(殻)から見て外側=キャビティ中心向きが正しいため反転する + inner.invert() + outer = trimesh.creation.icosphere(subdivisions=subdivisions, radius=radius + thickness) + + shell = trimesh.util.concatenate([outer, inner]) + shell.apply_translation(np.asarray(center, dtype=np.float64)) + return shell + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数をパースして返す.""" + parser = argparse.ArgumentParser( + prog="generate_sphere_housing", + description="球体筐体実験用の閉じた球殻 STL(mm 単位)を生成する.", + ) + parser.add_argument("--radius", type=float, default=100.0, help="内半径(mm).既定 100") + parser.add_argument("--thickness", type=float, default=3.0, help="殻厚(mm).既定 3") + parser.add_argument( + "--center", + type=float, + nargs=3, + default=(0.0, 0.0, -40.0), + metavar=("X", "Y", "Z"), + help="球中心座標(mm).既定 0 0 -40(評価板を内包する位置)", + ) + parser.add_argument( + "--subdivisions", + type=int, + default=6, + help="icosphere 分割回数.既定 6(面数 81920 x 2,辺長 ≈ 3mm)", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("configs/meshes/球殻_r100.stl"), + help="出力 STL パス.既定 configs/meshes/球殻_r100.stl", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """球殻を生成して STL 出力し,検証情報を表示する.""" + args = _parse_args(argv) + + shell = build_sphere_shell( + radius=args.radius, + thickness=args.thickness, + center=tuple(args.center), + subdivisions=args.subdivisions, + ) + + args.output.parent.mkdir(parents=True, exist_ok=True) + shell.export(args.output) + + # 期待体積 = (4/3)π((r+t)^3 - r^3).メッシュ体積との一致で内面反転の正しさを確認できる + expected_volume = ( + 4.0 / 3.0 * np.pi * ((args.radius + args.thickness) ** 3 - args.radius**3) + ) + print(f"出力: {args.output}") + print(f"faces: {len(shell.faces)}, watertight: {shell.is_watertight}") + print(f"bounds (mm): {shell.bounds.round(2).tolist()}") + print(f"体積 (mm^3): {shell.volume:.0f}(理論値 {expected_volume:.0f})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_sphere_loft_housing.py b/scripts/generate_sphere_loft_housing.py new file mode 100644 index 0000000..6d17653 --- /dev/null +++ b/scripts/generate_sphere_loft_housing.py @@ -0,0 +1,247 @@ +# TEST_11 改訂(球体筐体実験)用: 評価板の矩形輪郭と球を一体化した筐体メッシュ生成. +# +# 球単体では矩形の評価板と断面円の形が合わず,四隅の貫通か縁の隙間のどちらかが生じる. +# Fusion 360 のロフトに相当する「矩形 → 円」の遷移スカートをスクリプトで生成し, +# 球のお椀部分と 1 つの watertight ソリッドに縫合することで,評価板がフタとして +# 隙間なく嵌まる筐体を作る. +"""球+ロフト一体型筐体(評価板輪郭に整合する閉キャビティ)STL を生成する. + +構成(上から下へ,単位 mm): + 1. リム矩形: 評価板輪郭から rim_inset だけ内側の矩形.z=rim_z(板の実体に + 食い込む高さ)に置き,板とのオーバーラップで隙間なくシールする + 2. ロフト部: 矩形 → 球断面円(z=cut_z)への極座標 Hermite ブレンド. + 球側の端点勾配を球面の接線に一致させ滑らかに接続する + 3. 球部: 半径 radius・中心 (0,0,center_z) の球の z ≤ cut_z 部分(お椀) + +内面と外面(thickness だけオフセット)を生成し,リム帯で縫合した閉ソリッドを出力する. + +使用例: + uv run python scripts/generate_sphere_loft_housing.py \ + --output configs/meshes/球ロフト_r100.stl +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +import trimesh + + +def _rect_radius(theta: np.ndarray, half_x: float, half_y: float) -> np.ndarray: + """角度 theta ごとの矩形境界までの半径(極座標表現)を返す.""" + cos_t = np.abs(np.cos(theta)) + sin_t = np.abs(np.sin(theta)) + with np.errstate(divide="ignore"): + rx = np.where(cos_t > 1e-12, half_x / cos_t, np.inf) + ry = np.where(sin_t > 1e-12, half_y / sin_t, np.inf) + return np.minimum(rx, ry) + + +def _build_surface( + radius: float, + center_z: float, + half_x: float, + half_y: float, + rim_z: float, + cut_z: float, + n_theta: int, + n_loft: int, + n_sphere: int, +) -> tuple[np.ndarray, np.ndarray]: + """リム矩形 → ロフト → 球お椀の 1 枚曲面(頂点とリング構造)を生成する. + + Returns: + (vertices, rings): vertices は (N, 3),rings は (n_ring, n_theta) の頂点 index. + リングは上(リム)から下(極直前)の順.末尾に極頂点を 1 つ追加している. + """ + theta = np.linspace(0.0, 2.0 * np.pi, n_theta, endpoint=False) + rect_r = _rect_radius(theta, half_x, half_y) + + cut_d = cut_z - center_z + cut_r = float(np.sqrt(radius**2 - cut_d**2)) + # 球面の cut_z における dρ/dz(下向きに進むと ρ が増える側)を Hermite 端点勾配に使う + drho_dz = -cut_d / cut_r + # u: 0(リム)→ 1(カット面).dz/du = cut_z - rim_z + m1 = drho_dz * (cut_z - rim_z) + + rings_list: list[np.ndarray] = [] + z_list: list[np.ndarray] = [] + + # ロフト部(リム u=0 を含み,カット面 u=1 は球部の先頭リングに任せる) + for u in np.linspace(0.0, 1.0, n_loft, endpoint=False): + h00 = 2 * u**3 - 3 * u**2 + 1 + h01 = -2 * u**3 + 3 * u**2 + h11 = u**3 - u**2 + rho = h00 * rect_r + h01 * cut_r + h11 * m1 + rings_list.append(rho) + z_list.append(np.full(n_theta, rim_z + u * (cut_z - rim_z))) + + # 球部(カット面 → 極直前).角度パラメータで均等分割 + phi_cut = float(np.arccos(cut_d / radius)) + for phi in np.linspace(phi_cut, np.pi, n_sphere, endpoint=False): + rings_list.append(np.full(n_theta, radius * np.sin(phi))) + z_list.append(np.full(n_theta, center_z + radius * np.cos(phi))) + + n_ring = len(rings_list) + vertices = np.empty((n_ring * n_theta + 1, 3), dtype=np.float64) + rings = np.arange(n_ring * n_theta).reshape(n_ring, n_theta) + for i, (rho, z) in enumerate(zip(rings_list, z_list, strict=True)): + vertices[rings[i], 0] = rho * np.cos(theta) + vertices[rings[i], 1] = rho * np.sin(theta) + vertices[rings[i], 2] = z + vertices[-1] = (0.0, 0.0, center_z - radius) # 極頂点 + return vertices, rings + + +def _stitch_rings(rings: np.ndarray, pole_index: int) -> np.ndarray: + """隣接リング間を四角形 2 分割で三角形化し,最下リングは極頂点へファン接続する.""" + n_ring, n_theta = rings.shape + faces: list[tuple[int, int, int]] = [] + for i in range(n_ring - 1): + a, b = rings[i], rings[i + 1] + for j in range(n_theta): + k = (j + 1) % n_theta + faces.append((a[j], b[j], b[k])) + faces.append((a[j], b[k], a[k])) + bottom = rings[-1] + for j in range(n_theta): + k = (j + 1) % n_theta + faces.append((bottom[j], pole_index, bottom[k])) + return np.asarray(faces, dtype=np.int64) + + +def build_loft_housing( + radius: float, + thickness: float, + center_z: float, + board_half_x: float, + board_half_y: float, + rim_inset: float, + rim_z: float, + cut_z: float, + n_theta: int = 256, + n_loft: int = 48, + n_sphere: int = 160, +) -> trimesh.Trimesh: + """球+ロフト一体型筐体の閉ソリッド(内面+外面+リム帯)を生成する. + + Args: + radius: 球内半径(mm). + thickness: 殻厚(mm).外面は内面を径方向におおむね thickness オフセットした形. + center_z: 球中心の Z 座標(mm).X, Y は 0 固定. + board_half_x: 評価板輪郭の X 半幅(mm). + board_half_y: 評価板輪郭の Y 半幅(mm). + rim_inset: リム矩形を板輪郭から内側へ寄せる量(mm).板とのオーバーラップ代. + rim_z: リム矩形の Z 座標(mm).板の実体に食い込む高さに置いてシールする. + cut_z: ロフトと球の接続平面の Z 座標(mm). + n_theta: 周方向分割数. + n_loft: ロフト部の軸方向リング数. + n_sphere: 球部の軸方向リング数. + + Returns: + watertight な筐体ソリッドメッシュ(mm 単位). + + Raises: + ValueError: 生成メッシュが watertight にならなかった場合. + """ + hx_in = board_half_x - rim_inset + hy_in = board_half_y - rim_inset + v_in, rings_in = _build_surface( + radius, center_z, hx_in, hy_in, rim_z, cut_z, n_theta, n_loft, n_sphere + ) + v_out, rings_out = _build_surface( + radius + thickness, + center_z, + hx_in + thickness, + hy_in + thickness, + rim_z, + cut_z, + n_theta, + n_loft, + n_sphere, + ) + + f_in = _stitch_rings(rings_in, len(v_in) - 1) + f_out = _stitch_rings(rings_out, len(v_out) - 1) + + offset = len(v_in) + vertices = np.vstack([v_in, v_out]) + # リム帯: 内面上端リングと外面上端リングを接続して閉じる + top_in, top_out = rings_in[0], rings_out[0] + offset + band: list[tuple[int, int, int]] = [] + for j in range(n_theta): + k = (j + 1) % n_theta + band.append((top_in[j], top_out[j], top_out[k])) + band.append((top_in[j], top_out[k], top_in[k])) + faces = np.vstack([f_in, f_out + offset, np.asarray(band, dtype=np.int64)]) + + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + mesh.fix_normals() # 閉ソリッドとして法線を外向きに統一(内面は自動的にキャビティ向き) + if not mesh.is_watertight: + raise ValueError("生成メッシュが watertight になりませんでした(縫合パラメータを確認)") + return mesh + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """コマンドライン引数をパースして返す.""" + parser = argparse.ArgumentParser( + prog="generate_sphere_loft_housing", + description="評価板輪郭と球を一体化した筐体 STL(mm 単位)を生成する.", + ) + parser.add_argument("--radius", type=float, default=100.0, help="球内半径(mm).既定 100") + parser.add_argument("--thickness", type=float, default=3.0, help="殻厚(mm).既定 3") + parser.add_argument( + "--center-z", type=float, default=-92.0, help="球中心の Z 座標(mm).既定 -92" + ) + parser.add_argument( + "--board-half", type=float, nargs=2, default=(49.5, 51.0), metavar=("HX", "HY"), + help="評価板輪郭の半幅 X Y(mm).既定 49.5 51(99×102mm 板)", + ) + parser.add_argument( + "--rim-inset", + type=float, + default=1.5, + help="リムを板輪郭から内側へ寄せる量(mm).既定 1.5", + ) + parser.add_argument( + "--rim-z", type=float, default=0.0, + help="リムの Z 座標(mm).既定 0(板裏面 -4.5 より 4.5mm 食い込みシール)", + ) + parser.add_argument( + "--cut-z", type=float, default=-40.0, help="ロフト⇄球の接続平面 Z(mm).既定 -40" + ) + parser.add_argument( + "--output", + type=Path, + default=Path("configs/meshes/球ロフト_r100.stl"), + help="出力 STL パス.既定 configs/meshes/球ロフト_r100.stl", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """筐体を生成して STL 出力し,検証情報を表示する.""" + args = _parse_args(argv) + mesh = build_loft_housing( + radius=args.radius, + thickness=args.thickness, + center_z=args.center_z, + board_half_x=args.board_half[0], + board_half_y=args.board_half[1], + rim_inset=args.rim_inset, + rim_z=args.rim_z, + cut_z=args.cut_z, + ) + args.output.parent.mkdir(parents=True, exist_ok=True) + mesh.export(args.output) + print(f"出力: {args.output}") + print(f"faces: {len(mesh.faces)}, watertight: {mesh.is_watertight}") + print(f"bounds (mm): {mesh.bounds.round(2).tolist()}") + print(f"体積 (mm^3): {mesh.volume:.0f}(正値なら法線外向きで正常)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())