diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index 5fcf7d7..430dacc 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -419,7 +419,7 @@ Scene( tags: ["static"], transform: Transform4D( position: Vec4(x: 0.0, y: -2.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( @@ -437,7 +437,7 @@ Scene( tags: ["dynamic"], transform: Transform4D( position: Vec4(x: 0.0, y: 0.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( diff --git a/crates/rust4d_core/src/transform.rs b/crates/rust4d_core/src/transform.rs index 652024f..57b81a3 100644 --- a/crates/rust4d_core/src/transform.rs +++ b/crates/rust4d_core/src/transform.rs @@ -3,39 +3,14 @@ //! A Transform4D represents the position, rotation, and scale of an entity in 4D space. use rust4d_math::{Vec4, Rotor4}; -use serde::{Serialize, Deserialize, Serializer, Deserializer}; - -/// Custom serialization module for Rotor4 -/// -/// Since Rotor4 is defined in rust4d_math and doesn't have Serialize/Deserialize, -/// we serialize it as an array of 8 floats: [s, b_xy, b_xz, b_xw, b_yz, b_yw, b_zw, p] -mod rotor4_serde { - use super::*; - - pub fn serialize(rotor: &Rotor4, serializer: S) -> Result { - let arr = [ - rotor.s, rotor.b_xy, rotor.b_xz, rotor.b_xw, - rotor.b_yz, rotor.b_yw, rotor.b_zw, rotor.p - ]; - arr.serialize(serializer) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - let arr: [f32; 8] = <[f32; 8]>::deserialize(deserializer)?; - Ok(Rotor4 { - s: arr[0], b_xy: arr[1], b_xz: arr[2], b_xw: arr[3], - b_yz: arr[4], b_yw: arr[5], b_zw: arr[6], p: arr[7], - }) - } -} +use serde::{Serialize, Deserialize}; /// A 4D transform with position, rotation, and uniform scale #[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct Transform4D { /// Position in 4D space pub position: Vec4, - /// Rotation as a 4D rotor (serialized as 8-element array) - #[serde(with = "rotor4_serde")] + /// Rotation as a 4D rotor pub rotation: Rotor4, /// Uniform scale factor pub scale: f32, diff --git a/crates/rust4d_math/src/rotor4.rs b/crates/rust4d_math/src/rotor4.rs index bfa91d4..6f246fb 100644 --- a/crates/rust4d_math/src/rotor4.rs +++ b/crates/rust4d_math/src/rotor4.rs @@ -9,10 +9,11 @@ //! - 1 pseudoscalar (4-vector) use bytemuck::{Pod, Zeroable}; +use serde::{Serialize, Deserialize}; use crate::Vec4; /// The 6 rotation planes in 4D space -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RotationPlane { /// XY plane - standard yaw (rotation around Z axis in 3D) XY, @@ -33,7 +34,7 @@ pub enum RotationPlane { /// Rotor = scalar + bivectors + pseudoscalar /// R = s + b_xy*e12 + b_xz*e13 + b_xw*e14 + b_yz*e23 + b_yw*e24 + b_zw*e34 + p*e1234 #[repr(C)] -#[derive(Clone, Copy, Debug, Pod, Zeroable)] +#[derive(Clone, Copy, Debug, Pod, Zeroable, Serialize, Deserialize)] pub struct Rotor4 { /// Scalar component pub s: f32, diff --git a/scenes/default.ron b/scenes/default.ron index a77573f..d1c4522 100644 --- a/scenes/default.ron +++ b/scenes/default.ron @@ -9,7 +9,7 @@ Scene( // Entity transform positions the floor at y=-2.0 in world space // (Hyperplane visual mesh is created at y=0 in local space) position: Vec4(x: 0.0, y: -2.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( @@ -28,7 +28,7 @@ Scene( tags: ["dynamic"], transform: Transform4D( position: Vec4(x: 0.0, y: 0.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( diff --git a/scenes/test_chamber.ron b/scenes/test_chamber.ron index dbbd652..f68ab93 100644 --- a/scenes/test_chamber.ron +++ b/scenes/test_chamber.ron @@ -7,7 +7,7 @@ Scene( tags: ["static"], transform: Transform4D( position: Vec4(x: 0.0, y: 0.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( @@ -25,7 +25,7 @@ Scene( tags: ["dynamic"], transform: Transform4D( position: Vec4(x: -3.0, y: 3.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( @@ -39,7 +39,7 @@ Scene( tags: ["dynamic"], transform: Transform4D( position: Vec4(x: 3.0, y: 3.0, z: 0.0, w: 0.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( @@ -53,7 +53,7 @@ Scene( tags: ["dynamic"], transform: Transform4D( position: Vec4(x: 0.0, y: 5.0, z: 0.0, w: 2.0), - rotation: (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + rotation: (s: 1.0, b_xy: 0.0, b_xz: 0.0, b_xw: 0.0, b_yz: 0.0, b_yw: 0.0, b_zw: 0.0, p: 0.0), scale: 1.0, ), shape: ShapeTemplate( -- knotmirror 0.0.1 From e0f8d8f2e1d75231316593d3df3bfa8cb1e937b7 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 14:17:05 +0000 Subject: [PATCH 02/17] Add fixed timestep accumulator to physics PhysicsWorld::update(dt) subdivides variable frame time into fixed-size step() calls using an accumulator pattern. This ensures deterministic physics regardless of frame rate. - Add fixed_dt (default 1/60s) and accumulator to PhysicsWorld - Add PhysicsConfig::fixed_dt with serde default - Add interpolation_alpha() for future render smoothing - World::update() now calls physics.update() instead of step() - Relax simulation.rs dt cap from 33ms to 250ms (accumulator handles it) - 6 new tests for timestep consistency, accumulator, and interpolation Wave 0 - Pre-split foundation Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_core/src/world.rs | 15 ++- crates/rust4d_physics/src/world.rs | 169 +++++++++++++++++++++++++++++ src/config.rs | 6 +- src/systems/simulation.rs | 5 +- 4 files changed, 184 insertions(+), 11 deletions(-) diff --git a/crates/rust4d_core/src/world.rs b/crates/rust4d_core/src/world.rs index 6ac1974..af0fc3d 100644 --- a/crates/rust4d_core/src/world.rs +++ b/crates/rust4d_core/src/world.rs @@ -217,9 +217,9 @@ impl World { /// 2. Syncs entity transforms from their associated physics bodies /// 3. Marks entities as dirty when their transforms change pub fn update(&mut self, dt: f32) { - // Step the physics simulation + // Update the physics simulation (fixed timestep accumulator) if let Some(ref mut physics) = self.physics_world { - physics.step(dt); + physics.update(dt); } // Sync entity transforms from their physics bodies @@ -682,12 +682,17 @@ mod tests { // Verify initial position assert_eq!(world.get_entity(entity_handle).unwrap().transform.position.x, 0.0); - // Step physics (1 second with 10 units/sec velocity = 10 units displacement) - world.update(1.0); + // Simulate 1 second in realistic frame-sized increments + // (physics uses fixed timestep accumulator, large dt is clamped) + for _ in 0..60 { + world.update(1.0 / 60.0); + } // Entity transform should now reflect the physics body position + // ~10 units displacement (10 units/sec * ~1s, slight variance from fixed step rounding) let entity = world.get_entity(entity_handle).unwrap(); - assert!((entity.transform.position.x - 10.0).abs() < 0.001); + assert!((entity.transform.position.x - 10.0).abs() < 0.2, + "Expected ~10.0, got {}", entity.transform.position.x); assert!((entity.transform.position.y - 5.0).abs() < 0.001); } diff --git a/crates/rust4d_physics/src/world.rs b/crates/rust4d_physics/src/world.rs index 1db7896..19eca83 100644 --- a/crates/rust4d_physics/src/world.rs +++ b/crates/rust4d_physics/src/world.rs @@ -15,6 +15,9 @@ pub struct PhysicsConfig { pub gravity: f32, /// Jump velocity for player pub jump_velocity: f32, + /// Fixed timestep for physics simulation (default: 1/60s) + #[serde(default = "PhysicsConfig::default_fixed_dt")] + pub fixed_dt: f32, } impl Default for PhysicsConfig { @@ -22,16 +25,24 @@ impl Default for PhysicsConfig { Self { gravity: -20.0, jump_velocity: 8.0, + fixed_dt: Self::default_fixed_dt(), } } } +impl PhysicsConfig { + fn default_fixed_dt() -> f32 { + 1.0 / 60.0 + } +} + impl PhysicsConfig { /// Create a new physics config with the given gravity pub fn new(gravity: f32) -> Self { Self { gravity, jump_velocity: 8.0, + fixed_dt: Self::default_fixed_dt(), } } @@ -40,6 +51,12 @@ impl PhysicsConfig { self.jump_velocity = jump_velocity; self } + + /// Set the fixed timestep + pub fn with_fixed_dt(mut self, fixed_dt: f32) -> Self { + self.fixed_dt = fixed_dt; + self + } } /// The physics world containing all rigid bodies @@ -54,6 +71,10 @@ pub struct PhysicsWorld { player_body: Option, /// Jump velocity for the player player_jump_velocity: f32, + /// Fixed timestep duration + fixed_dt: f32, + /// Accumulator for fixed timestep + accumulator: f32, } impl PhysicsWorld { @@ -65,12 +86,15 @@ impl PhysicsWorld { /// Create a new physics world with custom configuration pub fn with_config(config: PhysicsConfig) -> Self { let jump_velocity = config.jump_velocity; + let fixed_dt = config.fixed_dt; Self { bodies: SlotMap::with_key(), static_colliders: Vec::new(), config, player_body: None, player_jump_velocity: jump_velocity, + fixed_dt, + accumulator: 0.0, } } @@ -183,6 +207,28 @@ impl PhysicsWorld { false } + /// Update the physics simulation using fixed timestep accumulator + /// + /// Accumulates the frame's `dt` and runs zero or more fixed-size `step()` + /// calls. This ensures deterministic physics regardless of frame rate. + pub fn update(&mut self, dt: f32) { + // Clamp incoming dt to prevent spiral of death + let dt = dt.min(0.25); + self.accumulator += dt; + while self.accumulator >= self.fixed_dt { + self.step(self.fixed_dt); + self.accumulator -= self.fixed_dt; + } + } + + /// Get the interpolation alpha for render smoothing + /// + /// Returns a value in [0, 1) representing how far into the next + /// fixed step we are. Can be used for visual interpolation. + pub fn interpolation_alpha(&self) -> f32 { + self.accumulator / self.fixed_dt + } + /// Step the physics simulation forward by dt seconds /// /// This performs: @@ -1472,4 +1518,127 @@ mod tests { final_y ); } + + // ====== Fixed Timestep Tests ====== + + #[test] + fn test_fixed_timestep_produces_consistent_results() { + // Two worlds: one updated with 1x16ms, one with 2x8ms + // Both should produce nearly identical results + let config = PhysicsConfig::new(-20.0).with_fixed_dt(1.0 / 60.0); + + let mut world_a = PhysicsWorld::with_config(config.clone()); + let body_a = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5); + let key_a = world_a.add_body(body_a); + + let mut world_b = PhysicsWorld::with_config(config); + let body_b = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5); + let key_b = world_b.add_body(body_b); + + // Simulate same total time with different frame rates + // World A: 60 frames at ~16.67ms + for _ in 0..60 { + world_a.update(1.0 / 60.0); + } + // World B: 120 frames at ~8.33ms + for _ in 0..120 { + world_b.update(1.0 / 120.0); + } + + let pos_a = world_a.get_body(key_a).unwrap().position; + let pos_b = world_b.get_body(key_b).unwrap().position; + + // Both ran same number of fixed steps (60), so positions should match closely + let diff = (pos_a.y - pos_b.y).abs(); + assert!( + diff < 0.01, + "Fixed timestep should produce consistent results. A: {}, B: {}, diff: {}", + pos_a.y, pos_b.y, diff + ); + } + + #[test] + fn test_fixed_timestep_accumulator_handles_large_dt() { + let config = PhysicsConfig::new(-20.0).with_fixed_dt(1.0 / 60.0); + let mut world = PhysicsWorld::with_config(config); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5); + let key = world.add_body(body); + + // A single large update should produce multiple sub-steps + // 100ms = ~6 steps at 1/60s each + world.update(0.1); + + let body = world.get_body(key).unwrap(); + // Body should have fallen (gravity applied over multiple sub-steps) + assert!(body.position.y < 10.0, "Body should have fallen"); + assert!(body.velocity.y < 0.0, "Body should have downward velocity"); + } + + #[test] + fn test_fixed_timestep_no_step_when_dt_small() { + let config = PhysicsConfig::new(-20.0).with_fixed_dt(1.0 / 60.0); + let mut world = PhysicsWorld::with_config(config); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5); + let key = world.add_body(body); + + // A very small update should not trigger any step + world.update(0.001); + + let body = world.get_body(key).unwrap(); + // No step occurred, so position unchanged + assert_eq!(body.position.y, 10.0, "No step should occur for tiny dt"); + + // But accumulator should hold the remainder + assert!(world.accumulator > 0.0); + assert!(world.accumulator < world.fixed_dt); + } + + #[test] + fn test_fixed_timestep_accumulator_carries_remainder() { + let config = PhysicsConfig::new(0.0).with_fixed_dt(1.0 / 60.0); // No gravity + let mut world = PhysicsWorld::with_config(config); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 0.0, 0.0, 0.0), 0.5) + .with_velocity(Vec4::new(60.0, 0.0, 0.0, 0.0)); // 60 units/sec + let key = world.add_body(body); + + // Update with 25ms - should do 1 step (16.67ms) with ~8.33ms remainder + world.update(0.025); + + let body = world.get_body(key).unwrap(); + // 1 step at 1/60s with velocity 60 = 1.0 unit moved + assert!((body.position.x - 1.0).abs() < 0.01, + "Should have moved 1 unit after 1 fixed step, got {}", body.position.x); + + // Remainder should be ~8.33ms + assert!(world.accumulator > 0.008 && world.accumulator < 0.009, + "Accumulator should carry remainder: {}", world.accumulator); + } + + #[test] + fn test_interpolation_alpha() { + let config = PhysicsConfig::new(0.0).with_fixed_dt(1.0 / 60.0); + let mut world = PhysicsWorld::with_config(config); + + // No update yet, alpha should be 0 + assert_eq!(world.interpolation_alpha(), 0.0); + + // Update with half a fixed step + world.update(0.5 / 60.0); + let alpha = world.interpolation_alpha(); + assert!((alpha - 0.5).abs() < 0.01, + "Alpha should be ~0.5, got {}", alpha); + } + + #[test] + fn test_existing_step_still_works_directly() { + // step() should still work as before for tests that call it directly + let mut world = PhysicsWorld::with_config(PhysicsConfig::new(-20.0)); + let body = RigidBody4D::new_sphere(Vec4::new(0.0, 10.0, 0.0, 0.0), 0.5); + let key = world.add_body(body); + + world.step(0.1); + + let body = world.get_body(key).unwrap(); + assert!((body.velocity.y - (-2.0)).abs() < 0.0001); + } } diff --git a/src/config.rs b/src/config.rs index bf6ad4e..1f6dec4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -192,10 +192,8 @@ impl Default for PhysicsConfigToml { impl PhysicsConfigToml { /// Convert to the physics engine's PhysicsConfig pub fn to_physics_config(&self) -> PhysicsConfig { - PhysicsConfig { - gravity: self.gravity, - jump_velocity: self.jump_velocity, - } + PhysicsConfig::new(self.gravity) + .with_jump_velocity(self.jump_velocity) } } diff --git a/src/systems/simulation.rs b/src/systems/simulation.rs index f39d426..c0c1dfa 100644 --- a/src/systems/simulation.rs +++ b/src/systems/simulation.rs @@ -57,8 +57,9 @@ impl SimulationSystem { // 1. Calculate delta time let now = Instant::now(); let raw_dt = (now - self.last_frame).as_secs_f32(); - // Cap dt to prevent huge physics steps on first frame or after window focus - let dt = raw_dt.min(1.0 / 30.0); // Max 33ms per frame + // Cap dt to prevent spiral of death on first frame or after window focus + // The physics accumulator further subdivides into fixed timesteps + let dt = raw_dt.min(0.25); self.last_frame = now; // 2. Get movement input from controller -- knotmirror 0.0.1 From ba86ecea85d019490731eb3d31ee25316a1d7795 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 14:17:37 +0000 Subject: [PATCH 03/17] Normalize diagonal movement to prevent speed boost Multi-axis input in 4D produced faster movement: 2-axis was ~41% faster (sqrt(2)) and 3-axis was ~73% faster (sqrt(3)). Now move_dir is normalized when its magnitude exceeds 1.0, preserving sub-unit input magnitudes while capping combined diagonal movement to the same speed as single-axis movement. Wave 0 - Pre-split foundation Co-Authored-By: Claude Opus 4.5 --- src/systems/simulation.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/systems/simulation.rs b/src/systems/simulation.rs index c0c1dfa..90315ad 100644 --- a/src/systems/simulation.rs +++ b/src/systems/simulation.rs @@ -78,8 +78,14 @@ impl SimulationSystem { Vec4::new(camera_right.x, 0.0, camera_right.z, camera_right.w).normalized(); let ana_xzw = Vec4::new(camera_ana.x, 0.0, camera_ana.z, camera_ana.w).normalized(); - // Combine movement direction + // Combine movement direction and normalize to prevent faster diagonal movement + // Without this, 2-axis movement is ~41% faster and 3-axis is ~73% faster let move_dir = forward_xzw * forward_input + right_xzw * right_input + ana_xzw * w_input; + let move_dir = if move_dir.length_squared() > 1.0 { + move_dir.normalized() + } else { + move_dir + }; // 4. Apply movement to player via physics let move_speed = controller.move_speed; -- knotmirror 0.0.1 From 3ec39a25516b5ff30c6e390e8557c2c74a67771f Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 14:18:04 +0000 Subject: [PATCH 04/17] Re-enable back-face culling in render pipeline Culling was disabled for debugging. Re-enabling halves rasterized triangle count and removes internal face artifacts. If the 4D slicing compute shader produces inconsistent winding order, this change should be reverted and a winding order fix filed separately. Wave 0 - Pre-split foundation Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_render/src/pipeline/render_pipeline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rust4d_render/src/pipeline/render_pipeline.rs b/crates/rust4d_render/src/pipeline/render_pipeline.rs index f3b3485..1784e18 100644 --- a/crates/rust4d_render/src/pipeline/render_pipeline.rs +++ b/crates/rust4d_render/src/pipeline/render_pipeline.rs @@ -94,7 +94,7 @@ impl RenderPipeline { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, // Disabled for debugging - was Some(wgpu::Face::Back) + cull_mode: Some(wgpu::Face::Back), unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, -- knotmirror 0.0.1 From a175fa37bd7d67cb277de4a7746f54af8fe7957a Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 14:22:38 +0000 Subject: [PATCH 05/17] Revert "Re-enable back-face culling in render pipeline" This reverts commit 3ec39a25516b5ff30c6e390e8557c2c74a67771f. --- crates/rust4d_render/src/pipeline/render_pipeline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rust4d_render/src/pipeline/render_pipeline.rs b/crates/rust4d_render/src/pipeline/render_pipeline.rs index 1784e18..f3b3485 100644 --- a/crates/rust4d_render/src/pipeline/render_pipeline.rs +++ b/crates/rust4d_render/src/pipeline/render_pipeline.rs @@ -94,7 +94,7 @@ impl RenderPipeline { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: Some(wgpu::Face::Back), + cull_mode: None, // Disabled for debugging - was Some(wgpu::Face::Back) unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, -- knotmirror 0.0.1 From 30651af5fae82c9c615c2b1972b3c79b71156fbc Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:02:03 +0000 Subject: [PATCH 06/17] Fix triangle winding order with determinant + case parity Replace camera-facing normal heuristic in slice_tetra.wgsl with a deterministic geometric computation: XOR the tetrahedron's signed volume (3D determinant) with case parity (case_idx >= 8) to decide winding flip. This produces consistent outward-facing normals regardless of camera position or tetrahedron orientation. Re-enable back-face culling in the render pipeline now that winding order is correct. Co-Authored-By: Claude Opus 4.5 --- .../src/pipeline/render_pipeline.rs | 2 +- .../src/shaders/slice_tetra.wgsl | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/rust4d_render/src/pipeline/render_pipeline.rs b/crates/rust4d_render/src/pipeline/render_pipeline.rs index f3b3485..1784e18 100644 --- a/crates/rust4d_render/src/pipeline/render_pipeline.rs +++ b/crates/rust4d_render/src/pipeline/render_pipeline.rs @@ -94,7 +94,7 @@ impl RenderPipeline { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: None, // Disabled for debugging - was Some(wgpu::Face::Back) + cull_mode: Some(wgpu::Face::Back), unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, diff --git a/crates/rust4d_render/src/shaders/slice_tetra.wgsl b/crates/rust4d_render/src/shaders/slice_tetra.wgsl index f47ab2e..a310832 100644 --- a/crates/rust4d_render/src/shaders/slice_tetra.wgsl +++ b/crates/rust4d_render/src/shaders/slice_tetra.wgsl @@ -253,6 +253,16 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { return; } + // Compute signed volume of the tetrahedron in camera space + // This determines the handedness (orientation) of the tetrahedron + let e1 = pos[1].xyz - pos[0].xyz; + let e2 = pos[2].xyz - pos[0].xyz; + let e3 = pos[3].xyz - pos[0].xyz; + let signed_vol = dot(e1, cross(e2, e3)); + // XOR determinant sign with case parity to get consistent outward-facing normals + // case_idx >= 8 means the complement half of the lookup table + let should_flip = (signed_vol > 0.0) != (case_idx >= 8u); + // Get edge mask and triangle count let edge_mask = TETRA_EDGE_TABLE[case_idx]; let tri_count = TETRA_TRI_COUNT[case_idx]; @@ -276,7 +286,6 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { // Output triangles let tri_indices = TETRA_TRI_TABLE[case_idx]; - let camera_eye = params.camera_eye; for (var t: u32 = 0u; t < tri_count; t++) { let base = t * 3u; @@ -294,12 +303,10 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let p2 = vertex_position(tv2); var normal = compute_normal(p0, p1, p2); - // Ensure normals face toward the camera - // The normal should point in the same direction as the vector from triangle to camera - let tri_center = (p0 + p1 + p2) / 3.0; - let to_camera = camera_eye - tri_center; - if (dot(normal, to_camera) < 0.0) { - // Normal points away from camera, flip to face camera + // Use determinant + case parity to determine winding order + // This produces consistent outward-facing normals regardless of + // camera position or tetrahedron orientation + if (should_flip) { let tmp = tv1; tv1 = tv2; tv2 = tmp; -- knotmirror 0.0.1 From d77fc187b2fbe678048a404ac000d68c05526253 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:15:46 +0000 Subject: [PATCH 07/17] Add hecs dependency to workspace Add hecs 0.10 to workspace dependencies and rust4d_core. This is the ECS crate that will replace our SlotMap-based entity storage. Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 26 ++++++++++++++++++++++++++ Cargo.toml | 1 + crates/rust4d_core/Cargo.toml | 1 + 3 files changed, 28 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index f401c88..5476446 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,6 +669,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -690,6 +699,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hecs" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cbc675ee8d97b4d206a985137f8ad59666538f56f906474f554467a63c776d" +dependencies = [ + "hashbrown 0.14.5", + "spin", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1559,6 +1578,7 @@ name = "rust4d_core" version = "0.1.0" dependencies = [ "bitflags 2.10.0", + "hecs", "log", "ron", "rust4d_math", @@ -1819,6 +1839,12 @@ dependencies = [ "serde", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" diff --git a/Cargo.toml b/Cargo.toml index b2920b4..a9ab9b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ pollster = "0.4" env_logger = "0.11" log = "0.4" slotmap = "1.0" +hecs = "0.10" bitflags = "2.4" serde = { version = "1.0", features = ["derive"] } figment = { version = "0.10", features = ["toml", "env"] } diff --git a/crates/rust4d_core/Cargo.toml b/crates/rust4d_core/Cargo.toml index 70e3fe6..5f007d0 100644 --- a/crates/rust4d_core/Cargo.toml +++ b/crates/rust4d_core/Cargo.toml @@ -8,6 +8,7 @@ description = "Core types for the Rust4D engine: Transform4D, Entity, World" rust4d_math.workspace = true rust4d_physics.workspace = true slotmap.workspace = true +hecs.workspace = true bitflags.workspace = true serde = { version = "1.0", features = ["derive"] } ron = "0.8" -- knotmirror 0.0.1 From d8aa2029eea5f5ea3a5c194a6c9c1d2ffde52973 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:16:14 +0000 Subject: [PATCH 08/17] Add ECS component types New components for the hecs-backed ECS: - Name(String) for entity lookup by name - Tags(HashSet) for categorization - PhysicsBody(BodyKey) linking entity to physics - Parent(hecs::Entity) and Children(Vec) for hierarchy Existing types (Transform4D, Material, ShapeRef, DirtyFlags) are used directly as components without wrappers. Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_core/src/components.rs | 110 +++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 crates/rust4d_core/src/components.rs diff --git a/crates/rust4d_core/src/components.rs b/crates/rust4d_core/src/components.rs new file mode 100644 index 0000000..38ab252 --- /dev/null +++ b/crates/rust4d_core/src/components.rs @@ -0,0 +1,110 @@ +//! ECS component types +//! +//! Each component represents a single aspect of an entity. +//! Components are stored in hecs and queried individually or in combination. +//! +//! Existing types like `Transform4D`, `Material`, `ShapeRef`, and `DirtyFlags` +//! are also used as ECS components directly (they satisfy Send + Sync + 'static). + +use std::collections::HashSet; +use rust4d_physics::BodyKey; + +// === Name Component === + +/// Entity name for lookup by name +/// +/// Only add this component to entities that need to be found by name. +/// The World wrapper maintains a side-table index for O(1) name lookups. +#[derive(Clone, Debug)] +pub struct Name(pub String); + +impl Name { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } +} + +impl std::fmt::Display for Name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +// === Tags Component === + +/// Tags for categorization and filtering +/// +/// Tags like "dynamic", "static", "enemy" allow querying entities by category. +#[derive(Clone, Debug, Default)] +pub struct Tags(pub HashSet); + +impl Tags { + pub fn new() -> Self { + Self(HashSet::new()) + } + + pub fn with_tag(mut self, tag: impl Into) -> Self { + self.0.insert(tag.into()); + self + } + + pub fn has(&self, tag: &str) -> bool { + self.0.contains(tag) + } + + pub fn insert(&mut self, tag: impl Into) { + self.0.insert(tag.into()); + } +} + +impl From for Tags +where + I: IntoIterator, + S: Into, +{ + fn from(iter: I) -> Self { + Self(iter.into_iter().map(Into::into).collect()) + } +} + +// === PhysicsBody Component === + +/// Links an entity to a rigid body in the PhysicsWorld +/// +/// The BodyKey references a body in the PhysicsWorld side-table. +/// When an entity with this component is despawned, the World wrapper +/// must clean up the corresponding physics body. +#[derive(Clone, Copy, Debug)] +pub struct PhysicsBody(pub BodyKey); + +// === Hierarchy Components === + +/// Parent relationship -- points to this entity's parent +#[derive(Clone, Copy, Debug)] +pub struct Parent(pub hecs::Entity); + +/// Children relationship -- lists this entity's children +#[derive(Clone, Debug, Default)] +pub struct Children(pub Vec); + +impl Children { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn add(&mut self, child: hecs::Entity) { + self.0.push(child); + } + + pub fn remove(&mut self, child: hecs::Entity) { + self.0.retain(|&c| c != child); + } + + pub fn contains(&self, child: hecs::Entity) -> bool { + self.0.contains(&child) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} -- knotmirror 0.0.1 From 2c1f5e8232acb0e5f157d7f93dbb14b4b564069c Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:22:37 +0000 Subject: [PATCH 09/17] Rewrite World as hecs::World wrapper Replace SlotMap with hecs::World for component-based entity storage. World now provides: - spawn() for direct component bundles - add_entity() for legacy Entity struct compatibility - ECS queries via ecs()/ecs_mut() accessors - Side-table name index for O(1) name lookups - Parent/Children hierarchy as hecs components - DirtyFlags as hecs component - Physics sync via combined ECS query Update lib.rs exports, scene test, and physics integration tests. Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_core/src/lib.rs | 12 +- crates/rust4d_core/src/scene.rs | 5 +- crates/rust4d_core/src/world.rs | 759 ++++++++---------- .../rust4d_core/tests/physics_integration.rs | 58 +- 4 files changed, 379 insertions(+), 455 deletions(-) diff --git a/crates/rust4d_core/src/lib.rs b/crates/rust4d_core/src/lib.rs index fdc64de..0769d27 100644 --- a/crates/rust4d_core/src/lib.rs +++ b/crates/rust4d_core/src/lib.rs @@ -4,10 +4,9 @@ //! //! - [`Transform4D`] - Position, rotation, and scale in 4D space //! - [`Material`] - Visual properties of an entity -//! - [`Entity`] - An object in the world with transform, shape, and material //! - [`ShapeRef`] - Reference to a shape (shared or owned) -//! - [`World`] - Container for all entities -//! - [`EntityKey`] - Generational key to an entity in the world +//! - [`World`] - ECS-backed container for all entities +//! - [`Name`], [`Tags`], [`PhysicsBody`] - ECS components //! - [`ShapeTemplate`] - Serializable shape template //! - [`EntityTemplate`] - Serializable entity template //! - [`Scene`] - Loadable/saveable scene containing entities @@ -15,6 +14,7 @@ mod transform; mod entity; mod world; +mod components; mod shapes; mod scene; mod scene_manager; @@ -26,7 +26,8 @@ mod scene_validator; pub use transform::Transform4D; pub use entity::{Material, Entity, ShapeRef, DirtyFlags, EntityTemplate}; -pub use world::{World, EntityKey, HierarchyError}; +pub use world::{World, HierarchyError}; +pub use components::{Name, Tags, PhysicsBody, Parent, Children}; pub use shapes::ShapeTemplate; pub use scene::{Scene, SceneLoadError, SceneSaveError, SceneError, ActiveScene}; pub use scene_manager::SceneManager; @@ -42,3 +43,6 @@ pub use rust4d_math::{Tesseract4D, Hyperplane4D}; // Re-export physics types for convenient access through rust4d_core pub use rust4d_physics::{BodyKey, PhysicsConfig, PhysicsWorld, RigidBody4D, StaticCollider}; + +// Re-export hecs::Entity for consumers +pub use hecs; diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index 430dacc..dd3c1ff 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -581,8 +581,9 @@ Scene( assert_eq!(active.world.physics().unwrap().config.gravity, -15.0); // Check entity was instantiated - let (_, entity) = active.world.get_by_name("cube").unwrap(); - assert_eq!(entity.material.base_color, [1.0, 0.0, 0.0, 1.0]); + let entity_handle = active.world.get_by_name("cube").unwrap(); + let material = active.world.ecs().get::<&Material>(entity_handle).unwrap(); + assert_eq!(material.base_color, [1.0, 0.0, 0.0, 1.0]); } #[test] diff --git a/crates/rust4d_core/src/world.rs b/crates/rust4d_core/src/world.rs index af0fc3d..d333fe0 100644 --- a/crates/rust4d_core/src/world.rs +++ b/crates/rust4d_core/src/world.rs @@ -1,23 +1,14 @@ -//! World container for entities +//! World container for entities (hecs ECS backend) //! -//! The World manages all entities in the simulation, including parent-child -//! entity hierarchy with cycle detection and recursive operations. +//! The World manages all entities in the simulation using hecs for storage. +//! It provides side-tables for name lookups and physics integration, +//! and stores hierarchy as Parent/Children components. use std::collections::{HashMap, VecDeque}; use std::fmt; -use crate::{Entity, DirtyFlags, Transform4D}; +use crate::components::*; +use crate::{DirtyFlags, Transform4D}; use rust4d_physics::{PhysicsConfig, PhysicsWorld}; -use slotmap::{new_key_type, SlotMap}; - -// Define generational key type for entities -new_key_type! { - /// Key to an entity in the world - /// - /// Uses generational indexing to prevent the ABA problem where a key - /// could point to a reused slot. If an entity is removed and its slot reused, - /// old keys will return None instead of pointing to the wrong entity. - pub struct EntityKey; -} /// Error type for hierarchy operations #[derive(Debug, Clone, PartialEq, Eq)] @@ -42,22 +33,21 @@ impl fmt::Display for HierarchyError { impl std::error::Error for HierarchyError {} -/// The 4D world containing all entities +/// The 4D world containing all entities (ECS-backed) /// -/// The World is the central container for all game objects. -/// It manages entities, integrates with physics simulation, -/// and tracks parent-child entity hierarchy. +/// Thin wrapper around hecs::World with side-tables for: +/// - Name lookups (HashMap) +/// - Physics simulation (optional PhysicsWorld) +/// +/// Hierarchy is stored as ECS components (Parent, Children) rather +/// than separate HashMaps. pub struct World { - /// All entities in the world (using generational keys) - entities: SlotMap, - /// Index from entity names to keys (for fast name lookup) - name_index: HashMap, - /// Optional physics simulation (None = no physics) + /// The hecs ECS world + ecs: hecs::World, + /// Index from entity names to hecs entities (for fast name lookup) + name_index: HashMap, + /// Optional physics simulation physics_world: Option, - /// Parent mapping: child entity key -> parent entity key - parents: HashMap, - /// Children mapping: parent entity key -> list of child entity keys - children_map: HashMap>, } impl Default for World { @@ -70,22 +60,9 @@ impl World { /// Create a new empty world pub fn new() -> Self { Self { - entities: SlotMap::with_key(), + ecs: hecs::World::new(), name_index: HashMap::new(), physics_world: None, - parents: HashMap::new(), - children_map: HashMap::new(), - } - } - - /// Create a world with pre-allocated capacity for entities - pub fn with_capacity(capacity: usize) -> Self { - Self { - entities: SlotMap::with_capacity_and_key(capacity), - name_index: HashMap::new(), - physics_world: None, - parents: HashMap::new(), - children_map: HashMap::new(), } } @@ -105,117 +82,178 @@ impl World { self.physics_world.as_mut() } - /// Add an entity to the world, returning its key - pub fn add_entity(&mut self, entity: Entity) -> EntityKey { - // Get the name before moving the entity - let name = entity.name.clone(); - let key = self.entities.insert(entity); + // === Entity Spawning === + + /// Spawn an entity from a component bundle (tuple or EntityBuilder) + /// + /// This is the PRIMARY way to create entities in the ECS API. + /// Returns the hecs::Entity handle. + /// + /// After spawning, checks for a Name component and updates the name index. + pub fn spawn(&mut self, components: impl hecs::DynamicBundle) -> hecs::Entity { + let entity = self.ecs.spawn(components); - // If the entity has a name, add it to the index - if let Some(name) = name { - self.name_index.insert(name, key); + // Check if spawned entity has a Name component and index it + if let Ok(name) = self.ecs.get::<&Name>(entity) { + self.name_index.insert(name.0.clone(), entity); } - key + entity } - /// Remove an entity from the world and return it + /// Legacy compatibility: spawn from the old Entity struct /// - /// This method also cleans up associated resources: - /// - Removes the entity from the name index if it was named - /// - Removes the physics body from PhysicsWorld if one was attached - /// - Removes the entity from its parent's children list (if it had a parent) - /// - Orphans the entity's children (they become root entities) - pub fn remove_entity(&mut self, key: EntityKey) -> Option { - // Remove from entities first - if let Some(entity) = self.entities.remove(key) { - // Clean up name index if the entity had a name - if let Some(ref name) = entity.name { - self.name_index.remove(name); - } + /// TRANSITIONAL METHOD -- decomposes the monolithic Entity into individual + /// ECS components and spawns them. + pub fn add_entity(&mut self, entity: crate::entity::Entity) -> hecs::Entity { + let mut builder = hecs::EntityBuilder::new(); + + builder.add(entity.transform); + builder.add(entity.shape); + builder.add(entity.material); + builder.add(DirtyFlags::ALL); + + if let Some(name) = entity.name { + builder.add(Name(name)); + } - // Clean up physics body if present - if let Some(body_key) = entity.physics_body { - if let Some(ref mut physics) = self.physics_world { - physics.remove_body(body_key); - } - } + if !entity.tags.is_empty() { + builder.add(Tags(entity.tags)); + } - // Clean up hierarchy: remove from parent's children list - if let Some(parent_key) = self.parents.remove(&key) { - if let Some(siblings) = self.children_map.get_mut(&parent_key) { - siblings.retain(|&k| k != key); - if siblings.is_empty() { - self.children_map.remove(&parent_key); - } - } + if let Some(body_key) = entity.physics_body { + builder.add(PhysicsBody(body_key)); + } + + self.spawn(builder.build()) + } + + // === Entity Removal === + + /// Remove an entity from the world + /// + /// Cleans up: + /// - Name index (if entity had Name component) + /// - Physics body (if entity had PhysicsBody component) + /// - Hierarchy (removes from parent, orphans children) + /// + /// Returns true if entity existed and was removed. + pub fn despawn(&mut self, entity: hecs::Entity) -> bool { + if !self.ecs.contains(entity) { + return false; + } + + // Clean up name index + if let Ok(name) = self.ecs.get::<&Name>(entity) { + let name_str = name.0.clone(); + drop(name); + self.name_index.remove(&name_str); + } + + // Clean up physics body + if let Ok(body) = self.ecs.get::<&PhysicsBody>(entity) { + let body_key = body.0; + drop(body); + if let Some(ref mut physics) = self.physics_world { + physics.remove_body(body_key); } + } - // Clean up hierarchy: orphan all children (they become root entities) - if let Some(children) = self.children_map.remove(&key) { - for child_key in children { - self.parents.remove(&child_key); - } + // Clean up hierarchy: remove from parent's Children list + let parent_entity = self.ecs.get::<&Parent>(entity).ok().map(|p| p.0); + if let Some(parent_entity) = parent_entity { + if let Ok(mut children) = self.ecs.get::<&mut Children>(parent_entity) { + children.remove(entity); } + } - Some(entity) - } else { - None + // Orphan all children (remove Parent component from them) + let child_list: Vec = self.ecs.get::<&Children>(entity) + .ok() + .map(|c| c.0.clone()) + .unwrap_or_default(); + for child in child_list { + let _ = self.ecs.remove_one::(child); } + + self.ecs.despawn(entity).is_ok() } - /// Get a reference to an entity by key - pub fn get_entity(&self, key: EntityKey) -> Option<&Entity> { - self.entities.get(key) + /// Legacy alias for despawn + pub fn remove_entity(&mut self, entity: hecs::Entity) -> bool { + self.despawn(entity) } - /// Get a mutable reference to an entity by key - pub fn get_entity_mut(&mut self, key: EntityKey) -> Option<&mut Entity> { - self.entities.get_mut(key) + // === Entity Access === + + /// Check if an entity exists + pub fn contains(&self, entity: hecs::Entity) -> bool { + self.ecs.contains(entity) } - /// Get an entity by name - pub fn get_by_name(&self, name: &str) -> Option<(EntityKey, &Entity)> { - let key = *self.name_index.get(name)?; - let entity = self.entities.get(key)?; - Some((key, entity)) + /// Direct access to the underlying hecs World (for advanced queries) + pub fn ecs(&self) -> &hecs::World { + &self.ecs } - /// Get a mutable reference to an entity by name - pub fn get_by_name_mut(&mut self, name: &str) -> Option<(EntityKey, &mut Entity)> { - let key = *self.name_index.get(name)?; - let entity = self.entities.get_mut(key)?; - Some((key, entity)) + /// Mutable access to the underlying hecs World + pub fn ecs_mut(&mut self) -> &mut hecs::World { + &mut self.ecs + } + + // === Name Lookups === + + /// Get an entity by name + /// + /// Returns the Entity handle if found. + pub fn get_by_name(&self, name: &str) -> Option { + self.name_index.get(name).copied() } + // === Tag Queries === + /// Get all entities with a specific tag - pub fn get_by_tag<'a>(&'a self, tag: &'a str) -> impl Iterator { - self.entities.iter().filter(move |(_, entity)| entity.has_tag(tag)) + /// + /// This performs a linear scan of all entities with Tags component. + pub fn get_by_tag(&self, tag: &str) -> Vec { + let mut result = Vec::new(); + for (entity, tags) in self.ecs.query::<&Tags>().iter() { + if tags.has(tag) { + result.push(entity); + } + } + result } + // === Entity Count and Iteration === + /// Get the number of entities #[inline] pub fn entity_count(&self) -> usize { - self.entities.len() + // hecs::World::len() returns u32 + self.ecs.len() as usize } /// Check if the world is empty #[inline] pub fn is_empty(&self) -> bool { - self.entities.is_empty() + self.ecs.is_empty() } - /// Iterate over all entity keys - pub fn entity_keys(&self) -> impl Iterator + '_ { - self.entities.keys() + /// Clear all entities from the world + pub fn clear(&mut self) { + self.ecs.clear(); + self.name_index.clear(); } + // === Physics Update === + /// Update the world by stepping physics and syncing entity transforms /// /// This method: /// 1. Steps the physics simulation (if enabled) /// 2. Syncs entity transforms from their associated physics bodies - /// 3. Marks entities as dirty when their transforms change + /// 3. Marks entities dirty when their transforms change pub fn update(&mut self, dt: f32) { // Update the physics simulation (fixed timestep accumulator) if let Some(ref mut physics) = self.physics_world { @@ -224,94 +262,63 @@ impl World { // Sync entity transforms from their physics bodies if let Some(ref physics) = self.physics_world { - for (_key, entity) in &mut self.entities { - if let Some(body_key) = entity.physics_body { - if let Some(body) = physics.get_body(body_key) { - // Only update and mark dirty if position actually changed - if entity.transform.position != body.position { - entity.transform.position = body.position; - entity.mark_dirty(DirtyFlags::TRANSFORM); - } + for (_entity, (transform, body, dirty)) in + self.ecs.query_mut::<(&mut Transform4D, &PhysicsBody, &mut DirtyFlags)>() + { + if let Some(phys_body) = physics.get_body(body.0) { + if transform.position != phys_body.position { + transform.position = phys_body.position; + *dirty |= DirtyFlags::TRANSFORM; } } } } } - // --- Dirty tracking methods --- + // === Dirty Tracking === /// Check if any entity in the world has dirty flags set pub fn has_dirty_entities(&self) -> bool { - self.entities.values().any(|entity| entity.is_dirty()) - } - - /// Iterate over all dirty entities (entities with any dirty flags set) - pub fn dirty_entities(&self) -> impl Iterator { - self.entities.iter().filter(|(_, entity)| entity.is_dirty()) - } - - /// Iterate over dirty entities mutably - pub fn dirty_entities_mut(&mut self) -> impl Iterator { - self.entities.iter_mut().filter(|(_, entity)| entity.is_dirty()) + for (_, dirty) in self.ecs.query::<&DirtyFlags>().iter() { + if !dirty.is_empty() { + return true; + } + } + false } /// Clear dirty flags on all entities pub fn clear_all_dirty(&mut self) { - for entity in self.entities.values_mut() { - entity.clear_dirty(); + for (_, dirty) in self.ecs.query_mut::<&mut DirtyFlags>() { + *dirty = DirtyFlags::NONE; } } - /// Clear all entities from the world - pub fn clear(&mut self) { - self.entities.clear(); - self.name_index.clear(); - self.parents.clear(); - self.children_map.clear(); - } - - /// Iterate over all entities - pub fn iter(&self) -> impl Iterator { - self.entities.values() - } - - /// Iterate over all entities mutably - pub fn iter_mut(&mut self) -> impl Iterator { - self.entities.values_mut() - } + // === Hierarchy Methods === - /// Iterate over keys and entities - pub fn iter_with_keys(&self) -> impl Iterator { - self.entities.iter() + /// Get an entity's parent + pub fn parent_of(&self, entity: hecs::Entity) -> Option { + self.ecs.get::<&Parent>(entity).ok().map(|p| p.0) } - // --- Hierarchy methods --- - - /// Get an entity's parent key - /// - /// Returns `None` if the entity has no parent (is a root entity) - /// or if the entity does not exist. - pub fn parent_of(&self, entity: EntityKey) -> Option { - self.parents.get(&entity).copied() - } - - /// Get an entity's children as a slice of keys - /// - /// Returns an empty slice if the entity has no children or does not exist. - pub fn children_of(&self, entity: EntityKey) -> &[EntityKey] { - self.children_map.get(&entity).map_or(&[], |v| v.as_slice()) + /// Get an entity's children + pub fn children_of(&self, entity: hecs::Entity) -> Vec { + self.ecs.get::<&Children>(entity) + .ok() + .map(|c| c.0.clone()) + .unwrap_or_default() } /// Check if an entity has any children - pub fn has_children(&self, entity: EntityKey) -> bool { - self.children_map - .get(&entity) - .is_some_and(|children| !children.is_empty()) + pub fn has_children(&self, entity: hecs::Entity) -> bool { + self.ecs.get::<&Children>(entity) + .ok() + .is_some_and(|c| !c.0.is_empty()) } /// Check if an entity has a parent - pub fn has_parent(&self, entity: EntityKey) -> bool { - self.parents.contains_key(&entity) + pub fn has_parent(&self, entity: hecs::Entity) -> bool { + self.ecs.get::<&Parent>(entity).is_ok() } /// Add a child entity to a parent entity @@ -320,9 +327,9 @@ impl World { /// that parent (reparenting). Returns an error if either entity does not /// exist, if the relationship would create a cycle, or if the child is /// already a child of the specified parent. - pub fn add_child(&mut self, parent: EntityKey, child: EntityKey) -> Result<(), HierarchyError> { + pub fn add_child(&mut self, parent: hecs::Entity, child: hecs::Entity) -> Result<(), HierarchyError> { // Validate both entities exist - if !self.entities.contains_key(parent) || !self.entities.contains_key(child) { + if !self.ecs.contains(parent) || !self.ecs.contains(child) { return Err(HierarchyError::InvalidEntity); } @@ -332,8 +339,10 @@ impl World { } // Check if child is already a child of this parent - if self.parents.get(&child) == Some(&parent) { - return Err(HierarchyError::AlreadyChild); + if let Ok(existing_parent) = self.ecs.get::<&Parent>(child) { + if existing_parent.0 == parent { + return Err(HierarchyError::AlreadyChild); + } } // Check for cycles: walk up from parent; if we reach child, it would create a cycle @@ -342,21 +351,26 @@ impl World { } // If child already has a different parent, remove it from that parent first - if let Some(old_parent) = self.parents.remove(&child) { - if let Some(old_siblings) = self.children_map.get_mut(&old_parent) { - old_siblings.retain(|&k| k != child); - if old_siblings.is_empty() { - self.children_map.remove(&old_parent); - } + let old_parent = self.ecs.get::<&Parent>(child).ok().map(|p| p.0); + if let Some(old_parent_entity) = old_parent { + if let Ok(mut old_children) = self.ecs.get::<&mut Children>(old_parent_entity) { + old_children.remove(child); } } - // Establish the new relationship - self.parents.insert(child, parent); - self.children_map - .entry(parent) - .or_default() - .push(child); + // Set the Parent component on child (insert_one replaces if exists, adds if not) + let _ = self.ecs.insert_one(child, Parent(parent)); + + // Add to parent's Children component + let has_children = self.ecs.get::<&Children>(parent).is_ok(); + if has_children { + if let Ok(mut children) = self.ecs.get::<&mut Children>(parent) { + children.add(child); + } + } else { + // Parent doesn't have Children component yet -- add it + let _ = self.ecs.insert_one(parent, Children(vec![child])); + } Ok(()) } @@ -364,43 +378,43 @@ impl World { /// Remove an entity from its parent, making it a root entity /// /// Does nothing if the entity has no parent or does not exist. - pub fn remove_from_parent(&mut self, child: EntityKey) { - if let Some(parent_key) = self.parents.remove(&child) { - if let Some(siblings) = self.children_map.get_mut(&parent_key) { - siblings.retain(|&k| k != child); - if siblings.is_empty() { - self.children_map.remove(&parent_key); - } + pub fn remove_from_parent(&mut self, child: hecs::Entity) { + let parent_entity = self.ecs.get::<&Parent>(child).ok().map(|p| p.0); + if let Some(parent_entity) = parent_entity { + // Remove from parent's Children + if let Ok(mut children) = self.ecs.get::<&mut Children>(parent_entity) { + children.remove(child); } + + // Remove Parent component from child + let _ = self.ecs.remove_one::(child); } } /// Get the world-space transform of an entity /// /// For root entities (no parent), this is just their own local transform. - /// For children, this composes transforms from root to leaf using - /// `Transform4D::compose`, which correctly handles position, rotation, - /// and scale accumulation. + /// For children, this composes transforms from root to leaf. /// /// Returns `None` if the entity does not exist. - pub fn world_transform(&self, entity: EntityKey) -> Option { - // Check entity exists - let local_transform = self.entities.get(entity)?.transform; + pub fn world_transform(&self, entity: hecs::Entity) -> Option { + let local_transform = *self.ecs.get::<&Transform4D>(entity).ok()?; - // Build the chain of ancestors from root to this entity + // Build the chain of ancestors from leaf to root let mut chain = vec![local_transform]; let mut current = entity; - while let Some(&parent_key) = self.parents.get(¤t) { - if let Some(parent_entity) = self.entities.get(parent_key) { - chain.push(parent_entity.transform); - current = parent_key; + while let Ok(parent) = self.ecs.get::<&Parent>(current) { + let parent_entity = parent.0; + drop(parent); + if let Ok(parent_transform) = self.ecs.get::<&Transform4D>(parent_entity) { + chain.push(*parent_transform); + current = parent_entity; } else { break; } } // Compose from root (last element) to leaf (first element) - // chain is [leaf, ..., root], so we iterate in reverse let mut result = Transform4D::identity(); for transform in chain.into_iter().rev() { result = result.compose(&transform); @@ -411,54 +425,31 @@ impl World { /// Delete an entity and all its descendants recursively /// - /// Returns a vector of all removed entities (the target entity and - /// all of its descendants). The target entity is first in the vector. - /// Returns an empty vector if the entity does not exist. - pub fn delete_recursive(&mut self, entity: EntityKey) -> Vec { + /// Returns a vector of all removed entity handles. + pub fn delete_recursive(&mut self, entity: hecs::Entity) -> Vec { let mut removed = Vec::new(); - // Collect all descendants first (breadth-first) - let mut to_remove = VecDeque::new(); - to_remove.push_back(entity); - - let mut keys_to_remove = Vec::new(); - while let Some(key) = to_remove.pop_front() { - keys_to_remove.push(key); - // Add children to the queue - if let Some(children) = self.children_map.get(&key) { - for &child_key in children { - to_remove.push_back(child_key); - } - } - } + // Collect all descendants (breadth-first) + let mut queue = VecDeque::new(); + queue.push_back(entity); - // Before removing the root entity, detach it from its parent - if let Some(parent_key) = self.parents.remove(&entity) { - if let Some(siblings) = self.children_map.get_mut(&parent_key) { - siblings.retain(|&k| k != entity); - if siblings.is_empty() { - self.children_map.remove(&parent_key); + let mut to_remove = Vec::new(); + while let Some(e) = queue.pop_front() { + to_remove.push(e); + if let Ok(children) = self.ecs.get::<&Children>(e) { + for &child in &children.0 { + queue.push_back(child); } } } - // Now remove all collected entities - for key in keys_to_remove { - // Clean up hierarchy maps for this entity - self.parents.remove(&key); - self.children_map.remove(&key); + // Detach root from its parent + self.remove_from_parent(entity); - // Remove the entity itself (with name/physics cleanup) - if let Some(ent) = self.entities.remove(key) { - if let Some(ref name) = ent.name { - self.name_index.remove(name); - } - if let Some(body_key) = ent.physics_body { - if let Some(ref mut physics) = self.physics_world { - physics.remove_body(body_key); - } - } - removed.push(ent); + // Despawn all (despawn handles name + physics cleanup) + for e in to_remove { + if self.despawn(e) { + removed.push(e); } } @@ -469,21 +460,21 @@ impl World { /// /// Returns an empty vector if the entity has no descendants or does not exist. /// Does not include the entity itself. - pub fn descendants(&self, entity: EntityKey) -> Vec { + pub fn descendants(&self, entity: hecs::Entity) -> Vec { let mut result = Vec::new(); let mut queue = VecDeque::new(); // Seed with direct children - if let Some(children) = self.children_map.get(&entity) { - for &child in children { + if let Ok(children) = self.ecs.get::<&Children>(entity) { + for &child in &children.0 { queue.push_back(child); } } - while let Some(key) = queue.pop_front() { - result.push(key); - if let Some(children) = self.children_map.get(&key) { - for &child in children { + while let Some(e) = queue.pop_front() { + result.push(e); + if let Ok(children) = self.ecs.get::<&Children>(e) { + for &child in &children.0 { queue.push_back(child); } } @@ -492,24 +483,28 @@ impl World { result } - /// Get all root entities (entities with no parent) - pub fn root_entities(&self) -> impl Iterator { - self.entities - .iter() - .filter(|(key, _)| !self.parents.contains_key(key)) + /// Get all root entities (entities with no Parent component) + pub fn root_entities(&self) -> Vec { + let mut roots = Vec::new(); + for entity in self.ecs.iter() { + if self.ecs.get::<&Parent>(entity.entity()).is_err() { + roots.push(entity.entity()); + } + } + roots } /// Check if `ancestor` is an ancestor of `entity` /// /// Walks up the hierarchy from `entity`. Returns `false` if either /// entity does not exist, or if `ancestor == entity`. - pub fn is_ancestor(&self, ancestor: EntityKey, entity: EntityKey) -> bool { + pub fn is_ancestor(&self, ancestor: hecs::Entity, entity: hecs::Entity) -> bool { let mut current = entity; - while let Some(&parent_key) = self.parents.get(¤t) { - if parent_key == ancestor { + while let Ok(parent) = self.ecs.get::<&Parent>(current) { + if parent.0 == ancestor { return true; } - current = parent_key; + current = parent.0; } false } @@ -518,7 +513,7 @@ impl World { #[cfg(test)] mod tests { use super::*; - use crate::{Material, ShapeRef}; + use crate::{Material, ShapeRef, Entity}; use rust4d_math::Tesseract4D; fn make_test_entity() -> Entity { @@ -540,7 +535,7 @@ mod tests { let key = world.add_entity(entity); // Key should be valid - assert!(world.get_entity(key).is_some()); + assert!(world.contains(key)); assert_eq!(world.entity_count(), 1); } @@ -550,9 +545,9 @@ mod tests { let entity = make_test_entity(); let handle = world.add_entity(entity); - let retrieved = world.get_entity(handle); - assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().shape().vertex_count(), 16); + let shape = world.ecs().get::<&ShapeRef>(handle); + assert!(shape.is_ok()); + assert_eq!(shape.unwrap().as_shape().vertex_count(), 16); } #[test] @@ -561,12 +556,13 @@ mod tests { let entity = make_test_entity(); let handle = world.add_entity(entity); - if let Some(entity) = world.get_entity_mut(handle) { - entity.material = Material::RED; + { + let mut material = world.ecs_mut().get::<&mut Material>(handle).unwrap(); + *material = Material::RED; } - let retrieved = world.get_entity(handle).unwrap(); - assert_eq!(retrieved.material.base_color, [1.0, 0.0, 0.0, 1.0]); + let material = world.ecs().get::<&Material>(handle).unwrap(); + assert_eq!(material.base_color, [1.0, 0.0, 0.0, 1.0]); } #[test] @@ -589,28 +585,15 @@ mod tests { } #[test] - fn test_world_iter() { + fn test_world_ecs_query() { let mut world = World::new(); world.add_entity(make_test_entity()); world.add_entity(make_test_entity()); - let count = world.iter().count(); + let count = world.ecs().query::<&Transform4D>().iter().count(); assert_eq!(count, 2); } - #[test] - fn test_world_iter_with_keys() { - let mut world = World::new(); - let key1 = world.add_entity(make_test_entity()); - let key2 = world.add_entity(make_test_entity()); - - let keys: Vec<_> = world.iter_with_keys().map(|(k, _)| k).collect(); - assert_eq!(keys.len(), 2); - // Keys should contain both added keys (order may vary with SlotMap) - assert!(keys.contains(&key1)); - assert!(keys.contains(&key2)); - } - #[test] fn test_world_update() { let mut world = World::new(); @@ -627,36 +610,29 @@ mod tests { } #[test] - fn test_world_with_capacity() { - let world = World::with_capacity(100); - assert!(world.is_empty()); - // Can't directly test capacity, but it shouldn't affect behavior - } - - #[test] - fn test_stale_key_returns_none() { + fn test_stale_entity_returns_false() { let mut world = World::new(); let entity = make_test_entity(); let key = world.add_entity(entity); // Key is valid initially - assert!(world.get_entity(key).is_some()); + assert!(world.contains(key)); // Remove the entity let removed = world.remove_entity(key); - assert!(removed.is_some()); + assert!(removed); - // Key is now stale - should return None - assert!(world.get_entity(key).is_none()); + // Key is now stale + assert!(!world.contains(key)); - // Add a new entity - it gets a different key + // Add a new entity let new_entity = make_test_entity(); let new_key = world.add_entity(new_entity); - // Old key still returns None (generational safety) - assert!(world.get_entity(key).is_none()); + // Old key still invalid + assert!(!world.contains(key)); // New key works - assert!(world.get_entity(new_key).is_some()); + assert!(world.contains(new_key)); } #[test] @@ -680,20 +656,21 @@ mod tests { let entity_handle = world.add_entity(entity); // Verify initial position - assert_eq!(world.get_entity(entity_handle).unwrap().transform.position.x, 0.0); + { + let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); + assert_eq!(transform.position.x, 0.0); + } // Simulate 1 second in realistic frame-sized increments - // (physics uses fixed timestep accumulator, large dt is clamped) for _ in 0..60 { world.update(1.0 / 60.0); } // Entity transform should now reflect the physics body position - // ~10 units displacement (10 units/sec * ~1s, slight variance from fixed step rounding) - let entity = world.get_entity(entity_handle).unwrap(); - assert!((entity.transform.position.x - 10.0).abs() < 0.2, - "Expected ~10.0, got {}", entity.transform.position.x); - assert!((entity.transform.position.y - 5.0).abs() < 0.001); + let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); + assert!((transform.position.x - 10.0).abs() < 0.2, + "Expected ~10.0, got {}", transform.position.x); + assert!((transform.position.y - 5.0).abs() < 0.001); } #[test] @@ -715,12 +692,9 @@ mod tests { // Step physics world.update(0.1); - // Entity should have fallen (gravity is -20, so after 0.1s, velocity = -2.0) - // Position changes by: initial_vel * dt + 0.5 * g * dt^2 = 0 + 0.5 * (-20) * 0.01 = -0.1 - // But the actual integration is: v += g*dt, then p += v*dt - // So v = -2.0, then p = 10.0 + (-2.0) * 0.1 = 10.0 - 0.2 = 9.8 - let entity = world.get_entity(entity_handle).unwrap(); - assert!(entity.transform.position.y < 10.0); + // Entity should have fallen + let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); + assert!(transform.position.y < 10.0); } #[test] @@ -728,7 +702,7 @@ mod tests { // Create a world with physics let mut world = World::new().with_physics(PhysicsConfig::default()); - // Add an entity WITHOUT a physics body + // Add an entity WITHOUT a physics body but with a specific position let mut entity = make_test_entity(); entity.transform.position = rust4d_math::Vec4::new(5.0, 5.0, 5.0, 5.0); let entity_handle = world.add_entity(entity); @@ -737,9 +711,9 @@ mod tests { world.update(1.0); // Entity position should be unchanged (not linked to physics) - let entity = world.get_entity(entity_handle).unwrap(); - assert_eq!(entity.transform.position.x, 5.0); - assert_eq!(entity.transform.position.y, 5.0); + let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); + assert_eq!(transform.position.x, 5.0); + assert_eq!(transform.position.y, 5.0); } #[test] @@ -753,36 +727,16 @@ mod tests { // Should be able to find by name let result = world.get_by_name("tesseract"); assert!(result.is_some()); - let (found_key, found_entity) = result.unwrap(); - assert_eq!(found_key, key); - assert_eq!(found_entity.name, Some("tesseract".to_string())); + assert_eq!(result.unwrap(), key); + + // Verify the entity has the Name component + let name = world.ecs().get::<&Name>(key).unwrap(); + assert_eq!(name.0, "tesseract"); // Non-existent name should return None assert!(world.get_by_name("nonexistent").is_none()); } - #[test] - fn test_get_by_name_mut() { - let mut world = World::new(); - - // Add a named entity - let entity = make_test_entity().with_name("tesseract"); - let key = world.add_entity(entity); - - // Should be able to get mutable reference by name - { - let result = world.get_by_name_mut("tesseract"); - assert!(result.is_some()); - let (found_key, entity) = result.unwrap(); - assert_eq!(found_key, key); - entity.material = Material::RED; - } - - // Verify the mutation worked - let entity = world.get_entity(key).unwrap(); - assert_eq!(entity.material.base_color, [1.0, 0.0, 0.0, 1.0]); - } - #[test] fn test_get_by_tag() { let mut world = World::new(); @@ -791,21 +745,20 @@ mod tests { let dynamic1 = make_test_entity().with_tag("dynamic").with_name("dyn1"); let dynamic2 = make_test_entity().with_tag("dynamic").with_name("dyn2"); let static1 = make_test_entity().with_tag("static").with_name("stat1"); - let _key1 = world.add_entity(dynamic1); - let _key2 = world.add_entity(dynamic2); - let _key3 = world.add_entity(static1); + world.add_entity(dynamic1); + world.add_entity(dynamic2); + world.add_entity(static1); // Should find 2 dynamic entities - let dynamic_entities: Vec<_> = world.get_by_tag("dynamic").collect(); + let dynamic_entities = world.get_by_tag("dynamic"); assert_eq!(dynamic_entities.len(), 2); // Should find 1 static entity - let static_entities: Vec<_> = world.get_by_tag("static").collect(); + let static_entities = world.get_by_tag("static"); assert_eq!(static_entities.len(), 1); - assert_eq!(static_entities[0].1.name, Some("stat1".to_string())); - // Non-existent tag should return empty iterator - let none_entities: Vec<_> = world.get_by_tag("nonexistent").collect(); + // Non-existent tag should return empty + let none_entities = world.get_by_tag("nonexistent"); assert!(none_entities.is_empty()); } @@ -856,7 +809,7 @@ mod tests { let key = world.add_entity(entity); // Entity should exist but not be findable by any name - assert!(world.get_entity(key).is_some()); + assert!(world.contains(key)); assert!(world.get_by_name("").is_none()); } @@ -868,8 +821,8 @@ mod tests { let key = world.add_entity(make_test_entity()); // New entities should be dirty (DirtyFlags::ALL) - let entity = world.get_entity(key).unwrap(); - assert!(entity.is_dirty()); + let dirty = world.ecs().get::<&DirtyFlags>(key).unwrap(); + assert!(!dirty.is_empty()); assert!(world.has_dirty_entities()); } @@ -881,38 +834,37 @@ mod tests { // Both should be dirty initially assert!(world.has_dirty_entities()); - assert_eq!(world.dirty_entities().count(), 2); // Clear all dirty flags world.clear_all_dirty(); // None should be dirty now assert!(!world.has_dirty_entities()); - assert_eq!(world.dirty_entities().count(), 0); } #[test] - fn test_dirty_entities_iterator() { + fn test_dirty_entities_tracking() { let mut world = World::new(); let key1 = world.add_entity(make_test_entity()); - let key2 = world.add_entity(make_test_entity()); + let _key2 = world.add_entity(make_test_entity()); // Clear dirty flags world.clear_all_dirty(); // Manually mark one as dirty - if let Some(entity) = world.get_entity_mut(key1) { - entity.mark_dirty(DirtyFlags::TRANSFORM); + { + let mut dirty = world.ecs_mut().get::<&mut DirtyFlags>(key1).unwrap(); + *dirty |= DirtyFlags::TRANSFORM; } - // Only one should be dirty - let dirty: Vec<_> = world.dirty_entities().collect(); - assert_eq!(dirty.len(), 1); - assert_eq!(dirty[0].0, key1); + // Should have dirty entities now + assert!(world.has_dirty_entities()); - // The other should not be dirty - let entity2 = world.get_entity(key2).unwrap(); - assert!(!entity2.is_dirty()); + // Count dirty entities via query + let dirty_count = world.ecs().query::<&DirtyFlags>().iter() + .filter(|(_, d)| !d.is_empty()) + .count(); + assert_eq!(dirty_count, 1); } #[test] @@ -941,9 +893,9 @@ mod tests { world.update(1.0); // Entity should now be dirty - let entity = world.get_entity(entity_handle).unwrap(); - assert!(entity.is_dirty()); - assert!(entity.dirty_flags().contains(DirtyFlags::TRANSFORM)); + let dirty = world.ecs().get::<&DirtyFlags>(entity_handle).unwrap(); + assert!(!dirty.is_empty()); + assert!(dirty.contains(DirtyFlags::TRANSFORM)); } #[test] @@ -970,32 +922,8 @@ mod tests { world.update(1.0); // Entity should NOT be dirty (position didn't change) - let entity = world.get_entity(entity_handle).unwrap(); - assert!(!entity.is_dirty()); - } - - #[test] - fn test_dirty_entities_mut() { - let mut world = World::new(); - world.add_entity(make_test_entity()); - world.add_entity(make_test_entity()); - - // Clear dirty flags - world.clear_all_dirty(); - - // Mark first entity dirty manually - for entity in world.iter_mut().take(1) { - entity.mark_dirty(DirtyFlags::TRANSFORM); - } - - // Use dirty_entities_mut to clear dirty on just the dirty entities - for (_, entity) in world.dirty_entities_mut() { - entity.material = Material::RED; - entity.clear_dirty(); - } - - // No entities should be dirty now - assert!(!world.has_dirty_entities()); + let dirty = world.ecs().get::<&DirtyFlags>(entity_handle).unwrap(); + assert!(dirty.is_empty()); } // --- Hierarchy tests --- @@ -1019,7 +947,7 @@ mod tests { // Verify parent/child relationship assert_eq!(world.parent_of(child), Some(parent)); - assert_eq!(world.children_of(parent), &[child]); + assert_eq!(world.children_of(parent), vec![child]); assert!(world.has_children(parent)); assert!(world.has_parent(child)); assert!(!world.has_parent(parent)); @@ -1031,7 +959,7 @@ mod tests { let mut world = World::new(); let parent = world.add_entity(make_test_entity()); - // Create a fake key by adding and removing an entity + // Create an invalid entity by adding and removing let temp = world.add_entity(make_test_entity()); world.remove_entity(temp); @@ -1143,9 +1071,6 @@ mod tests { world.add_child(parent, child).unwrap(); - // World transform of child should compose parent + child transforms - // With identity rotation and scale=1, compose just adds positions: - // parent.transform_point(child.position) = (10+1, 0+2, 0, 0) = (11, 2, 0, 0) let wt = world.world_transform(child).unwrap(); assert!((wt.position.x - 11.0).abs() < 0.001, "Expected x=11.0, got {}", wt.position.x); @@ -1167,7 +1092,6 @@ mod tests { world.add_child(parent, child).unwrap(); - // Parent composes: scale(2) * child_pos(1,0,0,0) + parent_pos(0,0,0,0) = (2, 0, 0, 0) let wt = world.world_transform(child).unwrap(); assert!((wt.position.x - 2.0).abs() < 0.001, "Expected x=2.0, got {}", wt.position.x); @@ -1192,9 +1116,9 @@ mod tests { assert_eq!(world.entity_count(), 0); // All should be gone - assert!(world.get_entity(root).is_none()); - assert!(world.get_entity(child1).is_none()); - assert!(world.get_entity(grandchild).is_none()); + assert!(!world.contains(root)); + assert!(!world.contains(child1)); + assert!(!world.contains(grandchild)); // Name index should be cleaned up assert!(world.get_by_name("root").is_none()); @@ -1219,11 +1143,11 @@ mod tests { assert_eq!(world.entity_count(), 2); // root and child2 should still exist - assert!(world.get_entity(root).is_some()); - assert!(world.get_entity(child2).is_some()); + assert!(world.contains(root)); + assert!(world.contains(child2)); // child1 should be removed from root's children - assert_eq!(world.children_of(root), &[child2]); + assert_eq!(world.children_of(root), vec![child2]); } #[test] @@ -1240,7 +1164,6 @@ mod tests { let desc = world.descendants(root); assert_eq!(desc.len(), 3); - // Breadth-first: child1, child2 first, then grandchild assert!(desc.contains(&child1)); assert!(desc.contains(&child2)); assert!(desc.contains(&grandchild)); @@ -1262,7 +1185,7 @@ mod tests { world.add_child(root1, child).unwrap(); - let roots: Vec = world.root_entities().map(|(k, _)| k).collect(); + let roots = world.root_entities(); assert_eq!(roots.len(), 2); assert!(roots.contains(&root1)); assert!(roots.contains(&root2)); @@ -1309,7 +1232,7 @@ mod tests { assert!(!world.has_parent(grandchild)); // Grandchild should still exist - assert!(world.get_entity(grandchild).is_some()); + assert!(world.contains(grandchild)); } #[test] @@ -1322,12 +1245,12 @@ mod tests { // First parent world.add_child(parent1, child).unwrap(); assert_eq!(world.parent_of(child), Some(parent1)); - assert_eq!(world.children_of(parent1), &[child]); + assert_eq!(world.children_of(parent1), vec![child]); // Reparent to parent2 world.add_child(parent2, child).unwrap(); assert_eq!(world.parent_of(child), Some(parent2)); - assert_eq!(world.children_of(parent2), &[child]); + assert_eq!(world.children_of(parent2), vec![child]); // Old parent should have no children assert!(!world.has_children(parent1)); @@ -1359,8 +1282,6 @@ mod tests { world.clear(); assert!(world.is_empty()); - // After clear, hierarchy maps should also be empty - // (verified implicitly: adding new entities won't have stale hierarchy) } #[test] @@ -1377,8 +1298,6 @@ mod tests { world.add_child(grandparent, parent).unwrap(); world.add_child(parent, child).unwrap(); - // World transform of child = grandparent compose parent compose child - // = (10+5+1, 0, 0, 0) = (16, 0, 0, 0) let wt = world.world_transform(child).unwrap(); assert!((wt.position.x - 16.0).abs() < 0.001, "Expected x=16.0, got {}", wt.position.x); diff --git a/crates/rust4d_core/tests/physics_integration.rs b/crates/rust4d_core/tests/physics_integration.rs index 6c838d4..2efe972 100644 --- a/crates/rust4d_core/tests/physics_integration.rs +++ b/crates/rust4d_core/tests/physics_integration.rs @@ -8,7 +8,7 @@ use rust4d_core::{ ActiveScene, Scene, World, EntityTemplate, Transform4D, Material, ShapeRef, - ShapeTemplate, + ShapeTemplate, DirtyFlags, PhysicsBody, }; use rust4d_physics::{ PhysicsConfig, PhysicsWorld, RigidBody4D, BodyType, StaticCollider, PhysicsMaterial, @@ -37,18 +37,19 @@ fn test_scene_dynamic_entity_has_physics_body() { let active = ActiveScene::from_template(&scene, None, 0.5); // Get the entity - let (_, entity) = active.world.get_by_name("tesseract") + let entity_handle = active.world.get_by_name("tesseract") .expect("Tesseract entity should exist"); // Verify physics body was created + let body_comp = active.world.ecs().get::<&PhysicsBody>(entity_handle); assert!( - entity.physics_body.is_some(), + body_comp.is_ok(), "Dynamic entity should have a physics body" ); // Verify the body exists in the physics world let physics = active.world.physics().expect("World should have physics"); - let body_key = entity.physics_body.unwrap(); + let body_key = body_comp.unwrap().0; let body = physics.get_body(body_key).expect("Physics body should exist"); // Verify body type is Dynamic @@ -237,15 +238,17 @@ fn test_entity_transform_syncs_from_physics() { world.update(0.1); // Entity should have new position - let (_, entity) = world.get_by_name("test").unwrap(); + let entity_handle = world.get_by_name("test").unwrap(); + let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); assert!( - entity.transform.position.y < 10.0, - "Entity should have moved. Y={}", entity.transform.position.y + transform.position.y < 10.0, + "Entity should have moved. Y={}", transform.position.y ); // Entity should be marked dirty + let dirty = world.ecs().get::<&DirtyFlags>(entity_handle).unwrap(); assert!( - entity.is_dirty(), + !dirty.is_empty(), "Entity should be marked dirty after position sync" ); } @@ -285,8 +288,8 @@ fn test_scene_dynamic_entity_falls_to_floor() { let mut active = ActiveScene::from_template(&scene, None, 0.5); // Get initial tesseract position - let initial_y = active.world.get_by_name("tesseract") - .unwrap().1.transform.position.y; + let entity_handle = active.world.get_by_name("tesseract").unwrap(); + let initial_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; // Simulate 2 seconds (120 frames at 60fps) for _ in 0..120 { @@ -294,8 +297,8 @@ fn test_scene_dynamic_entity_falls_to_floor() { } // Get final position - let (_, entity) = active.world.get_by_name("tesseract").unwrap(); - let final_y = entity.transform.position.y; + let entity_handle = active.world.get_by_name("tesseract").unwrap(); + let final_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; // Tesseract should have fallen assert!( @@ -304,7 +307,6 @@ fn test_scene_dynamic_entity_falls_to_floor() { ); // Tesseract should be near the floor (center at ~-1, bottom at -2) - // With size=2.0 (half_extent=1.0), center at y=-1 means bottom is at y=-2 (floor surface) assert!( final_y > -2.0, "Tesseract should be above floor surface. Y={}", final_y @@ -316,7 +318,8 @@ fn test_scene_dynamic_entity_falls_to_floor() { // Verify physics body is grounded let physics = active.world.physics().unwrap(); - let body = physics.get_body(entity.physics_body.unwrap()).unwrap(); + let body_comp = active.world.ecs().get::<&PhysicsBody>(entity_handle).unwrap(); + let body = physics.get_body(body_comp.0).unwrap(); assert!( body.grounded, "Tesseract physics body should be grounded" @@ -337,15 +340,15 @@ fn test_load_default_scene_file() { let mut active = ActiveScene::from_template(&scene, None, 0.5); // Verify tesseract entity exists and has physics body - let (_, entity) = active.world.get_by_name("tesseract") + let entity_handle = active.world.get_by_name("tesseract") .expect("Tesseract entity should exist in default scene"); assert!( - entity.physics_body.is_some(), + active.world.ecs().get::<&PhysicsBody>(entity_handle).is_ok(), "Tesseract should have physics body" ); - let initial_y = entity.transform.position.y; + let initial_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; // Simulate 2 seconds for _ in 0..120 { @@ -353,8 +356,8 @@ fn test_load_default_scene_file() { } // Get final position - let (_, entity) = active.world.get_by_name("tesseract").unwrap(); - let final_y = entity.transform.position.y; + let entity_handle = active.world.get_by_name("tesseract").unwrap(); + let final_y = active.world.ecs().get::<&Transform4D>(entity_handle).unwrap().position.y; // Tesseract should have fallen assert!( @@ -391,24 +394,20 @@ fn test_player_falls_off_w_edge() { let start_y = physics.player_position().unwrap().y; // Move player to W=6 (outside floor's W bounds of -5 to +5) - // Keep applying movement each frame (like the game loop does) for _ in 0..60 { - physics.apply_player_movement(Vec4::new(0.0, 0.0, 0.0, 10.0)); // W velocity + physics.apply_player_movement(Vec4::new(0.0, 0.0, 0.0, 10.0)); physics.step(1.0 / 60.0); } - // Player should now be at W > 5 (off the floor in W dimension) - // 10 units/s * 1 second = 10 units moved in W let pos = physics.player_position().unwrap(); assert!(pos.w > 5.0, "Player should have moved off W edge. W={}", pos.w); - // Player should NOT be grounded anymore (off the floor) assert!(!physics.player_is_grounded(), "Player should NOT be grounded when off W edge. W={}", pos.w); // Continue stepping - player should fall for _ in 0..60 { - physics.apply_player_movement(Vec4::ZERO); // Stop horizontal movement + physics.apply_player_movement(Vec4::ZERO); physics.step(1.0 / 60.0); } @@ -489,7 +488,7 @@ fn test_remove_entity_cleans_up_physics_body() { // Remove the entity let removed = world.remove_entity(entity_key); - assert!(removed.is_some(), "Entity should be removed"); + assert!(removed, "Entity should be removed"); // Verify physics body was also removed assert!( @@ -515,8 +514,9 @@ fn test_remove_entity_without_physics_body() { // Remove the entity - should not panic let removed = world.remove_entity(entity_key); - assert!(removed.is_some(), "Entity should be removed"); - assert!(removed.unwrap().physics_body.is_none(), "Removed entity should have no physics body"); + assert!(removed, "Entity should be removed"); + // Verify entity is gone + assert!(!world.contains(entity_key)); } /// Test that removing an entity in a world without physics works @@ -532,5 +532,5 @@ fn test_remove_entity_world_without_physics() { // Remove should work fine let removed = world.remove_entity(entity_key); - assert!(removed.is_some(), "Entity should be removed even without physics world"); + assert!(removed, "Entity should be removed even without physics world"); } -- knotmirror 0.0.1 From d0620d67c3b5fd394a77cff6392e7cc36d2d91d6 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:25:37 +0000 Subject: [PATCH 10/17] Port renderable.rs to ECS queries - Add add_components_with_color() method for decomposed ECS components - Rewrite from_world_with_color() to use hecs queries - Delegate add_entity_with_color() to new components method - Remove EntityKey from render crate re-exports - Add DirtyFlags, Tags to render crate re-exports Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_render/src/lib.rs | 2 +- crates/rust4d_render/src/renderable.rs | 38 ++++++++++++++++---------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/crates/rust4d_render/src/lib.rs b/crates/rust4d_render/src/lib.rs index ad508d2..ab0f7ad 100644 --- a/crates/rust4d_render/src/lib.rs +++ b/crates/rust4d_render/src/lib.rs @@ -22,7 +22,7 @@ pub mod pipeline; pub mod renderable; // Re-export core types for convenience -pub use rust4d_core::{World, Entity, Transform4D, Material, ShapeRef, EntityKey}; +pub use rust4d_core::{World, Entity, Transform4D, Material, ShapeRef, DirtyFlags, Tags}; pub use rust4d_core::{ConvexShape4D, Tetrahedron, Tesseract4D, Hyperplane4D}; pub use rust4d_core::{Vec4, Rotor4, RotationPlane}; diff --git a/crates/rust4d_render/src/renderable.rs b/crates/rust4d_render/src/renderable.rs index b6cd0b8..b8419ee 100644 --- a/crates/rust4d_render/src/renderable.rs +++ b/crates/rust4d_render/src/renderable.rs @@ -3,7 +3,7 @@ //! This module converts the abstract shape data from rust4d_core into //! GPU-compatible vertex and tetrahedra buffers. -use rust4d_core::{Entity, World, Material}; +use rust4d_core::{Entity, World, Material, Transform4D, ShapeRef}; use rust4d_math::Vec4; use crate::pipeline::{Vertex4D, GpuTetrahedron}; @@ -57,18 +57,13 @@ impl RenderableGeometry { } /// Collect geometry from all entities in a world with a custom color function + /// + /// Iterates all entities with Transform4D, ShapeRef, and Material components + /// using ECS queries. pub fn from_world_with_color(world: &World, color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4]) -> Self { - // Estimate capacity - let mut total_vertices = 0; - let mut total_tetrahedra = 0; - for entity in world.iter() { - total_vertices += entity.shape().vertex_count(); - total_tetrahedra += entity.shape().tetrahedron_count(); - } - - let mut result = Self::with_capacity(total_vertices, total_tetrahedra); - for entity in world.iter() { - result.add_entity_with_color(entity, color_fn); + let mut result = Self::new(); + for (_entity, (transform, shape, material)) in world.ecs().query::<(&Transform4D, &ShapeRef, &Material)>().iter() { + result.add_components_with_color(transform, shape.as_shape(), material, color_fn); } result } @@ -82,13 +77,26 @@ impl RenderableGeometry { /// Add an entity's geometry with a custom color function pub fn add_entity_with_color(&mut self, entity: &Entity, color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4]) { - let shape = entity.shape(); + self.add_components_with_color(&entity.transform, entity.shape(), &entity.material, color_fn); + } + + /// Add geometry from individual ECS components with a custom color function + /// + /// This is the core method that works with decomposed components rather than + /// a monolithic Entity struct. + pub fn add_components_with_color( + &mut self, + transform: &Transform4D, + shape: &dyn rust4d_math::ConvexShape4D, + material: &Material, + color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4], + ) { let vertex_offset = self.vertices.len(); // Transform and add vertices for v in shape.vertices() { - let world_pos = entity.transform.transform_point(*v); - let color = color_fn(v, &entity.material); + let world_pos = transform.transform_point(*v); + let color = color_fn(v, material); self.vertices.push(Vertex4D::new( [world_pos.x, world_pos.y, world_pos.z, world_pos.w], color, -- knotmirror 0.0.1 From 2b9bc2de70da7c4264cf73bd2b3f049795abe53e Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:25:42 +0000 Subject: [PATCH 11/17] Update main.rs build_geometry to use ECS queries Replace world.iter_with_keys() with hecs query for Transform4D, ShapeRef, Material, and optional Tags components. Co-Authored-By: Claude Opus 4.5 --- src/main.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1dc0ccf..17f5aa4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use winit::{ use input::{InputMapper, InputAction}; use systems::{RenderError, RenderSystem, SimulationSystem, WindowSystem}; -use rust4d_core::{World, SceneManager}; +use rust4d_core::{World, SceneManager, Transform4D, ShapeRef, Material, Tags}; use rust4d_render::{ camera4d::Camera4D, RenderableGeometry, CheckerboardGeometry, position_gradient_color, @@ -127,13 +127,18 @@ impl App { 2.0, // Cell size ); - for (_key, entity) in world.iter_with_keys() { - if entity.has_tag("dynamic") { + // Query all renderable entities (Transform4D + ShapeRef + Material) + // Optionally check Tags for coloring strategy + for (_entity, (transform, shape, material, tags)) in + world.ecs().query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>().iter() + { + let is_dynamic = tags.map(|t| t.has("dynamic")).unwrap_or(false); + if is_dynamic { // Dynamic entities (tesseract): use position gradient - geometry.add_entity_with_color(entity, &position_gradient_color); + geometry.add_components_with_color(transform, shape.as_shape(), material, &position_gradient_color); } else { // Static entities (floor): use checkerboard pattern - geometry.add_entity_with_color(entity, &|v, _m| { + geometry.add_components_with_color(transform, shape.as_shape(), material, &|v, _m| { checkerboard.color_for_position(v.x, v.z) }); } -- knotmirror 0.0.1 From c85021d64bcc72050bce4e36be896ada211b6a6a Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:25:47 +0000 Subject: [PATCH 12/17] Port examples 03 and 04 to ECS queries Replace world.iter() with hecs queries for entity iteration in build_geometry() functions. Co-Authored-By: Claude Opus 4.5 --- examples/03_physics_demo.rs | 13 ++++++++----- examples/04_camera_exploration.rs | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/examples/03_physics_demo.rs b/examples/03_physics_demo.rs index b0bd4d4..a84e440 100644 --- a/examples/03_physics_demo.rs +++ b/examples/03_physics_demo.rs @@ -21,7 +21,7 @@ use winit::{ }; use rust4d_core::{ - Entity, Material, ShapeRef, Tesseract4D, Transform4D, World, + Entity, Material, ShapeRef, Tesseract4D, Transform4D, World, Tags, PhysicsConfig, RigidBody4D, StaticCollider, Hyperplane4D, }; use rust4d_render::{ @@ -134,11 +134,14 @@ impl App { 2.0, ); - for entity in world.iter() { - if entity.has_tag("dynamic") { - geometry.add_entity_with_color(entity, &position_gradient_color); + for (_entity, (transform, shape, material, tags)) in + world.ecs().query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>().iter() + { + let is_dynamic = tags.map(|t| t.has("dynamic")).unwrap_or(false); + if is_dynamic { + geometry.add_components_with_color(transform, shape.as_shape(), material, &position_gradient_color); } else { - geometry.add_entity_with_color(entity, &|v, _m| { + geometry.add_components_with_color(transform, shape.as_shape(), material, &|v, _m| { checkerboard.color_for_position(v.x, v.z) }); } diff --git a/examples/04_camera_exploration.rs b/examples/04_camera_exploration.rs index 5753417..9e154ae 100644 --- a/examples/04_camera_exploration.rs +++ b/examples/04_camera_exploration.rs @@ -34,7 +34,7 @@ use winit::{ }; use rust4d_core::{ - Entity, Material, ShapeRef, Tesseract4D, Transform4D, World, + Entity, Material, ShapeRef, Tesseract4D, Transform4D, World, Tags, Hyperplane4D, }; use rust4d_render::{ @@ -135,11 +135,14 @@ impl App { 2.0, ); - for entity in world.iter() { - if entity.has_tag("dynamic") { - geometry.add_entity_with_color(entity, &position_gradient_color); + for (_entity, (transform, shape, material, tags)) in + world.ecs().query::<(&Transform4D, &ShapeRef, &Material, Option<&Tags>)>().iter() + { + let is_dynamic = tags.map(|t| t.has("dynamic")).unwrap_or(false); + if is_dynamic { + geometry.add_components_with_color(transform, shape.as_shape(), material, &position_gradient_color); } else { - geometry.add_entity_with_color(entity, &|v, _m| { + geometry.add_components_with_color(transform, shape.as_shape(), material, &|v, _m| { checkerboard.color_for_position(v.x, v.z) }); } -- knotmirror 0.0.1 From 20b596dbdac7a1e1971e6d64e697975053d190cc Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:37:18 +0000 Subject: [PATCH 13/17] Invert winding order sign to fix back-face culling The determinant + case parity formula was producing inward-facing normals, causing visible faces to be culled. Negate the condition from != to == so outward-facing normals are produced correctly. Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_render/src/shaders/slice_tetra.wgsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rust4d_render/src/shaders/slice_tetra.wgsl b/crates/rust4d_render/src/shaders/slice_tetra.wgsl index a310832..fb5a07a 100644 --- a/crates/rust4d_render/src/shaders/slice_tetra.wgsl +++ b/crates/rust4d_render/src/shaders/slice_tetra.wgsl @@ -261,7 +261,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let signed_vol = dot(e1, cross(e2, e3)); // XOR determinant sign with case parity to get consistent outward-facing normals // case_idx >= 8 means the complement half of the lookup table - let should_flip = (signed_vol > 0.0) != (case_idx >= 8u); + let should_flip = (signed_vol > 0.0) == (case_idx >= 8u); // Get edge mask and triangle count let edge_mask = TETRA_EDGE_TABLE[case_idx]; -- knotmirror 0.0.1 From 098e847ef3cb25c598dfc0f8c52590ad1ff2575e Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 17:38:28 +0000 Subject: [PATCH 14/17] Fix winding order: use camera-at-origin instead of determinant The determinant + case parity approach didn't produce correct winding for all cases. The real bug was simpler: the original camera-facing flip used params.camera_eye (world space) but vertices are in camera space where the camera is at the origin. Use -tri_center as the to-camera direction since the camera IS the origin in camera space. Co-Authored-By: Claude Opus 4.5 --- .../src/shaders/slice_tetra.wgsl | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/crates/rust4d_render/src/shaders/slice_tetra.wgsl b/crates/rust4d_render/src/shaders/slice_tetra.wgsl index fb5a07a..34638e0 100644 --- a/crates/rust4d_render/src/shaders/slice_tetra.wgsl +++ b/crates/rust4d_render/src/shaders/slice_tetra.wgsl @@ -253,16 +253,6 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { return; } - // Compute signed volume of the tetrahedron in camera space - // This determines the handedness (orientation) of the tetrahedron - let e1 = pos[1].xyz - pos[0].xyz; - let e2 = pos[2].xyz - pos[0].xyz; - let e3 = pos[3].xyz - pos[0].xyz; - let signed_vol = dot(e1, cross(e2, e3)); - // XOR determinant sign with case parity to get consistent outward-facing normals - // case_idx >= 8 means the complement half of the lookup table - let should_flip = (signed_vol > 0.0) == (case_idx >= 8u); - // Get edge mask and triangle count let edge_mask = TETRA_EDGE_TABLE[case_idx]; let tri_count = TETRA_TRI_COUNT[case_idx]; @@ -303,10 +293,14 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let p2 = vertex_position(tv2); var normal = compute_normal(p0, p1, p2); - // Use determinant + case parity to determine winding order - // This produces consistent outward-facing normals regardless of - // camera position or tetrahedron orientation - if (should_flip) { + // Ensure normals face toward the camera. + // In camera space the camera is at the origin, so the direction + // from the triangle to the camera is simply -tri_center. + // (The old bug used params.camera_eye which was in world space, + // not camera space, causing incorrect flips when the camera rotated.) + let tri_center = (p0 + p1 + p2) / 3.0; + let to_camera = -tri_center; + if (dot(normal, to_camera) < 0.0) { let tmp = tv1; tv1 = tv2; tv2 = tmp; -- knotmirror 0.0.1 From 41d1ca48a9f1cf85dd15766155c86e83ae97c4ca Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 18:23:54 +0000 Subject: [PATCH 15/17] Remove slotmap dependency from rust4d_core rust4d_core no longer uses slotmap directly after the hecs migration. BodyKey comes from rust4d_physics which has its own slotmap dep. Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 1 - crates/rust4d_core/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5476446..19f2157 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,7 +1584,6 @@ dependencies = [ "rust4d_math", "rust4d_physics", "serde", - "slotmap", ] [[package]] diff --git a/crates/rust4d_core/Cargo.toml b/crates/rust4d_core/Cargo.toml index 5f007d0..41c1919 100644 --- a/crates/rust4d_core/Cargo.toml +++ b/crates/rust4d_core/Cargo.toml @@ -7,7 +7,6 @@ description = "Core types for the Rust4D engine: Transform4D, Entity, World" [dependencies] rust4d_math.workspace = true rust4d_physics.workspace = true -slotmap.workspace = true hecs.workspace = true bitflags.workspace = true serde = { version = "1.0", features = ["derive"] } -- knotmirror 0.0.1 From 561ddb559e46734145a189b8787662ff4d861465 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 18:24:11 +0000 Subject: [PATCH 16/17] Remove Entity struct, add World safety and performance features Entity struct removal (Priority 1): - Delete Entity struct and all builder methods from entity.rs - Add EntityTemplate::spawn_in(&mut World) using hecs::EntityBuilder - Remove add_entity(), remove_entity() from World - Rename ecs_mut() to ecs_mut_unchecked() with safety docs - Add pub type EntityId = hecs::Entity re-export - Remove Entity from lib.rs exports - Update scene.rs from_template() to use spawn_in() World safety (Priority 2): - Name uniqueness warning in spawn() on collision - Add rename_entity(entity, new_name) method - Add rebuild_name_index() recovery method - Add world_transform() cycle guard (MAX_DEPTH: 64) - Log warning on physics body removal failure in despawn() - Add validate_hierarchy() debug method Performance (Priority 3): - Add tag_index HashMap> maintained in spawn/despawn - Add roots HashSet maintained in spawn/despawn/hierarchy - Children::remove() uses swap_remove for O(1) removal - Add dirty_count side-table for O(1) has_dirty_entities() Tests (Priority 4): - Name collision, ecs_mut_unchecked index corruption - world_transform without Transform4D, cycle guard - Physics body cleanup on despawn - Hierarchy validation with corrupted state - All existing tests ported from Entity pattern to world.spawn() Co-Authored-By: Claude Opus 4.5 --- crates/rust4d_core/src/components.rs | 4 +- crates/rust4d_core/src/entity.rs | 326 ++-------- crates/rust4d_core/src/lib.rs | 5 +- crates/rust4d_core/src/scene.rs | 37 +- crates/rust4d_core/src/scene_manager.rs | 22 +- crates/rust4d_core/src/world.rs | 811 ++++++++++++++++++------ 6 files changed, 727 insertions(+), 478 deletions(-) diff --git a/crates/rust4d_core/src/components.rs b/crates/rust4d_core/src/components.rs index 38ab252..f95f1b8 100644 --- a/crates/rust4d_core/src/components.rs +++ b/crates/rust4d_core/src/components.rs @@ -97,7 +97,9 @@ impl Children { } pub fn remove(&mut self, child: hecs::Entity) { - self.0.retain(|&c| c != child); + if let Some(pos) = self.0.iter().position(|&c| c == child) { + self.0.swap_remove(pos); + } } pub fn contains(&self, child: hecs::Entity) -> bool { diff --git a/crates/rust4d_core/src/entity.rs b/crates/rust4d_core/src/entity.rs index 1f10978..8a38086 100644 --- a/crates/rust4d_core/src/entity.rs +++ b/crates/rust4d_core/src/entity.rs @@ -1,12 +1,13 @@ -//! Entity and Material types +//! Entity-related types: Material, ShapeRef, DirtyFlags, EntityTemplate //! -//! An Entity represents an object in the 4D world with a transform, shape, and material. +//! These types represent visual and tracking properties of entities in the 4D world. +//! The Entity struct has been removed -- entities are now spawned directly as +//! ECS component tuples via `world.spawn(...)` or `EntityTemplate::spawn_in(...)`. use std::collections::HashSet; use std::sync::Arc; use bitflags::bitflags; use rust4d_math::ConvexShape4D; -use rust4d_physics::BodyKey; use serde::{Serialize, Deserialize}; use crate::Transform4D; use crate::shapes::ShapeTemplate; @@ -108,158 +109,11 @@ impl ShapeRef { } } -/// An entity in the 4D world -/// -/// Each entity has: -/// - An optional name (for lookup by name) -/// - Tags (for categorization and filtering) -/// - A transform (position, rotation, scale) -/// - A shape (the geometry) -/// - A material (visual properties) -/// - An optional physics body key (links to PhysicsWorld) -/// - Dirty flags (for change tracking) -pub struct Entity { - /// Optional name for this entity (for lookup) - pub name: Option, - /// Tags for categorization (e.g., "dynamic", "static", "enemy") - pub tags: HashSet, - /// The entity's transform in world space - pub transform: Transform4D, - /// The entity's shape - pub shape: ShapeRef, - /// The entity's material - pub material: Material, - /// Optional physics body key (links to PhysicsWorld) - pub physics_body: Option, - /// Dirty flags for change tracking (what needs rebuilding) - dirty: DirtyFlags, -} - -impl Entity { - /// Create a new entity with the given shape - pub fn new(shape: ShapeRef) -> Self { - Self { - name: None, - tags: HashSet::new(), - transform: Transform4D::identity(), - shape, - material: Material::default(), - physics_body: None, - dirty: DirtyFlags::ALL, // New entities are dirty - } - } - - /// Create a new entity with shape and material - pub fn with_material(shape: ShapeRef, material: Material) -> Self { - Self { - name: None, - tags: HashSet::new(), - transform: Transform4D::identity(), - shape, - material, - physics_body: None, - dirty: DirtyFlags::ALL, // New entities are dirty - } - } - - /// Create a new entity with shape, transform, and material - pub fn with_transform(shape: ShapeRef, transform: Transform4D, material: Material) -> Self { - Self { - name: None, - tags: HashSet::new(), - transform, - shape, - material, - physics_body: None, - dirty: DirtyFlags::ALL, // New entities are dirty - } - } - - /// Set the name of this entity (for lookup) - pub fn with_name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - /// Add a tag to this entity - pub fn with_tag(mut self, tag: impl Into) -> Self { - self.tags.insert(tag.into()); - self - } - - /// Add multiple tags to this entity - pub fn with_tags(mut self, tags: impl IntoIterator>) -> Self { - for tag in tags { - self.tags.insert(tag.into()); - } - self - } - - /// Check if this entity has a specific tag - pub fn has_tag(&self, tag: &str) -> bool { - self.tags.contains(tag) - } - - /// Attach a physics body to this entity - pub fn with_physics_body(mut self, key: BodyKey) -> Self { - self.physics_body = Some(key); - self - } - - /// Get the shape of this entity - pub fn shape(&self) -> &dyn ConvexShape4D { - self.shape.as_shape() - } - - // --- Dirty tracking methods --- - - /// Check if this entity has any dirty flags set - #[inline] - pub fn is_dirty(&self) -> bool { - !self.dirty.is_empty() - } - - /// Get the current dirty flags - #[inline] - pub fn dirty_flags(&self) -> DirtyFlags { - self.dirty - } - - /// Mark this entity as dirty with the given flags - #[inline] - pub fn mark_dirty(&mut self, flags: DirtyFlags) { - self.dirty |= flags; - } - - /// Clear all dirty flags - #[inline] - pub fn clear_dirty(&mut self) { - self.dirty = DirtyFlags::NONE; - } - - /// Set the position and mark the transform as dirty - pub fn set_position(&mut self, position: rust4d_math::Vec4) { - self.transform.position = position; - self.mark_dirty(DirtyFlags::TRANSFORM); - } - - /// Set the transform and mark it as dirty - pub fn set_transform(&mut self, transform: Transform4D) { - self.transform = transform; - self.mark_dirty(DirtyFlags::TRANSFORM); - } - - /// Set the material and mark it as dirty - pub fn set_material(&mut self, material: Material) { - self.material = material; - self.mark_dirty(DirtyFlags::MATERIAL); - } -} - /// A serializable entity template /// -/// EntityTemplate is used for scene serialization. Unlike Entity, it stores -/// a ShapeTemplate (enum) rather than a trait object, making it serializable. +/// EntityTemplate is used for scene serialization. It stores a ShapeTemplate (enum) +/// rather than a trait object, making it serializable. Use `spawn_in()` to +/// instantiate as an entity in a World. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EntityTemplate { /// Optional name for this entity (for lookup) @@ -299,28 +153,33 @@ impl EntityTemplate { self } - /// Convert this template to an Entity - pub fn to_entity(&self) -> Entity { + /// Spawn this template as an entity in the given World + /// + /// Creates the shape from the template and spawns an entity with all + /// configured components (shape, transform, material, dirty flags, + /// optional name and tags). + pub fn spawn_in(&self, world: &mut crate::World) -> hecs::Entity { let shape = self.shape.create_shape(); - let mut entity = Entity::with_transform( - ShapeRef::Owned(shape), - self.transform, - self.material, - ); + let mut builder = hecs::EntityBuilder::new(); + builder.add(ShapeRef::Owned(shape)); + builder.add(self.transform); + builder.add(self.material); + builder.add(DirtyFlags::ALL); if let Some(ref name) = self.name { - entity = entity.with_name(name.clone()); + builder.add(crate::components::Name(name.clone())); } - for tag in &self.tags { - entity = entity.with_tag(tag.clone()); + if !self.tags.is_empty() { + let tags: HashSet = self.tags.iter().cloned().collect(); + builder.add(crate::components::Tags(tags)); } - entity + world.spawn(builder.build()) } } #[cfg(test)] mod tests { use super::*; - use rust4d_math::{Vec4, Tesseract4D}; + use rust4d_math::Tesseract4D; #[test] fn test_material_default() { @@ -366,40 +225,6 @@ mod tests { assert_eq!(shape_ref.as_shape().vertex_count(), 16); } - #[test] - fn test_entity_new() { - let tesseract = Tesseract4D::new(2.0); - let entity = Entity::new(ShapeRef::shared(tesseract)); - - assert_eq!(entity.shape().vertex_count(), 16); - assert_eq!(entity.material.base_color, [1.0, 1.0, 1.0, 1.0]); - } - - #[test] - fn test_entity_with_material() { - let tesseract = Tesseract4D::new(2.0); - let entity = Entity::with_material( - ShapeRef::shared(tesseract), - Material::RED, - ); - - assert_eq!(entity.material.base_color, [1.0, 0.0, 0.0, 1.0]); - } - - #[test] - fn test_entity_with_transform() { - let tesseract = Tesseract4D::new(2.0); - let transform = Transform4D::from_position(Vec4::new(1.0, 2.0, 3.0, 4.0)); - let entity = Entity::with_transform( - ShapeRef::shared(tesseract), - transform, - Material::BLUE, - ); - - assert_eq!(entity.transform.position.x, 1.0); - assert_eq!(entity.material.base_color, [0.0, 0.0, 1.0, 1.0]); - } - // --- Dirty tracking tests --- #[test] @@ -426,93 +251,36 @@ mod tests { } #[test] - fn test_new_entity_is_dirty() { - let tesseract = Tesseract4D::new(2.0); - let entity = Entity::new(ShapeRef::shared(tesseract)); - - assert!(entity.is_dirty()); - assert_eq!(entity.dirty_flags(), DirtyFlags::ALL); - } - - #[test] - fn test_entity_clear_dirty() { - let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - - assert!(entity.is_dirty()); - entity.clear_dirty(); - assert!(!entity.is_dirty()); - assert_eq!(entity.dirty_flags(), DirtyFlags::NONE); - } - - #[test] - fn test_entity_mark_dirty() { - let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - entity.clear_dirty(); - - assert!(!entity.is_dirty()); + fn test_entity_template_spawn_in() { + use crate::shapes::ShapeTemplate; + use rust4d_math::Vec4; - entity.mark_dirty(DirtyFlags::TRANSFORM); - assert!(entity.is_dirty()); - assert!(entity.dirty_flags().contains(DirtyFlags::TRANSFORM)); - assert!(!entity.dirty_flags().contains(DirtyFlags::MESH)); - } - - #[test] - fn test_set_position_marks_dirty() { - let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - entity.clear_dirty(); - - entity.set_position(Vec4::new(1.0, 2.0, 3.0, 4.0)); - - assert!(entity.is_dirty()); - assert!(entity.dirty_flags().contains(DirtyFlags::TRANSFORM)); - assert_eq!(entity.transform.position.x, 1.0); - } - - #[test] - fn test_set_transform_marks_dirty() { - let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - entity.clear_dirty(); - - let new_transform = Transform4D::from_position(Vec4::new(5.0, 6.0, 7.0, 8.0)); - entity.set_transform(new_transform); + let template = EntityTemplate::new( + ShapeTemplate::tesseract(2.0), + Transform4D::from_position(Vec4::new(1.0, 2.0, 3.0, 4.0)), + Material::RED, + ).with_name("my_cube").with_tag("dynamic"); - assert!(entity.is_dirty()); - assert!(entity.dirty_flags().contains(DirtyFlags::TRANSFORM)); - assert_eq!(entity.transform.position.x, 5.0); - } + let mut world = crate::World::new(); + let entity = template.spawn_in(&mut world); - #[test] - fn test_set_material_marks_dirty() { - let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - entity.clear_dirty(); + // Verify components + let name = world.ecs().get::<&crate::components::Name>(entity).unwrap(); + assert_eq!(name.0, "my_cube"); - entity.set_material(Material::RED); + let tags = world.ecs().get::<&crate::components::Tags>(entity).unwrap(); + assert!(tags.has("dynamic")); - assert!(entity.is_dirty()); - assert!(entity.dirty_flags().contains(DirtyFlags::MATERIAL)); - assert!(!entity.dirty_flags().contains(DirtyFlags::TRANSFORM)); - assert_eq!(entity.material.base_color, [1.0, 0.0, 0.0, 1.0]); - } + let transform = world.ecs().get::<&Transform4D>(entity).unwrap(); + assert_eq!(transform.position.x, 1.0); - #[test] - fn test_mark_dirty_combines_flags() { - let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - entity.clear_dirty(); + let material = world.ecs().get::<&Material>(entity).unwrap(); + assert_eq!(material.base_color, [1.0, 0.0, 0.0, 1.0]); - entity.mark_dirty(DirtyFlags::TRANSFORM); - entity.mark_dirty(DirtyFlags::MATERIAL); + let shape = world.ecs().get::<&ShapeRef>(entity).unwrap(); + assert_eq!(shape.as_shape().vertex_count(), 16); - // Both flags should be set - let flags = entity.dirty_flags(); - assert!(flags.contains(DirtyFlags::TRANSFORM)); - assert!(flags.contains(DirtyFlags::MATERIAL)); - assert!(!flags.contains(DirtyFlags::MESH)); + // Verify name index works + assert!(world.get_by_name("my_cube").is_some()); } } diff --git a/crates/rust4d_core/src/lib.rs b/crates/rust4d_core/src/lib.rs index 0769d27..a017bf7 100644 --- a/crates/rust4d_core/src/lib.rs +++ b/crates/rust4d_core/src/lib.rs @@ -25,7 +25,7 @@ mod scene_loader; mod scene_validator; pub use transform::Transform4D; -pub use entity::{Material, Entity, ShapeRef, DirtyFlags, EntityTemplate}; +pub use entity::{Material, ShapeRef, DirtyFlags, EntityTemplate}; pub use world::{World, HierarchyError}; pub use components::{Name, Tags, PhysicsBody, Parent, Children}; pub use shapes::ShapeTemplate; @@ -37,6 +37,9 @@ pub use scene_transition::{SceneTransition, TransitionEffect, SlideDirection}; pub use scene_loader::{SceneLoader, LoadResult}; pub use scene_validator::{SceneValidator, ValidationError}; +/// Type alias for entity handles (hecs::Entity) +pub type EntityId = hecs::Entity; + // Re-export commonly used types from rust4d_math for convenience pub use rust4d_math::{Vec4, Rotor4, RotationPlane, ConvexShape4D, Tetrahedron}; pub use rust4d_math::{Tesseract4D, Hyperplane4D}; diff --git a/crates/rust4d_core/src/scene.rs b/crates/rust4d_core/src/scene.rs index dd3c1ff..5e471ff 100644 --- a/crates/rust4d_core/src/scene.rs +++ b/crates/rust4d_core/src/scene.rs @@ -10,6 +10,7 @@ use std::io; use crate::entity::EntityTemplate; use crate::shapes::ShapeTemplate; +use crate::components::PhysicsBody; use crate::World; use rust4d_math::Vec4; use rust4d_physics::{PhysicsConfig, RigidBody4D, StaticCollider, BodyType, PhysicsMaterial}; @@ -241,10 +242,12 @@ impl ActiveScene { // Instantiate all entities from the template, setting up physics based on tags for entity_template in &template.entities { - let mut entity = entity_template.to_entity(); let is_static = entity_template.tags.contains(&"static".to_string()); let is_dynamic = entity_template.tags.contains(&"dynamic".to_string()); + // Determine physics body key before spawning + let mut body_key = None; + if let Some(physics) = world.physics_mut() { if is_static { // Create bounded static collider for floor/walls (objects can fall off edges) @@ -282,12 +285,14 @@ impl ActiveScene { .with_mass(10.0) .with_material(PhysicsMaterial::WOOD); - let body_key = physics.add_body(body); - entity = entity.with_physics_body(body_key); + body_key = Some(physics.add_body(body)); } } - world.add_entity(entity); + let entity_handle = entity_template.spawn_in(&mut world); + if let Some(bk) = body_key { + let _ = world.ecs_mut_unchecked().insert_one(entity_handle, PhysicsBody(bk)); + } } // Create player body from player_spawn @@ -483,20 +488,30 @@ Scene( } #[test] - fn test_entity_template_to_entity() { + fn test_entity_template_spawn_in() { let template = EntityTemplate::new( ShapeTemplate::tesseract(2.0), Transform4D::from_position(Vec4::new(1.0, 2.0, 3.0, 4.0)), Material::RED, ).with_name("my_cube").with_tag("dynamic"); - let entity = template.to_entity(); + let mut world = crate::World::new(); + let entity = template.spawn_in(&mut world); + + let name = world.ecs().get::<&crate::Name>(entity).unwrap(); + assert_eq!(name.0, "my_cube"); + + let tags = world.ecs().get::<&crate::Tags>(entity).unwrap(); + assert!(tags.has("dynamic")); + + let transform = world.ecs().get::<&Transform4D>(entity).unwrap(); + assert_eq!(transform.position.x, 1.0); + + let material = world.ecs().get::<&Material>(entity).unwrap(); + assert_eq!(material.base_color, [1.0, 0.0, 0.0, 1.0]); - assert_eq!(entity.name, Some("my_cube".to_string())); - assert!(entity.has_tag("dynamic")); - assert_eq!(entity.transform.position.x, 1.0); - assert_eq!(entity.material.base_color, [1.0, 0.0, 0.0, 1.0]); - assert_eq!(entity.shape().vertex_count(), 16); // Tesseract has 16 vertices + let shape = world.ecs().get::<&crate::ShapeRef>(entity).unwrap(); + assert_eq!(shape.as_shape().vertex_count(), 16); // Tesseract has 16 vertices } // --- SceneError tests --- diff --git a/crates/rust4d_core/src/scene_manager.rs b/crates/rust4d_core/src/scene_manager.rs index 9c31afa..293279d 100644 --- a/crates/rust4d_core/src/scene_manager.rs +++ b/crates/rust4d_core/src/scene_manager.rs @@ -355,12 +355,17 @@ impl SceneManager { #[cfg(test)] mod tests { use super::*; - use crate::{Entity, ShapeRef}; + use crate::{ShapeRef, Material, DirtyFlags, Transform4D, Name}; use rust4d_math::Tesseract4D; - fn make_test_entity() -> Entity { + fn spawn_test_entity(world: &mut World) -> hecs::Entity { let tesseract = Tesseract4D::new(2.0); - Entity::new(ShapeRef::shared(tesseract)) + world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + )) } #[test] @@ -500,7 +505,14 @@ mod tests { // Create scene with an entity let mut scene = ActiveScene::new("Test"); - scene.world.add_entity(make_test_entity().with_name("cube")); + let tesseract = Tesseract4D::new(2.0); + scene.world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name("cube".to_string()), + )); manager.register_active_scene("test", scene); manager.push_scene("test").unwrap(); @@ -526,7 +538,7 @@ mod tests { { let world = manager.active_world_mut(); assert!(world.is_some()); - world.unwrap().add_entity(make_test_entity()); + spawn_test_entity(world.unwrap()); } // Verify entity was added diff --git a/crates/rust4d_core/src/world.rs b/crates/rust4d_core/src/world.rs index d333fe0..d69e64f 100644 --- a/crates/rust4d_core/src/world.rs +++ b/crates/rust4d_core/src/world.rs @@ -1,10 +1,10 @@ //! World container for entities (hecs ECS backend) //! //! The World manages all entities in the simulation using hecs for storage. -//! It provides side-tables for name lookups and physics integration, +//! It provides side-tables for name lookups, tag indexing, and physics integration, //! and stores hierarchy as Parent/Children components. -use std::collections::{HashMap, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fmt; use crate::components::*; use crate::{DirtyFlags, Transform4D}; @@ -37,6 +37,9 @@ impl std::error::Error for HierarchyError {} /// /// Thin wrapper around hecs::World with side-tables for: /// - Name lookups (HashMap) +/// - Tag index (HashMap>) +/// - Root entity tracking (HashSet) +/// - Dirty entity count /// - Physics simulation (optional PhysicsWorld) /// /// Hierarchy is stored as ECS components (Parent, Children) rather @@ -46,6 +49,12 @@ pub struct World { ecs: hecs::World, /// Index from entity names to hecs entities (for fast name lookup) name_index: HashMap, + /// Index from tags to entities (for fast tag queries) + tag_index: HashMap>, + /// Set of root entities (no Parent component) + roots: HashSet, + /// Count of entities with non-empty DirtyFlags + dirty_count: usize, /// Optional physics simulation physics_world: Option, } @@ -62,6 +71,9 @@ impl World { Self { ecs: hecs::World::new(), name_index: HashMap::new(), + tag_index: HashMap::new(), + roots: HashSet::new(), + dirty_count: 0, physics_world: None, } } @@ -89,43 +101,42 @@ impl World { /// This is the PRIMARY way to create entities in the ECS API. /// Returns the hecs::Entity handle. /// - /// After spawning, checks for a Name component and updates the name index. + /// After spawning, checks for Name and Tags components and updates indices. + /// If a Name component is present and the name already exists in the index, + /// a warning is logged and the index is updated to point to the new entity + /// (the old entity becomes unreachable by name). pub fn spawn(&mut self, components: impl hecs::DynamicBundle) -> hecs::Entity { let entity = self.ecs.spawn(components); // Check if spawned entity has a Name component and index it if let Ok(name) = self.ecs.get::<&Name>(entity) { - self.name_index.insert(name.0.clone(), entity); + let name_str = name.0.clone(); + drop(name); + if let Some(_old) = self.name_index.insert(name_str.clone(), entity) { + log::warn!("Name '{}' already exists in world; overwriting index entry", name_str); + } } - entity - } - - /// Legacy compatibility: spawn from the old Entity struct - /// - /// TRANSITIONAL METHOD -- decomposes the monolithic Entity into individual - /// ECS components and spawns them. - pub fn add_entity(&mut self, entity: crate::entity::Entity) -> hecs::Entity { - let mut builder = hecs::EntityBuilder::new(); - - builder.add(entity.transform); - builder.add(entity.shape); - builder.add(entity.material); - builder.add(DirtyFlags::ALL); - - if let Some(name) = entity.name { - builder.add(Name(name)); + // Check if spawned entity has Tags and index them + if let Ok(tags) = self.ecs.get::<&Tags>(entity) { + let tag_set: Vec = tags.0.iter().cloned().collect(); + drop(tags); + for tag in tag_set { + self.tag_index.entry(tag).or_default().insert(entity); + } } - if !entity.tags.is_empty() { - builder.add(Tags(entity.tags)); - } + // Track as root (new entities have no parent) + self.roots.insert(entity); - if let Some(body_key) = entity.physics_body { - builder.add(PhysicsBody(body_key)); + // Track dirty count + if let Ok(dirty) = self.ecs.get::<&DirtyFlags>(entity) { + if !dirty.is_empty() { + self.dirty_count += 1; + } } - self.spawn(builder.build()) + entity } // === Entity Removal === @@ -134,6 +145,9 @@ impl World { /// /// Cleans up: /// - Name index (if entity had Name component) + /// - Tag index (if entity had Tags component) + /// - Root tracking + /// - Dirty count /// - Physics body (if entity had PhysicsBody component) /// - Hierarchy (removes from parent, orphans children) /// @@ -150,12 +164,37 @@ impl World { self.name_index.remove(&name_str); } + // Clean up tag index + if let Ok(tags) = self.ecs.get::<&Tags>(entity) { + let tag_set: Vec = tags.0.iter().cloned().collect(); + drop(tags); + for tag in tag_set { + if let Some(set) = self.tag_index.get_mut(&tag) { + set.remove(&entity); + // Don't remove empty sets here for performance + } + } + } + + // Clean up dirty count + if let Ok(dirty) = self.ecs.get::<&DirtyFlags>(entity) { + if !dirty.is_empty() && self.dirty_count > 0 { + self.dirty_count -= 1; + } + } + + // Clean up root tracking + self.roots.remove(&entity); + // Clean up physics body if let Ok(body) = self.ecs.get::<&PhysicsBody>(entity) { let body_key = body.0; drop(body); if let Some(ref mut physics) = self.physics_world { - physics.remove_body(body_key); + let result = physics.remove_body(body_key); + if result.is_none() { + log::warn!("Physics body {:?} not found during entity despawn cleanup", body_key); + } } } @@ -167,23 +206,19 @@ impl World { } } - // Orphan all children (remove Parent component from them) + // Orphan all children (remove Parent component, add them to roots) let child_list: Vec = self.ecs.get::<&Children>(entity) .ok() .map(|c| c.0.clone()) .unwrap_or_default(); for child in child_list { let _ = self.ecs.remove_one::(child); + self.roots.insert(child); } self.ecs.despawn(entity).is_ok() } - /// Legacy alias for despawn - pub fn remove_entity(&mut self, entity: hecs::Entity) -> bool { - self.despawn(entity) - } - // === Entity Access === /// Check if an entity exists @@ -191,13 +226,14 @@ impl World { self.ecs.contains(entity) } - /// Direct access to the underlying hecs World (for advanced queries) + /// Direct access to the underlying hecs World (for queries) pub fn ecs(&self) -> &hecs::World { &self.ecs } - /// Mutable access to the underlying hecs World - pub fn ecs_mut(&mut self) -> &mut hecs::World { + /// Direct mutable access to the hecs World. WARNING: bypasses name index, + /// tag index, root tracking, hierarchy, dirty count, and physics invariants. + pub fn ecs_mut_unchecked(&mut self) -> &mut hecs::World { &mut self.ecs } @@ -210,19 +246,52 @@ impl World { self.name_index.get(name).copied() } + /// Rename an entity + /// + /// Removes the old name from the index, updates the Name component, + /// and adds the new name to the index. + /// + /// Returns `Some(())` on success, `None` if the entity doesn't exist + /// or doesn't have a Name component. + pub fn rename_entity(&mut self, entity: hecs::Entity, new_name: impl Into) -> Option<()> { + // Remove old name from index + let old_name = self.ecs.get::<&Name>(entity).ok().map(|n| n.0.clone())?; + self.name_index.remove(&old_name); + + let new_name = new_name.into(); + + // Update the component + if let Ok(mut name) = self.ecs.get::<&mut Name>(entity) { + name.0 = new_name.clone(); + } + + // Add new name to index + if let Some(_old) = self.name_index.insert(new_name.clone(), entity) { + log::warn!("Name '{}' already exists in world; overwriting index entry", new_name); + } + + Some(()) + } + + /// Rebuild the name index by scanning all entities with Name components + /// + /// Useful after bulk operations via `ecs_mut_unchecked()`. + pub fn rebuild_name_index(&mut self) { + self.name_index.clear(); + for (entity, name) in self.ecs.query::<&Name>().iter() { + self.name_index.insert(name.0.clone(), entity); + } + } + // === Tag Queries === /// Get all entities with a specific tag /// - /// This performs a linear scan of all entities with Tags component. + /// Uses the tag index for O(1) lookup instead of scanning. pub fn get_by_tag(&self, tag: &str) -> Vec { - let mut result = Vec::new(); - for (entity, tags) in self.ecs.query::<&Tags>().iter() { - if tags.has(tag) { - result.push(entity); - } - } - result + self.tag_index.get(tag) + .map(|set| set.iter().copied().collect()) + .unwrap_or_default() } // === Entity Count and Iteration === @@ -244,6 +313,9 @@ impl World { pub fn clear(&mut self) { self.ecs.clear(); self.name_index.clear(); + self.tag_index.clear(); + self.roots.clear(); + self.dirty_count = 0; } // === Physics Update === @@ -267,8 +339,12 @@ impl World { { if let Some(phys_body) = physics.get_body(body.0) { if transform.position != phys_body.position { + let was_clean = dirty.is_empty(); transform.position = phys_body.position; *dirty |= DirtyFlags::TRANSFORM; + if was_clean { + self.dirty_count += 1; + } } } } @@ -279,12 +355,7 @@ impl World { /// Check if any entity in the world has dirty flags set pub fn has_dirty_entities(&self) -> bool { - for (_, dirty) in self.ecs.query::<&DirtyFlags>().iter() { - if !dirty.is_empty() { - return true; - } - } - false + self.dirty_count > 0 } /// Clear dirty flags on all entities @@ -292,6 +363,7 @@ impl World { for (_, dirty) in self.ecs.query_mut::<&mut DirtyFlags>() { *dirty = DirtyFlags::NONE; } + self.dirty_count = 0; } // === Hierarchy Methods === @@ -361,6 +433,9 @@ impl World { // Set the Parent component on child (insert_one replaces if exists, adds if not) let _ = self.ecs.insert_one(child, Parent(parent)); + // Remove child from roots (it now has a parent) + self.roots.remove(&child); + // Add to parent's Children component let has_children = self.ecs.get::<&Children>(parent).is_ok(); if has_children { @@ -388,6 +463,9 @@ impl World { // Remove Parent component from child let _ = self.ecs.remove_one::(child); + + // Add back to roots + self.roots.insert(child); } } @@ -397,13 +475,22 @@ impl World { /// For children, this composes transforms from root to leaf. /// /// Returns `None` if the entity does not exist. + /// Includes a depth limit of 64 to guard against cycles. pub fn world_transform(&self, entity: hecs::Entity) -> Option { + const MAX_DEPTH: usize = 64; + let local_transform = *self.ecs.get::<&Transform4D>(entity).ok()?; // Build the chain of ancestors from leaf to root let mut chain = vec![local_transform]; let mut current = entity; + let mut depth = 0; while let Ok(parent) = self.ecs.get::<&Parent>(current) { + depth += 1; + if depth > MAX_DEPTH { + log::error!("world_transform: depth limit ({}) exceeded for entity, possible cycle", MAX_DEPTH); + break; + } let parent_entity = parent.0; drop(parent); if let Ok(parent_transform) = self.ecs.get::<&Transform4D>(parent_entity) { @@ -485,13 +572,7 @@ impl World { /// Get all root entities (entities with no Parent component) pub fn root_entities(&self) -> Vec { - let mut roots = Vec::new(); - for entity in self.ecs.iter() { - if self.ecs.get::<&Parent>(entity.entity()).is_err() { - roots.push(entity.entity()); - } - } - roots + self.roots.iter().copied().collect() } /// Check if `ancestor` is an ancestor of `entity` @@ -508,17 +589,117 @@ impl World { } false } + + /// Validate the hierarchy for consistency (debug tool) + /// + /// Checks that: + /// - Every Parent's target entity exists + /// - Every parent lists the child in its Children component + /// - No cycles exist (depth limit check) + /// + /// Returns a Vec of human-readable issue descriptions. Empty means valid. + pub fn validate_hierarchy(&self) -> Vec { + let mut issues = Vec::new(); + + // Check all Parent components + for (entity, parent) in self.ecs.query::<&Parent>().iter() { + // Check parent exists + if !self.ecs.contains(parent.0) { + issues.push(format!( + "Entity {:?} has Parent pointing to non-existent entity {:?}", + entity, parent.0 + )); + continue; + } + + // Check parent has this entity in its Children + match self.ecs.get::<&Children>(parent.0) { + Ok(children) => { + if !children.contains(entity) { + issues.push(format!( + "Entity {:?} has Parent {:?}, but parent's Children does not contain it", + entity, parent.0 + )); + } + } + Err(_) => { + issues.push(format!( + "Entity {:?} has Parent {:?}, but parent has no Children component", + entity, parent.0 + )); + } + } + } + + // Check for cycles by walking up from each entity with a depth limit + for (entity, _) in self.ecs.query::<&Parent>().iter() { + let mut current = entity; + let mut depth = 0; + while let Ok(p) = self.ecs.get::<&Parent>(current) { + depth += 1; + if depth > 64 { + issues.push(format!( + "Possible cycle detected starting from entity {:?} (depth exceeded 64)", + entity + )); + break; + } + current = p.0; + } + } + + issues + } } #[cfg(test)] mod tests { use super::*; - use crate::{Material, ShapeRef, Entity}; + use crate::{Material, ShapeRef}; use rust4d_math::Tesseract4D; - fn make_test_entity() -> Entity { + fn spawn_test_entity(world: &mut World) -> hecs::Entity { + let tesseract = Tesseract4D::new(2.0); + world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + )) + } + + fn spawn_positioned_entity(world: &mut World, x: f32, y: f32, z: f32, w: f32) -> hecs::Entity { + let tesseract = Tesseract4D::new(2.0); + world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::from_position(rust4d_math::Vec4::new(x, y, z, w)), + Material::default(), + DirtyFlags::ALL, + )) + } + + fn spawn_named_entity(world: &mut World, name: &str) -> hecs::Entity { + let tesseract = Tesseract4D::new(2.0); + world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name(name.to_string()), + )) + } + + fn spawn_tagged_entity(world: &mut World, name: &str, tags: &[&str]) -> hecs::Entity { let tesseract = Tesseract4D::new(2.0); - Entity::new(ShapeRef::shared(tesseract)) + let tag_set: std::collections::HashSet = tags.iter().map(|t| t.to_string()).collect(); + world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name(name.to_string()), + Tags(tag_set), + )) } #[test] @@ -529,10 +710,9 @@ mod tests { } #[test] - fn test_world_add_entity() { + fn test_world_spawn_entity() { let mut world = World::new(); - let entity = make_test_entity(); - let key = world.add_entity(entity); + let key = spawn_test_entity(&mut world); // Key should be valid assert!(world.contains(key)); @@ -542,8 +722,7 @@ mod tests { #[test] fn test_world_get_entity() { let mut world = World::new(); - let entity = make_test_entity(); - let handle = world.add_entity(entity); + let handle = spawn_test_entity(&mut world); let shape = world.ecs().get::<&ShapeRef>(handle); assert!(shape.is_ok()); @@ -553,11 +732,10 @@ mod tests { #[test] fn test_world_get_entity_mut() { let mut world = World::new(); - let entity = make_test_entity(); - let handle = world.add_entity(entity); + let handle = spawn_test_entity(&mut world); { - let mut material = world.ecs_mut().get::<&mut Material>(handle).unwrap(); + let mut material = world.ecs_mut_unchecked().get::<&mut Material>(handle).unwrap(); *material = Material::RED; } @@ -568,8 +746,8 @@ mod tests { #[test] fn test_world_entity_count() { let mut world = World::new(); - world.add_entity(make_test_entity()); - world.add_entity(make_test_entity()); + spawn_test_entity(&mut world); + spawn_test_entity(&mut world); assert_eq!(world.entity_count(), 2); } @@ -577,8 +755,8 @@ mod tests { #[test] fn test_world_clear() { let mut world = World::new(); - world.add_entity(make_test_entity()); - world.add_entity(make_test_entity()); + spawn_test_entity(&mut world); + spawn_test_entity(&mut world); world.clear(); assert!(world.is_empty()); @@ -587,8 +765,8 @@ mod tests { #[test] fn test_world_ecs_query() { let mut world = World::new(); - world.add_entity(make_test_entity()); - world.add_entity(make_test_entity()); + spawn_test_entity(&mut world); + spawn_test_entity(&mut world); let count = world.ecs().query::<&Transform4D>().iter().count(); assert_eq!(count, 2); @@ -597,7 +775,7 @@ mod tests { #[test] fn test_world_update() { let mut world = World::new(); - world.add_entity(make_test_entity()); + spawn_test_entity(&mut world); // Just verify it doesn't panic for now world.update(0.016); @@ -612,22 +790,20 @@ mod tests { #[test] fn test_stale_entity_returns_false() { let mut world = World::new(); - let entity = make_test_entity(); - let key = world.add_entity(entity); + let key = spawn_test_entity(&mut world); // Key is valid initially assert!(world.contains(key)); // Remove the entity - let removed = world.remove_entity(key); + let removed = world.despawn(key); assert!(removed); // Key is now stale assert!(!world.contains(key)); // Add a new entity - let new_entity = make_test_entity(); - let new_key = world.add_entity(new_entity); + let new_key = spawn_test_entity(&mut world); // Old key still invalid assert!(!world.contains(key)); @@ -652,8 +828,14 @@ mod tests { let body_handle = world.physics_mut().unwrap().add_body(body); // Create an entity linked to the physics body - let entity = make_test_entity().with_physics_body(body_handle); - let entity_handle = world.add_entity(entity); + let tesseract = Tesseract4D::new(2.0); + let entity_handle = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + PhysicsBody(body_handle), + )); // Verify initial position { @@ -686,8 +868,14 @@ mod tests { let body_handle = world.physics_mut().unwrap().add_body(body); // Create an entity linked to the physics body - let entity = make_test_entity().with_physics_body(body_handle); - let entity_handle = world.add_entity(entity); + let tesseract = Tesseract4D::new(2.0); + let entity_handle = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + PhysicsBody(body_handle), + )); // Step physics world.update(0.1); @@ -703,9 +891,13 @@ mod tests { let mut world = World::new().with_physics(PhysicsConfig::default()); // Add an entity WITHOUT a physics body but with a specific position - let mut entity = make_test_entity(); - entity.transform.position = rust4d_math::Vec4::new(5.0, 5.0, 5.0, 5.0); - let entity_handle = world.add_entity(entity); + let tesseract = Tesseract4D::new(2.0); + let entity_handle = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::from_position(rust4d_math::Vec4::new(5.0, 5.0, 5.0, 5.0)), + Material::default(), + DirtyFlags::ALL, + )); // Step physics world.update(1.0); @@ -721,8 +913,7 @@ mod tests { let mut world = World::new(); // Add a named entity - let entity = make_test_entity().with_name("tesseract"); - let key = world.add_entity(entity); + let key = spawn_named_entity(&mut world, "tesseract"); // Should be able to find by name let result = world.get_by_name("tesseract"); @@ -742,12 +933,9 @@ mod tests { let mut world = World::new(); // Add entities with different tags - let dynamic1 = make_test_entity().with_tag("dynamic").with_name("dyn1"); - let dynamic2 = make_test_entity().with_tag("dynamic").with_name("dyn2"); - let static1 = make_test_entity().with_tag("static").with_name("stat1"); - world.add_entity(dynamic1); - world.add_entity(dynamic2); - world.add_entity(static1); + spawn_tagged_entity(&mut world, "dyn1", &["dynamic"]); + spawn_tagged_entity(&mut world, "dyn2", &["dynamic"]); + spawn_tagged_entity(&mut world, "stat1", &["static"]); // Should find 2 dynamic entities let dynamic_entities = world.get_by_tag("dynamic"); @@ -767,14 +955,13 @@ mod tests { let mut world = World::new(); // Add a named entity - let entity = make_test_entity().with_name("tesseract"); - let key = world.add_entity(entity); + let key = spawn_named_entity(&mut world, "tesseract"); // Should be able to find by name assert!(world.get_by_name("tesseract").is_some()); // Remove the entity - world.remove_entity(key); + world.despawn(key); // Name should no longer be in the index assert!(world.get_by_name("tesseract").is_none()); @@ -785,8 +972,8 @@ mod tests { let mut world = World::new(); // Add named entities - world.add_entity(make_test_entity().with_name("entity1")); - world.add_entity(make_test_entity().with_name("entity2")); + spawn_named_entity(&mut world, "entity1"); + spawn_named_entity(&mut world, "entity2"); // Should be able to find by name assert!(world.get_by_name("entity1").is_some()); @@ -805,8 +992,7 @@ mod tests { let mut world = World::new(); // Add an unnamed entity - let entity = make_test_entity(); - let key = world.add_entity(entity); + let key = spawn_test_entity(&mut world); // Entity should exist but not be findable by any name assert!(world.contains(key)); @@ -818,7 +1004,7 @@ mod tests { #[test] fn test_new_entities_are_dirty() { let mut world = World::new(); - let key = world.add_entity(make_test_entity()); + let key = spawn_test_entity(&mut world); // New entities should be dirty (DirtyFlags::ALL) let dirty = world.ecs().get::<&DirtyFlags>(key).unwrap(); @@ -829,8 +1015,8 @@ mod tests { #[test] fn test_clear_all_dirty() { let mut world = World::new(); - world.add_entity(make_test_entity()); - world.add_entity(make_test_entity()); + spawn_test_entity(&mut world); + spawn_test_entity(&mut world); // Both should be dirty initially assert!(world.has_dirty_entities()); @@ -845,20 +1031,18 @@ mod tests { #[test] fn test_dirty_entities_tracking() { let mut world = World::new(); - let key1 = world.add_entity(make_test_entity()); - let _key2 = world.add_entity(make_test_entity()); + let key1 = spawn_test_entity(&mut world); + let _key2 = spawn_test_entity(&mut world); // Clear dirty flags world.clear_all_dirty(); // Manually mark one as dirty { - let mut dirty = world.ecs_mut().get::<&mut DirtyFlags>(key1).unwrap(); + let mut dirty = world.ecs_mut_unchecked().get::<&mut DirtyFlags>(key1).unwrap(); *dirty |= DirtyFlags::TRANSFORM; } - - // Should have dirty entities now - assert!(world.has_dirty_entities()); + // Note: dirty_count won't track this since we used ecs_mut_unchecked // Count dirty entities via query let dirty_count = world.ecs().query::<&DirtyFlags>().iter() @@ -882,8 +1066,14 @@ mod tests { let body_handle = world.physics_mut().unwrap().add_body(body); // Create an entity linked to the physics body - let entity = make_test_entity().with_physics_body(body_handle); - let entity_handle = world.add_entity(entity); + let tesseract = Tesseract4D::new(2.0); + let entity_handle = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + PhysicsBody(body_handle), + )); // Clear dirty flags world.clear_all_dirty(); @@ -912,8 +1102,14 @@ mod tests { let body_handle = world.physics_mut().unwrap().add_body(body); // Create an entity linked to the physics body - let entity = make_test_entity().with_physics_body(body_handle); - let entity_handle = world.add_entity(entity); + let tesseract = Tesseract4D::new(2.0); + let entity_handle = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + PhysicsBody(body_handle), + )); // Clear dirty flags world.clear_all_dirty(); @@ -928,20 +1124,11 @@ mod tests { // --- Hierarchy tests --- - fn make_positioned_entity(x: f32, y: f32, z: f32, w: f32) -> Entity { - let tesseract = Tesseract4D::new(2.0); - Entity::with_transform( - ShapeRef::shared(tesseract), - crate::Transform4D::from_position(rust4d_math::Vec4::new(x, y, z, w)), - Material::default(), - ) - } - #[test] fn test_add_child() { let mut world = World::new(); - let parent = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); assert!(world.add_child(parent, child).is_ok()); @@ -957,11 +1144,11 @@ mod tests { #[test] fn test_add_child_invalid_entity() { let mut world = World::new(); - let parent = world.add_entity(make_test_entity()); + let parent = spawn_test_entity(&mut world); // Create an invalid entity by adding and removing - let temp = world.add_entity(make_test_entity()); - world.remove_entity(temp); + let temp = spawn_test_entity(&mut world); + world.despawn(temp); // Both invalid child and invalid parent should fail assert_eq!( @@ -977,8 +1164,8 @@ mod tests { #[test] fn test_cycle_detection() { let mut world = World::new(); - let a = world.add_entity(make_test_entity()); - let b = world.add_entity(make_test_entity()); + let a = spawn_test_entity(&mut world); + let b = spawn_test_entity(&mut world); // A -> B assert!(world.add_child(a, b).is_ok()); @@ -999,9 +1186,9 @@ mod tests { #[test] fn test_deep_cycle_detection() { let mut world = World::new(); - let a = world.add_entity(make_test_entity()); - let b = world.add_entity(make_test_entity()); - let c = world.add_entity(make_test_entity()); + let a = spawn_test_entity(&mut world); + let b = spawn_test_entity(&mut world); + let c = spawn_test_entity(&mut world); // A -> B -> C assert!(world.add_child(a, b).is_ok()); @@ -1017,8 +1204,8 @@ mod tests { #[test] fn test_already_child() { let mut world = World::new(); - let parent = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); assert!(world.add_child(parent, child).is_ok()); @@ -1032,8 +1219,8 @@ mod tests { #[test] fn test_remove_from_parent() { let mut world = World::new(); - let parent = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); world.add_child(parent, child).unwrap(); assert!(world.has_parent(child)); @@ -1050,8 +1237,7 @@ mod tests { #[test] fn test_world_transform_no_parent() { let mut world = World::new(); - let entity = make_positioned_entity(1.0, 2.0, 3.0, 4.0); - let key = world.add_entity(entity); + let key = spawn_positioned_entity(&mut world, 1.0, 2.0, 3.0, 4.0); let wt = world.world_transform(key).unwrap(); assert!((wt.position.x - 1.0).abs() < 0.001); @@ -1065,9 +1251,9 @@ mod tests { let mut world = World::new(); // Parent at (10, 0, 0, 0) - let parent = world.add_entity(make_positioned_entity(10.0, 0.0, 0.0, 0.0)); + let parent = spawn_positioned_entity(&mut world, 10.0, 0.0, 0.0, 0.0); // Child at (1, 2, 0, 0) in local space - let child = world.add_entity(make_positioned_entity(1.0, 2.0, 0.0, 0.0)); + let child = spawn_positioned_entity(&mut world, 1.0, 2.0, 0.0, 0.0); world.add_child(parent, child).unwrap(); @@ -1083,12 +1269,18 @@ mod tests { let mut world = World::new(); // Parent with scale 2 at origin - let mut parent_entity = make_positioned_entity(0.0, 0.0, 0.0, 0.0); - parent_entity.transform.scale = 2.0; - let parent = world.add_entity(parent_entity); + let tesseract = Tesseract4D::new(2.0); + let mut parent_transform = Transform4D::from_position(rust4d_math::Vec4::new(0.0, 0.0, 0.0, 0.0)); + parent_transform.scale = 2.0; + let parent = world.spawn(( + ShapeRef::shared(tesseract), + parent_transform, + Material::default(), + DirtyFlags::ALL, + )); // Child at (1, 0, 0, 0) in local space - let child = world.add_entity(make_positioned_entity(1.0, 0.0, 0.0, 0.0)); + let child = spawn_positioned_entity(&mut world, 1.0, 0.0, 0.0, 0.0); world.add_child(parent, child).unwrap(); @@ -1100,10 +1292,10 @@ mod tests { #[test] fn test_delete_recursive() { let mut world = World::new(); - let root = world.add_entity(make_test_entity().with_name("root")); - let child1 = world.add_entity(make_test_entity().with_name("child1")); - let child2 = world.add_entity(make_test_entity().with_name("child2")); - let grandchild = world.add_entity(make_test_entity().with_name("grandchild")); + let root = spawn_named_entity(&mut world, "root"); + let child1 = spawn_named_entity(&mut world, "child1"); + let child2 = spawn_named_entity(&mut world, "child2"); + let grandchild = spawn_named_entity(&mut world, "grandchild"); world.add_child(root, child1).unwrap(); world.add_child(root, child2).unwrap(); @@ -1128,10 +1320,10 @@ mod tests { #[test] fn test_delete_recursive_subtree() { let mut world = World::new(); - let root = world.add_entity(make_test_entity()); - let child1 = world.add_entity(make_test_entity()); - let child2 = world.add_entity(make_test_entity()); - let grandchild = world.add_entity(make_test_entity()); + let root = spawn_test_entity(&mut world); + let child1 = spawn_test_entity(&mut world); + let child2 = spawn_test_entity(&mut world); + let grandchild = spawn_test_entity(&mut world); world.add_child(root, child1).unwrap(); world.add_child(root, child2).unwrap(); @@ -1153,10 +1345,10 @@ mod tests { #[test] fn test_descendants() { let mut world = World::new(); - let root = world.add_entity(make_test_entity()); - let child1 = world.add_entity(make_test_entity()); - let child2 = world.add_entity(make_test_entity()); - let grandchild = world.add_entity(make_test_entity()); + let root = spawn_test_entity(&mut world); + let child1 = spawn_test_entity(&mut world); + let child2 = spawn_test_entity(&mut world); + let grandchild = spawn_test_entity(&mut world); world.add_child(root, child1).unwrap(); world.add_child(root, child2).unwrap(); @@ -1179,9 +1371,9 @@ mod tests { #[test] fn test_root_entities() { let mut world = World::new(); - let root1 = world.add_entity(make_test_entity()); - let root2 = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); + let root1 = spawn_test_entity(&mut world); + let root2 = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); world.add_child(root1, child).unwrap(); @@ -1195,10 +1387,10 @@ mod tests { #[test] fn test_is_ancestor() { let mut world = World::new(); - let a = world.add_entity(make_test_entity()); - let b = world.add_entity(make_test_entity()); - let c = world.add_entity(make_test_entity()); - let d = world.add_entity(make_test_entity()); + let a = spawn_test_entity(&mut world); + let b = spawn_test_entity(&mut world); + let c = spawn_test_entity(&mut world); + let d = spawn_test_entity(&mut world); // A -> B -> C world.add_child(a, b).unwrap(); @@ -1213,17 +1405,17 @@ mod tests { } #[test] - fn test_remove_entity_cleans_hierarchy() { + fn test_despawn_cleans_hierarchy() { let mut world = World::new(); - let parent = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); - let grandchild = world.add_entity(make_test_entity()); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); + let grandchild = spawn_test_entity(&mut world); world.add_child(parent, child).unwrap(); world.add_child(child, grandchild).unwrap(); // Remove child (middle of hierarchy) - world.remove_entity(child); + world.despawn(child); // Parent should have no children (child was removed) assert!(!world.has_children(parent)); @@ -1238,9 +1430,9 @@ mod tests { #[test] fn test_reparent() { let mut world = World::new(); - let parent1 = world.add_entity(make_test_entity()); - let parent2 = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); + let parent1 = spawn_test_entity(&mut world); + let parent2 = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); // First parent world.add_child(parent1, child).unwrap(); @@ -1275,8 +1467,8 @@ mod tests { #[test] fn test_clear_cleans_hierarchy() { let mut world = World::new(); - let parent = world.add_entity(make_test_entity()); - let child = world.add_entity(make_test_entity()); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); world.add_child(parent, child).unwrap(); world.clear(); @@ -1289,11 +1481,11 @@ mod tests { let mut world = World::new(); // Grandparent at (10, 0, 0, 0) - let grandparent = world.add_entity(make_positioned_entity(10.0, 0.0, 0.0, 0.0)); + let grandparent = spawn_positioned_entity(&mut world, 10.0, 0.0, 0.0, 0.0); // Parent at (5, 0, 0, 0) local - let parent = world.add_entity(make_positioned_entity(5.0, 0.0, 0.0, 0.0)); + let parent = spawn_positioned_entity(&mut world, 5.0, 0.0, 0.0, 0.0); // Child at (1, 0, 0, 0) local - let child = world.add_entity(make_positioned_entity(1.0, 0.0, 0.0, 0.0)); + let child = spawn_positioned_entity(&mut world, 1.0, 0.0, 0.0, 0.0); world.add_child(grandparent, parent).unwrap(); world.add_child(parent, child).unwrap(); @@ -1306,10 +1498,267 @@ mod tests { #[test] fn test_world_transform_nonexistent() { let mut world = World::new(); - let key = world.add_entity(make_test_entity()); - world.remove_entity(key); + let key = spawn_test_entity(&mut world); + world.despawn(key); // Non-existent entity returns None assert!(world.world_transform(key).is_none()); } + + // --- Rename tests --- + + #[test] + fn test_rename_entity() { + let mut world = World::new(); + let key = spawn_named_entity(&mut world, "old_name"); + + assert!(world.get_by_name("old_name").is_some()); + assert!(world.get_by_name("new_name").is_none()); + + let result = world.rename_entity(key, "new_name"); + assert!(result.is_some()); + + assert!(world.get_by_name("old_name").is_none()); + assert!(world.get_by_name("new_name").is_some()); + assert_eq!(world.get_by_name("new_name").unwrap(), key); + } + + #[test] + fn test_rename_entity_no_name() { + let mut world = World::new(); + let key = spawn_test_entity(&mut world); + + // Should return None for entity without Name + assert!(world.rename_entity(key, "new_name").is_none()); + } + + // --- Rebuild index tests --- + + #[test] + fn test_rebuild_name_index() { + let mut world = World::new(); + let key = spawn_named_entity(&mut world, "test_entity"); + + // Manually corrupt the index + world.name_index.clear(); + assert!(world.get_by_name("test_entity").is_none()); + + // Rebuild should fix it + world.rebuild_name_index(); + assert!(world.get_by_name("test_entity").is_some()); + assert_eq!(world.get_by_name("test_entity").unwrap(), key); + } + + // --- Validate hierarchy tests --- + + #[test] + fn test_validate_hierarchy_clean() { + let mut world = World::new(); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); + world.add_child(parent, child).unwrap(); + + let issues = world.validate_hierarchy(); + assert!(issues.is_empty(), "Expected no issues, got: {:?}", issues); + } + + // --- Tag index tests --- + + #[test] + fn test_tag_index_cleanup_on_despawn() { + let mut world = World::new(); + let key = spawn_tagged_entity(&mut world, "tagged", &["enemy", "dynamic"]); + + assert_eq!(world.get_by_tag("enemy").len(), 1); + assert_eq!(world.get_by_tag("dynamic").len(), 1); + + world.despawn(key); + + assert_eq!(world.get_by_tag("enemy").len(), 0); + assert_eq!(world.get_by_tag("dynamic").len(), 0); + } + + // --- Root tracking tests --- + + #[test] + fn test_roots_tracking() { + let mut world = World::new(); + let a = spawn_test_entity(&mut world); + let b = spawn_test_entity(&mut world); + let c = spawn_test_entity(&mut world); + + // All three should be roots + assert_eq!(world.root_entities().len(), 3); + + // Make c a child of a + world.add_child(a, c).unwrap(); + + // Now only a and b should be roots + let roots = world.root_entities(); + assert_eq!(roots.len(), 2); + assert!(roots.contains(&a)); + assert!(roots.contains(&b)); + assert!(!roots.contains(&c)); + + // Remove c from parent -> becomes root again + world.remove_from_parent(c); + let roots = world.root_entities(); + assert_eq!(roots.len(), 3); + assert!(roots.contains(&c)); + } + + // --- Dirty count tests --- + + #[test] + fn test_dirty_count() { + let mut world = World::new(); + spawn_test_entity(&mut world); + spawn_test_entity(&mut world); + + // Both spawned with DirtyFlags::ALL + assert!(world.has_dirty_entities()); + + world.clear_all_dirty(); + assert!(!world.has_dirty_entities()); + } + + // --- Priority 4: Name collision tests --- + + #[test] + fn test_name_collision_overwrites_index() { + let mut world = World::new(); + let e1 = spawn_named_entity(&mut world, "cube"); + let e2 = spawn_named_entity(&mut world, "cube"); + + // Second entity should win in the name index + assert_eq!(world.get_by_name("cube"), Some(e2)); + // e1 still exists in the world, just unreachable by name + assert!(world.ecs().contains(e1)); + assert!(world.ecs().contains(e2)); + } + + #[test] + fn test_name_collision_first_entity_still_valid() { + let mut world = World::new(); + let e1 = spawn_named_entity(&mut world, "cube"); + let _e2 = spawn_named_entity(&mut world, "cube"); + + // e1 still has its Name component + let name = world.ecs().get::<&Name>(e1).unwrap(); + assert_eq!(name.0, "cube"); + } + + // --- ecs_mut_unchecked breaking name index --- + + #[test] + fn test_ecs_mut_unchecked_breaks_name_index() { + let mut world = World::new(); + let e1 = spawn_named_entity(&mut world, "original"); + + // Mutate via raw access -- bypasses index + world.ecs_mut_unchecked().get::<&mut Name>(e1).unwrap().0 = "changed".to_string(); + + // Index is now stale + assert_eq!(world.get_by_name("original"), Some(e1)); // stale entry + assert_eq!(world.get_by_name("changed"), None); // new name not indexed + + // rebuild_name_index fixes it + world.rebuild_name_index(); + assert_eq!(world.get_by_name("original"), None); + assert_eq!(world.get_by_name("changed"), Some(e1)); + } + + // --- world_transform on entity without Transform4D --- + + #[test] + fn test_world_transform_without_transform4d() { + let mut world = World::new(); + // Spawn entity with only DirtyFlags, no Transform4D + let e = world.spawn((DirtyFlags::ALL,)); + assert!(world.world_transform(e).is_none()); + } + + // --- world_transform cycle guard --- + + #[test] + fn test_world_transform_cycle_guard() { + let mut world = World::new(); + let e1 = spawn_test_entity(&mut world); + let e2 = spawn_test_entity(&mut world); + + // Create cycle by directly setting Parent components (bypassing add_child) + let _ = world.ecs_mut_unchecked().insert_one(e1, Parent(e2)); + let _ = world.ecs_mut_unchecked().insert_one(e2, Parent(e1)); + + // world_transform should not infinite-loop; it has a depth limit + let result = world.world_transform(e1); + // It returns Some because both have Transform4D, just hits depth limit + assert!(result.is_some()); + } + + // --- Physics cleanup verification --- + + #[test] + fn test_despawn_cleans_up_physics_body() { + use rust4d_physics::{PhysicsMaterial, BodyType}; + + let config = crate::PhysicsConfig::new(-9.81); + let mut world = World::new().with_physics(config); + + // Add a physics body + let body_key = { + let physics = world.physics_mut().unwrap(); + let body = crate::RigidBody4D::new_aabb( + rust4d_math::Vec4::new(0.0, 5.0, 0.0, 0.0), + rust4d_math::Vec4::new(0.5, 0.5, 0.5, 0.5), + ).with_body_type(BodyType::Dynamic) + .with_mass(1.0) + .with_material(PhysicsMaterial::RUBBER); + physics.add_body(body) + }; + + // Spawn entity with PhysicsBody component + let e = world.spawn(( + ShapeRef::shared(Tesseract4D::new(1.0)), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + PhysicsBody(body_key), + )); + + // Verify physics body exists + assert!(world.physics().unwrap().get_body(body_key).is_some()); + + // Despawn should clean up the physics body + world.despawn(e); + assert!(world.physics().unwrap().get_body(body_key).is_none()); + } + + // --- Validate hierarchy with corrupted state --- + + #[test] + fn test_validate_hierarchy_detects_orphaned_parent() { + let mut world = World::new(); + let child = spawn_test_entity(&mut world); + + // Manually set Parent to nonexistent entity (bypassing add_child) + let fake_parent = hecs::Entity::DANGLING; + let _ = world.ecs_mut_unchecked().insert_one(child, Parent(fake_parent)); + + let issues = world.validate_hierarchy(); + assert!(!issues.is_empty(), "Should detect parent pointing to nonexistent entity"); + } + + #[test] + fn test_validate_hierarchy_detects_missing_child_in_parent() { + let mut world = World::new(); + let parent = spawn_test_entity(&mut world); + let child = spawn_test_entity(&mut world); + + // Set Parent on child but don't add to parent's Children + let _ = world.ecs_mut_unchecked().insert_one(child, Parent(parent)); + + let issues = world.validate_hierarchy(); + assert!(!issues.is_empty(), "Should detect child not listed in parent's Children"); + } } -- knotmirror 0.0.1 From 782d4e490cb223d926d3090405c8e7ebd28b7b40 Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Sat, 31 Jan 2026 18:24:24 +0000 Subject: [PATCH 17/17] Port all consumers to new ECS API, remove Entity-based methods - Remove from_entity(), add_entity(), add_entity_with_color() from RenderableGeometry (renderable.rs) - Remove Entity re-export from rust4d_render/src/lib.rs - Port all 4 examples to use world.spawn() with component tuples - Port physics_integration.rs tests to world.spawn() - Port renderable.rs tests to use from_world/add_components_with_color All 478 tests pass. Zero references to Entity struct remain in compiled code (only docs have stale references). Co-Authored-By: Claude Opus 4.5 --- .../rust4d_core/tests/physics_integration.rs | 53 +++++++---- crates/rust4d_render/src/lib.rs | 2 +- crates/rust4d_render/src/renderable.rs | 93 +++++++++---------- examples/01_hello_tesseract.rs | 9 +- examples/02_multiple_shapes.rs | 10 +- examples/03_physics_demo.rs | 36 ++++--- examples/04_camera_exploration.rs | 28 +++--- 7 files changed, 127 insertions(+), 104 deletions(-) diff --git a/crates/rust4d_core/tests/physics_integration.rs b/crates/rust4d_core/tests/physics_integration.rs index 2efe972..38490c9 100644 --- a/crates/rust4d_core/tests/physics_integration.rs +++ b/crates/rust4d_core/tests/physics_integration.rs @@ -8,7 +8,7 @@ use rust4d_core::{ ActiveScene, Scene, World, EntityTemplate, Transform4D, Material, ShapeRef, - ShapeTemplate, DirtyFlags, PhysicsBody, + ShapeTemplate, DirtyFlags, PhysicsBody, Name, }; use rust4d_physics::{ PhysicsConfig, PhysicsWorld, RigidBody4D, BodyType, StaticCollider, PhysicsMaterial, @@ -226,10 +226,14 @@ fn test_entity_transform_syncs_from_physics() { // Create an entity linked to the physics body let tesseract = Tesseract4D::new(2.0); - let entity = rust4d_core::Entity::new(ShapeRef::shared(tesseract)) - .with_name("test") - .with_physics_body(body_key); - world.add_entity(entity); + let entity_handle = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name::new("test"), + PhysicsBody(body_key), + )); // Clear dirty flags world.clear_all_dirty(); @@ -238,7 +242,6 @@ fn test_entity_transform_syncs_from_physics() { world.update(0.1); // Entity should have new position - let entity_handle = world.get_by_name("test").unwrap(); let transform = world.ecs().get::<&Transform4D>(entity_handle).unwrap(); assert!( transform.position.y < 10.0, @@ -471,10 +474,14 @@ fn test_remove_entity_cleans_up_physics_body() { // Create an entity linked to the physics body let tesseract = Tesseract4D::new(2.0); - let entity = rust4d_core::Entity::new(ShapeRef::shared(tesseract)) - .with_name("physics_test") - .with_physics_body(body_key); - let entity_key = world.add_entity(entity); + let entity_key = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name::new("physics_test"), + PhysicsBody(body_key), + )); // Verify body exists in physics world assert!( @@ -487,7 +494,7 @@ fn test_remove_entity_cleans_up_physics_body() { ); // Remove the entity - let removed = world.remove_entity(entity_key); + let removed = world.despawn(entity_key); assert!(removed, "Entity should be removed"); // Verify physics body was also removed @@ -508,12 +515,16 @@ fn test_remove_entity_without_physics_body() { // Create an entity WITHOUT a physics body let tesseract = Tesseract4D::new(2.0); - let entity = rust4d_core::Entity::new(ShapeRef::shared(tesseract)) - .with_name("no_physics"); - let entity_key = world.add_entity(entity); + let entity_key = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name::new("no_physics"), + )); // Remove the entity - should not panic - let removed = world.remove_entity(entity_key); + let removed = world.despawn(entity_key); assert!(removed, "Entity should be removed"); // Verify entity is gone assert!(!world.contains(entity_key)); @@ -526,11 +537,15 @@ fn test_remove_entity_world_without_physics() { // Create an entity let tesseract = Tesseract4D::new(2.0); - let entity = rust4d_core::Entity::new(ShapeRef::shared(tesseract)) - .with_name("test"); - let entity_key = world.add_entity(entity); + let entity_key = world.spawn(( + ShapeRef::shared(tesseract), + Transform4D::identity(), + Material::default(), + DirtyFlags::ALL, + Name::new("test"), + )); // Remove should work fine - let removed = world.remove_entity(entity_key); + let removed = world.despawn(entity_key); assert!(removed, "Entity should be removed even without physics world"); } diff --git a/crates/rust4d_render/src/lib.rs b/crates/rust4d_render/src/lib.rs index ab0f7ad..d40f0a4 100644 --- a/crates/rust4d_render/src/lib.rs +++ b/crates/rust4d_render/src/lib.rs @@ -22,7 +22,7 @@ pub mod pipeline; pub mod renderable; // Re-export core types for convenience -pub use rust4d_core::{World, Entity, Transform4D, Material, ShapeRef, DirtyFlags, Tags}; +pub use rust4d_core::{World, Transform4D, Material, ShapeRef, DirtyFlags, Tags}; pub use rust4d_core::{ConvexShape4D, Tetrahedron, Tesseract4D, Hyperplane4D}; pub use rust4d_core::{Vec4, Rotor4, RotationPlane}; diff --git a/crates/rust4d_render/src/renderable.rs b/crates/rust4d_render/src/renderable.rs index b8419ee..f966122 100644 --- a/crates/rust4d_render/src/renderable.rs +++ b/crates/rust4d_render/src/renderable.rs @@ -3,7 +3,7 @@ //! This module converts the abstract shape data from rust4d_core into //! GPU-compatible vertex and tetrahedra buffers. -use rust4d_core::{Entity, World, Material, Transform4D, ShapeRef}; +use rust4d_core::{World, Material, Transform4D, ShapeRef}; use rust4d_math::Vec4; use crate::pipeline::{Vertex4D, GpuTetrahedron}; @@ -35,20 +35,6 @@ impl RenderableGeometry { } } - /// Collect geometry from a single entity - /// - /// Uses the entity's material base_color for all vertices. - pub fn from_entity(entity: &Entity) -> Self { - Self::from_entity_with_color(entity, &default_color_fn) - } - - /// Collect geometry from a single entity with a custom color function - pub fn from_entity_with_color(entity: &Entity, color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4]) -> Self { - let mut result = Self::new(); - result.add_entity_with_color(entity, color_fn); - result - } - /// Collect geometry from all entities in a world /// /// Uses each entity's material base_color for all its vertices. @@ -68,18 +54,6 @@ impl RenderableGeometry { result } - /// Add an entity's geometry to this collection - /// - /// Uses the entity's material base_color for all vertices. - pub fn add_entity(&mut self, entity: &Entity) { - self.add_entity_with_color(entity, &default_color_fn); - } - - /// Add an entity's geometry with a custom color function - pub fn add_entity_with_color(&mut self, entity: &Entity, color_fn: &dyn Fn(&Vec4, &Material) -> [f32; 4]) { - self.add_components_with_color(&entity.transform, entity.shape(), &entity.material, color_fn); - } - /// Add geometry from individual ECS components with a custom color function /// /// This is the core method that works with decomposed components rather than @@ -194,14 +168,16 @@ impl CheckerboardGeometry { #[cfg(test)] mod tests { use super::*; - use rust4d_core::{ShapeRef, Tesseract4D, Transform4D}; + use rust4d_core::{ShapeRef, Tesseract4D, Transform4D, DirtyFlags}; - fn make_test_entity() -> Entity { + fn spawn_test_entity(world: &mut World) -> rust4d_core::hecs::Entity { let tesseract = Tesseract4D::new(2.0); - Entity::with_material( + world.spawn(( ShapeRef::shared(tesseract), + Transform4D::identity(), Material::from_rgb(1.0, 0.5, 0.25), - ) + DirtyFlags::ALL, + )) } #[test] @@ -212,9 +188,14 @@ mod tests { } #[test] - fn test_renderable_geometry_from_entity() { - let entity = make_test_entity(); - let geom = RenderableGeometry::from_entity(&entity); + fn test_renderable_geometry_from_components() { + let tesseract = Tesseract4D::new(2.0); + let shape_ref = ShapeRef::shared(tesseract); + let material = Material::from_rgb(1.0, 0.5, 0.25); + let transform = Transform4D::identity(); + + let mut geom = RenderableGeometry::new(); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); assert_eq!(geom.vertex_count(), 16); // Tesseract has 16 vertices assert!(geom.tetrahedron_count() > 0); @@ -228,8 +209,8 @@ mod tests { #[test] fn test_renderable_geometry_from_world() { let mut world = World::new(); - world.add_entity(make_test_entity()); - world.add_entity(make_test_entity()); + spawn_test_entity(&mut world); + spawn_test_entity(&mut world); let geom = RenderableGeometry::from_world(&world); @@ -237,21 +218,29 @@ mod tests { } #[test] - fn test_renderable_geometry_add_entity() { + fn test_renderable_geometry_add_components() { let mut geom = RenderableGeometry::new(); - let entity = make_test_entity(); + let tesseract = Tesseract4D::new(2.0); + let shape_ref = ShapeRef::shared(tesseract); + let material = Material::from_rgb(1.0, 0.5, 0.25); + let transform = Transform4D::identity(); - geom.add_entity(&entity); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); assert_eq!(geom.vertex_count(), 16); - geom.add_entity(&entity); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); assert_eq!(geom.vertex_count(), 32); } #[test] fn test_renderable_geometry_clear() { - let entity = make_test_entity(); - let mut geom = RenderableGeometry::from_entity(&entity); + let tesseract = Tesseract4D::new(2.0); + let shape_ref = ShapeRef::shared(tesseract); + let material = Material::from_rgb(1.0, 0.5, 0.25); + let transform = Transform4D::identity(); + + let mut geom = RenderableGeometry::new(); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); assert!(geom.vertex_count() > 0); geom.clear(); @@ -292,12 +281,12 @@ mod tests { #[test] fn test_transform_applied() { let tesseract = Tesseract4D::new(2.0); - let mut entity = Entity::new(ShapeRef::shared(tesseract)); - - // Translate by (10, 0, 0, 0) - entity.transform = Transform4D::from_position(Vec4::new(10.0, 0.0, 0.0, 0.0)); + let shape_ref = ShapeRef::shared(tesseract); + let material = Material::default(); + let transform = Transform4D::from_position(Vec4::new(10.0, 0.0, 0.0, 0.0)); - let geom = RenderableGeometry::from_entity(&entity); + let mut geom = RenderableGeometry::new(); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); // All vertices should be offset by 10 in x for v in &geom.vertices { @@ -309,13 +298,15 @@ mod tests { #[test] fn test_tetrahedra_indices_offset() { let mut geom = RenderableGeometry::new(); - let entity1 = make_test_entity(); - let entity2 = make_test_entity(); + let tesseract = Tesseract4D::new(2.0); + let shape_ref = ShapeRef::shared(tesseract); + let material = Material::from_rgb(1.0, 0.5, 0.25); + let transform = Transform4D::identity(); - geom.add_entity(&entity1); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); let first_entity_verts = geom.vertex_count(); - geom.add_entity(&entity2); + geom.add_components_with_color(&transform, shape_ref.as_shape(), &material, &default_color_fn); // Second entity's tetrahedra should have indices >= first_entity_verts let second_tet = geom.tetrahedra.last().unwrap(); diff --git a/examples/01_hello_tesseract.rs b/examples/01_hello_tesseract.rs index 96325f8..bebf1df 100644 --- a/examples/01_hello_tesseract.rs +++ b/examples/01_hello_tesseract.rs @@ -19,7 +19,7 @@ use winit::{ window::{Window, WindowId}, }; -use rust4d_core::{Entity, Material, ShapeRef, Tesseract4D, World}; +use rust4d_core::{Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World}; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, @@ -46,11 +46,12 @@ impl App { // Create a tesseract (4D hypercube) with size 2.0 let tesseract = Tesseract4D::new(2.0); - let entity = Entity::with_material( + world.spawn(( ShapeRef::shared(tesseract), + Transform4D::identity(), Material::from_rgb(0.8, 0.4, 0.2), // Orange color - ); - world.add_entity(entity); + DirtyFlags::ALL, + )); // Build GPU geometry from the world let geometry = RenderableGeometry::from_world(&world); diff --git a/examples/02_multiple_shapes.rs b/examples/02_multiple_shapes.rs index 0257782..f412031 100644 --- a/examples/02_multiple_shapes.rs +++ b/examples/02_multiple_shapes.rs @@ -19,7 +19,7 @@ use winit::{ window::{Window, WindowId}, }; -use rust4d_core::{Entity, Material, ShapeRef, Tesseract4D, Transform4D, World}; +use rust4d_core::{Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World}; use rust4d_render::{ camera4d::Camera4D, context::RenderContext, @@ -78,8 +78,12 @@ impl App { for (position, material) in positions_and_colors { let tesseract = Tesseract4D::new(1.5); let transform = Transform4D::from_position(position); - let entity = Entity::with_transform(ShapeRef::shared(tesseract), transform, material); - world.add_entity(entity); + world.spawn(( + ShapeRef::shared(tesseract), + transform, + material, + DirtyFlags::ALL, + )); } let geometry = RenderableGeometry::from_world(&world); diff --git a/examples/03_physics_demo.rs b/examples/03_physics_demo.rs index a84e440..3c11f24 100644 --- a/examples/03_physics_demo.rs +++ b/examples/03_physics_demo.rs @@ -21,8 +21,8 @@ use winit::{ }; use rust4d_core::{ - Entity, Material, ShapeRef, Tesseract4D, Transform4D, World, Tags, - PhysicsConfig, RigidBody4D, StaticCollider, Hyperplane4D, + Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World, Tags, Name, + PhysicsConfig, PhysicsBody, RigidBody4D, StaticCollider, Hyperplane4D, }; use rust4d_render::{ camera4d::Camera4D, @@ -32,6 +32,7 @@ use rust4d_render::{ }; use rust4d_math::Vec4; use rust4d_physics::{BodyType, PhysicsMaterial}; +use rust4d_core::hecs; /// Application state struct App { @@ -60,11 +61,14 @@ impl App { // Add visual floor entity (shape at y=0 local, positioned by transform) let floor_shape = Hyperplane4D::new(15.0, 10, 2.0, 0.001); let floor_transform = Transform4D::from_position(Vec4::new(0.0, floor_y, 0.0, 0.0)); - world.add_entity( - Entity::with_transform(ShapeRef::shared(floor_shape), floor_transform, Material::GRAY) - .with_name("floor") - .with_tag("static"), - ); + world.spawn(( + ShapeRef::shared(floor_shape), + floor_transform, + Material::GRAY, + DirtyFlags::ALL, + Name::new("floor"), + Tags::new().with_tag("static"), + )); // Add falling tesseracts at different heights let spawn_positions = [ @@ -93,18 +97,20 @@ impl App { None }; - // Add visual entity + // Add visual entity with ECS components let tesseract = Tesseract4D::new(size); let transform = Transform4D::from_position(*position); - let mut entity = Entity::with_transform(ShapeRef::shared(tesseract), transform, *material) - .with_name(format!("tesseract_{}", i)) - .with_tag("dynamic"); - + let mut builder = hecs::EntityBuilder::new(); + builder.add(ShapeRef::shared(tesseract)); + builder.add(transform); + builder.add(*material); + builder.add(DirtyFlags::ALL); + builder.add(Name::new(format!("tesseract_{}", i))); + builder.add(Tags::new().with_tag("dynamic")); if let Some(key) = body_key { - entity = entity.with_physics_body(key); + builder.add(PhysicsBody(key)); } - - world.add_entity(entity); + world.spawn(builder.build()); } let geometry = Self::build_geometry(&world); diff --git a/examples/04_camera_exploration.rs b/examples/04_camera_exploration.rs index 9e154ae..dc14a32 100644 --- a/examples/04_camera_exploration.rs +++ b/examples/04_camera_exploration.rs @@ -34,7 +34,7 @@ use winit::{ }; use rust4d_core::{ - Entity, Material, ShapeRef, Tesseract4D, Transform4D, World, Tags, + Material, ShapeRef, Tesseract4D, Transform4D, DirtyFlags, World, Tags, Name, Hyperplane4D, }; use rust4d_render::{ @@ -67,11 +67,14 @@ impl App { // Add floor at Y = -2 for spatial reference (shape at y=0 local, positioned by transform) let floor_shape = Hyperplane4D::new(20.0, 12, 2.0, 0.001); let floor_transform = Transform4D::from_position(Vec4::new(0.0, -2.0, 0.0, 0.0)); - world.add_entity( - Entity::with_transform(ShapeRef::shared(floor_shape), floor_transform, Material::GRAY) - .with_name("floor") - .with_tag("static"), - ); + world.spawn(( + ShapeRef::shared(floor_shape), + floor_transform, + Material::GRAY, + DirtyFlags::ALL, + Name::new("floor"), + Tags::new().with_tag("static"), + )); // Create tesseracts at various 4D positions to explore // Each one at a different location in 4D space @@ -91,11 +94,14 @@ impl App { for (position, material, name) in tesseracts { let tesseract = Tesseract4D::new(1.5); let transform = Transform4D::from_position(position); - world.add_entity( - Entity::with_transform(ShapeRef::shared(tesseract), transform, material) - .with_name(name) - .with_tag("dynamic"), - ); + world.spawn(( + ShapeRef::shared(tesseract), + transform, + material, + DirtyFlags::ALL, + Name::new(name), + Tags::new().with_tag("dynamic"), + )); } let geometry = Self::build_geometry(&world);