bevy_carnage 0.2.0

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.
Documentation
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
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
//! The slicer: a CPU triangle soup and the plane cuts that break it apart.
//!
//! No asset types, no ECS, no `App` — this half is pure geometry and unit-tests without any of them.
//! Everything Bevy-shaped lives one module over, in [`crate::mesh`].

use std::collections::HashMap;
use std::f32::consts::TAU;
use std::hash::{BuildHasher, Hasher};

use bevy::log::{info, warn};
use bevy::math::{Vec2, Vec3};

use crate::CutSettings;
use crate::proxy::ProxyCell;
use crate::tree::{FragmentId, FragmentTree, TreeNode};

/// Multiplier for [`LatticeHasher`]. The fractional part of the golden ratio scaled to 64 bits — an
/// odd constant with well-distributed bits, which is all a multiply-shift mixer needs.
const LATTICE_MIX: u64 = 0x517c_c1b7_2722_0a95;

/// **The hasher every lattice-keyed map in this crate uses**, because the default one dominated the bake.
///
/// It lives beside [`WELD`] rather than at its first call site, because what it hashes is a position
/// quantised onto that lattice, and five separate maps do exactly that: the attribute weld's cell index
/// and the relief pass's coarse-vertex canon (both [`crate::mesh`]), the shell-welding vertex ids
/// below, and [`crate::proxy`]'s `CellBuilder` table and its point dedupe.
///
/// **Measured before it was written.** `sample` put 41 % of the benchmark's self time in
/// `mesh::soup_to_mesh`, and the reason is arithmetic rather than mysterious: the weld's 27-cell probe
/// performs **27 hash lookups per emitted vertex**, over 180 000 vertices per bake. `SipHash-1-3` —
/// what `RandomState` gives you — is a keyed MAC designed to be unforgeable by a remote attacker
/// sending crafted keys. Nothing here is reachable by an attacker: the keys are lattice coordinates
/// this crate computed itself from its own geometry, so that property cost cycles and bought nothing.
/// Swapping it on the weld alone was worth -24 % of the whole carnage frame.
///
/// **Output is unaffected, and the argument matters because the alternative would be a silent geometry
/// change.** A hasher decides which *bucket* a key lands in, never what `get` returns. So it is safe
/// exactly where a map's *iteration order* cannot reach emitted geometry — and every map listed above
/// is a pure lookup whose ids come from a `Vec` length, never from traversal. **A map that is iterated
/// into output must not be moved onto this hasher** without re-checking that argument; `bond.rs`'s
/// coplanar-face table and this module's shell grouping are the two to be careful with.
///
/// Not `rustc-hash` or `ahash` for the reason `tests/leaf.rs` exists: the dependency list is closed at
/// four crates, and twenty lines of mixer is not worth widening it.
#[derive(Default, Clone, Copy)]
pub(crate) struct LatticeHasher {
    h: u64,
}

impl LatticeHasher {
    #[inline]
    fn mix(&mut self, v: u64) {
        self.h = (self.h.rotate_left(5) ^ v).wrapping_mul(LATTICE_MIX);
    }
}

impl Hasher for LatticeHasher {
    /// Byte-wise fallback. Never taken for the tuple key below — `i64` hashes through
    /// [`Self::write_i64`] — but a `Hasher` must be total.
    fn write(&mut self, bytes: &[u8]) {
        for &b in bytes {
            self.mix(u64::from(b));
        }
    }

    #[inline]
    fn write_i64(&mut self, i: i64) {
        self.mix(i as u64);
    }

    #[inline]
    fn write_u64(&mut self, i: u64) {
        self.mix(i);
    }

    #[inline]
    fn write_usize(&mut self, i: usize) {
        self.mix(i as u64);
    }

    #[inline]
    fn finish(&self) -> u64 {
        // One final avalanche, so the low bits HashMap indexes with carry information from the high
        // ones. Without it a lattice walk in x alone would stride buckets in lockstep.
        let mut h = self.h;
        h ^= h >> 32;
        h = h.wrapping_mul(LATTICE_MIX);
        h ^= h >> 29;
        h
    }
}

#[derive(Default, Clone, Copy)]
pub(crate) struct LatticeHash;

impl BuildHasher for LatticeHash {
    type Hasher = LatticeHasher;

    #[inline]
    fn build_hasher(&self) -> LatticeHasher {
        LatticeHasher::default()
    }
}

/// A lattice-keyed map, spelled once.
pub(crate) type LatticeMap<K, V> = HashMap<K, V, LatticeHash>;

/// Classification tolerance: a vertex within `EPS` of the cut plane is treated as lying *on* it, so
/// slicing near-coincident geometry doesn't spawn zero-area slivers. Positions are in subject-local
/// units (~1.0 tall for a character), so this is a tight tolerance.
pub(crate) const EPS: f32 = 1.0e-5;
/// Endpoint-weld lattice step for boundary-loop assembly (quantize positions to this grid so cut
/// segments from adjacent triangles share canonical vertex ids even on non-watertight input).
///
/// **`bloodstain::WELD` is the one home, and this is a re-export of it.** The blood model quantises
/// every wound seed onto the same lattice, and two copies of a quantisation step is how a wound seeds
/// one way in the blood and another way in the geometry that opened it.
///
/// `pub(crate)` so [`crate::audit`] can derive its validation tolerance *from* it rather than pick a
/// second one. An audit that welded on a different lattice than the cap assembly used would be asking
/// about a different mesh: finer, and the cap↔skin seam reads as open purely from the mismatch;
/// coarser, and it closes seams the slicer left open.
pub(crate) const WELD: f32 = bloodstain::WELD;
/// Squared length of the cross product below which a triangle is not worth emitting — the
/// zero-area filter, in one place so the three sites that apply it cannot drift apart.
///
/// **Two of them agreeing is load-bearing.** [`crate::proxy::ProxyCell`] refuses to *build* a face
/// this small, precisely because [`crate::proxy::ProxyCell::append_cut_faces`] and [`soup_to_mesh`]
/// would refuse to *draw* it — and a face that exists in the cell but not in the emitted mesh is a
/// hole in something the crate promises is closed. Measured before the two were tied together: a
/// cube cut into 8 produced one fragment in 320 with `boundary_edges != 0`, seed-dependent, which
/// is why the pinned seeds never showed it.
pub(crate) const MIN_CROSS2: f32 = 1.0e-12;
/// How far behind its own surface a triangle is tested for cell membership.
///
/// Must exceed [`EPS`], or [`ProxyCell::contains`](crate::proxy::ProxyCell)'s "on a face counts as
/// inside" tolerance swallows it and the test is unchanged; must stay well under the thinnest cell
/// any caller supplies, or a triangle is nudged clean through its own solid and comes back homeless.
/// Positions are subject-local — roughly a metre for a character — so a tenth of a millimetre sits
/// two orders of magnitude clear on both sides.
pub(crate) const INWARD_NUDGE: f32 = 1.0e-3;

