bevy_carnage 0.1.1

Deterministic runtime gore for Bevy: plane-cut a character's own meshes into watertight-capped chunks, bore bullet channels through them, and drive blood, spatter and impact feel off the wounds that result.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! **The wound.** Where a subject came open, how wide, how badly, and which way it faces.
//!
//! The crate could already say where a shared face was ([`Bond`]), how hard a blow reached it
//! ([`Reach`]), and where a channel left the subject ([`EjectaChunk`]). What it could not say is
//! "there is a wound here" — a single value with a point, a direction, an area and a severity, which
//! is what every downstream carnage decision actually needs. Blood, spatter, stains, pulse and hit
//! feel all read a [`Wound`] and nothing else, so none of them has to know whether the subject was
//! cut in half or shot through.
//!
//! # Two sources, one type
//!
//! A wound comes from exactly two places, and they are geometrically different things that read the
//! same:
//!
//! - **A severance.** Two fragments stopped sharing a face. The bond *is* the wound surface: its
//!   centroid, its normal and its area are the wound's, with no derivation at all.
//! - **A channel.** A bore removed material, so the subject has an interior wall that is open to the
//!   air. That wall is a set of cut faces on a convex cell, which [`cap_faces`] extracts.
//!
//! [`crate::proxy::ProxyCell::face_is_cut`] answers `true` for a fracture cut face **and** a bore's
//! channel wall — see [`FaceKind`](crate::proxy::FaceKind), where that decision is recorded — so cut-
//! face extraction picks up bullet channels for free. That is the whole reason a bullet hole bleeds in
//! this crate without a second code path: the geometry already said the interior was exposed.
//!
//! # Determinism
//!
//! Everything here is a pure function of baked geometry. No clock, no `Entity`, no handle, no
//! accumulator. [`wounds_from_reach`] and [`wounds_from_bonds`] return their wounds sorted by
//! [`BondId`] through [`crate::order::sort_total_by_key_at`], so the order is a function of the graph
//! rather than of how the caller happened to iterate — and `BondId` is unique per bond, so the key is
//! total and the check cannot fire on well-formed input.

use bevy::log::warn;
use bevy::math::Vec3;

use crate::bake::EjectaChunk;
use crate::bond::{Bond, BondGraph, BondId};
use crate::order::sort_total_by_key_at;
use crate::proxy::ProxyCell;
use crate::severance::Reach;
use crate::soup::MIN_CROSS2;

/// A wound surface in subject-local space. Deterministic: derived only from baked geometry.
///
/// **A value, not an entity.** It has no lifetime, no handle and no id, so a caller can compute one,
/// hash it, send it, store it in a replay, or throw it away. That is what lets the same type serve a
/// headless simulation and a particle burst.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Wound {
    /// Where it is, subject-local — the centre of the opened surface.
    pub at: Vec3,
    /// Which way it faces, unit. Blood leaves along this, and the shake kicks along it.
    pub normal: Vec3,
    /// How much surface came open, subject-local units squared. Drives droplet count.
    pub area: f32,
    /// How badly, in `[0, 1]`. `1.0` is fully open; a pulse's taper scales this and nothing else.
    pub severity: f32,
    /// Which of the two things happened — see the module docs.
    pub kind: WoundKind,
}

/// What opened the wound.
///
/// **`u32`-valued and part of the seed**, so a severance and a channel at the same point do not draw
/// the same spray. The discriminants are written out because [`crate::spatter::wound_seed`] casts
/// this, and a reordered enum would silently move every seed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u32)]
pub enum WoundKind {
    /// Two fragments stopped sharing a face.
    Severance = 0,
    /// A bore left an interior wall open to the air.
    Channel = 1,
}

/// One cut face of a convex cell — the wound surface that travels with a fragment or a plug.
///
/// Separate from [`Wound`] because a cell has several of these and a wound is one: a caller that
/// wants "the wound on this chunk" takes [`largest_cap`], and one that wants "how much of this chunk
/// is raw" sums [`cap_faces`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CapFace {
    /// Area-weighted centre of the face, subject-local.
    pub centroid: Vec3,
    /// Outward unit normal, from the face's own winding.
    pub normal: Vec3,
    /// Face area, subject-local units squared.
    pub area: f32,
}

/// The wounds a blow opened: every bond it reached at or above `threshold`.
///
/// `threshold` is the caller's "this gives way" line — the same number it would pass to
/// [`Reach::above`], and deliberately the caller's rather than a dial here, because how much damage
/// severs a limb is a game rule.
///
/// Sorted by [`BondId`], so the result is a function of the graph.
pub fn wounds_from_reach(graph: &BondGraph, reach: &Reach, threshold: f32) -> Vec<Wound> {
    let mut out: Vec<(BondId, Wound)> = reach
        .iter()
        .filter(|(_, severity)| *severity >= threshold)
        .filter_map(|(id, severity)| {
            let bond = graph.bond(id)?;
            Some((id, wound_of(bond, severity.clamp(0.0, 1.0))))
        })
        .collect();
    sort_total_by_key_at("wound::wounds_from_reach", &mut out, |(id, _)| *id);
    out.into_iter().map(|(_, w)| w).collect()
}

