mod mesh;
pub(crate) mod model;
use bevy::camera::visibility::VisibilityRange;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use crate::plugin::{ElementsQuality, ElementsSystems, ElementsUnits, ElementsView};
use crate::query::EnvironmentSnapshot;
use crate::terrain::{TerrainLook, TerrainRuntime, TerrainSnapshot, TerrainState};
use crate::weathered::{WeatheredExtension, WeatheredMaterial, WeatheredPlugin};
use mesh::{
FAR_SUBDIVISIONS, NEAR_SUBDIVISIONS, SHAPES, ShapeInfo, TONES, rock_geometry, rock_mesh,
};
use model::{
CLASSES, ClassLattice, CoverParams, Lattices, PatchLevels, Placed, TILT_TO_SLOPE, tile_rocks,
};
#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
pub struct RockSettings {
pub talus_cover: f32,
pub meadow_cover: f32,
pub meadow_floor: f32,
pub range_scale: f32,
pub seed: u64,
pub allowed: Option<Vec<Rect>>,
}
impl Default for RockSettings {
fn default() -> Self {
Self {
talus_cover: 0.30,
meadow_cover: 0.05,
meadow_floor: 0.015,
range_scale: 1.0,
seed: 0,
allowed: None,
}
}
}
impl RockSettings {
fn cover(&self) -> CoverParams {
CoverParams {
talus_cover: self.talus_cover.max(0.0),
meadow_floor: self.meadow_floor.max(0.0),
meadow_cover: self.meadow_cover.max(0.0),
}
}
}
fn is_allowed(allowed: Option<&[Rect]>, xz: Vec2) -> bool {
allowed.is_none_or(|rects| rects.iter().any(|r| r.contains(xz)))
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
pub struct ScatteredRock {
pub diameter_m: f32,
pub height_m: f32,
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
pub struct RockStats {
pub rocks: u32,
pub tiles: u32,
}
pub struct RocksPlugin;
impl Plugin for RocksPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<WeatheredPlugin>() {
app.add_plugins(WeatheredPlugin);
}
app.init_resource::<RockSettings>()
.init_resource::<RockStats>()
.init_resource::<RockField>()
.add_systems(Startup, create_assets)
.add_systems(
Update,
(update_levels, scatter, seat_rocks)
.chain()
.after(ElementsSystems::Readback),
)
.add_systems(Update, grade_rocks);
}
}
fn grade_rocks(
look: Option<Res<TerrainLook>>,
assets: Option<Res<RockAssets>>,
materials: Option<ResMut<Assets<WeatheredMaterial>>>,
) {
let (Some(look), Some(assets), Some(mut materials)) = (look, assets, materials) else {
return;
};
let packed = look.packed();
if materials
.get(&assets.material)
.is_some_and(|m| m.extension.look != packed)
&& let Some(mut material) = materials.get_mut(&assets.material)
{
material.extension.look = packed;
}
}
const TILES_PER_FRAME: usize = 24;
const SEAT_STEP_M: f32 = 0.25;
const SEAT_TOLERANCE_M: f32 = 0.001;
const NEAR_PER_M: f32 = 22.0;
const FADE: f32 = 0.2;
const RANGE_BINS: usize = 6;
const REFERENCE_FOV: f32 = std::f32::consts::FRAC_PI_3;
const ORTHO_RING_MAX_M: f32 = 1200.0;
#[derive(Clone, Copy, Debug, PartialEq)]
enum ViewScale {
Distance { range_per_m: f32 },
Diameter {
min_diameter_m: f32,
near_diameter_m: f32,
ring_m: f32,
},
}
impl ViewScale {
fn ring_radius(&self, class_max_m: f32) -> f32 {
match *self {
Self::Distance { range_per_m, .. } => range_per_m * class_max_m,
Self::Diameter {
min_diameter_m,
ring_m,
..
} => {
if class_max_m >= min_diameter_m {
ring_m
} else {
0.0
}
}
}
}
fn draws(&self, d: f32) -> bool {
match *self {
Self::Distance { range_per_m, .. } => range_per_m > 0.0,
Self::Diameter { min_diameter_m, .. } => d >= min_diameter_m,
}
}
fn near_mesh(&self, d: f32) -> bool {
match *self {
Self::Distance { .. } => true,
Self::Diameter {
near_diameter_m, ..
} => d >= near_diameter_m,
}
}
fn key(&self) -> [f32; 4] {
match *self {
Self::Distance { range_per_m, .. } => [0.0, range_per_m, 0.0, 0.0],
Self::Diameter {
min_diameter_m,
ring_m,
..
} => [1.0, min_diameter_m, ring_m, 0.0],
}
}
}
fn view_scale(
projection: Option<&Projection>,
range_per_m: f32,
footprint_m: f32,
units: &ElementsUnits,
) -> ViewScale {
let height_m = match projection {
Some(Projection::Orthographic(o)) => units.metres(o.area.height()),
_ => f32::NAN,
};
if !(height_m.is_finite() && height_m > 0.0) {
return ViewScale::Distance { range_per_m };
}
let threshold = 2.0 * (0.5 * REFERENCE_FOV).tan();
let ground_m = 0.5 * footprint_m / (1.0 - FADE);
ViewScale::Diameter {
min_diameter_m: height_m / (range_per_m * threshold),
near_diameter_m: height_m / (NEAR_PER_M * threshold),
ring_m: ground_m.min(ORTHO_RING_MAX_M),
}
}
#[derive(Resource)]
struct RockAssets {
meshes: Vec<[Handle<Mesh>; 2]>,
shapes: Vec<ShapeInfo>,
material: Handle<WeatheredMaterial>,
}
const ROCK_ALBEDO: [f32; 3] = [0.076, 0.071, 0.066];
pub const STONE_SHAPES: usize = SHAPES;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum StoneDetail {
Near,
Far,
}
pub fn stone_palette() -> [Vec3; 4] {
TONES
}
pub fn stone_facet_tones(shape: usize) -> Vec<Vec3> {
rock_geometry(shape % SHAPES, FAR_SUBDIVISIONS).tones
}
pub fn stone_mesh(shape: usize, detail: StoneDetail) -> Mesh {
let subdivisions = match detail {
StoneDetail::Near => NEAR_SUBDIVISIONS,
StoneDetail::Far => FAR_SUBDIVISIONS,
};
rock_mesh(rock_geometry(shape % SHAPES, subdivisions))
}
const GRAIN_M: f32 = 0.30;
fn create_assets(
mut commands: Commands,
meshes: Option<ResMut<Assets<Mesh>>>,
materials: Option<ResMut<Assets<WeatheredMaterial>>>,
) {
let (Some(mut meshes), Some(mut materials)) = (meshes, materials) else {
return;
};
let mut handles = Vec::with_capacity(SHAPES);
let mut shapes = Vec::with_capacity(SHAPES);
for shape in 0..SHAPES {
let near = rock_geometry(shape, NEAR_SUBDIVISIONS);
shapes.push(near.info);
let far = rock_geometry(shape, FAR_SUBDIVISIONS);
handles.push([meshes.add(rock_mesh(near)), meshes.add(rock_mesh(far))]);
}
let material = materials.add(WeatheredMaterial {
base: StandardMaterial {
base_color: Color::linear_rgb(ROCK_ALBEDO[0], ROCK_ALBEDO[1], ROCK_ALBEDO[2]),
perceptual_roughness: 0.9,
reflectance: 0.35,
..default()
},
extension: WeatheredExtension::default().with_grain(GRAIN_M),
});
commands.insert_resource(RockAssets {
meshes: handles,
shapes,
material,
});
}
#[derive(Clone, Debug, PartialEq)]
struct FieldKey {
grid_origin: Vec2,
grid_cell: f32,
grid_size: UVec2,
grid_y0: f32,
cover: CoverParams,
seed: u64,
allowed: Option<Vec<Rect>>,
view: [f32; 4],
units: f32,
}
#[derive(Resource, Default)]
struct RockField {
key: Option<FieldKey>,
lattices: Vec<ClassLattice>,
tiles: HashMap<(usize, IVec2), (Entity, u32)>,
levels: Option<PatchLevels>,
levels_changed: bool,
}
#[derive(Component, Clone, Copy, Debug)]
struct RockSeat {
xz: Vec2,
up: Vec3,
lift_m: f32,
scale_m: f32,
far_m: f32,
lattices: Lattices,
version: u32,
patched: bool,
}
#[derive(Component)]
struct RockTile;
fn update_levels(
runtime: Option<Res<TerrainRuntime>>,
state: Option<Res<TerrainState>>,
mut field: ResMut<RockField>,
) {
let levels = runtime.zip(state).and_then(|(runtime, state)| {
let lod = runtime.lod?;
if !runtime.draws_ground() {
return None;
}
let leaves = lod.nodes(0);
let mut level = vec![u8::MAX; (leaves.x * leaves.y) as usize];
for patch in &runtime.selected {
let span = 1u32 << patch.level;
for z in patch.iz * span..((patch.iz + 1) * span).min(leaves.y) {
for x in patch.ix * span..((patch.ix + 1) * span).min(leaves.x) {
level[(z * leaves.x + x) as usize] = patch.level as u8;
}
}
}
Some(PatchLevels {
cell_m: state.grid.cell_m,
origin: state.grid.origin + Vec2::splat(lod.sample_offset()),
top: lod.levels.saturating_sub(1),
morph: (0..lod.levels)
.map(|k| (lod.morph_start_m(k), lod.range_m(k)))
.collect(),
leaf_m: lod.leaf_m,
leaves,
level,
})
});
if field.levels != levels {
field.levels = levels;
field.levels_changed = true;
}
}
fn binned_diameter(class: usize, diameter_m: f32) -> f32 {
let (a, b) = CLASSES[class];
let t = ((diameter_m / a).ln() / (b / a).ln()).clamp(0.0, 0.999);
let bin = (t * RANGE_BINS as f32).floor();
a * (b / a).powf((bin + 0.5) / RANGE_BINS as f32)
}
fn footprint_m(projection: &Projection, transform: &GlobalTransform, units: &ElementsUnits) -> f32 {
let Projection::Orthographic(o) = projection else {
return 0.0;
};
let down = (-transform.forward().y).clamp(0.2, 1.0);
units
.metres(o.area.width())
.hypot(units.metres(o.area.height()) / down)
}
#[allow(clippy::too_many_arguments)]
fn scatter(
mut commands: Commands,
settings: Res<RockSettings>,
quality: Res<ElementsQuality>,
units: Res<ElementsUnits>,
view: Res<ElementsView>,
cameras: Query<(&Projection, &GlobalTransform)>,
snapshot: Res<EnvironmentSnapshot>,
terrain: Option<Res<TerrainRuntime>>,
assets: Option<Res<RockAssets>>,
mut field: ResMut<RockField>,
mut stats: ResMut<RockStats>,
) {
let Some(assets) = assets else {
return;
};
let Some(snap) = snapshot.terrain.as_ref() else {
clear(&mut commands, &mut field, &mut stats);
field.key = None;
return;
};
if view.entity.is_none() {
return;
}
let cover = settings.cover();
let range_per_m = quality.rock_range() * settings.range_scale.max(0.0);
let u = units.per_metre();
let centre = terrain.map_or(view.eye, |t| t.morph_centre(view.eye));
let camera = view.entity.and_then(|e| cameras.get(e).ok());
let scale = view_scale(
camera.map(|(projection, _)| projection),
range_per_m,
camera.map_or(0.0, |(projection, transform)| {
footprint_m(projection, transform, &units)
}),
&units,
);
let key = FieldKey {
grid_origin: snap.grid.origin,
grid_cell: snap.grid.cell_m,
grid_size: snap.grid.size,
grid_y0: snap.grid.y0,
cover,
seed: settings.seed,
allowed: settings.allowed.clone(),
view: scale.key(),
units: u,
};
if field.key.as_ref() != Some(&key) {
clear(&mut commands, &mut field, &mut stats);
field.lattices = (0..CLASSES.len())
.map(|class| {
ClassLattice::with_radius(
class,
cover.max_cover(),
scale.ring_radius(CLASSES[class].1),
)
})
.collect();
field.key = Some(key);
}
if cover.max_cover() <= 0.0 || range_per_m <= 0.0 || settings.allowed.as_deref() == Some(&[]) {
return;
}
if field.lattices.iter().all(|lattice| lattice.radius_m <= 0.0) {
return;
}
let centre_xz = centre.xz();
let lattices = field.lattices.clone();
field.tiles.retain(|&(class, tile), &mut (entity, rocks)| {
let lattice = &lattices[class];
if lattice.tile_distance(tile, centre_xz) > lattice.radius_m + lattice.tile_m() {
commands.entity(entity).despawn();
stats.rocks = stats.rocks.saturating_sub(rocks);
stats.tiles = stats.tiles.saturating_sub(1);
false
} else {
true
}
});
let mut wanted: Vec<(f32, usize, IVec2)> = lattices
.iter()
.filter(|lattice| lattice.radius_m > 0.0)
.flat_map(|lattice| {
lattice
.tiles_near(centre_xz, 0.0)
.into_iter()
.map(move |tile| (lattice.tile_distance(tile, centre_xz), lattice.class, tile))
})
.filter(|(_, class, tile)| !field.tiles.contains_key(&(*class, *tile)))
.collect();
wanted.sort_by(|a, b| a.0.total_cmp(&b.0));
for &(_, class, tile) in wanted.iter().take(TILES_PER_FRAME) {
let lattice = &lattices[class];
if lattice.radius_m <= 0.0 {
continue;
}
let rocks = tile_rocks(snap, &cover, lattice, tile, settings.seed);
let parent = commands
.spawn((RockTile, Transform::IDENTITY, Visibility::default()))
.id();
let mut count = 0;
for rock in &rocks {
if !is_allowed(settings.allowed.as_deref(), rock.xz) || !scale.draws(rock.diameter_m) {
continue;
}
count += 1;
let child = spawn_rock(
&mut commands,
&assets,
class,
rock,
scale,
u,
centre,
snap,
field.levels.as_ref(),
);
commands.entity(parent).add_child(child);
}
field.tiles.insert((class, tile), (parent, count));
stats.rocks += count;
stats.tiles += 1;
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_rock(
commands: &mut Commands,
assets: &RockAssets,
class: usize,
rock: &Placed,
scale: ViewScale,
units: f32,
centre: Vec3,
snap: &TerrainSnapshot,
levels: Option<&PatchLevels>,
) -> Entity {
let info = assets.shapes[rock.shape];
let scale_m = 2.0 * rock.diameter_m / (1.0 + info.b);
let height_m = info.c * scale_m;
let up = Quat::from_euler(EulerRot::XYZ, rock.jitter.x, 0.0, rock.jitter.y)
* Vec3::Y.lerp(rock.normal, TILT_TO_SLOPE).normalize();
let rotation = Quat::from_rotation_arc(Vec3::Y, up) * Quat::from_rotation_y(rock.yaw);
let d = binned_diameter(class, rock.diameter_m);
let seat = RockSeat {
xz: rock.xz,
up,
lift_m: height_m * (0.5 - rock.bury),
scale_m,
far_m: match scale {
ViewScale::Distance { range_per_m, .. } => range_per_m * d,
ViewScale::Diameter { ring_m, .. } => ring_m,
},
lattices: Lattices::at(snap, levels, rock.xz),
version: snap.version,
patched: levels.is_some(),
};
let surface = seat.lattices.height(levels, centre, rock.xz);
let (translation, rock_scale) = seat_transform(&seat, surface, centre, units);
let [near_mesh, far_mesh] = assets.meshes[rock.shape].clone();
let (near_range, far_range, near_shown, far_shown) = match scale {
ViewScale::Distance { .. } => (
VisibilityRange::abrupt(0.0, NEAR_PER_M * d * units),
VisibilityRange::abrupt(NEAR_PER_M * d * units, seat.far_m * units),
Visibility::Inherited,
Visibility::Inherited,
),
ViewScale::Diameter { .. } => (
VisibilityRange::abrupt(0.0, f32::MAX),
VisibilityRange::abrupt(0.0, f32::MAX),
if scale.near_mesh(d) {
Visibility::Inherited
} else {
Visibility::Hidden
},
if scale.near_mesh(d) {
Visibility::Hidden
} else {
Visibility::Inherited
},
),
};
commands
.spawn((
ScatteredRock {
diameter_m: rock.diameter_m,
height_m,
},
seat,
Transform {
translation,
rotation,
scale: Vec3::splat(rock_scale),
},
Visibility::default(),
))
.with_children(|r| {
r.spawn((
Mesh3d(near_mesh),
MeshMaterial3d(assets.material.clone()),
near_range,
near_shown,
));
r.spawn((
Mesh3d(far_mesh),
MeshMaterial3d(assets.material.clone()),
far_range,
far_shown,
));
})
.id()
}
fn seat_transform(seat: &RockSeat, surface_m: f32, view: Vec3, units: f32) -> (Vec3, f32) {
let ground = Vec3::new(seat.xz.x, surface_m, seat.xz.y);
let t =
((view.distance(ground) - seat.far_m * (1.0 - FADE)) / (seat.far_m * FADE)).clamp(0.0, 1.0);
let fade = (1.0 - t * t * (3.0 - 2.0 * t)).max(1e-3);
(
(ground + seat.up * seat.lift_m * fade) * units,
seat.scale_m * fade * units,
)
}
fn clear(commands: &mut Commands, field: &mut RockField, stats: &mut RockStats) {
for (_, (entity, _)) in field.tiles.drain() {
commands.entity(entity).despawn();
}
*stats = RockStats::default();
}
fn seat_rocks(
view: Res<ElementsView>,
units: Res<ElementsUnits>,
snapshot: Res<EnvironmentSnapshot>,
terrain: Option<Res<TerrainRuntime>>,
mut field: ResMut<RockField>,
mut rocks: Query<(&mut RockSeat, &mut Transform)>,
mut last: Local<Option<(Vec3, Vec3, u32)>>,
) {
let Some(snap) = snapshot.terrain.as_ref() else {
return;
};
let eye = view.eye;
let centre = terrain.map_or(eye, |t| t.morph_centre(eye));
let surface_changed = last.is_none_or(|(_, _, version)| version != snap.version);
let moved = last.is_none_or(|(at, about, _)| {
at.distance(eye) > SEAT_STEP_M || about.distance(centre) > SEAT_STEP_M
});
let levels_changed = std::mem::take(&mut field.levels_changed);
if !moved && !surface_changed && !levels_changed {
return;
}
*last = Some((eye, centre, snap.version));
let levels = field.levels.as_ref();
let u = units.per_metre();
let tolerance = SEAT_TOLERANCE_M * u;
for (mut seat, mut transform) in &mut rocks {
let stale = seat.version != snap.version
|| seat.patched != levels.is_some()
|| (levels_changed
&& levels.is_some_and(|l| l.level_at(seat.xz).unwrap_or(0) != seat.lattices.level));
if stale {
seat.lattices = Lattices::at(snap, levels, seat.xz);
seat.version = snap.version;
seat.patched = levels.is_some();
}
let surface = seat.lattices.height(levels, centre, seat.xz);
let (translation, scale) = seat_transform(&seat, surface, centre, u);
if transform.translation.distance(translation) > tolerance
|| (transform.scale.x - scale).abs() > tolerance
{
transform.translation = translation;
transform.scale = Vec3::splat(scale);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_hosts_stone_wears_a_tone_per_joint_facet_without_the_plugin() {
use bevy::mesh::VertexAttributeValues;
for shape in [0, 5, 6, 11] {
let tones = stone_facet_tones(shape);
let mut distinct: Vec<Vec3> = Vec::new();
for t in &tones {
if !distinct.iter().any(|d| d.distance(*t) < 1e-4) {
distinct.push(*t);
}
}
assert!(
distinct.len() >= 3,
"shape {shape}: {} tones",
distinct.len()
);
for detail in [StoneDetail::Near, StoneDetail::Far] {
let mesh = stone_mesh(shape, detail);
let Some(VertexAttributeValues::Float32x4(colours)) =
mesh.attribute(Mesh::ATTRIBUTE_COLOR)
else {
panic!("shape {shape}: no vertex colours");
};
let hue = |c: &[f32; 4]| (c[0] - c[2]) / (c[0] + c[1] + c[2]).max(1e-6);
let (lo, hi) = colours.iter().fold((f32::MAX, f32::MIN), |(lo, hi), c| {
(lo.min(hue(c)), hi.max(hue(c)))
});
assert!(
hi - lo > 0.2,
"shape {shape} {detail:?}: one tone ({lo}..{hi})"
);
}
}
assert_eq!(stone_facet_tones(STONE_SHAPES + 3), stone_facet_tones(3));
let mean = stone_palette().iter().sum::<Vec3>() / 4.0;
assert!((mean - Vec3::ONE).abs().max_element() < 2e-3);
}
const U: ElementsUnits = ElementsUnits {
units_per_metre: 1.0,
};
fn orthographic(height_m: f32) -> Projection {
let mut projection = OrthographicProjection {
scaling_mode: bevy::camera::ScalingMode::FixedVertical {
viewport_height: height_m,
},
..OrthographicProjection::default_3d()
};
projection.area = bevy::math::Rect::from_center_size(
Vec2::ZERO,
Vec2::new(height_m * 16.0 / 9.0, height_m),
);
Projection::from(projection)
}
#[test]
fn a_region_keeps_what_is_inside_any_rectangle() {
let a = Rect::new(0.0, 0.0, 10.0, 10.0);
let b = Rect::new(20.0, 0.0, 30.0, 10.0);
let rects = [a, b];
assert!(is_allowed(None, Vec2::new(1e6, -1e6)));
assert!(!is_allowed(Some(&[]), Vec2::ZERO));
assert!(is_allowed(Some(&rects), Vec2::new(5.0, 5.0)));
assert!(is_allowed(Some(&rects), Vec2::new(25.0, 5.0)));
assert!(!is_allowed(Some(&rects), Vec2::new(15.0, 5.0)));
assert!(!is_allowed(Some(&rects), Vec2::new(5.0, 15.0)));
}
#[test]
fn a_perspective_view_draws_a_rock_at_the_same_range_as_before() {
let scale = view_scale(None, 100.0, 0.0, &U);
assert_eq!(scale, ViewScale::Distance { range_per_m: 100.0 });
assert!((scale.ring_radius(CLASSES[0].1) - 50.0).abs() < 1e-4);
assert!((scale.ring_radius(CLASSES[3].1) - 360.0).abs() < 1e-3);
assert!(scale.draws(0.25));
assert!(scale.near_mesh(0.25));
}
#[test]
fn an_orthographic_view_draws_the_same_screen_size_a_perspective_one_would() {
let height_m = 18.0;
let range_per_m = 100.0;
let scale = view_scale(Some(&orthographic(height_m)), range_per_m, 40.0, &U);
let ViewScale::Diameter {
min_diameter_m,
near_diameter_m,
ring_m,
..
} = scale
else {
panic!("an orthographic view must take the diameter rule: {scale:?}");
};
let tan_half = (0.5 * REFERENCE_FOV).tan();
let perspective_share = 1.0 / (2.0 * range_per_m * tan_half);
assert!(
(min_diameter_m / height_m - perspective_share).abs() < 1e-6,
"{min_diameter_m} {height_m} {perspective_share}"
);
assert!((min_diameter_m - 0.1559).abs() < 1e-3, "{min_diameter_m}");
assert!(scale.draws(CLASSES[0].0));
assert!(!scale.draws(0.1));
assert!((near_diameter_m - height_m / (2.0 * NEAR_PER_M * tan_half)).abs() < 1e-5);
assert!(!scale.near_mesh(0.4));
assert!(scale.near_mesh(1.0));
assert!((ring_m - 25.0).abs() < 1e-4, "{ring_m}");
assert!((scale.ring_radius(CLASSES[3].1) - 25.0).abs() < 1e-4);
}
#[test]
fn a_wide_orthographic_view_draws_only_the_stones_that_read() {
let ring = |height_m: f32| {
let scale = view_scale(Some(&orthographic(height_m)), 100.0, 9e3, &U);
(
scale.draws(CLASSES[0].0),
scale.draws(CLASSES[3].1),
scale.ring_radius(CLASSES[3].1),
)
};
assert_eq!(ring(18.0), (true, true, ORTHO_RING_MAX_M));
assert_eq!(ring(28.0), (true, true, ORTHO_RING_MAX_M));
assert_eq!(ring(30.0), (false, true, ORTHO_RING_MAX_M));
assert_eq!(ring(200.0), (false, true, ORTHO_RING_MAX_M));
assert_eq!(ring(2000.0), (false, false, 0.0));
assert_eq!(ring(4000.0), (false, false, 0.0));
}
#[test]
fn a_world_in_feet_sizes_the_view_in_metres() {
let feet = ElementsUnits {
units_per_metre: 3.280_84,
};
let metres = view_scale(Some(&orthographic(18.0)), 100.0, 40.0, &U);
let in_feet = view_scale(
Some(&orthographic(18.0 * 3.280_84)),
100.0,
40.0,
&feet,
);
assert_eq!(metres, in_feet);
}
#[test]
fn a_rock_fades_about_the_focus_not_the_eye() {
let centre = Vec3::new(45.0, 12.0, 52.0);
let scale = view_scale(Some(&orthographic(18.0)), 100.0, 40.0, &U);
let seat = |xz: Vec2, far_m: f32| RockSeat {
xz,
up: Vec3::Y,
lift_m: 0.0,
scale_m: 1.0,
far_m,
lattices: Lattices {
level: 0,
fine: 12.0,
coarse: 12.0,
},
version: 0,
patched: false,
};
let ViewScale::Diameter { ring_m, .. } = scale else {
panic!("an orthographic view must take the diameter rule");
};
let (_, at_focus) = seat_transform(&seat(centre.xz(), ring_m), 12.0, centre, 1.0);
let (_, at_edge) = seat_transform(
&seat(centre.xz() + Vec2::new(ring_m, 0.0), ring_m),
12.0,
centre,
1.0,
);
assert!((at_focus - 1.0).abs() < 1e-4, "{at_focus}");
assert!(at_edge < 2e-3, "{at_edge}");
let mut last = 0.0;
for step in 0..=10 {
let d = ring_m * 0.5 * step as f32 / 10.0;
let (_, s) = seat_transform(
&seat(centre.xz() + Vec2::new(d, 0.0), ring_m),
12.0,
centre,
1.0,
);
assert!(s >= last - 1e-6, "{s} {last}");
last = s;
}
}
}