From 815ba6099f12eebb551670380aa27e4e61c97d9e Mon Sep 17 00:00:00 2001 From: David Meza Date: Wed, 7 Oct 2026 21:05:31 -0300 Subject: [PATCH] fix(physics): collider auto-fit no longer inflates rotated shapes on load Auto-fit runs for any default-valued CollisionShapeData, which includes every scene load of a unit-mesh object whose collider was never resized. The fit took a world-axis AABB of the mesh and mapped its corners back into local space, which is only exact without rotation: a 15 degree Y platform scaled (5, 0.5, 4) came back with half extents (0.70, 0.5, 0.81), so after reopening the scene its collision no longer matched its mesh, and the inflated values were then saved. Bounds are now accumulated per corner in the collider's own local space, and a fit that already matches the stored shape is not written, which also stops 0.5 drifting to 0.49999997 on unrotated objects. tests/auto_fit_rotated.rs fails on the old code with exactly the inflated extents above and passes with the fix. Also validated in a running editor: save, close, reopen and save again now produce a scene that is identical apart from session entity ids. Assisted-by: Claude Opus 5.5 (claude-opus-5-5) --- RELEASE_NOTES.md | 3 ++ crates/renzora_physics/src/auto_fit.rs | 51 +++++++++---------- .../renzora_physics/tests/auto_fit_rotated.rs | 44 ++++++++++++++++ 3 files changed, 71 insertions(+), 27 deletions(-) create mode 100644 crates/renzora_physics/tests/auto_fit_rotated.rs 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); +}