/// Outward unit normal of a triangle, or zero for a degenerate one — which nudges nothing and leaves
/// the centroid test exactly as it was.
fn face_normal(a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
    (b - a).cross(c - a).normalize_or_zero()
}

/// The crate's only random source: a 32-bit integer hash mapped into `[0, 1)`.
///
/// **`bloodstain::hash_f32` is the one home, and this is a re-export of it.** The function was
/// written here and moved out with the blood model on 2026-09-02, because both halves draw from it
/// and a generator with two homes is two streams waiting to diverge. Its bits are frozen by a golden
/// in that crate under the same name, `hash_f32_is_frozen`, and the consuming game's
/// `tests/rng_guard.rs` asserts its own `util::hash_f32` matches this symbol.
///
/// **Still no RNG crate.** The fracture's whole reproducibility argument rests on this returning the
/// same bits on every machine and every toolchain, and a dependency that reserves the right to change
/// its stream between minor versions cannot promise that.
pub use bloodstain::hash_f32;

/// A vertex sample carried through clipping (interpolated at edge–plane crossings).
#[derive(Clone, Copy)]
pub(crate) struct Vtx {
    pub(crate) pos: Vec3,
    pub(crate) nrm: Vec3,
    pub(crate) uv: Vec2,
}

/// A cut plane: a point on the plane and a unit normal.
pub(crate) struct Plane {
    pub(crate) point: Vec3,
    pub(crate) normal: Vec3,
}

/// CPU triangle soup. Parallel per-vertex arrays plus one triangle per `idx` entry; `tri_interior`
/// tags a triangle as a **cut-cap** face (gets the interior material) vs original **skin** (the
/// subject's own surface). Every vertex always carries a UV (zero-filled when the source lacked
/// `UV_0`).
#[derive(Default, Clone)]
pub(crate) struct Soup {
    pub(crate) pos: Vec<Vec3>,
    pub(crate) nrm: Vec<Vec3>,
    pub(crate) uv: Vec<Vec2>,
    pub(crate) idx: Vec<[u32; 3]>,
    pub(crate) tri_interior: Vec<bool>,
}

impl Soup {
    pub(crate) fn is_empty(&self) -> bool {
        self.idx.is_empty()
    }

    /// A soup sized for a known triangle count, so [`Self::push_tri`] never regrows.
    ///
    /// Five vectors grown from empty is five reallocation ladders, and each rung copies everything
    /// written so far — which is bytes touched, not merely an allocator call. `soften`'s midpoint
    /// subdivision knows its output size exactly: four triangles per input triangle.
    pub(crate) fn with_capacity(tris: usize) -> Self {
        Self {
            pos: Vec::with_capacity(tris * 3),
            nrm: Vec::with_capacity(tris * 3),
            uv: Vec::with_capacity(tris * 3),
            idx: Vec::with_capacity(tris),
            tri_interior: Vec::with_capacity(tris),
        }
    }

    pub(crate) fn vtx(&self, i: u32) -> Vtx {
        let i = i as usize;
        Vtx { pos: self.pos[i], nrm: self.nrm[i], uv: self.uv[i] }
    }

    /// Append one triangle as three fresh vertices.
    ///
    /// **Three appends of a slice, not nine of a scalar.** This is the innermost write of the whole
    /// bake — the midpoint subdivision alone calls it four times per input triangle — and the loop it
    /// replaces performed nine separate `Vec::push`es, each re-checking capacity and re-reading the
    /// length. `extend_from_slice` of a three-element array does that once per buffer and copies the
    /// three together. Same values, same order, same resulting vectors.
    pub(crate) fn push_tri(&mut self, a: Vtx, b: Vtx, c: Vtx, interior: bool) {
        let base = self.pos.len() as u32;
        self.pos.extend_from_slice(&[a.pos, b.pos, c.pos]);
        self.nrm.extend_from_slice(&[a.nrm, b.nrm, c.nrm]);
        self.uv.extend_from_slice(&[a.uv, b.uv, c.uv]);
        self.idx.push([base, base + 1, base + 2]);
        self.tri_interior.push(interior);
    }

    /// Axis-aligned bounds over all vertices (min, max). `(ZERO, ZERO)` when empty.
    pub(crate) fn bbox(&self) -> (Vec3, Vec3) {
        let mut mn = Vec3::splat(f32::INFINITY);
        let mut mx = Vec3::splat(f32::NEG_INFINITY);
        for p in &self.pos {
            mn = mn.min(*p);
            mx = mx.max(*p);
        }
        if self.pos.is_empty() {
            (Vec3::ZERO, Vec3::ZERO)
        } else {
            (mn, mx)
        }
    }

    /// Largest bounding half-dimension — the "how big is this piece" measure driving fragment sizing.
    pub(crate) fn extent(&self) -> f32 {
        let (mn, mx) = self.bbox();
        ((mx - mn) * 0.5).max_element()
    }
}

/// Signed distance from `p` to the plane (positive on the `+normal` side).
pub(crate) fn signed_dist(p: Vec3, plane: &Plane) -> f32 {
    (p - plane.point).dot(plane.normal)
}

/// `+1` above / `-1` below / `0` on the plane (within `EPS`).
pub(crate) fn classify(s: f32) -> i32 {
    if s > EPS {
        1
    } else if s < -EPS {
        -1
    } else {
        0
    }
}

/// Vertex interpolated where segment `a→b` crosses the plane at parameter `t`.
fn lerp_vtx(a: Vtx, b: Vtx, t: f32) -> Vtx {
    Vtx {
        pos: a.pos.lerp(b.pos, t),
        nrm: a.nrm.lerp(b.nrm, t).normalize_or_zero(),
        uv: a.uv.lerp(b.uv, t),
    }
}

