# 轨道法线方向与 PT 标记规范

> **适用场景**：一切沿曲线扫掠生成几何体的工作——铁轨、路面、枕木、护栏、隧道截面等。
> 违反本文档规则会导致几何体在竖直段（爬坡顶点、回环）**翻转 180°**。

---

## 1. 问题根源：投影法在竖直切线处退化

`buildTrackFrames()` 的 **WorldUp 模式**（默认）计算法线：

```
normal = normalize( Y+ − (Y+·tangent) × tangent )
```

即把世界 Y+ 向量投影到切线的法平面。

| 切线方向 | `|dot(tangent, Y+)|` | 结果 |
|----------|----------------------|------|
| 水平 | ≈ 0 | 正常，法线稳定朝上 |
| 斜 45° | ≈ 0.7 | 正常 |
| 接近竖直（±Y） | → 1.0 | **投影结果趋近零向量，法线数值不稳定，随机翻转** |

**结论**：轨道切线与世界 Y+ 夹角 < ~11.5°（`|dotTUp| > 0.98`）时，WorldUp 法线不可信。

---

## 2. 解决方案：平行传输（Parallel Transport，PT）

PT 模式不依赖世界坐标系，而是用帧间**最小旋转**传递法线：

```javascript
axis  = cross(prevTangent, currTangent)          // 切线转过的轴
angle = acos(dot(prevTangent, currTangent))       // 转过的角度
ptNormal = prevNormal.applyAxisAngle(axis, angle) // 法线随之旋转
```

- 切线任何方向（包括竖直）都不会退化
- 代价：法线不"看"世界坐标系，轨道在平地上也可能非水平

**因此 PT 只在需要的段落启用，其余段用 WorldUp。**

---

## 3. 何时必须标记 PT

**规则**：节点的 Catmull-Rom 切线方向满足以下任一条件，该节点及前后各 ≥ 5 个节点都应标 `pt: true`：

```
|dot(tangent, Y+)| > 0.98      ← 切线与竖直方向夹角 < 11.5°
```

**典型场景**：

| 场景 | 是否需要 PT |
|------|------------|
| 平地弯道 | 否 |
| 普通上下坡（< 80°） | 否 |
| 竖向回环（顶点切线竖直） | **是，整个回环** |
| 急陡坡顶/底（切线越过竖直） | **是，穿越区段** |
| 螺旋（坡度 < 80°） | 通常否 |

---

## 4. 标记方法

### 方法 A：JSON 节点直接写（新轨道/手动）

在 `bezierNodes` 或 `nodes` 中为对应节点加 `"pt": true`：

```json
{ "position": [x, y, z], "handleOut": [...], "roll": 0.0, "pt": true }
```

Unity 端：`TrackSplineData.knotPT[i] = true`，导出时自动写入。

### 方法 B：Python 自动扫描（存量密集节点数据）

```bash
python CoasterRush/mark_pt_flags.py --threshold 0.98 --buffer 5
```

- 计算每个节点的 Catmull-Rom 切线
- `|tangent.y| > 0.98` 的连续区间扩展 5 个缓冲节点后全部标 PT
- 相邻区间自动合并

---

## 5. buildTrackFrames() 的完整决策逻辑

```
每一帧 i：
  ① ptNormal = 平行传输法线（始终计算，不管 isPT）
  ② isPT = ptFlags[节点索引] || ptFlags[下一节点索引]
  ③ roll 增量应用（ptExitOS 防抖：PT 末帧不叠加 roll）

  if isPT:
      normal = ptNormal                   ← PT 区间内直接用

  else:
      dotTUp = dot(tangent, Y+)
      if |dotTUp| ≥ 0.999:               ← 退化保险
          normal = ptNormal

      elif 刚从 PT 退出（blend 计数 > 0）:
          normal = lerp(ptNormal, worldN, 1-blend/100)
          blend--                         ← 100 帧 / 100 世界单位渐变

      elif dot(prevNormal, worldN) < 0:   ← 翻转检测
          normal = ptNormal               ← 临时回退，防突变

      else:
          normal = worldN                 ← 正常 WorldUp
```

**关键变量位置**：`game_code.html` `buildTrackFrames()` 函数，约 line 2233。

---

## 6. 做几何扫掠时的 Agent 检查清单

生成沿轨道扫掠的几何体（铁轨/路面/枕木/隧道截面）前，逐项确认：

- [ ] **使用 `getGameFrame(t)`** 而非自行计算法线，它已经包含了 PT 混合结果
- [ ] `getGameFrame(t).up` = 轨道面法线（垂直于轨面朝上），`getGameFrame(t).right` = 轨道横向
- [ ] 若自行构建帧（不用 `getGameFrame`），必须完整复现 PT 逻辑，不能只做 WorldUp 投影
- [ ] 竖向回环段：视觉检查顶点处几何体是否翻转——翻转了说明 PT 标记缺失或逻辑缺失
- [ ] PT 区间出口：渐变 100 帧内法线平滑，若出现扭曲接缝说明 holonomy 未补偿

---

## 7. 出口扭转（Holonomy）

平行传输在**非平面曲线**上绕一圈后，法线会偏转一个非零角度，导致回环出口法线相对入口扭转。

**补偿方式**（旧 Generator 系统）：
- `VerticalLoopGenerator.GetLocalExitRoll()` 测量扭转量
- 下游 `TransitionGenerator.entryRoll` 写入补偿值，用坡道段平滑消除

**新 SplineContainer 系统**：
- 在回环出口 knot 处手动设置 `knotRolls[i]` 补偿扭转
- 或在出口后几个 knot 渐变 roll 至 0

---

## 8. 坐标系提示

Three.js 的 `getGameFrame()` 输出：

| 字段 | 含义 | 铁轨用途 |
|------|------|---------|
| `pt` | 轨道中心线上的点 | 轨道/枕木位置原点 |
| `tan` | 切线（行进方向） | 枕木朝向 |
| `up` | 法线（轨面朝上） | 铁轨截面 Y 轴 |
| `right` | 副法线（轨道横向）| 左右轨道偏移方向 |

```javascript
// 标准铁轨左右轨道位置示例
const leftRail  = frame.pt.clone().addScaledVector(frame.right, -gaugeHalf);
const rightRail = frame.pt.clone().addScaledVector(frame.right,  gaugeHalf);
// frame.up 即截面"朝上"方向，直接用于截面旋转矩阵
```
