bevy_box3d 0.2.0

Bevy integration for Box3D.
use bevy::{
    diagnostic::{DiagnosticsStore, EntityCountDiagnosticsPlugin, FrameTimeDiagnosticsPlugin},
    math::primitives::{Cuboid, Sphere},
    prelude::*,
};
use bevy_box3d::{
    Box3dConfig, Box3dDebugPlugin, Box3dPlugin, Box3dStats, Collider, Damping, RigidBody, Velocity,
};
use box3d::SurfaceMaterial;

const HALF_EXTENTS: Vec3 = Vec3::new(0.5, 0.5, 0.5);
const PHYSICS_TICK_RATE: f32 = 60.0;
const PHYSICS_SUB_STEPS: i32 = 4;
const BALL_RADIUS: f32 = 0.3;
const BALL_DENSITY: f32 = 8.0;
const BALL_SPEED: f32 = 24.0;
const BALL_FRICTION: f32 = 0.9;
const BALL_RESTITUTION: f32 = 0.45;
const BALL_ROLLING_RESISTANCE: f32 = 0.08;
const BOX_FRICTION: f32 = 1.1;
const GROUND_FRICTION: f32 = 1.4;

#[derive(Component)]
struct StatsText;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "box3d Bevy stack".to_owned(),
                resolution: (1280, 720).into(),
                ..default()
            }),
            ..default()
        }))
        .add_plugins((
            FrameTimeDiagnosticsPlugin::default(),
            EntityCountDiagnosticsPlugin::default(),
            Box3dPlugin {
                config: Box3dConfig {
                    fixed_hz: PHYSICS_TICK_RATE as f64,
                    sub_steps: PHYSICS_SUB_STEPS,
                    continuous_enabled: false,
                    ..default()
                },
            },
            Box3dDebugPlugin::default(),
        ))
        .add_systems(Startup, setup)
        .add_systems(PreUpdate, throw_ball)
        .add_systems(Update, update_stats)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(8.0, 8.0, 12.0).looking_at(Vec3::new(0.0, 4.0, 0.0), Vec3::Y),
    ));
    commands.spawn((
        PointLight {
            intensity: 5_000.0,
            shadow_maps_enabled: true,
            ..default()
        },
        Transform::from_xyz(4.0, 10.0, 6.0),
    ));

    commands.spawn((
        RigidBody::Static,
        Collider::cuboid(Vec3::new(8.0, 0.5, 8.0)).with_surface_material(SurfaceMaterial {
            friction: GROUND_FRICTION,
            restitution: 0.0,
            ..default()
        }),
        Mesh3d(meshes.add(Cuboid::new(16.0, 1.0, 16.0))),
        MeshMaterial3d(materials.add(Color::srgb(0.22, 0.24, 0.26))),
        Transform::from_xyz(0.0, -0.5, 0.0),
    ));

    let cube_mesh = meshes.add(Cuboid::from_length(1.0));
    let cube_material = materials.add(Color::srgb(0.2, 0.55, 0.95));

    for row in 0..10 {
        let y = 0.5 + row as f32 * 1.05;
        let x_offset = if row % 2 == 0 { -0.25 } else { 0.25 };
        for col in 0..4 {
            let x = (col as f32 - 1.5) * 1.05 + x_offset;
            let z = (row as f32 * 0.17).sin() * 0.2;

            commands.spawn((
                RigidBody::Dynamic,
                Collider::cuboid(HALF_EXTENTS)
                    .with_density(1.0)
                    .with_surface_material(SurfaceMaterial {
                        friction: BOX_FRICTION,
                        restitution: 0.0,
                        ..default()
                    }),
                Mesh3d(cube_mesh.clone()),
                MeshMaterial3d(cube_material.clone()),
                Transform::from_xyz(x, y, z),
            ));
        }
    }

    commands.spawn((
        Text::new("stats"),
        TextFont {
            font_size: FontSize::Px(16.0),
            ..default()
        },
        TextColor(Color::WHITE),
        Node {
            position_type: PositionType::Absolute,
            top: Val::Px(12.0),
            left: Val::Px(12.0),
            ..default()
        },
        StatsText,
    ));
}

fn throw_ball(
    mouse: Res<ButtonInput<MouseButton>>,
    window: Single<&Window>,
    camera: Single<(&Camera, &GlobalTransform)>,
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    if !mouse.just_pressed(MouseButton::Left) {
        return;
    }

    let Some(cursor_position) = window.cursor_position() else {
        return;
    };

    let (camera, camera_transform) = *camera;
    let Ok(ray) = camera.viewport_to_world(camera_transform, cursor_position) else {
        return;
    };

    let start = ray.origin + *ray.direction * 1.5;
    let direction = *ray.direction;

    commands.spawn((
        RigidBody::Dynamic,
        Collider::sphere(BALL_RADIUS)
            .with_density(BALL_DENSITY)
            .with_surface_material(SurfaceMaterial {
                friction: BALL_FRICTION,
                restitution: BALL_RESTITUTION,
                rolling_resistance: BALL_ROLLING_RESISTANCE,
                ..default()
            }),
        Velocity::linear(direction * BALL_SPEED),
        Damping {
            linear: 0.08,
            angular: 0.06,
        },
        Mesh3d(meshes.add(Sphere::new(BALL_RADIUS))),
        MeshMaterial3d(materials.add(Color::srgb(0.95, 0.25, 0.18))),
        Transform::from_translation(start),
    ));
}

fn update_stats(
    diagnostics: Res<DiagnosticsStore>,
    physics: Res<Box3dStats>,
    mut text: Single<&mut Text, With<StatsText>>,
) {
    let fps = diagnostic_average(&diagnostics, &FrameTimeDiagnosticsPlugin::FPS);
    let frame_ms = diagnostic_average(&diagnostics, &FrameTimeDiagnosticsPlugin::FRAME_TIME);
    let entities = diagnostic_average(&diagnostics, &EntityCountDiagnosticsPlugin::ENTITY_COUNT);

    text.0 = format!(
        "fps: {fps:.0}\nrender frame: {frame_ms:.2} ms\nphysics: {:.2} ms / {} steps\nfixed tick: {PHYSICS_TICK_RATE:.0} Hz x {PHYSICS_SUB_STEPS} substeps\ninterpolation: {:.2}\nphysics bodies: {}\nentities: {entities:.0}",
        physics.step_ms,
        physics.step_count,
        physics.interpolation_alpha,
        physics.body_count
    );
}

fn diagnostic_average(
    diagnostics: &DiagnosticsStore,
    path: &bevy::diagnostic::DiagnosticPath,
) -> f64 {
    diagnostics
        .get(path)
        .and_then(|diagnostic| diagnostic.average())
        .unwrap_or(0.0)
}