this repo has no description
at main 471 lines 14 kB view raw
1use bevy::prelude::*; 2 3/// Frenet-Serret frame at a point on the helix. 4#[derive(Debug, Clone, Copy)] 5pub struct Frame { 6 pub position: Vec3, 7 #[allow(dead_code)] 8 pub tangent: Vec3, 9 pub normal: Vec3, 10 pub binormal: Vec3, 11} 12 13#[derive(Debug, Clone)] 14pub struct HelixLevel { 15 pub turns_per_parent: f32, 16 pub samples_per_turn: u32, 17 #[allow(dead_code)] 18 pub label: &'static str, 19 pub color: Color, 20 pub radius_scale: f32, 21} 22 23/// Outermost level is index 0, innermost is last. 24#[derive(Resource, Debug, Clone)] 25pub struct HelixHierarchy { 26 pub levels: Vec<HelixLevel>, 27 pub fill_factor: f32, 28 pub base_radius: f32, 29 pub pitch_per_turn: f32, 30} 31 32/// 1 unit of focal_time = 1 outermost helix turn = 1 minute. 33#[derive(Resource, Debug)] 34pub struct HelixState { 35 pub focal_time: f32, 36 pub active_level: usize, 37 pub target_level: f32, 38 pub interpolated_level: f32, 39 pub auto_follow: bool, 40 pub time_scale: f32, 41} 42 43pub fn default_hierarchy() -> HelixHierarchy { 44 HelixHierarchy { 45 levels: vec![ 46 HelixLevel { 47 turns_per_parent: 1.0, 48 samples_per_turn: 32, 49 label: "minutes", 50 color: Color::srgb(0.9, 0.2, 0.3), 51 radius_scale: 1.0, 52 }, 53 HelixLevel { 54 turns_per_parent: 60.0, 55 samples_per_turn: 32, 56 label: "seconds", 57 color: Color::srgb(0.2, 0.8, 0.4), 58 radius_scale: 1.0, 59 }, 60 HelixLevel { 61 turns_per_parent: 60.0, 62 samples_per_turn: 32, 63 label: "sub-second", 64 color: Color::srgb(0.3, 0.6, 0.9), 65 radius_scale: 2.0, 66 }, 67 ], 68 fill_factor: 0.55, 69 base_radius: 1.0, 70 pitch_per_turn: 7.0, 71 } 72} 73 74impl Default for HelixHierarchy { 75 fn default() -> Self { 76 default_hierarchy() 77 } 78} 79 80/// Evaluate helix frame at t through all levels up to `level`. 81/// Uses iterative Gram-Schmidt to avoid frame discontinuities. 82pub fn eval_coil(t: f32, level: usize, hierarchy: &HelixHierarchy) -> Frame { 83 use std::f32::consts::TAU; 84 85 let r = hierarchy.base_radius; 86 let p = hierarchy.pitch_per_turn; 87 let theta = t * TAU; 88 let (s, c) = theta.sin_cos(); 89 90 let mut px = r * c; 91 let mut py = t * p; 92 let mut pz = r * s; 93 94 let mut dtx = -r * TAU * s; 95 let mut dty = p; 96 let mut dtz = r * TAU * c; 97 98 let mut nx = -c; 99 let mut ny = 0.0_f32; 100 let mut nz = -s; 101 102 let t_len = (dtx * dtx + dty * dty + dtz * dtz).sqrt(); 103 let (mut tx, mut ty, mut tz) = (dtx / t_len, dty / t_len, dtz / t_len); 104 let mut bx = ty * nz - tz * ny; 105 let mut by = tz * nx - tx * nz; 106 let mut bz = tx * ny - ty * nx; 107 108 for lvl in 1..=level { 109 let level_info = &hierarchy.levels[lvl]; 110 111 // Child radius: cumulative depth scaling 112 let depth_scale: f32 = (0..lvl) 113 .map(|l| { 114 let child = &hierarchy.levels[l + 1]; 115 hierarchy.fill_factor * child.radius_scale / child.turns_per_parent.sqrt() 116 }) 117 .product(); 118 let child_r = hierarchy.base_radius * depth_scale; 119 120 let total_turns: f32 = hierarchy.levels[..=lvl] 121 .iter() 122 .map(|l| l.turns_per_parent) 123 .product(); 124 let alpha = t * total_turns * TAU; 125 let (sa, ca) = alpha.sin_cos(); 126 let w = total_turns * TAU; 127 128 let dx = nx * child_r * ca + bx * child_r * sa; 129 let dy = ny * child_r * ca + by * child_r * sa; 130 let dz = nz * child_r * ca + bz * child_r * sa; 131 132 px += dx; 133 py += dy; 134 pz += dz; 135 136 let ex = -nx * child_r * sa * w + bx * child_r * ca * w; 137 let ey = -ny * child_r * sa * w + by * child_r * ca * w; 138 let ez = -nz * child_r * sa * w + bz * child_r * ca * w; 139 140 dtx += ex; 141 dty += ey; 142 dtz += ez; 143 144 let t_len = (dtx * dtx + dty * dty + dtz * dtz).sqrt(); 145 tx = dtx / t_len; 146 ty = dty / t_len; 147 tz = dtz / t_len; 148 149 let dot = dx * tx + dy * ty + dz * tz; 150 let mut nnx = dx - dot * tx; 151 let mut nny = dy - dot * ty; 152 let mut nnz = dz - dot * tz; 153 let n_len = (nnx * nnx + nny * nny + nnz * nnz).sqrt(); 154 nnx /= n_len; 155 nny /= n_len; 156 nnz /= n_len; 157 158 nx = nnx; 159 ny = nny; 160 nz = nnz; 161 162 bx = ty * nz - tz * ny; 163 by = tz * nx - tx * nz; 164 bz = tx * ny - ty * nx; 165 } 166 167 Frame { 168 position: Vec3::new(px, py, pz), 169 tangent: Vec3::new(tx, ty, tz), 170 normal: Vec3::new(nx, ny, nz), 171 binormal: Vec3::new(bx, by, bz), 172 } 173} 174 175pub fn compute_focal_point(state: &HelixState, hierarchy: &HelixHierarchy) -> Vec3 { 176 let frame = eval_coil(state.focal_time, 0, hierarchy); 177 frame.position 178} 179 180#[derive(Resource)] 181pub struct HelixGeometry { 182 pub levels: Vec<HelixLevelGeometry>, 183 pub precomputed_to: f32, 184} 185 186pub struct HelixLevelGeometry { 187 pub t_start: f32, 188 pub t_step: f32, 189 pub positions: Vec<Vec3>, 190} 191 192fn samples_per_t(level_idx: usize, hierarchy: &HelixHierarchy) -> f32 { 193 let total_turns: f32 = hierarchy.levels[..=level_idx] 194 .iter() 195 .map(|l| l.turns_per_parent) 196 .product(); 197 let spt = if level_idx == hierarchy.levels.len() - 1 { 198 64.0 199 } else { 200 32.0 201 }; 202 total_turns * spt 203} 204 205const PRECOMPUTE_AHEAD: f32 = 30.0; 206const PRECOMPUTE_BEHIND: f32 = 10.0; 207 208pub fn setup_helix_geometry(mut commands: Commands, hierarchy: Res<HelixHierarchy>) { 209 let t_start = -PRECOMPUTE_BEHIND; 210 let t_end = PRECOMPUTE_AHEAD; 211 212 let mut levels = Vec::with_capacity(hierarchy.levels.len()); 213 214 for level_idx in 0..hierarchy.levels.len() { 215 let spt = samples_per_t(level_idx, &hierarchy); 216 let t_step = 1.0 / spt; 217 let num_samples = ((t_end - t_start) / t_step) as usize + 1; 218 219 let mut positions = Vec::with_capacity(num_samples); 220 for i in 0..num_samples { 221 let t = t_start + i as f32 * t_step; 222 let frame = eval_coil(t, level_idx, &hierarchy); 223 positions.push(frame.position); 224 } 225 226 info!( 227 "helix level {level_idx}: precomputed {num_samples} points, t=[{t_start}..{t_end}], step={t_step:.6}" 228 ); 229 230 levels.push(HelixLevelGeometry { 231 t_start, 232 t_step, 233 positions, 234 }); 235 } 236 237 commands.insert_resource(HelixGeometry { 238 levels, 239 precomputed_to: t_end, 240 }); 241} 242 243pub fn extend_helix_geometry( 244 state: Res<HelixState>, 245 hierarchy: Res<HelixHierarchy>, 246 mut geometry: ResMut<HelixGeometry>, 247) { 248 if state.focal_time + 5.0 < geometry.precomputed_to { 249 return; 250 } 251 252 let new_end = geometry.precomputed_to + PRECOMPUTE_AHEAD; 253 254 for (level_idx, level_geom) in geometry.levels.iter_mut().enumerate() { 255 let spt = samples_per_t(level_idx, &hierarchy); 256 let t_step = 1.0 / spt; 257 let current_end = level_geom.t_start + (level_geom.positions.len() as f32) * t_step; 258 259 let num_new = ((new_end - current_end) / t_step) as usize; 260 for i in 0..num_new { 261 let t = current_end + i as f32 * t_step; 262 let frame = eval_coil(t, level_idx, &hierarchy); 263 level_geom.positions.push(frame.position); 264 } 265 } 266 267 info!("extended helix geometry to t={new_end}"); 268 geometry.precomputed_to = new_end; 269} 270 271#[derive(Resource)] 272pub struct HelixGizmoCache { 273 pub last_focal_time: f32, 274 pub last_level: f32, 275 pub entity: Option<Entity>, 276 pub asset_handle: Option<Handle<GizmoAsset>>, 277} 278 279impl Default for HelixGizmoCache { 280 fn default() -> Self { 281 Self { 282 last_focal_time: 0.0, 283 last_level: -1.0, 284 entity: None, 285 asset_handle: None, 286 } 287 } 288} 289 290pub fn draw_helix( 291 mut commands: Commands, 292 hierarchy: Res<HelixHierarchy>, 293 state: Res<HelixState>, 294 geometry: Res<HelixGeometry>, 295 mut gizmo_assets: ResMut<Assets<GizmoAsset>>, 296 mut cache: ResMut<HelixGizmoCache>, 297) { 298 const WINDOW: f32 = 120.0; 299 const UPDATE_THRESHOLD: f32 = 0.005; 300 301 let focal_changed = (state.focal_time - cache.last_focal_time).abs() > UPDATE_THRESHOLD; 302 let level_changed = (state.interpolated_level - cache.last_level).abs() > 0.01; 303 304 if !focal_changed && !level_changed && cache.entity.is_some() { 305 return; 306 } 307 308 let mut gizmo = GizmoAsset::default(); 309 310 for (level_idx, level) in hierarchy.levels.iter().enumerate() { 311 let distance_from_active = (level_idx as f32 - state.interpolated_level).abs(); 312 let alpha = if distance_from_active < 0.5 { 1.0 } else { 0.4 }; 313 314 let total_turns: f32 = hierarchy.levels[..=level_idx] 315 .iter() 316 .map(|l| l.turns_per_parent) 317 .product(); 318 319 let window_half_t = (WINDOW / 2.0) / total_turns; 320 let t_start = state.focal_time - window_half_t; 321 let t_end = state.focal_time + window_half_t; 322 323 let level_geom = &geometry.levels[level_idx]; 324 325 let idx_start = ((t_start - level_geom.t_start) / level_geom.t_step).max(0.0) as usize; 326 let idx_end = ((t_end - level_geom.t_start) / level_geom.t_step) 327 .min(level_geom.positions.len() as f32 - 1.0) 328 .max(0.0) as usize; 329 330 if idx_end <= idx_start + 1 { 331 continue; 332 } 333 334 let slice = &level_geom.positions[idx_start..=idx_end]; 335 if slice.len() > 1 { 336 gizmo.linestrip(slice.iter().copied(), level.color.with_alpha(alpha)); 337 } 338 } 339 340 match &cache.asset_handle { 341 Some(h) => { 342 let _ = gizmo_assets.insert(h.id(), gizmo); 343 } 344 None => { 345 let h = gizmo_assets.add(gizmo); 346 cache.asset_handle = Some(h.clone()); 347 let entity = commands 348 .spawn(Gizmo { 349 handle: h, 350 ..default() 351 }) 352 .id(); 353 cache.entity = Some(entity); 354 } 355 } 356 357 cache.last_focal_time = state.focal_time; 358 cache.last_level = state.interpolated_level; 359} 360 361#[cfg(test)] 362mod tests { 363 use super::*; 364 365 366 /// Assert all components of a Vec3 are finite (non-NaN and non-Inf). 367 fn assert_finite(v: Vec3, label: &str) { 368 assert!(v.x.is_finite(), "{label}.x is not finite: {}", v.x); 369 assert!(v.y.is_finite(), "{label}.y is not finite: {}", v.y); 370 assert!(v.z.is_finite(), "{label}.z is not finite: {}", v.z); 371 } 372 373 /// Assert a Vec3 has length approximately 1.0 (within 0.01). 374 fn assert_unit(v: Vec3, label: &str) { 375 let len = v.length(); 376 assert!( 377 (len - 1.0).abs() < 0.01, 378 "{label} is not unit length: length = {len}" 379 ); 380 } 381 382 383 #[test] 384 fn default_hierarchy_has_three_levels_with_positive_params() { 385 let h = default_hierarchy(); 386 assert_eq!(h.levels.len(), 3, "expected 3 levels"); 387 assert!(h.fill_factor > 0.0, "fill_factor must be positive"); 388 assert!(h.base_radius > 0.0, "base_radius must be positive"); 389 assert!(h.pitch_per_turn > 0.0, "pitch_per_turn must be positive"); 390 for (i, level) in h.levels.iter().enumerate() { 391 assert!( 392 level.turns_per_parent > 0.0, 393 "level[{i}].turns_per_parent must be positive" 394 ); 395 assert!( 396 level.samples_per_turn > 0, 397 "level[{i}].samples_per_turn must be positive" 398 ); 399 } 400 } 401 402 403 #[test] 404 fn eval_coil_level0_finite_for_extreme_t_values() { 405 let h = default_hierarchy(); 406 let test_values = [-10.0_f32, 0.0, 0.5, 1.0, 100.0]; 407 for &t in &test_values { 408 let frame = eval_coil(t, 0, &h); 409 assert_finite(frame.position, &format!("level0 t={t} position")); 410 assert_finite(frame.normal, &format!("level0 t={t} normal")); 411 assert_finite(frame.binormal, &format!("level0 t={t} binormal")); 412 } 413 } 414 415 416 #[test] 417 fn eval_coil_level0_frame_is_orthonormal() { 418 let h = default_hierarchy(); 419 let test_values = [-10.0_f32, 0.0, 0.5, 1.0, 100.0]; 420 for &t in &test_values { 421 let frame = eval_coil(t, 0, &h); 422 423 // Each basis vector must be approximately unit length 424 assert_unit(frame.normal, &format!("level0 t={t} normal")); 425 assert_unit(frame.binormal, &format!("level0 t={t} binormal")); 426 427 // Orthogonality: dot products of distinct basis vectors must be ~0 428 let dot_nb = frame.normal.dot(frame.binormal).abs(); 429 assert!( 430 dot_nb < 0.01, 431 "level0 t={t}: normal·binormal = {dot_nb} (not orthogonal)" 432 ); 433 } 434 } 435 436 437 #[test] 438 fn eval_coil_level1_offset_from_level0_by_child_radius() { 439 let h = default_hierarchy(); 440 441 // Compute expected child_radius for level 1 using the same formula as eval_coil. 442 // depth_scale = fill_factor / sqrt(levels[1].turns_per_parent) 443 let depth_scale = h.fill_factor / h.levels[1].turns_per_parent.sqrt(); 444 let child_radius = h.base_radius * depth_scale; 445 446 let test_values = [0.0_f32, 0.25, 0.5, 1.0]; 447 for &t in &test_values { 448 let frame0 = eval_coil(t, 0, &h); 449 let frame1 = eval_coil(t, 1, &h); 450 451 let offset = (frame1.position - frame0.position).length(); 452 assert!( 453 (offset - child_radius).abs() < 0.01, 454 "t={t}: level-1 offset = {offset}, expected child_radius = {child_radius}" 455 ); 456 } 457 } 458 459 460 #[test] 461 fn eval_coil_level1_finite_for_various_t_values() { 462 let h = default_hierarchy(); 463 let test_values = [-10.0_f32, 0.0, 0.5, 1.0, 100.0]; 464 for &t in &test_values { 465 let frame = eval_coil(t, 1, &h); 466 assert_finite(frame.position, &format!("level1 t={t} position")); 467 assert_finite(frame.normal, &format!("level1 t={t} normal")); 468 assert_finite(frame.binormal, &format!("level1 t={t} binormal")); 469 } 470 } 471}