/// Clip one triangle to the half-space we keep (Sutherland–Hodgman on the 3-gon), fan-triangulate
/// the kept polygon, and append it to `out`. On-plane vertices (`classify == 0`) are kept for *both*
/// half-spaces so the seam geometry is shared. Original `interior` tag is inherited.
fn clip_half(v: [Vtx; 3], s: [f32; 3], keep_above: bool, interior: bool, out: &mut Soup) {
    let mut poly: Vec<Vtx> = Vec::with_capacity(4);
    for i in 0..3 {
        let j = (i + 1) % 3;
        let (ci, cj) = (classify(s[i]), classify(s[j]));
        let keep_i = if keep_above { ci >= 0 } else { ci <= 0 };
        if keep_i {
            poly.push(v[i]);
        }
        // Strict crossing (opposite strict sides) → insert the intersection vertex.
        if ci != 0 && cj != 0 && ci != cj {
            let t = s[i] / (s[i] - s[j]);
            poly.push(lerp_vtx(v[i], v[j], t));
        }
    }
    if poly.len() >= 3 {
        for i in 1..poly.len() - 1 {
            out.push_tri(poly[0], poly[i], poly[i + 1], interior);
        }
    }
}

/// Two orthonormal in-plane axes for a given plane normal (for cross-section UVs).
///
/// **Delegates to `bloodstain::plane_basis`, which is the one home.** Every direction in both crates
/// is derived against this basis — a spray cone and a cut face have to agree about what "sideways"
/// means, and a second copy of these four lines is exactly how they would stop agreeing. The leaf's
/// version is the same operations in the same order over `[f32; 3]`, mirroring `glam` deliberately,
/// so the conversion here cannot move a bit: `bloodstain`'s frozen spatter golden is what proves it.
pub(crate) fn plane_basis(n: Vec3) -> (Vec3, Vec3) {
    let (u, v) = bloodstain::plane_basis(crate::v3::to_v3(n));
    (crate::v3::from_v3(u), crate::v3::from_v3(v))
}

/// Random unit vector on the sphere from a hash seed (always exactly unit length — never zero).
fn random_dir(seed: u32) -> Vec3 {
    let h1 = hash_f32(seed.wrapping_add(0x1234_5678));
    let h2 = hash_f32(seed.wrapping_add(0x9E37_79B9));
    let z = 2.0 * h1 - 1.0;
    let r = (1.0 - z * z).max(0.0).sqrt();
    let phi = h2 * TAU;
    Vec3::new(r * phi.cos(), z, r * phi.sin())
}



/// One fragment mid-fracture: the cell being cut, the render surface clipped alongside it, and any
/// open shells riding whole.
pub(crate) struct Piece {
    pub(crate) cell: ProxyCell,
    pub(crate) render: Soup,
    /// Open shells assigned to this fragment and **never clipped** — see [`Shell`].
    pub(crate) sheets: Vec<Soup>,
    /// [`CutSettings::cap_relief`], carried to emit time. It changes nothing about the cut — it is
    /// Tier B, read only when the drawn cap is built — but riding along on the piece keeps it out of
    /// four intermediate signatures that have no other reason to know about it.
    pub(crate) relief: f32,
    /// [`CutSettings::soften`], carried the same way and for the same reason.
    pub(crate) soften: f32,
}

/// One plug on its way out, mid-fracture: the same [`Piece`] shape as a fragment, plus where it left.
///
/// A `Piece` because it is emitted through exactly the same path — a convex cell, a subset of the
/// subject's skin, cut faces from the cell, and the same two Tier B dials. The difference is what it
/// is *not*: it never enters the [`crate::tree::FragmentTree`] and never reaches
/// [`crate::bond::BondGraph`], so it cannot bond itself back into the hole it came from.
pub(crate) struct Ejected {
    pub(crate) piece: Piece,
    pub(crate) exit: Vec3,
    pub(crate) direction: Vec3,
}

/// One connected component of a triangle soup, and whether it is a *solid's* surface or a sheet.
///
/// **The distinction AG-003 exists for.** A cape, a hair card, a decal or any single-sided sheet has no
/// interior. Clipping one against a cut plane cuts it in half, which is wrong twice over: the piece has
/// no volume for the plane to divide, and the halves fly apart along a seam the artist drew as
/// continuous. Such a shell is assigned to exactly one fragment and carried **whole**.
struct Shell {
    tris: Vec<usize>,
    /// `true` when the shell has boundary edges — an edge used by exactly one triangle.
    open: bool,
    centroid: Vec3,
}

/// Partition a soup into connected components, welding positions so triangles that merely *share a
/// corner value* are recognised as adjacent.
///
/// **This is the island detection Müller lists as a required step**, not an optimisation — his §3.3
/// calls it "crucial… it is this step that makes sure that objects collapse in the correct way". Here
/// it does double duty: it finds the open sheets AG-003 must protect, and it is the same pass a future
/// compound-fracture would need.
fn shells(soup: &Soup) -> Vec<Shell> {
    let q = |x: f32| (x / WELD).round() as i64;
    // Lattice-keyed and never iterated — ids come from `vid.len()`. See [`LatticeHash`].
    let mut vid: LatticeMap<(i64, i64, i64), usize> =
        LatticeMap::with_capacity_and_hasher(soup.pos.len(), LatticeHash);
    let mut canon: Vec<usize> = Vec::with_capacity(soup.pos.len());
    for p in &soup.pos {
        let key = (q(p.x), q(p.y), q(p.z));
        let next = vid.len();
        canon.push(*vid.entry(key).or_insert(next));
    }

    // Union-find over welded vertices; triangles inherit their component from any corner.
    let mut parent: Vec<usize> = (0..vid.len()).collect();
    fn find(parent: &mut [usize], mut i: usize) -> usize {
        while parent[i] != i {
            parent[i] = parent[parent[i]];
            i = parent[i];
        }
        i
    }
    for tri in &soup.idx {
        let (a, b, c) = (canon[tri[0] as usize], canon[tri[1] as usize], canon[tri[2] as usize]);
        for (x, y) in [(a, b), (b, c)] {
            let (rx, ry) = (find(&mut parent, x), find(&mut parent, y));
            if rx != ry {
                parent[rx] = ry;
            }
        }
    }

    // Group triangles by root, in first-seen order so the result does not depend on hash iteration.
    let mut order: Vec<usize> = Vec::new();
    let mut slot: HashMap<usize, usize> = HashMap::new();
    let mut groups: Vec<Vec<usize>> = Vec::new();
    for (t, tri) in soup.idx.iter().enumerate() {
        let root = find(&mut parent, canon[tri[0] as usize]);
        let idx = *slot.entry(root).or_insert_with(|| {
            order.push(root);
            groups.push(Vec::new());
            groups.len() - 1
        });
        groups[idx].push(t);
    }

    groups
        .into_iter()
        .map(|tris| {
            // An edge used once is a boundary edge; any of them makes the shell a sheet.
            let mut edges: HashMap<(usize, usize), u32> = HashMap::new();
            let mut sum = Vec3::ZERO;
            let mut n = 0.0f32;
            for &t in &tris {
                let tri = soup.idx[t];
                let v = [canon[tri[0] as usize], canon[tri[1] as usize], canon[tri[2] as usize]];
                for i in 0..3 {
                    let (a, b) = (v[i], v[(i + 1) % 3]);
                    *edges.entry((a.min(b), a.max(b))).or_insert(0) += 1;
                }
                for &i in &tri {
                    sum += soup.pos[i as usize];
                    n += 1.0;
                }
            }
            Shell {
                open: edges.values().any(|&c| c == 1),
                centroid: if n > 0.0 { sum / n } else { Vec3::ZERO },
                tris,
            }
        })
        .collect()
}

