diff --git a/src/CortexEngine.App/ObjectManipulator.cs b/src/CortexEngine.App/ObjectManipulator.cs
new file mode 100644
index 0000000..2466776
--- /dev/null
+++ b/src/CortexEngine.App/ObjectManipulator.cs
@@ -0,0 +1,173 @@
+using System.Numerics;
+using Engine.Core;
+using Engine.Core.Components;
+using Engine.Physics;
+using Flecs.NET.Core;
+using Raylib_cs;
+using EngineMesh = Engine.Core.Components.Mesh;
+using EngineTransform = Engine.Core.Components.Transform;
+using EngineRigidBody = Engine.Core.Components.RigidBody;
+
+namespace CortexEngine.App;
+
+///
+/// Unity-like object manipulation: left-click to pick, drag to move on ground plane.
+/// Only active when ImGui is not capturing the mouse.
+///
+public sealed class ObjectManipulator
+{
+ private Entity _selectedEntity;
+ private bool _isDragging;
+ private Vector3 _dragOffset;
+ private float _dragDepth;
+
+ public string SelectedEntityName => _selectedEntity.IsValid() ? _selectedEntity.Name() : "";
+ public bool IsDragging => _isDragging;
+
+ ///
+ /// Process input for object picking and dragging.
+ /// Returns true if input was consumed (ImGui should be ignored).
+ ///
+ public bool ProcessInput(World world, Camera camera, IInputState input)
+ {
+ // Skip if ImGui is capturing mouse
+ if (ImGuiNET.ImGui.GetIO().WantCaptureMouse)
+ {
+ _isDragging = false;
+ return false;
+ }
+
+ var mousePos = new Vector2(input.MouseX, input.MouseY);
+ var ray = Raylib.GetScreenToWorldRay(mousePos, ToRaylibCamera(camera));
+
+ // Left click — pick or start drag
+ if (input.MouseLeft && !_isDragging)
+ {
+ var hit = RaycastEntities(world, ray);
+ if (hit.IsValid())
+ {
+ _selectedEntity = hit;
+ _isDragging = true;
+ // Store depth along camera forward axis and offset from object center
+ var objPos = hit.Get().Position;
+ var camForward = Vector3.Normalize(camera.Target - camera.Position);
+ _dragDepth = Vector3.Dot(objPos - camera.Position, camForward);
+ _dragOffset = objPos - ProjectToCameraPlane(ray, camera, _dragDepth);
+ return true;
+ }
+ }
+
+ // Drag — move object on plane orthogonal to camera (screen-space movement)
+ if (_isDragging)
+ {
+ if (input.MouseLeft)
+ {
+ var targetPos = ProjectToCameraPlane(ray, camera, _dragDepth) + _dragOffset;
+
+ if (_selectedEntity.IsValid() && _selectedEntity.Has())
+ {
+ var t = _selectedEntity.Get();
+ t.Position = targetPos;
+ _selectedEntity.Set(t);
+ }
+ return true;
+ }
+ else
+ {
+ _isDragging = false;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// After physics step, re-sync dragged entity position to physics body.
+ ///
+ public void SyncToPhysics(PhysicsWorld physicsWorld)
+ {
+ if (_isDragging && _selectedEntity.IsValid() && _selectedEntity.Has())
+ {
+ var t = _selectedEntity.Get();
+ physicsWorld.SyncToPhysics(_selectedEntity, t);
+ }
+ }
+
+ ///
+ /// Get the entity currently being dragged (for physics sync skip).
+ ///
+ public Entity? GetDraggedEntity() => _isDragging ? _selectedEntity : null;
+
+ private static Entity RaycastEntities(World world, Ray ray)
+ {
+ Entity closest = default;
+ var closestDist = float.MaxValue;
+
+ world.Each((Entity e, ref EngineTransform t, ref EngineMesh _) =>
+ {
+ var name = e.Name();
+ if (string.IsNullOrEmpty(name) || name == "Grid" || name == "Floor")
+ return;
+
+ // Simple sphere intersection using position + approximate radius
+ var radius = 1.0f;
+ if (e.Has())
+ {
+ var rb = e.Get();
+ radius = rb.ShapeSize.Length();
+ }
+
+ var toCenter = t.Position - ray.Position;
+ var proj = Vector3.Dot(toCenter, ray.Direction);
+ if (proj < 0) return; // behind camera
+
+ var closestPoint = ray.Position + ray.Direction * proj;
+ var dist = Vector3.Distance(closestPoint, t.Position);
+
+ if (dist <= radius)
+ {
+ var rayDist = Vector3.Distance(ray.Position, t.Position);
+ if (rayDist < closestDist)
+ {
+ closestDist = rayDist;
+ closest = e;
+ }
+ }
+ });
+
+ return closest;
+ }
+
+ ///
+ /// Project a screen ray onto a plane orthogonal to the camera at the given depth.
+ /// This makes objects move in screen-space (like Unity's screen-space drag).
+ ///
+ private static Vector3 ProjectToCameraPlane(Ray ray, Camera camera, float depth)
+ {
+ var camForward = Vector3.Normalize(camera.Target - camera.Position);
+ var planePoint = camera.Position + camForward * depth;
+
+ // Ray-plane intersection: plane through planePoint with normal = camForward
+ var denom = Vector3.Dot(ray.Direction, camForward);
+ if (MathF.Abs(denom) < 0.0001f)
+ return planePoint;
+
+ var t = Vector3.Dot(planePoint - ray.Position, camForward) / denom;
+ if (t < 0)
+ return planePoint;
+
+ return ray.Position + ray.Direction * t;
+ }
+
+ private static Camera3D ToRaylibCamera(Camera camera)
+ {
+ return new Camera3D
+ {
+ Position = camera.Position,
+ Target = camera.Target,
+ Up = camera.Up,
+ FovY = camera.FieldOfView * 180.0f / MathF.PI,
+ Projection = CameraProjection.Perspective
+ };
+ }
+}
diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs
index 6222143..c6a0239 100644
--- a/src/CortexEngine.App/Program.cs
+++ b/src/CortexEngine.App/Program.cs
@@ -50,6 +50,7 @@ class Program
// ImGui editor layer
var imGuiLayer = new ImGuiLayer();
+ var objectManipulator = new ObjectManipulator();
if (!cameraTour)
{
imGuiLayer.Initialize();
@@ -240,6 +241,13 @@ class Program
}
}
+ // Object manipulation (Unity-like drag)
+ if (!cameraTour)
+ {
+ var cam = cameraEntity.Get();
+ objectManipulator.ProcessInput(world, cam, input);
+ }
+
// Physics: create bodies, step, sync transforms
if (!cameraTour)
{
@@ -258,8 +266,13 @@ class Program
e.Set(rb);
}
+ // Sync dragged object to physics before stepping
+ objectManipulator.SyncToPhysics(physicsWorld);
+
physicsWorld.Update((float)timing.DeltaTime);
- physicsWorld.SyncTransforms(world);
+
+ // Sync all transforms EXCEPT the dragged object
+ physicsWorld.SyncTransforms(world, objectManipulator.IsDragging ? objectManipulator.GetDraggedEntity() : null);
}
// Capture a demo screenshot after the scene warms up (non-tour mode only).
@@ -272,6 +285,10 @@ class Program
// Feed frame data to ImGui before rendering.
imGuiLayer.SetFrameData(world, timing, currentFps);
+ // Sync selection between manipulator and ImGui
+ if (objectManipulator.IsDragging && !string.IsNullOrEmpty(objectManipulator.SelectedEntityName))
+ imGuiLayer.SetSelectedEntity(objectManipulator.SelectedEntityName);
+
renderer.RenderWorld(world);
queue.CompletePendingScreenshots();
diff --git a/src/Engine.Graphics.Raylib/ImGuiLayer.cs b/src/Engine.Graphics.Raylib/ImGuiLayer.cs
index 62c6b44..899d589 100644
--- a/src/Engine.Graphics.Raylib/ImGuiLayer.cs
+++ b/src/Engine.Graphics.Raylib/ImGuiLayer.cs
@@ -26,6 +26,16 @@ public sealed class ImGuiLayer : IDisposable
private Timing? _timing;
private int _fps;
+ ///
+ /// Set the selected entity name from external (e.g. ObjectManipulator).
+ ///
+ public void SetSelectedEntity(string name) { _selectedEntity = name; }
+
+ ///
+ /// Get the currently selected entity name.
+ ///
+ public string GetSelectedEntity() => _selectedEntity;
+
///
/// Set per-frame data before calling RenderImGuiUI.
///
diff --git a/src/Engine.Physics/PhysicsWorld.cs b/src/Engine.Physics/PhysicsWorld.cs
index 35c3347..c39c617 100644
--- a/src/Engine.Physics/PhysicsWorld.cs
+++ b/src/Engine.Physics/PhysicsWorld.cs
@@ -121,7 +121,7 @@ public sealed class PhysicsWorld : IDisposable
_physicsSystem.Update(deltaTime, collisionSteps, _jobSystem);
}
- public void SyncTransforms(World world)
+ public void SyncTransforms(World world, Entity? skipEntity = null)
{
// Collect updates first to avoid calling entity.Set() during dictionary iteration
var updates = new List<(Entity entity, Vector3 pos, Quaternion rot)>();
@@ -130,6 +130,9 @@ public sealed class PhysicsWorld : IDisposable
if ((ulong)entity.Id == 0)
continue;
+ if (skipEntity.HasValue && skipEntity.Value.Id == entity.Id)
+ continue;
+
var pos = _bodyInterface.GetPosition(bodyId);
var rot = _bodyInterface.GetRotation(bodyId);
updates.Add((entity, pos, rot));
@@ -154,6 +157,8 @@ public sealed class PhysicsWorld : IDisposable
return;
_bodyInterface.SetPositionAndRotation(bodyId, transform.Position, transform.Rotation, Activation.Activate);
+ // Zero out velocity so the object doesn't accumulate momentum while being dragged
+ _bodyInterface.SetLinearAndAngularVelocity(bodyId, Vector3.Zero, Vector3.Zero);
}
private static Shape CreateShape(RigidBody rb)