Skip to main content

he3d_physics/
world.rs

1use alloc::vec::Vec;
2
3use gp3d::{BodyDesc, BodyHandle, Motion};
4use he3d_core::{Mesh, ObjectId, Scene, Transform};
5use he3d_math::Vec3;
6
7use crate::StepResult;
8
9/// Default gravity, matching the reference engine's `(0, -9.8, 0)`.
10/// 默认重力,与参考引擎的 `(0, -9.8, 0)` 一致。
11const DEFAULT_GRAVITY: Vec3 = Vec3::new(0.0, -9.8, 0.0);
12
13/// A physics world mapping render objects by stable [`ObjectId`] onto the
14/// standalone [`gp3d`] engine's generational body handles.
15/// 一个通过稳定的 [`ObjectId`] 将渲染对象映射到独立 [`gp3d`] 引擎代际刚体句柄上的物理世界。
16///
17/// Bodies and colliders are owned by gp3d.
18/// 刚体和碰撞体由 gp3d 持有。
19/// After stepping, call [`PhysicsWorld::synchronize`] to write body transforms back into the scene so the renderer sees the physics state.
20/// 步进后,调用 [`PhysicsWorld::synchronize`] 将刚体变换写回场景,使渲染器看到物理状态。
21///
22/// gp3d and `he3d-math` both wrap `glam`, so `Vec3`/`Quat` interoperate directly.
23/// gp3d 与 `he3d-math` 都包装 `glam`,因此 `Vec3`/`Quat` 可直接互操作。
24pub struct PhysicsWorld {
25   scene: gp3d::PhysicsScene,
26   bodies: Vec<(ObjectId, BodyHandle)>,
27}
28
29impl PhysicsWorld {
30   /// Create an empty world with the reference default gravity `(0, -9.8, 0)`.
31   /// 创建一个空世界,重力为参考默认值 `(0, -9.8, 0)`。
32   ///
33   /// # Examples
34   /// 示例
35   /// ```
36   /// use he3d_core::{Material, Mesh, RenderObject, Rgb, Scene, Transform};
37   /// use he3d_math::Vec3;
38   /// use he3d_physics::PhysicsWorld;
39   ///
40   /// let mut scene = Scene::new();
41   /// let mesh = scene.meshes.insert(Mesh::cube(1.0, 1.0, 1.0)?);
42   /// let cube = scene.spawn(RenderObject {
43   ///    mesh,
44   ///    texture: None,
45   ///    transform: Transform::from_position(Vec3::new(0.0, 3.0, 0.0)),
46   ///    material: Material::new(Rgb::WHITE),
47   ///    visible: true,
48   /// });
49   ///
50   /// let mut world = PhysicsWorld::new();
51   /// world.add_dynamic_box(cube, Vec3::splat(0.5), Transform::from_position(Vec3::new(0.0, 3.0, 0.0)));
52   /// for _ in 0..60 {
53   ///    world.step(1.0 / 120.0);
54   /// }
55   /// let y = world.transform(cube).unwrap().position.y;
56   /// assert!(y > 0.0 && y < 3.0, "cube fell part-way, got y={y}");
57   /// # Ok::<(), he3d_core::Error>(())
58   /// ```
59   #[must_use]
60   pub fn new() -> Self {
61      let mut scene = gp3d::PhysicsScene::new();
62      scene.set_gravity(DEFAULT_GRAVITY);
63      Self {
64         scene,
65         bodies: Vec::new(),
66      }
67   }
68
69   /// Override the world gravity vector. 覆盖世界重力向量。
70   pub fn set_gravity(&mut self, gravity: Vec3) {
71      self.scene.set_gravity(gravity);
72   }
73
74   /// The current world gravity. 当前世界重力。
75   #[must_use]
76   pub fn gravity(&self) -> Vec3 {
77      self.scene.gravity()
78   }
79
80   /// Returns `false` if the object already has a body, leaving the world unchanged.
81   /// 若对象已拥有刚体则返回 `false`,世界保持不变。
82   pub fn add_dynamic_box(
83      &mut self,
84      object: ObjectId,
85      half_extents: Vec3,
86      transform: Transform,
87   ) -> bool {
88      self.add_box(object, half_extents, transform, Motion::Dynamic)
89   }
90
91   /// Returns `false` if the object already has a body, leaving the world unchanged.
92   /// 若对象已拥有刚体则返回 `false`,世界保持不变。
93   pub fn add_static_box(
94      &mut self,
95      object: ObjectId,
96      half_extents: Vec3,
97      transform: Transform,
98   ) -> bool {
99      self.add_box(object, half_extents, transform, Motion::Static)
100   }
101
102   /// Returns `false` if the object already has a body, leaving the world unchanged.
103   /// 若对象已拥有刚体则返回 `false`,世界保持不变。
104   pub fn add_kinematic_box(
105      &mut self,
106      object: ObjectId,
107      half_extents: Vec3,
108      transform: Transform,
109   ) -> bool {
110      self.add_box(object, half_extents, transform, Motion::Kinematic)
111   }
112
113   /// Register a body, rejecting a duplicate `ObjectId` (the reference `AddBody`
114   /// also rejects already-registered objects).
115   /// 注册刚体;拒绝重复的 `ObjectId`(参考实现 `AddBody` 同样拒绝已注册对象)。
116   fn add_box(
117      &mut self,
118      object: ObjectId,
119      half_extents: Vec3,
120      transform: Transform,
121      motion: Motion,
122   ) -> bool {
123      if self.handle(object).is_some() {
124         return false;
125      }
126      let mut desc = BodyDesc::default();
127      desc.motion = motion;
128      desc.collider = gp3d::Collider::box_collider(half_extents);
129      desc.position = transform.position;
130      desc.orientation = transform.orientation;
131      match self.scene.add_body(desc) {
132         Ok(handle) => {
133            self.bodies.push((object, handle));
134            true
135         }
136         Err(_) => false,
137      }
138   }
139
140   /// Add a static triangle-mesh collider from a [`Mesh`]. The mesh vertices are
141   /// copied; the reference `MeshCollider` reads live mesh geometry, and gp3d
142   /// stores its own copy, so re-adding after the mesh changes is the norm.
143   /// 从 [`Mesh`] 添加静态三角网格碰撞体。网格顶点会被复制;参考 `MeshCollider`
144   /// 读取实时网格几何,而 gp3d 保存自己的副本,因此网格变化后重新添加是常态。
145   pub fn add_static_mesh(&mut self, object: ObjectId, mesh: &Mesh, transform: Transform) -> bool {
146      if self.handle(object).is_some() {
147         return false;
148      }
149      let vertices = mesh.vertices().to_vec();
150      if vertices.is_empty() || vertices.len() % 3 != 0 {
151         return false;
152      }
153      match self
154         .scene
155         .add_static_mesh(vertices, transform.position, transform.orientation)
156      {
157         Ok(handle) => {
158            self.bodies.push((object, handle));
159            true
160         }
161         Err(_) => false,
162      }
163   }
164
165   /// Teleport a body, keeping its velocity. Returns `false` for unknown objects.
166   /// 瞬移刚体并保持其速度。对未知对象返回 `false`。
167   pub fn set_transform(&mut self, object: ObjectId, transform: Transform) -> bool {
168      match self.handle(object) {
169         Some(handle) => {
170            self
171               .scene
172               .set_transform(handle, transform.position, transform.orientation)
173         }
174         None => false,
175      }
176   }
177
178   /// Read the current body transform. Returns `None` for unknown objects.
179   /// 读取当前刚体变换。对未知对象返回 `None`。
180   #[must_use]
181   pub fn transform(&self, object: ObjectId) -> Option<Transform> {
182      let handle = self.handle(object)?;
183      let position = self.scene.position(handle)?;
184      let orientation = self.scene.orientation(handle)?;
185      Some(Transform::from_position_orientation(position, orientation))
186   }
187
188   /// Override the body's friction and restitution coefficients.
189   /// 覆盖刚体的摩擦系数和恢复系数。
190   pub fn set_material(&mut self, object: ObjectId, friction: f32, restitution: f32) -> bool {
191      match self.handle(object) {
192         Some(handle) => self.scene.set_material(handle, friction, restitution),
193         None => false,
194      }
195   }
196
197   /// Override the body's linear and angular damping. 覆盖刚体的线性和角向阻尼。
198   pub fn set_damping(&mut self, object: ObjectId, linear: f32, angular: f32) -> bool {
199      let Some(handle) = self.handle(object) else {
200         return false;
201      };
202      self.scene.set_linear_damping(handle, linear);
203      self.scene.set_angular_damping(handle, angular);
204      true
205   }
206
207   /// Whether the body is currently touching another collider.
208   /// 刚体当前是否与另一碰撞体接触。
209   #[must_use]
210   pub fn has_contacts(&self, object: ObjectId) -> bool {
211      match self.handle(object) {
212         Some(handle) => self.scene.has_contacts(handle),
213         None => false,
214      }
215   }
216
217   /// Remove a body. Returns `false` for unknown objects.
218   /// 移除一个刚体。对未知对象返回 `false`。
219   pub fn remove_body(&mut self, object: ObjectId) -> bool {
220      let Some(position) = self.bodies.iter().position(|(id, _)| *id == object) else {
221         return false;
222      };
223      let (_, handle) = self.bodies.swap_remove(position);
224      self.scene.remove_body(handle)
225   }
226
227   /// Whether the object currently has a registered body.
228   /// 对象当前是否注册了刚体。
229   #[must_use]
230   pub fn has_body(&self, object: ObjectId) -> bool {
231      self.handle(object).is_some()
232   }
233
234   /// Override the body's linear velocity. 覆盖刚体线速度。
235   pub fn set_velocity(&mut self, object: ObjectId, velocity: Vec3) -> bool {
236      match self.handle(object) {
237         Some(handle) => self.scene.set_velocity(handle, velocity),
238         None => false,
239      }
240   }
241
242   /// The body's linear velocity, or zero for unknown objects.
243   /// 刚体线速度;未知对象返回零向量。
244   #[must_use]
245   pub fn velocity(&self, object: ObjectId) -> Vec3 {
246      match self.handle(object) {
247         Some(handle) => self.scene.velocity(handle),
248         None => Vec3::ZERO,
249      }
250   }
251
252   /// Override the body's angular velocity. 覆盖刚体角速度。
253   pub fn set_angular_velocity(&mut self, object: ObjectId, velocity: Vec3) -> bool {
254      match self.handle(object) {
255         Some(handle) => self.scene.set_angular_velocity(handle, velocity),
256         None => false,
257      }
258   }
259
260   /// The body's angular velocity, or zero for unknown objects.
261   /// 刚体角速度;未知对象返回零向量。
262   #[must_use]
263   pub fn angular_velocity(&self, object: ObjectId) -> Vec3 {
264      match self.handle(object) {
265         Some(handle) => self.scene.angular_velocity(handle),
266         None => Vec3::ZERO,
267      }
268   }
269
270   /// Apply an instantaneous force for the next step only. 施加仅作用于下一步的瞬时力。
271   pub fn add_force(&mut self, object: ObjectId, force: Vec3) -> bool {
272      match self.handle(object) {
273         Some(handle) => self.scene.add_force(handle, force),
274         None => false,
275      }
276   }
277
278   /// Apply an instantaneous torque for the next step only. 施加仅作用于下一步的瞬时力矩。
279   pub fn add_torque(&mut self, object: ObjectId, torque: Vec3) -> bool {
280      match self.handle(object) {
281         Some(handle) => self.scene.add_torque(handle, torque),
282         None => false,
283      }
284   }
285
286   /// Apply an instantaneous impulse. 施加瞬时冲量。
287   pub fn add_impulse(&mut self, object: ObjectId, impulse: Vec3) -> bool {
288      match self.handle(object) {
289         Some(handle) => self.scene.add_impulse(handle, impulse),
290         None => false,
291      }
292   }
293
294   /// Apply an instantaneous angular impulse. 施加瞬时角冲量。
295   pub fn add_angular_impulse(&mut self, object: ObjectId, impulse: Vec3) -> bool {
296      match self.handle(object) {
297         Some(handle) => self.scene.add_angular_impulse(handle, impulse),
298         None => false,
299      }
300   }
301
302   /// Advance the simulation by one fixed step using the reference default of 4
303   /// solver iterations. Forces and torques applied with [`PhysicsWorld::add_force`]
304   /// / [`PhysicsWorld::add_torque`] act for one step only and are then cleared.
305   /// 以参考默认的 4 次求解器迭代推进模拟。由 [`PhysicsWorld::add_force`] /
306   /// [`PhysicsWorld::add_torque`] 施加的力与力矩只作用一步并被清除。
307   pub fn step(&mut self, delta_time: f32) -> StepResult {
308      self.scene.step(delta_time, 4)
309   }
310
311   /// Write body transforms back into the scene's render objects.
312   /// 将刚体变换写回场景的渲染对象。
313   pub fn synchronize(&mut self, scene: &mut Scene) {
314      for &(object, handle) in &self.bodies {
315         let Some(render_object) = scene.objects.get_mut(object) else {
316            continue;
317         };
318         let Some(position) = self.scene.position(handle) else {
319            continue;
320         };
321         let Some(orientation) = self.scene.orientation(handle) else {
322            continue;
323         };
324         render_object.transform.position = position;
325         render_object.transform.orientation = orientation;
326      }
327   }
328
329   fn handle(&self, object: ObjectId) -> Option<BodyHandle> {
330      self
331         .bodies
332         .iter()
333         .find(|(id, _)| *id == object)
334         .map(|(_, handle)| *handle)
335   }
336}
337
338impl Default for PhysicsWorld {
339   fn default() -> Self {
340      Self::new()
341   }
342}
343
344#[cfg(test)]
345mod tests {
346   use alloc::vec;
347
348   use super::*;
349   use he3d_core::{ColorRgba8, Material, Mesh, RenderObject, Rgb, Texture};
350   use he3d_math::Quat;
351
352   const DT: f32 = 1.0 / 60.0;
353
354   fn spawn_box(scene: &mut Scene, position: Vec3) -> ObjectId {
355      let mesh = scene
356         .meshes
357         .insert(Mesh::cube(1.0, 1.0, 1.0).expect("cube"));
358      let solid = scene.textures.insert(Texture::solid(ColorRgba8::WHITE));
359      scene.spawn(RenderObject {
360         mesh,
361         texture: Some(solid),
362         transform: Transform::from_position(position),
363         material: Material::new(Rgb::WHITE),
364         visible: true,
365      })
366   }
367
368   /// A flat quad mesh floor with a render object; use with `add_static_mesh`.
369   /// 一个带渲染对象的平坦四边形网格地面;配合 `add_static_mesh` 使用。
370   fn spawn_quad_floor(scene: &mut Scene, half: f32) -> (ObjectId, Mesh) {
371      let h = half;
372      let quad = Mesh::from_vertices(vec![
373         Vec3::new(-h, 0.0, -h),
374         Vec3::new(h, 0.0, h),
375         Vec3::new(h, 0.0, -h),
376         Vec3::new(-h, 0.0, -h),
377         Vec3::new(-h, 0.0, h),
378         Vec3::new(h, 0.0, h),
379      ])
380      .expect("quad");
381      let mesh_id = scene.meshes.insert(quad.clone());
382      let solid = scene
383         .textures
384         .insert(Texture::solid(ColorRgba8::from_rgb(77, 179, 77)));
385      let object = scene.spawn(RenderObject {
386         mesh: mesh_id,
387         texture: Some(solid),
388         transform: Transform::default(),
389         material: Material::new(Rgb::WHITE),
390         visible: true,
391      });
392      (object, quad)
393   }
394
395   fn run(world: &mut PhysicsWorld, steps: usize) {
396      for _ in 0..steps {
397         assert_eq!(world.step(DT), StepResult::Completed);
398      }
399   }
400
401   #[test]
402   fn registration_and_removal() {
403      let mut scene = Scene::new();
404      let cube = spawn_box(&mut scene, Vec3::ZERO);
405      let mut world = PhysicsWorld::new();
406
407      assert!(world.add_dynamic_box(cube, Vec3::splat(0.5), Transform::default()));
408      assert!(world.has_body(cube));
409      // Duplicate registration is rejected. 重复注册被拒绝。
410      assert!(!world.add_dynamic_box(cube, Vec3::splat(0.5), Transform::default()));
411      assert!(!world.add_static_box(cube, Vec3::splat(0.5), Transform::default()));
412
413      assert!(world.remove_body(cube));
414      assert!(!world.has_body(cube));
415      assert!(!world.remove_body(cube));
416      assert!(!world.add_force(cube, Vec3::X));
417   }
418
419   #[test]
420   fn transform_and_velocity_passthrough() {
421      let mut scene = Scene::new();
422      let cube = spawn_box(&mut scene, Vec3::new(0.0, 1.0, 0.0));
423      let mut world = PhysicsWorld::new();
424      world.add_dynamic_box(
425         cube,
426         Vec3::splat(0.5),
427         Transform::from_position(Vec3::new(0.0, 1.0, 0.0)),
428      );
429
430      assert!(world.set_velocity(cube, Vec3::new(2.0, 0.0, 0.0)));
431      assert_eq!(world.velocity(cube), Vec3::new(2.0, 0.0, 0.0));
432
433      // Teleport keeps velocity. 瞬移保持速度。
434      let teleport =
435         Transform::from_position_orientation(Vec3::new(3.0, 4.0, 5.0), Quat::from_rotation_y(1.0));
436      assert!(world.set_transform(cube, teleport));
437      assert_eq!(world.transform(cube), Some(teleport));
438      assert_eq!(world.velocity(cube), Vec3::new(2.0, 0.0, 0.0));
439
440      assert!(world.set_angular_velocity(cube, Vec3::new(0.0, 0.0, 1.0)));
441      assert_eq!(world.angular_velocity(cube), Vec3::new(0.0, 0.0, 1.0));
442
443      // Unknown objects are no-ops. 未知对象为无操作。
444      let stale = spawn_box(&mut scene, Vec3::ZERO);
445      let _ = scene.objects.remove(stale);
446      assert!(!world.set_transform(stale, Transform::default()));
447      assert_eq!(world.transform(stale), None);
448   }
449
450   #[test]
451   fn gravity_pulls_dynamic_body_down() {
452      let mut scene = Scene::new();
453      let cube = spawn_box(&mut scene, Vec3::new(0.0, 10.0, 0.0));
454      let mut world = PhysicsWorld::new();
455      world.add_dynamic_box(
456         cube,
457         Vec3::splat(0.5),
458         Transform::from_position(Vec3::new(0.0, 10.0, 0.0)),
459      );
460      run(&mut world, 60);
461      world.synchronize(&mut scene);
462      let y = scene.objects.get(cube).unwrap().transform.position.y;
463      assert!(y < 9.0, "body should have fallen, got y={y}");
464      assert!(y > 0.0, "body should not have fallen through, got y={y}");
465   }
466
467   #[test]
468   fn cube_rests_on_static_mesh_ground() {
469      let mut scene = Scene::new();
470      let cube = spawn_box(&mut scene, Vec3::new(0.0, 5.0, 0.0));
471      let (floor, floor_mesh) = spawn_quad_floor(&mut scene, 3.0);
472      let mut world = PhysicsWorld::new();
473      world.add_dynamic_box(
474         cube,
475         Vec3::splat(0.5),
476         Transform::from_position(Vec3::new(0.0, 5.0, 0.0)),
477      );
478      assert!(world.add_static_mesh(
479         floor,
480         &floor_mesh,
481         Transform::from_position(Vec3::new(0.0, -0.4, 0.0)),
482      ));
483
484      run(&mut world, 300);
485      world.synchronize(&mut scene);
486      let y = scene.objects.get(cube).unwrap().transform.position.y;
487      assert!(
488         (y - 0.1).abs() < 0.05,
489         "cube should rest near y=0.1, got y={y}"
490      );
491   }
492
493   #[test]
494   fn restitution_bounces_on_fast_impact() {
495      let mut scene = Scene::new();
496      let cube = spawn_box(&mut scene, Vec3::new(0.0, 5.0, 0.0));
497      let (floor, floor_mesh) = spawn_quad_floor(&mut scene, 6.0);
498      let mut world = PhysicsWorld::new();
499      world.add_dynamic_box(
500         cube,
501         Vec3::splat(0.5),
502         Transform::from_position(Vec3::new(0.0, 5.0, 0.0)),
503      );
504      world.set_material(cube, 0.5, 0.5);
505      world.add_static_mesh(
506         floor,
507         &floor_mesh,
508         Transform::from_position(Vec3::new(0.0, -0.4, 0.0)),
509      );
510
511      let mut max_vy = 0.0f32;
512      for _ in 0..120 {
513         world.step(DT);
514         max_vy = max_vy.max(world.velocity(cube).y);
515      }
516      assert!(max_vy > 0.5, "fast impact must bounce, got max_vy={max_vy}");
517   }
518
519   #[test]
520   fn force_acts_for_one_step_then_is_cleared() {
521      let mut scene = Scene::new();
522      let cube = spawn_box(&mut scene, Vec3::ZERO);
523      let mut world = PhysicsWorld::new();
524      world.set_gravity(Vec3::ZERO);
525      world.add_dynamic_box(cube, Vec3::splat(0.5), Transform::default());
526
527      assert!(world.add_force(cube, Vec3::new(60.0, 0.0, 0.0)));
528      assert_eq!(world.step(DT), StepResult::Completed);
529      let after_force = world.velocity(cube).x;
530      assert!(after_force > 0.9, "got {after_force}");
531
532      assert_eq!(world.step(DT), StepResult::Completed);
533      let after_idle = world.velocity(cube).x;
534      assert!(after_idle < after_force);
535      assert!(after_idle > after_force * 0.99);
536   }
537
538   #[test]
539   fn damping_decays_velocity() {
540      let mut scene = Scene::new();
541      let cube = spawn_box(&mut scene, Vec3::ZERO);
542      let mut world = PhysicsWorld::new();
543      world.set_gravity(Vec3::ZERO);
544      world.add_dynamic_box(cube, Vec3::splat(0.5), Transform::default());
545      assert!(world.set_damping(cube, 0.5, 0.5));
546      assert!(world.set_velocity(cube, Vec3::new(10.0, 0.0, 0.0)));
547      assert_eq!(world.step(DT), StepResult::Completed);
548      let vx = world.velocity(cube).x;
549      assert!(vx > 9.0 && vx < 10.0, "got {vx}");
550   }
551
552   #[test]
553   fn contacts_detect_overlap_and_separation() {
554      let mut scene = Scene::new();
555      let cube = spawn_box(&mut scene, Vec3::ZERO);
556      let other = spawn_box(&mut scene, Vec3::ZERO);
557      let mut world = PhysicsWorld::new();
558      world.add_dynamic_box(cube, Vec3::splat(0.5), Transform::default());
559      world.add_dynamic_box(other, Vec3::splat(0.5), Transform::default());
560
561      assert!(world.has_contacts(cube));
562      assert!(world.has_contacts(other));
563
564      assert!(world.set_transform(other, Transform::from_position(Vec3::new(10.0, 0.0, 0.0)),));
565      assert!(!world.has_contacts(cube));
566   }
567
568   #[test]
569   // A kinematic body ignores gravity, so `position.y` stays exactly zero by
570   // construction; the exact equality is intentional.
571   // 运动学刚体忽略重力,因此 `position.y` 在构造上严格保持为零;此精确相等是有意为之。
572   #[expect(clippy::float_cmp)]
573   fn kinematic_body_moves_and_ignores_gravity() {
574      let mut scene = Scene::new();
575      let mover = spawn_box(&mut scene, Vec3::ZERO);
576      let mut world = PhysicsWorld::new();
577      world.add_kinematic_box(mover, Vec3::splat(0.5), Transform::default());
578      assert!(world.set_velocity(mover, Vec3::new(1.0, 0.0, 0.0)));
579      run(&mut world, 10);
580      let position = world.transform(mover).unwrap().position;
581      assert!(
582         (position.x - 10.0 * DT).abs() < 1e-4,
583         "got x={}",
584         position.x
585      );
586      assert_eq!(position.y, 0.0);
587   }
588
589   #[test]
590   fn step_rejects_invalid_input() {
591      let mut world = PhysicsWorld::new();
592      assert_eq!(world.step(0.0), StepResult::InvalidInput);
593      assert_eq!(world.step(-1.0), StepResult::InvalidInput);
594      assert_eq!(world.step(f32::NAN), StepResult::InvalidInput);
595   }
596
597   #[test]
598   fn synchronize_writes_orientation_and_position() {
599      let mut scene = Scene::new();
600      let cube = spawn_box(&mut scene, Vec3::new(0.0, 2.0, 0.0));
601      let mut world = PhysicsWorld::new();
602      world.add_dynamic_box(
603         cube,
604         Vec3::splat(0.5),
605         Transform::from_position(Vec3::new(0.0, 2.0, 0.0)),
606      );
607      assert!(world.set_angular_velocity(cube, Vec3::new(0.0, 0.0, 2.0)));
608      run(&mut world, 30);
609      world.synchronize(&mut scene);
610      let orientation = scene.objects.get(cube).unwrap().transform.orientation;
611      assert_ne!(orientation, Quat::IDENTITY, "cube should have rotated");
612      assert!(orientation.is_finite());
613   }
614
615   #[test]
616   fn add_static_mesh_registers_and_rejects_duplicates() {
617      let mut scene = Scene::new();
618      let cube = spawn_box(&mut scene, Vec3::new(0.0, 5.0, 0.0));
619      let (floor, floor_mesh) = spawn_quad_floor(&mut scene, 3.0);
620      let mut world = PhysicsWorld::new();
621      assert!(world.add_static_mesh(
622         floor,
623         &floor_mesh,
624         Transform::from_position(Vec3::new(0.0, -0.4, 0.0)),
625      ));
626      assert!(world.has_body(floor));
627      // Duplicate registration for the same object is rejected.
628      // 同一对象的重复注册被拒绝。
629      assert!(!world.add_static_mesh(
630         floor,
631         &floor_mesh,
632         Transform::from_position(Vec3::new(0.0, -0.4, 0.0)),
633      ));
634      assert!(world.add_dynamic_box(
635         cube,
636         Vec3::splat(0.5),
637         Transform::from_position(Vec3::new(0.0, 5.0, 0.0)),
638      ));
639      run(&mut world, 300);
640      world.synchronize(&mut scene);
641      let y = scene.objects.get(cube).unwrap().transform.position.y;
642      assert!(
643         (y - 0.1).abs() < 0.05,
644         "cube should rest near y=0.1, got y={y}"
645      );
646   }
647}