/// **The fracture: Tier A cuts, Tier B rides along.**
///
/// Returns one `(cell, render)` pair per fragment. Each cut picks the largest remaining cell by
/// **volume**, puts a plane through its centroid, splits the cell, and splits that cell's render
/// payload with the *same* plane — by clipping only, never by capping. The cap is the cell's new face.
///
/// # Why volume and not extent
///
/// The soup cutter used `Soup::extent`, the largest bounding half-dimension, and that metric has a
/// standing failure: a flat sliver with one long axis keeps winning "largest piece" and being re-cut
/// forever, while compact pieces are never touched. Volume has no such degenerate case. This is the
/// first half of `AG-011`, delivered here because Tier A would otherwise have inherited the bug.
///
/// # Why each fragment is exactly one cell
///
/// A cut splits one cell into two, so a fragment is always a single convex cell rather than a set of
/// them. A bore makes several cells out of one *before* the loop starts, and each of those is still
/// exactly one convex cell, so the property holds either way. That is a deliberate narrowing of the
/// architecture note, and it pays twice: the fragment is trivially closed and convex, and `AG-007`
/// gets a solver-ready collider with no decomposition at spawn. Cells are never unioned across
/// shells, so a head cannot weld itself to a torso.
///
/// A bore's shards count toward [`CutSettings::target`](crate::CutSettings::target), so a heavily
/// bored subject may reach `target` with no fracture cuts at all. That is the honest reading: the
/// channel already divided it, and cutting further would only shrink pieces the bore made small.
///
/// # The returned forest
///
/// Every piece the loop ever held is returned, not just the final ones: a cut *adds* two children
/// and leaves the parent in place rather than overwriting it. The [`FragmentTree`] says which is
/// which, and [`FragmentTree::frontier_of`] reads the set back at any granularity from
/// `proxy.len()` pieces up to `target`. Keeping the parents is the whole hierarchy feature and it
/// costs no extra geometry work — only the memory of the payloads, which
/// [`max_depth`](crate::FractureSettings::max_depth) bounds.
///
/// **The cut sequence is unchanged by that bookkeeping.** Selection still runs over the live
/// frontier by slot, with the same volume metric and the same lower-slot tie-break, and the seed
/// still mixes in the frontier size — so a bake taken before the tree existed and one taken after
/// partition the mesh identically.
pub(crate) fn fracture(
    render: Soup,
    proxy: &[ProxyCell],
    cut: &CutSettings,
) -> (Vec<Piece>, FragmentTree, Vec<Ejected>) {
    let CutSettings {
        target,
        min_fraction,
        max_depth,
        plane_jitter,
        size_spread,
        weak_axis,
        cap_relief,
        soften,
        ejecta_soften,
        seed,
        ref bores,
        ref fault,
        tissue: _,
        spiral_pitch_deg,
        greenstick_impulse,
    } = *cut;
    let dials = MorphologyDials { spiral_pitch_deg, greenstick_impulse };

    // **The tissue bias, in one place, and read only under a morphology policy.**
    //
    // What a material does to the *debris* is not a direction, so it does not belong in
    // `choose_plane` beside the fault normal — it belongs to the dials the cut loop runs under.
    // Strain to failure is the whole of it: cortical bone fails at ~2 % and splinters, so it is
    // allowed thinner pieces and pushed harder toward the longest axis; trabecular bone tolerates
    // ~30 % and compacts, so it is clamped to a handful of pieces and its faces are crumpled more,
    // which is what reads as crushing rather than shattering.
    //
    // **Gated on `Morphology`.** Applying it under `WeakAxis` would move every frozen bake in the
    // crate, and `CutSettings::tissue` exists so a caller can *state* the tissue; `cut_for` puts it
    // into the policy, which is the one thing the loop reads.
    let (target, min_fraction, weak_axis, cap_relief) = match fault {
        crate::FaultPolicy::Morphology { tissue: crate::TissueClass::Cortical, .. } => {
            (target, min_fraction * 0.5, weak_axis.max(0.9), cap_relief)
        }
        crate::FaultPolicy::Morphology { tissue: crate::TissueClass::Trabecular, .. } => (
            target.min(crate::TRABECULAR_MAX_PIECES),
            min_fraction,
            weak_axis,
            (cap_relief * 1.6).min(1.0),
        ),
        _ => (target, min_fraction, weak_axis, cap_relief),
    };
    // **Bores first, because a channel is part of the subject's shape and not part of its breakage.**
    // Subtracting here means a bored cell reaches the loop as ordinary convex root cells, so the tree
    // stays binary and `fragments[id.index()]` stays parallel with `tree.node(id)`. The plugs come
    // back separately and stay out of both, so nothing can bond one into the hole it came from.
    //
    // The **skin** is carved a few lines down, per closed shell, rather than here: a carved skin has
    // boundary edges at every hole rim, so classifying it after the carve reads a bored solid as a
    // sheet and carries the whole subject to one fragment. See [`crate::bore::carve`].
    let (bored, prisms, plugs) = crate::bore::apply(proxy, bores);
    let proxy: &[ProxyCell] = &bored;
    // One slot per landed prism, filled by the per-shell carve below with the skin that channel took.
    let mut torn: Vec<Soup> = (0..prisms.len()).map(|_| Soup::default()).collect();
    // Tier B assignment. Every triangle goes to the first cell containing its centroid — first, not
    // nearest, because overlapping shells (a head sunk into a torso) are the normal case and a
    // deterministic tie-break beats a distance that can flip on a rounding difference.
    //
    // **Open shells are assigned as a unit and never clipped.** A cape, a hair card or a decal has no
    // interior for a plane to divide; cutting one in half separates geometry the artist drew as
    // continuous. See [`Shell`].
    let mut pieces: Vec<Piece> = proxy
        .iter()
        .map(|c| Piece { cell: c.clone(), render: Soup::default(), sheets: Vec::new(), relief: cap_relief, soften })
        .collect();
    let mut homeless = 0usize;
    let mut carried = 0usize;
    let mut considered = 0usize;

    for shell in shells(&render) {
        if shell.open {
            let mut whole = Soup::default();
            for &t in &shell.tris {
                let tri = render.idx[t];
                whole.push_tri(
                    render.vtx(tri[0]),
                    render.vtx(tri[1]),
                    render.vtx(tri[2]),
                    render.tri_interior[t],
                );
            }
            match pieces.iter().position(|p| p.cell.contains(shell.centroid)) {
                Some(i) => {
                    pieces[i].sheets.push(whole);
                    carried += 1;
                }
                None => homeless += shell.tris.len(),
            }
            considered += shell.tris.len();
            continue;
        }
        // **A bore opens a solid's skin, and only a solid's.** The shell was classified above on the
        // artist's own geometry and is carved here, so the opening is the width of the channel rather
        // than the width of a triangle, and its new boundary lands exactly on the barrel planes where
        // the wall's cut-face ring already is. A sheet is carried unbored: a bore is a subtraction
        // from the proxy, and a sheet is not in the proxy.
        let mut solid = Soup::default();
        for &t in &shell.tris {
            let tri = render.idx[t];
            solid.push_tri(
                render.vtx(tri[0]),
                render.vtx(tri[1]),
                render.vtx(tri[2]),
                render.tri_interior[t],
            );
        }
        let solid = crate::bore::carve(solid, &prisms, &mut torn);
        for (t, tri) in solid.idx.iter().enumerate() {
            let (a, b, c) = (solid.vtx(tri[0]), solid.vtx(tri[1]), solid.vtx(tri[2]));
            let mid = (a.pos + b.pos + c.pos) / 3.0;
            // **Test just inside the surface, not on it.** A triangle's outward normal points away
            // from the solid it bounds, so a point a hair behind it is inside the cell that triangle
            // actually belongs to — and outside the neighbour it merely touches.
            //
            // Testing the bare centroid gets this wrong whenever two cells share a face, which is
            // exactly what a joint is. `contains` counts "on a face" as inside, deliberately, so a
            // triangle on the boundary is inside *both* cells and the first one wins. Measured on a
            // six-part humanoid: every limb's inward face was assigned to the torso, leaving each
            // limb with a hole precisely where it met the body — head short by 0.0676, the neck's
            // own area; each arm by 0.1040, the shoulder's; each leg by 0.0528, the hip's; and the
            // torso holding all 0.3812 of it. Which is to say the hole was exactly joint-shaped.
            let mid = mid - face_normal(a.pos, b.pos, c.pos) * INWARD_NUDGE;
            match pieces.iter().position(|p| p.cell.contains(mid)) {
                Some(i) => pieces[i].render.push_tri(a, b, c, solid.tri_interior[t]),
                None => homeless += 1,
            }
            considered += 1;
        }
    }
    if carried > 0 {
        info!("carnage: carrying {carried} open shell(s) whole rather than cutting them");
    }
    if homeless > 0 {
        warn!(
            "carnage: {homeless} of {} triangles lie outside every proxy cell and were dropped — the \
             proxy does not cover the mesh",
            considered
        );
    }

    // **`min_fraction` is a *linear* fraction, cubed here to compare volumes.** Callers think in
    // sizes — "stop at about 15% of the subject" — and the soup cutter's `min_extent` meant exactly
    // that. Comparing 0.15 against a volume ratio instead would be roughly four times stricter and
    // would silently return far fewer fragments than any existing caller asked for.
    let whole: f32 = pieces.iter().map(|p| p.cell.volume()).sum();
    let f = min_fraction.max(0.0);
    let floor = whole * f * f * f;

    // One node per proxy cell to start: the roots of the forest, uncut.
    let mut nodes: Vec<TreeNode> =
        (0..pieces.len()).map(|_| TreeNode { parent: None, children: None, depth: 0, split_at: None }).collect();
    // **The live frontier, by slot.** `live[slot]` is the node id currently occupying that slot, and
    // the slot layout mirrors what the pre-hierarchy loop did to its `pieces` vector exactly: a cut
    // reuses its own slot for the `above` half and pushes a new slot for `below`. Selection and the
    // seed mix both read slots, never node ids, which is what keeps the cut sequence unmoved now
    // that ids no longer coincide with frontier positions.
    let mut live: Vec<usize> = (0..pieces.len()).collect();
    let mut unsplittable = vec![false; live.len()];
    let mut cuts: u32 = 0;

    let hard_cap = target * 16 + 32;
    for cut_index in 0..hard_cap {
        if live.len() >= target.max(1) {
            break;
        }
        // **Which piece to cut next.** Strictly the largest by volume marches down a size order and
        // levels everything toward the same size, which is the uniform-shard look; `size_spread`
        // nudges the ranking by a stable per-node hash so a slightly smaller piece can win. The
        // nudge keys on the *node id*, not the frontier slot, so it is a fixed property of the piece
        // rather than something that shifts as the frontier grows.
        let ranked = |node: usize| -> f32 {
            let v = pieces[node].cell.volume();
            if size_spread <= 0.0 {
                return v;
            }
            let h = hash_f32(seed ^ (node as u32).wrapping_mul(0x9E37_79B9));
            v * (1.0 - size_spread * 0.5 + size_spread * h)
        };
        // SORT-OK: `total_cmp` over the ranking with the slot as tie-break — a total order, so the
        // choice is a function of the geometry alone and not of the vector's incidental layout.
        let Some(slot) = (0..live.len())
            .filter(|&s| !unsplittable[s])
            .max_by(|&a, &b| ranked(live[a]).total_cmp(&ranked(live[b])).then(b.cmp(&a)))
        else {
            break;
        };
        let parent = live[slot];
        if pieces[parent].cell.volume() < floor {
            unsplittable[slot] = true;
            continue;
        }
        // The depth bound is a memory bound: total payload across the forest is roughly the deepest
        // path times the subject's own triangle count, because every level holds the whole subject
        // over again. A piece at the limit is retired exactly like one below the volume floor.
        if nodes[parent].depth >= max_depth {
            unsplittable[slot] = true;
            continue;
        }

        // Seed mixing is unchanged from the soup cutter, including the frontier-size term: the plane
        // sequence is a function of how many fragments exist so far, and changing that would move
        // every asset this crate has ever fractured.
        let s = seed
            .wrapping_add((cut_index as u32).wrapping_mul(2_654_435_761))
            .wrapping_add(live.len() as u32);
        let Some(plane) =
            choose_plane(&pieces[parent].cell, s, weak_axis, plane_jitter, fault, cut_index as u32, &dials)
        else {
            // **A greenstick, and it is retired rather than retried.** The policy said this load
            // opens the tension cortex without parting the bone, so there is no plane to cut — and
            // asking again with a different seed would be a second answer to a question already
            // answered. `mesh::fracture_mesh` reports the residual bend through `Fracture::bent`.
            unsplittable[slot] = true;
            continue;
        };

        let (Some(above), Some(below)) = pieces[parent].cell.clip(&plane, face_kind_for(fault, cut_index as u32))
        else {
            unsplittable[slot] = true;
            continue;
        };
        // Tier B: clip only. No `cap_side`, no loop recovery — the cap is `above`/`below`'s new face.
        //
        // **Both halves are sized from the parent rather than grown from empty.** A clip sends each
        // input triangle to one side or splits it across both, so neither half can exceed the parent's
        // triangle count by more than the straddling ones — reserving that much means `push_tri` never
        // climbs a reallocation ladder, and every rung of one recopies the whole buffer written so far.
        // Same mechanism that paid in `mesh::soften`'s subdivision buffer.
        let reserve = pieces[parent].render.idx.len();
        let (mut ra, mut rb) = (Soup::with_capacity(reserve), Soup::with_capacity(reserve));
        split_render(&pieces[parent].render, &plane, &mut ra, &mut rb);

        // **A sheet goes wholly to one side.** Its centroid lay in the parent cell, so the sign of its
        // distance to this plane picks a half without ambiguity and without a fallback branch.
        //
        // Cloned rather than moved out: the parent piece survives as an interior node of the forest,
        // and a coarser frontier will spawn it with its sheets still attached.
        let (mut sa, mut sb): (Vec<Soup>, Vec<Soup>) = (Vec::new(), Vec::new());
        for sheet in &pieces[parent].sheets {
            let c = sheet.pos.iter().copied().sum::<Vec3>() / sheet.pos.len().max(1) as f32;
            if signed_dist(c, &plane) >= 0.0 { sa.push(sheet.clone()) } else { sb.push(sheet.clone()) }
        }

        let (above_id, below_id) = (nodes.len(), nodes.len() + 1);
        let depth = nodes[parent].depth.saturating_add(1);
        let kid = |parent: usize| TreeNode {
            parent: Some(FragmentId(parent as u32)),
            children: None,
            depth,
            split_at: None,
        };
        nodes.push(kid(parent));
        nodes.push(kid(parent));
        nodes[parent].children = Some([FragmentId(above_id as u32), FragmentId(below_id as u32)]);
        nodes[parent].split_at = Some(cuts);

        pieces.push(Piece { cell: above, render: ra, sheets: sa, relief: cap_relief, soften });
        pieces.push(Piece { cell: below, render: rb, sheets: sb, relief: cap_relief, soften });

        live[slot] = above_id;
        live.push(below_id);
        unsplittable.push(false);
        cuts += 1;
    }

    // **The plugs, assembled last and kept out of everything above.** Each takes the skin its own
    // channel tore out — the entry and exit patches — assigned by the same inward-nudged centroid test
    // the fragments used, so a shot that crossed two cells gives each plug its own share. The cut faces
    // come from the plug's cell at emit time like any other piece, which is what makes the barrel wall
    // red without a second material path.
    let ejecta: Vec<Ejected> = plugs
        .into_iter()
        .enumerate()
        .flat_map(|(n, plug)| {
            let mut render = Soup::default();
            if let Some(skin) = torn.get(plug.prism) {
                for (t, tri) in skin.idx.iter().enumerate() {
                    let (a, b, c) = (skin.vtx(tri[0]), skin.vtx(tri[1]), skin.vtx(tri[2]));
                    let mid = (a.pos + b.pos + c.pos) / 3.0
                        - face_normal(a.pos, b.pos, c.pos) * INWARD_NUDGE;
                    if plug.cell.contains(mid) {
                        render.push_tri(a, b, c, skin.tri_interior[t]);
                    }
                }
            }
            // **And then it comes apart.** A plug is one convex prism, which is what a corer leaves;
            // breaking it with the crate's own cut policy is what turns cored material into gore. The
            // seed folds in the plug's index so two channels through one subject do not shatter
            // identically, and the shape dials are the bake's own — one look, one set of dials.
            let (exit, direction) = (plug.exit, plug.direction);
            crate::bore::shatter(
                plug.cell,
                render,
                plug.shatter,
                seed.wrapping_add((n as u32).wrapping_mul(0x27D4_EB2F)),
                weak_axis,
                plane_jitter,
                size_spread,
            )
            .into_iter()
            .map(move |(cell, render)| Ejected {
                piece: Piece {
                    cell,
                    render,
                    sheets: Vec::new(),
                    relief: cap_relief,
                    // **Debris is rounded on its own dial.** See `CutSettings::ejecta_soften`: a
                    // bored subject must be drawn flat or its wedges separate, and gore has no
                    // neighbour to separate from.
                    soften: ejecta_soften,
                },
                exit,
                direction,
            })
            .collect::<Vec<_>>()
        })
        .collect();

    (pieces, FragmentTree::from_nodes(nodes, cuts), ejecta)
}

