#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod audit;
mod bake;
mod bleed;
mod bond;
mod bore;
#[cfg(feature = "vfx")]
mod decal;
mod feel;
mod mesh;
mod order;
mod pool;
mod proxy;
mod severance;
mod soup;
mod spatter;
mod tree;
#[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 bleed::{Bleed, clotted, flow, pulse_period, pulse_wound, pulses_on};
pub use bond::{Bond, BondGraph, BondId, BondSet};
pub use bore::Bore;
#[cfg(feature = "vfx")]
pub use decal::{
PoolDecal, SPLAT_VARIANTS, SplatTextures, build_splats, spawn_pool, spawn_stain, splat_image,
update_pool_decals,
};
pub use feel::{hitstop_ticks, shake_offset, trauma_for};
pub use mesh::{Ejecta, Fracture, FragmentGeometry, FragmentSolid, fracture_mesh};
pub use pool::{Pool, absorb, spread_pools};
pub use proxy::ProxyCell;
pub use severance::{Reach, capsule, directional, radial, spread, swept_triangle};
pub use soup::hash_f32;
pub use spatter::{
BACK_SPATTER_SPEED, BLOOD_DENSITY, BLOOD_SURFACE_TENSION, Droplet, FORWARD_SPATTER_SPEED, Stain,
droplet, droplet_count, droplets, landing, stain_radius, stains, wound_seed,
};
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, WoundKind, cap_faces, largest_cap, wound_from_ejecta, wound_of_channel,
wounds_from_bonds, wounds_from_reach,
};
use bevy::prelude::*;
#[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>,
}
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(),
}
}
}
#[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,
}
fn default_ejecta_soften() -> f32 {
0.55
}
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,
}
}
}
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,
}
}
}
#[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 = "default_droplets_per_m2"))]
pub droplets_per_m2: f32,
#[cfg_attr(feature = "serde", serde(default = "default_max_droplets_per_wound"))]
pub max_droplets_per_wound: u32,
#[cfg_attr(feature = "serde", serde(default = "default_spatter_speed_scale"))]
pub spatter_speed_scale: f32,
#[cfg_attr(feature = "serde", serde(default = "default_spatter_cone_deg"))]
pub spatter_cone_deg: f32,
#[cfg_attr(feature = "serde", serde(default = "default_droplet_size_min"))]
pub droplet_size_min: f32,
#[cfg_attr(feature = "serde", serde(default = "default_droplet_size_max"))]
pub droplet_size_max: f32,
#[cfg_attr(feature = "serde", serde(default = "default_gravity"))]
pub gravity: f32,
#[cfg_attr(feature = "serde", serde(default = "default_drag"))]
pub drag: f32,
#[cfg_attr(feature = "serde", serde(default = "default_spurt_bpm"))]
pub spurt_bpm: f32,
#[cfg_attr(feature = "serde", serde(default = "default_spurt_ticks"))]
pub spurt_ticks: u32,
#[cfg_attr(feature = "serde", serde(default = "default_clot_ticks"))]
pub clot_ticks: u32,
#[cfg_attr(feature = "serde", serde(default = "default_stain_radius_min"))]
pub stain_radius_min: f32,
#[cfg_attr(feature = "serde", serde(default = "default_stain_radius_max"))]
pub stain_radius_max: f32,
#[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_pool_merge_radius"))]
pub pool_merge_radius: f32,
#[cfg_attr(feature = "serde", serde(default = "default_pool_spread"))]
pub pool_spread: f32,
#[cfg_attr(feature = "serde", serde(default = "default_pool_spread_rate"))]
pub pool_spread_rate: f32,
#[cfg_attr(feature = "serde", serde(default = "default_max_pools"))]
pub max_pools: u32,
}
mod shipped {
pub(super) fn droplets_per_m2() -> f32 {
2400.0
}
pub(super) fn max_droplets_per_wound() -> u32 {
512
}
pub(super) fn spatter_speed_scale() -> f32 {
1.0
}
pub(super) fn spatter_cone_deg() -> f32 {
32.0
}
pub(super) fn droplet_size_min() -> f32 {
0.000_8
}
pub(super) fn droplet_size_max() -> f32 {
0.006
}
pub(super) fn gravity() -> f32 {
18.0
}
pub(super) fn drag() -> f32 {
1.6
}
pub(super) fn spurt_bpm() -> f32 {
96.0
}
pub(super) fn spurt_ticks() -> u32 {
210
}
pub(super) fn clot_ticks() -> u32 {
360
}
pub(super) fn stain_radius_min() -> f32 {
0.02
}
pub(super) fn stain_radius_max() -> f32 {
0.12
}
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 pool_merge_radius() -> f32 {
0.10
}
pub(super) fn pool_spread() -> f32 {
1.35
}
pub(super) fn pool_spread_rate() -> f32 {
0.08
}
pub(super) fn max_pools() -> u32 {
256
}
}
fn default_droplets_per_m2() -> f32 {
shipped::droplets_per_m2()
}
fn default_max_droplets_per_wound() -> u32 {
shipped::max_droplets_per_wound()
}
fn default_spatter_speed_scale() -> f32 {
shipped::spatter_speed_scale()
}
fn default_spatter_cone_deg() -> f32 {
shipped::spatter_cone_deg()
}
fn default_droplet_size_min() -> f32 {
shipped::droplet_size_min()
}
fn default_droplet_size_max() -> f32 {
shipped::droplet_size_max()
}
fn default_gravity() -> f32 {
shipped::gravity()
}
fn default_drag() -> f32 {
shipped::drag()
}
fn default_spurt_bpm() -> f32 {
shipped::spurt_bpm()
}
fn default_spurt_ticks() -> u32 {
shipped::spurt_ticks()
}
fn default_clot_ticks() -> u32 {
shipped::clot_ticks()
}
fn default_stain_radius_min() -> f32 {
shipped::stain_radius_min()
}
fn default_stain_radius_max() -> f32 {
shipped::stain_radius_max()
}
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_pool_merge_radius() -> f32 {
shipped::pool_merge_radius()
}
fn default_pool_spread() -> f32 {
shipped::pool_spread()
}
fn default_pool_spread_rate() -> f32 {
shipped::pool_spread_rate()
}
fn default_max_pools() -> u32 {
shipped::max_pools()
}
impl Default for CarnageSettings {
fn default() -> Self {
CarnageSettings {
droplets_per_m2: shipped::droplets_per_m2(),
max_droplets_per_wound: shipped::max_droplets_per_wound(),
spatter_speed_scale: shipped::spatter_speed_scale(),
spatter_cone_deg: shipped::spatter_cone_deg(),
droplet_size_min: shipped::droplet_size_min(),
droplet_size_max: shipped::droplet_size_max(),
gravity: shipped::gravity(),
drag: shipped::drag(),
spurt_bpm: shipped::spurt_bpm(),
spurt_ticks: shipped::spurt_ticks(),
clot_ticks: shipped::clot_ticks(),
stain_radius_min: shipped::stain_radius_min(),
stain_radius_max: shipped::stain_radius_max(),
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(),
pool_merge_radius: shipped::pool_merge_radius(),
pool_spread: shipped::pool_spread(),
pool_spread_rate: shipped::pool_spread_rate(),
max_pools: shipped::max_pools(),
}
}
}
impl CarnageSettings {
pub fn validate(&self) -> Result<(), String> {
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 !(self.spurt_bpm > 0.0) || !self.spurt_bpm.is_finite() {
return Err(format!(
"carnage: spurt_bpm is {} — the pulse period is `60 / bpm`, so this must be finite \
and positive.",
self.spurt_bpm
));
}
if self.clot_ticks < self.spurt_ticks {
return Err(format!(
"carnage: clot_ticks ({}) < spurt_ticks ({}) — flow is full until `spurt_ticks` and \
zero at `clot_ticks`, so an inverted pair would have it rise before it fell.",
self.clot_ticks, self.spurt_ticks
));
}
for (name, lo, hi) in [
("droplet_size", self.droplet_size_min, self.droplet_size_max),
("stain_radius", self.stain_radius_min, self.stain_radius_max),
] {
if !(lo > 0.0) || !(hi >= lo) || !lo.is_finite() || !hi.is_finite() {
return Err(format!(
"carnage: {name}_min ({lo}) and {name}_max ({hi}) must be finite with \
0 < min <= max — the pair is lerped, and an inverted one reverses the model."
));
}
}
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 !(0.0..=180.0).contains(&self.spatter_cone_deg) {
return Err(format!(
"carnage: spatter_cone_deg is {} — it is a half-angle about the wound normal, so it \
must be in [0, 180].",
self.spatter_cone_deg
));
}
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(),
);
}
if self.max_pools == 0 {
return Err(
"carnage: max_pools is 0 — every stain would be dropped and blood would never \
accumulate, which is the whole feature switched off by a ceiling."
.to_string(),
);
}
for (name, v) in
[("pool_merge_radius", self.pool_merge_radius), ("pool_spread", self.pool_spread)]
{
if !(v > 0.0) || !v.is_finite() {
return Err(format!(
"carnage: {name} is {v} — it scales a radius, so it must be finite and positive."
));
}
}
if !(self.pool_spread_rate > 0.0 && self.pool_spread_rate <= 1.0) {
return Err(format!(
"carnage: pool_spread_rate is {} — it is the fraction of the remaining gap closed \
per tick, so it must be in (0, 1]. At 0 a pool never spreads; above 1 it \
overshoots and oscillates.",
self.pool_spread_rate
));
}
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,
}
impl Wound {
pub fn to_world(self, xf: &GlobalTransform) -> 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,
}
}
}
#[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>()
.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)] = &[
("droplets_per_m2", default_droplets_per_m2(), d.droplets_per_m2),
("spatter_speed_scale", default_spatter_speed_scale(), d.spatter_speed_scale),
("spatter_cone_deg", default_spatter_cone_deg(), d.spatter_cone_deg),
("droplet_size_min", default_droplet_size_min(), d.droplet_size_min),
("droplet_size_max", default_droplet_size_max(), d.droplet_size_max),
("gravity", default_gravity(), d.gravity),
("drag", default_drag(), d.drag),
("spurt_bpm", default_spurt_bpm(), d.spurt_bpm),
("stain_radius_min", default_stain_radius_min(), d.stain_radius_min),
("stain_radius_max", default_stain_radius_max(), d.stain_radius_max),
("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)] = &[
("max_droplets_per_wound", default_max_droplets_per_wound(), d.max_droplets_per_wound),
("spurt_ticks", default_spurt_ticks(), d.spurt_ticks),
("clot_ticks", default_clot_ticks(), d.clot_ticks),
("shake_ticks", default_shake_ticks(), d.shake_ticks),
("effect_capacity", default_effect_capacity(), d.effect_capacity),
];
for (name, serde_value, shipped_value) in pairs_u32 {
assert_eq!(serde_value, shipped_value, "{name}: serde and shipped defaults disagree");
}
}
#[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("(spurt_bpm: 120.0)")
.expect("a config naming one dial must take the shipped values for the rest");
assert_eq!(partial.spurt_bpm, 120.0, "the authored dial must survive");
assert_eq!(
partial.clot_ticks,
default_clot_ticks(),
"an unauthored dial must take the shipped value"
);
assert!(
ron::from_str::<CarnageSettings>("(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.spurt_bpm = 0.0).contains("spurt_bpm"));
assert!(bad(|s| s.clot_ticks = 10).contains("clot_ticks"));
assert!(bad(|s| s.droplet_size_max = 0.0).contains("droplet_size"));
assert!(bad(|s| s.stain_radius_min = 0.0).contains("stain_radius"));
assert!(bad(|s| s.trauma_per_wound = 1.5).contains("trauma_per_wound"));
assert!(bad(|s| s.spatter_cone_deg = 200.0).contains("spatter_cone_deg"));
assert!(bad(|s| s.effect_capacity = 0).contains("effect_capacity"));
}
#[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);
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).normal
}
}