singularity-rs/docs/superpowers/plans/2026-07-16-kerr-orbiting-planets.md
xfy 27e5e8b1f6 docs(plan): Kerr orbiting planets implementation plan (Phase 3.4)
9-task TDD plan implementing the Phase 3.4 spec. Path A: all orbital physics
on CPU, shader untouched.

Task ordering enforces a buildable chain:
- Task 0: add rand + rand_chacha (currently only transitive via bevy)
- Task 1: physics.rs Ω_φ + Ω_LT with 5 failing-first tests (χ=0 degeneracy,
  strong-field > weak-field, monotonic-in-spin)
- Task 2: OrbitParams + orbit_position pure fn with 3 geometry invariants
  (radius preserved, equatorial χ=0 stays in plane, motion over time)
- Task 3-4: orbit_system + BlackHoleParams fields (coupled, committed together)
- Task 5: spawn_planet_system (ChaCha8Rng, replaces spawn_default_planet) +
  PlanetSystemDirty resource + plugin registration
- Task 6-7: UI header + dirty-flag wiring on seed/count/k change
- Task 8: integration test + visual tuning

Each task is bite-sized (2-5 min steps), exact file paths/line numbers,
complete code blocks, exact test commands. Self-reviewed against spec: all
sections covered, no placeholders, type names consistent across tasks.
2026-07-16 16:50:18 +08:00