/// The wounds a set of already-broken bonds represents, at full severity.
///
/// For the caller that has severed bonds itself — through a [`BondSet`](crate::BondSet) — and wants
/// the wounds those severances left. A bond that is not in this graph is skipped loudly: it means two
/// frontiers' ids were mixed, which would otherwise resolve to a *different* bond and put a wound in
/// the wrong place.
pub fn wounds_from_bonds(graph: &BondGraph, broken: &[BondId]) -> Vec<Wound> {
    let mut out: Vec<(BondId, Wound)> = broken
        .iter()
        .filter_map(|id| match graph.bond(*id) {
            Some(bond) => Some((*id, wound_of(bond, 1.0))),
            None => {
                warn!("carnage: bond {id:?} is not in this graph — mixing graphs from two frontiers");
                None
            }
        })
        .collect();
    sort_total_by_key_at("wound::wounds_from_bonds", &mut out, |(id, _)| *id);
    out.into_iter().map(|(_, w)| w).collect()
}

/// The bond, as a wound. One place, so the two entry points cannot disagree about the mapping.
fn wound_of(bond: &Bond, severity: f32) -> Wound {
    Wound {
        at: bond.centroid,
        normal: bond.normal,
        area: bond.area,
        severity,
        kind: WoundKind::Severance,
    }
}

/// Every raw-interior face of a convex cell: fracture cut faces **and** bore channel walls.
///
/// Rings are converted with the Newell formula over the closed ring, which is exact for a planar
/// convex polygon and needs no triangulation choice — and the faces here are guaranteed planar and
/// convex, because a plane through a convex polyhedron produces exactly that.
///
/// **A grazed corner is not an error.** A ring shorter than a triangle, an index outside the cell's
/// points, or a face whose Newell cross product is below the crate's own zero-area floor is skipped
/// silently: those arise from a cut that clipped a vertex, they are geometrically correct, and this
/// runs per fragment per blow. Warning on them would print a page per gib.
pub fn cap_faces(cell: &ProxyCell) -> Vec<CapFace> {
    let points = cell.points();
    let mut out = Vec::new();
    for (fi, ring) in cell.faces().enumerate() {
        if !cell.face_is_cut(fi) || ring.len() < 3 {
            continue;
        }
        if ring.iter().any(|&i| i as usize >= points.len()) {
            continue;
        }
        // The ring's own vertex mean, as the pivot for both the Newell sum and the area-weighted
        // centroid. Any interior point works for Newell; using the mean keeps the fan triangles
        // well-conditioned, which matters for the weighting rather than for the normal.
        let pivot =
            ring.iter().map(|&i| points[i as usize]).sum::<Vec3>() / ring.len() as f32;

        let mut n = Vec3::ZERO;
        let mut weighted = Vec3::ZERO;
        let mut area2 = 0.0f32;
        for k in 0..ring.len() {
            let a = points[ring[k] as usize];
            let b = points[ring[(k + 1) % ring.len()] as usize];
            let cross = (a - pivot).cross(b - pivot);
            n += cross;
            // The fan triangle's own doubled area, as the weight for its own centroid. Summing
            // `centroid * weight` and dividing by the total is the area-weighted centre; a plain
            // vertex mean would drift toward whichever side of the face has more vertices.
            let w = cross.length();
            weighted += (pivot + a + b) / 3.0 * w;
            area2 += w;
        }
        let n2 = n.length_squared();
        if n2 < MIN_CROSS2 || area2 <= 0.0 {
            continue;
        }
        let len = n2.sqrt();
        out.push(CapFace {
            centroid: weighted / area2,
            normal: n / len,
            area: 0.5 * len,
        });
    }
    out
}

/// The widest raw-interior face of a cell — "the wound on this chunk".
///
/// `None` for a cell with none, which is the honest answer for an untouched proxy cell: it has no
/// exposed interior, so it has no wound. Ties keep the first face in ring order, which is a function
/// of the cell's own construction rather than of iteration.
pub fn largest_cap(cell: &ProxyCell) -> Option<CapFace> {
    cap_faces(cell)
        .into_iter()
        // Strictly greater, scanned in face order, so a tie keeps the first — the same rule
        // `Reach::strongest` and the weak-axis scan use, and for the same reason.
        .reduce(|best, f| if f.area > best.area { f } else { best })
}