/// **Where to cut one convex cell, given a mixed seed and the shape dials.** The crate's single cut
/// policy.
///
/// Lifted verbatim out of [`fracture`]'s loop when the bore learned to shatter its plug, and shared
/// rather than copied for one reason: the *look* of every broken thing in this crate comes from these
/// twenty lines, and two copies would drift the moment either was tuned. Gore that came apart along a
/// different rule than the body it came out of would be a second answer to one question.
///
/// `s` is the caller's already-mixed seed. Mixing stays with the caller because the two loops count
/// different things — [`fracture`] folds in the live frontier size, which is load-bearing for every
/// bake this crate has ever produced and must not be reinterpreted here.
///
/// # Cut across the piece's narrow dimension, not at a random angle
///
/// The cut face is perpendicular to the normal, so the direction the piece is *longest* along is the
/// one that gives the smallest cross-section — which is where a real thing comes apart. Sampling a
/// few candidates and keeping the longest is most of Sellán et al.'s "break across weak regions" for
/// the cost of a few dot products, and `weak_axis = 0` samples once, which is exactly the behaviour
/// every bake before that dial existed had.
///
/// # Slide the plane off centre
///
/// A plane through the centroid halves the piece, and halving every piece every time is what makes
/// the output read as uniform shards. The offset is measured against how far *this* piece reaches
/// along *this* normal, scaled back toward the centre by `plane_jitter` — so with jitter below 1.0
/// the plane is always strictly inside the cell and a cut can never be silently lost to a plane that
/// missed.
///
/// # One entry point, matched on the policy
///
/// [`FaultPolicy::Morphology`] adds the loading modes the fracture literature says geometric
/// prefracture is blind to. **`None` is a real answer**, not a failure: a bend below the greenstick
/// impulse produces no fault at all, and the caller's fragment stays whole with a residual bend.
pub(crate) fn choose_plane(
    cell: &ProxyCell,
    s: u32,
    weak_axis: f32,
    plane_jitter: f32,
    fault: &crate::FaultPolicy,
    cut_index: u32,
    fs: &MorphologyDials,
) -> Option<Plane> {
    let centroid = cell.centroid();

    // The direction the fault runs. One `match`, no second entry point.
    let normal = match fault {
        crate::FaultPolicy::WeakAxis => weak_axis_normal(cell, s, weak_axis, centroid),
        crate::FaultPolicy::Morphology { mode, tissue, axis, torque, impulse } => {
            match morphology_normal(
                cell, s, weak_axis, centroid, *mode, *tissue, *axis, *torque, *impulse, cut_index,
                fs,
            ) {
                Some(n) => n,
                // **Greenstick: the tension cortex opened and the far cortex did not.** No plane, so
                // the piece stays whole and the caller reports the residual bend
                // (`doi:10.3390/jimaging11060187`).
                None => return None,
            }
        }
    };

    let offset = if plane_jitter > 0.0 {
        let (lo, hi) = cell.span_along(normal, centroid);
        (lo + (hi - lo) * hash_f32(s ^ 0x5BD1_E995)) * plane_jitter
    } else {
        0.0
    };
    Some(Plane { point: centroid + normal * offset, normal })
}