840 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Kerr 轨道行星系统 实现计划 (Phase 3.4)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 58 颗行星沿 Kerr 圆轨道绕黑洞旋转,轨道面因 Lense-Thirring 进动绕自旋轴转动,位置每帧 CPU 闭式计算并上传既有 storage buffer。
**Architecture:** 路径 A — 物理/轨道全在 CPU,shader 零改动。新增 `OrbitParams`(不可变根数) + 复用 `Planet`(每帧派生 center)。闭式公式 `Ω_φ`(Bardeen 1972) 与 `Ω_θ`(垂直 epicyclic 频率) 给出精确强场节点进动 `Ω_LT = Ω_φ - Ω_θ`,χ=0 精确退化为牛顿。
**Tech Stack:** Bevy 0.19, Rust edition 2024, `rand` + `rand_chacha`(确定性 PRNG), egui 控制面板。
**Spec:** `docs/superpowers/specs/2026-07-16-kerr-orbiting-planets-design.md`
---
## 文件结构
| 文件 | 责任 | 改动 |
|---|---|---|
| `Cargo.toml` | 声明 `rand` + `rand_chacha` 直接依赖 | 新增 2 行 |
| `src/physics.rs` | `kerr_orbital_frequency` + `kerr_nodal_precession` + 测试 | 新增 ~80 行 |
| `src/scene/planets.rs` | `OrbitParams` 组件 + 轨道几何 + `orbit_system` + `spawn_planet_system` | 大改,删除 `spawn_default_planet` |
| `src/params.rs` | 5 个新 `BlackHoleParams` 字段 + Default | 新增 ~15 行 |
| `src/render/plugin.rs` | 系统注册 + 资源初始化 | 改 ~10 行 |
| `src/ui.rs` | Planets collapsing header | 新增 ~15 行 |
**不改动:** `src/render/material.rs`(`SphereData`/`BlackHoleUniforms` 不动), `assets/shaders/black_hole.wgsl`(shader 零改动)。
---
## Task 0: 添加 rand 依赖
**Files:**
- Modify: `Cargo.toml:10-12`
`rand` 当前只是 bevy 的传递依赖,未在 `Cargo.toml` 直接声明;`rand_chacha` 完全缺失。需要显式声明以便 `ChaCha8Rng` 稳定可用。
- [ ] **Step 1: 添加依赖**
修改 `Cargo.toml``[dependencies]` 段:
```toml
[dependencies]
bevy = "0.19"
bevy_egui = "0.41"
rand = "0.8"
rand_chacha = "0.3"
```
注:用 `rand 0.8` + `rand_chacha 0.3`(稳定 LTS,API 与 Bevy 0.19 生态兼容)。`seed_from_u64` + `gen_range` API 在 0.8 稳定。
- [ ] **Step 2: 验证编译**
Run: `cargo check`
Expected: 编译通过(可能下载新 crate)。若版本冲突,用 `cargo update -p rand` 或锁到与 bevy 兼容的版本。
- [ ] **Step 3: Commit**
```bash
git add Cargo.toml Cargo.lock
git commit -m "deps: add rand + rand_chacha for deterministic planet seeding"
```
---
## Task 1: Kerr 轨道频率 + 进动率 (physics.rs) — TDD
核心物理公式,先写测试。这部分是整个方案物理正确性的基石。
**Files:**
- Modify: `src/physics.rs`(在文件末尾,`_phantom` 函数之前)
- Test: `src/physics.rs` 内联 `#[cfg(test)]` 模块
### 1a: `kerr_orbital_frequency`
- [ ] **Step 1: 写失败测试**
`src/physics.rs``#[cfg(test)]` mod 里(`fn _phantom` 之后,或现有测试 mod 内)加:
```rust
#[test]
fn orbital_frequency_reduces_to_newton_at_zero_spin() {
// χ=0: Ω = 1/r^1.5 (牛顿开普勒, Rs=1)
for r in [4.0_f32, 6.0, 10.0, 20.0] {
let newton = 1.0 / r.powf(1.5);
let kerr = kerr_orbital_frequency(r, 0.0);
assert!(
(kerr - newton).abs() < 1e-6,
"χ=0 at r={}: expected {} (newton), got {}",
r, newton, kerr
);
}
}
#[test]
fn orbital_frequency_decreases_with_spin_at_fixed_r() {
// prograde 轨道 (a>0): Ω_φ 随 χ 减小 (分母 r^1.5+a 增大)
let r = 8.0;
let omega_0 = kerr_orbital_frequency(r, 0.0);
let omega_1 = kerr_orbital_frequency(r, 1.0);
assert!(omega_1 < omega_0, "prograde Ω should decrease with spin");
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test orbital_frequency`
Expected: FAIL,编译错误 `cannot find function kerr_orbital_frequency`
- [ ] **Step 3: 实现 `kerr_orbital_frequency`**
`src/physics.rs``kerr_horizon` 函数之后(`kerr_bending_accel` 之前)加:
```rust
/// Kerr 赤道 prograde 圆轨角速度 (Rs=1, M=0.5). Bardeen 1972 eqn 2.16.
/// `Ω_φ = 1 / (r^1.5 + a)`, a = χM = 0.5χ. χ=0 退化为牛顿 1/r^1.5.
pub fn kerr_orbital_frequency(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
1.0 / (r.powf(1.5) + a)
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test orbital_frequency`
Expected: PASS,2 个测试通过。
- [ ] **Step 5: Commit**
```bash
git add src/physics.rs
git commit -m "feat(physics): Kerr equatorial orbital frequency Ω_φ (Bardeen 1972)"
```
### 1b: `kerr_nodal_precession`
- [ ] **Step 1: 写失败测试**
`src/physics.rs` 的测试 mod 加:
```rust
#[test]
fn nodal_precession_vanishes_at_zero_spin() {
// χ=0: 球对称 (Schwarzschild), 无节点进动
for r in [4.0_f32, 6.0, 10.0, 20.0] {
let prec = kerr_nodal_precession(r, 0.0);
assert!(
prec.abs() < 1e-6,
"χ=0 at r={} should have zero precession, got {}",
r, prec
);
}
}
#[test]
fn nodal_precession_grows_with_spin() {
// 固定 r, prograde 节点进动率随 χ 单调增
let r = 6.0;
let p_low = kerr_nodal_precession(r, 0.3);
let p_high = kerr_nodal_precession(r, 0.9);
assert!(p_high > p_low, "precession should grow with spin");
assert!(p_low > 0.0, "prograde precession should be positive");
}
#[test]
fn nodal_precession_strong_field_exceeds_weak_field() {
// r<6 强场区: 精确 Ω_LT > 弱场近似 2Ma/r³ = χ/r³ (M=0.5)
let r = 4.0;
let chi = 0.9;
let weak = chi / r.powi(3); // 2Ma/r³ = (2·0.5·χ)/r³ = χ/r³
let strong = kerr_nodal_precession(r, chi);
assert!(
strong > weak,
"strong-field precession at r={} should exceed weak approx {} , got {}",
r, weak, strong
);
}
```
- [ ] **Step 2: 运行测试,确认失败**
Run: `cargo test nodal_precession`
Expected: FAIL,`cannot find function kerr_nodal_precession`
- [ ] **Step 3: 实现 `kerr_nodal_precession`**
`kerr_orbital_frequency` 之后加:
```rust
/// Kerr 赤道圆轨节点进动率 (Lense-Thirring, 强场精确). χ=0 返回 0.
///
/// `Ω_LT = Ω_φ - Ω_θ`, 其中 Ω_θ 是垂直 epicyclic 频率:
/// `Ω_θ² = Ω_φ² · (1 4a·Ω_φ/r + 3a²/r²)` (Caltech Ph236 lec27).
/// χ=0 时 a=0, 括号=1, 故 Ω_θ=Ω_φ, 进动为零 (Schwarzschild 球对称).
pub fn kerr_nodal_precession(r: f32, chi: f32) -> f32 {
let m = 0.5;
let a = chi * m;
let omega_phi = kerr_orbital_frequency(r, chi);
// 垂直 epicyclic 频率比 (>=0, 极端 r/a 组合下数值精度可能略负, 钳位)
let ratio = (1.0 - 4.0 * a * omega_phi / r + 3.0 * a * a / (r * r)).max(0.0);
let omega_theta = omega_phi * ratio.sqrt();
omega_phi - omega_theta
}
```
- [ ] **Step 4: 运行测试,确认通过**
Run: `cargo test nodal_precession`
Expected: PASS,3 个测试通过。
- [ ] **Step 5: 运行全部测试确认无回归**
Run: `cargo test`
Expected: 全部通过(原有 capture/escape 测试 + 5 个新测试)。
- [ ] **Step 6: Commit**
```bash
git add src/physics.rs
git commit -m "feat(physics): Kerr nodal precession Ω_LT (strong-field Lense-Thirring)"
```
---
## Task 2: OrbitParams 组件 + 轨道几何纯函数
先把不可变根数和"根数 + 时间 → 位置"的纯函数定下来。纯函数可独立测试,不依赖 Bevy 系统。
**Files:**
- Modify: `src/scene/planets.rs`(文件顶部,`Planet` struct 之后)
- Test: `src/scene/planets.rs` 内联 `#[cfg(test)]` mod
- [ ] **Step 1: 加 `OrbitParams` 组件 + 轨道几何函数(含测试 mod)**
`src/scene/planets.rs``Planet` struct 定义之后(当前 `:8-13`)加:
```rust
use std::f32::consts::{PI, TAU};
/// 轨道根数 (不变量, 启动时随机生成, 运行时不变除非 UI 改种子重生).
#[derive(Component, Clone, Copy)]
pub struct OrbitParams {
/// k, 乘到 kerr_isco(χ) 上得实际轨道半径.
pub radius_factor: f32,
/// 轨道面法向与 Y 轴(自旋轴)的夹角 (rad).
pub inclination: f32,
/// 升交点经度 (rad), 决定轨道面在方位上的初始取向.
pub longitude_of_node: f32,
/// 轨道内初始相位 (rad).
pub phase: f32,
}
/// 由轨道根数 + 当前 (模拟)时间 + 自旋, 计算行星世界空间位置.
/// 纯函数: 无 Bevy 依赖, 可独立测试.
///
/// 物理:
/// - r = k · kerr_isco(χ)
/// - Ω_φ = kerr_orbital_frequency(r, χ) (轨道角速度)
/// - Ω_LT = kerr_nodal_precession(r, χ) (轨道面绕 Y 轴的进动率)
/// 轨道面基 (u, v) 由 inclination + longitude_of_node 构造, 然后绕 Y 轴
/// 整体旋转 Ω_LT·t (Lense-Thirring 进动).
pub fn orbit_position(orbit: &OrbitParams, t: f32, chi: f32) -> Vec3 {
let r = orbit.radius_factor * crate::physics::kerr_isco(chi);
let omega_phi = crate::physics::kerr_orbital_frequency(r, chi);
let omega_lt = crate::physics::kerr_nodal_precession(r, chi);
// 1. 轨道面法向 (Y 轴为极轴的球坐标)
let inc = orbit.inclination;
let lon = orbit.longitude_of_node;
let sin_inc = inc.sin();
let n = Vec3::new(
sin_inc * lon.cos(),
inc.cos(),
sin_inc * lon.sin(),
);
// 2. 轨道面内正交基: u 沿升节点方向, v = n × u
// u 在 XZ 平面 (垂直于 Y 轴), 指向升节点
let u = Vec3::new(-lon.sin(), 0.0, lon.cos());
let v = n.cross(u);
// 3. 进动: (u, v) 绕 Y 轴整体旋转 Ω_LT·t
let pa = omega_lt * t;
let cp = pa.cos();
let sp = pa.sin();
let u_p = Vec3::new(u.x * cp + u.z * sp, u.y, -u.x * sp + u.z * cp);
let v_p = Vec3::new(v.x * cp + v.z * sp, v.y, -v.x * sp + v.z * cp);
// 4. 行星在进动后的轨道面内的位置
let theta = orbit.phase + omega_phi * t;
r * (theta.cos() * u_p + theta.sin() * v_p)
}
```
- [ ] **Step 2: 写几何不变量测试**
`src/scene/planets.rs` 文件末尾加测试 mod:
```rust
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::TAU;
#[test]
fn orbit_position_radius_is_preserved() {
// 不管时间/相位, 行星到原点距离应恒等于 r = k·isco(χ)
let orbit = OrbitParams {
radius_factor: 2.5,
inclination: 0.7,
longitude_of_node: 1.3,
phase: 0.5,
};
let chi = 0.8;
let expected_r = 2.5 * crate::physics::kerr_isco(chi);
for t in [0.0_f32, 1.0, 5.5, 100.0] {
let pos = orbit_position(&orbit, t, chi);
let dist = pos.length();
assert!(
(dist - expected_r).abs() < 1e-4,
"t={}: dist {} != r {}",
t, dist, expected_r
);
}
}
#[test]
fn orbit_position_zero_spin_keeps_plane_fixed() {
// χ=0: 无进动, 倾角 0 (赤道面) 的行星应严格在 y=0 平面
let orbit = OrbitParams {
radius_factor: 3.0,
inclination: 0.0, // 赤道面
longitude_of_node: 0.0,
phase: 0.0,
};
for t in [0.0_f32, 1.0, 10.0] {
let pos = orbit_position(&orbit, t, 0.0);
assert!(pos.y.abs() < 1e-5, "χ=0 equatorial orbit should stay in y=0 plane at t={}", t);
}
}
#[test]
fn orbit_position_advance_with_time() {
// 不同时间应给不同位置 (除非极端巧合)
let orbit = OrbitParams {
radius_factor: 3.0,
inclination: 0.5,
longitude_of_node: 0.0,
phase: 0.0,
};
let p0 = orbit_position(&orbit, 0.0, 0.5);
let p1 = orbit_position(&orbit, 1.0, 0.5);
assert!((p0 - p1).length() > 0.01, "planet should move over time");
}
}
```
- [ ] **Step 3: 运行测试**
Run: `cargo test --lib scene::planets`
Expected: PASS,3 个几何测试通过。
注:若 `--lib` 选择器不工作,用 `cargo test orbit_position`
- [ ] **Step 4: Commit**
```bash
git add src/scene/planets.rs
git commit -m "feat(planets): OrbitParams component + orbit_position geometry"
```
---
## Task 3: orbit_system (每帧更新 Planet.center)
把纯函数接进 Bevy 调度,每帧写 `Planet.center`
**Files:**
- Modify: `src/scene/planets.rs`
- [ ] **Step 1: 加 `orbit_system`**
`orbit_position` 函数之后加。注:`Time` 已在 `bevy::prelude::*` 里(`planets.rs:1` 已 import prelude,参考 `plugin.rs:582``time: Res<Time>` 用法),无需额外 import。
```rust
/// 每帧读 OrbitParams + time + spin, 用闭式公式写 Planet.center.
/// 必须在 upload_planets 之前运行 (plugin.rs 用 .before() 保证).
pub fn orbit_system(
time: Res<Time>,
params: Res<crate::params::BlackHoleParams>,
mut query: Query<(&OrbitParams, &mut Planet)>,
) {
if !params.planets_enabled {
return;
}
// time_scale 放大模拟时间, 让慢进动在合理时间内可见 (Ω_LT 在 r=8 转一圈 ~25 min)
let t = time.elapsed_secs() * params.planet_time_scale;
for (orbit, mut planet) in &mut query {
planet.center = orbit_position(orbit, t, params.spin);
}
}
```
- [ ] **Step 2: 验证编译**
Run: `cargo check`
Expected: 编译通过。会报 `planets_enabled` / `planet_time_scale` 字段不存在——这是 Task 4 要加的。**若如此,先做 Task 4 再回来验证。**
- [ ] **Step 3: Commit(字段未加前不 commit;待 Task 4 完成后一起验证再 commit)**
暂不 commit。
---
## Task 4: BlackHoleParams 新字段
**Files:**
- Modify: `src/params.rs:107-153`(struct 定义) + `:156-211`(Default impl)
- [ ] **Step 1: 加字段到 struct**
`src/params.rs``BlackHoleParams` struct 里,`aa_quality: AaQuality,`(`:153`)之后加:
```rust
// Planets (Phase 3.4: Kerr orbiting planets)
pub planets_enabled: bool,
pub planet_count_target: u32, // 0..=8
pub planet_radius_factor: f32, // k, 乘到 kerr_isco(χ) 上
pub planet_seed: u32, // ChaCha8Rng 种子, 改了触发系统重生
pub planet_time_scale: f32, // 模拟时间放大 (进动很慢, 需放大才可见)
```
- [ ] **Step 2: 加 Default 值**
`impl Default for BlackHoleParams``aa_quality: ...`(`:208`)之后加:
```rust
// Planets: 6 颗, k=2.5 (r ∈ [1.25, 7.5], 横跨强场区),
// 种子 42, time_scale 50× (Ω_LT 在 r=8 转一圈 ~25 min, 放大才可见).
planets_enabled: true,
planet_count_target: 6,
planet_radius_factor: 2.5,
planet_seed: 42,
planet_time_scale: 50.0,
```
- [ ] **Step 3: 验证编译 (回 Task 3 的待验证项)**
Run: `cargo check`
Expected: 编译通过,`orbit_system` 现在能找到 `planets_enabled` / `planet_time_scale`
- [ ] **Step 4: Commit**
```bash
git add src/params.rs src/scene/planets.rs
git commit -m "feat(params): planet system params + orbit_system wiring"
```
---
## Task 5: spawn_planet_system (随机生成 + despawn 旧的)
取代 `spawn_default_planet`。用确定性 PRNG,改种子时整个系统重生。
**Files:**
- Modify: `src/scene/planets.rs`
- Modify: `src/render/plugin.rs:113`(注册) + 资源初始化
- [ ] **Step 1: 加 dirty-flag 资源 + spawn_planet_system**
`src/scene/planets.rs` 顶部 `use` 区加(`bevy::prelude::*` 已含 `Commands`/`Query`/`Entity`/`Resource`/`With`/`Vec3`):
```rust
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand::Rng;
```
`OrbitParams` struct 之后加 dirty flag 资源:
```rust
/// UI 改了种子/count/k 时置位, spawn_planet_system 检测到就重生整个行星系统.
#[derive(Resource, Default)]
pub struct PlanetSystemDirty(pub bool);
```
用新函数取代 `spawn_default_planet`(删除 `:66-73` 的整个 `spawn_default_planet`):
```rust
/// (重)生成行星系统. 检测 PlanetSystemDirty: 若置位, 先 despawn 所有现有
/// (Planet, OrbitParams), 再用 ChaCha8Rng + params.planet_seed 重新随机生成.
/// 确定性种子 → 同种子给同布局, 方便调试/截图/测试.
pub fn spawn_planet_system(
mut commands: Commands,
params: Res<crate::params::BlackHoleParams>,
mut dirty: ResMut<PlanetSystemDirty>,
existing: Query<Entity, With<Planet>>,
) {
// 只在 dirty 时重生 (避免每帧重建). 首帧 dirty 默认 false → 需要初始 spawn.
// 用 Resource Default 给的 false + 一个 startup 标记, 或始终在 Startup 调一次.
// 简化: 此系统同时在 Startup 和 Update 注册; Update 路径靠 dirty 门控,
// Startup 路径靠 "现有为零" 门控.
if !dirty.0 && !existing.is_empty() {
return;
}
// despawn 现有行星
for entity in &existing {
commands.entity(entity).despawn();
}
dirty.0 = false;
if !params.planets_enabled {
return;
}
let mut rng = ChaCha8Rng::seed_from_u64(params.planet_seed as u64);
for _ in 0..params.planet_count_target.min(crate::render::material::MAX_PLANETS as u32) {
let inclination = rng.gen_range(0.0..PI);
let longitude = rng.gen_range(0.0..TAU);
let phase = rng.gen_range(0.0..TAU);
let radius_factor = rng.gen_range(2.0..4.0);
// 颜色: 暖色行星 (橙/红/黄系), 避开蓝色 (易与背景星混淆)
let hue = rng.gen_range(0.02..0.13); // 橙红色相
let color = hsv_to_rgb(hue, rng.gen_range(0.5..0.9), rng.gen_range(0.7..1.0));
commands.spawn((
OrbitParams {
radius_factor,
inclination,
longitude_of_node: longitude,
phase,
},
Planet {
center: Vec3::ZERO, // 首帧由 orbit_system 填
radius: rng.gen_range(0.8..1.6),
color,
emissive: false,
},
));
}
}
/// HSV → RGB (h,s,v ∈ [0,1]). 行星颜色用.
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Vec3 {
let i = (h * 6.0).floor() as i32 % 6;
let f = h * 6.0 - (h * 6.0).floor();
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
match i {
0 => Vec3::new(v, t, p),
1 => Vec3::new(q, v, p),
2 => Vec3::new(p, v, t),
3 => Vec3::new(p, q, v),
4 => Vec3::new(t, p, v),
_ => Vec3::new(v, p, q),
}
}
```
注:`hsv_to_rgb``i32 % 6` 处理 h=1.0 边界;`hue 0.02..0.13` 给橙红色相,避免与背景星(蓝/白)混淆。
- [ ] **Step 2: 改 plugin.rs 注册**
`src/render/plugin.rs:96-98``init_resource` 链,在 `WantsPointer` 之后加 `PlanetSystemDirty`:
把:
```rust
app.init_resource::<crate::camera::OrbitCamera>()
.init_resource::<crate::camera::WantsPointer>()
.init_resource::<crate::params::BlackHoleParams>()
```
改为(在 `BlackHoleParams` 之后加一行,注意 `init_resource::<BlackHoleParams>()` 后原本没有 `.` 链式调用——检查上下文,它可能用 `;` 结束。读 `plugin.rs:96-100` 确认):
```rust
app.init_resource::<crate::camera::OrbitCamera>()
.init_resource::<crate::camera::WantsPointer>()
.init_resource::<crate::params::BlackHoleParams>()
.init_resource::<crate::scene::planets::PlanetSystemDirty>();
```
`init_resource::<BlackHoleParams>()` 后是 `;` 而非 `.`(即链已断),把新行单独写:
```rust
app.init_resource::<crate::scene::planets::PlanetSystemDirty>();
```
放在 `BlackHoleParams` init 之后。
- [ ] **Step 3: 改系统注册**
`src/render/plugin.rs:113`:
```rust
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
```
改为:
```rust
.add_systems(Startup, crate::scene::planets::spawn_planet_system)
```
`:123`:
```rust
.add_systems(Update, crate::scene::planets::upload_planets)
```
改为(加 orbit_system 在 upload_planets 前, 加 spawn_planet_system 在 Update):
```rust
.add_systems(Update, crate::scene::planets::spawn_planet_system)
.add_systems(
Update,
crate::scene::planets::orbit_system
.before(crate::scene::planets::upload_planets),
)
.add_systems(Update, crate::scene::planets::upload_planets)
```
注:`spawn_planet_system` 放 Update 是为了检测 dirty flag 重生。它内部靠 dirty + `existing.is_empty()` 门控,不会每帧重建。
- [ ] **Step 4: 验证编译**
Run: `cargo check`
Expected: 编译通过。`spawn_default_planet` 已删除,无悬空引用。
- [ ] **Step 5: 验证启动**
Run: `cargo run --release`
Expected: 应用启动,看到 ~6 颗行星在轨道上。可能位置/速度还需调(time_scale 50× 下进动应可见)。
- [ ] **Step 6: Commit**
```bash
git add src/scene/planets.rs src/render/plugin.rs
git commit -m "feat(planets): spawn_planet_system with deterministic ChaCha8Rng seeding"
```
---
## Task 6: UI 控制面板
**Files:**
- Modify: `src/ui.rs`(在 Accretion Disk header 之后,约 `:34` 段之后)
- [ ] **Step 1: 定位插入点**
`src/ui.rs`,找 "Accretion Disk" collapsing header 结束的位置(下一个 `egui::CollapsingHeader::new` 之前)。
- [ ] **Step 2: 加 Planets header**
在 Accretion Disk header 之后加(具体缩进对齐现有代码):
```rust
egui::CollapsingHeader::new("Planets")
.default_open(false)
.show(ui, |ui| {
let was_enabled = params.planets_enabled;
ui.checkbox(&mut params.planets_enabled, "Enable");
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.5..=5.0).text("Radius factor k"));
let isco = crate::physics::kerr_isco(params.spin);
ui.label(format!("ISCO: {:.3} → r = {:.3}", isco, params.planet_radius_factor * isco));
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
});
```
- [ ] **Step 3: 验证编译**
Run: `cargo check`
Expected: 编译通过。
- [ ] **Step 4: Commit**
```bash
git add src/ui.rs
git commit -m "feat(ui): Planets control panel header"
```
---
## Task 7: dirty flag 联动 (UI 改种子/count/k 时重生)
当前 UI 改 `planet_seed` / `planet_count_target` / `planets_enabled` 不会触发 `spawn_planet_system` 重生。需要在 `ui_system` 里检测变化并置 `PlanetSystemDirty`
**Files:**
- Modify: `src/ui.rs`(`ui_system` 签名 + Planets header)
- [ ] **Step 1: 改 ui_system 签名加 PlanetSystemDirty**
`src/ui.rs:4-9`,当前签名:
```rust
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
) {
```
`PlanetSystemDirty`:
```rust
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
) {
```
- [ ] **Step 2: 在 Planets header 里检测变化置 dirty**
把 Task 6 加的 Planets header 改为(记录改前值,改后对比):
```rust
egui::CollapsingHeader::new("Planets")
.default_open(false)
.show(ui, |ui| {
let prev = (
params.planets_enabled,
params.planet_count_target,
params.planet_radius_factor,
params.planet_seed,
);
ui.checkbox(&mut params.planets_enabled, "Enable");
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.5..=5.0).text("Radius factor k"));
let isco = crate::physics::kerr_isco(params.spin);
ui.label(format!("ISCO: {:.3} → r = {:.3}", isco, params.planet_radius_factor * isco));
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
let curr = (
params.planets_enabled,
params.planet_count_target,
params.planet_radius_factor,
params.planet_seed,
);
if curr != prev {
planet_dirty.0 = true;
}
});
```
注:`planet_time_scale` 不触发重生(它只影响 orbit_system 的时间放大,不需重建实体)。
- [ ] **Step 3: spin 变化也触发重生**
Spin 改变 ISCO → 轨道半径变。读 `src/ui.rs` 的 "Black Hole" header(`:27-33`),在 spin slider 后加 dirty 标记。或者更简单:在 `ui_system` 末尾统一检测 spin 变化。
最简方案:在 `ui_system` 开头记录 `params.spin` 旧值,结尾对比。但这会污染整个函数。**推荐:** 在 "Black Hole" header 的 spin slider 后直接加:
`src/ui.rs:30` 附近:
```rust
ui.add(egui::Slider::new(&mut params.spin, 0.0..=1.0).text("Spin (χ)"));
```
改为(加 dirty 联动)——但这里需要 prev/curr。由于 spin 改变只影响半径(连续),不必重生实体(orbit_system 每帧读 spin)。**决定:spin 不触发重生**——orbit_system 已每帧读 `params.spin`,半径会平滑变化。只有种子/count/k 这些"根数"改变才需重生。
保持 Task 7 Step 2 的实现即可,spin 不加联动。
- [ ] **Step 4: 验证编译**
Run: `cargo check`
Expected: 编译通过。
- [ ] **Step 5: 验证 dirty 重生**
Run: `cargo run --release`
手动测试:在 UI 里拖动 Seed 滑条 → 行星布局应立即改变。拖动 Count → 行星数变化。拖动 k → 半径变化(可能需重生才体现新 k 的随机分布)。
- [ ] **Step 6: Commit**
```bash
git add src/ui.rs
git commit -m "feat(ui): planet dirty flag on seed/count/k change triggers respawn"
```
---
## Task 8: 集成验证 + 视觉调参
**Files:** 无代码改动(纯验证 + 可能微调默认值)
- [ ] **Step 1: 运行全部测试**
Run: `cargo test`
Expected: 所有测试通过:
- 原有 physics capture/escape 测试
- `orbital_frequency_*` (2)
- `nodal_precession_*` (3)
- `orbit_position_*` (3)
共 ~10+ 测试全绿。
- [ ] **Step 2: 桌面端视觉检查**
Run: `cargo run --release`
检查项:
- [ ] 启动后看到 ~6 颗行星
- [ ] 行星在轨道上运动(角速度可见)
- [ ] 拖动 Time Scale → 进动速率变化(高 time_scale 下轨道面绕 Y 轴转动可见)
- [ ] 拖动 Spin (χ) → 从 0 到 1:χ=0 时轨道面固定,χ>0 时进动出现
- [ ] 拖动 Seed → 行星布局重生
- [ ] 行星被引力透镜扭曲(爱因斯坦环/弧)——这是 shader 既有功能,验证位置上传正确
- [ ] 行星不会被吸积盘完全淹没(随机倾角应让多数行星偏离盘面)
- [ ] **Step 3: Web 端编译检查**
Run: `cargo check --target wasm32-unknown-unknown`
Expected: 编译通过。`ChaCha8Rng` 在 wasm 可用(纯计算,无平台依赖)。
- [ ] **Step 4: 微调默认值(若需要)**
若视觉不佳,调 `src/params.rs` 的 Default:
- 行星太小/大 → 调 `spawn_planet_system``radius: rng.gen_range(0.8..1.6)` 的范围
- 进动太慢/快 → 调 `planet_time_scale: 50.0`
- 行星太暗 → 调 `hsv_to_rgb` 的 value 范围,或让部分行星 `emissive: true`
- 颜色不好看 → 调 hue 范围 `0.02..0.13`
- [ ] **Step 5: 最终 commit(若有调参)**
```bash
git add -A
git commit -m "tune(planets): default visual parameters after visual check"
```
---
## Self-Review 结论
**1. Spec 覆盖:**
- ✅ 物理模型(Ω_φ, Ω_LT) → Task 1
- ✅ OrbitParams 组件 → Task 2
- ✅ 轨道几何 → Task 2 (`orbit_position`)
- ✅ orbit_system → Task 3
- ✅ BlackHoleParams 字段 → Task 4
- ✅ spawn_planet_system + ChaCha8Rng → Task 5
- ✅ UI 控制面板 → Task 6
- ✅ dirty flag 重生 → Task 7
- ✅ SphereData/shader 不动 → 全程未涉及
- ✅ 测试 → Task 1 (5 个) + Task 2 (3 个)
- ✅ χ=0 退化 → Task 1 测试覆盖
**2. 占位符扫描:** 无 TBD/TODO。所有代码块完整。
**3. 类型一致性:** `OrbitParams` 字段名(radius_factor, inclination, longitude_of_node, phase)在 Task 2/3/5 一致。`PlanetSystemDirty(pub bool)` 在 Task 5/7 一致。`planets_enabled` / `planet_time_scale` 在 Task 4/5/6/7 一致。
**4. 调度顺序:** orbit_system `.before(upload_planets)` (Task 5 Step 3) 保证上传最新位置。spawn_planet_system 在 Update 靠 dirty 门控。