Skip to main content

proof_engine/metaball/
entity_field.rs

1//! MetaballEntity — the scalar field definition for isosurface entities.
2//!
3//! Each entity is a collection of field sources whose combined scalar field defines
4//! the entity's visible body. The isosurface at `threshold` is the entity's skin.
5//! HP modulates source strength — taking damage literally reshapes the body.
6
7use glam::{Vec3, Vec4};
8use crate::math::AttractorType;
9
10// ── FalloffType ─────────────────────────────────────────────────────────────
11
12/// How a field source's strength decays with distance.
13#[derive(Debug, Clone)]
14pub enum FalloffType {
15    /// 1 / (1 + r²). Simple, infinite range.
16    InverseSquare,
17    /// exp(-r²/(2σ²)). Bell curve, configurable width.
18    Gaussian,
19    /// (1 - r²/R²)³ for r < R, else 0. C2 continuous. Standard metaball kernel.
20    Wyvill,
21    /// Field modulated by a chaotic attractor's trajectory.
22    Attractor(AttractorType),
23    /// Linear falloff: 1 - r/R, clamped to 0.
24    Linear,
25    /// Smooth polynomial: 1 - 3(r/R)² + 2(r/R)³. Hermite-like.
26    SmoothPoly,
27}
28
29impl FalloffType {
30    /// Evaluate the falloff at distance `r` with source radius `R`.
31    pub fn evaluate(&self, r: f32, radius: f32) -> f32 {
32        match self {
33            Self::InverseSquare => {
34                1.0 / (1.0 + (r * r) / (radius * radius))
35            }
36            Self::Gaussian => {
37                let sigma = radius * 0.4;
38                (-r * r / (2.0 * sigma * sigma)).exp()
39            }
40            Self::Wyvill => {
41                if r >= radius { return 0.0; }
42                let t = r * r / (radius * radius);
43                let v = 1.0 - t;
44                v * v * v
45            }
46            Self::Linear => {
47                if r >= radius { 0.0 } else { 1.0 - r / radius }
48            }
49            Self::SmoothPoly => {
50                if r >= radius { return 0.0; }
51                let t = r / radius;
52                1.0 - 3.0 * t * t + 2.0 * t * t * t
53            }
54            Self::Attractor(_) => {
55                // Attractor-driven falloff uses Wyvill as base
56                if r >= radius { return 0.0; }
57                let t = r * r / (radius * radius);
58                let v = 1.0 - t;
59                v * v * v
60            }
61        }
62    }
63}
64
65impl Default for FalloffType {
66    fn default() -> Self { Self::Wyvill }
67}
68
69// ── FieldSource ─────────────────────────────────────────────────────────────
70
71/// A single field source (metaball) contributing to the entity's scalar field.
72#[derive(Debug, Clone)]
73pub struct FieldSource {
74    /// Current world-space position of this source.
75    pub position: Vec3,
76    /// Base (rest) position, relative to entity center.
77    pub rest_offset: Vec3,
78    /// Current field strength (modulated by HP, damage, breathing).
79    pub strength: f32,
80    /// Influence radius. Beyond this distance, contribution is zero (for Wyvill/Linear/SmoothPoly).
81    pub radius: f32,
82    /// How the field decays with distance.
83    pub falloff: FalloffType,
84    /// Color contribution for surface coloring.
85    pub color: Vec4,
86    /// Emission intensity.
87    pub emission: f32,
88    /// Strength at full HP (before any modulation).
89    pub base_strength: f32,
90    /// Breathing animation amplitude.
91    pub breath_amplitude: f32,
92    /// Breathing animation phase offset (different sources breathe slightly out of sync).
93    pub breath_phase_offset: f32,
94    /// If true, this source has been permanently destroyed by a crit.
95    pub destroyed: bool,
96    /// Temporary strength reduction from recent damage (decays over time).
97    pub damage_reduction: f32,
98    /// Time of last damage hit to this source.
99    pub last_hit_time: f32,
100    /// Name/tag for this source (e.g. "head", "torso_upper", "left_arm").
101    pub tag: String,
102}
103
104impl FieldSource {
105    pub fn new(offset: Vec3, strength: f32, radius: f32) -> Self {
106        Self {
107            position: offset,
108            rest_offset: offset,
109            strength,
110            radius,
111            falloff: FalloffType::Wyvill,
112            color: Vec4::ONE,
113            emission: 0.0,
114            base_strength: strength,
115            breath_amplitude: 0.02,
116            breath_phase_offset: 0.0,
117            destroyed: false,
118            damage_reduction: 0.0,
119            last_hit_time: -10.0,
120            tag: String::new(),
121        }
122    }
123
124    pub fn with_color(mut self, color: Vec4) -> Self { self.color = color; self }
125    pub fn with_emission(mut self, e: f32) -> Self { self.emission = e; self }
126    pub fn with_falloff(mut self, f: FalloffType) -> Self { self.falloff = f; self }
127    pub fn with_tag(mut self, tag: impl Into<String>) -> Self { self.tag = tag.into(); self }
128    pub fn with_breath(mut self, amplitude: f32, phase: f32) -> Self {
129        self.breath_amplitude = amplitude;
130        self.breath_phase_offset = phase;
131        self
132    }
133
134    /// Effective strength after HP modulation, damage reduction, and destruction.
135    pub fn effective_strength(&self, hp_ratio: f32) -> f32 {
136        if self.destroyed { return 0.0; }
137        (self.base_strength * hp_ratio - self.damage_reduction).max(0.0)
138    }
139
140    /// Evaluate this source's contribution at a world-space point.
141    pub fn evaluate(&self, point: Vec3, hp_ratio: f32) -> f32 {
142        let strength = self.effective_strength(hp_ratio);
143        if strength <= 0.0 { return 0.0; }
144        let dist = (point - self.position).length();
145        strength * self.falloff.evaluate(dist, self.radius)
146    }
147
148    /// Whether this source can contribute any field value.
149    pub fn is_active(&self) -> bool {
150        !self.destroyed && self.base_strength > 0.0
151    }
152}
153
154impl Default for FieldSource {
155    fn default() -> Self { Self::new(Vec3::ZERO, 1.0, 1.0) }
156}
157
158// ── MetaballEntity ──────────────────────────────────────────────────────────
159
160/// An entity defined by a scalar field from metaball sources.
161///
162/// The visible body is the isosurface at `threshold`. HP modulates all source
163/// strengths, so damage physically reshapes the body. Crits destroy individual
164/// sources, punching permanent holes.
165#[derive(Debug, Clone)]
166pub struct MetaballEntity {
167    /// All field sources making up this entity.
168    pub sources: Vec<FieldSource>,
169    /// Isosurface threshold. Surface exists where field(p) >= threshold.
170    pub threshold: f32,
171    /// Current HP ratio (0.0 = dead, 1.0 = full HP).
172    pub hp_ratio: f32,
173    /// Grid resolution for marching cubes extraction.
174    /// 32 for normal entities, 64 for bosses.
175    pub grid_resolution: u32,
176    /// Current breathing animation phase.
177    pub breath_phase: f32,
178    /// Breathing animation frequency (Hz).
179    pub breath_frequency: f32,
180    /// Entity center in world space.
181    pub center: Vec3,
182    /// Entity rotation (euler angles for simplicity).
183    pub rotation: Vec3,
184    /// Entity scale.
185    pub scale: f32,
186    /// Bounding box half-extents (auto-computed from sources).
187    pub bounds_half: Vec3,
188    /// Whether the mesh needs re-extraction this frame.
189    pub dirty: bool,
190    /// Name for debugging.
191    pub name: String,
192}
193
194impl MetaballEntity {
195    pub fn new(threshold: f32, resolution: u32) -> Self {
196        Self {
197            sources: Vec::new(),
198            threshold,
199            hp_ratio: 1.0,
200            grid_resolution: resolution,
201            breath_phase: 0.0,
202            breath_frequency: 0.3,
203            center: Vec3::ZERO,
204            rotation: Vec3::ZERO,
205            scale: 1.0,
206            bounds_half: Vec3::splat(2.0),
207            dirty: true,
208            name: String::new(),
209        }
210    }
211
212    pub fn with_name(mut self, name: impl Into<String>) -> Self {
213        self.name = name.into();
214        self
215    }
216
217    /// Add a field source.
218    pub fn add_source(&mut self, source: FieldSource) -> usize {
219        let idx = self.sources.len();
220        self.sources.push(source);
221        self.recompute_bounds();
222        self.dirty = true;
223        idx
224    }
225
226    /// Find the source nearest to a world-space point.
227    pub fn nearest_source(&self, point: Vec3) -> Option<usize> {
228        self.sources.iter().enumerate()
229            .filter(|(_, s)| s.is_active())
230            .min_by(|(_, a), (_, b)| {
231                let da = (a.position - point).length_squared();
232                let db = (b.position - point).length_squared();
233                da.partial_cmp(&db).unwrap()
234            })
235            .map(|(i, _)| i)
236    }
237
238    /// Find a source by tag.
239    pub fn find_source(&self, tag: &str) -> Option<usize> {
240        self.sources.iter().position(|s| s.tag == tag)
241    }
242
243    /// Update the entity for a new frame.
244    ///
245    /// - Advances breathing animation
246    /// - Updates source positions from breathing offsets
247    /// - Decays damage reductions (spring-back)
248    /// - Marks as dirty if anything changed
249    pub fn update(&mut self, dt: f32, time: f32) {
250        self.breath_phase = time * self.breath_frequency * std::f32::consts::TAU;
251
252        let mut any_changed = false;
253        for source in &mut self.sources {
254            if source.destroyed { continue; }
255
256            // Breathing animation
257            let breath = (self.breath_phase + source.breath_phase_offset).sin()
258                * source.breath_amplitude;
259            let new_pos = self.center + (source.rest_offset + source.rest_offset.normalize_or_zero() * breath) * self.scale;
260
261            if (new_pos - source.position).length_squared() > 1e-6 {
262                source.position = new_pos;
263                any_changed = true;
264            }
265
266            // Damage recovery (spring-back): reduce damage_reduction by 50% per 0.5s
267            if source.damage_reduction > 0.001 {
268                let recovery_rate = 0.5_f32.powf(dt / 0.5);
269                let permanent_reduction = source.base_strength * (1.0 - self.hp_ratio);
270                let excess = (source.damage_reduction - permanent_reduction).max(0.0);
271                source.damage_reduction = permanent_reduction + excess * recovery_rate;
272                any_changed = true;
273            }
274
275            // Update effective strength
276            source.strength = source.effective_strength(self.hp_ratio);
277        }
278
279        if any_changed { self.dirty = true; }
280    }
281
282    /// Evaluate the scalar field at a world-space point.
283    pub fn evaluate(&self, point: Vec3) -> f32 {
284        let mut total = 0.0;
285        for source in &self.sources {
286            total += source.evaluate(point, self.hp_ratio);
287        }
288        total
289    }
290
291    /// Evaluate the scalar field and compute color/emission at a point.
292    pub fn evaluate_full(&self, point: Vec3) -> (f32, Vec4, f32) {
293        let mut total_strength = 0.0;
294        let mut total_color = Vec4::ZERO;
295        let mut total_emission = 0.0;
296
297        for source in &self.sources {
298            let contrib = source.evaluate(point, self.hp_ratio);
299            if contrib > 0.0 {
300                total_strength += contrib;
301                total_color += source.color * contrib;
302                total_emission += source.emission * contrib;
303            }
304        }
305
306        if total_strength > 0.0 {
307            total_color /= total_strength;
308            total_emission /= total_strength;
309        }
310
311        (total_strength, total_color, total_emission)
312    }
313
314    /// Compute the gradient (normal direction) at a point via central differences.
315    pub fn gradient(&self, point: Vec3) -> Vec3 {
316        let eps = 0.01 * self.scale;
317        Vec3::new(
318            self.evaluate(point + Vec3::X * eps) - self.evaluate(point - Vec3::X * eps),
319            self.evaluate(point + Vec3::Y * eps) - self.evaluate(point - Vec3::Y * eps),
320            self.evaluate(point + Vec3::Z * eps) - self.evaluate(point - Vec3::Z * eps),
321        ) / (2.0 * eps)
322    }
323
324    /// Normal at a point on the isosurface (normalized gradient, pointing outward).
325    pub fn normal_at(&self, point: Vec3) -> Vec3 {
326        self.gradient(point).normalize_or_zero()
327    }
328
329    /// Set HP ratio. Marks dirty.
330    pub fn set_hp(&mut self, ratio: f32) {
331        let new_ratio = ratio.clamp(0.0, 1.0);
332        if (self.hp_ratio - new_ratio).abs() > 1e-6 {
333            self.hp_ratio = new_ratio;
334            self.dirty = true;
335        }
336    }
337
338    /// Move the entity center. Updates all source positions.
339    pub fn set_center(&mut self, center: Vec3) {
340        if (self.center - center).length_squared() > 1e-6 {
341            self.center = center;
342            for source in &mut self.sources {
343                source.position = center + source.rest_offset * self.scale;
344            }
345            self.dirty = true;
346        }
347    }
348
349    /// Recompute bounding box from source positions and radii.
350    pub fn recompute_bounds(&mut self) {
351        let mut max_extent = Vec3::ZERO;
352        for source in &self.sources {
353            let extent = source.rest_offset.abs() + Vec3::splat(source.radius);
354            max_extent = max_extent.max(extent);
355        }
356        self.bounds_half = max_extent * self.scale * 1.1; // 10% padding
357    }
358
359    /// World-space bounding box (min, max).
360    pub fn bounds(&self) -> (Vec3, Vec3) {
361        (self.center - self.bounds_half, self.center + self.bounds_half)
362    }
363
364    /// Number of active (non-destroyed) sources.
365    pub fn active_source_count(&self) -> usize {
366        self.sources.iter().filter(|s| s.is_active()).count()
367    }
368
369    /// Total active source count.
370    pub fn source_count(&self) -> usize { self.sources.len() }
371
372    /// Whether the entity is effectively dead (no visible surface).
373    pub fn is_dead(&self) -> bool {
374        self.hp_ratio <= 0.0 || self.active_source_count() == 0
375    }
376
377    /// Consume the dirty flag.
378    pub fn take_dirty(&mut self) -> bool {
379        let d = self.dirty;
380        self.dirty = false;
381        d
382    }
383}
384
385impl Default for MetaballEntity {
386    fn default() -> Self { Self::new(0.5, 32) }
387}
388
389// ── Tests ───────────────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn basic_entity() -> MetaballEntity {
396        let mut e = MetaballEntity::new(0.5, 16);
397        e.add_source(FieldSource::new(Vec3::ZERO, 1.0, 1.0).with_tag("center"));
398        e.add_source(FieldSource::new(Vec3::new(0.8, 0.0, 0.0), 0.7, 0.8).with_tag("right"));
399        e.add_source(FieldSource::new(Vec3::new(-0.8, 0.0, 0.0), 0.7, 0.8).with_tag("left"));
400        e
401    }
402
403    #[test]
404    fn field_at_source_center_is_strong() {
405        let e = basic_entity();
406        let val = e.evaluate(Vec3::ZERO);
407        assert!(val > e.threshold, "Field at center should exceed threshold: {val}");
408    }
409
410    #[test]
411    fn field_far_away_is_zero() {
412        let e = basic_entity();
413        let val = e.evaluate(Vec3::new(100.0, 0.0, 0.0));
414        assert!(val < 0.01, "Field far away should be ~0: {val}");
415    }
416
417    #[test]
418    fn hp_modulates_field() {
419        let mut e = basic_entity();
420        let full_hp = e.evaluate(Vec3::ZERO);
421        e.set_hp(0.5);
422        let half_hp = e.evaluate(Vec3::ZERO);
423        assert!(half_hp < full_hp, "Half HP should weaken field: full={full_hp}, half={half_hp}");
424    }
425
426    #[test]
427    fn destroyed_source_contributes_nothing() {
428        let mut e = basic_entity();
429        e.sources[0].destroyed = true;
430        let val = e.sources[0].evaluate(Vec3::ZERO, 1.0);
431        assert_eq!(val, 0.0);
432    }
433
434    #[test]
435    fn find_source_by_tag() {
436        let e = basic_entity();
437        assert_eq!(e.find_source("right"), Some(1));
438        assert_eq!(e.find_source("nonexistent"), None);
439    }
440
441    #[test]
442    fn nearest_source_correct() {
443        let e = basic_entity();
444        let idx = e.nearest_source(Vec3::new(0.7, 0.0, 0.0)).unwrap();
445        assert_eq!(idx, 1); // "right" source at 0.8
446    }
447
448    #[test]
449    fn gradient_points_away_from_sources() {
450        let e = basic_entity();
451        let grad = e.gradient(Vec3::new(1.5, 0.0, 0.0));
452        // Gradient should point away from center (positive x direction)
453        assert!(grad.x > 0.0 || grad.length() < 0.01, "grad.x={}", grad.x);
454    }
455
456    #[test]
457    fn evaluate_full_returns_weighted_color() {
458        let mut e = MetaballEntity::new(0.5, 16);
459        e.add_source(FieldSource::new(Vec3::ZERO, 1.0, 1.0).with_color(Vec4::new(1.0, 0.0, 0.0, 1.0)));
460        let (strength, color, _emission) = e.evaluate_full(Vec3::ZERO);
461        assert!(strength > 0.0);
462        assert!(color.x > 0.5); // red dominant
463    }
464
465    #[test]
466    fn wyvill_falloff_c2_continuous() {
467        let f = FalloffType::Wyvill;
468        let r = 1.0;
469        // At boundary: should be exactly 0
470        assert_eq!(f.evaluate(r, r), 0.0);
471        // At center: should be 1
472        assert_eq!(f.evaluate(0.0, r), 1.0);
473        // Just inside boundary: should be very small but positive
474        assert!(f.evaluate(r * 0.99, r) > 0.0);
475    }
476
477    #[test]
478    fn entity_bounds_encompass_sources() {
479        let e = basic_entity();
480        let (min, max) = e.bounds();
481        for source in &e.sources {
482            assert!(source.rest_offset.x >= min.x && source.rest_offset.x <= max.x);
483        }
484    }
485
486    #[test]
487    fn dead_entity_detection() {
488        let mut e = basic_entity();
489        assert!(!e.is_dead());
490        e.set_hp(0.0);
491        assert!(e.is_dead());
492    }
493
494    #[test]
495    fn breathing_changes_positions() {
496        let mut e = basic_entity();
497        let pos_before = e.sources[1].position;
498        e.update(0.016, 1.0);
499        let pos_after = e.sources[1].position;
500        // Position should shift slightly from breathing
501        // (may be very small depending on amplitude)
502        assert!(e.sources[1].breath_amplitude > 0.0);
503    }
504}