Skip to main content

proof_engine/math/
springs.rs

1//! Spring-damper physics.
2//!
3//! Used for camera following, glyph position settling, UI element animation,
4//! and any value that should approach a target with physical feel.
5
6/// A spring-damper system that tracks a scalar value toward a target.
7///
8/// The spring has "mass" (implicit 1.0), stiffness `k`, and damping `d`.
9/// ζ (damping ratio) = d / (2 * √k).
10///   ζ < 1: underdamped (oscillates, overshoots)
11///   ζ = 1: critically damped (fastest convergence, no overshoot)
12///   ζ > 1: overdamped (slow, no overshoot)
13#[derive(Debug, Clone)]
14pub struct SpringDamper {
15    pub position: f32,
16    pub velocity: f32,
17    pub target: f32,
18    pub stiffness: f32,
19    pub damping: f32,
20}
21
22impl SpringDamper {
23    pub fn new(position: f32, stiffness: f32, damping: f32) -> Self {
24        Self { position, velocity: 0.0, target: position, stiffness, damping }
25    }
26
27    /// Create a critically damped spring (no overshoot, fastest convergence).
28    pub fn critical(position: f32, speed: f32) -> Self {
29        let k = speed * speed;
30        let d = 2.0 * speed;
31        Self::new(position, k, d)
32    }
33
34    /// Create an underdamped spring (bouncy, overshoots slightly).
35    pub fn bouncy(position: f32, frequency: f32, damping_ratio: f32) -> Self {
36        let k = frequency * frequency;
37        let d = 2.0 * damping_ratio * frequency;
38        Self::new(position, k, d)
39    }
40
41    /// Step the spring by `dt` seconds.
42    pub fn tick(&mut self, dt: f32) {
43        let force = -self.stiffness * (self.position - self.target) - self.damping * self.velocity;
44        self.velocity += force * dt;
45        self.position += self.velocity * dt;
46    }
47
48    /// Step and return the new position.
49    pub fn tick_get(&mut self, dt: f32) -> f32 {
50        self.tick(dt);
51        self.position
52    }
53
54    pub fn set_target(&mut self, target: f32) {
55        self.target = target;
56    }
57
58    pub fn teleport(&mut self, position: f32) {
59        self.position = position;
60        self.velocity = 0.0;
61        self.target = position;
62    }
63
64    pub fn is_settled(&self, threshold: f32) -> bool {
65        (self.position - self.target).abs() < threshold && self.velocity.abs() < threshold
66    }
67}
68
69/// A 2-D spring (two independent SpringDampers sharing the same parameters).
70#[derive(Debug, Clone)]
71pub struct Spring2D {
72    pub x: SpringDamper,
73    pub y: SpringDamper,
74}
75
76impl Spring2D {
77    pub fn new(px: f32, py: f32, stiffness: f32, damping: f32) -> Self {
78        Self {
79            x: SpringDamper::new(px, stiffness, damping),
80            y: SpringDamper::new(py, stiffness, damping),
81        }
82    }
83
84    pub fn critical(px: f32, py: f32, speed: f32) -> Self {
85        Self {
86            x: SpringDamper::critical(px, speed),
87            y: SpringDamper::critical(py, speed),
88        }
89    }
90
91    pub fn tick(&mut self, dt: f32) {
92        self.x.tick(dt);
93        self.y.tick(dt);
94    }
95
96    pub fn set_target(&mut self, tx: f32, ty: f32) {
97        self.x.set_target(tx);
98        self.y.set_target(ty);
99    }
100
101    pub fn position(&self) -> (f32, f32) {
102        (self.x.position, self.y.position)
103    }
104}
105
106/// A 3-D spring. Also exported as `SpringDamper3` for camera API compatibility.
107#[derive(Debug, Clone)]
108pub struct Spring3D {
109    pub x: SpringDamper,
110    pub y: SpringDamper,
111    pub z: SpringDamper,
112}
113
114/// Alias used by the camera system.
115pub type SpringDamper3 = Spring3D;
116
117impl Spring3D {
118    /// Create from component floats.
119    pub fn new(px: f32, py: f32, pz: f32, stiffness: f32, damping: f32) -> Self {
120        Self {
121            x: SpringDamper::new(px, stiffness, damping),
122            y: SpringDamper::new(py, stiffness, damping),
123            z: SpringDamper::new(pz, stiffness, damping),
124        }
125    }
126
127    /// Create from a Vec3 (used by camera).
128    pub fn from_vec3(pos: glam::Vec3, stiffness: f32, damping: f32) -> Self {
129        Self::new(pos.x, pos.y, pos.z, stiffness, damping)
130    }
131
132    pub fn critical(px: f32, py: f32, pz: f32, speed: f32) -> Self {
133        Self {
134            x: SpringDamper::critical(px, speed),
135            y: SpringDamper::critical(py, speed),
136            z: SpringDamper::critical(pz, speed),
137        }
138    }
139
140    /// Step and return new position as Vec3 (used by camera).
141    pub fn tick(&mut self, dt: f32) -> glam::Vec3 {
142        self.x.tick(dt);
143        self.y.tick(dt);
144        self.z.tick(dt);
145        self.position()
146    }
147
148    /// Set target from Vec3 (used by camera).
149    pub fn set_target(&mut self, t: glam::Vec3) {
150        self.x.set_target(t.x);
151        self.y.set_target(t.y);
152        self.z.set_target(t.z);
153    }
154
155    pub fn set_target_xyz(&mut self, tx: f32, ty: f32, tz: f32) {
156        self.x.set_target(tx);
157        self.y.set_target(ty);
158        self.z.set_target(tz);
159    }
160
161    pub fn position(&self) -> glam::Vec3 {
162        glam::Vec3::new(self.x.position, self.y.position, self.z.position)
163    }
164}
165
166// ── ConstrainedSpring ─────────────────────────────────────────────────────────
167
168/// A spring with configurable position clamping constraints.
169#[derive(Debug, Clone)]
170pub struct ConstrainedSpring {
171    pub inner:     SpringDamper,
172    pub min_pos:   Option<f32>,
173    pub max_pos:   Option<f32>,
174    pub min_vel:   Option<f32>,
175    pub max_vel:   Option<f32>,
176}
177
178impl ConstrainedSpring {
179    pub fn new(position: f32, stiffness: f32, damping: f32) -> Self {
180        Self {
181            inner:   SpringDamper::new(position, stiffness, damping),
182            min_pos: None, max_pos: None,
183            min_vel: None, max_vel: None,
184        }
185    }
186
187    pub fn with_pos_limits(mut self, min: f32, max: f32) -> Self {
188        self.min_pos = Some(min);
189        self.max_pos = Some(max);
190        self
191    }
192
193    pub fn with_vel_limits(mut self, min: f32, max: f32) -> Self {
194        self.min_vel = Some(min);
195        self.max_vel = Some(max);
196        self
197    }
198
199    pub fn tick(&mut self, dt: f32) -> f32 {
200        self.inner.tick(dt);
201        if let Some(lo) = self.min_pos {
202            if self.inner.position < lo {
203                self.inner.position = lo;
204                self.inner.velocity = self.inner.velocity.max(0.0);
205            }
206        }
207        if let Some(hi) = self.max_pos {
208            if self.inner.position > hi {
209                self.inner.position = hi;
210                self.inner.velocity = self.inner.velocity.min(0.0);
211            }
212        }
213        if let Some(lo) = self.min_vel {
214            self.inner.velocity = self.inner.velocity.max(lo);
215        }
216        if let Some(hi) = self.max_vel {
217            self.inner.velocity = self.inner.velocity.min(hi);
218        }
219        self.inner.position
220    }
221
222    pub fn set_target(&mut self, t: f32) { self.inner.set_target(t); }
223    pub fn position(&self) -> f32 { self.inner.position }
224    pub fn velocity(&self) -> f32 { self.inner.velocity }
225    pub fn is_settled(&self, threshold: f32) -> bool { self.inner.is_settled(threshold) }
226}
227
228// ── DistanceConstraint ────────────────────────────────────────────────────────
229
230/// Maintains a target distance between two points, with spring-based correction.
231///
232/// Used to connect two particles or bones with a stiff but springy constraint.
233#[derive(Debug, Clone)]
234pub struct DistanceConstraint {
235    /// Rest distance between point A and point B.
236    pub rest_length: f32,
237    /// How stiff the constraint is (0 = free, 1 = rigid solve).
238    pub stiffness: f32,
239    /// Damping ratio applied to constraint correction impulses.
240    pub damping: f32,
241    /// Whether to allow compression (shorter than rest_length).
242    pub allow_compression: bool,
243    /// Whether to allow extension (longer than rest_length).
244    pub allow_extension: bool,
245}
246
247impl DistanceConstraint {
248    pub fn new(rest_length: f32, stiffness: f32) -> Self {
249        Self {
250            rest_length,
251            stiffness,
252            damping: 0.3,
253            allow_compression: true,
254            allow_extension: true,
255        }
256    }
257
258    /// Rod — rigid, both directions.
259    pub fn rod(rest_length: f32) -> Self {
260        Self { rest_length, stiffness: 1.0, damping: 0.5, allow_compression: true, allow_extension: true }
261    }
262
263    /// Rope — only resists extension (allows slack).
264    pub fn rope(rest_length: f32) -> Self {
265        Self { rest_length, stiffness: 0.9, damping: 0.4, allow_compression: false, allow_extension: true }
266    }
267
268    /// Strut — only resists compression (allows stretching).
269    pub fn strut(rest_length: f32) -> Self {
270        Self { rest_length, stiffness: 0.9, damping: 0.4, allow_compression: true, allow_extension: false }
271    }
272
273    /// Compute position corrections to apply to points A and B.
274    ///
275    /// `pa`, `pb` — current positions.
276    /// `mass_a`, `mass_b` — masses (constraint splits correction by mass ratio).
277    /// Returns (delta_a, delta_b) — offsets to add to each point position.
278    pub fn solve(
279        &self,
280        pa: glam::Vec3, pb: glam::Vec3,
281        mass_a: f32, mass_b: f32,
282    ) -> (glam::Vec3, glam::Vec3) {
283        let delta = pb - pa;
284        let dist = delta.length();
285        if dist < 1e-6 { return (glam::Vec3::ZERO, glam::Vec3::ZERO); }
286
287        let error = dist - self.rest_length;
288        let stretch = error > 0.0;
289        let compress = error < 0.0;
290
291        // Check if constraint is active
292        if stretch && !self.allow_extension { return (glam::Vec3::ZERO, glam::Vec3::ZERO); }
293        if compress && !self.allow_compression { return (glam::Vec3::ZERO, glam::Vec3::ZERO); }
294
295        let dir = delta / dist;
296        let total_mass = (mass_a + mass_b).max(1e-6);
297        let ratio_a = mass_b / total_mass;
298        let ratio_b = mass_a / total_mass;
299        let correction = dir * error * self.stiffness;
300
301        (correction * ratio_a, -correction * ratio_b)
302    }
303}
304
305// ── PinConstraint ─────────────────────────────────────────────────────────────
306
307/// Pins a point to a fixed world-space position.
308///
309/// The correction snaps the constrained point back toward its anchor each step.
310#[derive(Debug, Clone)]
311pub struct PinConstraint {
312    pub anchor: glam::Vec3,
313    /// How strongly to pull (0 = no correction, 1 = snap to anchor immediately).
314    pub stiffness: f32,
315    /// Max allowed deviation before pin activates (0 = always active).
316    pub dead_zone: f32,
317}
318
319impl PinConstraint {
320    pub fn new(anchor: glam::Vec3) -> Self {
321        Self { anchor, stiffness: 1.0, dead_zone: 0.0 }
322    }
323
324    pub fn soft(anchor: glam::Vec3, stiffness: f32) -> Self {
325        Self { anchor, stiffness, dead_zone: 0.0 }
326    }
327
328    pub fn with_dead_zone(mut self, zone: f32) -> Self {
329        self.dead_zone = zone;
330        self
331    }
332
333    /// Compute position correction to apply to the constrained point.
334    pub fn solve(&self, pos: glam::Vec3) -> glam::Vec3 {
335        let delta = self.anchor - pos;
336        let dist = delta.length();
337        if dist <= self.dead_zone { return glam::Vec3::ZERO; }
338        let excess = dist - self.dead_zone;
339        let dir = delta / dist;
340        dir * excess * self.stiffness
341    }
342
343    pub fn move_anchor(&mut self, new_anchor: glam::Vec3) {
344        self.anchor = new_anchor;
345    }
346}
347
348// ── SpringChain ───────────────────────────────────────────────────────────────
349
350/// A chain of N particles connected by spring-based distance constraints.
351///
352/// Useful for tails, tendrils, cloth edges, banner text, and rope physics.
353/// The first particle is optionally pinned to an anchor.
354#[derive(Debug, Clone)]
355pub struct SpringChain {
356    /// Particle positions.
357    pub positions:    Vec<glam::Vec3>,
358    /// Particle velocities.
359    pub velocities:   Vec<glam::Vec3>,
360    /// Per-segment rest lengths (length = positions.len() - 1).
361    pub rest_lengths: Vec<f32>,
362    /// Constraint stiffness (0 = free-fall, 1 = rigid).
363    pub stiffness:    f32,
364    /// Velocity damping per step.
365    pub damping:      f32,
366    /// Per-particle mass (1.0 default, first particle can be infinity).
367    pub masses:       Vec<f32>,
368    /// If true, first particle is pinned to its initial position.
369    pub pin_head:     bool,
370    /// Gravity applied per step.
371    pub gravity:      glam::Vec3,
372    /// Number of constraint iterations per tick (higher = stiffer).
373    pub iterations:   usize,
374}
375
376impl SpringChain {
377    /// Create a chain hanging vertically from `anchor`.
378    ///
379    /// `count` — number of particles (including anchor).
380    /// `segment_length` — rest length per segment.
381    pub fn new(anchor: glam::Vec3, count: usize, segment_length: f32) -> Self {
382        let count = count.max(2);
383        let positions: Vec<glam::Vec3> = (0..count)
384            .map(|i| anchor + glam::Vec3::NEG_Y * (i as f32 * segment_length))
385            .collect();
386        let velocities = vec![glam::Vec3::ZERO; count];
387        let rest_lengths = vec![segment_length; count - 1];
388        let mut masses = vec![1.0_f32; count];
389        masses[0] = f32::INFINITY; // head is pinned by default
390        Self {
391            positions, velocities, rest_lengths,
392            stiffness: 0.8, damping: 0.98,
393            masses, pin_head: true,
394            gravity: glam::Vec3::NEG_Y * 9.8,
395            iterations: 4,
396        }
397    }
398
399    /// Create a chain for a banner or horizontal tendril.
400    pub fn horizontal(anchor: glam::Vec3, count: usize, segment_length: f32) -> Self {
401        let count = count.max(2);
402        let positions: Vec<glam::Vec3> = (0..count)
403            .map(|i| anchor + glam::Vec3::X * (i as f32 * segment_length))
404            .collect();
405        let velocities = vec![glam::Vec3::ZERO; count];
406        let rest_lengths = vec![segment_length; count - 1];
407        let mut masses = vec![1.0_f32; count];
408        masses[0] = f32::INFINITY;
409        Self {
410            positions, velocities, rest_lengths,
411            stiffness: 0.7, damping: 0.97,
412            masses, pin_head: true,
413            gravity: glam::Vec3::NEG_Y * 9.8,
414            iterations: 5,
415        }
416    }
417
418    /// Set the anchor (first particle) position.
419    pub fn set_anchor(&mut self, pos: glam::Vec3) {
420        self.positions[0] = pos;
421    }
422
423    /// Simulate one physics step.
424    ///
425    /// Applies gravity, integrates velocities, then resolves constraints.
426    pub fn tick(&mut self, dt: f32) {
427        let n = self.positions.len();
428
429        // Apply gravity and integrate
430        for i in 0..n {
431            if self.masses[i].is_infinite() { continue; }
432            self.velocities[i] += self.gravity * dt;
433            self.velocities[i] *= self.damping;
434            self.positions[i] += self.velocities[i] * dt;
435        }
436
437        // Constraint solving iterations (XPBD-style)
438        for _ in 0..self.iterations {
439            for seg in 0..(n - 1) {
440                let pa = self.positions[seg];
441                let pb = self.positions[seg + 1];
442                let rest = self.rest_lengths[seg];
443                let ma = self.masses[seg];
444                let mb = self.masses[seg + 1];
445
446                let delta = pb - pa;
447                let dist = delta.length();
448                if dist < 1e-6 { continue; }
449
450                let error = dist - rest;
451                let dir = delta / dist;
452                let total_w = (1.0 / ma + 1.0 / mb).max(1e-6);
453                let correction = dir * error * self.stiffness / total_w;
454
455                if !ma.is_infinite() {
456                    self.positions[seg]     += correction / ma;
457                    self.velocities[seg]    += correction / ma / dt;
458                }
459                if !mb.is_infinite() {
460                    self.positions[seg + 1] -= correction / mb;
461                    self.velocities[seg + 1] -= correction / mb / dt;
462                }
463            }
464        }
465
466        // Re-pin head
467        if self.pin_head && n > 0 {
468            // anchor velocity zeroed
469            self.velocities[0] = glam::Vec3::ZERO;
470        }
471    }
472
473    /// Apply an impulse to a specific particle.
474    pub fn apply_impulse(&mut self, index: usize, impulse: glam::Vec3) {
475        if index < self.velocities.len() && !self.masses[index].is_infinite() {
476            self.velocities[index] += impulse / self.masses[index];
477        }
478    }
479
480    /// Apply a wind force to all non-infinite-mass particles.
481    pub fn apply_wind(&mut self, wind: glam::Vec3, dt: f32) {
482        for i in 0..self.velocities.len() {
483            if !self.masses[i].is_infinite() {
484                self.velocities[i] += wind * dt;
485            }
486        }
487    }
488
489    /// Get tip (last particle) position.
490    pub fn tip(&self) -> glam::Vec3 {
491        *self.positions.last().unwrap()
492    }
493
494    /// Total chain length.
495    pub fn total_length(&self) -> f32 {
496        self.rest_lengths.iter().sum()
497    }
498
499    /// Current extension ratio (actual_length / rest_length).
500    pub fn extension_ratio(&self) -> f32 {
501        let actual: f32 = self.positions.windows(2)
502            .map(|w| (w[1] - w[0]).length())
503            .sum();
504        let rest = self.total_length();
505        if rest < 1e-6 { 1.0 } else { actual / rest }
506    }
507}
508
509// ── VerletPoint ───────────────────────────────────────────────────────────────
510
511/// A single Verlet-integrated point with optional pin.
512#[derive(Debug, Clone)]
513pub struct VerletPoint {
514    pub pos:      glam::Vec3,
515    pub prev_pos: glam::Vec3,
516    pub pinned:   bool,
517    pub mass:     f32,
518}
519
520impl VerletPoint {
521    pub fn new(pos: glam::Vec3) -> Self {
522        Self { pos, prev_pos: pos, pinned: false, mass: 1.0 }
523    }
524
525    pub fn pinned(mut self) -> Self {
526        self.pinned = true;
527        self
528    }
529
530    pub fn with_mass(mut self, mass: f32) -> Self {
531        self.mass = mass;
532        self
533    }
534
535    /// Apply Verlet integration (assumes `acceleration` includes gravity).
536    pub fn integrate(&mut self, acceleration: glam::Vec3, dt: f32) {
537        if self.pinned { return; }
538        let vel = self.pos - self.prev_pos;
539        let next = self.pos + vel + acceleration * dt * dt;
540        self.prev_pos = self.pos;
541        self.pos = next;
542    }
543
544    pub fn velocity(&self, dt: f32) -> glam::Vec3 {
545        (self.pos - self.prev_pos) / dt.max(1e-6)
546    }
547}
548
549// ── VerletCloth (2D grid for cloth simulation) ────────────────────────────────
550
551/// A 2D grid of Verlet points connected by distance constraints.
552///
553/// Suitable for cloth, net, and banner simulations.
554/// Grid is indexed row-major: `index = row * cols + col`.
555#[derive(Debug, Clone)]
556pub struct VerletCloth {
557    pub points:     Vec<VerletPoint>,
558    pub cols:       usize,
559    pub rows:       usize,
560    pub rest_len:   f32,
561    pub stiffness:  f32,
562    pub iterations: usize,
563    pub gravity:    glam::Vec3,
564    pub damping:    f32,
565}
566
567impl VerletCloth {
568    /// Create a flat cloth grid starting at `origin`, spreading along +X, +Y.
569    pub fn new(origin: glam::Vec3, cols: usize, rows: usize, spacing: f32) -> Self {
570        let mut points = Vec::with_capacity(cols * rows);
571        for r in 0..rows {
572            for c in 0..cols {
573                let pos = origin + glam::Vec3::new(
574                    c as f32 * spacing,
575                    -(r as f32 * spacing),
576                    0.0,
577                );
578                let mut pt = VerletPoint::new(pos);
579                // Pin top row
580                if r == 0 { pt.pinned = true; }
581                points.push(pt);
582            }
583        }
584        Self {
585            points, cols, rows,
586            rest_len: spacing,
587            stiffness: 0.8,
588            iterations: 5,
589            gravity: glam::Vec3::NEG_Y * 9.8,
590            damping: 0.99,
591        }
592    }
593
594    fn idx(&self, r: usize, c: usize) -> usize { r * self.cols + c }
595
596    /// Simulate one step.
597    pub fn tick(&mut self, dt: f32) {
598        // Integrate
599        for pt in &mut self.points {
600            if pt.pinned { continue; }
601            let vel = (pt.pos - pt.prev_pos) * self.damping;
602            let next = pt.pos + vel + self.gravity * dt * dt;
603            pt.prev_pos = pt.pos;
604            pt.pos = next;
605        }
606
607        // Constraint relaxation
608        for _ in 0..self.iterations {
609            // Horizontal constraints
610            for r in 0..self.rows {
611                for c in 0..(self.cols - 1) {
612                    let ia = self.idx(r, c);
613                    let ib = self.idx(r, c + 1);
614                    self.solve_constraint(ia, ib);
615                }
616            }
617            // Vertical constraints
618            for r in 0..(self.rows - 1) {
619                for c in 0..self.cols {
620                    let ia = self.idx(r, c);
621                    let ib = self.idx(r + 1, c);
622                    self.solve_constraint(ia, ib);
623                }
624            }
625            // Shear constraints (diagonals for stability)
626            for r in 0..(self.rows - 1) {
627                for c in 0..(self.cols - 1) {
628                    let ia = self.idx(r, c);
629                    let ib = self.idx(r + 1, c + 1);
630                    self.solve_constraint_len(ia, ib, self.rest_len * std::f32::consts::SQRT_2);
631                    let ic = self.idx(r, c + 1);
632                    let id = self.idx(r + 1, c);
633                    self.solve_constraint_len(ic, id, self.rest_len * std::f32::consts::SQRT_2);
634                }
635            }
636        }
637    }
638
639    fn solve_constraint(&mut self, ia: usize, ib: usize) {
640        self.solve_constraint_len(ia, ib, self.rest_len);
641    }
642
643    fn solve_constraint_len(&mut self, ia: usize, ib: usize, rest: f32) {
644        let pa = self.points[ia].pos;
645        let pb = self.points[ib].pos;
646        let delta = pb - pa;
647        let dist = delta.length();
648        if dist < 1e-6 { return; }
649        let error = (dist - rest) / dist;
650        let correction = delta * error * self.stiffness * 0.5;
651        if !self.points[ia].pinned { self.points[ia].pos += correction; }
652        if !self.points[ib].pinned { self.points[ib].pos -= correction; }
653    }
654
655    /// Apply a spherical wind gust — pushes cloth points near `center` outward.
656    pub fn apply_wind_gust(&mut self, center: glam::Vec3, strength: f32, radius: f32) {
657        for pt in &mut self.points {
658            if pt.pinned { continue; }
659            let d = pt.pos - center;
660            let dist = d.length();
661            if dist < radius && dist > 1e-6 {
662                let factor = (1.0 - dist / radius) * strength;
663                pt.pos += d / dist * factor;
664            }
665        }
666    }
667
668    /// Tear the cloth at a point — unpin nearby points to simulate a hole.
669    pub fn tear(&mut self, center: glam::Vec3, radius: f32) {
670        for pt in &mut self.points {
671            let dist = (pt.pos - center).length();
672            if dist < radius {
673                pt.pinned = false;
674                // give a small outward kick
675                let dir = (pt.pos - center).normalize_or_zero();
676                pt.pos += dir * 0.05;
677            }
678        }
679    }
680
681    pub fn point(&self, row: usize, col: usize) -> glam::Vec3 {
682        self.points[self.idx(row, col)].pos
683    }
684
685    pub fn pin(&mut self, row: usize, col: usize) {
686        let i = self.idx(row, col);
687        self.points[i].pinned = true;
688    }
689
690    pub fn unpin(&mut self, row: usize, col: usize) {
691        let i = self.idx(row, col);
692        self.points[i].pinned = false;
693    }
694}
695
696// ── SpringNetwork ─────────────────────────────────────────────────────────────
697
698/// A general graph of nodes connected by spring edges.
699///
700/// Each node is a mass-point; edges are distance constraints.
701/// Useful for soft body shapes, molecules, and amorphous entities.
702#[derive(Debug, Clone)]
703pub struct SpringNetwork {
704    pub positions:  Vec<glam::Vec3>,
705    pub velocities: Vec<glam::Vec3>,
706    pub masses:     Vec<f32>,
707    /// Edges: (node_a, node_b, rest_length, stiffness).
708    pub edges:      Vec<(usize, usize, f32, f32)>,
709    pub gravity:    glam::Vec3,
710    pub damping:    f32,
711    pub iterations: usize,
712}
713
714impl SpringNetwork {
715    pub fn new() -> Self {
716        Self {
717            positions:  Vec::new(),
718            velocities: Vec::new(),
719            masses:     Vec::new(),
720            edges:      Vec::new(),
721            gravity:    glam::Vec3::NEG_Y * 9.8,
722            damping:    0.98,
723            iterations: 4,
724        }
725    }
726
727    pub fn add_node(&mut self, pos: glam::Vec3, mass: f32) -> usize {
728        let i = self.positions.len();
729        self.positions.push(pos);
730        self.velocities.push(glam::Vec3::ZERO);
731        self.masses.push(mass);
732        i
733    }
734
735    /// Add an edge between two nodes. Automatically computes rest length from current positions.
736    pub fn add_edge(&mut self, a: usize, b: usize, stiffness: f32) {
737        let rest = (self.positions[b] - self.positions[a]).length();
738        self.edges.push((a, b, rest, stiffness));
739    }
740
741    pub fn add_edge_with_length(&mut self, a: usize, b: usize, rest_length: f32, stiffness: f32) {
742        self.edges.push((a, b, rest_length, stiffness));
743    }
744
745    pub fn tick(&mut self, dt: f32) {
746        let n = self.positions.len();
747
748        // Integrate with gravity and damping
749        for i in 0..n {
750            if self.masses[i].is_infinite() { continue; }
751            self.velocities[i] += self.gravity * dt;
752            self.velocities[i] *= self.damping;
753            self.positions[i] += self.velocities[i] * dt;
754        }
755
756        // Constraint relaxation
757        for _ in 0..self.iterations {
758            for &(a, b, rest, stiffness) in &self.edges {
759                let pa = self.positions[a];
760                let pb = self.positions[b];
761                let delta = pb - pa;
762                let dist = delta.length();
763                if dist < 1e-6 { continue; }
764                let error = dist - rest;
765                let dir = delta / dist;
766                let ma = self.masses[a];
767                let mb = self.masses[b];
768                let total_w = (1.0 / ma.min(1e6) + 1.0 / mb.min(1e6)).max(1e-6);
769                let correction = dir * error * stiffness / total_w;
770                if !ma.is_infinite() { self.positions[a] += correction / ma.min(1e6); }
771                if !mb.is_infinite() { self.positions[b] -= correction / mb.min(1e6); }
772            }
773        }
774    }
775
776    pub fn apply_impulse(&mut self, node: usize, impulse: glam::Vec3) {
777        if node < self.velocities.len() && !self.masses[node].is_infinite() {
778            self.velocities[node] += impulse / self.masses[node];
779        }
780    }
781
782    /// Apply a radial explosion impulse from `center`.
783    pub fn explode(&mut self, center: glam::Vec3, strength: f32, radius: f32) {
784        for i in 0..self.positions.len() {
785            if self.masses[i].is_infinite() { continue; }
786            let d = self.positions[i] - center;
787            let dist = d.length();
788            if dist < radius && dist > 1e-6 {
789                let factor = (1.0 - dist / radius) * strength;
790                self.velocities[i] += d / dist * factor / self.masses[i];
791            }
792        }
793    }
794
795    pub fn node_count(&self) -> usize { self.positions.len() }
796    pub fn edge_count(&self) -> usize { self.edges.len() }
797}
798
799impl Default for SpringNetwork {
800    fn default() -> Self { Self::new() }
801}
802
803// ── Oscillator bank ───────────────────────────────────────────────────────────
804
805/// A bank of N coupled oscillators — each oscillator influences its neighbors.
806///
807/// Models things like glyph cluster "breathing", synchronized pulsing,
808/// or the Kuramoto synchronization model.
809#[derive(Debug, Clone)]
810pub struct CoupledOscillators {
811    /// Phase of each oscillator (radians).
812    pub phases:      Vec<f32>,
813    /// Natural frequency of each oscillator (Hz).
814    pub frequencies: Vec<f32>,
815    /// Coupling strength — how strongly each oscillator pulls neighbors.
816    pub coupling:    f32,
817    /// Which oscillators are neighbors (pairs).
818    pub edges:       Vec<(usize, usize)>,
819}
820
821impl CoupledOscillators {
822    /// Create a ring of `n` oscillators with the same frequency.
823    pub fn ring(n: usize, frequency: f32, coupling: f32) -> Self {
824        let phases: Vec<f32> = (0..n).map(|i| {
825            i as f32 / n as f32 * std::f32::consts::TAU
826        }).collect();
827        let frequencies = vec![frequency; n];
828        let edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
829        Self { phases, frequencies, coupling, edges }
830    }
831
832    /// Create a chain of `n` oscillators with slightly varied frequencies.
833    pub fn chain(n: usize, base_freq: f32, freq_spread: f32, coupling: f32) -> Self {
834        let phases: Vec<f32> = (0..n).map(|i| i as f32 * 0.3).collect();
835        let frequencies: Vec<f32> = (0..n).map(|i| {
836            let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
837            base_freq + (t - 0.5) * freq_spread
838        }).collect();
839        let edges: Vec<(usize, usize)> = (0..(n - 1)).map(|i| (i, i + 1)).collect();
840        Self { phases, frequencies, coupling, edges }
841    }
842
843    /// Step the Kuramoto model by `dt` seconds.
844    pub fn tick(&mut self, dt: f32) {
845        let n = self.phases.len();
846        let mut dphi = vec![0.0_f32; n];
847
848        for i in 0..n {
849            dphi[i] += self.frequencies[i] * std::f32::consts::TAU;
850        }
851
852        for &(a, b) in &self.edges {
853            let diff = self.phases[b] - self.phases[a];
854            let coupling_term = self.coupling * diff.sin();
855            dphi[a] += coupling_term;
856            dphi[b] -= coupling_term;
857        }
858
859        for i in 0..n {
860            self.phases[i] += dphi[i] * dt;
861            // Wrap to [0, TAU)
862            self.phases[i] %= std::f32::consts::TAU;
863            if self.phases[i] < 0.0 { self.phases[i] += std::f32::consts::TAU; }
864        }
865    }
866
867    /// Output amplitude for oscillator `i` (sine wave).
868    pub fn value(&self, i: usize) -> f32 {
869        self.phases.get(i).map(|&p| p.sin()).unwrap_or(0.0)
870    }
871
872    /// Order parameter R ∈ [0, 1]: measures synchrony (1 = fully synchronized).
873    pub fn synchrony(&self) -> f32 {
874        if self.phases.is_empty() { return 0.0; }
875        let sx: f32 = self.phases.iter().map(|p| p.cos()).sum();
876        let sy: f32 = self.phases.iter().map(|p| p.sin()).sum();
877        let n = self.phases.len() as f32;
878        (sx * sx + sy * sy).sqrt() / n
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    #[test]
887    fn spring_converges() {
888        let mut s = SpringDamper::critical(0.0, 5.0);
889        s.set_target(1.0);
890        for _ in 0..500 {
891            s.tick(0.016);
892        }
893        assert!((s.position - 1.0).abs() < 0.01, "spring did not converge: {}", s.position);
894    }
895
896    #[test]
897    fn underdamped_overshoots() {
898        let mut s = SpringDamper::bouncy(0.0, 8.0, 0.3);
899        s.set_target(1.0);
900        let mut max = 0.0f32;
901        for _ in 0..200 {
902            s.tick(0.016);
903            max = max.max(s.position);
904        }
905        assert!(max > 1.0, "underdamped spring should overshoot, max={}", max);
906    }
907
908    #[test]
909    fn constrained_spring_clamps_position() {
910        let mut cs = ConstrainedSpring::new(0.5, 5.0, 2.0)
911            .with_pos_limits(0.0, 1.0);
912        cs.set_target(2.0); // tries to go above 1.0
913        for _ in 0..200 {
914            cs.tick(0.016);
915        }
916        assert!(cs.position() <= 1.001, "position should be clamped: {}", cs.position());
917    }
918
919    #[test]
920    fn spring_chain_falls() {
921        let mut chain = SpringChain::new(glam::Vec3::ZERO, 4, 0.5);
922        let initial_tip = chain.tip();
923        for _ in 0..60 {
924            chain.tick(0.016);
925        }
926        let new_tip = chain.tip();
927        // Tip should fall (more negative Y)
928        assert!(new_tip.y < initial_tip.y, "chain tip should fall under gravity");
929    }
930
931    #[test]
932    fn spring_chain_anchor_stays_put() {
933        let anchor = glam::Vec3::new(0.0, 5.0, 0.0);
934        let mut chain = SpringChain::new(anchor, 4, 0.5);
935        for _ in 0..100 {
936            chain.tick(0.016);
937        }
938        let head = chain.positions[0];
939        assert!((head - anchor).length() < 0.001, "anchor should stay fixed");
940    }
941
942    #[test]
943    fn distance_constraint_rope_ignores_compression() {
944        let rope = DistanceConstraint::rope(1.0);
945        let (da, db) = rope.solve(
946            glam::Vec3::ZERO,
947            glam::Vec3::new(0.5, 0.0, 0.0), // shorter than rest_length=1.0
948            1.0, 1.0,
949        );
950        // Rope doesn't resist compression — no correction
951        assert!(da.length() < 1e-5, "rope should not correct compression");
952        assert!(db.length() < 1e-5, "rope should not correct compression");
953    }
954
955    #[test]
956    fn spring_network_explode() {
957        let mut net = SpringNetwork::new();
958        let a = net.add_node(glam::Vec3::new(-1.0, 0.0, 0.0), 1.0);
959        let b = net.add_node(glam::Vec3::new( 1.0, 0.0, 0.0), 1.0);
960        net.add_edge(a, b, 0.5);
961        let initial_dist = (net.positions[b] - net.positions[a]).length();
962        net.explode(glam::Vec3::ZERO, 10.0, 5.0);
963        net.tick(0.016);
964        let new_dist = (net.positions[b] - net.positions[a]).length();
965        assert!(new_dist > initial_dist, "explosion should push nodes apart");
966    }
967
968    #[test]
969    fn coupled_oscillators_ring_synchrony() {
970        let mut osc = CoupledOscillators::ring(6, 1.0, 2.0);
971        // Run for a while — should synchronize
972        for _ in 0..1000 {
973            osc.tick(0.016);
974        }
975        let r = osc.synchrony();
976        assert!(r > 0.5, "ring oscillators should show some synchrony: r={:.3}", r);
977    }
978
979    #[test]
980    fn verlet_cloth_top_row_stays_pinned() {
981        let mut cloth = VerletCloth::new(glam::Vec3::ZERO, 4, 3, 0.5);
982        let initial_y = cloth.point(0, 0).y;
983        for _ in 0..100 {
984            cloth.tick(0.016);
985        }
986        let final_y = cloth.point(0, 0).y;
987        assert!((final_y - initial_y).abs() < 0.001, "pinned top row should not move");
988    }
989
990    #[test]
991    fn verlet_cloth_bottom_falls() {
992        let mut cloth = VerletCloth::new(glam::Vec3::ZERO, 2, 3, 0.5);
993        let init = cloth.point(2, 0).y;
994        for _ in 0..100 {
995            cloth.tick(0.016);
996        }
997        let after = cloth.point(2, 0).y;
998        assert!(after < init, "bottom row should fall under gravity");
999    }
1000}