#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod audit;
mod bake;
mod bond;
mod bore;
#[cfg(feature = "vfx")]
mod decal;
mod feel;
mod mesh;
mod order;
mod policy;
mod proxy;
mod severance;
mod soup;
mod tree;
mod v3;
#[cfg(feature = "vfx")]
mod vfx;
mod wound;
pub use audit::{SolidAudit, SurfaceReport, audit_cell, audit_proxies, audit_proxy, audit_render};
pub use bake::{
DetachedChunk, DetachedPart, EjectaChunk, Fragment, FractureBores, FractureCache, FractureProxy,
FractureSubject, bake_fractures, materialise_fragments,
};
pub use bond::{Bond, BondGraph, BondId, BondSet};
pub use bore::Bore;
#[cfg(feature = "vfx")]
pub use decal::{
PoolDecal, StainMask, StainMasks, spawn_pool, spawn_stain, update_pool_decals,
};
pub use feel::{hitstop_ticks, shake_offset, trauma_for};
pub use policy::coalesce_hitstop;
pub use mesh::{Ejecta, Fracture, FragmentGeometry, FragmentSolid, fracture_mesh};
pub use policy::{
DecalBudget, FlashGate, GorePolicy, GoreTier, WCAG_FLASHES_PER_SECOND, occludes_aim,
};
pub use proxy::ProxyCell;
pub use severance::{Reach, capsule, directional, radial, spread, swept_triangle};
pub use tree::{FragmentId, FragmentTree, TreeNode};
#[cfg(feature = "vfx")]
pub use vfx::{
BleedingChunk, CarnageEffects, CarnageVfxPlugin, CarnageVfxSystems, EffectFade, EffectTtl,
RibbonInstance, arterial_spurt, gib_ribbon, mist_puff, spatter_burst, wound_seep,
};
pub use wound::{
CapFace, Wound, cap_faces, largest_cap, wound_from_ejecta, wound_of_channel, wounds_from_bonds,
wounds_from_reach,
};
pub use bloodstain::{
Appearance, BACK_SPATTER_SPEED, BLOOD_DENSITY, BLOOD_SURFACE_TENSION, Bleed, BloodSettings,
Droplet, FORWARD_SPATTER_SPEED, Impact, PatternClass, Pool, Stain, StainShape, WELD, WoundKind,
absorb, appearance, area_of_origin, droplet, droplet_count, droplets, flow, flows, hash_f32,
landing, pick, pulse_period, pulse_wound, pulses_on, spread_pools, viscosity, wound_seed,
yield_stress,
};
pub use bloodstain::stain::{impact_at_plane, rasterise, stain_radius, stain_shape, stains};
pub use bloodstain as blood;
use bevy::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u32)]
pub enum LoadingMode {
Torsion = 0,
Bending = 1,
Axial = 2,
DirectHighEnergy = 3,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(u32)]
pub enum TissueClass {
Cortical = 0,
Trabecular = 1,
Soft = 2,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FaultPolicy {
WeakAxis,
Morphology {
mode: LoadingMode,
tissue: TissueClass,
axis: Vec3,
torque: f32,
impulse: f32,
},
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CutSettings {
pub target: usize,
pub min_fraction: f32,
pub max_depth: u16,
pub plane_jitter: f32,
pub size_spread: f32,
pub weak_axis: f32,
pub cap_relief: f32,
pub soften: f32,
pub ejecta_soften: f32,
pub seed: u32,
#[cfg_attr(feature = "serde", serde(default))]
pub bores: Vec<Bore>,
#[cfg_attr(feature = "serde", serde(default))]
pub fault: FaultPolicy,
#[cfg_attr(feature = "serde", serde(default = "default_spiral_pitch_deg"))]
pub spiral_pitch_deg: f32,
#[cfg_attr(feature = "serde", serde(default = "default_greenstick_impulse"))]
pub greenstick_impulse: f32,
#[cfg_attr(feature = "serde", serde(default))]
pub tissue: TissueClass,
}
impl CutSettings {
pub fn new(target: usize, min_fraction: f32, seed: u32) -> Self {
let d = FractureSettings::default();
CutSettings {
target,
min_fraction,
max_depth: d.max_depth,
plane_jitter: d.plane_jitter,
size_spread: d.size_spread,
weak_axis: d.weak_axis,
cap_relief: d.cap_relief,
soften: d.soften,
ejecta_soften: d.ejecta_soften,
seed,
bores: Vec::new(),
fault: FaultPolicy::WeakAxis,
tissue: TissueClass::Soft,
spiral_pitch_deg: d.spiral_pitch_deg,
greenstick_impulse: d.greenstick_impulse,
}
}
}
#[derive(Resource, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct FractureSettings {
pub pieces_base: i32,
pub ref_extent: f32,
pub min_pieces: i32,
pub max_pieces: i32,
pub min_fraction: f32,
pub max_depth: u16,
pub plane_jitter: f32,
pub size_spread: f32,
pub weak_axis: f32,
pub cap_relief: f32,
pub soften: f32,
#[cfg_attr(feature = "serde", serde(default = "default_ejecta_soften"))]
pub ejecta_soften: f32,
#[cfg_attr(feature = "serde", serde(default = "default_spiral_pitch_deg"))]
pub spiral_pitch_deg: f32,
#[cfg_attr(feature = "serde", serde(default = "default_greenstick_impulse"))]
pub greenstick_impulse: f32,
#[cfg_attr(feature = "serde", serde(default = "default_toughness_cortical"))]
pub toughness_cortical: f32,
#[cfg_attr(feature = "serde", serde(default = "default_toughness_trabecular"))]
pub toughness_trabecular: f32,
#[cfg_attr(feature = "serde", serde(default = "default_toughness_soft"))]
pub toughness_soft: f32,
#[cfg_attr(feature = "serde", serde(default = "default_density_kg_m3"))]
pub density_kg_m3: f32,
}
fn default_ejecta_soften() -> f32 {
0.55
}
fn default_spiral_pitch_deg() -> f32 {
30.0
}
fn default_greenstick_impulse() -> f32 {
12.0
}
fn default_toughness_cortical() -> f32 {
2500.0
}
fn default_toughness_trabecular() -> f32 {
250.0
}
fn default_toughness_soft() -> f32 {
9000.0
}
fn default_density_kg_m3() -> f32 {
1900.0
}
impl Default for FaultPolicy {
fn default() -> Self {
FaultPolicy::WeakAxis
}
}
impl Default for TissueClass {
fn default() -> Self {
TissueClass::Soft
}
}
impl Default for FractureSettings {
fn default() -> Self {
FractureSettings {
pieces_base: 14,
ref_extent: 0.5,
min_pieces: 6,
max_pieces: 40,
min_fraction: 0.18,
max_depth: 12,
plane_jitter: 0.35,
size_spread: 0.5,
weak_axis: 0.75,
cap_relief: 0.30,
soften: 0.5,
ejecta_soften: 0.55,
spiral_pitch_deg: default_spiral_pitch_deg(),
greenstick_impulse: default_greenstick_impulse(),
toughness_cortical: default_toughness_cortical(),
toughness_trabecular: default_toughness_trabecular(),
toughness_soft: default_toughness_soft(),
density_kg_m3: default_density_kg_m3(),
}
}
}
impl FractureSettings {
pub fn validate(&self) -> Result<(), String> {
if self.min_pieces > self.max_pieces {
return Err(format!(
"bevy_carnage: min_pieces ({}) > max_pieces ({}) — `i32::clamp` panics on an inverted \
range, so fix the authored values",
self.min_pieces, self.max_pieces
));
}
if self.max_depth == 0 {
return Err(
"bevy_carnage: max_depth is 0 — no cut is permitted, so the bake would return the \
proxy cells unfractured and call it done. Use 1 or more."
.to_string(),
);
}
if !(0.0..1.0).contains(&self.plane_jitter) {
return Err(format!(
"bevy_carnage: plane_jitter is {} — it must be in [0, 1). At 1.0 a plane can land on \
the edge of the piece it is meant to divide, silently costing a fragment.",
self.plane_jitter
));
}
for (name, v) in [
("weak_axis", self.weak_axis),
("cap_relief", self.cap_relief),
("soften", self.soften),
("ejecta_soften", self.ejecta_soften),
] {
if !(0.0..=1.0).contains(&v) {
return Err(format!("bevy_carnage: {name} is {v} — it must be in [0, 1]."));
}
}
if self.size_spread < 0.0 {
return Err(format!(
"bevy_carnage: size_spread is {} — negative would invert the cut order into \
smallest-first, which is not a spread. Use 0.0 or more.",
self.size_spread
));
}
Ok(())
}
pub fn cut_for(&self, target: usize, seed: u32, bores: Vec<Bore>) -> CutSettings {
CutSettings {
target,
min_fraction: self.min_fraction,
max_depth: self.max_depth,
plane_jitter: self.plane_jitter,
size_spread: self.size_spread,
weak_axis: self.weak_axis,
cap_relief: self.cap_relief,
soften: self.soften,
ejecta_soften: self.ejecta_soften,
seed,
bores,
fault: FaultPolicy::WeakAxis,
tissue: TissueClass::Soft,
spiral_pitch_deg: self.spiral_pitch_deg,
greenstick_impulse: self.greenstick_impulse,
}
}
}
pub fn grady_mott_target(
volume_m3: f32,
energy_j: f32,
strain_rate: f32,
tissue: TissueClass,
s: &FractureSettings,
) -> usize {
let lo = s.min_pieces.max(1) as usize;
let hi = s.max_pieces.max(s.min_pieces).max(1) as usize;
let toughness = match tissue {
TissueClass::Cortical => s.toughness_cortical,
TissueClass::Trabecular => s.toughness_trabecular,
TissueClass::Soft => s.toughness_soft,
};
let ok = [volume_m3, energy_j, strain_rate, toughness, s.density_kg_m3]
.iter()
.all(|v| v.is_finite() && *v > 0.0);
if !ok {
return lo;
}
let size = (24.0 * toughness / (s.density_kg_m3 * strain_rate * strain_rate)).cbrt();
if !size.is_finite() || size <= 0.0 {
return lo;
}
let by_rate = volume_m3 / (size * size * size);
let per_fragment = 6.0 * size * size * toughness;
let by_energy = if per_fragment > 0.0 { energy_j / per_fragment } else { f32::INFINITY };
let n = by_rate.min(by_energy);
if !n.is_finite() {
return lo;
}
let ceiling = match tissue {
TissueClass::Trabecular => hi.min(TRABECULAR_MAX_PIECES),
_ => hi,
};
(n.round().max(0.0) as usize).clamp(lo.min(ceiling), ceiling)
}
pub const TRABECULAR_MAX_PIECES: usize = 3;
#[derive(Resource, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CarnageSettings {
#[cfg_attr(feature = "serde", serde(default))]
pub blood: BloodSettings,
#[cfg_attr(feature = "serde", serde(default = "default_trauma_per_wound"))]
pub trauma_per_wound: f32,
#[cfg_attr(feature = "serde", serde(default = "default_hitstop_seconds"))]
pub hitstop_seconds: f32,
#[cfg_attr(feature = "serde", serde(default = "default_shake_amplitude"))]
pub shake_amplitude: f32,
#[cfg_attr(feature = "serde", serde(default = "default_shake_ticks"))]
pub shake_ticks: u32,
#[cfg_attr(feature = "serde", serde(default = "default_effect_capacity"))]
pub effect_capacity: u32,
#[cfg_attr(feature = "serde", serde(default = "default_max_ribbons"))]
pub max_ribbons: u32,
#[cfg_attr(feature = "serde", serde(default = "default_substitute_srgb"))]
pub substitute_srgb: [f32; 3],
#[cfg_attr(feature = "serde", serde(default = "default_hitstop_budget_per_second"))]
pub hitstop_budget_per_second: f32,
#[cfg_attr(feature = "serde", serde(default = "default_stain_lifetime_ticks"))]
pub stain_lifetime_ticks: u32,
#[cfg_attr(feature = "serde", serde(default = "default_pool_lifetime_ticks"))]
pub pool_lifetime_ticks: u32,
#[cfg_attr(feature = "serde", serde(default = "default_chunk_lifetime_ticks"))]
pub chunk_lifetime_ticks: u32,
}
mod shipped {
pub(super) fn trauma_per_wound() -> f32 {
0.55
}
pub(super) fn hitstop_seconds() -> f32 {
0.055
}
pub(super) fn shake_amplitude() -> f32 {
0.045
}
pub(super) fn shake_ticks() -> u32 {
11
}
pub(super) fn effect_capacity() -> u32 {
4096
}
pub(super) fn max_ribbons() -> u32 {
24
}
pub(super) fn substitute_srgb() -> [f32; 3] {
[1.0, 0.78, 0.25]
}
pub(super) fn hitstop_budget_per_second() -> f32 {
0.1
}
pub(super) fn stain_lifetime_ticks() -> u32 {
3600
}
pub(super) fn pool_lifetime_ticks() -> u32 {
7200
}
pub(super) fn chunk_lifetime_ticks() -> u32 {
1200
}
}
fn default_trauma_per_wound() -> f32 {
shipped::trauma_per_wound()
}
fn default_hitstop_seconds() -> f32 {
shipped::hitstop_seconds()
}
fn default_shake_amplitude() -> f32 {
shipped::shake_amplitude()
}
fn default_shake_ticks() -> u32 {
shipped::shake_ticks()
}
fn default_effect_capacity() -> u32 {
shipped::effect_capacity()
}
fn default_max_ribbons() -> u32 {
shipped::max_ribbons()
}
fn default_substitute_srgb() -> [f32; 3] {
shipped::substitute_srgb()
}
fn default_hitstop_budget_per_second() -> f32 {
shipped::hitstop_budget_per_second()
}
fn default_stain_lifetime_ticks() -> u32 {
shipped::stain_lifetime_ticks()
}
fn default_pool_lifetime_ticks() -> u32 {
shipped::pool_lifetime_ticks()
}
fn default_chunk_lifetime_ticks() -> u32 {
shipped::chunk_lifetime_ticks()
}
impl Default for CarnageSettings {
fn default() -> Self {
CarnageSettings {
blood: BloodSettings::default(),
trauma_per_wound: shipped::trauma_per_wound(),
hitstop_seconds: shipped::hitstop_seconds(),
shake_amplitude: shipped::shake_amplitude(),
shake_ticks: shipped::shake_ticks(),
effect_capacity: shipped::effect_capacity(),
max_ribbons: shipped::max_ribbons(),
substitute_srgb: shipped::substitute_srgb(),
hitstop_budget_per_second: shipped::hitstop_budget_per_second(),
stain_lifetime_ticks: shipped::stain_lifetime_ticks(),
pool_lifetime_ticks: shipped::pool_lifetime_ticks(),
chunk_lifetime_ticks: shipped::chunk_lifetime_ticks(),
}
}
}
impl CarnageSettings {
pub fn validate(&self) -> Result<(), String> {
self.blood.validate()?;
if self.shake_ticks == 0 {
return Err(
"carnage: shake_ticks is 0 — it is the modulus of the shake phase, so the first \
shake would panic on a division by zero. Use 1 or more."
.to_string(),
);
}
if !(0.0..=1.0).contains(&self.trauma_per_wound) {
return Err(format!(
"carnage: trauma_per_wound is {} — trauma is in [0, 1] and the caller accumulates \
it, so a value outside that is not a stronger hit, it is a broken one.",
self.trauma_per_wound
));
}
if self.effect_capacity == 0 {
return Err(
"carnage: effect_capacity is 0 — an effect that can hold no particles renders \
nothing, which is a settings bug rather than a look."
.to_string(),
);
}
if self.max_ribbons == 0 {
return Err(
"carnage: max_ribbons is 0 — that disables blood ribbons entirely, which is a \
content decision made by not inserting `CarnageVfxPlugin`, not by a dial that \
leaves the systems running and refusing every one."
.to_string(),
);
}
Ok(())
}
}
#[derive(Message, Clone, Copy, Debug, PartialEq)]
pub struct Wounded {
pub at: Vec3,
pub normal: Vec3,
pub area: f32,
pub severity: f32,
pub kind: WoundKind,
pub class: PatternClass,
}
impl Wound {
pub fn to_world(self, xf: &GlobalTransform, class: PatternClass) -> Wounded {
Wounded {
at: xf.transform_point(self.at),
normal: (xf.affine().matrix3 * self.normal).normalize_or_zero(),
area: self.area,
severity: self.severity,
kind: self.kind,
class,
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Deref, DerefMut)]
pub struct Bleeding(pub Bleed);
impl Bleeding {
pub fn new(opened_at: u32, area: f32) -> Self {
Bleeding(Bleed::new(opened_at, area))
}
}
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct CarnageSystems;
pub struct CarnagePlugin;
impl Plugin for CarnagePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<FractureCache>()
.init_resource::<FractureSettings>()
.init_resource::<GorePolicy>()
.init_resource::<FlashGate>()
.init_resource::<DecalBudget>()
.add_message::<Wounded>()
.add_systems(
Update,
(bake_fractures, materialise_fragments).chain().in_set(CarnageSystems),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_serde_default_matches_the_shipped_one() {
assert_eq!(
default_ejecta_soften(),
FractureSettings::default().ejecta_soften,
"serde would hand an authored file that omits `ejecta_soften` a different value than \
`FractureSettings::default()` uses, so the same config would look different depending \
on whether the field was written down"
);
}
#[test]
#[cfg(feature = "serde")]
fn a_config_written_before_ejecta_soften_still_loads() {
let authored = r#"(
pieces_base: 14,
ref_extent: 0.5,
min_pieces: 6,
max_pieces: 40,
min_fraction: 0.18,
max_depth: 12,
plane_jitter: 0.35,
size_spread: 0.5,
weak_axis: 0.75,
cap_relief: 0.30,
soften: 0.5,
)"#;
let s: FractureSettings = ron::from_str(authored)
.expect("a config listing the eleven pre-AG-024 dials must still deserialize");
assert_eq!(
s.ejecta_soften,
default_ejecta_soften(),
"the omitted dial must take the shipped default"
);
assert_eq!(s.pieces_base, 14, "the authored fields must survive");
assert_eq!(s.soften, 0.5, "the authored fields must survive");
s.validate().expect("an authored config plus the default dial must validate");
let typo = authored.replace("soften: 0.5,", "soften: 0.5, sofen: 0.9,");
assert!(
ron::from_str::<FractureSettings>(&typo).is_err(),
"a misspelled field must still be refused — `deny_unknown_fields` is what makes the \
serde default safe, and a default that also swallowed typos would be a fallback"
);
}
#[test]
fn the_shipped_settings_validate() {
FractureSettings::default().validate().expect("the shipped FractureSettings must validate");
CarnageSettings::default().validate().expect("the shipped CarnageSettings must validate");
}
#[test]
fn every_carnage_serde_default_is_the_shipped_value() {
let d = CarnageSettings::default();
let pairs_f32: &[(&str, f32, f32)] = &[
("trauma_per_wound", default_trauma_per_wound(), d.trauma_per_wound),
("hitstop_seconds", default_hitstop_seconds(), d.hitstop_seconds),
("shake_amplitude", default_shake_amplitude(), d.shake_amplitude),
];
for (name, serde_value, shipped_value) in pairs_f32 {
assert_eq!(
serde_value.to_bits(),
shipped_value.to_bits(),
"{name}: the serde default and the shipped default disagree, so a config that \
omits the dial behaves differently from one that never had it"
);
}
let pairs_u32: &[(&str, u32, u32)] = &[
("shake_ticks", default_shake_ticks(), d.shake_ticks),
("effect_capacity", default_effect_capacity(), d.effect_capacity),
("max_ribbons", default_max_ribbons(), d.max_ribbons),
];
for (name, serde_value, shipped_value) in pairs_u32 {
assert_eq!(serde_value, shipped_value, "{name}: serde and shipped defaults disagree");
}
assert_eq!(
d.blood,
BloodSettings::default(),
"the blood block must be exactly the leaf's shipped dials"
);
}
#[test]
#[cfg(feature = "serde")]
fn an_empty_carnage_config_loads_as_the_shipped_dials() {
let s: CarnageSettings =
ron::from_str("()").expect("a config that omits every carnage dial must deserialize");
assert_eq!(s, CarnageSettings::default(), "omitting every dial must give the shipped block");
let partial: CarnageSettings = ron::from_str("(blood: (spurt_bpm: 120.0))")
.expect("a config naming one dial must take the shipped values for the rest");
assert_eq!(partial.blood.spurt_bpm, 120.0, "the authored dial must survive");
assert_eq!(
partial.blood.clot_ticks,
BloodSettings::default().clot_ticks,
"an unauthored dial must take the shipped value"
);
assert_eq!(
partial.shake_ticks,
default_shake_ticks(),
"and an unauthored dial outside the blood block too"
);
assert!(
ron::from_str::<CarnageSettings>("(blood: (spurt_bmp: 120.0))").is_err(),
"a misspelled dial must be refused — `deny_unknown_fields` is what keeps the per-field \
defaults from becoming a fallback that swallows typos"
);
}
#[test]
fn the_carnage_door_refuses_what_would_panic_or_invert() {
let bad = |f: fn(&mut CarnageSettings)| {
let mut s = CarnageSettings::default();
f(&mut s);
s.validate().expect_err("this block must be refused")
};
assert!(bad(|s| s.shake_ticks = 0).contains("shake_ticks"));
assert!(bad(|s| s.trauma_per_wound = 1.5).contains("trauma_per_wound"));
assert!(bad(|s| s.effect_capacity = 0).contains("effect_capacity"));
assert!(bad(|s| s.max_ribbons = 0).contains("max_ribbons"));
assert!(bad(|s| s.blood.spurt_bpm = 0.0).contains("spurt_bpm"));
assert!(bad(|s| s.blood.clot_ticks = 10).contains("clot_ticks"));
assert!(bad(|s| s.blood.droplet_size_max = 0.0).contains("droplet_size"));
assert!(bad(|s| s.blood.stain_radius_min = 0.0).contains("stain_radius"));
assert!(bad(|s| s.blood.spatter_cone_deg = 200.0).contains("spatter_cone_deg"));
}
#[test]
fn to_world_moves_the_point_and_only_rotates_the_normal() {
let w = Wound {
at: Vec3::new(0.1, 0.2, 0.3),
normal: Vec3::X,
area: 0.004,
severity: 0.75,
kind: WoundKind::Channel,
};
let shifted = GlobalTransform::from(Transform::from_xyz(100.0, 50.0, -20.0));
let out = w.to_world(&shifted, PatternClass::Impact);
assert_eq!(out.at, Vec3::new(100.1, 50.2, -19.7), "the point must be translated");
assert_eq!(out.normal, Vec3::X, "a pure translation must not turn the normal at all");
assert_eq!(out.area, w.area, "area is carried across unchanged");
assert_eq!(out.severity, w.severity);
assert_eq!(out.kind, w.kind);
let turned = GlobalTransform::from(
Transform::from_xyz(3.0, 0.0, 0.0)
.with_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)),
);
let out = turned_normal(&w, &turned);
assert!(
(out - Vec3::Y).length() < 1.0e-5,
"a quarter turn about Z must take +X to +Y, got {out:?}"
);
let scaled = GlobalTransform::from(
Transform::from_xyz(0.0, 0.0, 0.0).with_scale(Vec3::new(4.0, 0.5, 2.0)),
);
let out = turned_normal(&w, &scaled);
assert!((out.length() - 1.0).abs() < 1.0e-5, "normal length {} is not unit", out.length());
}
fn turned_normal(w: &Wound, xf: &GlobalTransform) -> Vec3 {
w.to_world(xf, PatternClass::Impact).normal
}
}