/// **Cortical bone splits along its own grain**, so its cut planes contain the long axis.
///
/// Not a style bias. Cortical bone is built of osteons running along the shaft, and its fracture
/// toughness is far lower for a crack running *along* that direction than across it — which is why a
/// splintered long bone yields long sharp slivers rather than discs. Projecting the fault normal into
/// the plane perpendicular to the long axis is exactly that: the cut surface then contains the axis,
/// and the fragments come out long.
///
/// Applied to **torsion and axial loads only**. A bend's butterfly is a transverse-plus-oblique
/// break — the clinically named shape — and projecting its transverse plane away would destroy the
/// very morphology that arm exists to produce; comminution has no preferred direction at all by
/// definition. Anything but cortical bone, or a degenerate projection, is returned untouched.
fn longitudinal_if_cortical(n: Vec3, long: Vec3, tissue: crate::TissueClass) -> Vec3 {
    if tissue != crate::TissueClass::Cortical {
        return n;
    }
    let projected = (n - long * n.dot(long)).normalize_or_zero();
    if projected == Vec3::ZERO { n } else { projected }
}

/// **The residual bend a greenstick leaves**, or zero when the subject actually parted.
///
/// Greenstick is an *outcome*, not a mode: under a bend too gentle to fault the bone, the tension
/// cortex opens and the far cortex does not, so the subject stays in one piece and stays permanently
/// bent (`doi:10.3390/jimaging11060187`). The direction is the tension face's own in-plane axis and
/// the magnitude is how far short of `greenstick_impulse` the blow fell — so a barely-sub-threshold
/// blow leaves a barely-bent bone, and a feeble one leaves a badly bent one, which is the right way
/// round: a stronger blow gets closer to breaking instead of bending.
pub(crate) fn residual_bend(cut: &CutSettings) -> Vec3 {
    let crate::FaultPolicy::Morphology { mode: crate::LoadingMode::Bending, axis, impulse, .. } =
        cut.fault
    else {
        return Vec3::ZERO;
    };
    if !(impulse < cut.greenstick_impulse) || !(cut.greenstick_impulse > 0.0) {
        return Vec3::ZERO;
    }
    let long = axis.normalize_or_zero();
    if long == Vec3::ZERO {
        return Vec3::ZERO;
    }
    let (u, _) = plane_basis(long);
    u * (1.0 - impulse / cut.greenstick_impulse).clamp(0.0, 1.0)
}