/// **The wound a channel is**, from the plug's own geometry.
///
/// `at` is where the channel crossed the skin and `normal` is the channel's axis, so blood leaves the
/// hole along the direction the shot was travelling. The area is the plug's whole **channel wall** —
/// the sum of its raw-interior faces — because that is the surface the subject now has open. Not the
/// entry disc, which is only the hole a decal would cover.
///
/// **This takes the three geometric facts rather than a struct**, because the crate hands plugs back
/// in two shapes: [`crate::Ejecta`] from the pure [`crate::fracture_mesh`] path carries owned meshes,
/// and [`EjectaChunk`] from the ECS bake carries handles. They differ in nothing this function reads.
/// One implementation, so the pure path and the ECS path cannot disagree about how wide a bullet hole
/// bleeds — and so a caller holding either shape has a real answer instead of an approximation.
pub fn wound_of_channel(cell: &ProxyCell, exit: Vec3, direction: Vec3) -> Wound {
    Wound {
        at: exit,
        normal: direction,
        area: cap_faces(cell).iter().map(|f| f.area).sum(),
        severity: 1.0,
        kind: WoundKind::Channel,
    }
}

/// The wound a baked plug leaves behind — [`wound_of_channel`] for an [`EjectaChunk`].
pub fn wound_from_ejecta(chunk: &EjectaChunk) -> Wound {
    wound_of_channel(&chunk.cell, chunk.exit, chunk.direction)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bond::BondGraph;
    use crate::soup::fracture;
    use crate::{Bore, CutSettings};

    fn unit_cube_cells() -> Vec<ProxyCell> {
        vec![ProxyCell::from_box(Vec3::ZERO, Vec3::splat(0.5))]
    }

    /// Fracture the unit cube and build the finest frontier's bond graph — the same three lines every
    /// bond test in this crate writes, so they are written once here.
    fn baked_graph(cut: CutSettings) -> BondGraph {
        let (pieces, tree, _) = fracture(crate::soup::Soup::default(), &unit_cube_cells(), &cut);
        let leaves = tree.leaves();
        let members: Vec<_> =
            leaves.iter().filter_map(|&id| pieces.get(id.index()).map(|p| (id, &p.cell))).collect();
        BondGraph::of(&members, tree.len())
    }

    /// **An untouched proxy cell has no wound, and that is not a degenerate case to paper over.**
    ///
    /// Every face of `from_box` is `Supplied` — the caller's own hull — so nothing about it is open
    /// to the air. A `cap_faces` that returned the hull faces here would have every intact body part
    /// bleeding from its skin.
    #[test]
    fn an_uncut_cell_has_no_wound() {
        let cell = ProxyCell::from_box(Vec3::ZERO, Vec3::splat(0.5));
        assert!(cap_faces(&cell).is_empty(), "a supplied hull face is not an open wound");
        assert!(largest_cap(&cell).is_none(), "and there is no largest one");
    }

    /// A bond maps to a wound with **no derivation at all** — the four fields are the bond's own. If
    /// this ever needs arithmetic, the wound has stopped being the shared face.
    #[test]
    fn a_bond_is_its_wound_exactly() {
        let graph = baked_graph(CutSettings::new(2, 0.1, 0x00C0_FFEE));
        assert!(graph.len() >= 1, "a two-piece cut must leave the halves bonded");

        let id = BondId(0);
        let bond = graph.bond(id).expect("bond 0 exists");
        let wounds = wounds_from_bonds(&graph, &[id]);
        assert_eq!(wounds.len(), 1, "one broken bond is one wound");
        let w = wounds[0];
        assert_eq!(w.at, bond.centroid, "the wound is where the shared face was");
        assert_eq!(w.normal, bond.normal, "and faces the way that face did");
        assert_eq!(w.area, bond.area, "and is as wide as that face was");
        assert_eq!(w.severity, 1.0, "an already-broken bond is fully open");
        assert_eq!(w.kind, WoundKind::Severance);
    }

    /// A bond id from another frontier must be **skipped, not resolved**. Resolving it would silently
    /// return a different bond's geometry and put the wound somewhere the blow never landed.
    #[test]
    fn a_bond_from_another_graph_is_skipped() {
        let graph = baked_graph(CutSettings::new(2, 0.1, 0x00C0_FFEE));
        let beyond = BondId(graph.len() as u32 + 7);
        assert!(
            wounds_from_bonds(&graph, &[beyond]).is_empty(),
            "an id this graph does not hold must produce no wound at all"
        );
    }

    /// Wounds come back in `BondId` order regardless of the order the caller asked in — which is what
    /// makes a fold over them reproducible.
    #[test]
    fn wounds_are_returned_in_bond_id_order() {
        let graph = baked_graph(CutSettings::new(8, 0.05, 0xD00D));
        assert!(graph.len() >= 3, "need a few bonds to have an order at all");

        let ids: Vec<BondId> = (0..graph.len() as u32).map(BondId).collect();
        let forward = wounds_from_bonds(&graph, &ids);
        let mut reversed = ids.clone();
        reversed.reverse();
        let backward = wounds_from_bonds(&graph, &reversed);
        assert_eq!(forward, backward, "the caller's iteration order must not reach the output");
    }

    /// A real cut face: unit normal, positive area, and it exists. The tolerances are the crate's own
    /// — `1e-4` on a normal that was built by normalising a float cross product.
    #[test]
    fn a_cut_cell_has_a_unit_normal_cap_of_real_area() {
        let (pieces, _, _) =
            fracture(crate::soup::Soup::default(), &unit_cube_cells(), &CutSettings::new(2, 0.1, 0x00C0_FFEE));
        let with_caps: Vec<_> =
            pieces.iter().filter(|p| !cap_faces(&p.cell).is_empty()).collect();
        assert!(!with_caps.is_empty(), "a cut must leave at least one open face");
        for p in with_caps {
            for f in cap_faces(&p.cell) {
                assert!(
                    (f.normal.length() - 1.0).abs() < 1.0e-4,
                    "cap normal length {} is not unit",
                    f.normal.length()
                );
                assert!(f.area > 0.0, "a cap face with no area should have been skipped");
            }
        }
    }

    /// **The one that proves a bullet hole bleeds.** A bore's barrel wall is `FaceKind::Bore`, which
    /// `face_is_cut` reports as open — so it arrives as a cap face, and a barrel plane contains the
    /// channel axis, so its normal is perpendicular to that axis.
    ///
    /// **`flare: 0.0` is the point of the test, not a simplification.** Flare tilts every barrel
    /// plane rather than adding any, so the shipped `Bore::new` flare of 0.25 over this channel's
    /// length puts the wall about 0.01 rad off perpendicular — measured, and the reason a 1e-3
    /// tolerance against a *flared* bore fails. Jaggedness is left at its shipped 0.35 deliberately:
    /// it slides each plane inward along its own normal, so a ragged channel's walls are still
    /// exactly parallel to the axis, and this asserts that.
    #[test]
    fn a_bored_cell_exposes_its_channel_wall_perpendicular_to_the_axis() {
        let axis = Vec3::X;
        let bore = Bore {
            flare: 0.0,
            ..Bore::new(Vec3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 0.08)
        };
        let cut = CutSettings { bores: vec![bore], ..CutSettings::new(2, 0.1, 0x00C0_FFEE) };
        let (pieces, _, _) = fracture(crate::soup::Soup::default(), &unit_cube_cells(), &cut);

        let walls: Vec<CapFace> = pieces
            .iter()
            .flat_map(|p| cap_faces(&p.cell))
            .filter(|f| f.normal.dot(axis).abs() < 1.0e-3)
            .collect();
        assert!(
            !walls.is_empty(),
            "no cap face was perpendicular to the bore axis — the channel wall is not being \
             reported as open, so a bullet hole would not bleed"
        );
        assert!(
            walls.iter().all(|f| f.area > 0.0),
            "a channel wall with no area would give a bullet hole no blood to throw"
        );

        /// The same channel, flared, still exposes walls — just tilted by the flare. Asserted here
        /// rather than in its own test because the two are one fact about the same geometry.
        const FLARE_TILT: f32 = 0.02;
        let flared = Bore::new(Vec3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), 0.08);
        let cut = CutSettings { bores: vec![flared], ..CutSettings::new(2, 0.1, 0x00C0_FFEE) };
        let (pieces, _, _) = fracture(crate::soup::Soup::default(), &unit_cube_cells(), &cut);
        assert!(
            pieces
                .iter()
                .flat_map(|p| cap_faces(&p.cell))
                .any(|f| f.normal.dot(axis).abs() < FLARE_TILT),
            "a flared channel's wall should still be within the flare's own tilt of perpendicular"
        );
    }

    /// `largest_cap` picks the widest face, not the first — checked against the set it came from.
    #[test]
    fn the_largest_cap_is_the_widest_one() {
        let (pieces, _, _) =
            fracture(crate::soup::Soup::default(), &unit_cube_cells(), &CutSettings::new(8, 0.05, 0xD00D));
        for p in &pieces {
            let all = cap_faces(&p.cell);
            match largest_cap(&p.cell) {
                None => assert!(all.is_empty(), "None must mean there were none"),
                Some(best) => {
                    assert!(
                        all.iter().all(|f| f.area <= best.area),
                        "a wider cap face than the chosen one exists"
                    );
                }
            }
        }
    }
}