Skip to main content

proof_engine/glyph/
glyph_3d_dissolve.rs

1//! 3D dissolution effects for entity death — glyph meshes split into fragments
2//! that tumble with rigid-body physics, catching light as they scatter.
3
4use glam::{Vec2, Vec3, Vec4};
5
6use super::glyph_mesh::{GlyphMesh, Vertex3D};
7use super::glyph_materials::{GlyphMaterial, lerp_material};
8
9// ── Fragment ────────────────────────────────────────────────────────────────
10
11/// A single fragment of a dissolved glyph mesh.
12#[derive(Clone, Debug)]
13pub struct GlyphFragment {
14    pub mesh: GlyphMesh,
15    pub position: Vec3,
16    pub velocity: Vec3,
17    pub angular_velocity: Vec3,
18    pub rotation: Vec3,
19    pub material: GlyphMaterial,
20    pub life: f32,
21    pub max_life: f32,
22}
23
24impl GlyphFragment {
25    pub fn is_alive(&self) -> bool { self.life > 0.0 }
26    pub fn progress(&self) -> f32 { 1.0 - (self.life / self.max_life).clamp(0.0, 1.0) }
27}
28
29// ── Dissolve State ──────────────────────────────────────────────────────────
30
31/// Manages the dissolution of a glyph into fragments.
32#[derive(Clone, Debug)]
33pub struct DissolveState {
34    pub fragments: Vec<GlyphFragment>,
35    pub age: f32,
36    pub duration: f32,
37    pub done: bool,
38}
39
40impl DissolveState {
41    pub fn active_fragments(&self) -> usize {
42        self.fragments.iter().filter(|f| f.is_alive()).count()
43    }
44}
45
46// ── Mesh splitting ──────────────────────────────────────────────────────────
47
48/// Split a mesh along a plane into two halves.
49pub fn split_mesh_by_plane(
50    mesh: &GlyphMesh,
51    plane_point: Vec3,
52    plane_normal: Vec3,
53) -> (GlyphMesh, GlyphMesh) {
54    let normal = plane_normal.normalize_or_zero();
55    let mut verts_a = Vec::new();
56    let mut verts_b = Vec::new();
57    let mut indices_a = Vec::new();
58    let mut indices_b = Vec::new();
59    let mut map_a: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
60    let mut map_b: std::collections::HashMap<u32, u32> = std::collections::HashMap::new();
61
62    // Classify each triangle
63    let tri_count = mesh.indices.len() / 3;
64    for t in 0..tri_count {
65        let i0 = mesh.indices[t * 3] as usize;
66        let i1 = mesh.indices[t * 3 + 1] as usize;
67        let i2 = mesh.indices[t * 3 + 2] as usize;
68
69        let v0 = Vec3::from(mesh.vertices[i0].position);
70        let v1 = Vec3::from(mesh.vertices[i1].position);
71        let v2 = Vec3::from(mesh.vertices[i2].position);
72
73        let d0 = (v0 - plane_point).dot(normal);
74        let d1 = (v1 - plane_point).dot(normal);
75        let d2 = (v2 - plane_point).dot(normal);
76
77        let centroid_side = d0 + d1 + d2;
78
79        // Simple classification: entire triangle goes to whichever side the centroid is on
80        let original_indices = [mesh.indices[t * 3], mesh.indices[t * 3 + 1], mesh.indices[t * 3 + 2]];
81
82        if centroid_side >= 0.0 {
83            for &oi in &original_indices {
84                let new_idx = *map_a.entry(oi).or_insert_with(|| {
85                    let idx = verts_a.len() as u32;
86                    verts_a.push(mesh.vertices[oi as usize]);
87                    idx
88                });
89                indices_a.push(new_idx);
90            }
91        } else {
92            for &oi in &original_indices {
93                let new_idx = *map_b.entry(oi).or_insert_with(|| {
94                    let idx = verts_b.len() as u32;
95                    verts_b.push(mesh.vertices[oi as usize]);
96                    idx
97                });
98                indices_b.push(new_idx);
99            }
100        }
101    }
102
103    let compute_bounds = |verts: &[Vertex3D]| -> (Vec3, Vec3) {
104        let mut bmin = Vec3::splat(f32::MAX);
105        let mut bmax = Vec3::splat(f32::MIN);
106        for v in verts {
107            let p = Vec3::from(v.position);
108            bmin = bmin.min(p);
109            bmax = bmax.max(p);
110        }
111        if verts.is_empty() { (Vec3::ZERO, Vec3::ZERO) } else { (bmin, bmax) }
112    };
113
114    let (bmin_a, bmax_a) = compute_bounds(&verts_a);
115    let (bmin_b, bmax_b) = compute_bounds(&verts_b);
116
117    let mesh_a = GlyphMesh {
118        vertices: verts_a,
119        triangle_count: indices_a.len() as u32 / 3,
120        indices: indices_a,
121        character: mesh.character,
122        extrusion_depth: mesh.extrusion_depth,
123        bounds_min: bmin_a,
124        bounds_max: bmax_a,
125    };
126
127    let mesh_b = GlyphMesh {
128        vertices: verts_b,
129        triangle_count: indices_b.len() as u32 / 3,
130        indices: indices_b,
131        character: mesh.character,
132        extrusion_depth: mesh.extrusion_depth,
133        bounds_min: bmin_b,
134        bounds_max: bmax_b,
135    };
136
137    (mesh_a, mesh_b)
138}
139
140// ── Dissolve creation ───────────────────────────────────────────────────────
141
142/// Start a dissolution effect by splitting the mesh into fragments.
143pub fn start_dissolve(
144    mesh: &GlyphMesh,
145    material: &GlyphMaterial,
146    impact_dir: Vec3,
147    impact_strength: f32,
148) -> DissolveState {
149    let mut fragments = Vec::new();
150
151    // Generate 2-4 random split planes
152    let num_splits = if mesh.triangle_count > 20 { 3 } else { 2 };
153    let center = (mesh.bounds_min + mesh.bounds_max) * 0.5;
154
155    let mut current_meshes = vec![mesh.clone()];
156
157    // Simple deterministic "random" planes based on mesh properties
158    let planes: Vec<(Vec3, Vec3)> = (0..num_splits).map(|i| {
159        let angle = i as f32 * 1.2 + impact_dir.x * 0.5;
160        let normal = Vec3::new(angle.cos(), angle.sin(), 0.3).normalize();
161        let offset = (i as f32 - num_splits as f32 * 0.5) * 0.2;
162        let point = center + normal * offset;
163        (point, normal)
164    }).collect();
165
166    for (plane_point, plane_normal) in &planes {
167        let mut new_meshes = Vec::new();
168        for m in &current_meshes {
169            if m.triangle_count < 2 {
170                new_meshes.push(m.clone());
171                continue;
172            }
173            let (a, b) = split_mesh_by_plane(m, *plane_point, *plane_normal);
174            if !a.vertices.is_empty() { new_meshes.push(a); }
175            if !b.vertices.is_empty() { new_meshes.push(b); }
176        }
177        current_meshes = new_meshes;
178    }
179
180    // Create fragments from split meshes
181    for (i, frag_mesh) in current_meshes.into_iter().enumerate() {
182        if frag_mesh.vertices.is_empty() { continue; }
183
184        let frag_center = (frag_mesh.bounds_min + frag_mesh.bounds_max) * 0.5;
185        let scatter_dir = (frag_center - center).normalize_or_zero();
186
187        // Velocity: mix of impact direction and scatter direction
188        let base_speed = impact_strength * 2.0;
189        let velocity = (impact_dir.normalize_or_zero() * 0.5 + scatter_dir * 0.5).normalize_or_zero()
190            * base_speed * (1.0 + i as f32 * 0.3);
191
192        // Angular velocity perpendicular to velocity
193        let angular = impact_dir.cross(Vec3::new(0.0, 0.0, 1.0)).normalize_or_zero()
194            * impact_strength * 5.0 * (if i % 2 == 0 { 1.0 } else { -1.0 });
195
196        let life = 1.0 + i as f32 * 0.2;
197
198        fragments.push(GlyphFragment {
199            mesh: frag_mesh,
200            position: frag_center,
201            velocity,
202            angular_velocity: angular,
203            rotation: Vec3::ZERO,
204            material: *material,
205            life,
206            max_life: life,
207        });
208    }
209
210    let max_life = fragments.iter().map(|f| f.max_life).fold(0.0f32, f32::max);
211
212    DissolveState {
213        fragments,
214        age: 0.0,
215        duration: max_life,
216        done: false,
217    }
218}
219
220// ── Simulation ──────────────────────────────────────────────────────────────
221
222/// Advance the dissolve simulation by dt seconds.
223pub fn tick_dissolve(state: &mut DissolveState, dt: f32, gravity: Vec3) {
224    state.age += dt;
225
226    for frag in &mut state.fragments {
227        if !frag.is_alive() { continue; }
228
229        // Physics
230        frag.velocity += gravity * dt;
231        frag.position += frag.velocity * dt;
232        frag.rotation += frag.angular_velocity * dt;
233
234        // Damping
235        frag.velocity *= 0.98;
236        frag.angular_velocity *= 0.97;
237
238        // Life decay
239        frag.life -= dt;
240
241        // Material animation: fade emission, increase roughness, darken
242        frag.material = dissolve_material_at(&frag.material, frag.progress());
243    }
244
245    state.done = state.fragments.iter().all(|f| !f.is_alive());
246}
247
248/// Compute the material state at a given dissolution progress (0 = alive, 1 = dead).
249pub fn dissolve_material_at(base: &GlyphMaterial, progress: f32) -> GlyphMaterial {
250    let p = progress.clamp(0.0, 1.0);
251
252    let dead = GlyphMaterial {
253        base_color: [0.1, 0.1, 0.1, 0.0],
254        emission: 0.0,
255        metallic: 0.0,
256        roughness: 1.0,
257        subsurface: 0.0,
258        fresnel: 0.02,
259        _pad: [0.0; 3],
260    };
261
262    lerp_material(base, &dead, p)
263}
264
265// ── Dissolve renderer helper ────────────────────────────────────────────────
266
267/// Collects all alive fragment instances for submission to Glyph3DRenderer.
268pub struct DissolveRenderer;
269
270impl DissolveRenderer {
271    /// Get transform matrices and materials for all alive fragments.
272    pub fn collect_instances(state: &DissolveState) -> Vec<(glam::Mat4, GlyphMaterial, char)> {
273        let mut instances = Vec::new();
274        for frag in &state.fragments {
275            if !frag.is_alive() { continue; }
276
277            let translation = glam::Mat4::from_translation(frag.position);
278            let rot_x = glam::Mat4::from_rotation_x(frag.rotation.x);
279            let rot_y = glam::Mat4::from_rotation_y(frag.rotation.y);
280            let rot_z = glam::Mat4::from_rotation_z(frag.rotation.z);
281            let scale = glam::Mat4::from_scale(Vec3::splat(1.0 - frag.progress() * 0.3));
282            let transform = translation * rot_z * rot_y * rot_x * scale;
283
284            instances.push((transform, frag.material, frag.mesh.character));
285        }
286        instances
287    }
288}
289
290// ── Tests ───────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use super::super::glyph_mesh::create_box_mesh;
296
297    fn test_mesh() -> GlyphMesh {
298        create_box_mesh(Vec3::ZERO, Vec3::ONE, 'X', 0.5)
299    }
300
301    #[test]
302    fn split_produces_two_meshes() {
303        let mesh = test_mesh();
304        let (a, b) = split_mesh_by_plane(&mesh, Vec3::new(0.5, 0.5, 0.0), Vec3::X);
305        assert!(!a.vertices.is_empty() || !b.vertices.is_empty());
306        // Total triangles should be ≤ original (no duplication at split boundary in simple mode)
307        assert!(a.triangle_count + b.triangle_count <= mesh.triangle_count + 2);
308    }
309
310    #[test]
311    fn dissolve_creates_fragments() {
312        let mesh = test_mesh();
313        let mat = GlyphMaterial::enemy();
314        let state = start_dissolve(&mesh, &mat, Vec3::X, 1.0);
315        assert!(state.fragments.len() >= 2, "Should create at least 2 fragments, got {}", state.fragments.len());
316    }
317
318    #[test]
319    fn fragments_move_apart() {
320        let mesh = test_mesh();
321        let mat = GlyphMaterial::enemy();
322        let mut state = start_dissolve(&mesh, &mat, Vec3::X, 5.0);
323        let initial_positions: Vec<Vec3> = state.fragments.iter().map(|f| f.position).collect();
324
325        tick_dissolve(&mut state, 0.5, Vec3::new(0.0, -9.8, 0.0));
326
327        for (i, frag) in state.fragments.iter().enumerate() {
328            let moved = (frag.position - initial_positions[i]).length();
329            assert!(moved > 0.01, "Fragment {} should have moved", i);
330        }
331    }
332
333    #[test]
334    fn dissolve_material_darkens() {
335        let mat = GlyphMaterial::boss();
336        let dead_mat = dissolve_material_at(&mat, 1.0);
337        assert!(dead_mat.emission < mat.emission);
338        assert!(dead_mat.roughness > mat.roughness);
339    }
340
341    #[test]
342    fn dissolve_completes() {
343        let mesh = test_mesh();
344        let mat = GlyphMaterial::enemy();
345        let mut state = start_dissolve(&mesh, &mat, Vec3::X, 1.0);
346        for _ in 0..100 {
347            tick_dissolve(&mut state, 0.1, Vec3::ZERO);
348        }
349        assert!(state.done, "Should be done after enough time");
350    }
351
352    #[test]
353    fn collect_instances_only_alive() {
354        let mesh = test_mesh();
355        let mat = GlyphMaterial::enemy();
356        let mut state = start_dissolve(&mesh, &mat, Vec3::X, 1.0);
357        let initial = DissolveRenderer::collect_instances(&state).len();
358        for _ in 0..100 {
359            tick_dissolve(&mut state, 0.1, Vec3::ZERO);
360        }
361        let final_count = DissolveRenderer::collect_instances(&state).len();
362        assert!(final_count <= initial);
363    }
364}