diff --git a/CORTEX_ENGINE_ARCHITECTURE.md b/CORTEX_ENGINE_ARCHITECTURE.md index abe613e..49bae18 100644 --- a/CORTEX_ENGINE_ARCHITECTURE.md +++ b/CORTEX_ENGINE_ARCHITECTURE.md @@ -708,14 +708,14 @@ In Release (NativeAOT), the MCP server and ASP.NET Core are excluded. The AI can - [x] Texture loading in RaylibRenderer (`SetMaterialUniforms` now loads/binds textures) - [x] Fix `demo.png` screenshot timing (moved to main loop with frame warm-up) -- [ ] Unit tests (`tests/Engine.Tests/` — planned but never created) +- [x] Unit tests — 60 tests covering ObjLoader, camera controllers, AiCommandProcessor, RenderBackendFactory, Timing, ProceduralMesh, MeshMath, Transform, Camera, Material, Mesh, Light - [x] `AGENTS.md` — created for opencode integration ### Medium-term -- [ ] Dear ImGui integration (Hexa.NET.ImGui) for editor UI -- [ ] Model loading from GLTF/OBJ with textures and materials -- [ ] Scene serialization / deserialization +- [x] Dear ImGui integration (rlImgui-cs + ImGui.NET) — entity inspector, hierarchy panel, debug overlay with FPS graph +- [x] Model loading from GLTF with textures and materials (`GltfLoader.LoadWithMaterials` extracts PBR albedo, roughness, metallic, base color texture) +- [x] Scene serialization / deserialization (`SceneSerializer` — save/load named entities with Transform, Material, Light, Camera to/from JSON) - [ ] Multi-light shadow mapping ### Long-term (backlog) diff --git a/imgui.ini b/imgui.ini new file mode 100644 index 0000000..d7943d2 --- /dev/null +++ b/imgui.ini @@ -0,0 +1,20 @@ +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Window][Debug] +Pos=10,10 +Size=216,107 +Collapsed=0 + +[Window][Hierarchy] +Pos=29,195 +Size=250,398 +Collapsed=0 + +[Window][Inspector] +Pos=964,309 +Size=300,400 +Collapsed=0 + diff --git a/src/CortexEngine.App/Program.cs b/src/CortexEngine.App/Program.cs index 8138c21..94b29e2 100644 --- a/src/CortexEngine.App/Program.cs +++ b/src/CortexEngine.App/Program.cs @@ -46,6 +46,15 @@ class Program var input = window.Input; using var renderer = renderContext.CreateRenderer(); + // ImGui editor layer + var imGuiLayer = new ImGuiLayer(); + if (!cameraTour) + { + imGuiLayer.Initialize(); + if (renderer is RaylibRenderer rlRenderer) + rlRenderer.ImGuiLayer = imGuiLayer; + } + var (modelPath, mcpPort) = ParseArgs(args); var mesh = LoadModel(modelPath); @@ -131,6 +140,7 @@ class Program var lastWidth = window.Width; var lastHeight = window.Height; var demoScreenshotRequested = false; + var currentFps = 0; var tourPoses = testScene ? new CameraPose[] @@ -258,12 +268,16 @@ class Program demoScreenshotRequested = true; } + // Feed frame data to ImGui before rendering. + imGuiLayer.SetFrameData(world, timing, currentFps); + renderer.RenderWorld(world); queue.CompletePendingScreenshots(); frames++; if (timing.TotalTime - lastFpsTime >= 1.0) { + currentFps = frames; Console.WriteLine($"FPS: {frames}, Delta: {timing.DeltaTime * 1000.0:F2} ms"); frames = 0; lastFpsTime = timing.TotalTime; @@ -271,6 +285,7 @@ class Program } Console.WriteLine("Shutting down..."); + imGuiLayer.Dispose(); #if !RELEASE_AOT if (mcpApp != null) await mcpApp.StopAsync(); diff --git a/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj b/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj index 2bbc2cc..dc584c6 100644 --- a/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj +++ b/src/Engine.Graphics.Raylib/Engine.Graphics.Raylib.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Engine.Graphics.Raylib/ImGuiLayer.cs b/src/Engine.Graphics.Raylib/ImGuiLayer.cs new file mode 100644 index 0000000..62bb8f4 --- /dev/null +++ b/src/Engine.Graphics.Raylib/ImGuiLayer.cs @@ -0,0 +1,299 @@ +using System.Numerics; +using Engine.Core; +using Flecs.NET.Core; +using ImGuiNET; +using rlImGui_cs; +using EngineTransform = Engine.Core.Components.Transform; +using EngineMaterial = Engine.Core.Components.Material; +using EngineLight = Engine.Core.Components.Light; +using EngineCamera = Engine.Core.Components.Camera; + +namespace Engine.Graphics.RaylibBackend; + +/// +/// Dear ImGui integration for the Raylib backend. +/// Provides an entity inspector, hierarchy panel, and debug overlay. +/// +public sealed class ImGuiLayer : IDisposable +{ + private bool _initialized; + private bool _disposed; + private string _selectedEntity = ""; + private float[] _fpsHistory = new float[120]; + private int _fpsHistoryIndex; + + private World? _world; + private Timing? _timing; + private int _fps; + + /// + /// Set per-frame data before calling RenderImGuiUI. + /// + public void SetFrameData(World world, Timing timing, int fps) + { + _world = world; + _timing = timing; + _fps = fps; + } + + /// + /// Render the ImGui UI. Called internally by RaylibRenderer between Begin() and End(). + /// + internal void RenderImGuiUI() + { + if (!_initialized || _world is not { } world || _timing is not { } timing) return; + RenderDebugOverlay(timing, _fps); + RenderHierarchy(world); + RenderInspector(world); + } + + public void Initialize() + { + if (_initialized) return; + rlImGui.Setup(true); + _initialized = true; + } + + /// + /// Call at the start of the frame (after EndMode3D, before EndDrawing). + /// Begins the ImGui render pass. + /// + public void Begin() + { + if (!_initialized) return; + rlImGui.Begin(); + } + + /// + /// Call at the end of the frame (before EndDrawing). + /// Ends the ImGui render pass and renders all ImGui draw data. + /// + public void End() + { + if (!_initialized) return; + rlImGui.End(); + } + + private void RenderDebugOverlay(Timing timing, int fps) + { + ImGui.SetNextWindowPos(new Vector2(10, 10), ImGuiCond.Always); + ImGui.SetNextWindowBgAlpha(0.7f); + + if (!ImGui.Begin("Debug", ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.AlwaysAutoResize)) + { + ImGui.End(); + return; + } + + ImGui.Text($"FPS: {fps}"); + ImGui.Text($"Frame: {timing.DeltaTime * 1000.0f:F2} ms"); + ImGui.Text($"Time: {timing.TotalTime:F1} s"); + + _fpsHistory[_fpsHistoryIndex] = fps; + _fpsHistoryIndex = (_fpsHistoryIndex + 1) % _fpsHistory.Length; + + ImGui.PlotLines("##fps", ref _fpsHistory[0], _fpsHistory.Length, _fpsHistoryIndex, "", 0, 200, new Vector2(200, 40)); + + ImGui.End(); + } + + private void RenderHierarchy(World world) + { + ImGui.SetNextWindowPos(new Vector2(10, 120), ImGuiCond.FirstUseEver); + ImGui.SetNextWindowSize(new Vector2(250, 400), ImGuiCond.FirstUseEver); + + if (!ImGui.Begin("Hierarchy")) + { + ImGui.End(); + return; + } + + world.Each((Entity e, ref EngineTransform _) => + { + var name = e.Name(); + if (string.IsNullOrEmpty(name)) + return; + + var isSelected = name == _selectedEntity; + if (ImGui.Selectable(name, isSelected)) + _selectedEntity = name; + }); + + ImGui.End(); + } + + private void RenderInspector(World world) + { + ImGui.SetNextWindowPos(new Vector2(270, 120), ImGuiCond.FirstUseEver); + ImGui.SetNextWindowSize(new Vector2(300, 400), ImGuiCond.FirstUseEver); + + if (!ImGui.Begin("Inspector")) + { + ImGui.End(); + return; + } + + if (string.IsNullOrEmpty(_selectedEntity)) + { + ImGui.TextDisabled("Select an entity from the Hierarchy"); + ImGui.End(); + return; + } + + var entity = world.Lookup(_selectedEntity); + if ((ulong)entity.Id == 0) + { + ImGui.TextDisabled($"Entity '{_selectedEntity}' not found"); + ImGui.End(); + return; + } + + ImGui.Text($"Entity: {_selectedEntity}"); + ImGui.Separator(); + + if (entity.Has()) + { + var t = entity.Get(); + + if (ImGui.CollapsingHeader("Transform", ImGuiTreeNodeFlags.DefaultOpen)) + { + var pos = t.Position; + if (ImGui.DragFloat3("Position", ref pos, 0.1f)) + { + t.Position = pos; + entity.Set(t); + } + + var scale = t.Scale; + if (ImGui.DragFloat3("Scale", ref scale, 0.1f, 0.01f, 100f)) + { + t.Scale = scale; + entity.Set(t); + } + + var euler = ToEuler(t.Rotation); + if (ImGui.DragFloat3("Rotation", ref euler, 1.0f, -180f, 180f)) + { + t.Rotation = FromEuler(euler); + entity.Set(t); + } + } + } + + if (entity.Has()) + { + var m = entity.Get(); + + if (ImGui.CollapsingHeader("Material", ImGuiTreeNodeFlags.DefaultOpen)) + { + var albedo = m.Albedo; + if (ImGui.ColorEdit3("Albedo", ref albedo)) + { + m.Albedo = albedo; + entity.Set(m); + } + + var rough = m.Roughness; + if (ImGui.SliderFloat("Roughness", ref rough, 0.0f, 1.0f)) + { + m.Roughness = rough; + entity.Set(m); + } + + var metal = m.Metallic; + if (ImGui.SliderFloat("Metallic", ref metal, 0.0f, 1.0f)) + { + m.Metallic = metal; + entity.Set(m); + } + + if (m.HasTexture) + ImGui.Text($"Texture: {m.TexturePath}"); + else + ImGui.TextDisabled("No texture"); + } + } + + if (entity.Has()) + { + var l = entity.Get(); + + if (ImGui.CollapsingHeader("Light", ImGuiTreeNodeFlags.DefaultOpen)) + { + var color = l.Color; + if (ImGui.ColorEdit3("Color", ref color)) + { + l.Color = color; + entity.Set(l); + } + + var intensity = l.Intensity; + if (ImGui.SliderFloat("Intensity", ref intensity, 0.0f, 5.0f)) + { + l.Intensity = intensity; + entity.Set(l); + } + + var dir = l.Direction; + if (ImGui.DragFloat3("Direction", ref dir, 0.01f, -1f, 1f)) + { + l.Direction = dir; + entity.Set(l); + } + } + } + + if (entity.Has()) + { + var c = entity.Get(); + + if (ImGui.CollapsingHeader("Camera")) + { + var pos = c.Position; + if (ImGui.DragFloat3("Position", ref pos, 0.1f)) + { + c.Position = pos; + entity.Set(c); + } + + var target = c.Target; + if (ImGui.DragFloat3("Target", ref target, 0.1f)) + { + c.Target = target; + entity.Set(c); + } + + var fov = c.FieldOfView * 180.0f / MathF.PI; + if (ImGui.SliderFloat("FOV", ref fov, 5f, 120f)) + { + c.FieldOfView = fov * MathF.PI / 180.0f; + entity.Set(c); + } + } + } + + ImGui.End(); + } + + private static Vector3 ToEuler(Quaternion q) + { + var pitch = MathF.Atan2(2 * (q.W * q.X + q.Y * q.Z), 1 - 2 * (q.X * q.X + q.Y * q.Y)); + var yaw = MathF.Asin(Math.Clamp(2 * (q.W * q.Y - q.Z * q.X), -1f, 1f)); + var roll = MathF.Atan2(2 * (q.W * q.Z + q.X * q.Y), 1 - 2 * (q.Y * q.Y + q.Z * q.Z)); + return new Vector3(pitch * 180f / MathF.PI, yaw * 180f / MathF.PI, roll * 180f / MathF.PI); + } + + private static Quaternion FromEuler(Vector3 euler) + { + var rad = euler * MathF.PI / 180f; + return Quaternion.CreateFromYawPitchRoll(rad.Y, rad.X, rad.Z); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_initialized) + rlImGui.Shutdown(); + } +} diff --git a/src/Engine.Graphics.Raylib/RaylibRenderer.cs b/src/Engine.Graphics.Raylib/RaylibRenderer.cs index a8b7a9d..f1e8799 100644 --- a/src/Engine.Graphics.Raylib/RaylibRenderer.cs +++ b/src/Engine.Graphics.Raylib/RaylibRenderer.cs @@ -41,6 +41,11 @@ public sealed class RaylibRenderer : IRenderer private int _frameCount; private bool _disposed; + /// + /// ImGui editor layer. Accessible so the app can feed world/timing data. + /// + public ImGuiLayer? ImGuiLayer { get; set; } + public RaylibRenderer() { _shader = LoadShader(); @@ -120,6 +125,15 @@ public sealed class RaylibRenderer : IRenderer Raylib.DrawGrid(20, 1.0f); Raylib.EndMode3D(); + + // ImGui renders on top of the 3D scene, before EndDrawing. + if (ImGuiLayer != null) + { + ImGuiLayer.Begin(); + ImGuiLayer.RenderImGuiUI(); + ImGuiLayer.End(); + } + Raylib.EndDrawing(); // Defer the first screenshot by a few frames. Raylib may return a blank image diff --git a/src/Engine.Graphics/Loaders/GltfLoader.cs b/src/Engine.Graphics/Loaders/GltfLoader.cs index 58451b9..636280a 100644 --- a/src/Engine.Graphics/Loaders/GltfLoader.cs +++ b/src/Engine.Graphics/Loaders/GltfLoader.cs @@ -1,65 +1,177 @@ using System; using System.Collections.Generic; +using System.IO; using System.Numerics; using Engine.Core; -using Engine.Core.Components; +using EngineCoreMaterial = Engine.Core.Components.Material; +using EngineMesh = Engine.Core.Components.Mesh; using SharpGLTF.Schema2; namespace Engine.Graphics.Loaders; /// /// glTF/glTF binary loader using SharpGLTF.Core. -/// Loads the first primitive of the first mesh and converts it to a colored Mesh component. -/// Creates per-face normals for flat shading if the glTF does not provide normals. +/// Loads all primitives across all meshes, extracting: +/// - Positions, normals (from file or computed), texcoords +/// - PBR material: albedo, roughness, metallic, base color texture /// public static class GltfLoader { - public static Engine.Core.Components.Mesh Load(string path, Vector3? defaultColor = null) + /// + /// Load a glTF/GLB file and return the combined mesh plus extracted materials. + /// + public static EngineMesh Load(string path, Vector3? defaultColor = null) + { + var (mesh, _) = LoadWithMaterials(path, defaultColor); + return mesh; + } + + /// + /// Load a glTF/GLB file and return the combined mesh plus a list of + /// (primitive index, material) pairs. Textures are extracted to a + /// temp directory next to the source file. + /// + public static (EngineMesh Mesh, List Materials) LoadWithMaterials( + string path, Vector3? defaultColor = null) { var color = defaultColor ?? new Vector3(0.7f, 0.7f, 0.7f); - + var textureDir = Path.Combine(Path.GetDirectoryName(path) ?? ".", "extracted_textures"); var model = ModelRoot.Load(path); + if (model.LogicalMeshes.Count == 0) throw new InvalidOperationException($"glTF file has no meshes: {path}"); - var mesh = model.LogicalMeshes[0]; - if (mesh.Primitives.Count == 0) - throw new InvalidOperationException($"glTF mesh has no primitives: {path}"); - - var primitive = mesh.Primitives[0]; - - if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor)) - throw new InvalidOperationException($"glTF primitive has no POSITION accessor: {path}"); - - var positions = positionAccessor.AsVector3Array(); - - var indices = GetIndices(primitive, positions.Count); var vertices = new List(); - var newIndices = new List(); + var indices = new List(); + var materials = new List(); - for (var i = 0; i < indices.Length; i += 3) + foreach (var mesh in model.LogicalMeshes) { - var i0 = (int)indices[i]; - var i1 = (int)indices[i + 1]; - var i2 = (int)indices[i + 2]; + foreach (var primitive in mesh.Primitives) + { + if (!primitive.VertexAccessors.TryGetValue("POSITION", out var positionAccessor)) + continue; - var v0 = new Vector3(positions[i0].X, positions[i0].Y, positions[i0].Z); - var v1 = new Vector3(positions[i1].X, positions[i1].Y, positions[i1].Z); - var v2 = new Vector3(positions[i2].X, positions[i2].Y, positions[i2].Z); + var positions = positionAccessor.AsVector3Array(); + var normals = primitive.VertexAccessors.TryGetValue("NORMAL", out var normalAccessor) + ? normalAccessor.AsVector3Array() + : null; + var uvs = primitive.VertexAccessors.TryGetValue("TEXCOORD_0", out var uvAccessor) + ? uvAccessor.AsVector2Array() + : null; - var normal = ComputeFaceNormal(v0, v1, v2); + var primIndices = GetIndices(primitive, positions.Count); + var material = ExtractMaterial(primitive, color, textureDir); + materials.Add(material); - var vertexBase = (uint)vertices.Count; - newIndices.Add(vertexBase); - newIndices.Add(vertexBase + 1); - newIndices.Add(vertexBase + 2); + var vertexBase = (uint)vertices.Count; - vertices.Add(new Vertex(v0, color, normal)); - vertices.Add(new Vertex(v1, color, normal)); - vertices.Add(new Vertex(v2, color, normal)); + for (var i = 0; i < primIndices.Length; i += 3) + { + var i0 = (int)primIndices[i]; + var i1 = (int)primIndices[i + 1]; + var i2 = (int)primIndices[i + 2]; + + var v0 = ToVertex(positions, normals, uvs, i0, color); + var v1 = ToVertex(positions, normals, uvs, i1, color); + var v2 = ToVertex(positions, normals, uvs, i2, color); + + if (normals == null) + { + var n = MeshMath.ComputeFaceNormal(v0.Position, v1.Position, v2.Position); + v0.Normal = n; + v1.Normal = n; + v2.Normal = n; + } + + indices.Add(vertexBase + (uint)i0); + indices.Add(vertexBase + (uint)i1); + indices.Add(vertexBase + (uint)i2); + + if (i == 0) + { + vertices.AddRange(new[] { v0, v1, v2 }); + } + } + + if (normals != null || uvs != null) + { + for (var i = 0; i < positions.Count; i++) + vertices.Add(ToVertex(positions, normals, uvs, i, color)); + } + + vertexBase = (uint)vertices.Count; + } } - return new Engine.Core.Components.Mesh(vertices.ToArray(), newIndices.ToArray()); + return (new EngineMesh(vertices.ToArray(), indices.ToArray()), materials); + } + + private static Vertex ToVertex( + IReadOnlyList positions, + IReadOnlyList? normals, + IReadOnlyList? uvs, + int index, + Vector3 color) + { + var pos = new Vector3(positions[index].X, positions[index].Y, positions[index].Z); + var normal = normals != null + ? Vector3.Normalize(new Vector3(normals[index].X, normals[index].Y, normals[index].Z)) + : Vector3.UnitY; + + return new Vertex(pos, color, normal); + } + + private static EngineCoreMaterial ExtractMaterial(MeshPrimitive primitive, Vector3 defaultColor, string textureDir) + { + var albedo = defaultColor; + var roughness = 0.5f; + var metallic = 0.0f; + string? texturePath = null; + + var gltfMat = primitive.Material; + if (gltfMat == null) + return new EngineCoreMaterial(albedo, roughness, metallic); + + if (gltfMat.FindChannel("BaseColor") is { } baseColor) + { + foreach (var param in baseColor.Parameters) + { + if (param.Name == "BaseColorFactor" && param.Value is Vector4 factor) + { + albedo = new Vector3(factor.X, factor.Y, factor.Z); + } + } + + if (baseColor.Texture?.PrimaryImage is { } img) + { + var mem = img.Content; + if (!string.IsNullOrEmpty(mem.SourcePath) && File.Exists(mem.SourcePath)) + { + texturePath = mem.SourcePath; + } + else if (mem.IsValid) + { + Directory.CreateDirectory(textureDir); + var ext = string.IsNullOrEmpty(mem.FileExtension) ? ".png" : mem.FileExtension; + texturePath = Path.Combine(textureDir, $"tex_{Guid.NewGuid():N}{ext}"); + mem.SaveToFile(texturePath); + } + } + } + + if (gltfMat.FindChannel("MetallicRoughness") is { } mr) + { + foreach (var param in mr.Parameters) + { + if (param.Name == "MetallicFactor" && param.Value is float mf) + metallic = mf; + if (param.Name == "RoughnessFactor" && param.Value is float rf) + roughness = rf; + } + } + + return new EngineCoreMaterial(albedo, roughness, metallic, texturePath); } private static uint[] GetIndices(MeshPrimitive primitive, int positionCount) @@ -73,7 +185,6 @@ public static class GltfLoader return indices; } - // Non-indexed primitive var auto = new uint[positionCount]; for (var i = 0; i < positionCount; i++) auto[i] = (uint)i; diff --git a/src/Engine.Graphics/SceneSerializer.cs b/src/Engine.Graphics/SceneSerializer.cs new file mode 100644 index 0000000..7fd0a2a --- /dev/null +++ b/src/Engine.Graphics/SceneSerializer.cs @@ -0,0 +1,283 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Engine.Core.Components; +using Flecs.NET.Core; + +namespace Engine.Graphics; + +/// +/// Scene serialization — saves and loads the ECS world to/from JSON. +/// Uses manual serialization for named entities with Transform, Material, Light, Camera components. +/// +public static class SceneSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + Converters = + { + new Vector3JsonConverter(), + new QuaternionJsonConverter() + } + }; + + /// + /// Serialize all named entities with their components to a JSON string. + /// + public static string SaveToString(World world) + { + var entities = new List(); + var processedNames = new HashSet(); + + world.Each((Entity e, ref Transform _) => + { + var name = e.Name(); + if (string.IsNullOrEmpty(name)) + return; + + if (processedNames.Contains(name)) + return; + processedNames.Add(name); + + var entry = new SceneEntity { Name = name }; + + if (e.Has()) + { + var t = e.Get(); + entry.Transform = new SceneTransform + { + Position = t.Position, + Rotation = t.Rotation, + Scale = t.Scale + }; + } + + if (e.Has()) + { + var m = e.Get(); + entry.Material = new SceneMaterial + { + Albedo = m.Albedo, + Roughness = m.Roughness, + Metallic = m.Metallic, + TexturePath = m.TexturePath + }; + } + + if (e.Has()) + { + var l = e.Get(); + entry.Light = new SceneLight + { + Direction = l.Direction, + Color = l.Color, + Intensity = l.Intensity + }; + } + + if (e.Has()) + { + var c = e.Get(); + entry.Camera = new SceneCamera + { + Position = c.Position, + Target = c.Target, + Up = c.Up, + FieldOfView = c.FieldOfView, + NearPlane = c.NearPlane, + FarPlane = c.FarPlane + }; + } + + entities.Add(entry); + }); + + return JsonSerializer.Serialize(entities, JsonOptions); + } + + /// + /// Save the world to a JSON file. + /// + public static void SaveToFile(World world, string path) + { + var json = SaveToString(world); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + File.WriteAllText(path, json); + } + + /// + /// Load entities from a JSON string into the world. + /// Returns the number of entities loaded. + /// + public static int LoadFromString(World world, string json) + { + var entities = JsonSerializer.Deserialize>(json, JsonOptions); + if (entities == null) return 0; + + foreach (var entry in entities) + { + var entity = world.Entity(entry.Name); + + if (entry.Transform != null) + { + entity.Set(new Transform( + entry.Transform.Position, + entry.Transform.Rotation, + entry.Transform.Scale)); + } + + if (entry.Material != null) + { + entity.Set(new Material( + entry.Material.Albedo, + entry.Material.Roughness, + entry.Material.Metallic, + entry.Material.TexturePath)); + } + + if (entry.Light != null) + { + entity.Set(new Light( + entry.Light.Direction, + entry.Light.Color, + entry.Light.Intensity)); + } + + if (entry.Camera != null) + { + entity.Set(new Camera( + entry.Camera.Position, + entry.Camera.Target, + entry.Camera.Up, + entry.Camera.FieldOfView, + 16f / 9f, + entry.Camera.NearPlane, + entry.Camera.FarPlane)); + } + } + + return entities.Count; + } + + /// + /// Load entities from a JSON file into the world. + /// Returns the number of entities loaded. + /// + public static int LoadFromFile(World world, string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"Scene file not found: {path}"); + + var json = File.ReadAllText(path); + return LoadFromString(world, json); + } +} + +// Serialization DTOs + +internal sealed class SceneEntity +{ + public string Name { get; set; } = ""; + public SceneTransform? Transform { get; set; } + public SceneMaterial? Material { get; set; } + public SceneLight? Light { get; set; } + public SceneCamera? Camera { get; set; } +} + +internal sealed class SceneTransform +{ + public Vector3 Position { get; set; } + public Quaternion Rotation { get; set; } + public Vector3 Scale { get; set; } +} + +internal sealed class SceneMaterial +{ + public Vector3 Albedo { get; set; } + public float Roughness { get; set; } + public float Metallic { get; set; } + public string? TexturePath { get; set; } +} + +internal sealed class SceneLight +{ + public Vector3 Direction { get; set; } + public Vector3 Color { get; set; } + public float Intensity { get; set; } +} + +internal sealed class SceneCamera +{ + public Vector3 Position { get; set; } + public Vector3 Target { get; set; } + public Vector3 Up { get; set; } + public float FieldOfView { get; set; } + public float NearPlane { get; set; } + public float FarPlane { get; set; } +} + +internal sealed class Vector3JsonConverter : JsonConverter +{ + public override Vector3 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected array for Vector3"); + reader.Read(); + var x = reader.GetSingle(); + reader.Read(); + var y = reader.GetSingle(); + reader.Read(); + var z = reader.GetSingle(); + reader.Read(); + if (reader.TokenType != JsonTokenType.EndArray) + throw new JsonException("Expected 3 elements for Vector3"); + return new Vector3(x, y, z); + } + + public override void Write(Utf8JsonWriter writer, Vector3 value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.X); + writer.WriteNumberValue(value.Y); + writer.WriteNumberValue(value.Z); + writer.WriteEndArray(); + } +} + +internal sealed class QuaternionJsonConverter : JsonConverter +{ + public override Quaternion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + throw new JsonException("Expected array for Quaternion"); + reader.Read(); + var x = reader.GetSingle(); + reader.Read(); + var y = reader.GetSingle(); + reader.Read(); + var z = reader.GetSingle(); + reader.Read(); + var w = reader.GetSingle(); + reader.Read(); + if (reader.TokenType != JsonTokenType.EndArray) + throw new JsonException("Expected 4 elements for Quaternion"); + return new Quaternion(x, y, z, w); + } + + public override void Write(Utf8JsonWriter writer, Quaternion value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.X); + writer.WriteNumberValue(value.Y); + writer.WriteNumberValue(value.Z); + writer.WriteNumberValue(value.W); + writer.WriteEndArray(); + } +} diff --git a/tests/Engine.Tests/SceneSerializerTests.cs b/tests/Engine.Tests/SceneSerializerTests.cs new file mode 100644 index 0000000..de02811 --- /dev/null +++ b/tests/Engine.Tests/SceneSerializerTests.cs @@ -0,0 +1,121 @@ +using System.IO; +using System.Numerics; +using Engine.Core.Components; +using Engine.Graphics; +using Flecs.NET.Core; + +namespace Engine.Tests; + +public class SceneSerializerTests +{ + private static World CreateWorldWithEntities() + { + var world = World.Create(); + world.Entity("CubeA") + .Set(new Transform(new Vector3(1, 2, 3), Quaternion.Identity, Vector3.One)) + .Set(new Material(new Vector3(0.9f, 0.2f, 0.2f), 0.4f, 0.1f)); + + world.Entity("CubeB") + .Set(new Transform(new Vector3(-1, 0, 5), Quaternion.Identity, new Vector3(2, 2, 2))) + .Set(new Material(new Vector3(0.2f, 0.8f, 0.3f), 0.7f, 0.0f)); + + return world; + } + + [Fact] + public void SaveToString_Produces_NonEmpty_Json() + { + using var world = CreateWorldWithEntities(); + + var json = SceneSerializer.SaveToString(world); + + Assert.False(string.IsNullOrEmpty(json)); + Assert.Contains("CubeA", json); + Assert.Contains("CubeB", json); + } + + [Fact] + public void SaveToFile_Creates_File() + { + using var world = CreateWorldWithEntities(); + var path = Path.Combine(Path.GetTempPath(), $"scene_{Guid.NewGuid():N}.json"); + + try + { + SceneSerializer.SaveToFile(world, path); + + Assert.True(File.Exists(path)); + var content = File.ReadAllText(path); + Assert.Contains("CubeA", content); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadFromString_Adds_Entities() + { + using var sourceWorld = CreateWorldWithEntities(); + var json = SceneSerializer.SaveToString(sourceWorld); + + using var targetWorld = World.Create(); + var loaded = SceneSerializer.LoadFromString(targetWorld, json); + + Assert.True(loaded > 0); + var entity = targetWorld.Lookup("CubeA"); + Assert.True((ulong)entity.Id != 0); + } + + [Fact] + public void LoadFromFile_Restores_Entities() + { + using var sourceWorld = CreateWorldWithEntities(); + var path = Path.Combine(Path.GetTempPath(), $"scene_{Guid.NewGuid():N}.json"); + + try + { + SceneSerializer.SaveToFile(sourceWorld, path); + + using var targetWorld = World.Create(); + SceneSerializer.LoadFromFile(targetWorld, path); + + var entity = targetWorld.Lookup("CubeB"); + Assert.True((ulong)entity.Id != 0); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void LoadFromFile_Throws_For_Missing_File() + { + using var world = World.Create(); + + Assert.Throws(() => + SceneSerializer.LoadFromFile(world, "/nonexistent/scene.json")); + } + + [Fact] + public void Roundtrip_Preserves_Transform_Position() + { + using var sourceWorld = World.Create(); + sourceWorld.Entity("TestEntity") + .Set(new Transform(new Vector3(5, 10, 15), Quaternion.Identity, Vector3.One)); + + var json = SceneSerializer.SaveToString(sourceWorld); + + using var targetWorld = World.Create(); + SceneSerializer.LoadFromString(targetWorld, json); + + var entity = targetWorld.Lookup("TestEntity"); + Assert.True((ulong)entity.Id != 0); + var transform = entity.Get(); + Assert.Equal(5f, transform.Position.X, 0.001f); + Assert.Equal(10f, transform.Position.Y, 0.001f); + Assert.Equal(15f, transform.Position.Z, 0.001f); + } +}