Compare commits
38 Commits
b42398bbce
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d4238e3086 | |||
| f552a4e9e1 | |||
| 5b34da6d6c | |||
| 1f1e21905e | |||
| acd8e60ffb | |||
| c6e58b057c | |||
| 34dc6fc8c9 | |||
| d9343a94dc | |||
| b84ee586bf | |||
| 52dfa12e84 | |||
| 326aee2b1c | |||
| 2cbe1ca789 | |||
| 1fe080dd87 | |||
| 211ea2254d | |||
| 914dde2448 | |||
| 5222e801b9 | |||
| a1c5ccf937 | |||
| 6b501efda0 | |||
| a40140cabd | |||
| 7dac1e4437 | |||
| e04c7eb6a7 | |||
| 84c936bb7a | |||
| 18505cdff6 | |||
| f85cf4edde | |||
| 982ce7d8e0 | |||
| cf5af7ee50 | |||
| 60b807abd7 | |||
| cbaa129d9e | |||
| bb07644580 | |||
| 314b0111f0 | |||
| a95ff01366 | |||
| 7e1db7b908 | |||
| 2ea3c509b0 | |||
| e2bc68d359 | |||
| 1cc65e8218 | |||
| d0076c852f | |||
| ba8694ed0c | |||
| 80e820a858 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@
|
|||||||
AGENTS.md
|
AGENTS.md
|
||||||
.opencode/
|
.opencode/
|
||||||
.code-review-graph/
|
.code-review-graph/
|
||||||
.github/instructions/
|
.antigravitycli/
|
||||||
|
|
||||||
ref/
|
ref/
|
||||||
docfx/
|
docfx/
|
||||||
|
|||||||
17
src/Directory.Build.props
Normal file
17
src/Directory.Build.props
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Debug_Editor'">
|
||||||
|
<DefineConstants>$(DefineConstants);DEBUG;TRACE;GHOST_EDITOR;GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>false</Optimize>
|
||||||
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<DebugType>portable</DebugType>
|
||||||
|
<TieredCompilation>false</TieredCompilation>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release_Editor'">
|
||||||
|
<DefineConstants>$(DefineConstants);GHOST_EDITOR;GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)' == 'Release_Dev'">
|
||||||
|
<DefineConstants>$(DefineConstants);GHOST_SAFETY_CHECKS;</DefineConstants>
|
||||||
|
<Optimize>true</Optimize>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public struct DSLShaderError
|
|||||||
|
|
||||||
public static class DSLShaderCompiler
|
public static class DSLShaderCompiler
|
||||||
{
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
private static PipelineState MeragePipeline(PipelineSemantic? semantic, PipelineState parent)
|
||||||
{
|
{
|
||||||
if (semantic == null)
|
if (semantic == null)
|
||||||
@@ -85,11 +86,13 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
// TODO: Implement shader inheritance resolution, including property and pass merging.
|
||||||
// Currently, we just ignore inheritance.
|
// Currently, we just ignore inheritance.
|
||||||
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
public static Result<GraphicsShaderDescriptor> ResolveShader(DSLShaderSemantics semantics)
|
||||||
{
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
{
|
{
|
||||||
propertyInfo = default;
|
propertyInfo = default;
|
||||||
@@ -101,7 +104,7 @@ public static class DSLShaderCompiler
|
|||||||
var pass = semantics.passes![i];
|
var pass = semantics.passes![i];
|
||||||
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
|
var localPipeline = MeragePipeline(pass.localPipeline, PipelineState.Default);
|
||||||
|
|
||||||
var result = BuildFinalShaderCode(pass.amplificationShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.code);
|
var result = BuildFinalShaderCode(pass.amplificationShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
||||||
@@ -109,7 +112,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
var amplificationShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.amplificationShader.entry };
|
var amplificationShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.amplificationShader.entry };
|
||||||
|
|
||||||
result = BuildFinalShaderCode(pass.meshShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.code);
|
result = BuildFinalShaderCode(pass.meshShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
||||||
@@ -117,7 +120,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
var meshShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.meshShader.entry };
|
var meshShaderCode = new ShaderCode { code = result.Value, entryPoint = pass.meshShader.entry };
|
||||||
|
|
||||||
result = BuildFinalShaderCode(pass.pixelShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.code);
|
result = BuildFinalShaderCode(pass.pixelShader.shaderPath, pass.includes.AsSpan(), pass.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for pass '{pass.name}': {result.Message}");
|
||||||
@@ -142,7 +145,7 @@ public static class DSLShaderCompiler
|
|||||||
var descriptor = new GraphicsShaderDescriptor
|
var descriptor = new GraphicsShaderDescriptor
|
||||||
{
|
{
|
||||||
Name = semantics.name,
|
Name = semantics.name,
|
||||||
PropertyBufferSize = propertyInfo.size,
|
PropertyBufferSize = propertyInfo.Size,
|
||||||
|
|
||||||
ShaderModel = semantics.shaderModel,
|
ShaderModel = semantics.shaderModel,
|
||||||
Passes = passes
|
Passes = passes
|
||||||
@@ -154,6 +157,10 @@ public static class DSLShaderCompiler
|
|||||||
}
|
}
|
||||||
|
|
||||||
return descriptor;
|
return descriptor;
|
||||||
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
|
#endif
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
public static Result<GraphicsShaderDescriptor> CompileGraphicsShader(Stream stream)
|
||||||
@@ -294,6 +301,7 @@ public static class DSLShaderCompiler
|
|||||||
|
|
||||||
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
|
public static Result<ComputeShaderDescriptor> ResolveComputeShader(DSLComputeShaderSemantics semantics)
|
||||||
{
|
{
|
||||||
|
#if GHOST_SAFETY_CHECKS
|
||||||
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
if (!ShaderPropertiesRegistry.TryGetInfo(semantics.name, out var propertyInfo))
|
||||||
{
|
{
|
||||||
propertyInfo = default;
|
propertyInfo = default;
|
||||||
@@ -302,7 +310,7 @@ public static class DSLShaderCompiler
|
|||||||
var shaderCodes = new ShaderCode[semantics.entryPoints.Count];
|
var shaderCodes = new ShaderCode[semantics.entryPoints.Count];
|
||||||
for (var i = 0; i < shaderCodes.Length; i++)
|
for (var i = 0; i < shaderCodes.Length; i++)
|
||||||
{
|
{
|
||||||
var result = BuildFinalShaderCode(semantics.entryPoints[i].shaderPath, semantics.includes.AsSpan(), semantics.hlsl, propertyInfo.code);
|
var result = BuildFinalShaderCode(semantics.entryPoints[i].shaderPath, semantics.includes.AsSpan(), semantics.hlsl, propertyInfo.Code);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
return Result.Failure($"Failed to build shader code for entry point '{semantics.entryPoints[i].entry}': {result.Message}");
|
return Result.Failure($"Failed to build shader code for entry point '{semantics.entryPoints[i].entry}': {result.Message}");
|
||||||
@@ -314,11 +322,14 @@ public static class DSLShaderCompiler
|
|||||||
return new ComputeShaderDescriptor
|
return new ComputeShaderDescriptor
|
||||||
{
|
{
|
||||||
Name = semantics.name,
|
Name = semantics.name,
|
||||||
PropertyBufferSize = propertyInfo.size,
|
PropertyBufferSize = propertyInfo.Size,
|
||||||
ShaderModel = semantics.shaderModel,
|
ShaderModel = semantics.shaderModel,
|
||||||
ShaderCodes = shaderCodes,
|
ShaderCodes = shaderCodes,
|
||||||
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
|
Defines = semantics.defines?.ToArray() ?? Array.Empty<string>(),
|
||||||
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
|
Keywords = semantics.keywords?.ToArray() ?? Array.Empty<KeywordsGroup>()
|
||||||
};
|
};
|
||||||
|
#else
|
||||||
|
return Result.Failure("GHOST_EDITOR is not defined");
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Antlr4.Runtime.Misc;
|
using Antlr4.Runtime.Misc;
|
||||||
using Ghost.DSL.ShaderParser.Model;
|
using Ghost.DSL.ShaderParser.Model;
|
||||||
using TerraFX.Interop.Windows;
|
|
||||||
|
|
||||||
namespace Ghost.DSL.ShaderParser;
|
namespace Ghost.DSL.ShaderParser;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ public sealed class CustomAssetHandlerAttribute : Attribute
|
|||||||
} = true;
|
} = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAsset : IDisposable
|
public abstract class IAsset : GhostObject
|
||||||
{
|
{
|
||||||
public Guid ID
|
public Guid ID
|
||||||
{
|
{
|
||||||
@@ -48,6 +48,14 @@ public interface IAsset : IDisposable
|
|||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected IAsset(Guid id, Guid typeId, IAssetSettings? settings)
|
||||||
|
:base(id)
|
||||||
|
{
|
||||||
|
ID = id;
|
||||||
|
TypeID = typeId;
|
||||||
|
Settings = settings;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAssetExportOptions;
|
public interface IAssetExportOptions;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
using Misaki.HighPerformance.Mathematics;
|
using Misaki.HighPerformance.Mathematics;
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Ghost.Graphics.RHI;
|
|||||||
using Ghost.Graphics.Utilities;
|
using Ghost.Graphics.Utilities;
|
||||||
using Ghost.MeshOptimizer;
|
using Ghost.MeshOptimizer;
|
||||||
using Ghost.Ufbx;
|
using Ghost.Ufbx;
|
||||||
using Misaki.HighPerformance.Jobs;
|
|
||||||
using Misaki.HighPerformance.LowLevel;
|
using Misaki.HighPerformance.LowLevel;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
@@ -13,11 +12,9 @@ using Misaki.HighPerformance.Mathematics;
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using TLSFPool = Misaki.HighPerformance.LowLevel.Buffer.MemoryPool<Misaki.HighPerformance.LowLevel.Buffer.TLSF, Misaki.HighPerformance.LowLevel.Buffer.TLSF.CreationOptions>;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
internal readonly unsafe struct MeshParsingJob : IJob
|
internal unsafe class MeshParsingJob
|
||||||
{
|
{
|
||||||
private struct GeometryPart : IDisposable
|
private struct GeometryPart : IDisposable
|
||||||
{
|
{
|
||||||
@@ -38,14 +35,20 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
|
|
||||||
private readonly string _filePath;
|
private readonly string _filePath;
|
||||||
private readonly AllocationHandle _allocationHandle;
|
private readonly AllocationHandle _allocationHandle;
|
||||||
private readonly MeshAssetSettings _settings;
|
private readonly ModelAssetSettings _settings;
|
||||||
|
|
||||||
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, MeshAssetSettings settings)
|
private readonly TaskCompletionSource<Result> _taskCompletionSource;
|
||||||
|
|
||||||
|
public Task<Result> Task => _taskCompletionSource.Task;
|
||||||
|
|
||||||
|
public MeshParsingJob(MeshNode rootNode, string filePath, AllocationHandle allocationHandle, ModelAssetSettings settings)
|
||||||
{
|
{
|
||||||
_rootNode = rootNode;
|
_rootNode = rootNode;
|
||||||
_filePath = filePath;
|
_filePath = filePath;
|
||||||
_allocationHandle = allocationHandle;
|
_allocationHandle = allocationHandle;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
|
||||||
|
_taskCompletionSource = new TaskCompletionSource<Result>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
@@ -321,7 +324,7 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Execute(ref readonly JobExecutionContext context)
|
public Result Execute()
|
||||||
{
|
{
|
||||||
var error = new ufbx_error();
|
var error = new ufbx_error();
|
||||||
var load_Opts = new ufbx_load_opts
|
var load_Opts = new ufbx_load_opts
|
||||||
@@ -354,15 +357,20 @@ internal readonly unsafe struct MeshParsingJob : IJob
|
|||||||
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
|
using var scene = new DisposablePtr<ufbx_scene>(ufbx_scene.LoadFile((sbyte*)str.GetUnsafePtr(), &load_Opts, &error));
|
||||||
if (scene.Get() == null)
|
if (scene.Get() == null)
|
||||||
{
|
{
|
||||||
Logger.Error(error.description.ToString());
|
return Result.Failure(error.description.ToString());
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
|
ParseHierarchy(scene.Get()->root_node, _rootNode, AllocationHandle.TLSF);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MeshProcessor
|
internal static partial class MeshProcessor
|
||||||
{
|
{
|
||||||
|
public static Task<Result> ParseMeshAsync(MeshNode root, string sourcePath, AllocationHandle allocationHandle, ModelAssetSettings meshSettings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var parseJob = new MeshParsingJob(root, sourcePath, allocationHandle, meshSettings);
|
||||||
|
return Task.Run(parseJob.Execute, token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using Misaki.HighPerformance.Mathematics;
|
|||||||
using Misaki.HighPerformance.Mathematics.Geometry;
|
using Misaki.HighPerformance.Mathematics.Geometry;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using TerraFX.Interop.Windows;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
@@ -164,7 +165,7 @@ public unsafe struct ClodCluster
|
|||||||
|
|
||||||
internal static unsafe partial class MeshProcessor
|
internal static unsafe partial class MeshProcessor
|
||||||
{
|
{
|
||||||
private delegate int ClodOutputDelegate(MeshletContext context, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters);
|
private delegate int ClodOutputDelegate(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters);
|
||||||
|
|
||||||
private static ClodBounds ComputeBounds(ref readonly ClodMesh mesh, UnsafeList<uint> indices, float error)
|
private static ClodBounds ComputeBounds(ref readonly ClodMesh mesh, UnsafeList<uint> indices, float error)
|
||||||
{
|
{
|
||||||
@@ -431,7 +432,7 @@ internal static unsafe partial class MeshProcessor
|
|||||||
public uint id;
|
public uint id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SimplifyFallback(ref UnsafeArray<uint> lod, ref readonly ClodMesh mesh, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint target_count, float* error, AllocationHandle allocationHandle)
|
private static void SimplifyFallback(ref UnsafeArray<uint> lod, ref readonly ClodMesh mesh, ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint target_count, float* error, AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
using var subset = new UnsafeArray<SloppyVertex>(indices.Count, allocationHandle);
|
using var subset = new UnsafeArray<SloppyVertex>(indices.Count, allocationHandle);
|
||||||
using var subset_locks = new UnsafeArray<byte>(indices.Count, allocationHandle);
|
using var subset_locks = new UnsafeArray<byte>(indices.Count, allocationHandle);
|
||||||
@@ -469,7 +470,7 @@ internal static unsafe partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
|
private static UnsafeArray<uint> Simplify(ref readonly ClodConfig config, ref readonly ClodMesh mesh,
|
||||||
ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<byte> locks, nuint targetCount, float* error,
|
ReadOnlyView<uint> indices, ReadOnlyView<byte> locks, nuint targetCount, float* error,
|
||||||
AllocationHandle allocationHandle)
|
AllocationHandle allocationHandle)
|
||||||
{
|
{
|
||||||
var lod = new UnsafeArray<uint>(indices.Count, allocationHandle);
|
var lod = new UnsafeArray<uint>(indices.Count, allocationHandle);
|
||||||
@@ -703,16 +704,31 @@ internal static unsafe partial class MeshProcessor
|
|||||||
public int materialIndex;
|
public int materialIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int MeshletOutputCallback(MeshletContext context, ClodGroup group, ReadOnlyUnsafeCollection<ClodCluster> clusters)
|
private static int MeshletOutputCallback(MeshletContext context, ClodGroup group, ReadOnlyView<ClodCluster> clusters)
|
||||||
{
|
{
|
||||||
var meshletData = context.data;
|
var meshletData = context.data;
|
||||||
var materialIndex = context.materialIndex;
|
var materialIndex = context.materialIndex;
|
||||||
|
|
||||||
// Ensure lists are initialized
|
// Ensure lists are initialized
|
||||||
if (!meshletData->groups.IsCreated) meshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.TLSF);
|
if (!meshletData->groups.IsCreated)
|
||||||
if (!meshletData->meshlets.IsCreated) meshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.TLSF);
|
{
|
||||||
if (!meshletData->meshletVertices.IsCreated) meshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.TLSF);
|
meshletData->groups = new UnsafeList<MeshletGroup>(16, AllocationHandle.TLSF);
|
||||||
if (!meshletData->meshletTriangles.IsCreated) meshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.TLSF);
|
}
|
||||||
|
|
||||||
|
if (!meshletData->meshlets.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->meshlets = new UnsafeList<Meshlet>(64, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meshletData->meshletVertices.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->meshletVertices = new UnsafeList<uint>(128, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!meshletData->meshletTriangles.IsCreated)
|
||||||
|
{
|
||||||
|
meshletData->meshletTriangles = new UnsafeList<uint>(128, AllocationHandle.TLSF);
|
||||||
|
}
|
||||||
|
|
||||||
var meshletGroup = new MeshletGroup
|
var meshletGroup = new MeshletGroup
|
||||||
{
|
{
|
||||||
@@ -770,15 +786,14 @@ internal static unsafe partial class MeshProcessor
|
|||||||
|
|
||||||
internal static partial class MeshProcessor
|
internal static partial class MeshProcessor
|
||||||
{
|
{
|
||||||
|
private class MeshletBuildJob
|
||||||
private struct MeshletBuildJob : IJob
|
|
||||||
{
|
{
|
||||||
public ClodConfig clodConfig;
|
public ClodConfig clodConfig;
|
||||||
public ClodMesh clodMesh;
|
public ClodMesh clodMesh;
|
||||||
|
|
||||||
public MeshletContext context;
|
public MeshletContext context;
|
||||||
|
|
||||||
public readonly void Execute(ref readonly JobExecutionContext ctx)
|
public void Execute()
|
||||||
{
|
{
|
||||||
Build(in clodConfig, in clodMesh, context, MeshletOutputCallback);
|
Build(in clodConfig, in clodMesh, context, MeshletOutputCallback);
|
||||||
}
|
}
|
||||||
@@ -789,9 +804,7 @@ internal static partial class MeshProcessor
|
|||||||
/// Each <see cref="MaterialPartInfo"/> describes a material partition's index range within the unified buffer.
|
/// Each <see cref="MaterialPartInfo"/> describes a material partition's index range within the unified buffer.
|
||||||
/// Meshlets are built per-part and tagged with the corresponding <c>localMaterialIndex</c>.
|
/// Meshlets are built per-part and tagged with the corresponding <c>localMaterialIndex</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<DisposablePtr<MeshletMeshData>> BuildMeshletsAsync(JobScheduler jobScheduler,
|
public static async Task<DisposablePtr<MeshletMeshData>> BuildMeshletsAsync(ReadOnlyView<Vertex> vertices, ReadOnlyView<uint> indices, ReadOnlyView<MaterialPartInfo> parts, CancellationToken token)
|
||||||
ReadOnlyUnsafeCollection<Vertex> vertices, ReadOnlyUnsafeCollection<uint> indices, ReadOnlyUnsafeCollection<MaterialPartInfo> parts,
|
|
||||||
CancellationToken token)
|
|
||||||
{
|
{
|
||||||
Logger.DebugAssert(vertices.Count > 0, "Mesh must have vertices to build meshlets.");
|
Logger.DebugAssert(vertices.Count > 0, "Mesh must have vertices to build meshlets.");
|
||||||
Logger.DebugAssert(indices.Count > 0, "Mesh must have indices to build meshlets.");
|
Logger.DebugAssert(indices.Count > 0, "Mesh must have indices to build meshlets.");
|
||||||
@@ -821,8 +834,6 @@ internal static partial class MeshProcessor
|
|||||||
simplifyFallbackSloppy = true,
|
simplifyFallbackSloppy = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
var jobs = new MeshletBuildJob[parts.Length];
|
|
||||||
|
|
||||||
IntPtr meshletData;
|
IntPtr meshletData;
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
@@ -836,6 +847,7 @@ internal static partial class MeshProcessor
|
|||||||
for (var i = 0; i < parts.Length; i++)
|
for (var i = 0; i < parts.Length; i++)
|
||||||
{
|
{
|
||||||
ref readonly var part = ref parts[i];
|
ref readonly var part = ref parts[i];
|
||||||
|
MeshletBuildJob job;
|
||||||
|
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
@@ -859,21 +871,15 @@ internal static partial class MeshProcessor
|
|||||||
materialIndex = part.materialIndex
|
materialIndex = part.materialIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
var job = new MeshletBuildJob
|
job = new MeshletBuildJob
|
||||||
{
|
{
|
||||||
clodConfig = config,
|
clodConfig = config,
|
||||||
clodMesh = clodMesh,
|
clodMesh = clodMesh,
|
||||||
context = context
|
context = context
|
||||||
};
|
};
|
||||||
|
|
||||||
jobs[i] = job;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var job in jobs)
|
await Task.Run(job.Execute, token);
|
||||||
{
|
|
||||||
var handle = jobScheduler.Schedule(in job);
|
|
||||||
await jobScheduler.WaitAsync(handle, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe
|
unsafe
|
||||||
@@ -956,8 +962,15 @@ internal static partial class MeshProcessor
|
|||||||
|
|
||||||
var extents = centroidMax - centroidMin;
|
var extents = centroidMax - centroidMin;
|
||||||
var splitAxis = 0;
|
var splitAxis = 0;
|
||||||
if (extents.y > extents.x && extents.y > extents.z) splitAxis = 1;
|
if (extents.y > extents.x && extents.y > extents.z)
|
||||||
if (extents.z > extents.x && extents.z > extents.y) splitAxis = 2;
|
{
|
||||||
|
splitAxis = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extents.z > extents.x && extents.z > extents.y)
|
||||||
|
{
|
||||||
|
splitAxis = 2;
|
||||||
|
}
|
||||||
|
|
||||||
var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f;
|
var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f;
|
||||||
|
|
||||||
@@ -1008,8 +1021,15 @@ internal static partial class MeshProcessor
|
|||||||
{
|
{
|
||||||
gathered.Clear();
|
gathered.Clear();
|
||||||
var node = binaryNodes[nodeIndex];
|
var node = binaryNodes[nodeIndex];
|
||||||
if (node.leftChild != -1) gathered.Add(node.leftChild);
|
if (node.leftChild != -1)
|
||||||
if (node.rightChild != -1) gathered.Add(node.rightChild);
|
{
|
||||||
|
gathered.Add(node.leftChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.rightChild != -1)
|
||||||
|
{
|
||||||
|
gathered.Add(node.rightChild);
|
||||||
|
}
|
||||||
|
|
||||||
while (gathered.Count < 4)
|
while (gathered.Count < 4)
|
||||||
{
|
{
|
||||||
@@ -1034,12 +1054,22 @@ internal static partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (largestInternalIndex == -1) break; // all gathered are leaves
|
if (largestInternalIndex == -1)
|
||||||
|
{
|
||||||
|
break; // all gathered are leaves
|
||||||
|
}
|
||||||
|
|
||||||
gathered.RemoveAt(listIndexToRemove);
|
gathered.RemoveAt(listIndexToRemove);
|
||||||
var largestNode = binaryNodes[largestInternalIndex];
|
var largestNode = binaryNodes[largestInternalIndex];
|
||||||
if (largestNode.leftChild != -1) gathered.Add(largestNode.leftChild);
|
if (largestNode.leftChild != -1)
|
||||||
if (largestNode.rightChild != -1) gathered.Add(largestNode.rightChild);
|
{
|
||||||
|
gathered.Add(largestNode.leftChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (largestNode.rightChild != -1)
|
||||||
|
{
|
||||||
|
gathered.Add(largestNode.rightChild);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,20 +1167,19 @@ internal static partial class MeshProcessor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private unsafe struct BuildClusterLodHierarchyJob : IJob
|
private unsafe class BuildClusterLodHierarchyJob
|
||||||
{
|
{
|
||||||
public MeshletMeshData* meshletData;
|
public MeshletMeshData* meshletData;
|
||||||
|
|
||||||
public readonly void Execute(ref readonly JobExecutionContext ctx)
|
public void Execute()
|
||||||
{
|
{
|
||||||
using var scope = AllocationManager.CreateStackScope();
|
using var meshletIndices = new UnsafeArray<int>(meshletData->meshletCount, AllocationHandle.TLSF);
|
||||||
using var meshletIndices = new UnsafeArray<int>(meshletData->meshletCount, scope.AllocationHandle);
|
|
||||||
for (var i = 0; i < meshletData->meshletCount; i++)
|
for (var i = 0; i < meshletData->meshletCount; i++)
|
||||||
{
|
{
|
||||||
meshletIndices[i] = i;
|
meshletIndices[i] = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
var binaryNodes = new UnsafeList<TempBinaryNode>(meshletData->meshletCount * 2, scope.AllocationHandle);
|
var binaryNodes = new UnsafeList<TempBinaryNode>(meshletData->meshletCount * 2, AllocationHandle.TLSF);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1201,14 +1230,13 @@ internal static partial class MeshProcessor
|
|||||||
/// Builds a cluster LOD hierarchy from the input meshlet data.
|
/// Builds a cluster LOD hierarchy from the input meshlet data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="meshletData">The meshlet data.</param>
|
/// <param name="meshletData">The meshlet data.</param>
|
||||||
public static async Task BuildClusterLodHierarchyAsync(JobScheduler jobScheduler, SharedPtr<MeshletMeshData> meshletData, CancellationToken token)
|
public static Task BuildClusterLodHierarchyAsync(SharedPtr<MeshletMeshData> meshletData, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (meshletData.GetRef().meshletCount == 0)
|
if (meshletData.GetRef().meshletCount == 0)
|
||||||
{
|
{
|
||||||
return;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
JobHandle handle;
|
|
||||||
unsafe
|
unsafe
|
||||||
{
|
{
|
||||||
var job = new BuildClusterLodHierarchyJob
|
var job = new BuildClusterLodHierarchyJob
|
||||||
@@ -1216,9 +1244,7 @@ internal static partial class MeshProcessor
|
|||||||
meshletData = meshletData.Get()
|
meshletData = meshletData.Get()
|
||||||
};
|
};
|
||||||
|
|
||||||
handle = jobScheduler.Schedule(in job);
|
return Task.Run(job.Execute, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
await jobScheduler.WaitAsync(handle, token);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Core.Utilities;
|
using Ghost.Core.Utilities;
|
||||||
using Ghost.Editor.Core.Services;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using Ghost.Graphics.Core;
|
using Ghost.Graphics.Core;
|
||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Misaki.HighPerformance.Jobs;
|
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
using Misaki.HighPerformance.LowLevel.Collections;
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
using Misaki.HighPerformance.Mathematics;
|
using Misaki.HighPerformance.Mathematics;
|
||||||
@@ -14,7 +13,6 @@ using System.Runtime.CompilerServices;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using TerraFX.Interop.Mimalloc;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
@@ -71,54 +69,25 @@ public sealed class ModelManifestMetadata
|
|||||||
|
|
||||||
internal sealed class ImportedModelAsset : IAsset
|
internal sealed class ImportedModelAsset : IAsset
|
||||||
{
|
{
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(MeshAsset).GUID;
|
|
||||||
|
|
||||||
public IAssetSettings? Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ModelManifest Manifest
|
public ModelManifest Manifest
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
|
public ImportedModelAsset(Guid id, IAssetSettings? settings, ModelManifest manifest)
|
||||||
|
: base(id, typeof(ModelAsset).GUID, settings)
|
||||||
{
|
{
|
||||||
ID = id;
|
|
||||||
Settings = settings;
|
|
||||||
Manifest = manifest;
|
Manifest = manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Guid(GUID)]
|
[Guid(GUID)]
|
||||||
public abstract class MeshAsset : IAsset
|
public abstract class ModelAsset : IAsset
|
||||||
{
|
{
|
||||||
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
|
public const string GUID = "B99CA68E-EE7A-4822-BF1C-AA0A5120C36A";
|
||||||
|
|
||||||
private MeshNode _root;
|
private MeshNode _root;
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(MeshAsset).GUID;
|
|
||||||
|
|
||||||
public MeshNode Root
|
public MeshNode Root
|
||||||
{
|
{
|
||||||
get => _root;
|
get => _root;
|
||||||
@@ -129,19 +98,20 @@ public abstract class MeshAsset : IAsset
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal MeshAsset(MeshNode root, Guid id, MeshAssetSettings settings)
|
internal ModelAsset(MeshNode root, Guid id, ModelAssetSettings settings)
|
||||||
|
: base(id, typeof(ModelAsset).GUID, settings)
|
||||||
{
|
{
|
||||||
_root = root;
|
_root = root;
|
||||||
|
|
||||||
ID = id;
|
|
||||||
Settings = settings;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
{
|
{
|
||||||
_root?.Dispose();
|
_root?.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum CoordinateAxis
|
public enum CoordinateAxis
|
||||||
{
|
{
|
||||||
@@ -160,7 +130,7 @@ public enum VertexDataSource
|
|||||||
ComputedIfMissing
|
ComputedIfMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MeshAssetSettings : IAssetSettings
|
public class ModelAssetSettings : IAssetSettings
|
||||||
{
|
{
|
||||||
public VertexDataSource NormalDataSource
|
public VertexDataSource NormalDataSource
|
||||||
{
|
{
|
||||||
@@ -173,7 +143,7 @@ public class MeshAssetSettings : IAssetSettings
|
|||||||
} = VertexDataSource.ComputedIfMissing;
|
} = VertexDataSource.ComputedIfMissing;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class ObjAssetSettings : MeshAssetSettings
|
internal class ObjAssetSettings : ModelAssetSettings
|
||||||
{
|
{
|
||||||
public CoordinateAxis ObjectUpAxis
|
public CoordinateAxis ObjectUpAxis
|
||||||
{
|
{
|
||||||
@@ -196,12 +166,12 @@ internal class ObjAssetSettings : MeshAssetSettings
|
|||||||
} = 1.0f;
|
} = 1.0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class FbxAssetSettings : MeshAssetSettings
|
internal class FbxAssetSettings : ModelAssetSettings
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
[CustomAssetHandler(AssetTypeId = MeshAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
|
[CustomAssetHandler(AssetTypeId = ModelAsset.GUID, RuntimeAssetType = AssetType.Mesh, Extensions = new[] { ".fbx", ".obj" })]
|
||||||
internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
internal class ModelAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
|
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions
|
||||||
{
|
{
|
||||||
@@ -209,8 +179,6 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly JobScheduler _jobScheduler = EditorApplication.GetService<EngineCore>().JobScheduler;
|
|
||||||
|
|
||||||
public IAssetSettings? CreateDefaultSettings(string ext)
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
{
|
{
|
||||||
if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(ext, ".obj", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -264,10 +232,12 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|||||||
var meshSettings = ResolveSettings(sourcePath, settings);
|
var meshSettings = ResolveSettings(sourcePath, settings);
|
||||||
|
|
||||||
using var root = new MeshNode();
|
using var root = new MeshNode();
|
||||||
|
var result = await MeshProcessor.ParseMeshAsync(root, sourcePath, AllocationHandle.TLSF, meshSettings, token).ConfigureAwait(false);
|
||||||
|
|
||||||
var parseJob = new MeshParsingJob(root, sourcePath, AllocationHandle.Persistent, meshSettings);
|
if (result.IsFailure)
|
||||||
var handle = _jobScheduler.Schedule(in parseJob);
|
{
|
||||||
await _jobScheduler.WaitAsync(handle, token);
|
return Result.Failure(result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
var manifest = new ModelManifest
|
var manifest = new ModelManifest
|
||||||
{
|
{
|
||||||
@@ -295,9 +265,9 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|||||||
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
|
return ValueTask.FromResult(Result.Failure("Packing model assets is not supported yet."));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MeshAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
|
private static ModelAssetSettings ResolveSettings(string sourcePath, IAssetSettings? settings)
|
||||||
{
|
{
|
||||||
if (settings is MeshAssetSettings meshSettings)
|
if (settings is ModelAssetSettings meshSettings)
|
||||||
{
|
{
|
||||||
return meshSettings;
|
return meshSettings;
|
||||||
}
|
}
|
||||||
@@ -354,7 +324,7 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|||||||
node.Name,
|
node.Name,
|
||||||
stablePath,
|
stablePath,
|
||||||
$"{sourcePath}#Mesh/{stablePath}",
|
$"{sourcePath}#Mesh/{stablePath}",
|
||||||
typeof(MeshAsset).GUID));
|
typeof(ModelAsset).GUID));
|
||||||
}
|
}
|
||||||
else if (node is LightMeshNode)
|
else if (node is LightMeshNode)
|
||||||
{
|
{
|
||||||
@@ -376,24 +346,24 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|||||||
|
|
||||||
private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token)
|
private async ValueTask<(int materialSlotCount, int lodLevelCount)> WriteMeshContentAsync(string targetPath, GeometryMeshNode geometry, CancellationToken token)
|
||||||
{
|
{
|
||||||
using var meshletData = await MeshProcessor.BuildMeshletsAsync(_jobScheduler, geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
|
using var meshletData = await MeshProcessor.BuildMeshletsAsync(geometry.Vertices, geometry.Indices, geometry.MaterialParts, token).ConfigureAwait(false);
|
||||||
await MeshProcessor.BuildClusterLodHierarchyAsync(_jobScheduler, meshletData.Share(), token).ConfigureAwait(false);
|
await MeshProcessor.BuildClusterLodHierarchyAsync(meshletData.Share(), token).ConfigureAwait(false);
|
||||||
|
|
||||||
var bounds = ComputeBounds(geometry.Vertices);
|
var bounds = ComputeBounds(geometry.Vertices);
|
||||||
var header = new MeshContentHeader
|
var header = new MeshContentHeader
|
||||||
{
|
{
|
||||||
magic = MeshContentHeader.MAGIC,
|
magic = MeshContentHeader.MAGIC,
|
||||||
version = MeshContentHeader.VERSION,
|
version = MeshContentHeader.VERSION,
|
||||||
vertexCount = (uint)geometry.Vertices.Count,
|
vertexCount = geometry.Vertices.Count,
|
||||||
indexCount = (uint)geometry.Indices.Count,
|
indexCount = geometry.Indices.Count,
|
||||||
materialPartCount = (uint)geometry.MaterialParts.Length,
|
materialPartCount = geometry.MaterialParts.Length,
|
||||||
meshletCount = (uint)meshletData.GetRef().meshlets.Count,
|
meshletCount = meshletData.GetRef().meshlets.Count,
|
||||||
meshletGroupCount = (uint)meshletData.GetRef().groups.Count,
|
meshletGroupCount = meshletData.GetRef().groups.Count,
|
||||||
meshletHierarchyNodeCount = (uint)meshletData.GetRef().hierarchyNodes.Count,
|
meshletHierarchyNodeCount = meshletData.GetRef().hierarchyNodes.Count,
|
||||||
meshletVertexCount = (uint)meshletData.GetRef().meshletVertices.Count,
|
meshletVertexCount = meshletData.GetRef().meshletVertices.Count,
|
||||||
meshletTriangleCount = (uint)meshletData.GetRef().meshletTriangles.Count,
|
meshletTriangleCount = meshletData.GetRef().meshletTriangles.Count,
|
||||||
materialSlotCount = (uint)meshletData.GetRef().materialSlotCount,
|
materialSlotCount = meshletData.GetRef().materialSlotCount,
|
||||||
lodLevelCount = (uint)meshletData.GetRef().lodLevelCount,
|
lodLevelCount = meshletData.GetRef().lodLevelCount,
|
||||||
boundsMin = bounds.Min,
|
boundsMin = bounds.Min,
|
||||||
boundsMax = bounds.Max,
|
boundsMax = bounds.Max,
|
||||||
};
|
};
|
||||||
@@ -401,28 +371,28 @@ internal class MeshAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
|||||||
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
stream.Write(header);
|
stream.Write(header);
|
||||||
|
|
||||||
header.vertexOffset = (ulong)stream.Position;
|
header.vertexOffset = stream.Position;
|
||||||
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
|
await stream.WriteAsync<Vertex, UnsafeList<Vertex>>(geometry.Vertices, token);
|
||||||
|
|
||||||
header.indexOffset = (ulong)stream.Position;
|
header.indexOffset = stream.Position;
|
||||||
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
|
await stream.WriteAsync<uint, UnsafeList<uint>>(geometry.Indices, token);
|
||||||
|
|
||||||
header.materialPartOffset = (ulong)stream.Position;
|
header.materialPartOffset = stream.Position;
|
||||||
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
|
WriteMaterialParts(stream, geometry.MaterialParts.AsSpan());
|
||||||
|
|
||||||
header.meshletOffset = (ulong)stream.Position;
|
header.meshletOffset = stream.Position;
|
||||||
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
|
await stream.WriteAsync<Meshlet, UnsafeList<Meshlet>>(meshletData.GetRef().meshlets, token);
|
||||||
|
|
||||||
header.meshletGroupOffset = (ulong)stream.Position;
|
header.meshletGroupOffset = stream.Position;
|
||||||
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
|
await stream.WriteAsync<MeshletGroup, UnsafeList<MeshletGroup>>(meshletData.GetRef().groups, token);
|
||||||
|
|
||||||
header.meshletHierarchyNodeOffset = (ulong)stream.Position;
|
header.meshletHierarchyNodeOffset = stream.Position;
|
||||||
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
|
await stream.WriteAsync<MeshletHierarchyNode, UnsafeList<MeshletHierarchyNode>>(meshletData.GetRef().hierarchyNodes, token);
|
||||||
|
|
||||||
header.meshletVertexOffset = (ulong)stream.Position;
|
header.meshletVertexOffset = stream.Position;
|
||||||
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
|
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletVertices, token);
|
||||||
|
|
||||||
header.meshletTriangleOffset = (ulong)stream.Position;
|
header.meshletTriangleOffset = stream.Position;
|
||||||
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token);
|
await stream.WriteAsync<uint, UnsafeList<uint>>(meshletData.GetRef().meshletTriangles, token);
|
||||||
|
|
||||||
stream.Position = 0;
|
stream.Position = 0;
|
||||||
35
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
35
src/Editor/Ghost.Editor.Core/Assets/SceneAsset.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[Guid(GUID)]
|
||||||
|
public sealed class SceneAsset : IAsset
|
||||||
|
{
|
||||||
|
public const string GUID = "1B5E3F2A-8D91-4C67-BE32-A0F9C6D4E781";
|
||||||
|
|
||||||
|
public ushort RuntimeSceneID
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SceneName
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int EntityCount
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneAsset(Guid id, IAssetSettings? settings)
|
||||||
|
: base(id, typeof(SceneAsset).GUID, settings)
|
||||||
|
{
|
||||||
|
SceneName = string.Empty;
|
||||||
|
EntityCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SceneAssetSettings : IAssetSettings
|
||||||
|
{
|
||||||
|
}
|
||||||
161
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
161
src/Editor/Ghost.Editor.Core/Assets/SceneAssetHandler.cs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Assets;
|
||||||
|
|
||||||
|
[CustomAssetHandler(AssetTypeId = SceneAsset.GUID, RuntimeAssetType = AssetType.Scene, Extensions = new[] { ".gscene" })]
|
||||||
|
internal class SceneAssetHandler : IImportableAssetHandler, IPackableAssetHandler
|
||||||
|
{
|
||||||
|
[AssetOpenHandler(".gscene")]
|
||||||
|
private static async Task<Result> OpenAsync(string path)
|
||||||
|
{
|
||||||
|
// Actually double clicking the asset in content browser will just open it.
|
||||||
|
// We probably shouldn't do the actual loading in OpenAsync, but let's keep it simple for now.
|
||||||
|
// OpenAsync usually returns immediately if there's no UI, or we should use AssetRegistry.LoadAssetAsync
|
||||||
|
var assetRegistry = EditorApplication.GetService<IAssetRegistry>();
|
||||||
|
var id = Guid.NewGuid(); // Wait, how do we know the ID?
|
||||||
|
// AssetMeta handles this. This method is just a quick hack for double clicking.
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(path);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to load scene.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
service.LoadSceneIntoEditorWorld(data, SceneLoadingType.Single, null);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAssetSettings? CreateDefaultSettings(string ext)
|
||||||
|
{
|
||||||
|
return new SceneAssetSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<IAsset>> LoadAssetAsync(string assetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
|
||||||
|
var asset = new SceneAsset(id, settings)
|
||||||
|
{
|
||||||
|
SceneName = Path.GetFileNameWithoutExtension(assetPath),
|
||||||
|
EntityCount = data?.Entities?.Count ?? 0,
|
||||||
|
RuntimeSceneID = Ghost.Engine.Core.Scene.INVALID_ID // Default
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data != null)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<IAsset>();
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
service.LoadSceneIntoEditorWorld(data, SceneLoadingType.Single, (scene) =>
|
||||||
|
{
|
||||||
|
asset.RuntimeSceneID = scene.ID;
|
||||||
|
EditorApplication.GetService<IEditorWorldService>().RegisterSceneAsset(scene.ID, asset);
|
||||||
|
tcs.TrySetResult(asset);
|
||||||
|
});
|
||||||
|
return Result.Success(await tcs.Task);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Success<IAsset>(asset);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> SaveAssetAsync(string targetPath, IAsset asset, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (asset is not SceneAsset sceneAsset)
|
||||||
|
{
|
||||||
|
return Result.Failure("Asset type is not SceneAsset");
|
||||||
|
}
|
||||||
|
|
||||||
|
var worldService = EditorApplication.GetService<IEditorWorldService>();
|
||||||
|
var tcs = new TaskCompletionSource<byte[]>();
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scene = Ghost.Engine.Core.Scene.FromID(sceneAsset.RuntimeSceneID);
|
||||||
|
var service = EditorApplication.GetService<SceneSerializationService>();
|
||||||
|
var bytes = service.SerializeSceneToMemory(scene);
|
||||||
|
tcs.TrySetResult(bytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.TrySetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = await tcs.Task;
|
||||||
|
await File.WriteAllBytesAsync(targetPath, bytes, token);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to save scene: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result<ImportedSubAsset[]>> ImportAsync(string sourcePath, string targetPath, Guid id, IAssetSettings? settings, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(sourcePath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Source scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(sourcePath, token);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
SceneSerializationService.SerializeToBinary(data, stream);
|
||||||
|
|
||||||
|
return Result.Success(Array.Empty<ImportedSubAsset>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to import scene asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Result> PackAsync(string assetPath, MemoryStream targetStream, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(assetPath))
|
||||||
|
{
|
||||||
|
return Result.Failure("Scene file does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await SceneSerializationService.DeserializeSceneFileAsync(assetPath, token);
|
||||||
|
if (data == null)
|
||||||
|
{
|
||||||
|
return Result.Failure("Failed to deserialize scene file.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneSerializationService.SerializeToBinary(data, targetStream);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Failed to pack scene asset: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Core.Graphics;
|
using Ghost.Core.Graphics;
|
||||||
using Ghost.DSL.ShaderCompiler;
|
using Ghost.DSL.ShaderCompiler;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Assets;
|
namespace Ghost.Editor.Core.Assets;
|
||||||
@@ -11,32 +11,16 @@ public sealed partial class GraphicsShaderAsset : IAsset
|
|||||||
{
|
{
|
||||||
public const string GUID = "7BD4591C-B017-4814-AA0B-3F30EB3E727E";
|
public const string GUID = "7BD4591C-B017-4814-AA0B-3F30EB3E727E";
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings? Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(GraphicsShaderAsset).GUID;
|
|
||||||
|
|
||||||
public GraphicsShaderDescriptor Descriptor
|
public GraphicsShaderDescriptor Descriptor
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal GraphicsShaderAsset(GraphicsShaderDescriptor descriptor, Guid id)
|
internal GraphicsShaderAsset(GraphicsShaderDescriptor descriptor, Guid id)
|
||||||
|
: base(id, typeof(GraphicsShaderAsset).GUID, null)
|
||||||
{
|
{
|
||||||
ID = id;
|
|
||||||
Descriptor = descriptor;
|
Descriptor = descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Guid(GUID)]
|
[Guid(GUID)]
|
||||||
@@ -44,32 +28,16 @@ public sealed partial class ComputeShaderAsset : IAsset
|
|||||||
{
|
{
|
||||||
public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A";
|
public const string GUID = "EA881979-CD8D-4088-B568-D42645F18C2A";
|
||||||
|
|
||||||
public Guid ID
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAssetSettings? Settings
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Guid TypeID => typeof(ComputeShaderAsset).GUID;
|
|
||||||
|
|
||||||
public ComputeShaderDescriptor Descriptor
|
public ComputeShaderDescriptor Descriptor
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
|
internal ComputeShaderAsset(ComputeShaderDescriptor descriptor, Guid id)
|
||||||
|
: base(id, typeof(ComputeShaderAsset).GUID, null)
|
||||||
{
|
{
|
||||||
ID = id;
|
|
||||||
Descriptor = descriptor;
|
Descriptor = descriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shader does not handle import/export via asset registry, it will handled by the hot reload system.
|
// Shader does not handle import/export via asset registry, it will handled by the hot reload system.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using Ghost.Graphics.RHI;
|
using Ghost.Graphics.RHI;
|
||||||
using Ghost.StbI;
|
using Ghost.StbI;
|
||||||
using Misaki.HighPerformance.LowLevel;
|
using Misaki.HighPerformance.LowLevel;
|
||||||
@@ -54,11 +54,6 @@ public unsafe class TextureAsset : IAsset
|
|||||||
{
|
{
|
||||||
public const string GUID = "27965FFF-860C-40EF-9123-1874D7DE9CDC";
|
public const string GUID = "27965FFF-860C-40EF-9123-1874D7DE9CDC";
|
||||||
|
|
||||||
private static readonly Guid s_typeID = Guid.Parse(GUID);
|
|
||||||
|
|
||||||
private readonly Guid _id;
|
|
||||||
private readonly IAssetSettings _settings;
|
|
||||||
|
|
||||||
private readonly IntPtr _textureData;
|
private readonly IntPtr _textureData;
|
||||||
private readonly uint _width;
|
private readonly uint _width;
|
||||||
private readonly uint _height;
|
private readonly uint _height;
|
||||||
@@ -66,10 +61,6 @@ public unsafe class TextureAsset : IAsset
|
|||||||
private readonly uint _colorComponents;
|
private readonly uint _colorComponents;
|
||||||
private readonly uint _dimension;
|
private readonly uint _dimension;
|
||||||
|
|
||||||
public Guid ID => _id;
|
|
||||||
public Guid TypeID => typeof(TextureAsset).GUID;
|
|
||||||
public IAssetSettings Settings => _settings;
|
|
||||||
|
|
||||||
public IntPtr TextureData => _textureData;
|
public IntPtr TextureData => _textureData;
|
||||||
public uint Width => _width;
|
public uint Width => _width;
|
||||||
public uint Height => _height;
|
public uint Height => _height;
|
||||||
@@ -78,10 +69,8 @@ public unsafe class TextureAsset : IAsset
|
|||||||
public uint ColorComponents => _colorComponents;
|
public uint ColorComponents => _colorComponents;
|
||||||
|
|
||||||
internal TextureAsset([OwnershipTransfer] IntPtr data, TextureContentHeader header, Guid id, IAssetSettings settings)
|
internal TextureAsset([OwnershipTransfer] IntPtr data, TextureContentHeader header, Guid id, IAssetSettings settings)
|
||||||
|
: base(id, typeof(TextureAsset).GUID, settings)
|
||||||
{
|
{
|
||||||
_id = id;
|
|
||||||
_settings = settings;
|
|
||||||
|
|
||||||
_textureData = data;
|
_textureData = data;
|
||||||
_width = header.width;
|
_width = header.width;
|
||||||
_height = header.height;
|
_height = header.height;
|
||||||
@@ -90,15 +79,9 @@ public unsafe class TextureAsset : IAsset
|
|||||||
_colorComponents = header.colorComponents;
|
_colorComponents = header.colorComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
~TextureAsset()
|
protected override void Dispose(bool disposing)
|
||||||
{
|
|
||||||
Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
{
|
||||||
StbIApi.ImageFree((void*)_textureData);
|
StbIApi.ImageFree((void*)_textureData);
|
||||||
GC.SuppressFinalize(this);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Windows.System;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core;
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -5,35 +7,6 @@ namespace Ghost.Editor.Core;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class DiscoverableAttributeBase : Attribute;
|
public abstract class DiscoverableAttributeBase : Attribute;
|
||||||
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Method)]
|
|
||||||
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
|
||||||
public string[] Extensions
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AssetOpenHandlerAttribute(params string[] extensions)
|
|
||||||
{
|
|
||||||
Extensions = extensions.Select(e => e.StartsWith('.') ? e.ToLowerInvariant() : '.' + e.ToLowerInvariant()).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
|
|
||||||
internal class AssetImporterAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
|
||||||
public string[] SupportedExtensions
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AssetImporterAttribute(params string[] supportedExtensions)
|
|
||||||
{
|
|
||||||
SupportedExtensions = supportedExtensions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class)]
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
public class CustomEditorAttribute : DiscoverableAttributeBase
|
public class CustomEditorAttribute : DiscoverableAttributeBase
|
||||||
{
|
{
|
||||||
@@ -48,29 +21,16 @@ public class CustomEditorAttribute : DiscoverableAttributeBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = false)]
|
public class AssetOpenHandlerAttribute : DiscoverableAttributeBase
|
||||||
public class EditorInjectionAttribute : DiscoverableAttributeBase
|
|
||||||
{
|
{
|
||||||
public enum ServiceLifetime
|
internal string[] Extensions
|
||||||
{
|
|
||||||
Singleton,
|
|
||||||
Transient,
|
|
||||||
}
|
|
||||||
|
|
||||||
public ServiceLifetime Lifetime
|
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Type ImplementationType
|
public AssetOpenHandlerAttribute(params string[] extensions)
|
||||||
{
|
{
|
||||||
get;
|
Extensions = extensions;
|
||||||
}
|
|
||||||
|
|
||||||
public EditorInjectionAttribute(ServiceLifetime lifetime, Type implementationType)
|
|
||||||
{
|
|
||||||
Lifetime = lifetime;
|
|
||||||
ImplementationType = implementationType;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +52,35 @@ public sealed class ContextMenuItemAttribute : DiscoverableAttributeBase
|
|||||||
get;
|
get;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ContextMenuItemAttribute(string tag, string name, int group = 0)
|
public int Priority
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContextMenuItemAttribute(string tag, string name, int group = 0, int priority = 0)
|
||||||
{
|
{
|
||||||
Tag = tag;
|
Tag = tag;
|
||||||
Name = name;
|
Name = name;
|
||||||
Group = group;
|
Group = group;
|
||||||
|
Priority = priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ShortcutAttribute : DiscoverableAttributeBase
|
||||||
|
{
|
||||||
|
public VirtualKey Key
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VirtualKeyModifiers Modifiers
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShortcutAttribute(VirtualKey key, VirtualKeyModifiers modifiers = VirtualKeyModifiers.None)
|
||||||
|
{
|
||||||
|
Key = key;
|
||||||
|
Modifiers = modifiers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Services;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Engine.AssetLoader;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Contracts;
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
@@ -59,4 +58,7 @@ public interface IAssetRegistry : IDisposable
|
|||||||
ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default);
|
ValueTask<Result> SaveAssetIfDirtyAsync(IAsset asset, CancellationToken token = default);
|
||||||
ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default);
|
ValueTask<Result> SaveAssetIfDirtyAsync(Guid id, CancellationToken token = default);
|
||||||
ValueTask<Result[]> SaveDirtyAssetsAsync();
|
ValueTask<Result[]> SaveDirtyAssetsAsync();
|
||||||
|
|
||||||
|
Task<Result> OpenAssetAsync(Guid id);
|
||||||
|
Task<Result> OpenAssetAsync(string assetPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
public interface IDirtyTrackerService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the specified object as dirty.
|
||||||
|
/// </summary>
|
||||||
|
void MarkDirty(GhostObject obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if the specified object is dirty compared to its clean state.
|
||||||
|
/// </summary>
|
||||||
|
bool IsDirty(GhostObject obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the specified object as clean (e.g., after a successful save).
|
||||||
|
/// </summary>
|
||||||
|
void MarkClean(GhostObject obj);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a list of all currently dirty objects.
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<GhostObject> GetDirtyObjects();
|
||||||
|
}
|
||||||
@@ -9,5 +9,8 @@ public interface IInspectable
|
|||||||
|
|
||||||
UIElement? CreateHeader();
|
UIElement? CreateHeader();
|
||||||
|
|
||||||
UIElement? CreateInspector();
|
IInspectorModel CreateInspectorModel();
|
||||||
|
|
||||||
|
// void OnSelected();
|
||||||
|
// void OnDeselected();
|
||||||
}
|
}
|
||||||
26
src/Editor/Ghost.Editor.Core/Contracts/IInspectorModel.cs
Normal file
26
src/Editor/Ghost.Editor.Core/Contracts/IInspectorModel.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an active model for an object being inspected.
|
||||||
|
/// Responsible for generating its own UI.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInspectorModel : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generate the UI element that represents the body of the inspector for this model.
|
||||||
|
/// </summary>
|
||||||
|
UIElement BuildUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An inspector model that requires continuous synchronization (e.g. per-frame updates).
|
||||||
|
/// </summary>
|
||||||
|
public interface ISyncableInspectorModel : IInspectorModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Called per-frame to sync data (e.g. from ECS to UI and back).
|
||||||
|
/// </summary>
|
||||||
|
void Sync();
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ public enum ShaderStage
|
|||||||
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
|
Library // For ray tracing shaders or work graph shaders that don't fit into the traditional shader stages
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IShaderCompiler : IDisposable
|
internal interface IShaderCompiler : IDisposable
|
||||||
{
|
{
|
||||||
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
Result<UnsafeArray<byte>> Compile(ref readonly ShaderCompilationConfig config, AllocationHandle handle);
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,9 @@ public sealed partial class Float3Field : ValueControl<float3>
|
|||||||
_yComponent = GetTemplateChild("YComponent") as NumberBox;
|
_yComponent = GetTemplateChild("YComponent") as NumberBox;
|
||||||
_zComponent = GetTemplateChild("ZComponent") as NumberBox;
|
_zComponent = GetTemplateChild("ZComponent") as NumberBox;
|
||||||
|
|
||||||
|
SuppressChangedEvent = true;
|
||||||
SyncFromValue();
|
SyncFromValue();
|
||||||
|
SuppressChangedEvent = false;
|
||||||
|
|
||||||
_xComponent?.ValueChanged += OnComponentChanged;
|
_xComponent?.ValueChanged += OnComponentChanged;
|
||||||
_yComponent?.ValueChanged += OnComponentChanged;
|
_yComponent?.ValueChanged += OnComponentChanged;
|
||||||
@@ -44,11 +46,9 @@ public sealed partial class Float3Field : ValueControl<float3>
|
|||||||
|
|
||||||
private void SyncFromValue()
|
private void SyncFromValue()
|
||||||
{
|
{
|
||||||
SuppressChangedEvent = true;
|
|
||||||
_xComponent?.Value = Value.x;
|
_xComponent?.Value = Value.x;
|
||||||
_yComponent?.Value = Value.y;
|
_yComponent?.Value = Value.y;
|
||||||
_zComponent?.Value = Value.z;
|
_zComponent?.Value = Value.z;
|
||||||
SuppressChangedEvent = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnComponentChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
|
private void OnComponentChanged(NumberBox sender, NumberBoxValueChangedEventArgs args)
|
||||||
@@ -63,7 +63,6 @@ public sealed partial class Float3Field : ValueControl<float3>
|
|||||||
(float)(_yComponent?.Value ?? 0),
|
(float)(_yComponent?.Value ?? 0),
|
||||||
(float)(_zComponent?.Value ?? 0));
|
(float)(_zComponent?.Value ?? 0));
|
||||||
|
|
||||||
RiseChangedEvent(Value, newValue);
|
|
||||||
Value = newValue;
|
Value = newValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public sealed partial class PropertyField : ContentControl
|
public sealed partial class PropertyField : ContentControl
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<Type, DependencyProperty> _valueProperties = new()
|
private static readonly Dictionary<Type, DependencyProperty> s_valueProperties = new()
|
||||||
{
|
{
|
||||||
{ typeof(TextBox), TextBox.TextProperty },
|
{ typeof(TextBox), TextBox.TextProperty },
|
||||||
{ typeof(NumberBox), NumberBox.ValueProperty },
|
{ typeof(NumberBox), NumberBox.ValueProperty },
|
||||||
@@ -39,6 +39,18 @@ public sealed partial class PropertyField : ContentControl
|
|||||||
typeof(PropertyField),
|
typeof(PropertyField),
|
||||||
new PropertyMetadata(default(string)));
|
new PropertyMetadata(default(string)));
|
||||||
|
|
||||||
|
public bool IsEditable
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsEditableProperty);
|
||||||
|
set => SetValue(IsEditableProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsEditableProperty = DependencyProperty.Register(
|
||||||
|
nameof(IsEditable),
|
||||||
|
typeof(bool),
|
||||||
|
typeof(PropertyField),
|
||||||
|
new PropertyMetadata(true));
|
||||||
|
|
||||||
public PropertyField()
|
public PropertyField()
|
||||||
{
|
{
|
||||||
DefaultStyleKey = typeof(PropertyField);
|
DefaultStyleKey = typeof(PropertyField);
|
||||||
@@ -48,7 +60,7 @@ public sealed partial class PropertyField : ContentControl
|
|||||||
{
|
{
|
||||||
while (fieldType != null)
|
while (fieldType != null)
|
||||||
{
|
{
|
||||||
if (_valueProperties.TryGetValue(fieldType, out var dp))
|
if (s_valueProperties.TryGetValue(fieldType, out var dp))
|
||||||
{
|
{
|
||||||
return dp;
|
return dp;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,24 +8,22 @@
|
|||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="local:PropertyField">
|
<ControlTemplate TargetType="local:PropertyField">
|
||||||
<Grid Height="32" Margin="2,4">
|
<StackPanel Margin="2,4" Spacing="4">
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="125" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="0,0,0,4"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Style="{StaticResource BodyTextBlockStyle}"
|
Style="{StaticResource BodyTextBlockStyle}"
|
||||||
Text="{TemplateBinding Label}"
|
Text="{TemplateBinding Label}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<ContentPresenter
|
<ContentControl
|
||||||
Grid.Column="1"
|
Margin="2,0,0,0"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
Content="{TemplateBinding Content}"
|
Content="{TemplateBinding Content}"
|
||||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
</Grid>
|
IsEnabled="{TemplateBinding IsEditable}" />
|
||||||
|
</StackPanel>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ namespace Ghost.Editor.Core.Controls;
|
|||||||
|
|
||||||
public partial class ControlsDictionary : ResourceDictionary
|
public partial class ControlsDictionary : ResourceDictionary
|
||||||
{
|
{
|
||||||
private const string _DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
private const string DICTIONARY_PATH = "ms-appx:///Ghost.Editor.Core/Controls/ControlsDictionary.xaml";
|
||||||
|
|
||||||
public ControlsDictionary()
|
public ControlsDictionary()
|
||||||
{
|
{
|
||||||
Source = new Uri(_DICTIONARY_PATH, UriKind.Absolute);
|
Source = new Uri(DICTIONARY_PATH, UriKind.Absolute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,5 @@
|
|||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/PropertyField.xaml" />
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml" />
|
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/BasicInput/Float3Field.xaml" />
|
||||||
|
|
||||||
<ResourceDictionary Source="ms-appx:///Ghost.Editor.Core/Controls/Internal/ComponentView.xaml" />
|
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
using Ghost.Editor.Core.Inspector;
|
|
||||||
using Ghost.Editor.Core.Resources;
|
|
||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using Ghost.Entities;
|
|
||||||
using Microsoft.UI.Xaml;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls;
|
|
||||||
|
|
||||||
internal sealed unsafe partial class ComponentView : Control
|
|
||||||
{
|
|
||||||
private delegate void EditorUpdate();
|
|
||||||
|
|
||||||
private StackPanel? _contentContainer;
|
|
||||||
|
|
||||||
private readonly World? _world;
|
|
||||||
private readonly Entity _entity = Entity.Invalid;
|
|
||||||
private readonly Type? _componentType;
|
|
||||||
private readonly ComponentInfo _componentInfo;
|
|
||||||
|
|
||||||
private object? _managedInstance;
|
|
||||||
private void* _pComponentData;
|
|
||||||
|
|
||||||
private ComponentEditor? _customEditor;
|
|
||||||
private PropertyField[]? _propertyFields;
|
|
||||||
private EditorUpdate? _editorUpdate;
|
|
||||||
|
|
||||||
public string HeaderText
|
|
||||||
{
|
|
||||||
get => (string)GetValue(HeaderTextProperty);
|
|
||||||
set => SetValue(HeaderTextProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly DependencyProperty HeaderTextProperty =
|
|
||||||
DependencyProperty.Register(nameof(HeaderText), typeof(string), typeof(ComponentView), new PropertyMetadata(string.Empty));
|
|
||||||
|
|
||||||
internal ComponentView()
|
|
||||||
{
|
|
||||||
DefaultStyleKey = typeof(ComponentView);
|
|
||||||
|
|
||||||
Unloaded += (s, e) =>
|
|
||||||
{
|
|
||||||
_customEditor?.Destroy();
|
|
||||||
|
|
||||||
_contentContainer = null;
|
|
||||||
_customEditor = null;
|
|
||||||
_propertyFields = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public ComponentView(string header, World world, Entity entity, Type componentType) : this()
|
|
||||||
{
|
|
||||||
HeaderText = header;
|
|
||||||
|
|
||||||
_world = world;
|
|
||||||
_entity = entity;
|
|
||||||
_componentType = componentType;
|
|
||||||
_componentInfo = ComponentRegistry.GetComponentInfo(componentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnApplyTemplate()
|
|
||||||
{
|
|
||||||
_contentContainer = (StackPanel)GetTemplateChild("ContentContainer");
|
|
||||||
|
|
||||||
base.OnApplyTemplate();
|
|
||||||
ReBuild();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReflectionUpdate()
|
|
||||||
{
|
|
||||||
if (_propertyFields == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var propertyField in _propertyFields)
|
|
||||||
{
|
|
||||||
propertyField.UpdateValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CustomEditorUpdate()
|
|
||||||
{
|
|
||||||
_customEditor?.Update();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ReBuild()
|
|
||||||
{
|
|
||||||
if (_contentContainer == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_contentContainer.Children.Clear();
|
|
||||||
if (_world == null || _componentType == null || _entity == Entity.Invalid)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_propertyFields != null)
|
|
||||||
{
|
|
||||||
foreach (var propertyField in _propertyFields)
|
|
||||||
{
|
|
||||||
propertyField.OnValueChanged -= OnPropertyValueChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var componentObject = new ComponentObject(_world, _entity);
|
|
||||||
var editorType = TypeCache.GetTypes().FirstOrDefault(t =>
|
|
||||||
typeof(ComponentEditor).IsAssignableFrom(t) &&
|
|
||||||
t.GetCustomAttribute<CustomEditorAttribute>()?.TargetType.IsAssignableFrom(_componentType) == true);
|
|
||||||
|
|
||||||
if (editorType != null)
|
|
||||||
{
|
|
||||||
_customEditor = (ComponentEditor)Activator.CreateInstance(editorType)!;
|
|
||||||
_customEditor.Initialize(componentObject);
|
|
||||||
_customEditor.Create(_contentContainer);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var fields = _componentType.GetFields(StaticResource.ComponentPropertyBindingFlags);
|
|
||||||
_propertyFields = new PropertyField[fields.Length];
|
|
||||||
|
|
||||||
_pComponentData = _world.EntityManager.GetComponent(_entity, _componentInfo.id);
|
|
||||||
_managedInstance = Marshal.PtrToStructure((nint)_pComponentData, _componentType);
|
|
||||||
if (_managedInstance == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < fields.Length; i++)
|
|
||||||
{
|
|
||||||
var field = fields[i];
|
|
||||||
var propertyField = PropertyField.Create(field.Name, field, _managedInstance);
|
|
||||||
propertyField.OnValueChanged += OnPropertyValueChanged;
|
|
||||||
|
|
||||||
_propertyFields[i] = propertyField;
|
|
||||||
_contentContainer.Children.Add(propertyField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_editorUpdate = _customEditor == null ? ReflectionUpdate : CustomEditorUpdate;
|
|
||||||
_editorUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPropertyValueChanged(PropertyField field)
|
|
||||||
{
|
|
||||||
if (_managedInstance == null || _pComponentData == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Marshal.StructureToPtr(_managedInstance, (nint)_pComponentData, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<ResourceDictionary
|
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:local="using:Ghost.Editor.Core.Controls">
|
|
||||||
|
|
||||||
<Style TargetType="local:ComponentView">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate TargetType="local:ComponentView">
|
|
||||||
<StackPanel Margin="0,0,0,16">
|
|
||||||
<Border
|
|
||||||
Padding="8"
|
|
||||||
HorizontalAlignment="Stretch"
|
|
||||||
Background="{ThemeResource SolidBackgroundFillColorSecondaryBrush}">
|
|
||||||
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{TemplateBinding HeaderText}" />
|
|
||||||
</Border>
|
|
||||||
<StackPanel
|
|
||||||
x:Name="ContentContainer"
|
|
||||||
Margin="8,2,2,0"
|
|
||||||
Spacing="2" />
|
|
||||||
</StackPanel>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</ResourceDictionary>
|
|
||||||
@@ -1,44 +1,12 @@
|
|||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls;
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
public sealed partial class ContextFlyout : MenuFlyout
|
public sealed partial class ContextFlyout : MenuFlyout
|
||||||
{
|
{
|
||||||
private class MenuNode
|
|
||||||
{
|
|
||||||
public required string Name
|
|
||||||
{
|
|
||||||
get; init;
|
|
||||||
}
|
|
||||||
|
|
||||||
public MethodInfo? Method
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<MenuNode> Children
|
|
||||||
{
|
|
||||||
get;
|
|
||||||
} = new();
|
|
||||||
|
|
||||||
public int RawGroup
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
} = int.MaxValue;
|
|
||||||
|
|
||||||
// The calculated group used for sorting (min of children for folders)
|
|
||||||
public int EffectiveGroup
|
|
||||||
{
|
|
||||||
get; set;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isPopulated;
|
private bool _isPopulated;
|
||||||
|
|
||||||
public string Tag
|
public string ContextMenuTag
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
} = string.Empty;
|
} = string.Empty;
|
||||||
@@ -48,160 +16,13 @@ public sealed partial class ContextFlyout : MenuFlyout
|
|||||||
Opening += ContextFlyout_Opening;
|
Opening += ContextFlyout_Opening;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively sorts nodes and calculates folder pGroups
|
|
||||||
private static void PrepareNodes(List<MenuNode> nodes)
|
|
||||||
{
|
|
||||||
if (nodes.Count == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var node in nodes)
|
|
||||||
{
|
|
||||||
if (node.Children.Count > 0)
|
|
||||||
{
|
|
||||||
// Go deep first
|
|
||||||
PrepareNodes(node.Children);
|
|
||||||
|
|
||||||
// A folder's group is determined by its highest priority child (lowest group number).
|
|
||||||
// This ensures a "File" folder (containing Group 0 items) sits at the top
|
|
||||||
// alongside other Group 0 leaf items.
|
|
||||||
node.EffectiveGroup = node.Children.Min(c => c.EffectiveGroup);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
node.EffectiveGroup = node.RawGroup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by Group, then by Name
|
|
||||||
nodes.Sort((a, b) =>
|
|
||||||
{
|
|
||||||
var groupCompare = a.EffectiveGroup.CompareTo(b.EffectiveGroup);
|
|
||||||
return groupCompare != 0
|
|
||||||
? groupCompare
|
|
||||||
: string.CompareOrdinal(a.Name, b.Name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively builds the UI elements
|
|
||||||
private static void BuildNodes(List<MenuNode> nodes, IList<MenuFlyoutItemBase> targetCollection)
|
|
||||||
{
|
|
||||||
if (nodes.Count == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentGroup = nodes[0].EffectiveGroup;
|
|
||||||
|
|
||||||
foreach (var node in nodes)
|
|
||||||
{
|
|
||||||
if (node.EffectiveGroup != currentGroup)
|
|
||||||
{
|
|
||||||
targetCollection.Add(new MenuFlyoutSeparator());
|
|
||||||
currentGroup = node.EffectiveGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.Children.Count > 0)
|
|
||||||
{
|
|
||||||
var subItem = new MenuFlyoutSubItem
|
|
||||||
{
|
|
||||||
Text = node.Name
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively render children into the subitem
|
|
||||||
BuildNodes(node.Children, subItem.Items);
|
|
||||||
targetCollection.Add(subItem);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var menuItem = new MenuFlyoutItem
|
|
||||||
{
|
|
||||||
Text = node.Name
|
|
||||||
};
|
|
||||||
|
|
||||||
var methodToInvoke = node.Method;
|
|
||||||
menuItem.Click += (_, _) =>
|
|
||||||
{
|
|
||||||
methodToInvoke?.Invoke(null, null);
|
|
||||||
};
|
|
||||||
|
|
||||||
targetCollection.Add(menuItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PopulateContextMenu()
|
private void PopulateContextMenu()
|
||||||
{
|
{
|
||||||
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
var rootNodes = MenuUtility.BuildTree(ContextMenuTag);
|
||||||
if (methods == null)
|
MenuUtility.BuildNodes(rootNodes, Items);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Build the Tree
|
private void ContextFlyout_Opening(object? sender, object e)
|
||||||
var rootNodes = new List<MenuNode>();
|
|
||||||
|
|
||||||
foreach (var method in methods)
|
|
||||||
{
|
|
||||||
var attr = method.GetCustomAttribute<ContextMenuItemAttribute>();
|
|
||||||
if (attr == null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter tags
|
|
||||||
if (!string.Equals(attr.Tag, Tag, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nameSpan = attr.Name.AsSpan();
|
|
||||||
var pathParts = nameSpan.Split('/');
|
|
||||||
|
|
||||||
var currentLevel = rootNodes;
|
|
||||||
MenuNode? currentNode = null;
|
|
||||||
|
|
||||||
foreach (var range in pathParts)
|
|
||||||
{
|
|
||||||
var part = nameSpan[range.Start..range.End];
|
|
||||||
|
|
||||||
MenuNode? foundNode = null;
|
|
||||||
|
|
||||||
// Try to find existing node in the current level
|
|
||||||
foreach (var node in currentLevel)
|
|
||||||
{
|
|
||||||
if (part.Equals(node.Name.AsSpan(), StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
foundNode = node;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (foundNode == null)
|
|
||||||
{
|
|
||||||
foundNode = new MenuNode { Name = part.ToString() };
|
|
||||||
currentLevel.Add(foundNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentNode = foundNode;
|
|
||||||
|
|
||||||
// If this is the last part, it's the executable item
|
|
||||||
if (range.End.Value == nameSpan.Length)
|
|
||||||
{
|
|
||||||
currentNode.Method = method;
|
|
||||||
currentNode.RawGroup = attr.Group;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLevel = currentNode.Children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PrepareNodes(rootNodes);
|
|
||||||
BuildNodes(rootNodes, Items);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void ContextFlyout_Opening(object? sender, object e)
|
|
||||||
{
|
{
|
||||||
if (_isPopulated)
|
if (_isPopulated)
|
||||||
{
|
{
|
||||||
|
|||||||
53
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuContextBar.cs
Normal file
53
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuContextBar.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public sealed partial class MenuContextBar : MenuBar
|
||||||
|
{
|
||||||
|
private bool _isPopulated;
|
||||||
|
|
||||||
|
public string ContextMenuTag
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = string.Empty;
|
||||||
|
|
||||||
|
public MenuContextBar()
|
||||||
|
{
|
||||||
|
Loaded += MenuContextBar_Loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MenuContextBar_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isPopulated)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PopulateMenu();
|
||||||
|
_isPopulated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateMenu()
|
||||||
|
{
|
||||||
|
var rootNodes = MenuUtility.BuildTree(ContextMenuTag);
|
||||||
|
|
||||||
|
foreach (var node in rootNodes)
|
||||||
|
{
|
||||||
|
if (node.Children.Count == 0)
|
||||||
|
{
|
||||||
|
Logger.Warning($"Menu item '{node.Name}' cannot be placed at the root of a MenuContextBar because it lacks a parent group.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var menuBarItem = new MenuBarItem
|
||||||
|
{
|
||||||
|
Title = node.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
MenuUtility.BuildNodes(node.Children, menuBarItem.Items);
|
||||||
|
Items.Add(menuBarItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuUtility.cs
Normal file
236
src/Editor/Ghost.Editor.Core/Controls/Menu/MenuUtility.cs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Reflection;
|
||||||
|
using Windows.System;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
internal class MenuNode
|
||||||
|
{
|
||||||
|
public required string Name
|
||||||
|
{
|
||||||
|
get; init;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MethodInfo? Method
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MenuNode> Children
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
public int RawGroup
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = int.MaxValue;
|
||||||
|
|
||||||
|
// The calculated group used for sorting (min of children for folders)
|
||||||
|
public int EffectiveGroup
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int RawPriority
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 0;
|
||||||
|
|
||||||
|
public int EffectivePriority
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VirtualKey ShortCut
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VirtualKey.None;
|
||||||
|
|
||||||
|
public VirtualKeyModifiers ShortCutModifiers
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = VirtualKeyModifiers.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class MenuUtility
|
||||||
|
{
|
||||||
|
// Recursively sorts nodes and calculates folder pGroups
|
||||||
|
public static void PrepareNodes(List<MenuNode> nodes)
|
||||||
|
{
|
||||||
|
if (nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.Children.Count > 0)
|
||||||
|
{
|
||||||
|
// Go deep first
|
||||||
|
PrepareNodes(node.Children);
|
||||||
|
|
||||||
|
// A folder's group is determined by its highest priority child (lowest group number).
|
||||||
|
// This ensures a "File" folder (containing Group 0 items) sits at the top
|
||||||
|
// alongside other Group 0 leaf items.
|
||||||
|
node.EffectiveGroup = node.Children.Min(c => c.EffectiveGroup);
|
||||||
|
node.EffectivePriority = node.Children.Max(c => c.EffectivePriority);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
node.EffectiveGroup = node.RawGroup;
|
||||||
|
node.EffectivePriority = node.RawPriority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by Group, then by Priority (higher first), then by Name
|
||||||
|
nodes.Sort((a, b) =>
|
||||||
|
{
|
||||||
|
var groupCompare = a.EffectiveGroup.CompareTo(b.EffectiveGroup);
|
||||||
|
if (groupCompare != 0)
|
||||||
|
{
|
||||||
|
return groupCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
var priorityCompare = b.EffectivePriority.CompareTo(a.EffectivePriority);
|
||||||
|
return priorityCompare != 0
|
||||||
|
? priorityCompare
|
||||||
|
: string.CompareOrdinal(a.Name, b.Name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively builds the UI elements
|
||||||
|
public static void BuildNodes(List<MenuNode> nodes, IList<MenuFlyoutItemBase> targetCollection)
|
||||||
|
{
|
||||||
|
if (nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentGroup = nodes[0].EffectiveGroup;
|
||||||
|
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.EffectiveGroup != currentGroup)
|
||||||
|
{
|
||||||
|
targetCollection.Add(new MenuFlyoutSeparator());
|
||||||
|
currentGroup = node.EffectiveGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.Children.Count > 0)
|
||||||
|
{
|
||||||
|
var subItem = new MenuFlyoutSubItem
|
||||||
|
{
|
||||||
|
Text = node.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recursively render children into the subitem
|
||||||
|
BuildNodes(node.Children, subItem.Items);
|
||||||
|
targetCollection.Add(subItem);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var menuItem = new MenuFlyoutItem
|
||||||
|
{
|
||||||
|
Text = node.Name
|
||||||
|
};
|
||||||
|
|
||||||
|
var methodToInvoke = node.Method;
|
||||||
|
menuItem.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
methodToInvoke?.Invoke(null, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.ShortCut != VirtualKey.None)
|
||||||
|
{
|
||||||
|
menuItem.KeyboardAccelerators.Add(new Microsoft.UI.Xaml.Input.KeyboardAccelerator
|
||||||
|
{
|
||||||
|
Key = node.ShortCut,
|
||||||
|
Modifiers = node.ShortCutModifiers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
targetCollection.Add(menuItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<MenuNode> BuildTree(string tag)
|
||||||
|
{
|
||||||
|
var methods = TypeCache.GetMethodsWithAttribute<ContextMenuItemAttribute>();
|
||||||
|
if (methods == null)
|
||||||
|
{
|
||||||
|
return new List<MenuNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Build the Tree
|
||||||
|
var rootNodes = new List<MenuNode>();
|
||||||
|
|
||||||
|
foreach (var method in methods)
|
||||||
|
{
|
||||||
|
var attr = method.GetCustomAttribute<ContextMenuItemAttribute>();
|
||||||
|
if (attr == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tags
|
||||||
|
if (!string.Equals(attr.Tag, tag, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameSpan = attr.Name.AsSpan();
|
||||||
|
var pathParts = nameSpan.Split('/');
|
||||||
|
|
||||||
|
var currentLevel = rootNodes;
|
||||||
|
MenuNode? currentNode = null;
|
||||||
|
|
||||||
|
foreach (var range in pathParts)
|
||||||
|
{
|
||||||
|
var part = nameSpan[range.Start..range.End];
|
||||||
|
|
||||||
|
MenuNode? foundNode = null;
|
||||||
|
|
||||||
|
// Try to find existing node in the current level
|
||||||
|
foreach (var node in currentLevel)
|
||||||
|
{
|
||||||
|
if (part.Equals(node.Name.AsSpan(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
foundNode = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundNode == null)
|
||||||
|
{
|
||||||
|
foundNode = new MenuNode { Name = part.ToString() };
|
||||||
|
currentLevel.Add(foundNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = foundNode;
|
||||||
|
|
||||||
|
// If this is the last part, it's the executable item
|
||||||
|
if (range.End.Value == nameSpan.Length)
|
||||||
|
{
|
||||||
|
currentNode.Method = method;
|
||||||
|
currentNode.RawGroup = attr.Group;
|
||||||
|
currentNode.RawPriority = attr.Priority;
|
||||||
|
|
||||||
|
var shortCutAttr = method.GetCustomAttribute<ShortcutAttribute>();
|
||||||
|
if (shortCutAttr != null)
|
||||||
|
{
|
||||||
|
currentNode.ShortCut = shortCutAttr.Key;
|
||||||
|
currentNode.ShortCutModifiers = shortCutAttr.Modifiers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel = currentNode.Children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PrepareNodes(rootNodes);
|
||||||
|
return rootNodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml
Normal file
32
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="Ghost.Editor.Core.Controls.ReferenceField"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
AllowDrop="{x:Bind AllowDrop, Mode=OneWay}"
|
||||||
|
DragOver="OnDragOver"
|
||||||
|
Drop="OnDrop">
|
||||||
|
|
||||||
|
<Grid CornerRadius="4" BorderThickness="1" x:Name="RootBorder" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<FontIcon Grid.Column="0" Margin="8,0,0,0" Glyph="{x:Bind IconGlyph, Mode=OneWay}" FontSize="12" Foreground="{ThemeResource TextFillColorSecondaryBrush}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Margin="8,4,8,4" Text="{x:Bind DisplayText, Mode=OneWay}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" FontSize="12" Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||||
|
|
||||||
|
<Button Grid.Column="2" x:Name="GotoButton" Margin="0,0,4,0" Padding="4" Background="Transparent" BorderThickness="0" Click="OnGotoButtonClicked" Visibility="Collapsed" VerticalAlignment="Center">
|
||||||
|
<FontIcon Glyph="" FontSize="12" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" x:Name="ClearButton" Margin="0,0,4,0" Padding="4" Background="Transparent" BorderThickness="0" Click="OnClearButtonClicked" Visibility="Collapsed" VerticalAlignment="Center">
|
||||||
|
<FontIcon Glyph="" FontSize="10" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
158
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml.cs
Normal file
158
src/Editor/Ghost.Editor.Core/Controls/ReferenceField.xaml.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
|
public sealed partial class ReferenceField : UserControl
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty DisplayTextProperty =
|
||||||
|
DependencyProperty.Register(nameof(DisplayText), typeof(string), typeof(ReferenceField), new PropertyMetadata(string.Empty, OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty TypeLabelProperty =
|
||||||
|
DependencyProperty.Register(nameof(TypeLabel), typeof(string), typeof(ReferenceField), new PropertyMetadata("Object", OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IconGlyphProperty =
|
||||||
|
DependencyProperty.Register(nameof(IconGlyph), typeof(string), typeof(ReferenceField), new PropertyMetadata("\uEA86"));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty HasValueProperty =
|
||||||
|
DependencyProperty.Register(nameof(HasValue), typeof(bool), typeof(ReferenceField), new PropertyMetadata(false, OnStateChanged));
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsReadOnlyProperty =
|
||||||
|
DependencyProperty.Register(nameof(IsReadOnly), typeof(bool), typeof(ReferenceField), new PropertyMetadata(false, OnStateChanged));
|
||||||
|
|
||||||
|
public string DisplayText
|
||||||
|
{
|
||||||
|
get => (string)GetValue(DisplayTextProperty);
|
||||||
|
set => SetValue(DisplayTextProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TypeLabel
|
||||||
|
{
|
||||||
|
get => (string)GetValue(TypeLabelProperty);
|
||||||
|
set => SetValue(TypeLabelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string IconGlyph
|
||||||
|
{
|
||||||
|
get => (string)GetValue(IconGlyphProperty);
|
||||||
|
set => SetValue(IconGlyphProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasValue
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(HasValueProperty);
|
||||||
|
set => SetValue(HasValueProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadOnly
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsReadOnlyProperty);
|
||||||
|
set => SetValue(IsReadOnlyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Func<DragEventArgs, bool>? ValidateDrop;
|
||||||
|
public Action<DragEventArgs>? OnDropAccepted;
|
||||||
|
public Action? OnClearClicked;
|
||||||
|
public Action? OnGotoClicked;
|
||||||
|
|
||||||
|
private readonly SolidColorBrush _accentBrush;
|
||||||
|
private readonly SolidColorBrush _errorBrush;
|
||||||
|
private readonly SolidColorBrush _defaultBorderBrush;
|
||||||
|
|
||||||
|
public ReferenceField()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_accentBrush = (SolidColorBrush)Application.Current.Resources["SystemControlHighlightAccentBrush"];
|
||||||
|
_errorBrush = new SolidColorBrush(Microsoft.UI.Colors.Red);
|
||||||
|
_defaultBorderBrush = (SolidColorBrush)Application.Current.Resources["CardStrokeColorDefaultBrush"];
|
||||||
|
|
||||||
|
UpdateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
((ReferenceField)d).UpdateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateState()
|
||||||
|
{
|
||||||
|
if (HasValue)
|
||||||
|
{
|
||||||
|
ClearButton.Visibility = IsReadOnly ? Visibility.Collapsed : Visibility.Visible;
|
||||||
|
GotoButton.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ClearButton.Visibility = Visibility.Collapsed;
|
||||||
|
GotoButton.Visibility = Visibility.Collapsed;
|
||||||
|
if (string.IsNullOrEmpty(DisplayText))
|
||||||
|
{
|
||||||
|
// We shouldn't change DependencyProperty value here to avoid loops,
|
||||||
|
// but we can bind a different text if needed. For now, rely on caller to set DisplayText to "None (Type)".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AllowDrop = !IsReadOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid = ValidateDrop?.Invoke(e) ?? false;
|
||||||
|
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.Link;
|
||||||
|
RootBorder.BorderBrush = _accentBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
e.AcceptedOperation = DataPackageOperation.None;
|
||||||
|
// Optionally set error brush
|
||||||
|
RootBorder.BorderBrush = _errorBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDragLeave(DragEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDragLeave(e);
|
||||||
|
RootBorder.BorderBrush = _defaultBorderBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDrop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
RootBorder.BorderBrush = _defaultBorderBrush;
|
||||||
|
RootBorder.BorderThickness = new Thickness(1);
|
||||||
|
|
||||||
|
if (IsReadOnly) return;
|
||||||
|
|
||||||
|
var isValid = ValidateDrop?.Invoke(e) ?? false;
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
OnDropAccepted?.Invoke(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClearButtonClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OnClearClicked?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGotoButtonClicked(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
OnGotoClicked?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
using Ghost.Editor.Core.Event;
|
using Ghost.Editor.Core.Event;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls;
|
namespace Ghost.Editor.Core.Controls;
|
||||||
|
|
||||||
public partial class ValueControl<T> : Control
|
public interface INotifyValueChanged<T>
|
||||||
|
{
|
||||||
|
T Value { get; set; }
|
||||||
|
|
||||||
|
event ValueChangedEventHandler<T>? OnValueChanged;
|
||||||
|
|
||||||
|
void SetValueWithoutNotify(T value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class ValueControl<T> : Control, INotifyValueChanged<T>
|
||||||
{
|
{
|
||||||
private bool _suppressChangedEvent;
|
private bool _suppressChangedEvent;
|
||||||
|
|
||||||
@@ -39,7 +49,7 @@ public partial class ValueControl<T> : Control
|
|||||||
{
|
{
|
||||||
valueControl.ValueChanged((T)e.OldValue, (T)e.NewValue);
|
valueControl.ValueChanged((T)e.OldValue, (T)e.NewValue);
|
||||||
|
|
||||||
if (!valueControl._suppressChangedEvent)
|
if (!valueControl.SuppressChangedEvent)
|
||||||
{
|
{
|
||||||
valueControl.OnValueChanged?.Invoke(valueControl, new((T)e.OldValue, (T)e.NewValue));
|
valueControl.OnValueChanged?.Invoke(valueControl, new((T)e.OldValue, (T)e.NewValue));
|
||||||
}
|
}
|
||||||
@@ -55,16 +65,26 @@ public partial class ValueControl<T> : Control
|
|||||||
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
OnValueChanged?.Invoke(this, new(oldValue, newValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the value of the control.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="value">The new value to set.</param>
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public void SetValue(T value)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the _value without notifying the change event.
|
/// Sets the _value without notifying the change event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The new _value to set.</param>
|
/// <param name="value">The new _value to set.</param>
|
||||||
/// <remarks>This method only suppresses the change event notification, not the <see cref="ValueChanged(T, T)"/> method.
|
/// <remarks>This method only suppresses the change event notification, not the <see cref="ValueChanged(T, T)"/> method.
|
||||||
/// Useful when you need to change the _value programmatically without triggering the change event.</remarks>
|
/// Useful when you need to change the _value programmatically without triggering the change event.</remarks>
|
||||||
public void SetValueWithoutNotifying(T value)
|
public void SetValueWithoutNotify(T value)
|
||||||
{
|
{
|
||||||
_suppressChangedEvent = true;
|
SuppressChangedEvent = true;
|
||||||
SetValue(ValueProperty, value);
|
SetValue(value);
|
||||||
_suppressChangedEvent = false;
|
SuppressChangedEvent = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
using Ghost.Core.Utilities;
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core;
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
|
public enum EditorState
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
Compiling,
|
||||||
|
}
|
||||||
|
|
||||||
public static class EditorApplication
|
public static class EditorApplication
|
||||||
{
|
{
|
||||||
public const string ASSETS_FOLDER_NAME = "Assets";
|
public const string ASSETS_FOLDER_NAME = "Assets";
|
||||||
@@ -54,6 +63,11 @@ public static class EditorApplication
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static EditorState State
|
||||||
|
{
|
||||||
|
get; internal set;
|
||||||
|
} = EditorState.Idle;
|
||||||
|
|
||||||
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
internal static void Initialize(IServiceProvider serviceProvider, string projectPath, string projectName)
|
||||||
{
|
{
|
||||||
projectPath = PathUtility.Normalize(projectPath);
|
projectPath = PathUtility.Normalize(projectPath);
|
||||||
@@ -89,12 +103,25 @@ public static class EditorApplication
|
|||||||
public static T GetService<T>()
|
public static T GetService<T>()
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
if (s_serviceProvider?.GetService(typeof(T)) is not T service)
|
if (TryGetService<T>(out var service))
|
||||||
{
|
{
|
||||||
throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices.");
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
return service;
|
throw new ArgumentException("Requested service of type " + typeof(T).FullName + " is not registered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetService<T>([NotNullWhen(true)] out T? service)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
if (s_serviceProvider?.GetService(typeof(T)) is T resolvedService)
|
||||||
|
{
|
||||||
|
service = resolvedService;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
service = null;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void Shutdown()
|
internal static void Shutdown()
|
||||||
|
|||||||
@@ -10,17 +10,41 @@
|
|||||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
|
||||||
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages -->
|
<NoWarn>$(NoWarn);MVVMTK0050</NoWarn>
|
||||||
<langversion>preview</langversion>
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Remove="Assets\MeshNode.cs" />
|
<Content Remove="Assets\MeshNode.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FluentIcons.WinUI" Version="2.1.324" />
|
<PackageReference Include="FluentIcons.WinUI" Version="2.1.328" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.6" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Behaviors" Version="8.2.251219" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -43,8 +67,5 @@
|
|||||||
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
<Page Update="Controls\BasicInput\Vector3Field.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
</Page>
|
</Page>
|
||||||
<Page Update="Controls\Internal\ComponentView.xaml">
|
|
||||||
<SubType>Designer</SubType>
|
|
||||||
</Page>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
100
src/Editor/Ghost.Editor.Core/GhostObject.cs
Normal file
100
src/Editor/Ghost.Editor.Core/GhostObject.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The base class for all objects that can be tracked and recorded by the Undo system.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class GhostObject : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A persistent unique identifier used to track this object across Undo/Redo operations,
|
||||||
|
/// even if the underlying object is destroyed and resurrected.
|
||||||
|
/// </summary>
|
||||||
|
public Guid InstanceID { get; protected set; }
|
||||||
|
|
||||||
|
// Use WeakReference so we don't prevent Garbage Collection of dead objects
|
||||||
|
private static readonly Dictionary<Guid, WeakReference<GhostObject>> s_objectRegistry = new();
|
||||||
|
|
||||||
|
public static event Action<GhostObject>? OnObjectModified;
|
||||||
|
|
||||||
|
protected GhostObject()
|
||||||
|
{
|
||||||
|
InstanceID = Guid.NewGuid();
|
||||||
|
s_objectRegistry[InstanceID] = new WeakReference<GhostObject>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected GhostObject(Guid instanceID)
|
||||||
|
{
|
||||||
|
InstanceID = instanceID;
|
||||||
|
s_objectRegistry[InstanceID] = new WeakReference<GhostObject>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a GhostObject by its InstanceID in O(1) time.
|
||||||
|
/// </summary>
|
||||||
|
public static GhostObject? Find(Guid id)
|
||||||
|
{
|
||||||
|
if (s_objectRegistry.TryGetValue(id, out var weakRef))
|
||||||
|
{
|
||||||
|
if (weakRef.TryGetTarget(out var obj))
|
||||||
|
{
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Dead object, GC has collected it
|
||||||
|
s_objectRegistry.Remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called before mutating state.
|
||||||
|
/// Hooks into the Undo and Dirty Tracking systems.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void Modify()
|
||||||
|
{
|
||||||
|
OnObjectModified?.Invoke(this);
|
||||||
|
|
||||||
|
// TODO: Unify RecordObject in future sessions. For now, we skip IUndoService.RecordObject here
|
||||||
|
// since specialized methods are still required in UndoService.
|
||||||
|
|
||||||
|
// Mark dirty for persistence directly
|
||||||
|
EditorApplication.GetService<IDirtyTrackerService>().MarkDirty(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the state of this object into a binary format.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes the state of this object from a binary format.
|
||||||
|
/// </summary>
|
||||||
|
public virtual void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
s_objectRegistry.Remove(InstanceID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~GhostObject()
|
||||||
|
{
|
||||||
|
Dispose(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Metadata for an entire ECS component type, including all its editable fields.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ComponentDescriptor
|
||||||
|
{
|
||||||
|
public Type ComponentType { get; }
|
||||||
|
public Identifier<IComponent> ComponentId { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public int Size { get; }
|
||||||
|
public bool IsShared { get; }
|
||||||
|
public PropertyDescriptor[] Properties { get; }
|
||||||
|
|
||||||
|
private ComponentDescriptor(Type componentType, Identifier<IComponent> componentId, string displayName, int size, bool isShared, PropertyDescriptor[] properties)
|
||||||
|
{
|
||||||
|
ComponentType = componentType;
|
||||||
|
ComponentId = componentId;
|
||||||
|
DisplayName = displayName;
|
||||||
|
Size = size;
|
||||||
|
IsShared = isShared;
|
||||||
|
Properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentDescriptor Create(Type componentType)
|
||||||
|
{
|
||||||
|
var componentId = ComponentRegistry.GetComponentID(componentType);
|
||||||
|
var info = ComponentRegistry.GetComponentInfo(componentId);
|
||||||
|
|
||||||
|
var nameAttr = componentType.GetCustomAttribute<InspectorNameAttribute>();
|
||||||
|
var displayName = nameAttr?.Name ?? componentType.Name;
|
||||||
|
|
||||||
|
var properties = new List<PropertyDescriptor>();
|
||||||
|
var fields = componentType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
if (field.GetCustomAttribute<HideInInspectorAttribute>() != null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Exclude internal/private fields unless they have a specific attribute, but for now we just show public or specifically included.
|
||||||
|
if (!field.IsPublic && field.GetCustomAttribute<InspectorNameAttribute>() == null)
|
||||||
|
{
|
||||||
|
// In GhostEngine we often use public fields for component data, or private fields with [InspectorName].
|
||||||
|
// We'll just include public fields by default, and any non-public with specific attributes.
|
||||||
|
if (field.GetCustomAttribute<ReadOnlyInInspectorAttribute>() == null &&
|
||||||
|
field.GetCustomAttribute<InspectorGroupAttribute>() == null)
|
||||||
|
{
|
||||||
|
continue; // Skip normal private fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
properties.Add(new PropertyDescriptor(field, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ComponentDescriptor(componentType, componentId, displayName, info.size, info.isShared, properties.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
// TODO: We can use source generator to directly generate ComponentDescriptor on each component type and avoid reflection and caching altogether. This is just a quick solution for now.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe cache of ComponentDescriptor per component type.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentDescriptorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<nint, ComponentDescriptor> s_cache = new();
|
||||||
|
private static readonly Lock s_lock = new();
|
||||||
|
|
||||||
|
public static ComponentDescriptor GetOrCreate(Type componentType)
|
||||||
|
{
|
||||||
|
var handle = componentType.TypeHandle.Value;
|
||||||
|
|
||||||
|
lock (s_lock)
|
||||||
|
{
|
||||||
|
if (s_cache.TryGetValue(handle, out var descriptor))
|
||||||
|
{
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptor = ComponentDescriptor.Create(componentType);
|
||||||
|
s_cache[handle] = descriptor;
|
||||||
|
return descriptor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ComponentDescriptor GetOrCreate(Identifier<IComponent> componentId)
|
||||||
|
{
|
||||||
|
return GetOrCreate(ComponentRegistry.s_runtimeIDToType[componentId.Value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,16 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Inspector;
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
public abstract class ComponentEditor
|
public abstract class ComponentEditor
|
||||||
{
|
{
|
||||||
private ComponentObject _componentObject;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents the underlying component object used by this class to manage its functionality.
|
|
||||||
/// </summary>
|
|
||||||
protected ComponentObject ComponentObject => _componentObject;
|
|
||||||
|
|
||||||
internal void Initialize(ComponentObject componentObject)
|
|
||||||
{
|
|
||||||
_componentObject = componentObject;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called when the component editor is created.
|
/// Called when the component editor is created.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="container">The container to add the editor controls to.</param>
|
/// <param name="root">The root panel to which the editor should add its UI elements.</param>
|
||||||
public virtual void Create(StackPanel container)
|
/// <param name="componentNode">The component node being edited.</param>
|
||||||
{
|
public abstract void Create(Panel root, ComponentNode componentNode);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public virtual void Destroy() { }
|
||||||
/// Called when the component editor needs to update its UI based on the current state of the component data.
|
|
||||||
/// </summary>
|
|
||||||
public virtual void Update()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Called when the component editor is destroyed.
|
|
||||||
/// </summary>
|
|
||||||
public virtual void Destroy()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registry mapping ECS component types to their custom UI editor types.
|
||||||
|
/// </summary>
|
||||||
|
public static class ComponentEditorRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, Type> s_editors = new();
|
||||||
|
|
||||||
|
static ComponentEditorRegistry()
|
||||||
|
{
|
||||||
|
var editorTypes = TypeCache.GetTypesWithAttribute<CustomEditorAttribute>();
|
||||||
|
if (editorTypes == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var editorType in editorTypes)
|
||||||
|
{
|
||||||
|
var attr = editorType.GetCustomAttribute<CustomEditorAttribute>();
|
||||||
|
if (attr != null && attr.TargetType != null)
|
||||||
|
{
|
||||||
|
if (typeof(ComponentEditor).IsAssignableFrom(editorType))
|
||||||
|
{
|
||||||
|
s_editors[attr.TargetType] = editorType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a custom editor exists for the given component type.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasCustomEditor(Type componentType)
|
||||||
|
{
|
||||||
|
return s_editors.ContainsKey(componentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates the custom editor for the given component type, or null if none exists.
|
||||||
|
/// </summary>
|
||||||
|
public static ComponentEditor? CreateCustomEditor(Type componentType)
|
||||||
|
{
|
||||||
|
if (s_editors.TryGetValue(componentType, out var editorType))
|
||||||
|
{
|
||||||
|
return (ComponentEditor?)Activator.CreateInstance(editorType);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
using Ghost.Entities;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Inspector;
|
|
||||||
|
|
||||||
public readonly struct ComponentObject
|
|
||||||
{
|
|
||||||
private readonly World _world;
|
|
||||||
private readonly Entity _entity;
|
|
||||||
|
|
||||||
internal ComponentObject(World world, Entity entity)
|
|
||||||
{
|
|
||||||
_world = world;
|
|
||||||
_entity = entity;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ref T GetData<T>()
|
|
||||||
where T : unmanaged, IComponent
|
|
||||||
{
|
|
||||||
return ref _world.EntityManager.GetComponent<T>(_entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetData<T>(in T data)
|
|
||||||
where T : unmanaged, IComponent
|
|
||||||
{
|
|
||||||
_world.EntityManager.SetComponent(_entity, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a class as a custom property drawer for a specific type.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Class)]
|
||||||
|
public sealed class CustomPropertyDrawerAttribute : DiscoverableAttributeBase
|
||||||
|
{
|
||||||
|
public Type TargetFieldType { get; }
|
||||||
|
|
||||||
|
public CustomPropertyDrawerAttribute(Type targetFieldType)
|
||||||
|
{
|
||||||
|
TargetFieldType = targetFieldType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class EmptyDrawer<T> : PropertyDrawer<T> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
// For a nested struct, the PropertyField will draw the Label,
|
||||||
|
// and this empty border will be the Content (taking no space).
|
||||||
|
// The children properties will be drawn underneath.
|
||||||
|
return new Border();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
internal class EntityDrawer : PropertyDrawer<Entity>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<Entity> model)
|
||||||
|
{
|
||||||
|
static void UpdateUI(Entity val, ReferenceField field)
|
||||||
|
{
|
||||||
|
if (val.IsValid)
|
||||||
|
{
|
||||||
|
field.HasValue = true;
|
||||||
|
|
||||||
|
// TODO: For now, just display the Entity ID. We could resolve its SceneGraph Node name in the future.
|
||||||
|
field.DisplayText = $"Entity {val.ID}:{val.Generation}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.HasValue = false;
|
||||||
|
field.DisplayText = "None (Entity)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = new ReferenceField
|
||||||
|
{
|
||||||
|
TypeLabel = "Entity",
|
||||||
|
IconGlyph = "\uF158",
|
||||||
|
Margin = new Thickness(0, 2, 0, 2),
|
||||||
|
ValidateDrop = (args) =>
|
||||||
|
{
|
||||||
|
// TODO: Implement drag and drop for entities from the hierarchy
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnClearClicked = () =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(Entity.Invalid);
|
||||||
|
UpdateUI(Entity.Invalid, field);
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateUI(model.Value, field);
|
||||||
|
|
||||||
|
model.OnValueChanged += (val) =>
|
||||||
|
{
|
||||||
|
field.DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
UpdateUI(val, field);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Editor/Ghost.Editor.Core/Inspector/Drawers/EnumDrawer.cs
Normal file
38
src/Editor/Ghost.Editor.Core/Inspector/Drawers/EnumDrawer.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class EnumDrawer<T> : PropertyDrawer<T>
|
||||||
|
where T : unmanaged, Enum
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var comboBox = new ComboBox
|
||||||
|
{
|
||||||
|
ItemsSource = Enum.GetNames(typeof(T)),
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsEnabled = !model.Descriptor.IsReadOnly,
|
||||||
|
SelectedItem = model.Value.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
comboBox.SelectionChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (comboBox.SelectedItem is string str)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<T>(str, out var parsed))
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
comboBox.SelectedItem = newVal.ToString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return comboBox;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class Float3Drawer : PropertyDrawer<float3>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(SceneGraph.PropertyNode<float3> node)
|
||||||
|
{
|
||||||
|
var field = new Float3Field
|
||||||
|
{
|
||||||
|
IsEnabled = !node.Descriptor.IsReadOnly,
|
||||||
|
Value = node.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
field.BindTwoWay(node);
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<Handle<T>> model)
|
||||||
|
{
|
||||||
|
static void UpdateUI(HandlePropertyNode<T> handleNode, ReferenceField field)
|
||||||
|
{
|
||||||
|
var guid = handleNode?.AssetGuid ?? Guid.Empty;
|
||||||
|
field.HasValue = guid != Guid.Empty;
|
||||||
|
field.DisplayText = guid != Guid.Empty ? $"{typeof(T).Name} ({guid.ToString().Substring(0, 8)})" : $"None ({typeof(T).Name})";
|
||||||
|
}
|
||||||
|
|
||||||
|
var field = new ReferenceField
|
||||||
|
{
|
||||||
|
TypeLabel = typeof(T).Name,
|
||||||
|
Margin = new Thickness(0, 2, 0, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var handleNode = model as HandlePropertyNode<T>;
|
||||||
|
Logger.DebugAssert(handleNode != null);
|
||||||
|
|
||||||
|
field.ValidateDrop = (args) =>
|
||||||
|
{
|
||||||
|
// For now, assume payload has standard string Guid or we implement format
|
||||||
|
return args.DataView.Contains(Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text);
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnDropAccepted = async (args) =>
|
||||||
|
{
|
||||||
|
if (handleNode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = await args.DataView.GetTextAsync();
|
||||||
|
if (Guid.TryParse(text, out var guid))
|
||||||
|
{
|
||||||
|
handleNode.SetHandleFromAsset(guid);
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
field.OnClearClicked = () =>
|
||||||
|
{
|
||||||
|
if (handleNode != null)
|
||||||
|
{
|
||||||
|
handleNode.ClearHandle();
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
|
||||||
|
// When ECS value changes outside of UI
|
||||||
|
model.OnValueChanged += (val) =>
|
||||||
|
{
|
||||||
|
// UI Thread check usually required here, but property model events should be on UI thread or marshaled
|
||||||
|
field.DispatcherQueue.TryEnqueue(() =>
|
||||||
|
{
|
||||||
|
UpdateUI(handleNode, field);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class NumberBoxDrawer<T> : PropertyDrawer<T>
|
||||||
|
where T : unmanaged, INumber<T>, IMinMaxValue<T>
|
||||||
|
{
|
||||||
|
private readonly int _fractionDigits;
|
||||||
|
private readonly double _min;
|
||||||
|
private readonly double _max;
|
||||||
|
|
||||||
|
public NumberBoxDrawer(int fractionDigits, double min, double max)
|
||||||
|
{
|
||||||
|
_fractionDigits = fractionDigits;
|
||||||
|
_min = min;
|
||||||
|
_max = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe NumberBoxDrawer<T> CreateFloatingPoint()
|
||||||
|
{
|
||||||
|
var digits = sizeof(T) > 4 ? 6 : 3;
|
||||||
|
return new NumberBoxDrawer<T>(digits, double.CreateTruncating(T.MinValue), double.CreateTruncating(T.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NumberBoxDrawer<T> CreateInteger()
|
||||||
|
{
|
||||||
|
return new NumberBoxDrawer<T>(0, double.CreateTruncating(T.MinValue), double.CreateTruncating(T.MaxValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override FrameworkElement CreateControlT(SceneGraph.PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var box = new NumberBox
|
||||||
|
{
|
||||||
|
SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
MaxWidth = double.PositiveInfinity, // To fill PropertyField
|
||||||
|
Maximum = _max,
|
||||||
|
Minimum = _min,
|
||||||
|
Value = double.CreateTruncating(model.Value)
|
||||||
|
};
|
||||||
|
|
||||||
|
var formatter = new Windows.Globalization.NumberFormatting.DecimalFormatter
|
||||||
|
{
|
||||||
|
FractionDigits = _fractionDigits
|
||||||
|
};
|
||||||
|
box.NumberFormatter = formatter;
|
||||||
|
|
||||||
|
box.ValueChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
if (double.IsNaN(e.NewValue)) return;
|
||||||
|
model.SetValueFromUI(T.CreateTruncating(e.NewValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
box.Value = double.CreateTruncating(newVal);
|
||||||
|
};
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class ReadOnlyDrawer<T> : PropertyDrawer<T> where T : unmanaged
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(PropertyNode<T> model)
|
||||||
|
{
|
||||||
|
var box = new TextBox
|
||||||
|
{
|
||||||
|
Text = model.Value.ToString(),
|
||||||
|
IsReadOnly = true,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
box.Text = newVal.ToString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
|
||||||
|
public sealed class ToggleSwitchDrawer : PropertyDrawer<bool>
|
||||||
|
{
|
||||||
|
public override FrameworkElement CreateControlT(SceneGraph.PropertyNode<bool> model)
|
||||||
|
{
|
||||||
|
var toggle = new ToggleSwitch
|
||||||
|
{
|
||||||
|
OnContent = "",
|
||||||
|
OffContent = "",
|
||||||
|
IsOn = model.Value
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle.Toggled += (s, e) =>
|
||||||
|
{
|
||||||
|
model.SetValueFromUI(toggle.IsOn);
|
||||||
|
};
|
||||||
|
|
||||||
|
model.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
toggle.IsOn = newVal;
|
||||||
|
};
|
||||||
|
|
||||||
|
return toggle;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/Editor/Ghost.Editor.Core/Inspector/EntityInspectorModel.cs
Normal file
191
src/Editor/Ghost.Editor.Core/Inspector/EntityInspectorModel.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Model for an entire entity being inspected.
|
||||||
|
/// Discovers components from archetype, builds ComponentModels.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EntityInspectorModel : ISyncableInspectorModel
|
||||||
|
{
|
||||||
|
private readonly World _world;
|
||||||
|
private readonly Entity _entity;
|
||||||
|
private EntityNode? _entityNode;
|
||||||
|
private readonly List<ComponentNode> _components = new();
|
||||||
|
private readonly List<ComponentEditor> _activeCustomEditors = new();
|
||||||
|
private int _lastArchetypeId = -1;
|
||||||
|
|
||||||
|
public World World => _world;
|
||||||
|
public Entity Entity => _entity;
|
||||||
|
public IReadOnlyList<ComponentNode> Components => _components;
|
||||||
|
|
||||||
|
public EntityInspectorModel(World world, Entity entity)
|
||||||
|
{
|
||||||
|
_world = world;
|
||||||
|
_entity = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildComponentList()
|
||||||
|
{
|
||||||
|
_components.Clear();
|
||||||
|
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entityNode == null)
|
||||||
|
{
|
||||||
|
var syncService = EditorApplication.GetService<Services.SceneGraphSyncService>();
|
||||||
|
if (syncService != null && syncService.TryGetNode(_entity, out var node))
|
||||||
|
{
|
||||||
|
_entityNode = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_entityNode != null)
|
||||||
|
{
|
||||||
|
// Update components list in EntityNode first
|
||||||
|
_entityNode.BuildComponents();
|
||||||
|
|
||||||
|
foreach (var compNode in _entityNode.Components)
|
||||||
|
{
|
||||||
|
_components.Add(compNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when entity archetype may have changed.
|
||||||
|
/// Returns true if structure was rebuilt (components added/removed).
|
||||||
|
/// </summary>
|
||||||
|
public bool RefreshStructure()
|
||||||
|
{
|
||||||
|
var locationResult = _world.EntityManager.GetEntityLocation(_entity);
|
||||||
|
if (locationResult.IsFailure)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
if (location.archetypeID == _lastArchetypeId)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastArchetypeId = location.archetypeID;
|
||||||
|
RebuildComponentList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildPropertyUI(PropertyNode propNode, Panel container)
|
||||||
|
{
|
||||||
|
var drawer = PropertyDrawerRegistry.GetDrawer(propNode.Descriptor.ValueType);
|
||||||
|
var control = drawer.CreateControl(propNode);
|
||||||
|
|
||||||
|
var propertyField = new Controls.PropertyField
|
||||||
|
{
|
||||||
|
Label = propNode.Descriptor.DisplayName,
|
||||||
|
Content = control,
|
||||||
|
IsEditable = !propNode.Descriptor.IsReadOnly
|
||||||
|
};
|
||||||
|
|
||||||
|
container.Children.Add(propertyField);
|
||||||
|
|
||||||
|
if (propNode.Children != null && propNode.Children.Length > 0)
|
||||||
|
{
|
||||||
|
var childrenPanel = new StackPanel { Spacing = 4, Margin = new Thickness(12, 4, 0, 0) };
|
||||||
|
foreach (var child in propNode.Children)
|
||||||
|
{
|
||||||
|
BuildPropertyUI(child, childrenPanel);
|
||||||
|
}
|
||||||
|
container.Children.Add(childrenPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all component values from ECS -> model.
|
||||||
|
/// </summary>
|
||||||
|
public void SyncFromECS()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var comp in _components)
|
||||||
|
{
|
||||||
|
foreach (var prop in comp.Properties)
|
||||||
|
{
|
||||||
|
prop.Sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Sync()
|
||||||
|
{
|
||||||
|
if (!_world.EntityManager.Exists(_entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshStructure();
|
||||||
|
SyncFromECS();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Deselect is not supported yet.
|
||||||
|
|
||||||
|
public UIElement BuildUI()
|
||||||
|
{
|
||||||
|
RefreshStructure();
|
||||||
|
|
||||||
|
var container = new StackPanel { Spacing = 4 };
|
||||||
|
|
||||||
|
foreach (var compNode in _components)
|
||||||
|
{
|
||||||
|
// TODO: Use a more compact UI for components
|
||||||
|
var expander = new Expander
|
||||||
|
{
|
||||||
|
Header = compNode.Descriptor.DisplayName,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsExpanded = true,
|
||||||
|
Margin = new Thickness(4, 2, 4, 2)
|
||||||
|
};
|
||||||
|
|
||||||
|
var propertiesPanel = new StackPanel { Spacing = 8 };
|
||||||
|
|
||||||
|
if (ComponentEditorRegistry.HasCustomEditor(compNode.ComponentType))
|
||||||
|
{
|
||||||
|
var editor = ComponentEditorRegistry.CreateCustomEditor(compNode.ComponentType);
|
||||||
|
if (editor != null)
|
||||||
|
{
|
||||||
|
editor.Create(propertiesPanel, compNode);
|
||||||
|
_activeCustomEditors.Add(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var propNode in compNode.Properties)
|
||||||
|
{
|
||||||
|
BuildPropertyUI(propNode, propertiesPanel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expander.Content = propertiesPanel;
|
||||||
|
container.Children.Add(expander);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_components.Clear();
|
||||||
|
_activeCustomEditors.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/Editor/Ghost.Editor.Core/Inspector/PropertyDescriptor.cs
Normal file
120
src/Editor/Ghost.Editor.Core/Inspector/PropertyDescriptor.cs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
using Ghost.Core.Attributes;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a single editable field within an ECS component.
|
||||||
|
/// Knows how to read/write a specific field directly from/to unmanaged memory.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PropertyDescriptor
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public string DisplayName { get; }
|
||||||
|
public Type ValueType { get; }
|
||||||
|
public int OffsetInComponent { get; }
|
||||||
|
public bool IsReadOnly { get; }
|
||||||
|
|
||||||
|
// For nested structs (e.g. float4x4 -> float4 -> float)
|
||||||
|
public PropertyDescriptor[]? Children { get; }
|
||||||
|
|
||||||
|
// TODO: Use source generators to build these at compile time and avoid all reflection/attributes at runtime.
|
||||||
|
internal PropertyDescriptor(FieldInfo fieldInfo, int parentOffset)
|
||||||
|
{
|
||||||
|
Name = fieldInfo.Name;
|
||||||
|
ValueType = fieldInfo.FieldType;
|
||||||
|
OffsetInComponent = parentOffset + (int)Marshal.OffsetOf(fieldInfo.DeclaringType!, fieldInfo.Name);
|
||||||
|
|
||||||
|
IsReadOnly = fieldInfo.GetCustomAttribute<ReadOnlyInInspectorAttribute>() != null;
|
||||||
|
|
||||||
|
var nameAttr = fieldInfo.GetCustomAttribute<InspectorNameAttribute>();
|
||||||
|
DisplayName = nameAttr?.Name ?? FormatName(Name);
|
||||||
|
|
||||||
|
// Handle nested structs if this is an unmanaged struct that is not a primitive or common vector type we have custom drawers for.
|
||||||
|
if (ValueType.IsValueType && !ValueType.IsPrimitive && !ValueType.IsEnum)
|
||||||
|
{
|
||||||
|
if (!PropertyDrawerRegistry.HasCustomDrawer(ValueType))
|
||||||
|
{
|
||||||
|
var children = new List<PropertyDescriptor>();
|
||||||
|
var fields = ValueType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
||||||
|
foreach (var nestedField in fields)
|
||||||
|
{
|
||||||
|
if (!nestedField.IsPublic &&
|
||||||
|
nestedField.GetCustomAttribute<InspectorGroupAttribute>() == null &&
|
||||||
|
nestedField.GetCustomAttribute<ReadOnlyInInspectorAttribute>() == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
children.Add(new PropertyDescriptor(nestedField, OffsetInComponent));
|
||||||
|
}
|
||||||
|
if (children.Count > 0)
|
||||||
|
{
|
||||||
|
Children = children.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal PropertyDescriptor(string name, Type type, int offset, bool isReadOnly, PropertyDescriptor[]? children = null)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
DisplayName = FormatName(name);
|
||||||
|
ValueType = type;
|
||||||
|
OffsetInComponent = offset;
|
||||||
|
IsReadOnly = isReadOnly;
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.StartsWith('_'))
|
||||||
|
{
|
||||||
|
name = name.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Length == 0)
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return char.ToUpperInvariant(name[0]) + name.Substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe object ReadBoxed(void* pComponent)
|
||||||
|
{
|
||||||
|
var src = (byte*)pComponent + OffsetInComponent;
|
||||||
|
return Marshal.PtrToStructure((nint)src, ValueType)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void WriteBoxed(void* pComponent, object value)
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dst = (byte*)pComponent + OffsetInComponent;
|
||||||
|
Marshal.StructureToPtr(value, (nint)dst, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe ref T Read<T>(void* pComponent) where T : unmanaged
|
||||||
|
{
|
||||||
|
return ref *(T*)((byte*)pComponent + OffsetInComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe void Write<T>(void* pComponent, in T value) where T : unmanaged
|
||||||
|
{
|
||||||
|
if (IsReadOnly)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
*(T*)((byte*)pComponent + OffsetInComponent) = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawer.cs
Normal file
25
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawer.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for type-specific property UI factories.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PropertyDrawer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create the UI control bound to the given property node.
|
||||||
|
/// </summary>
|
||||||
|
public abstract FrameworkElement CreateControl(PropertyNode model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class PropertyDrawer<T> : PropertyDrawer where T : unmanaged
|
||||||
|
{
|
||||||
|
public sealed override FrameworkElement CreateControl(PropertyNode model)
|
||||||
|
{
|
||||||
|
return CreateControlT((PropertyNode<T>)model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract FrameworkElement CreateControlT(PropertyNode<T> model);
|
||||||
|
}
|
||||||
122
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawerRegistry.cs
Normal file
122
src/Editor/Ghost.Editor.Core/Inspector/PropertyDrawerRegistry.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector.Drawers;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.Mathematics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Inspector;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Discovers PropertyDrawer subclasses and maps field types to drawers.
|
||||||
|
/// </summary>
|
||||||
|
public static class PropertyDrawerRegistry
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, PropertyDrawer> s_drawers = new();
|
||||||
|
private static bool s_initialized;
|
||||||
|
private static readonly Lock s_lock = new();
|
||||||
|
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
lock (s_lock)
|
||||||
|
{
|
||||||
|
if (s_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register built-in drawers
|
||||||
|
s_drawers[typeof(float)] = NumberBoxDrawer<float>.CreateFloatingPoint();
|
||||||
|
s_drawers[typeof(double)] = NumberBoxDrawer<double>.CreateFloatingPoint();
|
||||||
|
s_drawers[typeof(int)] = NumberBoxDrawer<int>.CreateInteger();
|
||||||
|
s_drawers[typeof(uint)] = NumberBoxDrawer<uint>.CreateInteger();
|
||||||
|
s_drawers[typeof(short)] = NumberBoxDrawer<short>.CreateInteger();
|
||||||
|
s_drawers[typeof(ushort)] = NumberBoxDrawer<ushort>.CreateInteger();
|
||||||
|
s_drawers[typeof(long)] = NumberBoxDrawer<long>.CreateInteger();
|
||||||
|
s_drawers[typeof(ulong)] = NumberBoxDrawer<ulong>.CreateInteger();
|
||||||
|
s_drawers[typeof(sbyte)] = NumberBoxDrawer<sbyte>.CreateInteger();
|
||||||
|
s_drawers[typeof(byte)] = NumberBoxDrawer<byte>.CreateInteger();
|
||||||
|
s_drawers[typeof(bool)] = new ToggleSwitchDrawer();
|
||||||
|
|
||||||
|
s_drawers[typeof(float3)] = new Float3Drawer();
|
||||||
|
|
||||||
|
s_drawers[typeof(Entity)] = new EntityDrawer();
|
||||||
|
|
||||||
|
// Discover user-defined drawers via TypeCache
|
||||||
|
var customDrawers = TypeCache.GetTypesWithAttribute<CustomPropertyDrawerAttribute>();
|
||||||
|
if (customDrawers != null)
|
||||||
|
{
|
||||||
|
foreach (var typeInfo in customDrawers)
|
||||||
|
{
|
||||||
|
var type = typeInfo.AsType();
|
||||||
|
var attr = type.GetCustomAttribute<CustomPropertyDrawerAttribute>();
|
||||||
|
if (attr != null && typeof(PropertyDrawer).IsAssignableFrom(type))
|
||||||
|
{
|
||||||
|
if (Activator.CreateInstance(typeInfo) is PropertyDrawer drawer)
|
||||||
|
{
|
||||||
|
s_drawers[attr.TargetFieldType] = drawer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasCustomDrawer(Type fieldType)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
return s_drawers.ContainsKey(fieldType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyDrawer GetDrawer(Type fieldType)
|
||||||
|
{
|
||||||
|
if (!s_initialized)
|
||||||
|
{
|
||||||
|
Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s_drawers.TryGetValue(fieldType, out var drawer))
|
||||||
|
{
|
||||||
|
return drawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType.IsEnum)
|
||||||
|
{
|
||||||
|
var enumDrawerType = typeof(EnumDrawer<>).MakeGenericType(fieldType);
|
||||||
|
var enumDrawer = (PropertyDrawer)Activator.CreateInstance(enumDrawerType)!;
|
||||||
|
s_drawers[fieldType] = enumDrawer;
|
||||||
|
return enumDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldType.IsGenericType && fieldType.GetGenericTypeDefinition() == typeof(Handle<>))
|
||||||
|
{
|
||||||
|
var argType = fieldType.GetGenericArguments()[0];
|
||||||
|
var handleDrawerType = typeof(HandleDrawer<>).MakeGenericType(argType);
|
||||||
|
var handleDrawer = (PropertyDrawer)Activator.CreateInstance(handleDrawerType)!;
|
||||||
|
s_drawers[fieldType] = handleDrawer;
|
||||||
|
return handleDrawer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown types. If it's an unmanaged struct with fields, we use EmptyDrawer
|
||||||
|
// to let the children render. If it's a primitive or something else, use ReadOnlyDrawer.
|
||||||
|
Type genericDrawerType;
|
||||||
|
if (fieldType.IsValueType && !fieldType.IsPrimitive && !fieldType.IsEnum)
|
||||||
|
{
|
||||||
|
genericDrawerType = typeof(EmptyDrawer<>);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
genericDrawerType = typeof(ReadOnlyDrawer<>);
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawerType = genericDrawerType.MakeGenericType(fieldType);
|
||||||
|
var drawerInstance = (PropertyDrawer)Activator.CreateInstance(drawerType)!;
|
||||||
|
s_drawers[fieldType] = drawerInstance;
|
||||||
|
return drawerInstance;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Resources;
|
|
||||||
|
|
||||||
public static class EditorIconSource
|
|
||||||
{
|
|
||||||
public static readonly IconSource scene_24 = new FontIconSource
|
|
||||||
{
|
|
||||||
Glyph = "\uF159",
|
|
||||||
FontSize = 24
|
|
||||||
};
|
|
||||||
|
|
||||||
public static readonly IconSource entity_24 = new FontIconSource
|
|
||||||
{
|
|
||||||
Glyph = "\uF158",
|
|
||||||
FontSize = 24
|
|
||||||
};
|
|
||||||
}
|
|
||||||
227
src/Editor/Ghost.Editor.Core/SceneGraph/ComponentNode.cs
Normal file
227
src/Editor/Ghost.Editor.Core/SceneGraph/ComponentNode.cs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single component on an entity within the Editor's scene graph.
|
||||||
|
/// Acts as the middleware between the Inspector's PropertyModels and the actual ECS memory.
|
||||||
|
/// </summary>
|
||||||
|
public unsafe class ComponentNode
|
||||||
|
{
|
||||||
|
private readonly IUndoService _undoService;
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, int> _propertyIndices;
|
||||||
|
protected readonly World _world;
|
||||||
|
|
||||||
|
public EntityNode EntityNode { get; }
|
||||||
|
|
||||||
|
public Type ComponentType { get; }
|
||||||
|
public ComponentDescriptor Descriptor { get; }
|
||||||
|
public PropertyNode[] Properties { get; }
|
||||||
|
public string Name => Descriptor.DisplayName;
|
||||||
|
|
||||||
|
internal ComponentNode(World world, EntityNode entityNode, Type componentType, ComponentDescriptor descriptor)
|
||||||
|
{
|
||||||
|
_undoService = EditorApplication.GetService<IUndoService>();
|
||||||
|
_worldService = EditorApplication.GetService<IEditorWorldService>();
|
||||||
|
|
||||||
|
_propertyIndices = new Dictionary<string, int>(descriptor.Properties.Length);
|
||||||
|
_world = world;
|
||||||
|
|
||||||
|
EntityNode = entityNode;
|
||||||
|
|
||||||
|
ComponentType = componentType;
|
||||||
|
Descriptor = descriptor;
|
||||||
|
|
||||||
|
Properties = new PropertyNode[descriptor.Properties.Length];
|
||||||
|
for (var i = 0; i < descriptor.Properties.Length; i++)
|
||||||
|
{
|
||||||
|
_propertyIndices[descriptor.Properties[i].Name] = i;
|
||||||
|
|
||||||
|
// TODO: We should use a registry/factory for different PropertyNode types instead of hardcoding HandlePropertyNode here. This is just a quick solution for handles for now.
|
||||||
|
var prop = descriptor.Properties[i];
|
||||||
|
if (prop.ValueType.IsGenericType && prop.ValueType.GetGenericTypeDefinition() == typeof(Ghost.Core.Handle<>))
|
||||||
|
{
|
||||||
|
var nodeType = typeof(HandlePropertyNode<>).MakeGenericType(prop.ValueType.GetGenericArguments()[0]);
|
||||||
|
Properties[i] = (PropertyNode)Activator.CreateInstance(nodeType, prop, this)!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Create a standard PropertyNode<T> for non-handle types
|
||||||
|
// We use MakeGenericType to create the correct PropertyNode<T> based on FieldType
|
||||||
|
var nodeType = typeof(PropertyNode<>).MakeGenericType(prop.ValueType);
|
||||||
|
Properties[i] = (PropertyNode)Activator.CreateInstance(nodeType, prop, this, null)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPropertyValue<T>(PropertyDescriptor property, T value)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
if (property.ValueType != typeof(T))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Property type does not match value type");
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoService.RecordEntityComponent(this, $"Edit property {property.DisplayName} on {Descriptor.DisplayName}");
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
var ptr = _world.EntityManager.GetSharedComponent(EntityNode.Entity, Descriptor.ComponentId);
|
||||||
|
if (ptr != null)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
System.Runtime.CompilerServices.Unsafe.CopyBlock(buffer.GetUnsafePtr(), ptr, (uint)Descriptor.Size);
|
||||||
|
property.Write(buffer.GetUnsafePtr(), value);
|
||||||
|
_world.EntityManager.SetSharedComponent(EntityNode.Entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
property.Write(pComponent, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetComponent<T>(T value)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
if (typeof(T) != ComponentType)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Value type does not match component type");
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoService.RecordEntityComponent(this, $"Edit component {Descriptor.DisplayName}");
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
buffer.GetElementAt<T>(0) = value;
|
||||||
|
_world.EntityManager.SetSharedComponent(EntityNode.Entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
*(T*)pComponent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public PropertyNode GetProperty(string propertyName)
|
||||||
|
{
|
||||||
|
if (_propertyIndices.TryGetValue(propertyName, out var index))
|
||||||
|
{
|
||||||
|
return Properties[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Property '{propertyName}' not found in component '{Name}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PropertyNode<T> GetProperty<T>(string propertyName)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
var prop = GetProperty(propertyName);
|
||||||
|
if (prop is PropertyNode<T> typedProp)
|
||||||
|
{
|
||||||
|
return typedProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"Property '{propertyName}' is not of type {typeof(T).Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void* GetComponentPointer()
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
return _world.EntityManager.GetSharedComponent(EntityNode.Entity, Descriptor.ComponentId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return _world.EntityManager.GetComponent(EntityNode.Entity, Descriptor.ComponentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public T GetComponent<T>()
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
if (typeof(T) != ComponentType)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Field type does not match component type");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
return *(T*)pComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T GetPropertyValue<T>(PropertyDescriptor field)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
var pComponent = GetComponentPointer();
|
||||||
|
return field.Read<T>(pComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serialize this component to JSON. Base reads from ECS directly.</summary>
|
||||||
|
public virtual void Serialize(Utf8JsonWriter writer, JsonSerializerOptions options, Action<object>? preSerialize = null)
|
||||||
|
{
|
||||||
|
var boxed = System.Runtime.InteropServices.Marshal.PtrToStructure((nint)GetComponentPointer(), ComponentType);
|
||||||
|
if (boxed != null)
|
||||||
|
{
|
||||||
|
preSerialize?.Invoke(boxed);
|
||||||
|
|
||||||
|
var jsonString = JsonSerializer.Serialize(boxed, ComponentType, options);
|
||||||
|
using var doc = JsonDocument.Parse(jsonString);
|
||||||
|
var root = System.Text.Json.Nodes.JsonObject.Create(doc.RootElement);
|
||||||
|
if (root != null)
|
||||||
|
{
|
||||||
|
foreach (var prop in Properties)
|
||||||
|
{
|
||||||
|
prop.SerializeOverride(root, boxed);
|
||||||
|
}
|
||||||
|
root.WriteTo(writer, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonSerializer.Serialize(writer, boxed, ComponentType, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deserialize from JSON and apply to ECS. Base writes to ECS directly.</summary>
|
||||||
|
public virtual void Deserialize(JsonElement element, JsonSerializerOptions options, Action<object>? postDeserialize = null)
|
||||||
|
{
|
||||||
|
var boxed = element.Deserialize(ComponentType, options);
|
||||||
|
if (boxed != null)
|
||||||
|
{
|
||||||
|
postDeserialize?.Invoke(boxed);
|
||||||
|
|
||||||
|
foreach (var prop in Properties)
|
||||||
|
{
|
||||||
|
prop.DeserializeOverride(element, boxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (Descriptor.IsShared)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)Descriptor.Size, 16, scope.AllocationHandle);
|
||||||
|
System.Runtime.InteropServices.Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
_world.EntityManager.SetSharedComponent(EntityNode.Entity, Descriptor.ComponentId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
System.Runtime.InteropServices.Marshal.StructureToPtr(boxed, (nint)GetComponentPointer(), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Entities;
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
@@ -6,9 +7,51 @@ namespace Ghost.Editor.Core.SceneGraph;
|
|||||||
|
|
||||||
public sealed partial class EntityNode : SceneGraphNode
|
public sealed partial class EntityNode : SceneGraphNode
|
||||||
{
|
{
|
||||||
private readonly Entity _entity;
|
public Entity Entity
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
public List<ComponentNode> Components { get; } = new();
|
||||||
|
|
||||||
public Entity Entity => _entity;
|
public SceneNode? SceneNode { get; }
|
||||||
|
|
||||||
|
internal EntityNode(World world, Entity entity, string name, SceneNode? sceneNode)
|
||||||
|
: base(world, name)
|
||||||
|
{
|
||||||
|
Entity = entity;
|
||||||
|
SceneNode = sceneNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SceneNode? GetOwningSceneNode() => SceneNode;
|
||||||
|
|
||||||
|
public void BuildComponents()
|
||||||
|
{
|
||||||
|
Components.Clear();
|
||||||
|
var locationResult = World.EntityManager.GetEntityLocation(Entity);
|
||||||
|
if (!locationResult.IsSuccess)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref World.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
var it = archetype._signature.GetIterator();
|
||||||
|
while (it.Next(out var componentID))
|
||||||
|
{
|
||||||
|
if (ComponentRegistry.s_runtimeIDToType.TryGetValue(componentID, out var type))
|
||||||
|
{
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(new Ghost.Core.Identifier<IComponent>(componentID));
|
||||||
|
if (compInfo.isCleanup)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compDescriptor = Inspector.ComponentDescriptor.Create(type);
|
||||||
|
Components.Add(new ComponentNode(World, this, type, compDescriptor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override IconSource? CreateIcon()
|
public override IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
@@ -20,26 +63,54 @@ public sealed partial class EntityNode : SceneGraphNode
|
|||||||
|
|
||||||
public override UIElement? CreateHeader()
|
public override UIElement? CreateHeader()
|
||||||
{
|
{
|
||||||
return null;
|
var root = new Grid
|
||||||
}
|
|
||||||
|
|
||||||
public override UIElement? CreateInspector()
|
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
ColumnSpacing = 8,
|
||||||
}
|
};
|
||||||
|
|
||||||
public override DataTemplate GetSceneHierarchyTemplate()
|
root.ColumnDefinitions.Add(new ColumnDefinition
|
||||||
{
|
{
|
||||||
var template = @"
|
Width = new GridLength(1, GridUnitType.Star)
|
||||||
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:Key=""EntityTemplate"" x:DataType=""sg:SceneGraphNode"">
|
});
|
||||||
<TreeViewItem AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}"" ItemsSource=""{x:Bind Children, Mode=OneWay}"">
|
root.ColumnDefinitions.Add(new ColumnDefinition
|
||||||
<StackPanel Margin=""10,0"" Orientation=""Horizontal"">
|
{
|
||||||
<FontIcon FontSize=""14"" Glyph="""" />
|
Width = GridLength.Auto,
|
||||||
<TextBlock Margin=""5,0,0,0"" Text=""{x:Bind Name, Mode=OneWay}"" />
|
MinWidth = 20
|
||||||
</StackPanel>
|
});
|
||||||
</TreeViewItem>
|
|
||||||
</DataTemplate>";
|
|
||||||
|
|
||||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
var nameBox = new TextBox
|
||||||
|
{
|
||||||
|
Text = Name,
|
||||||
|
FontSize = 14,
|
||||||
|
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
|
||||||
|
};
|
||||||
|
|
||||||
|
nameBox.SetBinding(TextBox.TextProperty, new Microsoft.UI.Xaml.Data.Binding
|
||||||
|
{
|
||||||
|
Source = this,
|
||||||
|
Path = new PropertyPath(nameof(Name)),
|
||||||
|
Mode = Microsoft.UI.Xaml.Data.BindingMode.TwoWay,
|
||||||
|
UpdateSourceTrigger = Microsoft.UI.Xaml.Data.UpdateSourceTrigger.PropertyChanged
|
||||||
|
});
|
||||||
|
|
||||||
|
var entityBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = $"{Entity.ID}:{Entity.Generation}",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(nameBox, 0);
|
||||||
|
Grid.SetColumn(entityBlock, 1);
|
||||||
|
|
||||||
|
root.Children.Add(nameBox);
|
||||||
|
root.Children.Add(entityBlock);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override IInspectorModel CreateInspectorModel()
|
||||||
|
{
|
||||||
|
return new Inspector.EntityInspectorModel(World, Entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/Editor/Ghost.Editor.Core/SceneGraph/HandlePropertyNode.cs
Normal file
134
src/Editor/Ghost.Editor.Core/SceneGraph/HandlePropertyNode.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public class HandlePropertyNode<T> : PropertyNode<Handle<T>> where T : unmanaged
|
||||||
|
{
|
||||||
|
public Guid AssetGuid { get; private set; } = Guid.Empty;
|
||||||
|
public long ExpectedHandleValue { get; private set; }
|
||||||
|
|
||||||
|
public HandlePropertyNode(PropertyDescriptor descriptor, ComponentNode parent)
|
||||||
|
: base(descriptor, parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetHandleFromAsset(Guid assetGuid)
|
||||||
|
{
|
||||||
|
var assetManager = EditorApplication.GetService<AssetManager>();
|
||||||
|
|
||||||
|
MethodInfo? resolveMethod = null;
|
||||||
|
if (typeof(T).Name == "GPUTexture")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveTexture", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
else if (typeof(T).Name == "Mesh")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveMesh", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
Handle<T> handle = default;
|
||||||
|
if (resolveMethod != null && assetManager != null)
|
||||||
|
{
|
||||||
|
var res = resolveMethod.Invoke(assetManager, new object[] { assetGuid });
|
||||||
|
if (res != null)
|
||||||
|
{
|
||||||
|
handle = (Handle<T>)res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.Error($"No resolve method found for type {typeof(T).Name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetGuid = assetGuid;
|
||||||
|
ExpectedHandleValue = UnsafeGetHandleValue(handle);
|
||||||
|
SetValueFromUI(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearHandle()
|
||||||
|
{
|
||||||
|
AssetGuid = Guid.Empty;
|
||||||
|
ExpectedHandleValue = 0;
|
||||||
|
SetValueFromUI(default);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long UnsafeGetHandleValue(Handle<T> handle)
|
||||||
|
{
|
||||||
|
return System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SerializeOverride(JsonObject jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
if (AssetGuid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var camelCaseName = char.ToLowerInvariant(Descriptor.Name[0]) + Descriptor.Name.Substring(1);
|
||||||
|
if (jsonRoot.ContainsKey(camelCaseName))
|
||||||
|
jsonRoot[camelCaseName] = AssetGuid.ToString();
|
||||||
|
else
|
||||||
|
jsonRoot[Descriptor.Name] = AssetGuid.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeOverride(JsonElement jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
var camelCaseName = char.ToLowerInvariant(Descriptor.Name[0]) + Descriptor.Name.Substring(1);
|
||||||
|
|
||||||
|
if (jsonRoot.TryGetProperty(camelCaseName, out var propElement) || jsonRoot.TryGetProperty(Descriptor.Name, out propElement))
|
||||||
|
{
|
||||||
|
if (propElement.ValueKind == JsonValueKind.String && Guid.TryParse(propElement.GetString(), out var guid) && guid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var assetManager = EditorApplication.GetService<AssetManager>();
|
||||||
|
|
||||||
|
MethodInfo? resolveMethod = null;
|
||||||
|
if (typeof(T).Name == "GPUTexture")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveTexture", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
else if (typeof(T).Name == "Mesh")
|
||||||
|
resolveMethod = typeof(AssetManager).GetMethod("ResolveMesh", BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (resolveMethod != null && assetManager != null)
|
||||||
|
{
|
||||||
|
var handleObj = resolveMethod.Invoke(assetManager, new object[] { guid });
|
||||||
|
if (handleObj != null)
|
||||||
|
{
|
||||||
|
var fieldInfo = boxedComponent.GetType().GetField(Descriptor.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
fieldInfo.SetValue(boxedComponent, handleObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = (Handle<T>)handleObj;
|
||||||
|
var handleValue = System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
|
||||||
|
AssetGuid = guid;
|
||||||
|
ExpectedHandleValue = handleValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Validate(object boxedComponent)
|
||||||
|
{
|
||||||
|
if (AssetGuid != Guid.Empty)
|
||||||
|
{
|
||||||
|
var fieldInfo = boxedComponent.GetType().GetField(Descriptor.Name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
if (fieldInfo != null)
|
||||||
|
{
|
||||||
|
var val = fieldInfo.GetValue(boxedComponent);
|
||||||
|
if (val != null)
|
||||||
|
{
|
||||||
|
var handle = (Handle<T>)val;
|
||||||
|
var currentVal = System.Runtime.CompilerServices.Unsafe.As<Handle<T>, long>(ref handle);
|
||||||
|
|
||||||
|
if (currentVal != ExpectedHandleValue)
|
||||||
|
{
|
||||||
|
Logger.Error($"Handle field '{Descriptor.Name}' was modified externally. Guid tracking cleared.");
|
||||||
|
AssetGuid = Guid.Empty;
|
||||||
|
ExpectedHandleValue = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNode.cs
Normal file
86
src/Editor/Ghost.Editor.Core/SceneGraph/PropertyNode.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single property/field within a ComponentNode.
|
||||||
|
/// Handles ECS reading/writing as well as serialization overrides (like Guid metadata).
|
||||||
|
/// </summary>
|
||||||
|
public abstract class PropertyNode
|
||||||
|
{
|
||||||
|
public PropertyDescriptor Descriptor { get; }
|
||||||
|
public ComponentNode ComponentNode { get; }
|
||||||
|
public PropertyNode[]? Children { get; protected set; }
|
||||||
|
|
||||||
|
protected PropertyNode(PropertyDescriptor descriptor, ComponentNode parent)
|
||||||
|
{
|
||||||
|
Descriptor = descriptor;
|
||||||
|
ComponentNode = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronize the cached value from the ECS backend.
|
||||||
|
/// </summary>
|
||||||
|
public abstract void Sync();
|
||||||
|
|
||||||
|
public virtual void SerializeOverride(JsonObject jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void DeserializeOverride(JsonElement jsonRoot, object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void Validate(object boxedComponent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PropertyNode<T> : PropertyNode
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
private T _value;
|
||||||
|
public T Value => _value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event fired when the value is updated from ECS. UI controls bind to this.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<T>? OnValueChanged;
|
||||||
|
|
||||||
|
public PropertyNode(PropertyDescriptor descriptor, ComponentNode parent, PropertyNode[]? children = null)
|
||||||
|
: base(descriptor, parent)
|
||||||
|
{
|
||||||
|
_value = parent.GetPropertyValue<T>(descriptor);
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Sync()
|
||||||
|
{
|
||||||
|
var newValue = ComponentNode.GetPropertyValue<T>(Descriptor);
|
||||||
|
|
||||||
|
if (!EqualityComparer<T>.Default.Equals(_value, newValue))
|
||||||
|
{
|
||||||
|
_value = newValue;
|
||||||
|
OnValueChanged?.Invoke(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Children != null)
|
||||||
|
{
|
||||||
|
foreach (var child in Children)
|
||||||
|
{
|
||||||
|
child.Sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the UI when the user edits the value.
|
||||||
|
/// </summary>
|
||||||
|
public void SetValueFromUI(T newValue)
|
||||||
|
{
|
||||||
|
_value = newValue;
|
||||||
|
ComponentNode.SetPropertyValue(Descriptor, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Architecture Plan: Scene Graph and Scene Representation
|
|
||||||
|
|
||||||
The Scene Graph is a hierarchical structure that represents all the objects and entities within a 3D scene in the Ghost Editor.
|
|
||||||
|
|
||||||
## Scene Graph (Editor representation of runtime data)
|
|
||||||
|
|
||||||
There should be three main types of nodes in the Scene Graph for now:
|
|
||||||
|
|
||||||
1. **Scene Graph Node**: The base class for all nodes in the Scene Graph.
|
|
||||||
2. **Entity Node**: Represents an individual entity within a scene. Name stored here, not runtime component.
|
|
||||||
3. **Scene Node**: Represents a Scene object, which can contain multiple entities. Name stored here not runtime data.
|
|
||||||
|
|
||||||
### Editor World
|
|
||||||
|
|
||||||
Editor contains a different world compares to the runtime world. When user click the Play button, we will create a runtime world and load the scene data from the editor world to the runtime world.
|
|
||||||
This allows us to
|
|
||||||
|
|
||||||
1. Unload the runtime only systems like physics, rendering, etc when user stop playing.
|
|
||||||
2. Load editor only systems like gizmos, debug, etc when user stop playing.
|
|
||||||
3. Allow editor only entities like editor camera, editor lights, etc to exist in the editor world without affecting the runtime world.
|
|
||||||
|
|
||||||
### Editor Hierarchy
|
|
||||||
|
|
||||||
The Scene Graph should be represented as a tree structure in the editor (TreeView in WinUI 3), where:
|
|
||||||
|
|
||||||
- The top level nodes represents the loaded Scenes in the editor world.
|
|
||||||
- Levels below the Scene nodes represents the Entity nodes that belong to that scene.
|
|
||||||
- Each Entity node can have child Entity nodes representing parent-child relationships between entities.
|
|
||||||
|
|
||||||
An example hierarchy could look like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
- Scene 1
|
|
||||||
- Entity A
|
|
||||||
- Entity B
|
|
||||||
- Entity C
|
|
||||||
- Scene 2
|
|
||||||
- Entity D
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scene (The runtime representation)
|
|
||||||
|
|
||||||
A Scene is a collection of entities with SceneID component from a world that are grouped together. There can be multiple scenes in a world.
|
|
||||||
|
|
||||||
### Save a Scene
|
|
||||||
|
|
||||||
When save a scene, all entities with the SceneID component matching the scene's ID should be included in the saved data.
|
|
||||||
When an Entity references another Entity in the same scene, we should store the file local id instead of the global entity id.
|
|
||||||
For example, if Entity A (id: 10, 5th in scene) references Entity B (id: 20, 50th in scene) in the same scene, in the saved data for Entity A,
|
|
||||||
we should store 50 (the file local id) as the reference to Entity B instead of 20 (the global entity id).
|
|
||||||
|
|
||||||
> We does not allow cross-scene references for now because ideally it's not a good practice to have cross-scene references.
|
|
||||||
> We can use query or singleton pattern to access entities from other scenes if needed because they are in the same world.
|
|
||||||
|
|
||||||
### Load a Scene
|
|
||||||
|
|
||||||
When loading a scene, we need to reconstruct the entities and their relationships based on the saved data.
|
|
||||||
|
|
||||||
1. We allocate the entities in the world and assign them new global entity IDs.
|
|
||||||
2. We remap the file local IDs to the new global entity IDs and change the references accordingly.
|
|
||||||
For example if Entity A (file local id: 5) references Entity B (file local id: 50) in the saved data,
|
|
||||||
we need to find the new global entity IDs for both entities after loading and update the reference in Entity A to point to the new global entity ID of Entity B.
|
|
||||||
|
|
||||||
### Data format
|
|
||||||
|
|
||||||
The scene data should be stored in a structured format (JSON and binary) that includes:
|
|
||||||
|
|
||||||
- List of entities with their components and properties (Entities must in the order that file local id directly maps to the index in the list)
|
|
||||||
- References between entities using file local IDs
|
|
||||||
|
|
||||||
> The name of the saved scene file should match the name of the scene node in the editor.
|
|
||||||
|
|
||||||
JSON should only be used in the editor and JSON serialization/deserialization logic should also only exist in the editor codebase (Ghost.Editor.Core). Reflection is allowed here.
|
|
||||||
Binary format should be used in the runtime for better performance. The runtime codebase (Ghost.Engine) must be aot compatible.
|
|
||||||
|
|
||||||
Currently we strict the IComponent to must be unmanaged and blittable types.
|
|
||||||
However, we also support ManagedEntity and ManagedEntityRef with ScriptComponent to allow OOP like logic for common gameplay logic that DOD pattern is not suitable for.
|
|
||||||
Serializing/deserializing with those components will be tricky. We can use MemoryPack (already installed) for binary serialization/deserialization because it supports both unmanaged and managed types.
|
|
||||||
|
|
||||||
## What need to implement
|
|
||||||
|
|
||||||
- [ ] Scene type for the runtime representation if needed
|
|
||||||
- [ ] Scene Graph data structures (SceneNode, EntityNode)
|
|
||||||
- [ ] Editor World management (loading/unloading scenes, managing entities)
|
|
||||||
- [ ] Scene saving/loading logic with file local ID remapping
|
|
||||||
- [ ] Serialization/deserialization logic for scene data (JSON for editor, binary for runtime)
|
|
||||||
- [ ] UI integration for displaying and managing the Scene Graph in the editor with WinUI 3 TreeView
|
|
||||||
162
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
162
src/Editor/Ghost.Editor.Core/SceneGraph/SceneGraphBuilder.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
|
public static class SceneGraphBuilder
|
||||||
|
{
|
||||||
|
public static List<SceneNode> Build(World world, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
var sceneNodes = new List<SceneNode>();
|
||||||
|
var sceneEntities = GroupEntitiesByScene(world);
|
||||||
|
|
||||||
|
foreach (var (scene, entities) in sceneEntities)
|
||||||
|
{
|
||||||
|
var sceneName = GetDefaultSceneName(scene);
|
||||||
|
var sceneNode = new SceneNode(world, new Scene(scene), sceneName);
|
||||||
|
BuildEntityTree(entities, sceneNode, initialNames);
|
||||||
|
sceneNodes.Add(sceneNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sceneNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<ushort, List<Entity>> GroupEntitiesByScene(World world)
|
||||||
|
{
|
||||||
|
var sceneMap = new Dictionary<ushort, List<Entity>>();
|
||||||
|
var queryID = new QueryBuilder().WithAll<SceneID>().Build(world);
|
||||||
|
ref var query = ref world.ComponentManager.GetEntityQueryReference(queryID);
|
||||||
|
|
||||||
|
foreach (var chunk in query.GetChunkIterator())
|
||||||
|
{
|
||||||
|
var entities = chunk.GetEntities();
|
||||||
|
var scene = chunk.GetSharedComponent<SceneID>();
|
||||||
|
|
||||||
|
if (scene.value == Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < chunk.EntityCount; i++)
|
||||||
|
{
|
||||||
|
if (!sceneMap.TryGetValue(scene.value, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
sceneMap[scene.value] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entities[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sceneMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildEntityTree(List<Entity> entities, SceneGraphNode parentNode, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
var entitySet = new HashSet<Entity>(entities);
|
||||||
|
var childrenByParent = new Dictionary<Entity, List<Entity>>();
|
||||||
|
var roots = new List<Entity>();
|
||||||
|
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
Hierarchy hierarchy = default;
|
||||||
|
var hasHierarchy = TryGetHierarchyComponent(parentNode.World, entity, ref hierarchy);
|
||||||
|
|
||||||
|
if (hasHierarchy && hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(hierarchy.parent, out var list))
|
||||||
|
{
|
||||||
|
list = new List<Entity>();
|
||||||
|
childrenByParent[hierarchy.parent] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rootEntity in roots)
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(rootEntity, out var n) ? n : "Entity";
|
||||||
|
var entityNode = new EntityNode(parentNode.World, rootEntity, name, parentNode.GetOwningSceneNode());
|
||||||
|
parentNode.Children.Add(entityNode);
|
||||||
|
BuildSubtree(entityNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BuildSubtree(EntityNode parentNode, Dictionary<Entity, List<Entity>> childrenByParent, Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
if (!childrenByParent.TryGetValue(parentNode.Entity, out var childList))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Hierarchy parentHierarchy = default;
|
||||||
|
if (!TryGetHierarchyComponent(parentNode.World, parentNode.Entity, ref parentHierarchy))
|
||||||
|
{
|
||||||
|
foreach (var childEntity in childList)
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(childEntity, out var n) ? n : "Entity";
|
||||||
|
var childNode = new EntityNode(parentNode.World, childEntity, name, parentNode.SceneNode);
|
||||||
|
parentNode.Children.Add(childNode);
|
||||||
|
BuildSubtree(childNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sibling = parentHierarchy.firstChild;
|
||||||
|
while (sibling.IsValid)
|
||||||
|
{
|
||||||
|
if (childList.Contains(sibling))
|
||||||
|
{
|
||||||
|
var name = initialNames != null && initialNames.TryGetValue(sibling, out var n) ? n : "Entity";
|
||||||
|
var childNode = new EntityNode(parentNode.World, sibling, name, parentNode.SceneNode);
|
||||||
|
parentNode.Children.Add(childNode);
|
||||||
|
BuildSubtree(childNode, childrenByParent, initialNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
Hierarchy siblingHierarchy = default;
|
||||||
|
if (!TryGetHierarchyComponent(parentNode.World, sibling, ref siblingHierarchy))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
sibling = siblingHierarchy.nextSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe bool TryGetHierarchyComponent(World world, Entity entity, ref Hierarchy hierarchy)
|
||||||
|
{
|
||||||
|
var location = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (!location.IsSuccess)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.Value.archetypeID);
|
||||||
|
var hierarchyID = ComponentTypeID<Hierarchy>.Value;
|
||||||
|
if (!archetype.HasComponent(hierarchyID))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var pData = archetype.GetComponentData(location.Value.chunkIndex, location.Value.rowIndex, hierarchyID);
|
||||||
|
if (pData == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hierarchy = *(Hierarchy*)pData;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetDefaultSceneName(ushort sceneID)
|
||||||
|
{
|
||||||
|
return $"NewScene ({sceneID})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.SceneGraph;
|
namespace Ghost.Editor.Core.SceneGraph;
|
||||||
|
|
||||||
public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
[ObservableObject]
|
||||||
|
public abstract partial class SceneGraphNode : GhostObject, IInspectable
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Name
|
public partial string Name
|
||||||
@@ -14,14 +17,89 @@ public abstract partial class SceneGraphNode : ObservableObject, IInspectable
|
|||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public World World
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneGraphNode? Parent
|
||||||
|
{
|
||||||
|
get; internal set;
|
||||||
|
}
|
||||||
|
|
||||||
public ObservableCollection<SceneGraphNode> Children
|
public ObservableCollection<SceneGraphNode> Children
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
} = new();
|
} = new();
|
||||||
|
|
||||||
public abstract IconSource? CreateIcon();
|
protected SceneGraphNode(World world, string name)
|
||||||
public abstract UIElement? CreateHeader();
|
{
|
||||||
public abstract UIElement? CreateInspector();
|
World = world;
|
||||||
|
Name = name;
|
||||||
public abstract DataTemplate GetSceneHierarchyTemplate();
|
Children.CollectionChanged += OnChildrenChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OldItems != null)
|
||||||
|
{
|
||||||
|
foreach (SceneGraphNode oldItem in e.OldItems)
|
||||||
|
{
|
||||||
|
if (oldItem.Parent == this)
|
||||||
|
{
|
||||||
|
oldItem.Parent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.NewItems != null)
|
||||||
|
{
|
||||||
|
foreach (SceneGraphNode newItem in e.NewItems)
|
||||||
|
{
|
||||||
|
newItem.Parent = this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual SceneNode? GetOwningSceneNode()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Modify()
|
||||||
|
{
|
||||||
|
base.Modify(); // Marks this node dirty via base GhostObject logic
|
||||||
|
|
||||||
|
var sceneNode = GetOwningSceneNode();
|
||||||
|
if (sceneNode != null)
|
||||||
|
{
|
||||||
|
var worldService = EditorApplication.GetService<IEditorWorldService>();
|
||||||
|
var sceneAsset = worldService.GetAssetForScene(sceneNode.Scene.ID);
|
||||||
|
if (sceneAsset != null)
|
||||||
|
{
|
||||||
|
EditorApplication.GetService<IDirtyTrackerService>().MarkDirty(sceneAsset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SerializeState(BinaryWriter writer)
|
||||||
|
{
|
||||||
|
writer.Write(Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DeserializeState(BinaryReader reader)
|
||||||
|
{
|
||||||
|
Name = reader.ReadString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual IconSource? CreateIcon()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual UIElement? CreateHeader()
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract IInspectorModel CreateInspectorModel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
@@ -5,6 +8,19 @@ namespace Ghost.Editor.Core.SceneGraph;
|
|||||||
|
|
||||||
public sealed partial class SceneNode : SceneGraphNode
|
public sealed partial class SceneNode : SceneGraphNode
|
||||||
{
|
{
|
||||||
|
public Scene Scene
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal SceneNode(World world, Scene scene, string name)
|
||||||
|
: base(world, name)
|
||||||
|
{
|
||||||
|
Scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SceneNode? GetOwningSceneNode() => this;
|
||||||
|
|
||||||
public override IconSource? CreateIcon()
|
public override IconSource? CreateIcon()
|
||||||
{
|
{
|
||||||
return new FontIconSource
|
return new FontIconSource
|
||||||
@@ -13,33 +29,13 @@ public sealed partial class SceneNode : SceneGraphNode
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement custom header and inspector UI for the SceneNode
|
|
||||||
public override UIElement? CreateHeader()
|
public override UIElement? CreateHeader()
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override UIElement? CreateInspector()
|
public override IInspectorModel CreateInspectorModel()
|
||||||
{
|
{
|
||||||
return null;
|
return null!;
|
||||||
}
|
|
||||||
|
|
||||||
public override DataTemplate GetSceneHierarchyTemplate()
|
|
||||||
{
|
|
||||||
var template = @"
|
|
||||||
<DataTemplate xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation"" xmlns:sg=""using:Ghost.Editor.Core.SceneGraph"" x:DataType=""sg:SceneGraphNode"">
|
|
||||||
<TreeViewItem
|
|
||||||
AutomationProperties.Name=""{x:Bind Name, Mode=OneWay}""
|
|
||||||
Background=""{ThemeResource ControlSolidFillColorDefaultBrush}""
|
|
||||||
IsExpanded=""True""
|
|
||||||
ItemsSource=""{ x:Bind Children, Mode=OneWay}"" >
|
|
||||||
<StackPanel Orientation=""Horizontal"" >
|
|
||||||
<FontIcon FontSize=""14"" Glyph=""""/>
|
|
||||||
<TextBlock Margin=""10,0"" Text=""{ x:Bind Name, Mode=OneWay}""/>
|
|
||||||
</StackPanel>
|
|
||||||
</TreeViewItem>
|
|
||||||
</DataTemplate>";
|
|
||||||
|
|
||||||
return (DataTemplate)Microsoft.UI.Xaml.Markup.XamlReader.Load(template);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +89,7 @@ public sealed partial class AssetCatalog
|
|||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_path ON assets(source_path);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid);
|
CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parent_guid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_type_id ON assets(asset_type_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dependencies (
|
CREATE TABLE IF NOT EXISTS dependencies (
|
||||||
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
from_guid BLOB(16) NOT NULL REFERENCES assets(guid) ON DELETE CASCADE,
|
||||||
@@ -281,6 +282,33 @@ public sealed partial class AssetCatalog
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Guid> EnumerateByTypes(params Guid[] assetTypeIds)
|
||||||
|
{
|
||||||
|
if (assetTypeIds.Length == 0)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
|
||||||
|
var parameterNames = new List<string>(assetTypeIds.Length);
|
||||||
|
for (var i = 0; i < assetTypeIds.Length; i++)
|
||||||
|
{
|
||||||
|
var paramName = $"@typeId{i}";
|
||||||
|
parameterNames.Add(paramName);
|
||||||
|
cmd.Parameters.AddWithValue(paramName, assetTypeIds[i].ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.CommandText = $"SELECT guid FROM assets WHERE asset_type_id IN ({string.Join(", ", parameterNames)})";
|
||||||
|
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
yield return new Guid((byte[])reader[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<SubAssetInfo> GetSubAssets(Guid parentGuid)
|
public List<SubAssetInfo> GetSubAssets(Guid parentGuid)
|
||||||
{
|
{
|
||||||
using var connection = OpenConnection();
|
using var connection = OpenConnection();
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ using Ghost.Core;
|
|||||||
using Ghost.Core.Utilities;
|
using Ghost.Core.Utilities;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Services;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
@@ -188,7 +190,7 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.Error(ex);
|
Logger.Warning($"FileSystemEvent exception: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +423,37 @@ internal sealed class AssetRegistry : IAssetRegistry, IDisposable
|
|||||||
return await Task.WhenAll(tasks);
|
return await Task.WhenAll(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Result> OpenAssetAsync(Guid id)
|
||||||
|
{
|
||||||
|
var path = GetAssetPath(id);
|
||||||
|
if (path == null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure("Asset not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenAssetAsync(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Result> OpenAssetAsync(string assetPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var method = TypeCache.GetMethodsWithAttribute<AssetOpenHandlerAttribute>()?
|
||||||
|
.FirstOrDefault(m => m.GetCustomAttribute<AssetOpenHandlerAttribute>()?.Extensions.Contains(Path.GetExtension(assetPath)) ?? false);
|
||||||
|
|
||||||
|
if (method == null)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure("No handler for this asset type."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Task<Result>)method.Invoke(null, new object[] { assetPath })!;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Task.FromResult(Result.Failure($"Failed to open asset: {ex.Message}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_watcher.Dispose();
|
_watcher.Dispose();
|
||||||
|
|||||||
77
src/Editor/Ghost.Editor.Core/Services/DirtyTrackerService.cs
Normal file
77
src/Editor/Ghost.Editor.Core/Services/DirtyTrackerService.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal class DirtyTrackerService : IDirtyTrackerService
|
||||||
|
{
|
||||||
|
private readonly IUndoService _undoService;
|
||||||
|
private readonly Dictionary<Guid, int> _cleanVersions = new();
|
||||||
|
private readonly HashSet<GhostObject> _trackedObjects = new();
|
||||||
|
|
||||||
|
public DirtyTrackerService(IUndoService undoService)
|
||||||
|
{
|
||||||
|
_undoService = undoService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkDirty(GhostObject obj)
|
||||||
|
{
|
||||||
|
// When marked dirty, we just ensure it is tracked.
|
||||||
|
// Its "clean version" remains whatever it was (or 0 if it was never saved).
|
||||||
|
// If it was never saved and just got modified, its clean version is assumed to be 0 (or something that won't match GlobalVersion).
|
||||||
|
|
||||||
|
if (!_cleanVersions.ContainsKey(obj.InstanceID))
|
||||||
|
{
|
||||||
|
// If we've never seen it, and it's being marked dirty,
|
||||||
|
// its "clean state" is whatever state existed BEFORE this edit (which caused GlobalVersion to increment).
|
||||||
|
// Actually, if it's a brand new edit, UndoService will push an operation and increment GlobalVersion.
|
||||||
|
// If the object was clean at the *current* version before the edit, we should record its clean version as (GlobalVersion - 1),
|
||||||
|
// but since UndoService.RecordObject increments GlobalVersion, the timing matters.
|
||||||
|
// Let's just say its clean version is 0. If GlobalVersion is > 0, it will be dirty.
|
||||||
|
_cleanVersions[obj.InstanceID] = _undoService.GlobalVersion - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_trackedObjects.Add(obj);
|
||||||
|
|
||||||
|
if (obj is IAsset asset)
|
||||||
|
{
|
||||||
|
EditorApplication.GetService<IAssetRegistry>().SetAssetDirty(asset.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsDirty(GhostObject obj)
|
||||||
|
{
|
||||||
|
if (_cleanVersions.TryGetValue(obj.InstanceID, out var cleanVersion))
|
||||||
|
{
|
||||||
|
return cleanVersion != _undoService.GlobalVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not tracked, it's clean.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkClean(GhostObject obj)
|
||||||
|
{
|
||||||
|
_cleanVersions[obj.InstanceID] = _undoService.GlobalVersion;
|
||||||
|
_trackedObjects.Add(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<GhostObject> GetDirtyObjects()
|
||||||
|
{
|
||||||
|
var dirtyObjects = new List<GhostObject>();
|
||||||
|
|
||||||
|
// Remove dead references
|
||||||
|
_trackedObjects.RemoveWhere(obj => GhostObject.Find(obj.InstanceID) == null);
|
||||||
|
|
||||||
|
foreach (var obj in _trackedObjects)
|
||||||
|
{
|
||||||
|
if (IsDirty(obj))
|
||||||
|
{
|
||||||
|
dirtyObjects.Add(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirtyObjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using Ghost.Core;
|
using Ghost.Core;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
|
|
||||||
namespace Ghost.Editor.Core.Services;
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Graphics;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal sealed class EditorShaderCompilerBridge : IShaderCompilationBridge
|
||||||
|
{
|
||||||
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IShaderCompiler _compiler;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<ulong, Guid> _shaderIdToAssetId = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, Dictionary<int, string>[]> _assetKeywordMappings = new();
|
||||||
|
private Task? _shaderDictionaryPopulated;
|
||||||
|
|
||||||
|
public event ShaderVariantCompiledHandler? OnShaderVariantCompiled;
|
||||||
|
public event Action<ulong>? OnShaderInvalidated;
|
||||||
|
|
||||||
|
public EditorShaderCompilerBridge(IAssetRegistry assetRegistry, IServiceProvider serviceProvider, IShaderCompiler shaderCompiler)
|
||||||
|
{
|
||||||
|
_assetRegistry = assetRegistry;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_compiler = shaderCompiler;
|
||||||
|
|
||||||
|
_assetRegistry.OnAssetImported += OnAssetImported;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAssetImported(object? sender, Guid guid)
|
||||||
|
{
|
||||||
|
var path = _assetRegistry.GetAssetPath(guid);
|
||||||
|
if (path != null && (path.EndsWith(".gshdr") || path.EndsWith(".gcomp")))
|
||||||
|
{
|
||||||
|
var result = _assetRegistry.LoadAssetAsync(guid).AsTask().Result;
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var nameHash = ExtractNameHash(result.Value);
|
||||||
|
if (nameHash != 0)
|
||||||
|
{
|
||||||
|
_shaderIdToAssetId[nameHash] = guid;
|
||||||
|
BuildKeywordMappings(result.Value, guid);
|
||||||
|
|
||||||
|
OnShaderInvalidated?.Invoke(nameHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static ulong ExtractNameHash(IAsset asset)
|
||||||
|
{
|
||||||
|
if (asset is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
return RHIUtility.GetShaderID(graphicsAsset.Descriptor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
return RHIUtility.GetShaderID(computeAsset.Descriptor.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task EnsureShaderDictionaryPopulatedAsync()
|
||||||
|
{
|
||||||
|
var existing = Volatile.Read(ref _shaderDictionaryPopulated);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var original = Interlocked.CompareExchange(ref _shaderDictionaryPopulated, tcs.Task, null);
|
||||||
|
if (original != null)
|
||||||
|
{
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var catalog = _assetRegistry.GetAssetCatalog();
|
||||||
|
var assetGuids = catalog.EnumerateByTypes(typeof(GraphicsShaderAsset).GUID, typeof(ComputeShaderAsset).GUID);
|
||||||
|
|
||||||
|
foreach (var assetGuid in assetGuids)
|
||||||
|
{
|
||||||
|
var result = await _assetRegistry.LoadAssetAsync(assetGuid);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
var nameHash = ExtractNameHash(result.Value);
|
||||||
|
if (nameHash != 0)
|
||||||
|
{
|
||||||
|
_shaderIdToAssetId[nameHash] = assetGuid;
|
||||||
|
BuildKeywordMappings(result.Value, assetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tcs.SetResult();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.SetException(ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildKeywordMappings(IAsset asset, Guid assetId)
|
||||||
|
{
|
||||||
|
if (asset is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
var passes = graphicsAsset.Descriptor.Passes;
|
||||||
|
var mappings = new Dictionary<int, string>[passes.Length];
|
||||||
|
for (var i = 0; i < passes.Length; i++)
|
||||||
|
{
|
||||||
|
mappings[i] = BuildKeywordMappingFromGroups(passes[i].keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
_assetKeywordMappings[assetId] = mappings;
|
||||||
|
}
|
||||||
|
else if (asset is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
var entryCount = computeAsset.Descriptor.ShaderCodes.Length;
|
||||||
|
var mappings = new Dictionary<int, string>[entryCount];
|
||||||
|
var sharedMapping = BuildKeywordMappingFromGroups(computeAsset.Descriptor.Keywords);
|
||||||
|
for (var i = 0; i < entryCount; i++)
|
||||||
|
{
|
||||||
|
mappings[i] = sharedMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
_assetKeywordMappings[assetId] = mappings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<int, string> BuildKeywordMappingFromGroups(KeywordsGroup[] groups)
|
||||||
|
{
|
||||||
|
var mapping = new Dictionary<int, string>();
|
||||||
|
var localIndex = 0;
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group.keywords == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.space != KeywordSpace.Local)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kw in group.keywords)
|
||||||
|
{
|
||||||
|
mapping[localIndex++] = kw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[] BuildVariantDefines(LocalKeywordSet keywordMask, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
if (keywordMapping == null || keywordMapping.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var defines = new List<string>(keywordMapping.Count);
|
||||||
|
foreach (var (localIndex, keywordName) in keywordMapping)
|
||||||
|
{
|
||||||
|
if (keywordMask.IsKeywordEnabled(localIndex))
|
||||||
|
{
|
||||||
|
defines.Add(keywordName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defines.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReadOnlySpan<string> CombineDefines(ReadOnlySpan<string> staticDefines, ReadOnlySpan<string> variantDefines)
|
||||||
|
{
|
||||||
|
if (variantDefines.Length == 0)
|
||||||
|
{
|
||||||
|
return staticDefines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staticDefines.Length == 0)
|
||||||
|
{
|
||||||
|
return variantDefines;
|
||||||
|
}
|
||||||
|
|
||||||
|
var combined = new string[staticDefines.Length + variantDefines.Length];
|
||||||
|
staticDefines.CopyTo(combined);
|
||||||
|
variantDefines.CopyTo(combined.AsSpan(staticDefines.Length));
|
||||||
|
return combined;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestCompilation(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask)
|
||||||
|
{
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await EnsureShaderDictionaryPopulatedAsync();
|
||||||
|
|
||||||
|
if (!_shaderIdToAssetId.TryGetValue(shaderId, out var assetId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetResult = await _assetRegistry.LoadAssetAsync(assetId);
|
||||||
|
if (assetResult.IsFailure)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, string>? keywordMapping = null;
|
||||||
|
if (_assetKeywordMappings.TryGetValue(assetId, out var mappings) && passIndex < mappings.Length)
|
||||||
|
{
|
||||||
|
keywordMapping = mappings[passIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetResult.Value is GraphicsShaderAsset graphicsAsset)
|
||||||
|
{
|
||||||
|
var pass = graphicsAsset.Descriptor.Passes[passIndex];
|
||||||
|
await CompileGraphicsPassAsync(shaderId, passIndex, variantKey, keywordMask, pass, graphicsAsset.Descriptor.ShaderModel, keywordMapping);
|
||||||
|
}
|
||||||
|
else if (assetResult.Value is ComputeShaderAsset computeAsset)
|
||||||
|
{
|
||||||
|
await CompileComputePassAsync(shaderId, passIndex, variantKey, keywordMask, computeAsset.Descriptor, passIndex, keywordMapping);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Task CompileGraphicsPassAsync(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask, PassDescriptor descriptor, ShaderModel shaderModel, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
var variantDefines = BuildVariantDefines(keywordMask, keywordMapping);
|
||||||
|
|
||||||
|
var additionalConfig = new ShaderCompilationConfig
|
||||||
|
{
|
||||||
|
defines = variantDefines,
|
||||||
|
model = shaderModel,
|
||||||
|
optimizeLevel = CompilerOptimizeLevel.O3,
|
||||||
|
options = CompilerOption.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var compileResult = _compiler.CompileShaderPass(ref descriptor, ref additionalConfig, AllocationHandle.Persistent);
|
||||||
|
if (compileResult.IsFailure)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to compile graphics shader {shaderId}: {compileResult.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var compiled = compileResult.Value;
|
||||||
|
|
||||||
|
var stageCount = 0;
|
||||||
|
if (compiled.asResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.msResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.psResult.IsCreated)
|
||||||
|
{
|
||||||
|
stageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byteCodes = stackalloc ShaderByteCode[stageCount];
|
||||||
|
var idx = 0;
|
||||||
|
if (compiled.asResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.asResult.GetUnsafePtr(), size = (ulong)compiled.asResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.msResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.msResult.GetUnsafePtr(), size = (ulong)compiled.msResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.psResult.IsCreated)
|
||||||
|
{
|
||||||
|
byteCodes[idx++] = new ShaderByteCode { pCode = (byte*)compiled.psResult.GetUnsafePtr(), size = (ulong)compiled.psResult.Length };
|
||||||
|
}
|
||||||
|
|
||||||
|
OnShaderVariantCompiled?.Invoke(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(byteCodes, stageCount));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unsafe Task CompileComputePassAsync(ulong shaderId, int passIndex, Key64<ShaderVariant> variantKey, LocalKeywordSet keywordMask, ComputeShaderDescriptor descriptor, int entryIndex, Dictionary<int, string>? keywordMapping)
|
||||||
|
{
|
||||||
|
var variantDefines = BuildVariantDefines(keywordMask, keywordMapping);
|
||||||
|
var fullDefines = CombineDefines(descriptor.Defines, variantDefines);
|
||||||
|
|
||||||
|
var code = descriptor.ShaderCodes[entryIndex];
|
||||||
|
var config = new ShaderCompilationConfig
|
||||||
|
{
|
||||||
|
shaderCode = code.code,
|
||||||
|
entryPoint = code.entryPoint,
|
||||||
|
stage = ShaderStage.ComputeShader,
|
||||||
|
defines = fullDefines,
|
||||||
|
model = descriptor.ShaderModel,
|
||||||
|
optimizeLevel = CompilerOptimizeLevel.O3,
|
||||||
|
options = CompilerOption.None
|
||||||
|
};
|
||||||
|
|
||||||
|
var compileResult = _compiler.Compile(ref config, AllocationHandle.Persistent);
|
||||||
|
if (compileResult.IsFailure)
|
||||||
|
{
|
||||||
|
Logger.Error($"Failed to compile compute shader {shaderId}: {compileResult.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bytecodeArray = compileResult.Value;
|
||||||
|
|
||||||
|
var byteCode = new ShaderByteCode
|
||||||
|
{
|
||||||
|
pCode = (byte*)bytecodeArray.GetUnsafePtr(),
|
||||||
|
size = (ulong)bytecodeArray.Length
|
||||||
|
};
|
||||||
|
|
||||||
|
OnShaderVariantCompiled?.Invoke(shaderId, passIndex, variantKey, new ReadOnlySpan<ShaderByteCode>(ref byteCode));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_assetRegistry.OnAssetImported -= OnAssetImported;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/Editor/Ghost.Editor.Core/Services/EditorTickEngine.cs
Normal file
89
src/Editor/Ghost.Editor.Core/Services/EditorTickEngine.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public sealed class EditorTickEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly DispatcherQueueTimer _timer;
|
||||||
|
private bool _isStarted;
|
||||||
|
|
||||||
|
// Time data
|
||||||
|
private TimeData _timeData;
|
||||||
|
private long _startTimestamp;
|
||||||
|
private long _lastFrameTimestamp;
|
||||||
|
|
||||||
|
public event Action? OnSafeZone;
|
||||||
|
public event Action? OnSystemUpdate;
|
||||||
|
public event Action? OnInspectorSync;
|
||||||
|
public event Action? OnFireEvents;
|
||||||
|
|
||||||
|
public EditorTickEngine(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_timer = EditorApplication.DispatcherQueue.CreateTimer();
|
||||||
|
_timer.Interval = TimeSpan.FromMilliseconds(16); // ~60Hz
|
||||||
|
_timer.Tick += OnTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_startTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
_lastFrameTimestamp = _startTimestamp;
|
||||||
|
_timeData = new TimeData();
|
||||||
|
|
||||||
|
_timer.Start();
|
||||||
|
_isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTick(DispatcherQueueTimer sender, object args)
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
var dt = (float)(now - _lastFrameTimestamp) / Stopwatch.Frequency;
|
||||||
|
var elapsed = (double)(now - _startTimestamp) / Stopwatch.Frequency;
|
||||||
|
|
||||||
|
_timeData = new TimeData
|
||||||
|
{
|
||||||
|
FrameCount = _timeData.FrameCount + 1,
|
||||||
|
DeltaTime = dt,
|
||||||
|
ElapsedTime = elapsed
|
||||||
|
};
|
||||||
|
|
||||||
|
_lastFrameTimestamp = now;
|
||||||
|
|
||||||
|
// Safe Zone (Drain Commands & ECB)
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
OnSafeZone?.Invoke();
|
||||||
|
|
||||||
|
// Editor Systems
|
||||||
|
_worldService.EditorWorld.SystemManager.UpdateAll(_timeData);
|
||||||
|
OnSystemUpdate?.Invoke();
|
||||||
|
|
||||||
|
// Inspector Sync
|
||||||
|
OnInspectorSync?.Invoke();
|
||||||
|
|
||||||
|
// Fire Events
|
||||||
|
_worldService.FirePendingEvents();
|
||||||
|
OnFireEvents?.Invoke();
|
||||||
|
|
||||||
|
_worldService.EditorWorld.AdvanceVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isStarted && _timer != null)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
_timer.Tick -= OnTick;
|
||||||
|
_isStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
315
src/Editor/Ghost.Editor.Core/Services/EditorWorldService.cs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Assets;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.Jobs;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public interface IEditorWorldService : IDisposable
|
||||||
|
{
|
||||||
|
World EditorWorld { get; }
|
||||||
|
ObservableCollection<SceneNode> RootNodes { get; }
|
||||||
|
|
||||||
|
event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
event Action<Entity>? EntityDestroyed;
|
||||||
|
event Action<Entity, Entity, Entity>? EntityParentChanged;
|
||||||
|
event Action<Entity, string>? EntityNameChanged;
|
||||||
|
event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
void ChangeEntityScene(Entity entity, ushort sceneID);
|
||||||
|
void CreateDefaultScene();
|
||||||
|
void CreateEntity(string name, ushort sceneID, Entity parent = default);
|
||||||
|
void Defer(Action action);
|
||||||
|
void DestroyEntity(Entity entity);
|
||||||
|
void FirePendingEvents();
|
||||||
|
void FlushCommands();
|
||||||
|
ushort GetEntitySceneID(Entity entity);
|
||||||
|
SceneAsset? GetAssetForScene(ushort sceneID);
|
||||||
|
void RegisterSceneAsset(ushort sceneID, SceneAsset asset);
|
||||||
|
void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null);
|
||||||
|
Error RemoveParent(Entity child);
|
||||||
|
void RenameEntity(Entity entity, string newName);
|
||||||
|
Error SetParent(Entity child, Entity parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class EditorWorldService : IEditorWorldService
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<Action> _deferredActions = new();
|
||||||
|
private readonly ConcurrentQueue<Action> _pendingEvents = new();
|
||||||
|
private readonly ConcurrentDictionary<ushort, SceneAsset> _sceneAssetMap = new();
|
||||||
|
|
||||||
|
public World EditorWorld
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<SceneNode> RootNodes
|
||||||
|
{
|
||||||
|
get;
|
||||||
|
} = new();
|
||||||
|
|
||||||
|
public event Action<Entity, string, ushort>? EntityCreated;
|
||||||
|
public event Action<Entity>? EntityDestroyed;
|
||||||
|
public event Action<Entity, Entity, Entity>? EntityParentChanged; // (child, oldParent, newParent)
|
||||||
|
public event Action<Entity, string>? EntityNameChanged;
|
||||||
|
public event Action? SceneGraphRebuilt;
|
||||||
|
|
||||||
|
public EditorWorldService(JobScheduler? jobScheduler = null)
|
||||||
|
{
|
||||||
|
EditorWorld = World.Create(jobScheduler, 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Defer(Action action)
|
||||||
|
{
|
||||||
|
_deferredActions.Enqueue(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FlushCommands()
|
||||||
|
{
|
||||||
|
while (_deferredActions.TryDequeue(out var action))
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FirePendingEvents()
|
||||||
|
{
|
||||||
|
while (_pendingEvents.TryDequeue(out var evt))
|
||||||
|
{
|
||||||
|
evt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateEntity(string name, ushort sceneID, Entity parent = default)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var entity = EditorWorld.EntityManager.CreateEntity();
|
||||||
|
|
||||||
|
EditorWorld.EntityManager.AddComponent(entity, new Engine.Components.Hierarchy
|
||||||
|
{
|
||||||
|
parent = Entity.Invalid,
|
||||||
|
firstChild = Entity.Invalid,
|
||||||
|
nextSibling = Entity.Invalid
|
||||||
|
});
|
||||||
|
|
||||||
|
EditorWorld.EntityManager.AddSharedComponent(entity, new Engine.Components.SceneID
|
||||||
|
{
|
||||||
|
value = sceneID
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
HierarchyUtility.SetParent(EditorWorld, entity, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(() =>
|
||||||
|
{
|
||||||
|
EntityCreated?.Invoke(entity, name, sceneID);
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
EntityParentChanged?.Invoke(entity, Entity.Invalid, parent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DestroyEntity(Entity entity)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
DestroyEntityRecursive(entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyEntityRecursive(Entity entity)
|
||||||
|
{
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
ref var hierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(entity);
|
||||||
|
var child = hierarchy.firstChild;
|
||||||
|
while (child.IsValid)
|
||||||
|
{
|
||||||
|
ref var childHierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child);
|
||||||
|
var next = childHierarchy.nextSibling;
|
||||||
|
DestroyEntityRecursive(child);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HierarchyUtility.RemoveParent(EditorWorld, entity);
|
||||||
|
EditorWorld.EntityManager.DestroyEntity(entity);
|
||||||
|
_pendingEvents.Enqueue(() => EntityDestroyed?.Invoke(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSceneIDRecursive(Entity entity, ushort sceneID)
|
||||||
|
{
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(entity))
|
||||||
|
{
|
||||||
|
EditorWorld.EntityManager.SetSharedComponent(entity, new Engine.Components.SceneID { value = sceneID });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
ref var hierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(entity);
|
||||||
|
var child = hierarchy.firstChild;
|
||||||
|
while (child.IsValid)
|
||||||
|
{
|
||||||
|
ref var childHierarchy = ref EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child);
|
||||||
|
var next = childHierarchy.nextSibling;
|
||||||
|
UpdateSceneIDRecursive(child, sceneID);
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ChangeEntityScene(Entity entity, ushort sceneID)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
|
||||||
|
UpdateSceneIDRecursive(entity, sceneID);
|
||||||
|
_pendingEvents.Enqueue(() => EntityParentChanged?.Invoke(entity, Entity.Invalid, Entity.Invalid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error SetParent(Entity child, Entity parent)
|
||||||
|
{
|
||||||
|
if (!child.IsValid) return Error.InvalidArgument;
|
||||||
|
|
||||||
|
Error err = Error.None;
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
err = HierarchyUtility.IsValidParent(EditorWorld, child, parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(child))
|
||||||
|
{
|
||||||
|
err = Error.NotFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err != Error.None)
|
||||||
|
{
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var oldParent = Entity.Invalid;
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.Hierarchy>(child))
|
||||||
|
{
|
||||||
|
oldParent = EditorWorld.EntityManager.GetComponent<Engine.Components.Hierarchy>(child).parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.IsValid)
|
||||||
|
{
|
||||||
|
HierarchyUtility.SetParent(EditorWorld, child, parent);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
HierarchyUtility.RemoveParent(EditorWorld, child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.IsValid && EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(parent))
|
||||||
|
{
|
||||||
|
var locRes = EditorWorld.EntityManager.GetEntityLocation(parent);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref EditorWorld.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
var chunkView = new ChunkView(in archetype, in chunk);
|
||||||
|
var parentSceneID = chunkView.GetSharedComponent<Engine.Components.SceneID>().value;
|
||||||
|
UpdateSceneIDRecursive(child, parentSceneID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingEvents.Enqueue(() => EntityParentChanged?.Invoke(child, oldParent, parent));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Error.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Error RemoveParent(Entity child)
|
||||||
|
{
|
||||||
|
return SetParent(child, Entity.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort GetEntitySceneID(Entity entity)
|
||||||
|
{
|
||||||
|
if (!entity.IsValid)
|
||||||
|
{
|
||||||
|
return Scene.INVALID_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EditorWorld.EntityManager.HasComponent<Engine.Components.SceneID>(entity))
|
||||||
|
{
|
||||||
|
var locRes = EditorWorld.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref EditorWorld.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
var chunkView = new ChunkView(in archetype, in chunk);
|
||||||
|
return chunkView.GetSharedComponent<Engine.Components.SceneID>().value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scene.INVALID_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SceneAsset? GetAssetForScene(ushort sceneID)
|
||||||
|
{
|
||||||
|
_sceneAssetMap.TryGetValue(sceneID, out var asset);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RegisterSceneAsset(ushort sceneID, SceneAsset asset)
|
||||||
|
{
|
||||||
|
_sceneAssetMap[sceneID] = asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RenameEntity(Entity entity, string newName)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
if (!entity.IsValid) return;
|
||||||
|
_pendingEvents.Enqueue(() => EntityNameChanged?.Invoke(entity, newName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CreateDefaultScene()
|
||||||
|
{
|
||||||
|
var scene = SceneManager.CreateScene();
|
||||||
|
CreateEntity("Entity", scene.ID);
|
||||||
|
}
|
||||||
|
public void RebuildSceneGraph(Dictionary<Entity, string>? initialNames = null)
|
||||||
|
{
|
||||||
|
Defer(() =>
|
||||||
|
{
|
||||||
|
var sceneNodes = SceneGraphBuilder.Build(EditorWorld, initialNames);
|
||||||
|
_pendingEvents.Enqueue(() =>
|
||||||
|
{
|
||||||
|
RootNodes.Clear();
|
||||||
|
foreach (var node in sceneNodes)
|
||||||
|
{
|
||||||
|
RootNodes.Add(node);
|
||||||
|
}
|
||||||
|
SceneGraphRebuilt?.Invoke();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
World.Destroy(EditorWorld.ID);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,16 @@ internal sealed partial class ImportCoordinator : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
|
public ValueTask EnqueueAsync(ImportJob job, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return _importChannel.Writer.WriteAsync(job, token);
|
return _importChannel.Writer.WriteAsync(job, token);
|
||||||
}
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task WorkerLoop(CancellationToken token)
|
private async Task WorkerLoop(CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -209,6 +216,15 @@ internal sealed partial class ImportCoordinator : IDisposable
|
|||||||
{
|
{
|
||||||
_importChannel.Writer.TryComplete();
|
_importChannel.Writer.TryComplete();
|
||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Task.WaitAll(_workers);
|
||||||
|
}
|
||||||
|
catch (AggregateException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Syncs the inspector model from ECS data on every editor tick (Phase 3).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InspectorSyncService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly EditorTickEngine _tickEngine;
|
||||||
|
private ISyncableInspectorModel? _activeModel;
|
||||||
|
private bool _isStarted;
|
||||||
|
|
||||||
|
public InspectorSyncService(EditorTickEngine tickEngine)
|
||||||
|
{
|
||||||
|
_tickEngine = tickEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_tickEngine.OnInspectorSync += OnInspectorSync;
|
||||||
|
_isStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind(ISyncableInspectorModel model)
|
||||||
|
{
|
||||||
|
_activeModel = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unbind()
|
||||||
|
{
|
||||||
|
_activeModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInspectorSync()
|
||||||
|
{
|
||||||
|
if (_activeModel == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeModel.Sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_isStarted)
|
||||||
|
{
|
||||||
|
_tickEngine.OnInspectorSync -= OnInspectorSync;
|
||||||
|
_isStarted = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
192
src/Editor/Ghost.Editor.Core/Services/SceneGraphSyncService.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal class SceneGraphSyncService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly Dictionary<Entity, EntityNode> _nodeMap = new();
|
||||||
|
|
||||||
|
public SceneGraphSyncService(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
|
||||||
|
_worldService.EntityCreated += OnEntityCreated;
|
||||||
|
_worldService.EntityDestroyed += OnEntityDestroyed;
|
||||||
|
_worldService.EntityParentChanged += OnEntityParentChanged;
|
||||||
|
_worldService.EntityNameChanged += OnEntityNameChanged;
|
||||||
|
_worldService.SceneGraphRebuilt += OnSceneGraphRebuilt;
|
||||||
|
|
||||||
|
// Initialize node map from current root nodes
|
||||||
|
OnSceneGraphRebuilt();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetNode(Entity entity, out EntityNode node)
|
||||||
|
{
|
||||||
|
return _nodeMap.TryGetValue(entity, out node!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_worldService.EntityCreated -= OnEntityCreated;
|
||||||
|
_worldService.EntityDestroyed -= OnEntityDestroyed;
|
||||||
|
_worldService.EntityParentChanged -= OnEntityParentChanged;
|
||||||
|
_worldService.EntityNameChanged -= OnEntityNameChanged;
|
||||||
|
_worldService.SceneGraphRebuilt -= OnSceneGraphRebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSceneGraphRebuilt()
|
||||||
|
{
|
||||||
|
_nodeMap.Clear();
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
PopulateNodeMapRecursive(sceneNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateNodeMapRecursive(SceneGraphNode node)
|
||||||
|
{
|
||||||
|
if (node is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_nodeMap[entityNode.Entity] = entityNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
PopulateNodeMapRecursive(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityCreated(Entity entity, string name, ushort sceneID)
|
||||||
|
{
|
||||||
|
if (_nodeMap.ContainsKey(entity))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default, add to the scene's root collection
|
||||||
|
var sceneNode = FindOrCreateSceneNode(sceneID);
|
||||||
|
|
||||||
|
var node = new EntityNode(_worldService.EditorWorld, entity, name, sceneNode);
|
||||||
|
_nodeMap[entity] = node;
|
||||||
|
|
||||||
|
sceneNode.Children.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityDestroyed(Entity entity)
|
||||||
|
{
|
||||||
|
if (!_nodeMap.TryGetValue(entity, out var node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively remove from node map
|
||||||
|
RemoveNodeAndDescendantsRecursive(node);
|
||||||
|
|
||||||
|
// Remove from its parent's Children collection (or from RootNodes if it was a scene's root entity)
|
||||||
|
RemoveNodeFromParent(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveNodeFromParent(EntityNode node)
|
||||||
|
{
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
if (sceneNode.Children.Remove(node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveNodeFromChildrenRecursive(sceneNode.Children, node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RemoveNodeFromChildrenRecursive(System.Collections.ObjectModel.ObservableCollection<SceneGraphNode> children, EntityNode target)
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
if (child.Children.Remove(target))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RemoveNodeFromChildrenRecursive(child.Children, target))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveNodeAndDescendantsRecursive(EntityNode node)
|
||||||
|
{
|
||||||
|
_nodeMap.Remove(node.Entity);
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
if (child is EntityNode childEntityNode)
|
||||||
|
{
|
||||||
|
RemoveNodeAndDescendantsRecursive(childEntityNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityParentChanged(Entity child, Entity oldParent, Entity newParent)
|
||||||
|
{
|
||||||
|
if (!_nodeMap.TryGetValue(child, out var childNode))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from the old parent collection (wherever it currently is)
|
||||||
|
RemoveNodeFromParent(childNode);
|
||||||
|
|
||||||
|
// Add to the new parent collection (prepend at index 0 to match HierarchyUtility firstChild behavior)
|
||||||
|
if (newParent.IsValid && _nodeMap.TryGetValue(newParent, out var newParentNode))
|
||||||
|
{
|
||||||
|
newParentNode.Children.Insert(0, childNode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Add to the scene's root collection
|
||||||
|
if (_worldService.EditorWorld.EntityManager.HasComponent<SceneID>(child))
|
||||||
|
{
|
||||||
|
var sceneID = _worldService.GetEntitySceneID(child);
|
||||||
|
if (sceneID != Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
var sceneNode = FindOrCreateSceneNode(sceneID);
|
||||||
|
sceneNode.Children.Insert(0, childNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEntityNameChanged(Entity entity, string newName)
|
||||||
|
{
|
||||||
|
if (_nodeMap.TryGetValue(entity, out var node))
|
||||||
|
{
|
||||||
|
node.Name = newName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SceneNode FindOrCreateSceneNode(ushort sceneID)
|
||||||
|
{
|
||||||
|
foreach (var existing in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
if (existing.Scene.ID == sceneID)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sceneName = $"NewScene ({sceneID})";
|
||||||
|
var newSceneNode = new SceneNode(_worldService.EditorWorld, new Scene(sceneID), sceneName);
|
||||||
|
_worldService.RootNodes.Add(newSceneNode);
|
||||||
|
return newSceneNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Components;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
|
using Misaki.HighPerformance.LowLevel.Collections;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
internal sealed class SceneSaveData
|
||||||
|
{
|
||||||
|
public uint FormatVersion
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = 1;
|
||||||
|
|
||||||
|
public List<EntitySaveData> Entities
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EntitySaveData
|
||||||
|
{
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = "Entity";
|
||||||
|
|
||||||
|
public Dictionary<string, JsonElement> Components
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
} = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Serialize shared components.
|
||||||
|
internal class SceneSerializationService : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<Type, FieldInfo[]> s_entityFieldsCache = new();
|
||||||
|
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||||
|
{
|
||||||
|
IncludeFields = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
Converters = { new EntityJsonConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class EntityJsonConverter : JsonConverter<Entity>
|
||||||
|
{
|
||||||
|
public override Entity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var localId = reader.GetInt32();
|
||||||
|
return new Entity(localId, localId >= 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, Entity value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteNumberValue(value.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly IAssetRegistry _assetRegistry;
|
||||||
|
private readonly SceneGraphSyncService _syncService;
|
||||||
|
|
||||||
|
public SceneSerializationService(IEditorWorldService worldService, IAssetRegistry assetRegistry, SceneGraphSyncService syncService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
_assetRegistry = assetRegistry;
|
||||||
|
_syncService = syncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static int FileLocalIndexOf(Dictionary<Entity, int> reverseMap, Entity entity)
|
||||||
|
{
|
||||||
|
if (reverseMap.TryGetValue(entity, out var index))
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FieldInfo[] GetEntityFields(Type type)
|
||||||
|
{
|
||||||
|
if (!s_entityFieldsCache.TryGetValue(type, out var fields))
|
||||||
|
{
|
||||||
|
var list = new List<FieldInfo>();
|
||||||
|
foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
if (field.FieldType == typeof(Entity))
|
||||||
|
{
|
||||||
|
list.Add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = list.ToArray();
|
||||||
|
s_entityFieldsCache[type] = fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemapEntityFieldsToLocal(object boxed, Type type, Dictionary<Entity, int> reverseMap)
|
||||||
|
{
|
||||||
|
var entityFields = GetEntityFields(type);
|
||||||
|
foreach (var field in entityFields)
|
||||||
|
{
|
||||||
|
var entity = (Entity)field.GetValue(boxed)!;
|
||||||
|
var localIndex = FileLocalIndexOf(reverseMap, entity);
|
||||||
|
field.SetValue(boxed, new Entity(localIndex, localIndex >= 0 ? 0 : -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemapLocalFieldsToEntity(object boxed, Type type, Dictionary<int, Entity> forwardMap)
|
||||||
|
{
|
||||||
|
var entityFields = GetEntityFields(type);
|
||||||
|
foreach (var field in entityFields)
|
||||||
|
{
|
||||||
|
var localAsEntity = (Entity)field.GetValue(boxed)!;
|
||||||
|
var localIndex = localAsEntity.ID;
|
||||||
|
if (!forwardMap.TryGetValue(localIndex, out var entity))
|
||||||
|
{
|
||||||
|
entity = Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
field.SetValue(boxed, entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Binary Serialization
|
||||||
|
|
||||||
|
private static uint GetTypeNameHash(string typeName)
|
||||||
|
{
|
||||||
|
var hash = 2166136261u;
|
||||||
|
for (var i = 0; i < typeName.Length; i++)
|
||||||
|
{
|
||||||
|
var c = typeName[i];
|
||||||
|
hash ^= c;
|
||||||
|
hash *= 16777619u;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static unsafe void SerializeToBinary(SceneSaveData data, Stream targetStream)
|
||||||
|
{
|
||||||
|
using var writer = new BinaryWriter(targetStream, Encoding.UTF8, true);
|
||||||
|
|
||||||
|
var header = new SceneContentHeader
|
||||||
|
{
|
||||||
|
magic = SceneContentHeader.MAGIC,
|
||||||
|
version = SceneContentHeader.VERSION,
|
||||||
|
entityCount = data.Entities.Count,
|
||||||
|
};
|
||||||
|
|
||||||
|
writer.Write(MemoryMarshal.AsBytes(new ReadOnlySpan<SceneContentHeader>(ref header)));
|
||||||
|
|
||||||
|
if (data.Entities == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entity in data.Entities)
|
||||||
|
{
|
||||||
|
if (entity.Components == null)
|
||||||
|
{
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(entity.Components.Count);
|
||||||
|
|
||||||
|
foreach (var (typeName, componentElement) in entity.Components)
|
||||||
|
{
|
||||||
|
var typeHash = GetTypeNameHash(typeName);
|
||||||
|
var componentType = TypeCache.GetTypes(typeName);
|
||||||
|
if (componentType == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (componentType == null)
|
||||||
|
{
|
||||||
|
writer.Write(typeHash);
|
||||||
|
|
||||||
|
var nameBytes = Encoding.UTF8.GetBytes(typeName);
|
||||||
|
writer.Write(nameBytes.Length);
|
||||||
|
writer.Write(nameBytes);
|
||||||
|
|
||||||
|
var jsonBytes = Encoding.UTF8.GetBytes(componentElement.GetRawText());
|
||||||
|
writer.Write(jsonBytes.Length);
|
||||||
|
writer.Write(jsonBytes);
|
||||||
|
writer.Write(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxed = componentElement.Deserialize(componentType, s_jsonOptions);
|
||||||
|
if (boxed == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(componentType);
|
||||||
|
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var buffer = new MemoryBlock((nuint)compInfo.size, (nuint)compInfo.alignment, scope.AllocationHandle);
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(boxed, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
|
||||||
|
var entityFieldOffsets = GetEntityFields(componentType);
|
||||||
|
var offsets = new int[entityFieldOffsets.Length];
|
||||||
|
for (var i = 0; i < entityFieldOffsets.Length; i++)
|
||||||
|
{
|
||||||
|
offsets[i] = (int)Marshal.OffsetOf(componentType, entityFieldOffsets[i].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Write(typeHash);
|
||||||
|
|
||||||
|
var nameBytes2 = Encoding.UTF8.GetBytes(typeName);
|
||||||
|
writer.Write(nameBytes2.Length);
|
||||||
|
writer.Write(nameBytes2);
|
||||||
|
|
||||||
|
writer.Write((int)buffer.Size);
|
||||||
|
writer.Write(buffer.AsSpan<byte>());
|
||||||
|
writer.Write(offsets.Length);
|
||||||
|
|
||||||
|
foreach (var off in offsets)
|
||||||
|
{
|
||||||
|
writer.Write(off);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Scene File Deserialization (static, used by handler too)
|
||||||
|
|
||||||
|
public static async ValueTask<SceneSaveData?> DeserializeSceneFileAsync(string jsonPath, CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(jsonPath, token);
|
||||||
|
using var document = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
var root = document.RootElement;
|
||||||
|
var data = new SceneSaveData
|
||||||
|
{
|
||||||
|
FormatVersion = root.TryGetProperty("formatVersion", out var v) ? v.GetUInt32() : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.TryGetProperty("entities", out var entitiesElement))
|
||||||
|
{
|
||||||
|
foreach (var entityElement in entitiesElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var entityData = new EntitySaveData();
|
||||||
|
|
||||||
|
if (entityElement.TryGetProperty("name", out var nameElement))
|
||||||
|
{
|
||||||
|
entityData.Name = nameElement.GetString() ?? "Entity";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityElement.TryGetProperty("components", out var componentsElement))
|
||||||
|
{
|
||||||
|
foreach (var componentProperty in componentsElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
entityData.Components[componentProperty.Name] = componentProperty.Value.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Entities.Add(entityData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Load Scene into Editor World
|
||||||
|
|
||||||
|
public unsafe void LoadSceneIntoEditorWorld(SceneSaveData data, SceneLoadingType loadingType = SceneLoadingType.Single, Action<Scene>? onComplete = null)
|
||||||
|
{
|
||||||
|
_worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (loadingType == SceneLoadingType.Single)
|
||||||
|
{
|
||||||
|
_worldService.EditorWorld.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
var activeScene = SceneManager.CreateScene();
|
||||||
|
|
||||||
|
var entityCount = data.Entities.Count;
|
||||||
|
var forwardMap = new Dictionary<int, Entity>(entityCount);
|
||||||
|
if (entityCount == 0)
|
||||||
|
{
|
||||||
|
goto RebuildAndReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = AllocationManager.CreateStackScope();
|
||||||
|
var typeIds = new UnsafeArray<UnsafeList<Identifier<IComponent>>>(entityCount, scope.AllocationHandle);
|
||||||
|
for (var i = 0; i < typeIds.Length; i++)
|
||||||
|
{
|
||||||
|
typeIds[i] = new UnsafeList<Identifier<IComponent>>(16, scope.AllocationHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
var entityData = data.Entities[fileIndex];
|
||||||
|
ref var list = ref typeIds[fileIndex];
|
||||||
|
|
||||||
|
list.Add(ComponentRegistry.GetOrRegisterComponentID<SceneID>());
|
||||||
|
|
||||||
|
foreach (var (typeName, _) in entityData.Components)
|
||||||
|
{
|
||||||
|
var compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compId = RegisterComponentByType(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(compId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentSet = new ComponentSetView(list);
|
||||||
|
var entity = world.EntityManager.CreateEntity(componentSet);
|
||||||
|
forwardMap[fileIndex] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var buffer = new MemoryBlock(1024, 16, scope.AllocationHandle);
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
if (!forwardMap.TryGetValue(fileIndex, out var entity))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
world.EntityManager.SetSharedComponent(entity, new SceneID { value = activeScene.ID });
|
||||||
|
|
||||||
|
var entityData = data.Entities[fileIndex];
|
||||||
|
|
||||||
|
foreach (var (typeName, componentElement) in entityData.Components)
|
||||||
|
{
|
||||||
|
var compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
var type = TypeCache.GetTypes().FirstOrDefault(t => t.FullName == typeName);
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
compId = ComponentRegistry.GetComponentIDByName(typeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compId.IsInvalid)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var componentType = ComponentRegistry.s_runtimeIDToType[compId];
|
||||||
|
|
||||||
|
if (_syncService.TryGetNode(entity, out var node))
|
||||||
|
{
|
||||||
|
node.BuildComponents();
|
||||||
|
var compNode = node.Components.FirstOrDefault(c => c.ComponentType == componentType);
|
||||||
|
if (compNode != null)
|
||||||
|
{
|
||||||
|
compNode.Deserialize(componentElement, s_jsonOptions, (boxed) =>
|
||||||
|
{
|
||||||
|
RemapLocalFieldsToEntity(boxed, componentType, forwardMap);
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct deserialization
|
||||||
|
var boxedLegacy = componentElement.Deserialize(componentType, s_jsonOptions);
|
||||||
|
if (boxedLegacy == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemapLocalFieldsToEntity(boxedLegacy, componentType, forwardMap);
|
||||||
|
|
||||||
|
Marshal.StructureToPtr(boxedLegacy, (nint)buffer.GetUnsafePtr(), false);
|
||||||
|
|
||||||
|
world.EntityManager.SetComponent(entity, compId, buffer.GetUnsafePtr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
scope.Dispose();
|
||||||
|
|
||||||
|
for (var i = 0; i < typeIds.Length; i++)
|
||||||
|
{
|
||||||
|
typeIds[i].Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
typeIds.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildAndReturn:
|
||||||
|
var initialNames = new Dictionary<Entity, string>();
|
||||||
|
for (var fileIndex = 0; fileIndex < entityCount; fileIndex++)
|
||||||
|
{
|
||||||
|
if (forwardMap.TryGetValue(fileIndex, out var entity))
|
||||||
|
{
|
||||||
|
initialNames[entity] = data.Entities[fileIndex].Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_worldService.RebuildSceneGraph(initialNames);
|
||||||
|
onComplete?.Invoke(activeScene);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Identifier<IComponent> RegisterComponentByType(Type type)
|
||||||
|
{
|
||||||
|
var getOrRegisterMethod = typeof(ComponentRegistry).GetMethod(
|
||||||
|
"GetOrRegisterComponentID",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static,
|
||||||
|
Array.Empty<Type>());
|
||||||
|
|
||||||
|
if (getOrRegisterMethod == null)
|
||||||
|
{
|
||||||
|
return Identifier<IComponent>.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == null)
|
||||||
|
{
|
||||||
|
return Identifier<IComponent>.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
var genericMethod = getOrRegisterMethod.MakeGenericMethod(type);
|
||||||
|
return (Identifier<IComponent>)genericMethod.Invoke(null, null)!;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Save Scene from Editor World
|
||||||
|
|
||||||
|
public unsafe void SaveSceneFromEditorWorld(string filePath, Scene scene)
|
||||||
|
{
|
||||||
|
var bytes = SerializeSceneToMemory(scene);
|
||||||
|
File.WriteAllBytes(filePath, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe byte[] SerializeSceneToMemory(Scene scene)
|
||||||
|
{
|
||||||
|
var world = _worldService.EditorWorld;
|
||||||
|
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
using var entities = SceneManager.GetSceneEntities(world, scene, scope.AllocationHandle);
|
||||||
|
|
||||||
|
using var sorted = SortEntitiesByHierarchy(world, entities, scope.AllocationHandle);
|
||||||
|
|
||||||
|
var reverseMap = new Dictionary<Entity, int>();
|
||||||
|
for (var i = 0; i < sorted.Count; i++)
|
||||||
|
{
|
||||||
|
reverseMap[sorted[i]] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new SceneSaveData
|
||||||
|
{
|
||||||
|
FormatVersion = SceneContentHeader.VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteNumber("formatVersion", SceneContentHeader.VERSION);
|
||||||
|
writer.WriteStartArray("entities");
|
||||||
|
|
||||||
|
foreach (var entity in sorted)
|
||||||
|
{
|
||||||
|
var locationResult = world.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (!locationResult.IsSuccess)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var location = locationResult.Value;
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(location.archetypeID);
|
||||||
|
|
||||||
|
writer.WriteStartObject();
|
||||||
|
|
||||||
|
var entityName = "Entity";
|
||||||
|
SceneGraph.EntityNode? node = null;
|
||||||
|
if (_syncService != null && _syncService.TryGetNode(entity, out node))
|
||||||
|
{
|
||||||
|
entityName = node.Name;
|
||||||
|
}
|
||||||
|
writer.WriteString("name", entityName);
|
||||||
|
|
||||||
|
writer.WriteStartObject("components");
|
||||||
|
|
||||||
|
if (node != null)
|
||||||
|
{
|
||||||
|
node.BuildComponents(); // Ensure latest
|
||||||
|
|
||||||
|
foreach (var compNode in node.Components)
|
||||||
|
{
|
||||||
|
var type = compNode.ComponentType;
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
writer.WritePropertyName(fullName);
|
||||||
|
compNode.Serialize(writer, s_jsonOptions, (boxed) =>
|
||||||
|
{
|
||||||
|
RemapEntityFieldsToLocal(boxed, type, reverseMap);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var layout in archetype._layouts)
|
||||||
|
{
|
||||||
|
var type = ComponentRegistry.s_runtimeIDToType[layout.componentID];
|
||||||
|
if (type == typeof(SceneID))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullName = type.FullName ?? type.Name;
|
||||||
|
var compInfo = ComponentRegistry.GetComponentInfo(layout.componentID);
|
||||||
|
|
||||||
|
var pData = archetype.GetComponentData(location.chunkIndex, location.rowIndex, layout.componentID);
|
||||||
|
if (pData == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var boxed = Marshal.PtrToStructure((nint)pData, type);
|
||||||
|
if (boxed == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemapEntityFieldsToLocal(boxed, type, reverseMap);
|
||||||
|
|
||||||
|
writer.WritePropertyName(fullName);
|
||||||
|
JsonSerializer.Serialize(writer, boxed, type, s_jsonOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteEndArray();
|
||||||
|
writer.WriteEndObject();
|
||||||
|
writer.Flush();
|
||||||
|
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UnsafeList<Entity> SortEntitiesByHierarchy(World world, ReadOnlySpan<Entity> entities, AllocationHandle allocationHandle)
|
||||||
|
{
|
||||||
|
using var scope = AllocationManager.CreateStackScope();
|
||||||
|
|
||||||
|
using var entitySet = new UnsafeHashSet<Entity>(entities.Length, scope.AllocationHandle);
|
||||||
|
using var roots = new UnsafeList<Entity>(32, scope.AllocationHandle);
|
||||||
|
var childrenMap = new UnsafeHashMap<Entity, UnsafeList<Entity>>(32, scope.AllocationHandle);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var entity in entities)
|
||||||
|
{
|
||||||
|
if (!world.EntityManager.HasComponent<Hierarchy>(entity))
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref var hierarchy = ref world.EntityManager.GetComponent<Hierarchy>(entity);
|
||||||
|
if (hierarchy.parent.IsValid && entitySet.Contains(hierarchy.parent))
|
||||||
|
{
|
||||||
|
ref var list = ref childrenMap.GetValueRefOrAddDefault(hierarchy.parent, out var exist);
|
||||||
|
if (!exist)
|
||||||
|
{
|
||||||
|
list = new UnsafeList<Entity>(4, allocationHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(entity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
roots.Add(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorted = new UnsafeList<Entity>(entities.Length, allocationHandle);
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
AddEntityAndDescendants(ref sorted, root, in childrenMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var kvp in childrenMap)
|
||||||
|
{
|
||||||
|
kvp.Value.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
childrenMap.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEntityAndDescendants(ref UnsafeList<Entity> sorted, Entity entity, ref readonly UnsafeHashMap<Entity, UnsafeList<Entity>> childrenMap)
|
||||||
|
{
|
||||||
|
sorted.Add(entity);
|
||||||
|
if (childrenMap.TryGetValue(entity, out var children))
|
||||||
|
{
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
AddEntityAndDescendants(ref sorted, child, in childrenMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
567
src/Editor/Ghost.Editor.Core/Services/UndoService.cs
Normal file
567
src/Editor/Ghost.Editor.Core/Services/UndoService.cs
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Core.Collections;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Entities;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Services;
|
||||||
|
|
||||||
|
public enum LifecycleEvent { Created, Destroyed }
|
||||||
|
|
||||||
|
public interface IUndoService
|
||||||
|
{
|
||||||
|
IEnumerable<UndoOperation> UndoOperations { get; }
|
||||||
|
IEnumerable<UndoOperation> RedoOperations { get; }
|
||||||
|
|
||||||
|
int GlobalVersion { get; }
|
||||||
|
|
||||||
|
event Action? UndoRedoPerformed;
|
||||||
|
|
||||||
|
void RecordObject(GhostObject obj, string actionName);
|
||||||
|
void RecordEntityComponent(ComponentNode node, string actionName);
|
||||||
|
void RecordEntityStructure(EntityNode node, string actionName);
|
||||||
|
void RecordEntityLifecycle(EntityNode node, LifecycleEvent type);
|
||||||
|
|
||||||
|
void BeginTransaction(string name);
|
||||||
|
void EndTransaction();
|
||||||
|
void PerformUndo();
|
||||||
|
void PerformRedo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class UndoOperation
|
||||||
|
{
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string ActionName { get; set; } = string.Empty;
|
||||||
|
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Creates an operation that holds the *current* state, so it can be pushed to Redo.
|
||||||
|
public abstract UndoOperation CreateReciprocal(IEditorWorldService worldService);
|
||||||
|
public abstract void Revert(IEditorWorldService worldService);
|
||||||
|
|
||||||
|
public virtual bool CanMerge(UndoOperation other) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ObjectStateOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public byte[] State { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var obj = GhostObject.Find(InstanceID);
|
||||||
|
var reciprocal = new ObjectStateOperation { GroupId = GroupId, ActionName = ActionName, InstanceID = InstanceID };
|
||||||
|
if (obj != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(ms);
|
||||||
|
obj.SerializeState(writer);
|
||||||
|
reciprocal.State = ms.ToArray();
|
||||||
|
}
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var obj = GhostObject.Find(InstanceID);
|
||||||
|
if (obj != null)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(State);
|
||||||
|
using var reader = new BinaryReader(ms);
|
||||||
|
obj.DeserializeState(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is ObjectStateOperation op)
|
||||||
|
{
|
||||||
|
return op.InstanceID == InstanceID && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityComponentOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public int ComponentId { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public unsafe override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(InstanceID) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? Entity;
|
||||||
|
|
||||||
|
var reciprocal = new EntityComponentOperation { GroupId = GroupId, ActionName = ActionName, Entity = targetEntity, InstanceID = InstanceID, ComponentId = ComponentId };
|
||||||
|
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(ComponentId));
|
||||||
|
if (pComp != null)
|
||||||
|
{
|
||||||
|
var size = ComponentRegistry.GetComponentInfo(new Identifier<IComponent>(ComponentId)).size;
|
||||||
|
var data = new byte[size];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pComp, pDst, size, size);
|
||||||
|
}
|
||||||
|
reciprocal.ComponentData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var cId = ComponentId;
|
||||||
|
var data = ComponentData;
|
||||||
|
var instId = InstanceID;
|
||||||
|
var fallbackEntity = Entity;
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(instId) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? fallbackEntity;
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
var pComp = worldService.EditorWorld.EntityManager.GetComponent(targetEntity, new Identifier<IComponent>(cId));
|
||||||
|
if (pComp != null)
|
||||||
|
{
|
||||||
|
fixed (byte* pSrc = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pSrc, pComp, data.Length, data.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is EntityComponentOperation op)
|
||||||
|
{
|
||||||
|
if (op.Entity == Entity && op.ComponentId == ComponentId)
|
||||||
|
{
|
||||||
|
// Explicit transaction merge
|
||||||
|
if (op.GroupId != 0 && op.GroupId == GroupId)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based merge fallback for non-transactional continuous edits (e.g. 500ms)
|
||||||
|
if (op.GroupId == 0)
|
||||||
|
{
|
||||||
|
return Math.Abs((op.Timestamp - Timestamp).TotalMilliseconds) < 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityStructureOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public int ArchetypeID { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] SharedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public int SharedDataHash { get; set; }
|
||||||
|
|
||||||
|
public unsafe static EntityStructureOperation Capture(IEditorWorldService worldService, EntityNode node)
|
||||||
|
{
|
||||||
|
var entity = node.Entity;
|
||||||
|
var op = new EntityStructureOperation { Entity = entity, InstanceID = node.InstanceID };
|
||||||
|
var locRes = worldService.EditorWorld.EntityManager.GetEntityLocation(entity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
op.ArchetypeID = locRes.Value.archetypeID;
|
||||||
|
ref var archetype = ref worldService.EditorWorld.ComponentManager.GetArchetypeReference(op.ArchetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
|
||||||
|
// Compute size of all unmanaged components
|
||||||
|
var totalSize = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
totalSize += archetype._layouts[i].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new byte[totalSize];
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
var layout = archetype._layouts[i];
|
||||||
|
var pSrc = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
|
||||||
|
Buffer.MemoryCopy(pSrc, pDst + offset, layout.size, layout.size);
|
||||||
|
offset += layout.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.ComponentData = data;
|
||||||
|
|
||||||
|
if (chunk._groupIndex >= 0 && chunk._groupIndex < archetype._chunkGroups.Count)
|
||||||
|
{
|
||||||
|
var group = archetype._chunkGroups[chunk._groupIndex];
|
||||||
|
op.SharedData = group.sharedData.AsSpan().ToArray();
|
||||||
|
op.SharedDataHash = group.sharedDataHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
if (GhostObject.Find(InstanceID) is not EntityNode node)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reciprocal = Capture(worldService, node);
|
||||||
|
reciprocal.GroupId = GroupId;
|
||||||
|
reciprocal.ActionName = ActionName;
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsafe override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var instId = InstanceID;
|
||||||
|
var fallbackEntity = Entity;
|
||||||
|
var archId = ArchetypeID;
|
||||||
|
var compData = ComponentData;
|
||||||
|
var sharedData = SharedData;
|
||||||
|
var sharedHash = SharedDataHash;
|
||||||
|
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
var node = GhostObject.Find(instId) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? fallbackEntity;
|
||||||
|
|
||||||
|
var world = worldService.EditorWorld;
|
||||||
|
var locRes = world.EntityManager.GetEntityLocation(targetEntity);
|
||||||
|
if (!locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
return; // Entity destroyed? Should use Lifecycle undo for that.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locRes.Value.archetypeID != archId)
|
||||||
|
{
|
||||||
|
ref var targetArchetype = ref world.ComponentManager.GetArchetypeReference(archId);
|
||||||
|
|
||||||
|
// Build ComponentSetView from the target archetype
|
||||||
|
var it = targetArchetype._signature.GetIterator();
|
||||||
|
var components = new List<Identifier<IComponent>>();
|
||||||
|
while (it.Next(out var compId))
|
||||||
|
{
|
||||||
|
components.Add(new Identifier<IComponent>(compId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var set = new ComponentSetView(components.ToArray(), sharedData ?? Array.Empty<byte>());
|
||||||
|
world.EntityManager.MigrateEntity(targetEntity, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Overwrite unmanaged memory
|
||||||
|
locRes = world.EntityManager.GetEntityLocation(targetEntity);
|
||||||
|
if (locRes.IsSuccess)
|
||||||
|
{
|
||||||
|
ref var archetype = ref world.ComponentManager.GetArchetypeReference(locRes.Value.archetypeID);
|
||||||
|
ref var chunk = ref archetype.GetChunkReference(locRes.Value.chunkIndex);
|
||||||
|
|
||||||
|
fixed (byte* pSrcBase = compData)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var i = 0; i < archetype._layouts.Count; i++)
|
||||||
|
{
|
||||||
|
var layout = archetype._layouts[i];
|
||||||
|
var pDst = chunk.GetUnsafePtr() + layout.offset + (layout.size * locRes.Value.rowIndex);
|
||||||
|
Buffer.MemoryCopy(pSrcBase + offset, pDst, layout.size, layout.size);
|
||||||
|
offset += layout.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanMerge(UndoOperation other)
|
||||||
|
{
|
||||||
|
if (other is EntityStructureOperation op)
|
||||||
|
{
|
||||||
|
return op.Entity == Entity && op.GroupId == GroupId;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EntityLifecycleOperation : UndoOperation
|
||||||
|
{
|
||||||
|
public Entity Entity { get; set; }
|
||||||
|
public Guid InstanceID { get; set; }
|
||||||
|
public LifecycleEvent EventType { get; set; }
|
||||||
|
|
||||||
|
// State for destruction
|
||||||
|
public int ArchetypeID { get; set; }
|
||||||
|
public byte[] ComponentData { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] SharedData { get; set; } = Array.Empty<byte>();
|
||||||
|
public int SharedDataHash { get; set; }
|
||||||
|
|
||||||
|
public override UndoOperation CreateReciprocal(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
var reciprocal = new EntityLifecycleOperation
|
||||||
|
{
|
||||||
|
GroupId = GroupId,
|
||||||
|
ActionName = ActionName,
|
||||||
|
Entity = Entity,
|
||||||
|
InstanceID = InstanceID,
|
||||||
|
EventType = EventType == LifecycleEvent.Created ? LifecycleEvent.Destroyed : LifecycleEvent.Created,
|
||||||
|
ArchetypeID = ArchetypeID,
|
||||||
|
ComponentData = ComponentData,
|
||||||
|
SharedData = SharedData,
|
||||||
|
SharedDataHash = SharedDataHash
|
||||||
|
};
|
||||||
|
return reciprocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Revert(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
worldService.Defer(() =>
|
||||||
|
{
|
||||||
|
if (EventType == LifecycleEvent.Created)
|
||||||
|
{
|
||||||
|
// Revert a Creation = Destroy
|
||||||
|
var node = GhostObject.Find(InstanceID) as EntityNode;
|
||||||
|
var targetEntity = node?.Entity ?? Entity;
|
||||||
|
worldService.EditorWorld.EntityManager.DestroyEntity(targetEntity);
|
||||||
|
// The InstanceID GhostObject will be naturally unlinked, handles become null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Revert a Destruction = Recreate
|
||||||
|
var newEntity = worldService.EditorWorld.EntityManager.CreateEntity();
|
||||||
|
|
||||||
|
// TODO: Apply the ArchetypeID, ComponentData, SharedData to the newEntity.
|
||||||
|
// We'd add the components using the archetype signature, then memcopy the bytes.
|
||||||
|
|
||||||
|
// Fix the Node reference
|
||||||
|
if (GhostObject.Find(InstanceID) is not EntityNode node)
|
||||||
|
{
|
||||||
|
node = new EntityNode(worldService.EditorWorld, newEntity, "Resurrected", null);
|
||||||
|
// Force the InstanceID using backing field
|
||||||
|
var backingField = typeof(EntityNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
|
||||||
|
?? typeof(SceneGraphNode).GetField("<InstanceID>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
backingField?.SetValue(node, InstanceID);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Update the entity property of the existing node (using reflection since it's init/readonly)
|
||||||
|
var entityField = typeof(EntityNode).GetField("<Entity>k__BackingField", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
entityField?.SetValue(node, newEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class UndoService : IUndoService
|
||||||
|
{
|
||||||
|
public event Action? UndoRedoPerformed;
|
||||||
|
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly RingBuffer<UndoOperation> _undoStack = new(50);
|
||||||
|
private readonly Stack<UndoOperation> _redoStack = new();
|
||||||
|
|
||||||
|
private int _nextGroupId = 1;
|
||||||
|
private int _activeGroupId = 0;
|
||||||
|
|
||||||
|
public int GlobalVersion { get; private set; } = 0;
|
||||||
|
|
||||||
|
public IEnumerable<UndoOperation> UndoOperations => _undoStack;
|
||||||
|
public IEnumerable<UndoOperation> RedoOperations => _redoStack;
|
||||||
|
|
||||||
|
public UndoService(IEditorWorldService worldService)
|
||||||
|
{
|
||||||
|
_worldService = worldService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BeginTransaction(string name)
|
||||||
|
{
|
||||||
|
_activeGroupId = _nextGroupId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndTransaction()
|
||||||
|
{
|
||||||
|
_activeGroupId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PushOperation(UndoOperation op)
|
||||||
|
{
|
||||||
|
bool isTransaction = _activeGroupId != 0;
|
||||||
|
op.GroupId = isTransaction ? _activeGroupId : 0;
|
||||||
|
|
||||||
|
UndoOperation? top = _undoStack.Count > 0 ? _undoStack.Peek() : null;
|
||||||
|
if (top != null && op.CanMerge(top))
|
||||||
|
{
|
||||||
|
// Extend the merge window by updating the timestamp
|
||||||
|
top.Timestamp = op.Timestamp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTransaction)
|
||||||
|
{
|
||||||
|
op.GroupId = _nextGroupId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_undoStack.Push(op);
|
||||||
|
_redoStack.Clear(); // Any new action clears the redo stack
|
||||||
|
GlobalVersion++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordObject(GhostObject obj, string actionName)
|
||||||
|
{
|
||||||
|
var op = new ObjectStateOperation()
|
||||||
|
{
|
||||||
|
ActionName = actionName,
|
||||||
|
InstanceID = obj.InstanceID
|
||||||
|
};
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using var writer = new BinaryWriter(ms);
|
||||||
|
obj.SerializeState(writer);
|
||||||
|
op.State = ms.ToArray();
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: We may want to have unified api RecordObject that can handle everything.
|
||||||
|
public unsafe void RecordEntityComponent(ComponentNode node, string actionName)
|
||||||
|
{
|
||||||
|
var op = new EntityComponentOperation
|
||||||
|
{
|
||||||
|
ActionName = actionName,
|
||||||
|
Entity = node.EntityNode.Entity,
|
||||||
|
ComponentId = node.Descriptor.ComponentId,
|
||||||
|
InstanceID = node.EntityNode.InstanceID
|
||||||
|
};
|
||||||
|
|
||||||
|
var pComp = node.GetComponentPointer();
|
||||||
|
var size = node.Descriptor.Size;
|
||||||
|
var data = new byte[size];
|
||||||
|
|
||||||
|
fixed (byte* pDst = data)
|
||||||
|
{
|
||||||
|
Buffer.MemoryCopy(pComp, pDst, size, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
op.ComponentData = data;
|
||||||
|
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityStructure(EntityNode node, string actionName)
|
||||||
|
{
|
||||||
|
var op = EntityStructureOperation.Capture(_worldService, node);
|
||||||
|
op.ActionName = actionName;
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordEntityLifecycle(EntityNode node, LifecycleEvent type)
|
||||||
|
{
|
||||||
|
var op = new EntityLifecycleOperation
|
||||||
|
{
|
||||||
|
ActionName = type == LifecycleEvent.Created ? "Create Entity" : "Destroy Entity",
|
||||||
|
Entity = node.Entity,
|
||||||
|
InstanceID = node.InstanceID,
|
||||||
|
EventType = type
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type == LifecycleEvent.Destroyed)
|
||||||
|
{
|
||||||
|
// Capture state before destruction
|
||||||
|
var structure = EntityStructureOperation.Capture(_worldService, node);
|
||||||
|
op.ArchetypeID = structure.ArchetypeID;
|
||||||
|
op.ComponentData = structure.ComponentData;
|
||||||
|
op.SharedData = structure.SharedData;
|
||||||
|
op.SharedDataHash = structure.SharedDataHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
PushOperation(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PerformUndo()
|
||||||
|
{
|
||||||
|
if (_undoStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetGroup = _undoStack.Peek().GroupId;
|
||||||
|
var toUndo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
while (_undoStack.Count > 0 && _undoStack.Peek().GroupId == targetGroup)
|
||||||
|
{
|
||||||
|
toUndo.Add(_undoStack.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
var toRedo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
// Revert in reverse order (which is standard for stack pop, but we popped them into a list)
|
||||||
|
// Wait, the list has them in reverse chronological order (newest at index 0).
|
||||||
|
// We should execute them in that order.
|
||||||
|
foreach (var op in toUndo)
|
||||||
|
{
|
||||||
|
// Snapshot current state for Redo BEFORE reverting
|
||||||
|
var reciprocal = op.CreateReciprocal(_worldService);
|
||||||
|
toRedo.Add(reciprocal);
|
||||||
|
|
||||||
|
op.Revert(_worldService);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to Redo stack (we push the oldest action first so it comes off last on redo)
|
||||||
|
toRedo.Reverse();
|
||||||
|
foreach (var op in toRedo)
|
||||||
|
{
|
||||||
|
_redoStack.Push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalVersion--;
|
||||||
|
|
||||||
|
// Flush ECS commands before UI updates
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
|
||||||
|
UndoRedoPerformed?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PerformRedo()
|
||||||
|
{
|
||||||
|
if (_redoStack.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetGroup = _redoStack.Peek().GroupId;
|
||||||
|
var toRedo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
while (_redoStack.Count > 0 && _redoStack.Peek().GroupId == targetGroup)
|
||||||
|
{
|
||||||
|
toRedo.Add(_redoStack.Pop());
|
||||||
|
}
|
||||||
|
|
||||||
|
toRedo.Reverse();
|
||||||
|
|
||||||
|
var toUndo = new List<UndoOperation>();
|
||||||
|
|
||||||
|
foreach (var op in toRedo)
|
||||||
|
{
|
||||||
|
var reciprocal = op.CreateReciprocal(_worldService);
|
||||||
|
toUndo.Add(reciprocal);
|
||||||
|
op.Revert(_worldService); // Revert actually means Apply in this symmetric design
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var op in toUndo)
|
||||||
|
{
|
||||||
|
_undoStack.Push(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalVersion++;
|
||||||
|
|
||||||
|
_worldService.FlushCommands();
|
||||||
|
|
||||||
|
UndoRedoPerformed?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/Editor/Ghost.Editor.Core/Utilities/BindingUtility.cs
Normal file
61
src/Editor/Ghost.Editor.Core/Utilities/BindingUtility.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using Ghost.Editor.Core.Controls;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class BindingUtility
|
||||||
|
{
|
||||||
|
public static void BindTwoWay<T>(this INotifyValueChanged<T> control, PropertyNode<T> node)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(node.Value);
|
||||||
|
control.OnValueChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
node.ComponentNode.EntityNode.Modify();
|
||||||
|
node.SetValueFromUI(e.NewValue);
|
||||||
|
};
|
||||||
|
node.OnValueChanged += control.SetValueWithoutNotify;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindTwoWay<T, U>(this INotifyValueChanged<T> control, PropertyNode<U> node, Func<PropertyNode<U>, T> getter, Action<PropertyNode<U>, T> setter)
|
||||||
|
where U : unmanaged
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(getter(node));
|
||||||
|
control.OnValueChanged += (_, args) =>
|
||||||
|
{
|
||||||
|
node.ComponentNode.EntityNode.Modify();
|
||||||
|
setter(node, args.NewValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(getter(node));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindOneWay<T>(this INotifyValueChanged<T> control, PropertyNode<T> node)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(node.Value);
|
||||||
|
node.OnValueChanged += control.SetValueWithoutNotify;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindOneWay<T, U>(this INotifyValueChanged<T> control, PropertyNode<U> node, Func<PropertyNode<U>, T> getter)
|
||||||
|
where U : unmanaged
|
||||||
|
{
|
||||||
|
node.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
control.SetValueWithoutNotify(getter(node));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindOneWay<T>(this FrameworkElement element, DependencyProperty dp, PropertyNode<T> node)
|
||||||
|
where T : unmanaged
|
||||||
|
{
|
||||||
|
node.OnValueChanged += (newVal) =>
|
||||||
|
{
|
||||||
|
element.SetValue(dp, newVal);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/Editor/Ghost.Editor.Core/Utilities/PathUtility.cs
Normal file
37
src/Editor/Ghost.Editor.Core/Utilities/PathUtility.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Core.Utilities;
|
||||||
|
|
||||||
|
public static class PathUtility
|
||||||
|
{
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static string Normalize(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static string GetUniqueName(string path)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(path);
|
||||||
|
directory ??= ".";
|
||||||
|
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
|
||||||
|
var uniqueName = fileName;
|
||||||
|
var counter = 1;
|
||||||
|
|
||||||
|
while (File.Exists(Path.Combine(directory, uniqueName + extension)))
|
||||||
|
{
|
||||||
|
uniqueName = $"{fileName} ({counter++})";
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(directory, uniqueName + extension);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ internal static class ShaderCompilerUtility
|
|||||||
};
|
};
|
||||||
|
|
||||||
var compiled = new UnsafeArray<UnsafeArray<byte>>(descriptor.ShaderCodes.Length, allocationHandle);
|
var compiled = new UnsafeArray<UnsafeArray<byte>>(descriptor.ShaderCodes.Length, allocationHandle);
|
||||||
for (int i = 0; i < descriptor.ShaderCodes.Length; i++)
|
for (var i = 0; i < descriptor.ShaderCodes.Length; i++)
|
||||||
{
|
{
|
||||||
config.shaderCode = descriptor.ShaderCodes[i].code;
|
config.shaderCode = descriptor.ShaderCodes[i].code;
|
||||||
config.entryPoint = descriptor.ShaderCodes[i].entryPoint;
|
config.entryPoint = descriptor.ShaderCodes[i].entryPoint;
|
||||||
@@ -150,7 +150,7 @@ internal static class ShaderCompilerUtility
|
|||||||
var result = shaderCompiler.Compile(ref config, allocationHandle);
|
var result = shaderCompiler.Compile(ref config, allocationHandle);
|
||||||
if (result.IsFailure)
|
if (result.IsFailure)
|
||||||
{
|
{
|
||||||
for (int j = 0; j < i; j++)
|
for (var j = 0; j < i; j++)
|
||||||
{
|
{
|
||||||
compiled[j].Dispose();
|
compiled[j].Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ public static class TypeCache
|
|||||||
private static Dictionary<nint, List<int>> FindTypesWithAttribute()
|
private static Dictionary<nint, List<int>> FindTypesWithAttribute()
|
||||||
{
|
{
|
||||||
var dict = new Dictionary<nint, List<int>>();
|
var dict = new Dictionary<nint, List<int>>();
|
||||||
for (int i = 0; i < s_types.Length; i++)
|
for (var i = 0; i < s_types.Length; i++)
|
||||||
{
|
{
|
||||||
TypeInfo? type = s_types[i];
|
var type = s_types[i];
|
||||||
var attrs = type.GetCustomAttributes<DiscoverableAttributeBase>(false);
|
var attrs = type.GetCustomAttributes<DiscoverableAttributeBase>(false);
|
||||||
foreach (var attr in attrs)
|
foreach (var attr in attrs)
|
||||||
{
|
{
|
||||||
@@ -85,12 +85,6 @@ public static class TypeCache
|
|||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void Initialize()
|
|
||||||
{
|
|
||||||
// Intentionally left blank.
|
|
||||||
// This method exists to force the static constructor to run.
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void Reload()
|
internal static void Reload()
|
||||||
{
|
{
|
||||||
s_types = LoadTypes();
|
s_types = LoadTypes();
|
||||||
@@ -103,6 +97,11 @@ public static class TypeCache
|
|||||||
return s_types;
|
return s_types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static TypeInfo? GetTypes(string typeFullName)
|
||||||
|
{
|
||||||
|
return s_types.FirstOrDefault(t => t.FullName == typeFullName);
|
||||||
|
}
|
||||||
|
|
||||||
public static IEnumerable<MethodInfo>? GetMethodsWithAttribute<T>()
|
public static IEnumerable<MethodInfo>? GetMethodsWithAttribute<T>()
|
||||||
where T : DiscoverableAttributeBase
|
where T : DiscoverableAttributeBase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Editor.Models;
|
using Ghost.Editor.Models;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine;
|
||||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||||
@@ -58,10 +58,10 @@ internal static class ActivationHandler
|
|||||||
var opts = new AllocationManagerDesc
|
var opts = new AllocationManagerDesc
|
||||||
{
|
{
|
||||||
ArenaCapacity = 1024 * 1024 * 1024, // 1 GB. Arena using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
ArenaCapacity = 1024 * 1024 * 1024, // 1 GB. Arena using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
||||||
StackCapacity = 1024 * 1024 * 64, // 64 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
StackCapacity = 64 * 1024 * 1024, // 64 MB. Stack using virtual memory, so this is just a reservation and won't actually consume physical memory until used.
|
||||||
FreeListChunkSize = 64 * 1024,
|
FreeListChunkSize = 64 * 1024,
|
||||||
FreeListDefaultAlignment = 8,
|
FreeListDefaultAlignment = 8,
|
||||||
TLSFInitialChunkSize = 64 * 1024,
|
TLSFInitialChunkSize = 32 * 1024 * 1024,
|
||||||
TLSFAlignment = 8,
|
TLSFAlignment = 8,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +69,9 @@ internal static class ActivationHandler
|
|||||||
|
|
||||||
var assetRegistry = App.GetService<IAssetRegistry>();
|
var assetRegistry = App.GetService<IAssetRegistry>();
|
||||||
var engineCore = App.GetService<EngineCore>();
|
var engineCore = App.GetService<EngineCore>();
|
||||||
|
var editorTick = App.GetService<EditorTickEngine>();
|
||||||
|
|
||||||
|
editorTick.Start();
|
||||||
|
|
||||||
assetRegistry.OnAssetImported += (sender, e) =>
|
assetRegistry.OnAssetImported += (sender, e) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ using Ghost.Core;
|
|||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Services;
|
using Ghost.Editor.Core.Services;
|
||||||
using Ghost.Editor.Core.Utilities;
|
|
||||||
using Ghost.Editor.ViewModels.Controls;
|
using Ghost.Editor.ViewModels.Controls;
|
||||||
using Ghost.Editor.ViewModels.Windows;
|
using Ghost.Editor.ViewModels.Windows;
|
||||||
using Ghost.Editor.Views.Windows;
|
using Ghost.Editor.Views.Windows;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine;
|
||||||
|
using Ghost.Engine.Streaming;
|
||||||
|
using Ghost.Graphics.Core;
|
||||||
|
using Ghost.Graphics.RHI;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
@@ -51,8 +53,6 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
TypeCache.Initialize();
|
|
||||||
|
|
||||||
Host = Microsoft.Extensions.Hosting.Host.
|
Host = Microsoft.Extensions.Hosting.Host.
|
||||||
CreateDefaultBuilder().
|
CreateDefaultBuilder().
|
||||||
UseContentRoot(AppContext.BaseDirectory).
|
UseContentRoot(AppContext.BaseDirectory).
|
||||||
@@ -65,39 +65,22 @@ public partial class App : Application
|
|||||||
services.AddSingleton<IInspectorService, InspectorService>();
|
services.AddSingleton<IInspectorService, InspectorService>();
|
||||||
services.AddSingleton<IPreviewService, PreviewService>();
|
services.AddSingleton<IPreviewService, PreviewService>();
|
||||||
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
services.AddSingleton<IAssetRegistry, AssetRegistry>();
|
||||||
services.AddSingleton<IContentProvider, EditorContentProvider>();
|
services.AddSingleton<IShaderCompiler, DXCShaderCompiler>();
|
||||||
|
services.AddSingleton<IEditorWorldService, EditorWorldService>();
|
||||||
|
services.AddSingleton<IUndoService, UndoService>();
|
||||||
|
services.AddSingleton<IDirtyTrackerService, DirtyTrackerService>();
|
||||||
|
|
||||||
services.AddSingleton<EngineCore>();
|
services.AddSingleton<InspectorSyncService>();
|
||||||
|
services.AddSingleton<EditorTickEngine>();
|
||||||
|
services.AddSingleton<SceneSerializationService>();
|
||||||
|
services.AddSingleton<SceneGraphSyncService>();
|
||||||
|
|
||||||
|
services.AddSingleton<IContentProvider, EditorContentProvider>();
|
||||||
|
services.AddSingleton<IShaderCompilationBridge, EditorShaderCompilerBridge>();
|
||||||
|
|
||||||
services.AddSingleton<EngineEditorViewModel>();
|
services.AddSingleton<EngineEditorViewModel>();
|
||||||
|
|
||||||
services.AddTransient<ContentBrowserViewModel>();
|
services.AddTransient<ContentBrowserViewModel>();
|
||||||
|
|
||||||
// TODO: Use source generators to generate this code at compile time instead of using reflection at runtime.
|
|
||||||
foreach (var type in TypeCache.GetTypes())
|
|
||||||
{
|
|
||||||
var data = type.GetCustomAttributesData().FirstOrDefault(a => a.AttributeType == typeof(EditorInjectionAttribute));
|
|
||||||
if (data is null)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var lifeTime = (EditorInjectionAttribute.ServiceLifetime)data.ConstructorArguments[0].Value!;
|
|
||||||
var implementationType = (Type)data.ConstructorArguments[1].Value!;
|
|
||||||
var serviceType = type.IsInterface ? type.AsType() : implementationType;
|
|
||||||
|
|
||||||
switch (lifeTime)
|
|
||||||
{
|
|
||||||
case EditorInjectionAttribute.ServiceLifetime.Singleton:
|
|
||||||
services.AddSingleton(serviceType, implementationType);
|
|
||||||
break;
|
|
||||||
case EditorInjectionAttribute.ServiceLifetime.Transient:
|
|
||||||
services.AddTransient(serviceType, implementationType);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
using Ghost.Editor.Core.Inspector;
|
|
||||||
using Microsoft.UI.Xaml.Controls;
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Components;
|
|
||||||
|
|
||||||
//[CustomEditor(typeof(Hierarchy))]
|
|
||||||
internal class HierarchyEditor : ComponentEditor
|
|
||||||
{
|
|
||||||
public void Create(ComponentObject componentObject, StackPanel container)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(ComponentObject componentObject)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Destroy(ComponentObject componentObject)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
using Ghost.Editor.Core.Controls;
|
using Ghost.Editor.Core.Controls;
|
||||||
using Ghost.Editor.Core.Inspector;
|
using Ghost.Editor.Core.Inspector;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Ghost.Engine.Components;
|
using Ghost.Engine.Components;
|
||||||
using Ghost.Engine.Utilities;
|
using Ghost.Engine.Utilities;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
@@ -15,52 +17,60 @@ internal class LocalToWorldEditor : ComponentEditor
|
|||||||
private Float3Field _rotationField = null!;
|
private Float3Field _rotationField = null!;
|
||||||
private Float3Field _scaleField = null!;
|
private Float3Field _scaleField = null!;
|
||||||
|
|
||||||
public override void Create(StackPanel container)
|
public override void Create(Panel root, ComponentNode componentNode)
|
||||||
{
|
{
|
||||||
_translationField = new Float3Field();
|
_translationField = new Float3Field();
|
||||||
_rotationField = new Float3Field();
|
_rotationField = new Float3Field();
|
||||||
_scaleField = new Float3Field();
|
_scaleField = new Float3Field();
|
||||||
|
|
||||||
_translationField.OnValueChanged += (s, e) =>
|
root.Children.Add(new PropertyField() { Label = "Position", Content = _translationField });
|
||||||
|
root.Children.Add(new PropertyField() { Label = "Rotation", Content = _rotationField });
|
||||||
|
root.Children.Add(new PropertyField() { Label = "Scale", Content = _scaleField });
|
||||||
|
|
||||||
|
var property = componentNode.GetProperty<float4x4>(nameof(LocalToWorld.matrix));
|
||||||
|
|
||||||
|
_translationField.BindTwoWay(property,
|
||||||
|
getter: node =>
|
||||||
{
|
{
|
||||||
ref var data = ref ComponentObject.GetData<LocalToWorld>();
|
return node.Value.c3.xyz;
|
||||||
data.matrix.c3.xyz = e.NewValue;
|
},
|
||||||
};
|
setter: (node, val) =>
|
||||||
|
|
||||||
_rotationField.OnValueChanged += (s, e) =>
|
|
||||||
{
|
{
|
||||||
ref var data = ref ComponentObject.GetData<LocalToWorld>();
|
var data = node.Value;
|
||||||
var newRotation = quaternion.EulerXYZ(e.NewValue * math.TORADIANS);
|
data.c3.xyz = val;
|
||||||
|
node.SetValueFromUI(data);
|
||||||
|
});
|
||||||
|
|
||||||
data.matrix.GetTRS(out var oldTranslation, out var _, out var oldScale);
|
_rotationField.BindTwoWay(property,
|
||||||
data.matrix = float4x4.TRS(oldTranslation, newRotation, oldScale);
|
getter: node =>
|
||||||
};
|
|
||||||
|
|
||||||
_scaleField.OnValueChanged += (s, e) =>
|
|
||||||
{
|
{
|
||||||
ref var data = ref ComponentObject.GetData<LocalToWorld>();
|
node.Value.GetTRS(out _, out var rotation, out _);
|
||||||
var newScale = e.NewValue;
|
return math.degrees(math.EulerXYZ(rotation));
|
||||||
|
},
|
||||||
data.matrix.GetTRS(out var oldTranslation, out var oldRotation, out var _);
|
setter: (node, val) =>
|
||||||
data.matrix = float4x4.TRS(oldTranslation, oldRotation, newScale);
|
|
||||||
};
|
|
||||||
|
|
||||||
container.Children.Add(new PropertyField() { Label = "Position", Content = _translationField });
|
|
||||||
container.Children.Add(new PropertyField() { Label = "Rotation", Content = _rotationField });
|
|
||||||
container.Children.Add(new PropertyField() { Label = "Scale", Content = _scaleField });
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Update()
|
|
||||||
{
|
{
|
||||||
var data = ComponentObject.GetData<LocalToWorld>();
|
var data = node.Value;
|
||||||
data.matrix.GetTRS(out var position, out var rotation, out var scale);
|
var newRotation = quaternion.EulerXYZ(val * math.TORADIANS);
|
||||||
|
data.GetTRS(out var oldTranslation, out _, out var oldScale);
|
||||||
|
data = float4x4.TRS(oldTranslation, newRotation, oldScale);
|
||||||
|
node.SetValueFromUI(data);
|
||||||
|
});
|
||||||
|
|
||||||
_translationField.Value = position;
|
_scaleField.BindTwoWay(property,
|
||||||
_rotationField.Value = math.degrees(math.EulerXYZ(rotation));
|
getter: node =>
|
||||||
_scaleField.Value = scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Destroy()
|
|
||||||
{
|
{
|
||||||
|
var matrix = node.Value;
|
||||||
|
var scaleX = math.length(matrix.c0.xyz);
|
||||||
|
var scaleY = math.length(matrix.c1.xyz);
|
||||||
|
var scaleZ = math.length(matrix.c2.xyz);
|
||||||
|
return new float3(scaleX, scaleY, scaleZ);
|
||||||
|
},
|
||||||
|
setter: (node, val) =>
|
||||||
|
{
|
||||||
|
var data = node.Value;
|
||||||
|
data.GetTRS(out var oldTranslation, out var oldRotation, out _);
|
||||||
|
data = float4x4.TRS(oldTranslation, oldRotation, val);
|
||||||
|
node.SetValueFromUI(data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Editor.Core.Utilities;
|
||||||
|
using Ghost.Editor.Views.Controls;
|
||||||
|
using Ghost.Engine.Core;
|
||||||
|
|
||||||
namespace Ghost.Editor.Views.Controls;
|
namespace Ghost.Editor.ContextMenu;
|
||||||
|
|
||||||
internal partial class ContentBrowser
|
internal static class ContentBrowserContextMenu
|
||||||
{
|
{
|
||||||
[ContextMenuItem("project-browser", "Show in Explorer")]
|
[ContextMenuItem("project-browser", "Show in Explorer")]
|
||||||
private static void ShowInExplorer()
|
private static void ShowInExplorer()
|
||||||
{
|
{
|
||||||
var path = LastFocused?.ViewModel.CurrentDirectoryPath;
|
var path = ContentBrowser.LastFocused?.ViewModel.CurrentDirectoryPath;
|
||||||
if (!Directory.Exists(path))
|
if (!Directory.Exists(path))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -26,7 +30,7 @@ internal partial class ContentBrowser
|
|||||||
{
|
{
|
||||||
// TODO: Use AssetService
|
// TODO: Use AssetService
|
||||||
|
|
||||||
var viewModel = LastFocused?.ViewModel;
|
var viewModel = ContentBrowser.LastFocused?.ViewModel;
|
||||||
if (viewModel is null)
|
if (viewModel is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -50,4 +54,28 @@ internal partial class ContentBrowser
|
|||||||
// Refresh the view model to show the new folder
|
// Refresh the view model to show the new folder
|
||||||
viewModel.NavigateToDirectory(currentDir);
|
viewModel.NavigateToDirectory(currentDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("project-browser", "Create/Asset/Scene")]
|
||||||
|
private static void CreateSceneAsset()
|
||||||
|
{
|
||||||
|
var viewModel = ContentBrowser.LastFocused?.ViewModel;
|
||||||
|
if (viewModel is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentDir = viewModel.CurrentDirectoryPath;
|
||||||
|
if (!Directory.Exists(currentDir))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newScenePath = PathUtility.GetUniqueName(Path.Combine(currentDir, "New Scene.gscene"));
|
||||||
|
var tempScene = SceneManager.CreateScene();
|
||||||
|
|
||||||
|
var sceneSerializationService = App.GetService<SceneSerializationService>();
|
||||||
|
sceneSerializationService.SaveSceneFromEditorWorld(newScenePath, tempScene);
|
||||||
|
|
||||||
|
SceneManager.DestroyScene(tempScene, App.GetService<IEditorWorldService>().EditorWorld);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
34
src/Editor/Ghost.Editor/ContextMenu/EditPageMenu.cs
Normal file
34
src/Editor/Ghost.Editor/ContextMenu/EditPageMenu.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Editor.Core;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Windows.System;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.ContextMenu;
|
||||||
|
|
||||||
|
internal static class EditPageContextMenu
|
||||||
|
{
|
||||||
|
[Shortcut(VirtualKey.S, VirtualKeyModifiers.Control)]
|
||||||
|
[ContextMenuItem("edit-page-menu", "File/Save")]
|
||||||
|
private static async void MenuBar_Save()
|
||||||
|
{
|
||||||
|
if (EditorApplication.State != EditorState.Idle)
|
||||||
|
{
|
||||||
|
Logger.Warning("Cannot save while the editor is busy.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await App.GetService<Ghost.Editor.Core.Contracts.IAssetRegistry>().SaveDirtyAssetsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("edit-page-menu", "Edit/Undo", priority: 1, group: 1)]
|
||||||
|
private static void MenuBar_Undo()
|
||||||
|
{
|
||||||
|
App.GetService<IUndoService>().PerformUndo();
|
||||||
|
}
|
||||||
|
|
||||||
|
[ContextMenuItem("edit-page-menu", "Edit/Redo", priority: 0, group: 1)]
|
||||||
|
private static void MenuBar_Redo()
|
||||||
|
{
|
||||||
|
App.GetService<IUndoService>().PerformRedo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@
|
|||||||
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
|
||||||
<UseWinUI>true</UseWinUI>
|
<UseWinUI>true</UseWinUI>
|
||||||
<EnableMsixTooling>true</EnableMsixTooling>
|
<EnableMsixTooling>true</EnableMsixTooling>
|
||||||
<!-- in .net 10, field keyword is not preview anymore, but we are still waiting roslyn team to update their code analyzer packages -->
|
<Configurations>Debug;Release;Debug_Editor;Release_Editor</Configurations>
|
||||||
<langversion>preview</langversion>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Views\Controls\Hierarchy.xaml" />
|
<None Remove="Views\Controls\Hierarchy.xaml" />
|
||||||
|
<None Remove="Views\Controls\Inspector.xaml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="Assets\SplashScreen.scale-200.png" />
|
<Content Include="Assets\SplashScreen.scale-200.png" />
|
||||||
@@ -38,10 +38,10 @@
|
|||||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" Version="8.2.251219" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
|
||||||
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
|
<PackageReference Include="CommunityToolkit.WinUI.Controls.TabbedCommandBar" Version="8.2.251219" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
<PackageReference Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1839" />
|
||||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
|
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.1.3" />
|
||||||
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
<PackageReference Include="WinUIEx" Version="2.9.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -141,6 +141,9 @@
|
|||||||
<None Update="Assets\icon.ico">
|
<None Update="Assets\icon.ico">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<Page Update="Views\Controls\Inspector.xaml">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
</Page>
|
||||||
<Page Update="Views\Controls\LogViewer.xaml">
|
<Page Update="Views\Controls\LogViewer.xaml">
|
||||||
<SubType>Designer</SubType>
|
<SubType>Designer</SubType>
|
||||||
</Page>
|
</Page>
|
||||||
@@ -200,7 +203,6 @@
|
|||||||
</Page>
|
</Page>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="ContextMenu\" />
|
|
||||||
<Folder Include="ViewModels\Pages\" />
|
<Folder Include="ViewModels\Pages\" />
|
||||||
<Folder Include="Views\Pages\" />
|
<Folder Include="Views\Pages\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -215,17 +217,45 @@
|
|||||||
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)'=='Debug_Editor'">
|
||||||
|
<XamlDebuggingInformation>True</XamlDebuggingInformation>
|
||||||
|
<DisableXbfLineInfo>False</DisableXbfLineInfo>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Publish Properties -->
|
<!-- Publish Properties -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(Configuration)'=='Debug_Editor'">False</PublishReadyToRun>
|
||||||
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
|
||||||
<PublishTrimmed Condition="'$(Configuration)' != 'Debug'">True</PublishTrimmed>
|
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
<SupportedOSPlatformVersion>10.0.20348.0</SupportedOSPlatformVersion>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<PublishAot>False</PublishAot>
|
|
||||||
<PublishTrimmed>False</PublishTrimmed>
|
|
||||||
<RootNamespace>Ghost.Editor</RootNamespace>
|
<RootNamespace>Ghost.Editor</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|x64'">
|
||||||
|
<Optimize>True</Optimize>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release_Editor|ARM64'">
|
||||||
|
<Optimize>True</Optimize>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|x64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug_Editor|ARM64'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Ghost.Core.Utilities;
|
using Ghost.Engine.Streaming;
|
||||||
using Ghost.Engine;
|
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace Ghost.Editor.Models;
|
namespace Ghost.Editor.Models;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Ghost.Core.Utilities;
|
|
||||||
using Ghost.Editor.Core;
|
using Ghost.Editor.Core;
|
||||||
using Ghost.Editor.Core.Assets;
|
using Ghost.Editor.Core.Assets;
|
||||||
using Ghost.Editor.Core.Contracts;
|
using Ghost.Editor.Core.Contracts;
|
||||||
using Ghost.Editor.Core.Utilities;
|
using Ghost.Editor.Core.Utilities;
|
||||||
using Ghost.Editor.Models;
|
using Ghost.Editor.Models;
|
||||||
using Ghost.Engine;
|
using Ghost.Engine.Streaming;
|
||||||
using Microsoft.UI.Dispatching;
|
using Microsoft.UI.Dispatching;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
@@ -157,7 +156,7 @@ internal partial class ContentBrowserViewModel : ObservableObject
|
|||||||
CurrentDirectoryPath = Path.GetFullPath(path);
|
CurrentDirectoryPath = Path.GetFullPath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal (ExplorerItem?, int) OpenSelected()
|
internal async ValueTask<(ExplorerItem?, int)> OpenSelected()
|
||||||
{
|
{
|
||||||
if (SelectedItem == null)
|
if (SelectedItem == null)
|
||||||
{
|
{
|
||||||
@@ -172,7 +171,12 @@ internal partial class ContentBrowserViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// _assetRegistry.OpenAsset(SelectedItem.FullName);
|
var result = await _assetRegistry.OpenAssetAsync(SelectedItem.Path);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
return (null, -1);
|
||||||
|
}
|
||||||
|
|
||||||
return (null, 1);
|
return (null, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</GridView.ItemTemplate>
|
</GridView.ItemTemplate>
|
||||||
<GridView.ContextFlyout>
|
<GridView.ContextFlyout>
|
||||||
<ghost:ContextFlyout Tag="project-browser" />
|
<ghost:ContextFlyout ContextMenuTag="project-browser" />
|
||||||
</GridView.ContextFlyout>
|
</GridView.ContextFlyout>
|
||||||
</GridView>
|
</GridView>
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
private void ProjectBrowser_Loaded(object sender, RoutedEventArgs e)
|
private void ProjectBrowser_Loaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_inspectorService.OnSelectionChanged += _inspectorService_OnSelectionChanged;
|
//_inspectorService.OnSelectionChanged += _inspectorService_OnSelectionChanged;
|
||||||
GettingFocus += ProjectBrowser_GettingFocus;
|
GettingFocus += ProjectBrowser_GettingFocus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
private void ProjectBrowser_Unloaded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
//_inspectorService.OnSelectionChanged -= _inspectorService_OnSelectionChanged;
|
||||||
GettingFocus -= ProjectBrowser_GettingFocus;
|
GettingFocus -= ProjectBrowser_GettingFocus;
|
||||||
|
|
||||||
if (LastFocused == this)
|
if (LastFocused == this)
|
||||||
@@ -62,14 +62,14 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
//private void _inspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
||||||
{
|
//{
|
||||||
if (e.Source is not ContentBrowserViewModel)
|
// if (e.Source is not ContentBrowserViewModel)
|
||||||
{
|
// {
|
||||||
PART_FilesView.DeselectAll();
|
// PART_FilesView.DeselectAll();
|
||||||
PART_DirectoriesView.SelectedNodes.Clear();
|
// PART_DirectoriesView.SelectedNodes.Clear();
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
private void PART_DirectoriesView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
|
private void PART_DirectoriesView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
_isUpdatingSelection = true;
|
_isUpdatingSelection = true;
|
||||||
|
|
||||||
PART_FilesView.DeselectAll();
|
//PART_FilesView.DeselectAll();
|
||||||
if (args.AddedItems.Count > 0 && args.AddedItems[0] is ExplorerItem selectedItem)
|
if (args.AddedItems.Count > 0 && args.AddedItems[0] is ExplorerItem selectedItem)
|
||||||
{
|
{
|
||||||
ViewModel.SelectedItem = selectedItem;
|
ViewModel.SelectedItem = selectedItem;
|
||||||
@@ -99,7 +99,7 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
_isUpdatingSelection = true;
|
_isUpdatingSelection = true;
|
||||||
|
|
||||||
PART_DirectoriesView.SelectedNodes.Clear();
|
//PART_DirectoriesView.SelectedNodes.Clear();
|
||||||
if (PART_FilesView.SelectedItem is ExplorerItem selectedItem)
|
if (PART_FilesView.SelectedItem is ExplorerItem selectedItem)
|
||||||
{
|
{
|
||||||
ViewModel.SelectedItem = selectedItem;
|
ViewModel.SelectedItem = selectedItem;
|
||||||
@@ -127,7 +127,7 @@ internal sealed partial class ContentBrowser : UserControl
|
|||||||
|
|
||||||
ViewModel.SelectedItem = selectedItem;
|
ViewModel.SelectedItem = selectedItem;
|
||||||
|
|
||||||
var navigatedItem = ViewModel.OpenSelected();
|
var navigatedItem = await ViewModel.OpenSelected();
|
||||||
if (navigatedItem.Item1 != null)
|
if (navigatedItem.Item1 != null)
|
||||||
{
|
{
|
||||||
if (navigatedItem.Item2 == 0)
|
if (navigatedItem.Item2 == 0)
|
||||||
|
|||||||
@@ -6,57 +6,108 @@
|
|||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:local="using:Ghost.Editor.Views.Controls"
|
xmlns:local="using:Ghost.Editor.Views.Controls"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:sg="using:Ghost.Editor.Core.SceneGraph"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<local:SceneGraphTemplateSelector
|
||||||
|
x:Key="SceneGraphTemplateSelector"
|
||||||
|
EntityNodeTemplate="{StaticResource EntityNodeTemplate}"
|
||||||
|
SceneNodeTemplate="{StaticResource SceneNodeTemplate}" />
|
||||||
|
|
||||||
|
<DataTemplate x:Key="SceneNodeTemplate" x:DataType="sg:SceneNode">
|
||||||
|
<TreeViewItem
|
||||||
|
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
|
||||||
|
IsExpanded="True"
|
||||||
|
ItemsSource="{x:Bind Children, Mode=OneWay}">
|
||||||
|
<TreeViewItem.ContextFlyout>
|
||||||
|
<MenuFlyout>
|
||||||
|
<MenuFlyoutItem Click="OnCreateEntityClick" Text="Create Entity" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</TreeViewItem.ContextFlyout>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
|
<TextBlock Margin="10,0,0,0" Text="{x:Bind Name, Mode=OneWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<DataTemplate x:Key="EntityNodeTemplate" x:DataType="sg:EntityNode">
|
||||||
|
<TreeViewItem AutomationProperties.Name="{x:Bind Name, Mode=OneWay}" ItemsSource="{x:Bind Children, Mode=OneWay}">
|
||||||
|
<TreeViewItem.ContextFlyout>
|
||||||
|
<MenuFlyout>
|
||||||
|
<MenuFlyoutItem Click="OnCreateChildClick" Text="Create Child" />
|
||||||
|
<MenuFlyoutItem Click="OnDeleteEntityClick" Text="Delete" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</TreeViewItem.ContextFlyout>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<FontIcon FontSize="14" Glyph="" />
|
||||||
|
<TextBlock Margin="5,0,0,0" Text="{x:Bind Name, Mode=OneWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
</TreeViewItem>
|
||||||
|
</DataTemplate>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<Grid
|
<StackPanel
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Height="40"
|
Padding="8,2,4,4"
|
||||||
Padding="8,8,8,4"
|
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
|
||||||
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
|
<Grid>
|
||||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
|
||||||
BorderThickness="0,0,0,1">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<AutoSuggestBox
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
PlaceholderText="Search"
|
HorizontalAlignment="Left"
|
||||||
QueryIcon="Find" />
|
VerticalAlignment="Center"
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||||
<AppBarSeparator />
|
Style="{StaticResource BodyLargeStrongTextBlockStyle}"
|
||||||
<Button Style="{ThemeResource ToolbarButton}">
|
Text="Hierarchy" />
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
<Button
|
||||||
|
Grid.Column="1"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
Style="{ThemeResource ToolbarButton}">
|
||||||
|
<FontIcon Glyph="" />
|
||||||
</Button>
|
</Button>
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Row="1" Padding="4">
|
<Grid Margin="0,2">
|
||||||
<ListView>
|
<Grid.ColumnDefinitions>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<ColumnDefinition Width="Auto" />
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
<ColumnDefinition Width="*" />
|
||||||
<TextBlock Text="Test" />
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<FontIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
FontSize="{StaticResource ToolbarFontIconFontSize}"
|
||||||
|
Glyph="" />
|
||||||
|
<TextBox Grid.Column="1" PlaceholderText="Search item..." />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border Margin="-8,8,-4,-4" Style="{StaticResource HorizontalStrongDivider}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
<TreeView
|
||||||
<TextBlock Text="Test" />
|
x:Name="SceneTreeView"
|
||||||
</StackPanel>
|
Grid.Row="1"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
Margin="4,2,0,2"
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
AllowDrop="True"
|
||||||
<TextBlock Text="Test" />
|
CanDrag="True"
|
||||||
</StackPanel>
|
CanDragItems="True"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
CanReorderItems="True"
|
||||||
<FontIcon FontSize="{StaticResource ToolbarIconSize}" Glyph="" />
|
DragItemsCompleted="OnTreeViewDragItemsCompleted"
|
||||||
<TextBlock Text="Test" />
|
DragItemsStarting="OnTreeViewDragItemsStarting"
|
||||||
</StackPanel>
|
ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}"
|
||||||
</ListView>
|
KeyDown="OnTreeViewKeyDown"
|
||||||
</Border>
|
SelectionMode="Single" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,14 +1,228 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Ghost.Core;
|
||||||
|
using Ghost.Engine;
|
||||||
|
using Ghost.Entities;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Input;
|
||||||
// To learn more about WinUI, the WinUI project structure,
|
|
||||||
// and more about our project templates, see: http://aka.ms/winui-project-info.
|
|
||||||
|
|
||||||
namespace Ghost.Editor.Views.Controls;
|
namespace Ghost.Editor.Views.Controls;
|
||||||
|
|
||||||
public sealed partial class Hierarchy : UserControl
|
public sealed partial class Hierarchy : UserControl
|
||||||
{
|
{
|
||||||
|
private readonly IInspectorService _inspectorService;
|
||||||
|
private readonly IEditorWorldService _worldService;
|
||||||
|
private readonly SceneGraphSyncService _syncService;
|
||||||
|
private EntityNode? _draggedNode;
|
||||||
|
|
||||||
public Hierarchy()
|
public Hierarchy()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
|
_inspectorService = App.GetService<IInspectorService>();
|
||||||
|
|
||||||
|
// We resolve SceneGraphSyncService here to force the DI container to instantiate it.
|
||||||
|
// This ensures the singleton hooks into EditorWorldService events and starts populating RootNodes.
|
||||||
|
_syncService = App.GetService<SceneGraphSyncService>();
|
||||||
|
|
||||||
|
_worldService = App.GetService<IEditorWorldService>();
|
||||||
|
|
||||||
|
SceneTreeView.ItemsSource = _worldService.RootNodes;
|
||||||
|
|
||||||
|
SceneTreeView.ItemInvoked += OnTreeViewItemInvoked;
|
||||||
|
SceneTreeView.SelectionChanged += OnTreeViewSelectionChanged;
|
||||||
|
|
||||||
|
Unloaded += OnUnloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.InvokedItem is IInspectable inspectable)
|
||||||
|
{
|
||||||
|
_inspectorService.SetSelected(inspectable, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewSelectionChanged(object sender, TreeViewSelectionChangedEventArgs args)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewKeyDown(object sender, KeyRoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == global::Windows.System.VirtualKey.Delete)
|
||||||
|
{
|
||||||
|
if (SceneTreeView.SelectedItem is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_worldService.DestroyEntity(entityNode.Entity);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewDragItemsStarting(TreeView sender, TreeViewDragItemsStartingEventArgs args)
|
||||||
|
{
|
||||||
|
if (args.Items.Count > 0 && args.Items[0] is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_draggedNode = entityNode;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_draggedNode = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTreeViewDragItemsCompleted(TreeView sender, TreeViewDragItemsCompletedEventArgs args)
|
||||||
|
{
|
||||||
|
var entityNode = args.Items.Count > 0 ? args.Items[0] as EntityNode : _draggedNode;
|
||||||
|
_draggedNode = null;
|
||||||
|
|
||||||
|
if (entityNode == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.DropResult != global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.NewParentItem is not SceneGraphNode newParent)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newParent == entityNode)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = Error.None;
|
||||||
|
|
||||||
|
if (newParent is EntityNode parentEntityNode)
|
||||||
|
{
|
||||||
|
if (HierarchyUtility.IsAncestor(_worldService.EditorWorld, parentEntityNode.Entity, entityNode.Entity))
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentParent = GetCurrentParent(entityNode);
|
||||||
|
if (currentParent == parentEntityNode.Entity)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = _worldService.SetParent(entityNode.Entity, parentEntityNode.Entity);
|
||||||
|
}
|
||||||
|
else if (newParent is SceneNode sceneNode)
|
||||||
|
{
|
||||||
|
var currentParent = GetCurrentParent(entityNode);
|
||||||
|
var sceneChanged = _worldService.GetEntitySceneID(entityNode.Entity) != sceneNode.Scene.ID;
|
||||||
|
if (!currentParent.IsValid && !sceneChanged)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentParent.IsValid)
|
||||||
|
{
|
||||||
|
result = _worldService.RemoveParent(entityNode.Entity);
|
||||||
|
if (result != Error.None)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sceneChanged)
|
||||||
|
{
|
||||||
|
_worldService.ChangeEntityScene(entityNode.Entity, sceneNode.Scene.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != Error.None)
|
||||||
|
{
|
||||||
|
RebuildSceneGraphFromECS();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCreateEntityClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is SceneNode sceneNode)
|
||||||
|
{
|
||||||
|
_worldService.CreateEntity("Entity", sceneNode.Scene.ID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCreateChildClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
var sceneID = _worldService.GetEntitySceneID(entityNode.Entity);
|
||||||
|
if (sceneID != Engine.Core.Scene.INVALID_ID)
|
||||||
|
{
|
||||||
|
_worldService.CreateEntity("Entity", sceneID, parent: entityNode.Entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDeleteEntityClick(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
_worldService.DestroyEntity(entityNode.Entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Entity GetCurrentParent(EntityNode entityNode)
|
||||||
|
{
|
||||||
|
if (!_worldService.EditorWorld.EntityManager.HasComponent<Ghost.Engine.Components.Hierarchy>(entityNode.Entity))
|
||||||
|
{
|
||||||
|
return Entity.Invalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _worldService.EditorWorld.EntityManager.GetComponent<Ghost.Engine.Components.Hierarchy>(entityNode.Entity).parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildSceneGraphFromECS()
|
||||||
|
{
|
||||||
|
var names = new Dictionary<Entity, string>();
|
||||||
|
foreach (var sceneNode in _worldService.RootNodes)
|
||||||
|
{
|
||||||
|
CaptureEntityNames(sceneNode, names);
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldService.RebuildSceneGraph(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CaptureEntityNames(SceneGraphNode node, Dictionary<Entity, string> names)
|
||||||
|
{
|
||||||
|
if (node is EntityNode entityNode)
|
||||||
|
{
|
||||||
|
names[entityNode.Entity] = entityNode.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
CaptureEntityNames(child, names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SceneTreeView.ItemInvoked -= OnTreeViewItemInvoked;
|
||||||
|
SceneTreeView.SelectionChanged -= OnTreeViewSelectionChanged;
|
||||||
|
Unloaded -= OnUnloaded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/Editor/Ghost.Editor/Views/Controls/Inspector.xaml
Normal file
68
src/Editor/Ghost.Editor/Views/Controls/Inspector.xaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<UserControl
|
||||||
|
x:Class="Ghost.Editor.Views.Controls.Inspector"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:inspector="using:Ghost.Editor.Core.Inspector"
|
||||||
|
xmlns:local="using:Ghost.Editor.Views.Controls"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Inspector Header -->
|
||||||
|
<Grid
|
||||||
|
Grid.Row="0"
|
||||||
|
Padding="12,12,8,12"
|
||||||
|
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
||||||
|
BorderBrush="{ThemeResource ControlElevationBorderBrush}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<ContentControl
|
||||||
|
x:Name="IconPresenter"
|
||||||
|
Grid.Column="0"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalContentAlignment="Stretch" />
|
||||||
|
|
||||||
|
<ContentControl
|
||||||
|
x:Name="HeaderPresenter"
|
||||||
|
Grid.Column="1"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalContentAlignment="Stretch" />
|
||||||
|
|
||||||
|
<DropDownButton
|
||||||
|
Grid.Column="2"
|
||||||
|
Padding="2"
|
||||||
|
Style="{ThemeResource ToolbarButton}">
|
||||||
|
<DropDownButton.Flyout>
|
||||||
|
<MenuFlyout Placement="Bottom">
|
||||||
|
<MenuFlyoutItem Text="Send" />
|
||||||
|
</MenuFlyout>
|
||||||
|
</DropDownButton.Flyout>
|
||||||
|
<FontIcon FontSize="12" Glyph="" />
|
||||||
|
</DropDownButton>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<ScrollView x:Name="ContentScrollView" Grid.Row="1">
|
||||||
|
<StackPanel
|
||||||
|
x:Name="InspectorContentContainer"
|
||||||
|
Padding="0,4,0,12"
|
||||||
|
Orientation="Vertical"
|
||||||
|
Spacing="4" />
|
||||||
|
</ScrollView>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
89
src/Editor/Ghost.Editor/Views/Controls/Inspector.xaml.cs
Normal file
89
src/Editor/Ghost.Editor/Views/Controls/Inspector.xaml.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Ghost.Editor.Core.Contracts;
|
||||||
|
using Ghost.Editor.Core.Services;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Views.Controls;
|
||||||
|
|
||||||
|
public sealed partial class Inspector : UserControl
|
||||||
|
{
|
||||||
|
private readonly IInspectorService _inspectorService;
|
||||||
|
private readonly InspectorSyncService _syncService;
|
||||||
|
|
||||||
|
private IInspectorModel? _currentModel;
|
||||||
|
|
||||||
|
public Inspector()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_inspectorService = App.GetService<IInspectorService>();
|
||||||
|
_syncService = App.GetService<InspectorSyncService>();
|
||||||
|
|
||||||
|
_inspectorService.OnSelectionChanged += InspectorService_OnSelectionChanged;
|
||||||
|
Loaded += Inspector_Loaded;
|
||||||
|
Unloaded += Inspector_Unloaded;
|
||||||
|
|
||||||
|
if (_inspectorService.Selected != null)
|
||||||
|
{
|
||||||
|
BuildInspector(_inspectorService.Selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Inspector_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_syncService.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Inspector_Unloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_syncService.Unbind();
|
||||||
|
_currentModel?.Dispose();
|
||||||
|
_currentModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InspectorService_OnSelectionChanged(object? sender, InspectorSelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
BuildInspector(e.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildInspector(IInspectable? inspectable)
|
||||||
|
{
|
||||||
|
// Cleanup old
|
||||||
|
_syncService.Unbind();
|
||||||
|
_currentModel?.Dispose();
|
||||||
|
_currentModel = null;
|
||||||
|
InspectorContentContainer.Children.Clear();
|
||||||
|
|
||||||
|
if (inspectable == null)
|
||||||
|
{
|
||||||
|
IconPresenter.Content = null;
|
||||||
|
HeaderPresenter.Content = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set header
|
||||||
|
var icon = inspectable.CreateIcon();
|
||||||
|
if (icon != null)
|
||||||
|
{
|
||||||
|
IconPresenter.Content = new IconSourceElement { IconSource = icon };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
IconPresenter.Content = new FontIcon { Glyph = "\uF158", FontSize = 18 };
|
||||||
|
}
|
||||||
|
|
||||||
|
HeaderPresenter.Content = inspectable.CreateHeader();
|
||||||
|
|
||||||
|
// Build body
|
||||||
|
_currentModel = inspectable.CreateInspectorModel();
|
||||||
|
if (_currentModel != null)
|
||||||
|
{
|
||||||
|
InspectorContentContainer.Children.Add(_currentModel.BuildUI());
|
||||||
|
|
||||||
|
if (_currentModel is ISyncableInspectorModel syncableModel)
|
||||||
|
{
|
||||||
|
_syncService.Bind(syncableModel);
|
||||||
|
syncableModel.Sync(); // Initial sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Ghost.Editor.Core.SceneGraph;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
namespace Ghost.Editor.Views.Controls;
|
||||||
|
|
||||||
|
public partial class SceneGraphTemplateSelector : DataTemplateSelector
|
||||||
|
{
|
||||||
|
public DataTemplate? SceneNodeTemplate
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataTemplate? EntityNodeTemplate
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DataTemplate SelectTemplateCore(object item)
|
||||||
|
{
|
||||||
|
var result = item switch
|
||||||
|
{
|
||||||
|
SceneNode => SceneNodeTemplate,
|
||||||
|
EntityNode => EntityNodeTemplate,
|
||||||
|
_ => base.SelectTemplateCore(item)
|
||||||
|
};
|
||||||
|
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
|
||||||
|
{
|
||||||
|
return SelectTemplateCore(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:controls="using:Ghost.Editor.Views.Controls"
|
xmlns:controls="using:Ghost.Editor.Views.Controls"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:ghost="using:Ghost.Editor.Core.Controls"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
Background="{ThemeResource LayerFillColorDefaultBrush}"
|
Background="{ThemeResource LayerFillColorDefaultBrush}"
|
||||||
NavigationCacheMode="Enabled"
|
NavigationCacheMode="Enabled"
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
|
|
||||||
<Border Height="12" Style="{StaticResource VerticalDivider}" />
|
<Border Height="12" Style="{StaticResource VerticalDivider}" />
|
||||||
|
|
||||||
<MenuBar>
|
<!--<MenuBar x:Name="TopMenuBar">
|
||||||
<MenuBarItem Title="File">
|
<MenuBarItem Title="File">
|
||||||
<MenuFlyoutItem Text="New" />
|
<MenuFlyoutItem Text="New" />
|
||||||
<MenuFlyoutItem Text="Open..." />
|
<MenuFlyoutItem Text="Open..." />
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
|
|
||||||
<MenuBarItem Title="Edit">
|
<MenuBarItem Title="Edit">
|
||||||
<MenuFlyoutItem Text="Undo" />
|
<MenuFlyoutItem Text="Undo" />
|
||||||
|
<MenuFlyoutItem Text="Redo" />
|
||||||
<MenuFlyoutItem Text="Cut" />
|
<MenuFlyoutItem Text="Cut" />
|
||||||
<MenuFlyoutItem Text="Copy" />
|
<MenuFlyoutItem Text="Copy" />
|
||||||
<MenuFlyoutItem Text="Paste" />
|
<MenuFlyoutItem Text="Paste" />
|
||||||
@@ -66,7 +68,8 @@
|
|||||||
<MenuBarItem Title="Help">
|
<MenuBarItem Title="Help">
|
||||||
<MenuFlyoutItem Text="About" />
|
<MenuFlyoutItem Text="About" />
|
||||||
</MenuBarItem>
|
</MenuBarItem>
|
||||||
</MenuBar>
|
</MenuBar>-->
|
||||||
|
<ghost:MenuContextBar ContextMenuTag="edit-page-menu" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel
|
<StackPanel
|
||||||
@@ -95,67 +98,12 @@
|
|||||||
<ColumnDefinition Width="0.25*" MaxWidth="350" />
|
<ColumnDefinition Width="0.25*" MaxWidth="350" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<!-- Hierarchy -->
|
<Border
|
||||||
<Grid
|
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||||
BorderThickness="0,0,1,0">
|
BorderThickness="0,0,1,0">
|
||||||
<Grid.RowDefinitions>
|
<controls:Hierarchy />
|
||||||
<RowDefinition Height="Auto" />
|
</Border>
|
||||||
<RowDefinition Height="*" />
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<StackPanel
|
|
||||||
Grid.Row="0"
|
|
||||||
Padding="8,2,4,4"
|
|
||||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<TextBlock
|
|
||||||
Grid.Column="0"
|
|
||||||
HorizontalAlignment="Left"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
|
||||||
Style="{StaticResource BodyLargeStrongTextBlockStyle}"
|
|
||||||
Text="Hierarchy" />
|
|
||||||
<Button
|
|
||||||
Grid.Column="1"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
Style="{ThemeResource ToolbarButton}">
|
|
||||||
<FontIcon Glyph="" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid Margin="0,2">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<FontIcon
|
|
||||||
Grid.Column="0"
|
|
||||||
Margin="0,0,4,0"
|
|
||||||
FontSize="{StaticResource ToolbarFontIconFontSize}"
|
|
||||||
Glyph="" />
|
|
||||||
<TextBox Grid.Column="1" PlaceholderText="Sreach item..." />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Margin="-8,8,-4,-4" Style="{StaticResource HorizontalStrongDivider}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<ListView Grid.Row="1" Padding="4,2,0,2">
|
|
||||||
<ListViewItem Content="Test" />
|
|
||||||
<ListViewItem Content="Test" />
|
|
||||||
<ListViewItem Content="Test" />
|
|
||||||
<ListViewItem Content="Test" />
|
|
||||||
<ListViewItem Content="Test" />
|
|
||||||
<ListViewItem Content="Test" />
|
|
||||||
</ListView>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Scene and Content -->
|
<!-- Scene and Content -->
|
||||||
<Grid Grid.Column="1">
|
<Grid Grid.Column="1">
|
||||||
@@ -195,7 +143,6 @@
|
|||||||
Stretch="UniformToFill" />
|
Stretch="UniformToFill" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
||||||
<!-- Content Brower -->
|
<!-- Content Brower -->
|
||||||
<Border
|
<Border
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
@@ -213,105 +160,12 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Inspector -->
|
<!-- Inspector -->
|
||||||
<Grid
|
<Border
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||||
BorderThickness="1,0,0,0">
|
BorderThickness="1,0,0,0">
|
||||||
<Grid.RowDefinitions>
|
<controls:Inspector />
|
||||||
<RowDefinition Height="Auto" />
|
</Border>
|
||||||
<RowDefinition Height="*" />
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Inspector Header -->
|
|
||||||
<Grid
|
|
||||||
Grid.Row="0"
|
|
||||||
Padding="12,12,8,12"
|
|
||||||
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
|
|
||||||
BorderBrush="{ThemeResource ControlElevationBorderBrush}"
|
|
||||||
BorderThickness="0,0,0,1"
|
|
||||||
ColumnSpacing="8">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<FontIcon
|
|
||||||
Grid.Column="0"
|
|
||||||
FontSize="18"
|
|
||||||
Glyph="" />
|
|
||||||
<TextBox
|
|
||||||
Grid.Column="1"
|
|
||||||
FontSize="14"
|
|
||||||
Text="Name" />
|
|
||||||
<DropDownButton
|
|
||||||
Grid.Column="2"
|
|
||||||
Padding="2"
|
|
||||||
Style="{ThemeResource ToolbarButton}">
|
|
||||||
<DropDownButton.Flyout>
|
|
||||||
<MenuFlyout Placement="Bottom">
|
|
||||||
<MenuFlyoutItem Text="Send" />
|
|
||||||
<MenuFlyoutItem Text="Reply" />
|
|
||||||
<MenuFlyoutItem Text="Reply All" />
|
|
||||||
</MenuFlyout>
|
|
||||||
</DropDownButton.Flyout>
|
|
||||||
<FontIcon FontSize="12" Glyph="" />
|
|
||||||
</DropDownButton>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<Grid Grid.Row="1">
|
|
||||||
<Grid.RowDefinitions>
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="*" MaxHeight="150" />
|
|
||||||
<RowDefinition Height="*" />
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<Grid Grid.Row="0" Padding="8,2">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<TextBlock
|
|
||||||
Grid.Column="0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Style="{StaticResource BodyStrongTextBlockStyle}"
|
|
||||||
Text="Components" />
|
|
||||||
<Button Grid.Column="1" Style="{ThemeResource ToolbarButton}">
|
|
||||||
<FontIcon FontSize="{StaticResource ToolbarFontIconFontSize}" Glyph="" />
|
|
||||||
</Button>
|
|
||||||
<Button Grid.Column="2" Style="{ThemeResource ToolbarButton}">
|
|
||||||
<FontIcon FontSize="{StaticResource ToolbarFontIconFontSize}" Glyph="" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<AutoSuggestBox
|
|
||||||
Grid.Row="1"
|
|
||||||
Margin="8,0"
|
|
||||||
PlaceholderText="Search components..." />
|
|
||||||
|
|
||||||
<!-- Components List -->
|
|
||||||
<ListView
|
|
||||||
Grid.Row="2"
|
|
||||||
Padding="4,2,0,2"
|
|
||||||
SelectionMode="Extended">
|
|
||||||
<TextBlock Text="Test" />
|
|
||||||
<TextBlock Text="Test" />
|
|
||||||
<TextBlock Text="Test" />
|
|
||||||
</ListView>
|
|
||||||
|
|
||||||
<!-- Component Properties for Selected Component -->
|
|
||||||
<ScrollView
|
|
||||||
Grid.Row="3"
|
|
||||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
|
||||||
BorderThickness="0,1,0,0">
|
|
||||||
<ItemsRepeater />
|
|
||||||
</ScrollView>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Ghost.Editor.Views.Controls;
|
using Ghost.Editor.Views.Controls;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace Ghost.Editor.Views.Pages;
|
namespace Ghost.Editor.Views.Pages;
|
||||||
|
|
||||||
@@ -15,6 +16,17 @@ public sealed partial class EditPage : Page
|
|||||||
ContentBrowserPresenter.Content = GetContentBrowser();
|
ContentBrowserPresenter.Content = GetContentBrowser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MenuFlyoutItem CreateNewMenuItem(string name, MethodInfo methodInfo)
|
||||||
|
{
|
||||||
|
var menuItem = new MenuFlyoutItem { Text = name };
|
||||||
|
menuItem.Click += (s, e) =>
|
||||||
|
{
|
||||||
|
methodInfo.Invoke(null, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return menuItem;
|
||||||
|
}
|
||||||
|
|
||||||
private ContentBrowser GetContentBrowser()
|
private ContentBrowser GetContentBrowser()
|
||||||
{
|
{
|
||||||
return _contentBrowser ??= new ContentBrowser();
|
return _contentBrowser ??= new ContentBrowser();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Configurations>
|
<Configurations>
|
||||||
|
<BuildType Name="Debug" />
|
||||||
|
<BuildType Name="Debug_Editor" />
|
||||||
|
<BuildType Name="Release" />
|
||||||
|
<BuildType Name="Release_Editor" />
|
||||||
<Platform Name="ARM64" />
|
<Platform Name="ARM64" />
|
||||||
<Platform Name="x64" />
|
<Platform Name="x64" />
|
||||||
<Platform Name="x86" />
|
<Platform Name="x86" />
|
||||||
@@ -9,12 +13,18 @@
|
|||||||
<Project Path="Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj">
|
<Project Path="Editor/Ghost.Editor.Core/Ghost.Editor.Core.csproj">
|
||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
<Platform Solution="*|x86" Project="x86" />
|
<Platform Solution="Debug_Editor|x86" Project="x64" />
|
||||||
|
<Platform Solution="Debug|x86" Project="x86" />
|
||||||
|
<Platform Solution="Release_Editor|x86" Project="x64" />
|
||||||
|
<Platform Solution="Release|x86" Project="x86" />
|
||||||
</Project>
|
</Project>
|
||||||
<Project Path="Editor/Ghost.Editor/Ghost.Editor.csproj">
|
<Project Path="Editor/Ghost.Editor/Ghost.Editor.csproj">
|
||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
<Platform Solution="*|x86" Project="x86" />
|
<Platform Solution="Debug_Editor|x86" Project="x64" />
|
||||||
|
<Platform Solution="Debug|x86" Project="x86" />
|
||||||
|
<Platform Solution="Release_Editor|x86" Project="x64" />
|
||||||
|
<Platform Solution="Release|x86" Project="x86" />
|
||||||
<Deploy />
|
<Deploy />
|
||||||
</Project>
|
</Project>
|
||||||
</Folder>
|
</Folder>
|
||||||
@@ -36,21 +46,14 @@
|
|||||||
<Project Path="Runtime/Ghost.Graphics/Ghost.Graphics.csproj" />
|
<Project Path="Runtime/Ghost.Graphics/Ghost.Graphics.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Test/">
|
<Folder Name="/Test/">
|
||||||
<Project Path="Test/Ghost.Entities.Test/Ghost.Entities.Test.csproj" />
|
|
||||||
<Project Path="Test/Ghost.Graphics.Test/Ghost.Graphics.Test.csproj">
|
|
||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
|
||||||
<Platform Solution="*|x86" Project="x86" />
|
|
||||||
<Deploy />
|
|
||||||
</Project>
|
|
||||||
<Project Path="Test/Ghost.MicroTest/Ghost.MicroTest.csproj" Id="8c8ffa4b-e1e4-46a1-9221-7b508a109edd" />
|
<Project Path="Test/Ghost.MicroTest/Ghost.MicroTest.csproj" Id="8c8ffa4b-e1e4-46a1-9221-7b508a109edd" />
|
||||||
<Project Path="Test/Ghost.Shader.Test/Ghost.Shader.Test.csproj" />
|
<Project Path="Test/Ghost.TestCore/Ghost.TestCore.csproj" />
|
||||||
<Project Path="Test/Ghost.Test.Core/Ghost.Test.Core.csproj" />
|
|
||||||
<Project Path="Test/Ghost.UnitTest/Ghost.UnitTest.csproj" Id="4da45668-456b-4dcc-acd8-6bfe154e6837">
|
<Project Path="Test/Ghost.UnitTest/Ghost.UnitTest.csproj" Id="4da45668-456b-4dcc-acd8-6bfe154e6837">
|
||||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
<Platform Solution="*|x86" Project="x86" />
|
<Platform Solution="*|x86" Project="x86" />
|
||||||
<Deploy />
|
<Deploy Solution="Debug|*" />
|
||||||
|
<Deploy Solution="Release|*" />
|
||||||
</Project>
|
</Project>
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Tools/">
|
<Folder Name="/Tools/">
|
||||||
|
|||||||
45
src/Runtime/Ghost.Core/Attributes/InspectorAttributes.cs
Normal file
45
src/Runtime/Ghost.Core/Attributes/InspectorAttributes.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Ghost.Core.Attributes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a field as read-only in the inspector.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Field)]
|
||||||
|
public sealed class ReadOnlyInInspectorAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hides a field from the inspector entirely.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Field)]
|
||||||
|
public sealed class HideInInspectorAttribute : Attribute
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overrides the display name for a field in the inspector.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Field)]
|
||||||
|
public sealed class InspectorNameAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public InspectorNameAttribute(string name)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups fields under a collapsible header in the inspector.
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Field)]
|
||||||
|
public sealed class InspectorGroupAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string GroupName { get; }
|
||||||
|
|
||||||
|
public InspectorGroupAttribute(string groupName)
|
||||||
|
{
|
||||||
|
GroupName = groupName;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user