/// **Which face a cut leaves, given the policy and which cut this is.**
///
/// Only a *bend* produces two distinguishable faces, so only `Bending` returns anything but
/// `FaceKind::Cut` — and the mapping is the butterfly's own order: the transverse plane lies on the
/// tension face, the two oblique branches carry the wedge toward compression. Every other policy
/// leaves an ordinary cut face, which is why no existing bake's relief moves.
pub(crate) fn face_kind_for(fault: &crate::FaultPolicy, cut_index: u32) -> crate::proxy::FaceKind {
    match fault {
        crate::FaultPolicy::Morphology { mode: crate::LoadingMode::Bending, .. } => {
            match cut_index % 3 {
                0 => crate::proxy::FaceKind::Tension,
                _ => crate::proxy::FaceKind::Compression,
            }
        }
        _ => crate::proxy::FaceKind::Cut,
    }
}

/// **The direction-blind choice**: sample candidates, keep the axis the piece is longest along.
///
/// Lifted out of [`choose_plane`] unchanged when the morphology arm arrived, and it is byte-for-byte
/// the arithmetic every bake before that produced — same draws, same order, same tie rule. That is
/// what keeps `fracture_output_is_bit_identical_across_runs` and the locked topology counts unmoved.
fn weak_axis_normal(cell: &ProxyCell, s: u32, weak_axis: f32, centroid: Vec3) -> Vec3 {
    let candidates = 1 + (weak_axis.clamp(0.0, 1.0) * 7.0).round() as u32;
    let mut normal = random_dir(s);
    if candidates > 1 {
        let span_of = |n: Vec3| {
            let (lo, hi) = cell.span_along(n, centroid);
            hi - lo
        };
        let mut best = span_of(normal);
        for k in 1..candidates {
            let d = random_dir(s ^ k.wrapping_mul(0x9E37_79B9));
            let span = span_of(d);
            // SORT-OK: strictly greater, scanned in candidate order, so a tie keeps the first —
            // a total order over the candidate list and a function of the geometry alone.
            if span > best {
                best = span;
                normal = d;
            }
        }
    }
    normal
}

