this repo has no description
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}