diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 57cf261d3..7c1831038 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,8 @@ # Renzora Engine `r1-alpha8` +## 2026-10-07 21:03 +- fix(physics): reopening a scene no longer resizes the colliders of rotated objects + ## 2026-09-18 16:27 - fix(game-ui): an `expand` canvas previewed over the viewport lays out like the shipped game, so edge-anchored UI reaches the edge - fix(game-ui): a canvas root no longer keeps a preview scale that was saved into a scene, which shrank the UI in an exported game diff --git a/crates/renzora_physics/src/auto_fit.rs b/crates/renzora_physics/src/auto_fit.rs index a7873f85c..9e4dde5dc 100644 --- a/crates/renzora_physics/src/auto_fit.rs +++ b/crates/renzora_physics/src/auto_fit.rs @@ -54,31 +54,15 @@ pub fn auto_fit_collision_shapes( commands.entity(entity).remove::(); continue; } - let Some((min, max)) = combined_world_aabb(entity, &aabbs, &children) else { - // Mesh not ready yet — try again next frame. + // Bounds are taken in the collider's own local space, corner by corner. + // Fitting a world-axis box first and mapping that back inflated every + // rotated collider (a 15 degree Y platform grew to 1.4x1.6 of its mesh). + let inv = entity_gt.affine().inverse(); + let Some((local_min, local_max)) = combined_local_aabb(entity, inv, &aabbs, &children) else { + // Mesh not ready yet: try again next frame. continue; }; - let inv = entity_gt.affine().inverse(); - let corners = [ - Vec3::new(min.x, min.y, min.z), - Vec3::new(max.x, min.y, min.z), - Vec3::new(min.x, max.y, min.z), - Vec3::new(max.x, max.y, min.z), - Vec3::new(min.x, min.y, max.z), - Vec3::new(max.x, min.y, max.z), - Vec3::new(min.x, max.y, max.z), - Vec3::new(max.x, max.y, max.z), - ]; - - let mut local_min = Vec3::splat(f32::INFINITY); - let mut local_max = Vec3::splat(f32::NEG_INFINITY); - for c in corners { - let lp = inv.transform_point3(c); - local_min = local_min.min(lp); - local_max = local_max.max(lp); - } - let half = (local_max - local_min) * 0.5; let center = (local_max + local_min) * 0.5; @@ -104,13 +88,26 @@ pub fn auto_fit_collision_shapes( CollisionShapeType::Mesh => unreachable!(), } - commands.entity(entity).insert(new_shape); + // A default-valued shape on a unit mesh already fits; rewriting it only + // injects float noise (0.5 -> 0.49999997) and dirties a loaded scene. + if !shape_close(shape, &new_shape) { + commands.entity(entity).insert(new_shape); + } commands.entity(entity).remove::(); } } -fn combined_world_aabb( +fn shape_close(a: &CollisionShapeData, b: &CollisionShapeData) -> bool { + const EPS: f32 = 1e-4; + a.offset.abs_diff_eq(b.offset, EPS) + && a.half_extents.abs_diff_eq(b.half_extents, EPS) + && (a.radius - b.radius).abs() <= EPS + && (a.half_height - b.half_height).abs() <= EPS +} + +fn combined_local_aabb( root: Entity, + to_local: bevy::math::Affine3A, aabbs: &Query<(&Aabb, &GlobalTransform)>, children: &Query<&Children>, ) -> Option<(Vec3, Vec3)> { @@ -127,9 +124,9 @@ fn combined_world_aabb( for sy in [-1.0, 1.0] { for sz in [-1.0, 1.0] { let local = center + half * Vec3::new(sx, sy, sz); - let world = gt.transform_point(local); - min = min.min(world); - max = max.max(world); + let root_local = to_local.transform_point3(gt.transform_point(local)); + min = min.min(root_local); + max = max.max(root_local); } } } diff --git a/crates/renzora_physics/tests/auto_fit_rotated.rs b/crates/renzora_physics/tests/auto_fit_rotated.rs new file mode 100644 index 000000000..c2519fd0f --- /dev/null +++ b/crates/renzora_physics/tests/auto_fit_rotated.rs @@ -0,0 +1,44 @@ +//! Auto-fit must be rotation-invariant. A unit cube mesh under a rotated, +//! non-uniformly scaled entity already fits the default 0.5 box exactly; the old +//! fit took a world-axis AABB and mapped it back, which grew a 15 degree Y +//! platform's collider to roughly 0.70 x 0.5 x 0.81 every time the scene loaded. + +use bevy::camera::primitives::Aabb; +use bevy::math::Vec3A; +use bevy::prelude::*; +use renzora_physics::auto_fit::{auto_fit_collision_shapes, mark_new_collision_shapes}; +use renzora_physics::CollisionShapeData; + +#[test] +fn rotated_unit_cube_keeps_default_extents() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_systems(Update, (mark_new_collision_shapes, auto_fit_collision_shapes).chain()); + + let tf = Transform { + translation: Vec3::new(7.0, 1.0, 0.0), + rotation: Quat::from_rotation_y(15f32.to_radians()), + scale: Vec3::new(5.0, 0.5, 4.0), + }; + let e = app + .world_mut() + .spawn(( + tf, + GlobalTransform::from(tf), + Aabb { center: Vec3A::ZERO, half_extents: Vec3A::splat(0.5) }, + CollisionShapeData::default(), + )) + .id(); + + for _ in 0..3 { + app.update(); + } + + let shape = app.world().get::(e).unwrap(); + assert!( + shape.half_extents.abs_diff_eq(Vec3::splat(0.5), 1e-4), + "half extents changed to {:?}", + shape.half_extents + ); + assert!(shape.offset.abs_diff_eq(Vec3::ZERO, 1e-4), "offset changed to {:?}", shape.offset); +}