これは開発日誌(2)の続きです。前回(第1並行バッチ)でCCRsとCCRmの直進シナリオを実装し、シナリオ選択UIを確立しました。今回は交差点・旋回系シナリオ対応に向けた2D化を記録します。
今回やったこと:全体像
- geometry2d.ts の実装 — 座標変換・車両諸元解決・矩形端点算出・OBB衝突判定・profile補間(
src/sim/core/geometry2d.ts) - CCFtapシナリオJSONの作成 — スキーマv0.1.0のまま
profile運動で旋回を表現(data/scenarios/euro-ncap/ccftap-turn.json) - TurningDemoコンポーネントの実装 — 2D俯瞰Canvas + OBB衝突判定 + 再生UI(
src/components/TurningDemo.astro) - CCFtapプロトコル幾何への整合 — 車線間隔・オーバーラップを調査して修正(decision_log #037)
なぜ2D化が必要か
既存の直進モデル(kinematics.ts)は「相対距離のスカラー値」だけで動く1Dモデルです。CCRs(静止ターゲット追突)やCCRm(移動ターゲット追突)は自車の進行軸上で完結するためこれで十分でした。
交差点・旋回シナリオに対応しようとすると、3点の問題が生じます。
- 自車の進行方向が時々刻々と変わる(ヨー角を持つ)
- 相手車と自車の進路が同一軸上に乗らない(直交〜斜交する)
- 車体の向きが変わるため、衝突判定を矩形の「向き」まで含めて行う必要がある
設計調査(docs/design/2026-06-09_coordinate-system-data-model.md)を実施したところ、既存スキーマの骨格はすでに2D対応済みでした(initial_state に x_m/y_m/heading_deg、motion.profile.samples に時系列ポーズ列が存在)。これは設計時の重要な発見です。スキーマを一切変えずに、profile 運動を流用することで旋回1本を先行実装できると判断し、この方針(Step1)で進めました(decision_log #032・#033・#034)。
座標系の設計
2Dシミュレーターには2つの座標系を使い分けます。
**世界座標系(慣性系)**は、t=0 の自車後軸中心を原点とし、自車の初期進行方向を X 軸正方向、左方向を Y 軸正方向とする右手系です(geometry2d.ts 冒頭のJSDocに明記)。角度はラジアン・反時計回りが正。衝突判定と軌跡の記録は世界座標系で行います。
**車両座標系(車体固定系)**は、各アクターの後軸中心を瞬時原点、瞬時の車頭方向を X 軸正方向とします。センサが検知する「相対位置」はこちらで表現するのが自然です。ただし、MVP では衝突判定を世界座標系に統一したため、車両座標系は将来のセンシング対応に向けた準備として変換関数だけ実装しています(decision_log #033)。
pose(姿勢)の原点を後軸中心にした理由
後軸中心を原点にした理由は、車両運動学における自然さです。前輪駆動・後輪駆動どちらの場合でも、ヨーレート積分(旋回弧の計算)の基準として後軸が安定しています。前端中心を使うと、旋回中に前端と後軸の相対位置が変化して諸元計算が複雑になります。
座標変換(並進+回転)の実装
世界座標系と車両座標系の変換は2×2行列の回転と並進で完結します。
車両 → 世界(端点・タイヤ位置の算出に使う):
p_world = R(ψ) · p_veh + (x, y)
R(ψ) = [ cosψ -sinψ ]
[ sinψ cosψ ]
世界 → 車両(センシング用途・MVP では参照実装として保持):
p_veh = R(-ψ) · (p_world - (x, y))
R(-ψ) = [ cosψ sinψ ]
[ -sinψ cosψ ]
vehicleToWorld / worldToVehicle として geometry2d.ts に実装し、恒等変換の自己検証(worldToVehicle ∘ vehicleToWorld = Id)をファイル末尾のコメントに手計算として残しています(検証5)。
車両諸元から矩形4端点・タイヤ位置を算出する設計
なぜ諸元から算出するか
従来のデモは車両を「長さ×幅の軸平行矩形」で表現していましたが、これでは旋回中の回頭を正確に再現できません。車体が斜めを向いたとき、四隅の世界座標は都度計算し直す必要があります。また、対象シナリオ(乗用車vs乗用車、乗用車vsトラック等)の車種差を正しく反映するためにも、ホイールベース・トレッド・オーバーハングという実務的な諸元セットが必要です(decision_log #033)。
諸元フィールドの拡張
JSONスキーマの dimensions_m は従来 length / width だけでした。今回の実装では以下を追加しました(省略可・後方互換):
"dimensions_m": {
"length": 4.5,
"width": 1.8,
"wheelbase": 2.7,
"front_overhang": 0.9,
"rear_overhang": 0.9,
"track_width": 1.55
}
resolveDims() 関数が省略フィールドをフォールバック補完します(wheelbase = length × 0.6 など乗用車標準比率を使用)。既存の CCRs/CCRm シナリオは諸元が length/width しか指定しておらず、このフォールバックで動作します。
車両座標系での定位置(後軸中心原点)
矩形4端点:
front_left = ( front_overhang + wheelbase, +width/2 )
front_right = ( front_overhang + wheelbase, -width/2 )
rear_left = ( -rear_overhang, +width/2 )
rear_right = ( -rear_overhang, -width/2 )
タイヤ接地中心:
wheel_FL = ( wheelbase, +track_width/2 )
wheel_FR = ( wheelbase, -track_width/2 )
wheel_RL = ( 0, +track_width/2 )
wheel_RR = ( 0, -track_width/2 )
vehicleCornersLocal / vehicleWheelsLocal がこれらを返し、vehicleCornersWorld / vehicleWheelsWorld が vehicleToWorld で世界座標へ写像します。
OBB衝突判定(分離軸定理・SAT)を採用した理由
直進シナリオでは「後軸間距離が閾値以下か」という1D判定で十分でした。交差点シナリオでは2台の矩形が異なる向きを持ち、斜交するため、軸平行矩形(AABB)では過剰検知(実際には離れているのに衝突と誤判定)が生じます。
OBB(Oriented Bounding Box)の重複判定に分離軸定理(Separating Axis Theorem、SAT)を用います。2つの凸多角形が分離している場合、必ずその2多角形の辺の法線のいずれかが「分離軸」として機能します(4矩形辺 × 各法線 = 計4軸で十分)。
実装(obbOverlap)は各軸への射影区間が重なるかを確認し、1軸でも分離されていれば false(衝突なし)を返します。
for (const axis of axes) {
const projA = projectOntoAxis(cornersA, axis);
const projB = projectOntoAxis(cornersB, axis);
if (projA.max < projB.min || projB.max < projA.min) {
return false; // 分離軸が見つかった
}
}
return true; // すべての軸で重なる → 衝突
手計算による自己検証は geometry2d.ts 末尾のコメント(検証3)に3ケース分(重なる・離れる・斜め配置)を残しています。
スキーマを変えずに旋回軌跡を表現した段階的アプローチ
今後 constant_yaw_rate(定曲率旋回)型を追加する計画(Step2・v0.2.0)がありますが、今回はスキーマv0.1.0のまま既存の profile 型を流用しました。
profile はx/y/heading_degの時系列サンプル列なので、旋回弧をサンプリングした座標列を書けば旋回軌跡を表現できます。デメリットはJSONが長くなること(VUT・GVTそれぞれ26サンプル)ですが、スキーマ変更ゼロで動作確認できる利点のほうが今回は大きいと判断しました。
poseFromProfile 関数は以下を処理します:
- x, y の線形補間(
lerp) - heading の最短経路角度補間(
lerpAngleRad・350deg と 10deg の中間が正しく 0deg になる) heading_deg省略時は前後サンプルの進行方向ベクトルからatan2で推定
CCFtapプロトコル幾何との整合(#037)
初版のシナリオJSON(#036)は車線間隔6m・任意の旋回半径で作成しており、プロトコルとの整合が取れていませんでした(decision_log #037)。
Euro NCAP CCFtapプロトコル(AEB C2C v4.3 系)を確認した結果、以下の幾何を反映しました:
- 対向2車線の中央線から各車線中心まで 1.75 m
- 主車線幅 3.5 m
- 前面同士が VUT 車幅の約 50% オーバーラップ で接触
- GVT(対向直進)の初期位置は中央線から 1.75 m 側(y = 3.5 m)を西向きに直進
修正後のシナリオJSON(ccftap-turn.json)では:
"coordinate_system": {
"origin": "試験開始時(t=0)の自車後軸中心。自車車線中心 y=0、中央線 y=1.75、対向(GVT)車線中心 y=3.5。"
}
VUT は x=4m まで直進後、旋回半径8mで左折し対向車線を横切ります(t≈2.2s で前面衝突)。旋回半径・クロソイド近似・発進距離は当サイトの代表値・自作表現であり、プロトコル文書の転載ではありません。
検証の仕方
① Node.js での数値検証
geometry2d.ts 末尾のコメントに手計算値を記録し、コードの計算結果と照合しています(検証1〜5)。例えば、90度回頭(psiRad = π/2)のときの端点世界座標:
fl_local = (3.65, 0.9)
R(π/2) · fl_local = (0*3.65 - 1*0.9, 1*3.65 + 0*0.9) = (-0.9, 3.65)
② ブラウザでの位置トレース確認
TurningDemo のスライダを手動操作し、HUDの後軸間距離と OBB 接触判定を目視確認します。t=0 で GVT が x=34, y=3.5(34.2m)、t=2.2s で接触(⚠マーク)、t=5s で離隔することを確認しました。
③ ビルド確認
npm run build で20ページビルド成功。コンソールエラーゼロ。
つまずき・実務メモ
ヘッドレス環境での requestAnimationFrame
ブラウザ検証の際、headless 環境(Playwright等の非表示Chrome)では requestAnimationFrame が自動前進しません。タブがバックグラウンドになると同じ現象が起きます。実ブラウザで前面にタブを置いて確認する必要がありました(decision_log #037 末尾に明記)。スライダを手動操作する方法で衝突判定を検証しています。
heading_deg 省略サンプルの扱い
GVTのような直進車は heading_degが全サンプルで一定(180度)なので問題ありませんが、VUTの旋回中は各サンプルに heading_deg を明示する方が補間精度が高くなります。省略した場合は前後サンプル間のベクトルから atan2 で推定しますが、旋回部の角速度が急激に変化する場面では補間誤差が出ることがあります。
角度補間の最短経路
350度と10度の間(20度差)を補間する場合、単純な線形補間では340度(340度差・遠回り)になります。lerpAngleRad では差を [-π, π) に正規化してから補間することで最短経路を選びます。旋回中の heading_deg の変化が0°をまたぐ場合(南→東→北など)に必要な処理です。
OBBの端点順序
cornersToArray が fl → fr → rr → rl の時計回り順で配列化しています。obbOverlap の辺法線計算では隣接頂点間のエッジから法線を求めているため、頂点順が反転していると法線の向きが逆になります(ただし、分離軸判定の対称性から最終結果は変わりません)。将来の拡張(侵入深度計算など)のために順序を明記しています。
次のステップ
constant_yaw_rateモーション型の追加(v0.2.0・Step2): 曲率一定の旋回を JSON で簡潔に表現できるようにする- 時系列データの公開フォーマット実装(item2 対応): 車両幾何(pose + 端点 + タイヤ)+ 運動量の CSV/JSON 出力
- 複数の旋回シナリオへの展開: CPTA(旋回中歩行者)など VRU 系シナリオへ
旋回デモは/scenariosで触れることができます。今回実装した幾何・座標変換の考え方は、次の交差点・出会い頭系シナリオにも共通して使えます。
関連記事: