feat: implement scene graph system with ECS hierarchy support
Add hierarchical scene graph for editor with TreeView UI, runtime HierarchyUtility for parent/child linked-list management, and incremental sync between editor world and scene graph nodes. - SceneGraphNode/SceneNode/EntityNode with World, Scene, Entity refs - SceneGraphBuilder — construct tree from ECS World queries - HierarchyUtility — SetParent, RemoveParent, IsAncestor, cascade destroy - EditorWorldService + SceneGraphSyncService — editor world lifecycle & incremental sync - Hierarchy.xaml — TreeView with DataTemplate + SceneGraphTemplateSelector - 25 unit tests covering hierarchy ops and scene graph building
This commit is contained in:
@@ -12,8 +12,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" />
|
||||
<PackageReference Include="MSTest" Version="4.2.1" />
|
||||
<PackageReference Include="MSTest" Version="4.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
286
src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs
Normal file
286
src/Test/Ghost.UnitTest/HierarchyUtilityTests.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Engine;
|
||||
using Ghost.Engine.Components;
|
||||
using Ghost.Entities;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
|
||||
namespace Ghost.UnitTest;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class HierarchyUtilityTests
|
||||
{
|
||||
private World _world = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
AllocationManager.Initialize();
|
||||
_world = World.Create(entityCapacity: 64);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
World.Destroy(_world.ID);
|
||||
AllocationManager.Dispose();
|
||||
}
|
||||
|
||||
private Entity CreateHierarchyEntity()
|
||||
{
|
||||
var entity = _world.EntityManager.CreateEntity();
|
||||
_world.EntityManager.AddComponent(entity, new Hierarchy
|
||||
{
|
||||
parent = Entity.Invalid,
|
||||
firstChild = Entity.Invalid,
|
||||
nextSibling = Entity.Invalid
|
||||
});
|
||||
return entity;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_ChildBecomesChildOfParent()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
var result = HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
Assert.AreEqual(Error.None, result);
|
||||
|
||||
ref var childHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child);
|
||||
Assert.AreEqual(parent, childHierarchy.parent);
|
||||
|
||||
ref var parentHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent);
|
||||
Assert.AreEqual(child, parentHierarchy.firstChild);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_SecondChildBecomesSibling()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child1 = CreateHierarchyEntity();
|
||||
var child2 = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child1, parent);
|
||||
HierarchyUtility.SetParent(_world, child2, parent);
|
||||
|
||||
ref var parentHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent);
|
||||
Assert.AreEqual(child2, parentHierarchy.firstChild);
|
||||
|
||||
ref var child2Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child2);
|
||||
Assert.AreEqual(child1, child2Hierarchy.nextSibling);
|
||||
|
||||
ref var child1Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child1);
|
||||
Assert.AreEqual(Entity.Invalid, child1Hierarchy.nextSibling);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_ReparentFromOneParentToAnother()
|
||||
{
|
||||
var parent1 = CreateHierarchyEntity();
|
||||
var parent2 = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child, parent1);
|
||||
HierarchyUtility.SetParent(_world, child, parent2);
|
||||
|
||||
ref var childHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child);
|
||||
Assert.AreEqual(parent2, childHierarchy.parent);
|
||||
|
||||
ref var parent1Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent1);
|
||||
Assert.AreEqual(Entity.Invalid, parent1Hierarchy.firstChild);
|
||||
|
||||
ref var parent2Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent2);
|
||||
Assert.AreEqual(child, parent2Hierarchy.firstChild);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_SelfParentingIsRejected()
|
||||
{
|
||||
var entity = CreateHierarchyEntity();
|
||||
|
||||
var result = HierarchyUtility.SetParent(_world, entity, entity);
|
||||
|
||||
Assert.AreEqual(Error.InvalidArgument, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_CycleIsRejected()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
var result = HierarchyUtility.SetParent(_world, parent, child);
|
||||
|
||||
Assert.AreEqual(Error.InvalidArgument, result);
|
||||
Assert.AreEqual(parent, _world.EntityManager.GetComponent<Hierarchy>(child).parent);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_GrandchildCycleIsRejected()
|
||||
{
|
||||
var grandparent = CreateHierarchyEntity();
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, parent, grandparent);
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
var result = HierarchyUtility.SetParent(_world, grandparent, child);
|
||||
|
||||
Assert.AreEqual(Error.InvalidArgument, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetParent_EntityWithoutHierarchyComponentReturnsNotFound()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = _world.EntityManager.CreateEntity();
|
||||
|
||||
var result = HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
Assert.AreEqual(Error.NotFound, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveParent_UnlinksChildFromParent()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
var result = HierarchyUtility.RemoveParent(_world, child);
|
||||
|
||||
Assert.AreEqual(Error.None, result);
|
||||
|
||||
ref var childHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child);
|
||||
Assert.AreEqual(Entity.Invalid, childHierarchy.parent);
|
||||
Assert.AreEqual(Entity.Invalid, childHierarchy.nextSibling);
|
||||
|
||||
ref var parentHierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(parent);
|
||||
Assert.AreEqual(Entity.Invalid, parentHierarchy.firstChild);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveParent_MiddleChildMaintainsSiblingChain()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child1 = CreateHierarchyEntity();
|
||||
var child2 = CreateHierarchyEntity();
|
||||
var child3 = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child1, parent);
|
||||
HierarchyUtility.SetParent(_world, child2, parent);
|
||||
HierarchyUtility.SetParent(_world, child3, parent);
|
||||
|
||||
HierarchyUtility.RemoveParent(_world, child2);
|
||||
|
||||
ref var child3Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child3);
|
||||
Assert.AreEqual(child1, child3Hierarchy.nextSibling);
|
||||
|
||||
ref var child2Hierarchy = ref _world.EntityManager.GetComponent<Hierarchy>(child2);
|
||||
Assert.AreEqual(Entity.Invalid, child2Hierarchy.parent);
|
||||
Assert.AreEqual(Entity.Invalid, child2Hierarchy.nextSibling);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveParent_WhenNoParentReturnsNone()
|
||||
{
|
||||
var entity = CreateHierarchyEntity();
|
||||
|
||||
var result = HierarchyUtility.RemoveParent(_world, entity);
|
||||
|
||||
Assert.AreEqual(Error.None, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAncestor_ReturnsTrueForDirectParent()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
Assert.IsTrue(HierarchyUtility.IsAncestor(_world, child, parent));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAncestor_ReturnsTrueForGrandparent()
|
||||
{
|
||||
var grandparent = CreateHierarchyEntity();
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, parent, grandparent);
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
Assert.IsTrue(HierarchyUtility.IsAncestor(_world, child, grandparent));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAncestor_ReturnsFalseForUnrelatedEntity()
|
||||
{
|
||||
var entity1 = CreateHierarchyEntity();
|
||||
var entity2 = CreateHierarchyEntity();
|
||||
|
||||
Assert.IsFalse(HierarchyUtility.IsAncestor(_world, entity1, entity2));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsAncestor_ReturnsFalseForChild()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
|
||||
Assert.IsFalse(HierarchyUtility.IsAncestor(_world, parent, child));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DestroyEntityWithChildren_CascadeDestroysAllChildren()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child1 = CreateHierarchyEntity();
|
||||
var child2 = CreateHierarchyEntity();
|
||||
var grandchild = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child1, parent);
|
||||
HierarchyUtility.SetParent(_world, child2, parent);
|
||||
HierarchyUtility.SetParent(_world, grandchild, child1);
|
||||
|
||||
HierarchyUtility.DestroyEntityWithChildren(_world, parent);
|
||||
|
||||
Assert.IsFalse(_world.EntityManager.Exists(parent));
|
||||
Assert.IsFalse(_world.EntityManager.Exists(child1));
|
||||
Assert.IsFalse(_world.EntityManager.Exists(child2));
|
||||
Assert.IsFalse(_world.EntityManager.Exists(grandchild));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DestroyEntityWithChildren_DoesNotAffectUnrelatedEntities()
|
||||
{
|
||||
var parent = CreateHierarchyEntity();
|
||||
var child = CreateHierarchyEntity();
|
||||
var unrelated = CreateHierarchyEntity();
|
||||
|
||||
HierarchyUtility.SetParent(_world, child, parent);
|
||||
HierarchyUtility.DestroyEntityWithChildren(_world, parent);
|
||||
|
||||
Assert.IsTrue(_world.EntityManager.Exists(unrelated));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RemoveEntity_DestroysSingleEntity()
|
||||
{
|
||||
var entity = CreateHierarchyEntity();
|
||||
|
||||
var result = HierarchyUtility.RemoveEntity(_world, entity);
|
||||
|
||||
Assert.AreEqual(Error.None, result);
|
||||
Assert.IsFalse(_world.EntityManager.Exists(entity));
|
||||
}
|
||||
}
|
||||
174
src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs
Normal file
174
src/Test/Ghost.UnitTest/SceneGraphBuilderTests.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using Ghost.Editor.Core.SceneGraph;
|
||||
using Ghost.Engine;
|
||||
using Ghost.Engine.Components;
|
||||
using Ghost.Engine.Core;
|
||||
using Ghost.Entities;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
|
||||
namespace Ghost.UnitTest;
|
||||
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class SceneGraphBuilderTests
|
||||
{
|
||||
private World _world = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
AllocationManager.Initialize();
|
||||
_world = World.Create(entityCapacity: 64);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
World.Destroy(_world.ID);
|
||||
AllocationManager.Dispose();
|
||||
}
|
||||
|
||||
private Entity CreateEntityWithScene(Scene scene)
|
||||
{
|
||||
var entity = _world.EntityManager.CreateEntity();
|
||||
_world.EntityManager.AddComponent(entity, new SceneID { scene = scene });
|
||||
return entity;
|
||||
}
|
||||
|
||||
private Entity CreateEntityWithSceneAndHierarchy(Scene scene, Entity parent)
|
||||
{
|
||||
var entity = _world.EntityManager.CreateEntity();
|
||||
_world.EntityManager.AddComponent(entity, new SceneID { scene = scene });
|
||||
_world.EntityManager.AddComponent(entity, new Hierarchy
|
||||
{
|
||||
parent = Entity.Invalid,
|
||||
firstChild = Entity.Invalid,
|
||||
nextSibling = Entity.Invalid
|
||||
});
|
||||
|
||||
if (parent.IsValid)
|
||||
{
|
||||
HierarchyUtility.SetParent(_world, entity, parent);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_EmptyWorldReturnsEmptyList()
|
||||
{
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
Assert.AreEqual(0, nodes.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_OneSceneOneEntity_CreatesSceneNodeWithEntityChild()
|
||||
{
|
||||
var scene = SceneManager.CreateScene();
|
||||
CreateEntityWithScene(scene);
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
Assert.AreEqual(1, nodes.Count);
|
||||
|
||||
var sceneNode = nodes[0];
|
||||
Assert.AreEqual(scene, sceneNode.Scene);
|
||||
Assert.AreEqual(1, sceneNode.Children.Count);
|
||||
Assert.IsInstanceOfType<EntityNode>(sceneNode.Children[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_MultipleScenes_CreatesMultipleSceneNodes()
|
||||
{
|
||||
var scene1 = SceneManager.CreateScene();
|
||||
var scene2 = SceneManager.CreateScene();
|
||||
CreateEntityWithScene(scene1);
|
||||
CreateEntityWithScene(scene2);
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
Assert.AreEqual(2, nodes.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_HierarchicalEntities_CreatesNestedEntityNodes()
|
||||
{
|
||||
var scene = SceneManager.CreateScene();
|
||||
var rootEntity = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid);
|
||||
var childEntity = CreateEntityWithSceneAndHierarchy(scene, rootEntity);
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
Assert.AreEqual(1, nodes.Count);
|
||||
var sceneNode = nodes[0];
|
||||
Assert.AreEqual(1, sceneNode.Children.Count);
|
||||
|
||||
var rootNode = (EntityNode)sceneNode.Children[0];
|
||||
Assert.AreEqual(rootEntity, rootNode.Entity);
|
||||
Assert.AreEqual(1, rootNode.Children.Count);
|
||||
|
||||
var childNode = (EntityNode)rootNode.Children[0];
|
||||
Assert.AreEqual(childEntity, childNode.Entity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_EntitiesWithoutHierarchy_AreFlatChildren()
|
||||
{
|
||||
var scene = SceneManager.CreateScene();
|
||||
CreateEntityWithScene(scene);
|
||||
CreateEntityWithScene(scene);
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
Assert.AreEqual(1, nodes.Count);
|
||||
var sceneNode = nodes[0];
|
||||
Assert.AreEqual(2, sceneNode.Children.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_SiblingOrder_PreservesChildOrder()
|
||||
{
|
||||
var scene = SceneManager.CreateScene();
|
||||
var parent = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid);
|
||||
var child1 = CreateEntityWithSceneAndHierarchy(scene, parent);
|
||||
var child2 = CreateEntityWithSceneAndHierarchy(scene, parent);
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
var rootNode = (EntityNode)nodes[0].Children[0];
|
||||
Assert.AreEqual(2, rootNode.Children.Count);
|
||||
Assert.AreEqual(child2, ((EntityNode)rootNode.Children[0]).Entity);
|
||||
Assert.AreEqual(child1, ((EntityNode)rootNode.Children[1]).Entity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_DeepHierarchy_BuildsFullTree()
|
||||
{
|
||||
var scene = SceneManager.CreateScene();
|
||||
var level1 = CreateEntityWithSceneAndHierarchy(scene, Entity.Invalid);
|
||||
var level2 = CreateEntityWithSceneAndHierarchy(scene, level1);
|
||||
var level3 = CreateEntityWithSceneAndHierarchy(scene, level2);
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
var n1 = (EntityNode)nodes[0].Children[0];
|
||||
Assert.AreEqual(1, n1.Children.Count);
|
||||
|
||||
var n2 = (EntityNode)n1.Children[0];
|
||||
Assert.AreEqual(1, n2.Children.Count);
|
||||
|
||||
var n3 = (EntityNode)n2.Children[0];
|
||||
Assert.AreEqual(level3, n3.Entity);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Build_InvalidSceneEntitiesAreExcluded()
|
||||
{
|
||||
var entity = _world.EntityManager.CreateEntity();
|
||||
_world.EntityManager.AddComponent(entity, new SceneID { scene = Scene.Invalid });
|
||||
|
||||
var nodes = SceneGraphBuilder.Build(_world);
|
||||
|
||||
Assert.AreEqual(0, nodes.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user