/// The morphology dials [`choose_plane`] needs, without dragging the whole resource into this module.
///
/// A borrowed struct rather than five loose arguments, because the call chain
/// (`fracture` → `choose_plane`) would otherwise grow a parameter every time a mode learned a dial.
#[derive(Clone, Copy, Debug)]
pub(crate) struct MorphologyDials {
    /// Degrees of helix per successive torsional cut.
    pub(crate) spiral_pitch_deg: f32,
    /// Impulse below which a bend is a greenstick rather than a fault, N·s.
    pub(crate) greenstick_impulse: f32,
}

/// **The fault direction a loading mode actually produces.** `None` is a greenstick.
///
/// Each arm cites the measurement it implements:
///
/// - **Torsion** → a helix. Under torsion the tensile stress is maximum in a plane at **45° to the
///   long axis**, and a material weaker in tension than in shear cracks along that spiral; each
///   successive cut is rotated by `spiral_pitch_deg` about the axis, which is what produces the long
///   sharp ends (Miyasaka et al., `doi:10.3233/BME-1991-1102`).
/// - **Bending** → a butterfly, **in the measured order**: the transverse plane on the tension face
///   first, then oblique planes branching from it toward the compression face. The order matters and
///   it is the observed one — the wedge forms as fracture initiates in tension, branches obliquely,
///   then terminates back on the tension face, so the transverse portion is what forms *last*
///   (Isa et al., `doi:10.1016/j.forsciint.2021.110899`). Below `greenstick_impulse` there is no
///   fault at all.
/// - **Axial** → the weak-axis choice, which is what an axial load actually produces.
/// - **DirectHighEnergy** → comminution: no preferred plane, so the direction is the unbiased draw
///   and the *count* is what carries the energy (see [`crate::grady_mott_target`]).
#[allow(clippy::too_many_arguments)]
fn morphology_normal(
    cell: &ProxyCell,
    s: u32,
    weak_axis: f32,
    centroid: Vec3,
    mode: crate::LoadingMode,
    tissue: crate::TissueClass,
    axis: Vec3,
    torque: f32,
    impulse: f32,
    cut_index: u32,
    fs: &MorphologyDials,
) -> Option<Vec3> {
    // A subject with no stated long axis has no torsion and no tension face; the honest answer is the
    // direction-blind one rather than a fabricated axis.
    let long = axis.normalize_or_zero();
    if long == Vec3::ZERO {
        return Some(weak_axis_normal(cell, s, weak_axis, centroid));
    }
    let (u, v) = plane_basis(long);

    match mode {
        crate::LoadingMode::Torsion => {
            // 45° to the long axis, rotated about it by the pitch per successive cut. A larger torque
            // tightens the helix, because a faster twist runs the crack further per unit length.
            let twist = (fs.spiral_pitch_deg * (1.0 + torque.abs().min(4.0) * 0.25)).to_radians()
                * cut_index as f32;
            let radial = (u * twist.cos() + v * twist.sin()).normalize_or_zero();
            let n = (long + radial).normalize_or_zero();
            let n = if n == Vec3::ZERO { long } else { n };
            Some(longitudinal_if_cortical(n, long, tissue))
        }
        crate::LoadingMode::Bending => {
            if impulse < fs.greenstick_impulse {
                return None;
            }
            // The butterfly, in the measured order. Cut 0 is the transverse plane on the tension
            // face; cuts 1 and 2 are the oblique branches that carry the wedge toward compression;
            // anything past that is transverse again, because a bend that keeps breaking is breaking
            // somewhere else along the shaft.
            let n = match cut_index % 3 {
                0 => long,
                1 => (long + u * 0.9).normalize_or_zero(),
                _ => (long - u * 0.9).normalize_or_zero(),
            };
            Some(if n == Vec3::ZERO { long } else { n })
        }
        crate::LoadingMode::Axial => {
            // The *dial* bias (thinner pieces, harder weak-axis sampling) is applied once, in
            // `fracture`'s prologue where those dials live. What is applied here is the *direction*
            // bias, which is a different thing and has a different reason — see
            // `longitudinal_if_cortical`.
            Some(longitudinal_if_cortical(
                weak_axis_normal(cell, s, weak_axis, centroid),
                long,
                tissue,
            ))
        }
        crate::LoadingMode::DirectHighEnergy => {
            // Comminution has no preferred plane: an unbiased draw, deliberately *not* the weak-axis
            // sample, because a preferred direction is exactly what a shattering blow does not have.
            Some(random_dir(s ^ 0x0DE1_C0DE))
        }
    }
}

/// Split a render payload by a plane into both half-spaces. **Clipping only** — a render fragment is a
/// surface subset, not a solid, and giving it a cap here would duplicate the one the cell carries.
pub(crate) fn split_render(src: &Soup, plane: &Plane, above: &mut Soup, below: &mut Soup) {
    for (t, tri) in src.idx.iter().enumerate() {
        let v = [src.vtx(tri[0]), src.vtx(tri[1]), src.vtx(tri[2])];
        let d = [
            signed_dist(v[0].pos, plane),
            signed_dist(v[1].pos, plane),
            signed_dist(v[2].pos, plane),
        ];
        let interior = src.tri_interior[t];
        clip_half(v, d, true, interior, above);
        clip_half(v, d, false, interior, below);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **The generator's golden moved to `bloodstain` with the generator.**
    ///
    /// `hash_f32` is a re-export now, and the frozen table for it lives in that crate under the same
    /// name, `hash_f32_is_frozen`. A copy of the table here would be a second set of expected values
    /// for one function — and the first time either was re-blessed they would disagree. What this
    /// module still owns is the property that depends on the generator *and* on this crate's own
    /// geometry, which is the test below.
    #[test]
    fn random_dir_is_unit_length_and_never_zero() {
        for i in 0..512u32 {
            let d = random_dir(i.wrapping_mul(2_654_435_761));
            assert!((d.length() - 1.0).abs() < 1.0e-5, "random_dir({i}) length {}", d.length());
        }
    }
}