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