mirror of
https://github.com/Heretek-AI/game-engine.git
synced 2026-11-07 10:55:58 -10:00
[P3-05] Inspector panel with edit-undo (issue #91)
Native ABI 4.5 tail (minor 4->5, append-only): entity_remove after the P3-04 field tail (remove @424, table 432B). GenericSystems::remove (no-op when absent) + ApiEntityRemove (drops the lane plan). MeshRenderer self-description (mesh_id u32 @0, base_color color4 @4); EcsWorld::construct registers P1-08 render components (gotcha 20). New engine_inspector_edits ctest: float-lane edit, undo replay, remove + re-add, double-remove no-op. Managed: FieldValue codec (all 11 field types + ZYX Euler-quat), SetFieldCommand (entity_get/set; LocalTransform via editor_get_local/set_local), Add/RemoveComponentCommand (LocalTransform structural), EditorSession World/FieldsOf/HasComponent. Panel: InspectorPanel — per-component Expanders, TextBox lanes, CheckBox, color swatch + RGBA lanes, entity ComboBox, asset TextBox + drop. One undo step per LostFocus/Enter. Add Component menu + Remove entry. Headless acceptance: 2.5+Enter sets position.x, Ctrl+Z restores, golden editor_inspector_lm (LocalTransform + MeshRenderer). Closes #91.
This commit is contained in:
1 parent
cb6a1c760d
commit
31c673bb00
25 files changed
+2305
-17
No files matched your search
@@ -259,3 +259,11 @@ Chunk_Spike_Mover.g.cs(37,65): error CS1503: Argument 3: cannot convert from 'de
|
||||
- Generator: `ComponentSystemGenerator` predicate widened to every partial struct (Tick-less IComponents produce a spec with empty Ticks); `SystemSpec.Fields` carries `(name, code, size, align, offset)` rows computed with C-like sequential packing from declaration order (MetadataToken). `EmitDescribe` emits `Describe(table, world)` next to Register with `/// <name> <type> @<offset>` comment rows (the acceptance-readable surface) and sibling `fixed` pins (no nesting bug). CS0649 on Tick-less `s_faulted` suppressed with disable/restore inside the generated partial (Directory.Build.props treats warnings as errors).
|
||||
- Managed readback: `Engine.Editor.ComponentFields.Describe/ReadAll` over `TableOverride`-aware `TablePtr` (GCHandle-pinned UTF-8 names). `GeneratorDriverTests` HealthMeta fixture asserts `Current f32 @0` / `Max i32 @4` comments + offset literals; `engine_field_metadata` ctest asserts Health describe/read-back, NOT_FOUND/BAD_ARG paths, and built-in LocalTransform rows.
|
||||
- Scope note: the scene half of P3-04 ("switch user comps from $bytes to named fields", `"Health": {"Current": 75, "Max": 100}` round-trip) is NOT in this slice — no P2-06 scene format exists in tree (no native/scene, no Scene.Save/Load, no $bytes reader/writer; verified on origin/main). That acceptance moves to the P2-06 follow-up; `component_describe` rows are the contract it will consume.
|
||||
|
||||
## P3-05: Inspector panel with edit-undo (issue #91, ABI 4.5)
|
||||
- ABI tail (minor 4->5, append-only): `entity_remove` after the P3-04 field tail (remove @424, table 432B). `GenericSystems::remove` (no-op when absent so redo/undo stacks never fail) + `ApiEntityRemove` (drops the run_systems lane plan like `ApiEntitySet`). Pinned in `test_abi_layout.cpp` + `AbiLayoutTests.cs` (offset + size). Codegen `EngineEntityRemoveFn` added to the required-entries list + `ulong,ulong->int` mapping (the stacked P3-03+P3-04 cherry-pick needed two conflict fixes: dropped duplicate `char*` map arm, kept both table tails).
|
||||
- Native: `SeedBuiltinRows` also self-describes `Engine.Runtime.MeshRenderer` (mesh_id u32 @0, base_color color4 @4); `EcsWorld::construct` registers P1-08 `Camera`/`MeshRenderer`/`DirectionalLight` via `register_render()` (cherry-picked from the P1-08 commit since the worktree base predates it; same idempotent gotcha-20 helper). New `engine_inspector_edits` ctest: base_color[0]=2.5 edit, undo replay, remove + re-add, double-remove no-op — all PASS.
|
||||
- Managed (`Engine.Editor`): `FieldValue` codec (f32/i32/u32/bool8/vec2-4/quat/color4/entity/asset-ref lanes + XYZ... corrected to ZYX yaw-pitch-roll Euler<->quat, verified by quaternion round-trip not angle identity), `SetFieldCommand` (whole-image before/after over entity_get/entity_set; LocalTransform routes through editor_get_local/editor_set_local because the B-side mirror is not in the generic name registry), `AddComponentCommand`/`RemoveComponentCommand` (LocalTransform structural: add=EnsureLocal, remove refuses). `EditorSession` gains borrowed `World` handle + `FieldsOf`/`HasComponent`.
|
||||
- Panel (`Engine.EditorApp`): `InspectorPanel` — per-component `Expander`s, `TextBox` lanes (f32/vec/quat-Euler-deg), `CheckBox`, color swatch `Border` + 4 RGBA lanes (the `Avalonia.Controls.ColorPicker` 12.1.4 MIT package is referenced per spec but its control renders blank in this headless layout, so lanes carry the edit; file a P3-follow-up), entity `ComboBox`, asset `TextBox` + drop (`IDataTransfer`/`TryGetText`, `DataFormat` not obsolete `DataFormats`). LostFocus/Enter commits exactly one undo via `Inspector.Execute`; Add Component menu + Remove context entry.
|
||||
- Gotchas hit: (1) worktree base was the P4-00 spike merge — P3-01/P3-03/P3-04 files missing, restored by cherry-picking both parent commits (ABI tables stacked, NOT overwritten: describe moved 360->400, field_at 376->416); (2) `Engine.EditorApp.Tests` bin DLL went stale (byte-identical hash) until `git add -f` — new files are NOT auto-compiled when the csproj uses default globs... actually the build DID include them (csc line lists InspectorPanelGolden.cs) but MSBuild skipped CoreCompile as up-to-date; `-t:Rebuild` fixed it; (3) `NumericUpDown` text part collapses to ~0px in Expander/ScrollViewer layout — TextBox replacement; (4) headless `KeyPress(Enter)` never reaches the box — raise `KeyDown` on the box itself; (5) Ctrl+Z in headless needs `inspector.Undo()` directly (no key binding in the test window); (6) new-file staging: several files needed `git add -f` — check `.gitignore`/sparse config if this recurs (untracked files visible but `git add <path>` silently staged nothing until `-f`).
|
||||
- Verified: `ctest --preset ci-linux` 45/45 (incl. new `engine_inspector_edits`); `Engine.Tests` all green (incl. FieldValue codec CPU checks + SetField/Add/Remove live round-trips); EditorApp.Tests 39 PASS incl. new inspector golden `editor_inspector_lm` (byte-stable x4, shows LocalTransform position 2.5/rotation/scale + MeshRenderer mesh_id/base_color + Add Component); `dotnet format whitespace --verify-no-changes` clean.
|
||||
@@ -1,7 +1,7 @@
|
||||
// <auto-generated>
|
||||
// NativeApi.g.cs — GENERATED by tools/abi_codegen from
|
||||
// native/bridge/include/engine/bridge/engine_abi.h. DO NOT EDIT.
|
||||
// ABI 4.4.0, column stride 64.
|
||||
// ABI 4.5.0, column stride 64.
|
||||
// Regenerate: dotnet run --project tools/abi_codegen -- <repo-root>
|
||||
// </auto-generated>
|
||||
#nullable enable
|
||||
@@ -25,7 +25,7 @@ namespace Engine.Bindings;
|
||||
public static unsafe class NativeApi
|
||||
{
|
||||
public const int AbiVersionMajor = 4;
|
||||
public const int AbiVersionMinor = 4;
|
||||
public const int AbiVersionMinor = 5;
|
||||
public const int AbiVersionPatch = 0;
|
||||
public const int ColumnStride = 64;
|
||||
public const string NativeLibraryName = "engine_core";
|
||||
@@ -258,6 +258,7 @@ public static unsafe class NativeApi
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, EngineFieldDesc*, uint, int> EngineComponentDescribe;
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, uint*, int> EngineComponentFieldCount;
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, byte*, uint, byte*, uint, uint*, uint*, int> EngineComponentFieldAt;
|
||||
public delegate* unmanaged[Cdecl]<EngineWorld*, ulong, ulong, int> EngineEntityRemove;
|
||||
}
|
||||
|
||||
/// <summary>Resolves engine_get_api_table and validates the ABI.</summary>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// ComponentCommands.cs — P3-05: Add/Remove component undo commands.
|
||||
//
|
||||
// AddComponentCommand writes a zeroed (or supplied) byte image through
|
||||
// entity_set (which adds the row when absent) and removes the row on
|
||||
// revert via entity_remove (ABI 4.5). RemoveComponentCommand snapshots
|
||||
// the current image through entity_get, removes the row with
|
||||
// entity_remove on apply, and replays the snapshot bytes on undo.
|
||||
using Engine.Bindings;
|
||||
|
||||
namespace Engine.Editor;
|
||||
|
||||
/// <summary>Adds a component row (zeroed image) to an entity; revert removes it.</summary>
|
||||
public sealed unsafe class AddComponentCommand : IEditCommand
|
||||
{
|
||||
private readonly EditorSession _session;
|
||||
private readonly ulong _entity;
|
||||
private readonly string _component;
|
||||
private readonly byte[] _image;
|
||||
private bool _disposed;
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public AddComponentCommand(EditorSession session, ulong entity, string component, byte[] image, string? label = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
ArgumentOutOfRangeException.ThrowIfZero(entity);
|
||||
ArgumentException.ThrowIfNullOrEmpty(component);
|
||||
ArgumentNullException.ThrowIfNull(image);
|
||||
if (image.Length == 0)
|
||||
{
|
||||
throw new ArgumentException("Component image must be non-empty.", nameof(image));
|
||||
}
|
||||
|
||||
_entity = entity;
|
||||
_component = component;
|
||||
_image = (byte[])image.Clone();
|
||||
Label = label ?? $"AddComponent({_entity}.{_component})";
|
||||
}
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_component == "LocalTransform")
|
||||
{
|
||||
_session.EnsureLocal(_entity);
|
||||
return;
|
||||
}
|
||||
|
||||
var world = _session.World;
|
||||
if (world == null)
|
||||
{
|
||||
throw new InvalidOperationException("EditorSession is disposed.");
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
var id = ResolveComponent(table, world, _component, _image.Length);
|
||||
int rc;
|
||||
fixed (byte* p = _image)
|
||||
{
|
||||
rc = table->EngineEntitySet(world, _entity, id, p, (uint)_image.Length);
|
||||
}
|
||||
|
||||
if (rc != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"entity_set({_entity}.{_component}) failed: {rc}.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Revert()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Remove();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Remove()
|
||||
{
|
||||
if (_component == "LocalTransform")
|
||||
{
|
||||
throw new InvalidOperationException("LocalTransform is structural and cannot be removed.");
|
||||
}
|
||||
|
||||
var world = _session.World;
|
||||
if (world == null)
|
||||
{
|
||||
throw new InvalidOperationException("EditorSession is disposed.");
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
var id = ResolveComponent(table, world, _component, _image.Length);
|
||||
var rc = table->EngineEntityRemove(world, _entity, id);
|
||||
if (rc != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"entity_remove({_entity}.{_component}) failed: {rc}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong ResolveComponent(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, string component, int size)
|
||||
{
|
||||
// Alignment must match the native registration exactly (gotcha 20:
|
||||
// a size/alignment mismatch returns 0, never a second component).
|
||||
// LocalTransform is align-64; MeshRenderer/user components are align-4.
|
||||
uint align = component == "LocalTransform" ? 64u : 4u;
|
||||
var nameBytes = System.Text.Encoding.UTF8.GetBytes(component + "\0");
|
||||
ulong id;
|
||||
fixed (byte* np = nameBytes)
|
||||
{
|
||||
id = table->EngineComponentRegister(world, np, (uint)size, align);
|
||||
}
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"component_register({component}) failed (unknown component or size mismatch).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes a component row; undo restores the snapshotted bytes.</summary>
|
||||
public sealed unsafe class RemoveComponentCommand : IEditCommand
|
||||
{
|
||||
private readonly EditorSession _session;
|
||||
private readonly ulong _entity;
|
||||
private readonly string _component;
|
||||
private readonly int _size;
|
||||
private byte[]? _before;
|
||||
private bool _disposed;
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public RemoveComponentCommand(EditorSession session, ulong entity, string component, int componentSize, string? label = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
ArgumentOutOfRangeException.ThrowIfZero(entity);
|
||||
ArgumentException.ThrowIfNullOrEmpty(component);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(componentSize, 0);
|
||||
_entity = entity;
|
||||
_component = component;
|
||||
_size = componentSize;
|
||||
Label = label ?? $"RemoveComponent({entity}.{component})";
|
||||
}
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_component == "LocalTransform")
|
||||
{
|
||||
throw new InvalidOperationException("LocalTransform is structural and cannot be removed.");
|
||||
}
|
||||
|
||||
_before = SetFieldCommand.ReadImage(_session, _entity, _component, _size);
|
||||
var world = _session.World;
|
||||
if (world == null)
|
||||
{
|
||||
throw new InvalidOperationException("EditorSession is disposed.");
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
var id = ResolveComponent(table, world, _component, _size);
|
||||
var rc = table->EngineEntityRemove(world, _entity, id);
|
||||
if (rc != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"entity_remove({_entity}.{_component}) failed: {rc}.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Revert()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_before == null)
|
||||
{
|
||||
throw new InvalidOperationException("RemoveComponentCommand has no snapshot to revert (Apply was never called).");
|
||||
}
|
||||
|
||||
var world = _session.World;
|
||||
if (world == null)
|
||||
{
|
||||
throw new InvalidOperationException("EditorSession is disposed.");
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
var id = ResolveComponent(table, world, _component, _size);
|
||||
int rc;
|
||||
fixed (byte* p = _before)
|
||||
{
|
||||
rc = table->EngineEntitySet(world, _entity, id, p, (uint)_size);
|
||||
}
|
||||
|
||||
if (rc != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"entity_set({_entity}.{_component}) failed: {rc}.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static ulong ResolveComponent(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, string component, int size)
|
||||
{
|
||||
// Alignment must match the native registration exactly (gotcha 20:
|
||||
// a size/alignment mismatch returns 0, never a second component).
|
||||
// LocalTransform is align-64; MeshRenderer/user components are align-4.
|
||||
uint align = component == "LocalTransform" ? 64u : 4u;
|
||||
var nameBytes = System.Text.Encoding.UTF8.GetBytes(component + "\0");
|
||||
ulong id;
|
||||
fixed (byte* np = nameBytes)
|
||||
{
|
||||
id = table->EngineComponentRegister(world, np, (uint)size, align);
|
||||
}
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"component_register({component}) failed (unknown component or size mismatch).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ namespace Engine.Editor;
|
||||
public sealed unsafe class EditorSession : IDisposable
|
||||
{
|
||||
private NativeApi.EngineEditorSession* _session;
|
||||
private NativeApi.EngineWorld* _world;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Test/live-probe table injection (mirrors GraphicsCompositor).</summary>
|
||||
@@ -43,6 +44,7 @@ public sealed unsafe class EditorSession : IDisposable
|
||||
}
|
||||
|
||||
_session = session;
|
||||
_world = world;
|
||||
}
|
||||
|
||||
/// <summary>Spawn one named identity entity; returns its id.</summary>
|
||||
@@ -349,6 +351,71 @@ public sealed unsafe class EditorSession : IDisposable
|
||||
|
||||
internal NativeApi.EngineEditorSession* Handle => _disposed ? null : _session;
|
||||
|
||||
/// <summary>Borrowed native world this session is bound to (null when disposed).</summary>
|
||||
internal NativeApi.EngineWorld* World => _disposed ? null : _world;
|
||||
|
||||
/// <summary>True when the entity currently carries the named component (P3-05 panel).</summary>
|
||||
public bool HasComponent(ulong entity, string componentName, int componentSize)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentOutOfRangeException.ThrowIfZero(entity);
|
||||
ArgumentException.ThrowIfNullOrEmpty(componentName);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(componentSize, 0);
|
||||
|
||||
// LocalTransform lives in the native B-side mirror (registered by
|
||||
// EcsWorld::construct, not the generic name registry): presence is
|
||||
// editor_get_local success, not a name-registry lookup.
|
||||
if (componentName == "LocalTransform")
|
||||
{
|
||||
var probe = new float[10];
|
||||
return TryGetLocal(entity, probe);
|
||||
}
|
||||
|
||||
var id = ComponentId(componentName, componentSize);
|
||||
var image = new byte[componentSize];
|
||||
int rc;
|
||||
fixed (byte* img = image)
|
||||
{
|
||||
rc = TablePtr->EngineEntityGet(_world, entity, id, img, (uint)componentSize);
|
||||
}
|
||||
|
||||
return rc == 0;
|
||||
}
|
||||
|
||||
private bool TryGetLocal(ulong entity, float[] pose)
|
||||
{
|
||||
fixed (float* p = pose)
|
||||
{
|
||||
return TablePtr->EngineEditorGetLocal(_session, entity, p) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolve (and validate) the native component id for a name+size pair.</summary>
|
||||
private ulong ComponentId(string componentName, int componentSize)
|
||||
{
|
||||
var nameBytes = System.Text.Encoding.UTF8.GetBytes(componentName + "\0");
|
||||
ulong id;
|
||||
fixed (byte* np = nameBytes)
|
||||
{
|
||||
id = TablePtr->EngineComponentRegister(_world, np, (uint)componentSize, 4);
|
||||
}
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"component_register({componentName}) failed (unknown component or size mismatch).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Field rows for a component (empty when undescribed). P3-05 panel.</summary>
|
||||
public IReadOnlyList<ComponentFieldInfo> FieldsOf(string componentName)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ArgumentException.ThrowIfNullOrEmpty(componentName);
|
||||
return ComponentFields.ReadAll(_world, componentName);
|
||||
}
|
||||
|
||||
public static unsafe NativeApi.ApiTable* Table => TablePtr;
|
||||
|
||||
public void Dispose()
|
||||
@@ -364,6 +431,8 @@ public sealed unsafe class EditorSession : IDisposable
|
||||
TablePtr->EngineEditorDestroy(_session);
|
||||
_session = null;
|
||||
}
|
||||
|
||||
_world = null;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed([CallerMemberName] string? caller = null)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
// FieldValue.cs — P3-05: typed field-value codec for the inspector.
|
||||
//
|
||||
// Converts between native component bytes (read via entity_get, written
|
||||
// via entity_set) and the per-type editor values the panel binds:
|
||||
// f32/i32/u32 scalars, bool8, vec2/3/4, quat (Euler degrees in the UI),
|
||||
// color4 (RGBA floats), entity (ulong id), asset-ref (ulong cookie).
|
||||
//
|
||||
// Pure-managed and fully unit-testable (no native handle needed): every
|
||||
// method works on caller-supplied spans. The panel and SetFieldCommand
|
||||
// own the native calls; this type owns the byte math.
|
||||
using System.Numerics;
|
||||
|
||||
namespace Engine.Editor;
|
||||
|
||||
/// <summary>Field-type codes (mirror ENGINE_ABI_FIELD_* in engine_abi.h).</summary>
|
||||
public enum InspectorFieldType : uint
|
||||
{
|
||||
F32 = 0,
|
||||
I32 = 1,
|
||||
U32 = 2,
|
||||
Bool8 = 3,
|
||||
Vec2 = 4,
|
||||
Vec3 = 5,
|
||||
Vec4 = 6,
|
||||
Quat = 7,
|
||||
Color4 = 8,
|
||||
Entity = 9,
|
||||
AssetRef = 10,
|
||||
}
|
||||
|
||||
/// <summary>Byte size of one value of each field type.</summary>
|
||||
public static class FieldValue
|
||||
{
|
||||
public static int ByteSize(InspectorFieldType type) => type switch
|
||||
{
|
||||
InspectorFieldType.F32 => 4,
|
||||
InspectorFieldType.I32 => 4,
|
||||
InspectorFieldType.U32 => 4,
|
||||
InspectorFieldType.Bool8 => 1,
|
||||
InspectorFieldType.Vec2 => 8,
|
||||
InspectorFieldType.Vec3 => 12,
|
||||
InspectorFieldType.Vec4 => 16,
|
||||
InspectorFieldType.Quat => 16,
|
||||
InspectorFieldType.Color4 => 16,
|
||||
InspectorFieldType.Entity => 8,
|
||||
InspectorFieldType.AssetRef => 8,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unknown field type {(uint)type}."),
|
||||
};
|
||||
|
||||
/// <summary>Reads one lane (float) of a scalar/vector field at <paramref name="lane"/>.</summary>
|
||||
public static float ReadFloat(ReadOnlySpan<byte> componentBytes, int fieldOffset, InspectorFieldType type, int lane = 0)
|
||||
{
|
||||
var lanes = LaneCount(type);
|
||||
if ((uint)lane >= (uint)lanes)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lane), $"Lane {lane} out of range for {type} ({lanes} lanes).");
|
||||
}
|
||||
|
||||
return type switch
|
||||
{
|
||||
InspectorFieldType.F32 => ReadF32(componentBytes, fieldOffset),
|
||||
InspectorFieldType.I32 => ReadI32(componentBytes, fieldOffset),
|
||||
InspectorFieldType.U32 => ReadU32(componentBytes, fieldOffset),
|
||||
InspectorFieldType.Bool8 => ReadBool(componentBytes, fieldOffset) ? 1.0f : 0.0f,
|
||||
InspectorFieldType.Vec2 or InspectorFieldType.Vec3 or InspectorFieldType.Vec4
|
||||
or InspectorFieldType.Quat or InspectorFieldType.Color4 => ReadF32(componentBytes, fieldOffset + lane * 4),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Type {type} is not float-readable."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Writes one lane (float) of a scalar/vector field at <paramref name="lane"/>.</summary>
|
||||
public static void WriteFloat(Span<byte> componentBytes, int fieldOffset, InspectorFieldType type, float value, int lane = 0)
|
||||
{
|
||||
var lanes = LaneCount(type);
|
||||
if ((uint)lane >= (uint)lanes)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lane), $"Lane {lane} out of range for {type} ({lanes} lanes).");
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case InspectorFieldType.F32:
|
||||
WriteF32(componentBytes, fieldOffset, value);
|
||||
break;
|
||||
case InspectorFieldType.I32:
|
||||
WriteI32(componentBytes, fieldOffset, (int)MathF.Round(value));
|
||||
break;
|
||||
case InspectorFieldType.U32:
|
||||
WriteU32(componentBytes, fieldOffset, (uint)MathF.Round(value));
|
||||
break;
|
||||
case InspectorFieldType.Bool8:
|
||||
WriteBool(componentBytes, fieldOffset, value != 0.0f);
|
||||
break;
|
||||
case InspectorFieldType.Vec2:
|
||||
case InspectorFieldType.Vec3:
|
||||
case InspectorFieldType.Vec4:
|
||||
case InspectorFieldType.Quat:
|
||||
case InspectorFieldType.Color4:
|
||||
WriteF32(componentBytes, fieldOffset + lane * 4, value);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Type {type} is not float-writable.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads a bool8 field.</summary>
|
||||
public static bool ReadBool(ReadOnlySpan<byte> componentBytes, int fieldOffset)
|
||||
{
|
||||
CheckRange(componentBytes.Length, fieldOffset, 1);
|
||||
return componentBytes[fieldOffset] != 0;
|
||||
}
|
||||
|
||||
/// <summary>Writes a bool8 field (1 byte).</summary>
|
||||
public static void WriteBool(Span<byte> componentBytes, int fieldOffset, bool value)
|
||||
{
|
||||
CheckRange(componentBytes.Length, fieldOffset, 1);
|
||||
componentBytes[fieldOffset] = value ? (byte)1 : (byte)0;
|
||||
}
|
||||
|
||||
/// <summary>Reads an entity/asset-ref field (ulong cookie).</summary>
|
||||
public static ulong ReadId(ReadOnlySpan<byte> componentBytes, int fieldOffset, InspectorFieldType type)
|
||||
{
|
||||
if (type != InspectorFieldType.Entity && type != InspectorFieldType.AssetRef)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Type {type} is not id-readable.");
|
||||
}
|
||||
|
||||
CheckRange(componentBytes.Length, fieldOffset, 8);
|
||||
return BitConverter.ToUInt64(componentBytes.Slice(fieldOffset, 8));
|
||||
}
|
||||
|
||||
/// <summary>Writes an entity/asset-ref field (ulong cookie).</summary>
|
||||
public static void WriteId(Span<byte> componentBytes, int fieldOffset, InspectorFieldType type, ulong value)
|
||||
{
|
||||
if (type != InspectorFieldType.Entity && type != InspectorFieldType.AssetRef)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Type {type} is not id-writable.");
|
||||
}
|
||||
|
||||
CheckRange(componentBytes.Length, fieldOffset, 8);
|
||||
BitConverter.TryWriteBytes(componentBytes.Slice(fieldOffset, 8), value);
|
||||
}
|
||||
|
||||
/// <summary>Reads a quat field as Euler degrees (XYZ order, matching the panel).</summary>
|
||||
public static Vector3 QuatToEulerDeg(ReadOnlySpan<byte> componentBytes, int fieldOffset)
|
||||
{
|
||||
CheckRange(componentBytes.Length, fieldOffset, 16);
|
||||
var q = new Quaternion(
|
||||
BitConverter.ToSingle(componentBytes.Slice(fieldOffset, 4)),
|
||||
BitConverter.ToSingle(componentBytes.Slice(fieldOffset + 4, 4)),
|
||||
BitConverter.ToSingle(componentBytes.Slice(fieldOffset + 8, 4)),
|
||||
BitConverter.ToSingle(componentBytes.Slice(fieldOffset + 12, 4)));
|
||||
return QuatToEulerDeg(q);
|
||||
}
|
||||
|
||||
/// <summary>Writes a quat field from Euler degrees (XYZ order).</summary>
|
||||
public static void EulerDegToQuat(Span<byte> componentBytes, int fieldOffset, Vector3 eulerDeg)
|
||||
{
|
||||
var q = EulerDegToQuatValue(eulerDeg);
|
||||
CheckRange(componentBytes.Length, fieldOffset, 16);
|
||||
BitConverter.TryWriteBytes(componentBytes.Slice(fieldOffset, 4), q.X);
|
||||
BitConverter.TryWriteBytes(componentBytes.Slice(fieldOffset + 4, 4), q.Y);
|
||||
BitConverter.TryWriteBytes(componentBytes.Slice(fieldOffset + 8, 4), q.Z);
|
||||
BitConverter.TryWriteBytes(componentBytes.Slice(fieldOffset + 12, 4), q.W);
|
||||
}
|
||||
|
||||
/// <summary>Quaternion to Euler degrees (XYZ order).</summary>
|
||||
public static Vector3 QuatToEulerDeg(Quaternion q)
|
||||
{
|
||||
// Inverse of EulerDegToQuatValue (q = qz * qy * qx): ZYX
|
||||
// yaw-pitch-roll. R = Rz * Ry * Rx gives m20 = -sin(pitch), so the
|
||||
// pitch gimbal sits at |m20| = 1 with yaw = atan2(m10, m00).
|
||||
var n = MathF.Sqrt(q.X * q.X + q.Y * q.Y + q.Z * q.Z + q.W * q.W);
|
||||
float x = q.X, y = q.Y, z = q.Z, w = q.W;
|
||||
if (n > 0.0f)
|
||||
{
|
||||
x /= n; y /= n; z /= n; w /= n;
|
||||
}
|
||||
|
||||
var m00 = 1.0f - 2.0f * (y * y + z * z);
|
||||
var m10 = 2.0f * (x * y + z * w);
|
||||
var m20 = 2.0f * (x * z - y * w);
|
||||
var m21 = 2.0f * (y * z + x * w);
|
||||
var m22 = 1.0f - 2.0f * (x * x + y * y);
|
||||
|
||||
float roll, pitch, yaw;
|
||||
if (m20 >= 0.9999f)
|
||||
{
|
||||
pitch = -MathF.PI / 2.0f;
|
||||
roll = 0.0f;
|
||||
yaw = MathF.Atan2(-m10, m00);
|
||||
}
|
||||
else if (m20 <= -0.9999f)
|
||||
{
|
||||
pitch = MathF.PI / 2.0f;
|
||||
roll = 0.0f;
|
||||
yaw = MathF.Atan2(-m10, m00);
|
||||
}
|
||||
else
|
||||
{
|
||||
pitch = MathF.Asin(Math.Clamp(-m20, -1.0f, 1.0f));
|
||||
roll = MathF.Atan2(m21, m22);
|
||||
yaw = MathF.Atan2(m10, m00);
|
||||
}
|
||||
|
||||
const float Rad2Deg = 180.0f / MathF.PI;
|
||||
return new Vector3(roll * Rad2Deg, pitch * Rad2Deg, yaw * Rad2Deg);
|
||||
}
|
||||
|
||||
/// <summary>Euler degrees (XYZ order) to quaternion.</summary>
|
||||
public static Quaternion EulerDegToQuatValue(Vector3 eulerDeg)
|
||||
{
|
||||
// Matches QuatToEulerDeg below: q = qz * qy * qx (extrinsic X
|
||||
// then Y then Z, equivalently ZYX intrinsic / yaw-pitch-roll).
|
||||
const float Deg2Rad = MathF.PI / 180.0f;
|
||||
var half = eulerDeg * (Deg2Rad * 0.5f);
|
||||
var cx = MathF.Cos(half.X); var sx = MathF.Sin(half.X);
|
||||
var cy = MathF.Cos(half.Y); var sy = MathF.Sin(half.Y);
|
||||
var cz = MathF.Cos(half.Z); var sz = MathF.Sin(half.Z);
|
||||
var qx = new Quaternion(sx, 0, 0, cx);
|
||||
var qy = new Quaternion(0, sy, 0, cy);
|
||||
var qz = new Quaternion(0, 0, sz, cz);
|
||||
return qz * qy * qx;
|
||||
}
|
||||
|
||||
private static int LaneCount(InspectorFieldType type) => type switch
|
||||
{
|
||||
InspectorFieldType.F32 or InspectorFieldType.I32 or InspectorFieldType.U32 or InspectorFieldType.Bool8 => 1,
|
||||
InspectorFieldType.Vec2 => 2,
|
||||
InspectorFieldType.Vec3 => 3,
|
||||
InspectorFieldType.Vec4 or InspectorFieldType.Quat or InspectorFieldType.Color4 => 4,
|
||||
InspectorFieldType.Entity or InspectorFieldType.AssetRef => 0,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type), $"Unknown field type {(uint)type}."),
|
||||
};
|
||||
|
||||
private static float ReadF32(ReadOnlySpan<byte> bytes, int offset)
|
||||
{
|
||||
CheckRange(bytes.Length, offset, 4);
|
||||
return BitConverter.ToSingle(bytes.Slice(offset, 4));
|
||||
}
|
||||
|
||||
private static void WriteF32(Span<byte> bytes, int offset, float value)
|
||||
{
|
||||
CheckRange(bytes.Length, offset, 4);
|
||||
BitConverter.TryWriteBytes(bytes.Slice(offset, 4), value);
|
||||
}
|
||||
|
||||
private static float ReadI32(ReadOnlySpan<byte> bytes, int offset)
|
||||
{
|
||||
CheckRange(bytes.Length, offset, 4);
|
||||
return BitConverter.ToInt32(bytes.Slice(offset, 4));
|
||||
}
|
||||
|
||||
private static void WriteI32(Span<byte> bytes, int offset, int value)
|
||||
{
|
||||
CheckRange(bytes.Length, offset, 4);
|
||||
BitConverter.TryWriteBytes(bytes.Slice(offset, 4), value);
|
||||
}
|
||||
|
||||
private static float ReadU32(ReadOnlySpan<byte> bytes, int offset)
|
||||
{
|
||||
CheckRange(bytes.Length, offset, 4);
|
||||
return BitConverter.ToUInt32(bytes.Slice(offset, 4));
|
||||
}
|
||||
|
||||
private static void WriteU32(Span<byte> bytes, int offset, uint value)
|
||||
{
|
||||
CheckRange(bytes.Length, offset, 4);
|
||||
BitConverter.TryWriteBytes(bytes.Slice(offset, 4), value);
|
||||
}
|
||||
|
||||
private static void CheckRange(int length, int offset, int size)
|
||||
{
|
||||
if (offset < 0 || size < 0 || offset + size > length)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), $"Field range [{offset}, {offset + size}) exceeds {length} bytes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// SetFieldCommand.cs — P3-05: one-undo-step field edit over entity_get/entity_set.
|
||||
//
|
||||
// Captures the whole component byte image before the edit (via the
|
||||
// session's world + table), applies the after-image, and replays the
|
||||
// before-image on undo. The panel commits one of these per LostFocus/Enter
|
||||
// (never per keystroke); Ctrl+Z reverts through the shared Inspector stack.
|
||||
using Engine.Bindings;
|
||||
|
||||
namespace Engine.Editor;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites one field of one component on one entity as a single undo step.
|
||||
/// </summary>
|
||||
public sealed unsafe class SetFieldCommand : IEditCommand
|
||||
{
|
||||
private readonly EditorSession _session;
|
||||
private readonly ulong _entity;
|
||||
private readonly string _component;
|
||||
private readonly string _field;
|
||||
private readonly byte[] _before;
|
||||
private readonly byte[] _after;
|
||||
private bool _disposed;
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public SetFieldCommand(
|
||||
EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
byte[] before,
|
||||
byte[] after,
|
||||
string? label = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
ArgumentOutOfRangeException.ThrowIfZero(entity);
|
||||
ArgumentException.ThrowIfNullOrEmpty(component);
|
||||
ArgumentException.ThrowIfNullOrEmpty(field);
|
||||
ArgumentNullException.ThrowIfNull(before);
|
||||
ArgumentNullException.ThrowIfNull(after);
|
||||
if (before.Length == 0 || before.Length != after.Length)
|
||||
{
|
||||
throw new ArgumentException("Field images must be non-empty and equal-length.", nameof(after));
|
||||
}
|
||||
|
||||
_entity = entity;
|
||||
_component = component;
|
||||
_field = field;
|
||||
_before = (byte[])before.Clone();
|
||||
_after = (byte[])after.Clone();
|
||||
Label = label ?? $"SetField({_entity}.{_component}.{_field})";
|
||||
}
|
||||
|
||||
public ulong Entity => _entity;
|
||||
public string Component => _component;
|
||||
public string Field => _field;
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Write(_after);
|
||||
}
|
||||
|
||||
public void Revert()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Write(_before);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Write(byte[] image)
|
||||
{
|
||||
if (_component == "LocalTransform")
|
||||
{
|
||||
_session.SetLocal(_entity, LocalPoseOf(image));
|
||||
return;
|
||||
}
|
||||
|
||||
var world = _session.World;
|
||||
if (world == null)
|
||||
{
|
||||
throw new InvalidOperationException("EditorSession is disposed.");
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
var componentId = ResolveComponent(table, world, _component, image.Length);
|
||||
int rc;
|
||||
fixed (byte* p = image)
|
||||
{
|
||||
rc = table->EngineEntitySet(world, _entity, componentId, p, (uint)image.Length);
|
||||
}
|
||||
|
||||
if (rc != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"entity_set({_entity}.{_component}.{_field}) failed: {rc}.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the whole current component byte image for
|
||||
/// (<paramref name="entity"/>, <paramref name="component"/>).
|
||||
/// Size comes from the field-metadata envelope: the caller passes the
|
||||
/// highest (offset + size) end over the component's described rows, or
|
||||
/// the exact native size when known (e.g. LocalTransform is 64 bytes).
|
||||
/// LocalTransform routes through editor_get_local (the native B-side
|
||||
/// mirror); everything else goes through entity_get/entity_set.
|
||||
/// </summary>
|
||||
public static byte[] ReadImage(EditorSession session, ulong entity, string component, int size)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(entity);
|
||||
ArgumentException.ThrowIfNullOrEmpty(component);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(size, 0);
|
||||
|
||||
if (component == "LocalTransform")
|
||||
{
|
||||
return ReadLocalImage(session, entity, size);
|
||||
}
|
||||
|
||||
var world = session.World;
|
||||
if (world == null)
|
||||
{
|
||||
throw new InvalidOperationException("EditorSession is disposed.");
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
var componentId = ResolveComponent(table, world, component, size);
|
||||
var image = new byte[size];
|
||||
int rc;
|
||||
fixed (byte* p = image)
|
||||
{
|
||||
rc = table->EngineEntityGet(world, entity, componentId, p, (uint)size);
|
||||
}
|
||||
|
||||
if (rc != 0)
|
||||
{
|
||||
throw new InvalidOperationException($"entity_get({entity}.{component}) failed: {rc}.");
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
private static byte[] ReadLocalImage(EditorSession session, ulong entity, int size)
|
||||
{
|
||||
var pose = new float[10];
|
||||
session.GetLocal(entity, pose);
|
||||
var image = new byte[size];
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[0]), 0, image, 0, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[1]), 0, image, 4, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[2]), 0, image, 8, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[3]), 0, image, 16, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[4]), 0, image, 20, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[5]), 0, image, 24, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[6]), 0, image, 28, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[7]), 0, image, 32, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[8]), 0, image, 36, 4);
|
||||
Buffer.BlockCopy(BitConverter.GetBytes(pose[9]), 0, image, 40, 4);
|
||||
return image;
|
||||
}
|
||||
|
||||
private static float[] LocalPoseOf(byte[] image)
|
||||
{
|
||||
return new float[]
|
||||
{
|
||||
BitConverter.ToSingle(image, 0), BitConverter.ToSingle(image, 4), BitConverter.ToSingle(image, 8),
|
||||
BitConverter.ToSingle(image, 16), BitConverter.ToSingle(image, 20), BitConverter.ToSingle(image, 24), BitConverter.ToSingle(image, 28),
|
||||
BitConverter.ToSingle(image, 32), BitConverter.ToSingle(image, 36), BitConverter.ToSingle(image, 40),
|
||||
};
|
||||
}
|
||||
|
||||
private static ulong ResolveComponent(NativeApi.ApiTable* table, NativeApi.EngineWorld* world, string component, int size)
|
||||
{
|
||||
// Alignment must match the native registration exactly (gotcha 20:
|
||||
// a size/alignment mismatch returns 0, never a second component).
|
||||
// LocalTransform is align-64; MeshRenderer/user components are align-4.
|
||||
uint align = component == "LocalTransform" ? 64u : 4u;
|
||||
var nameBytes = System.Text.Encoding.UTF8.GetBytes(component + "\0");
|
||||
ulong id;
|
||||
fixed (byte* np = nameBytes)
|
||||
{
|
||||
id = table->EngineComponentRegister(world, np, (uint)size, align);
|
||||
}
|
||||
|
||||
if (id == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"component_register({component}) failed (unknown component or size mismatch).");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// InspectorPanelGolden.cs — P3-05: headless Inspector panel acceptance.
|
||||
//
|
||||
// Inside the shell window session (one Avalonia Setup per process):
|
||||
// spawns an entity with LocalTransform + MeshRenderer, binds the
|
||||
// InspectorPanel, types 2.5 + Enter into LocalTransform.position.x
|
||||
// (asserts the value through EditorSession.GetLocal), sends Ctrl+Z
|
||||
// (asserts restore), then renders the inspector golden (LocalTransform
|
||||
// + MeshRenderer sections). Needs the native engine_core beside the
|
||||
// test binary (same-dir resolution).
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.LogicalTree;
|
||||
using Avalonia.VisualTree;
|
||||
using Engine.Bindings;
|
||||
using Engine.Editor;
|
||||
using Engine.Runtime;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Engine.EditorApp.Tests;
|
||||
|
||||
internal static class InspectorPanelGolden
|
||||
{
|
||||
public static unsafe int Check(Avalonia.Controls.Window window, string outDir, string goldenDir)
|
||||
{
|
||||
var failures = 0;
|
||||
|
||||
var dir = AppContext.BaseDirectory;
|
||||
var loaded = false;
|
||||
string? lastError = null;
|
||||
IntPtr preloaded = IntPtr.Zero;
|
||||
foreach (var cand in new[] { Path.Combine(dir, "libengine_core.so"), Path.Combine(dir, "libengine_core.so.0.1.0") })
|
||||
{
|
||||
try
|
||||
{
|
||||
var flecs = Path.Combine(dir, "libflecs.so");
|
||||
if (File.Exists(flecs))
|
||||
{
|
||||
try { NativeLibrary.Load(flecs); } catch { }
|
||||
}
|
||||
|
||||
preloaded = NativeLibrary.Load(cand);
|
||||
loaded = true;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex.GetType().Name + ": " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
if (!loaded)
|
||||
{
|
||||
Console.WriteLine($"[SKIP] inspector-panel live (no native lib: {lastError})");
|
||||
return failures;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LoadTableFromHandle(preloaded);
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException || ex is InvalidOperationException)
|
||||
{
|
||||
Console.WriteLine($"[SKIP] inspector-panel live (no native lib: {ex.GetType().Name})");
|
||||
return failures;
|
||||
}
|
||||
|
||||
var table = EditorSession.Table;
|
||||
NativeApi.EngineWorld* world = table->EngineWorldCreate();
|
||||
failures += CheckOk(world != null, "inspector-panel: world_create");
|
||||
if (world == null)
|
||||
{
|
||||
return failures;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var session = new EditorSession(world);
|
||||
var inspector = new Inspector(session);
|
||||
|
||||
var entity = session.Spawn("Cube");
|
||||
failures += CheckOk(entity != 0, "inspector-panel: spawn Cube");
|
||||
session.EnsureLocal(entity);
|
||||
|
||||
// MeshRenderer row: zeroed 20B image through AddComponentCommand.
|
||||
inspector.Execute(new AddComponentCommand(session, entity, "Engine.Runtime.MeshRenderer", new byte[20]));
|
||||
failures += CheckOk(session.HasComponent(entity, "Engine.Runtime.MeshRenderer", 20), "inspector-panel: MeshRenderer added");
|
||||
|
||||
// Panel bound to the live session, hosted in its own window so
|
||||
// the golden frames the inspector (the shell window keeps its
|
||||
// own Content for its golden; one Setup per process still holds
|
||||
// because both windows share the same Application).
|
||||
var panel = new InspectorPanel(inspector);
|
||||
var panelWindow = new Window
|
||||
{
|
||||
Title = "Inspector",
|
||||
Width = 1280,
|
||||
Height = 720,
|
||||
Content = panel,
|
||||
};
|
||||
panelWindow.Show();
|
||||
panel.Select(entity, session);
|
||||
var undoBaseline = inspector.UndoCount;
|
||||
panel.UpdateLayout();
|
||||
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
||||
panelWindow.UpdateLayout();
|
||||
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
||||
|
||||
// position.x editor exists and starts at 0 (scoped to the panel window).
|
||||
var posX = FindEditor(panel, "position", "");
|
||||
failures += CheckOk(posX != null, "inspector-panel: position.x editor present");
|
||||
|
||||
// Type 2.5 + Enter: one undo step (LostFocus/Enter semantics).
|
||||
// Headless note: KeyPress(Enter) on the window does not route
|
||||
// into the box's KeyDown handler, and moving focus headless
|
||||
// does not raise LostFocus either. Focus the box first (real
|
||||
// path: the user typed into the focused editor), then raise the
|
||||
// box's own KeyDown with Enter so the panel commits exactly one
|
||||
// undo step through SetFieldCommand.
|
||||
if (posX != null)
|
||||
{
|
||||
posX.Focus();
|
||||
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
||||
posX.Text = "2.5";
|
||||
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
||||
posX.RaiseEvent(new KeyEventArgs
|
||||
{
|
||||
RoutedEvent = InputElement.KeyDownEvent,
|
||||
Key = Key.Enter,
|
||||
});
|
||||
Avalonia.Threading.Dispatcher.UIThread.RunJobs();
|
||||
panel.Refresh(session);
|
||||
}
|
||||
|
||||
|
||||
var pose = new float[10];
|
||||
session.GetLocal(entity, pose);
|
||||
failures += CheckOk(MathF.Abs(pose[0] - 2.5f) < 1e-4f, "inspector-panel: typing 2.5+Enter sets position.x");
|
||||
failures += CheckOk(inspector.UndoCount == undoBaseline + 1, "inspector-panel: one undo step committed");
|
||||
|
||||
// Ctrl+Z restores the old value (same shared stack the docked
|
||||
// UI's Ctrl+Z binding drives).
|
||||
failures += CheckOk(inspector.Undo(), "inspector-panel: Ctrl+Z succeeds");
|
||||
panel.Refresh(session);
|
||||
session.GetLocal(entity, pose);
|
||||
failures += CheckOk(MathF.Abs(pose[0]) < 1e-4f, "inspector-panel: Ctrl+Z restores position.x");
|
||||
|
||||
// Redo for the golden (shows the edited value path is intact).
|
||||
_ = inspector.Redo();
|
||||
panel.Refresh(session);
|
||||
|
||||
// Component sections: LocalTransform + MeshRenderer expanders.
|
||||
failures += CheckOk(HasSection(panel, "LocalTransform"), "inspector-panel: LocalTransform section present");
|
||||
failures += CheckOk(HasSection(panel, "Engine.Runtime.MeshRenderer"), "inspector-panel: MeshRenderer section present");
|
||||
|
||||
// Golden: inspector showing LocalTransform + MeshRenderer.
|
||||
Directory.CreateDirectory(outDir);
|
||||
var frame = panelWindow.CaptureRenderedFrame();
|
||||
if (frame is null)
|
||||
{
|
||||
Console.WriteLine("[FAIL] golden: editor_inspector_lm (no frame captured)");
|
||||
return failures + 1;
|
||||
}
|
||||
|
||||
var shotPath = Path.Combine(outDir, "editor_inspector_lm.png");
|
||||
frame.Save(shotPath, PngBitmapEncoderOptions.Default);
|
||||
Console.WriteLine($"[PASS] inspector-panel: screenshot saved to {shotPath}");
|
||||
failures += Golden.Check("editor_inspector_lm", frame, goldenDir);
|
||||
panelWindow.Close();
|
||||
}
|
||||
finally
|
||||
{
|
||||
table->EngineWorldDestroy(world);
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static TextBox? FindEditor(InspectorPanel panel, string field, string _lane)
|
||||
{
|
||||
foreach (var descendant in panel.GetLogicalDescendants())
|
||||
{
|
||||
if (descendant is TextBox box && box.Name == $"Edit_{field}")
|
||||
{
|
||||
return box;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasSection(InspectorPanel panel, string component)
|
||||
{
|
||||
foreach (var descendant in panel.GetLogicalDescendants())
|
||||
{
|
||||
if (descendant is Expander exp && exp.Name == $"Component_{component}")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int CheckOk(bool ok, string name)
|
||||
{
|
||||
Console.WriteLine($"[{(ok ? "PASS" : "FAIL")}] {name}");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
private static unsafe void LoadTableFromHandle(IntPtr handle)
|
||||
{
|
||||
if (!NativeLibrary.TryGetExport(handle, "engine_get_api_table", out var addr))
|
||||
{
|
||||
throw new EntryPointNotFoundException("engine_get_api_table not exported.");
|
||||
}
|
||||
|
||||
var getTable = (delegate* unmanaged[Cdecl, SuppressGCTransition]<NativeApi.ApiTable*>)addr;
|
||||
var table = getTable();
|
||||
if (table == null)
|
||||
{
|
||||
throw new InvalidOperationException("engine_get_api_table returned null.");
|
||||
}
|
||||
|
||||
EditorSession.TableOverride = (IntPtr)table;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ internal static class Program
|
||||
// the SAME window session (Avalonia allows one Setup per process, so
|
||||
// the panel renders into the shell window instead of its own).
|
||||
failures += HierarchyPanelGolden.Check(window, outDir, goldenDir);
|
||||
// P3-05: inspector panel golden (LocalTransform + MeshRenderer) —
|
||||
// same window session, runs after the hierarchy golden restored
|
||||
// the shell content (hosted panel replaces window.Content again).
|
||||
failures += InspectorPanelGolden.Check(window, outDir, goldenDir);
|
||||
window.Close();
|
||||
return failures;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,763 @@
|
||||
// InspectorPanel.cs — P3-05: Inspector panel control for engine-editor.
|
||||
//
|
||||
// Per-component Expanders for the selected entity, one editor per field
|
||||
// type: NumericUpDown (f32/i32/u32), CheckBox (bool8), 3x NumericUpDown
|
||||
// (vec3), Euler-degree NumericUpDowns (quat), ColorPicker (color4),
|
||||
// entity ComboBox, asset TextBox + drop target. Every edit goes through
|
||||
// SetFieldCommand via Inspector.Execute so one LostFocus/Enter commits
|
||||
// exactly one undo step (never per keystroke); Ctrl+Z reverts via the
|
||||
// shared stack. Add Component lists registered components; Remove
|
||||
// Component context entry removes one component.
|
||||
//
|
||||
// Headless-safe: pure Avalonia controls, no GPU dependency.
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Engine.Runtime;
|
||||
|
||||
namespace Engine.EditorApp;
|
||||
|
||||
public sealed class InspectorPanel : Border
|
||||
{
|
||||
private readonly Editor.Inspector? _inspector;
|
||||
private ulong _entity;
|
||||
|
||||
private static readonly string[] KnownComponents =
|
||||
[
|
||||
"LocalTransform",
|
||||
"Engine.Runtime.Camera",
|
||||
"Engine.Runtime.MeshRenderer",
|
||||
"Engine.Runtime.DirectionalLight",
|
||||
];
|
||||
|
||||
public InspectorPanel()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public InspectorPanel(Editor.Inspector? inspector)
|
||||
{
|
||||
Name = "Inspector";
|
||||
_inspector = inspector;
|
||||
Child = BuildPlaceholder();
|
||||
}
|
||||
|
||||
/// <summary>Entity the panel is bound to (0 = none).</summary>
|
||||
public ulong Entity => _entity;
|
||||
|
||||
/// <summary>Rebind the panel to another entity (rebuilds sections).</summary>
|
||||
public void Select(ulong entity, Editor.EditorSession? session)
|
||||
{
|
||||
_entity = entity;
|
||||
if (session == null || entity == 0)
|
||||
{
|
||||
Child = BuildPlaceholder();
|
||||
return;
|
||||
}
|
||||
|
||||
Child = BuildBody(session, entity);
|
||||
}
|
||||
|
||||
/// <summary>Refresh editors from native (call after undo/redo/external edits).</summary>
|
||||
public void Refresh(Editor.EditorSession? session)
|
||||
{
|
||||
if (session == null || _entity == 0)
|
||||
{
|
||||
Child = BuildPlaceholder();
|
||||
return;
|
||||
}
|
||||
|
||||
Child = BuildBody(session, _entity);
|
||||
}
|
||||
|
||||
private static TextBlock BuildPlaceholder()
|
||||
{
|
||||
return new TextBlock
|
||||
{
|
||||
Name = "InspectorPlaceholder",
|
||||
Text = "Select an entity",
|
||||
Margin = new Thickness(8),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
}
|
||||
|
||||
private ScrollViewer BuildBody(Editor.EditorSession session, ulong entity)
|
||||
{
|
||||
var stack = new StackPanel
|
||||
{
|
||||
Name = "InspectorSections",
|
||||
Orientation = Orientation.Vertical,
|
||||
Spacing = 4,
|
||||
Margin = new Thickness(4),
|
||||
};
|
||||
var scroll = new ScrollViewer
|
||||
{
|
||||
Content = stack,
|
||||
HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
|
||||
};
|
||||
|
||||
var entityName = TryGetName(session, entity);
|
||||
stack.Children.Add(new TextBlock
|
||||
{
|
||||
Name = "InspectorEntityName",
|
||||
Text = string.IsNullOrEmpty(entityName) ? $"Entity {entity}" : entityName,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Margin = new Thickness(4, 2),
|
||||
});
|
||||
|
||||
foreach (var component in KnownComponents)
|
||||
{
|
||||
var fields = SafeFields(session, component);
|
||||
if (fields.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// LocalTransform poses come from editor_get_local; only add a
|
||||
// section when the entity actually carries the component
|
||||
// (entity_get succeeds on the full byte image).
|
||||
if (!HasComponent(session, entity, component, fields))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stack.Children.Add(BuildSection(session, entity, component, fields));
|
||||
}
|
||||
|
||||
var add = new Button
|
||||
{
|
||||
Name = "AddComponent",
|
||||
Content = "Add Component",
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
Margin = new Thickness(4),
|
||||
};
|
||||
var menu = new ContextMenu();
|
||||
foreach (var component in KnownComponents)
|
||||
{
|
||||
var item = new MenuItem { Header = component };
|
||||
var captured = component;
|
||||
item.Click += (_, _) => AddComponent(session, entity, captured);
|
||||
menu.Items.Add(item);
|
||||
}
|
||||
|
||||
add.ContextMenu = menu;
|
||||
add.Click += (_, _) => menu.Open(add);
|
||||
stack.Children.Add(add);
|
||||
|
||||
return scroll;
|
||||
}
|
||||
|
||||
private Expander BuildSection(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
IReadOnlyList<Editor.ComponentFieldInfo> fields)
|
||||
{
|
||||
var rows = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Vertical,
|
||||
Spacing = 2,
|
||||
Margin = new Thickness(4),
|
||||
};
|
||||
|
||||
var size = ComponentByteSize(fields);
|
||||
var image = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
var type = (Editor.InspectorFieldType)field.Type;
|
||||
rows.Children.Add(BuildFieldRow(session, entity, component, field.Name, type, (int)field.Offset, image));
|
||||
}
|
||||
|
||||
var remove = new MenuItem { Header = "Remove Component" };
|
||||
remove.Click += (_, _) => RemoveComponent(session, entity, component);
|
||||
|
||||
return new Expander
|
||||
{
|
||||
Name = $"Component_{component}",
|
||||
Header = component,
|
||||
Content = rows,
|
||||
ContextMenu = new ContextMenu { Items = { remove } },
|
||||
IsExpanded = true,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
};
|
||||
}
|
||||
|
||||
private StackPanel BuildFieldRow(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
Editor.InspectorFieldType type,
|
||||
int offset,
|
||||
byte[] image)
|
||||
{
|
||||
var row = new StackPanel
|
||||
{
|
||||
Name = $"Field_{component}_{field}",
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 6,
|
||||
Margin = new Thickness(2),
|
||||
};
|
||||
row.Children.Add(new TextBlock
|
||||
{
|
||||
Text = field,
|
||||
Width = 110,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
|
||||
var editors = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, HorizontalAlignment = HorizontalAlignment.Left };
|
||||
switch (type)
|
||||
{
|
||||
case Editor.InspectorFieldType.F32:
|
||||
case Editor.InspectorFieldType.I32:
|
||||
case Editor.InspectorFieldType.U32:
|
||||
editors.Children.Add(MakeScalar(session, entity, component, field, type, offset, image, 0));
|
||||
break;
|
||||
case Editor.InspectorFieldType.Bool8:
|
||||
editors.Children.Add(MakeBool(session, entity, component, field, offset, image));
|
||||
break;
|
||||
case Editor.InspectorFieldType.Vec2:
|
||||
case Editor.InspectorFieldType.Vec3:
|
||||
case Editor.InspectorFieldType.Vec4:
|
||||
for (var lane = 0; lane < LaneCount(type); lane++)
|
||||
{
|
||||
editors.Children.Add(MakeScalar(session, entity, component, field, type, offset, image, lane));
|
||||
}
|
||||
|
||||
break;
|
||||
case Editor.InspectorFieldType.Quat:
|
||||
for (var lane = 0; lane < 3; lane++)
|
||||
{
|
||||
editors.Children.Add(MakeEuler(session, entity, component, field, offset, image, lane));
|
||||
}
|
||||
|
||||
break;
|
||||
case Editor.InspectorFieldType.Color4:
|
||||
editors.Children.Add(MakeColorRow(session, entity, component, field, offset, image));
|
||||
break;
|
||||
case Editor.InspectorFieldType.Entity:
|
||||
editors.Children.Add(MakeEntity(session, entity, component, field, offset, image));
|
||||
break;
|
||||
case Editor.InspectorFieldType.AssetRef:
|
||||
editors.Children.Add(MakeAsset(session, entity, component, field, offset, image));
|
||||
break;
|
||||
default:
|
||||
editors.Children.Add(new TextBlock { Text = $"(type {(uint)type} has no editor)" });
|
||||
break;
|
||||
}
|
||||
|
||||
row.Children.Add(editors);
|
||||
return row;
|
||||
}
|
||||
|
||||
private TextBox MakeScalar(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
Editor.InspectorFieldType type,
|
||||
int offset,
|
||||
byte[] image,
|
||||
int lane)
|
||||
{
|
||||
var current = Editor.FieldValue.ReadFloat(image, offset, type, lane);
|
||||
var box = new TextBox
|
||||
{
|
||||
Name = lane == 0 ? $"Edit_{field}" : $"Edit_{field}_{lane}",
|
||||
Text = FormatFloat(current),
|
||||
Width = 90,
|
||||
};
|
||||
box.LostFocus += (_, _) => CommitScalar(session, entity, component, field, box, type, offset, lane);
|
||||
box.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
CommitScalar(session, entity, component, field, box, type, offset, lane);
|
||||
}
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
private void CommitScalar(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
TextBox box,
|
||||
Editor.InspectorFieldType type,
|
||||
int offset,
|
||||
int lane)
|
||||
{
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
var before = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
var after = (byte[])before.Clone();
|
||||
if (!float.TryParse(box.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Editor.FieldValue.WriteFloat(after, offset, type, parsed, lane);
|
||||
if (before.SequenceEqual(after))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_inspector.Execute(new Editor.SetFieldCommand(session, entity, component, field, before, after));
|
||||
}
|
||||
|
||||
private CheckBox MakeBool(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
byte[] image)
|
||||
{
|
||||
var box = new CheckBox
|
||||
{
|
||||
Name = $"Edit_{field}",
|
||||
IsChecked = Editor.FieldValue.ReadBool(image, offset),
|
||||
};
|
||||
box.Click += (_, _) =>
|
||||
{
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
var before = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
var after = (byte[])before.Clone();
|
||||
Editor.FieldValue.WriteBool(after, offset, box.IsChecked == true);
|
||||
if (!before.SequenceEqual(after))
|
||||
{
|
||||
_inspector.Execute(new Editor.SetFieldCommand(session, entity, component, field, before, after));
|
||||
}
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
private TextBox MakeEuler(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
byte[] image,
|
||||
int lane)
|
||||
{
|
||||
var euler = Editor.FieldValue.QuatToEulerDeg(image, offset);
|
||||
var current = lane == 0 ? euler.X : lane == 1 ? euler.Y : euler.Z;
|
||||
var box = new TextBox
|
||||
{
|
||||
Name = $"Edit_{field}_{AxisName(lane)}",
|
||||
Text = FormatFloat(current),
|
||||
Width = 90,
|
||||
};
|
||||
box.LostFocus += (_, _) => CommitEuler(session, entity, component, field, offset, image, lane, box);
|
||||
box.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
CommitEuler(session, entity, component, field, offset, image, lane, box);
|
||||
}
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
private void CommitEuler(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
byte[] image,
|
||||
int lane,
|
||||
TextBox box)
|
||||
{
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var euler = Editor.FieldValue.QuatToEulerDeg(image, offset);
|
||||
if (!float.TryParse(box.Text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var next))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var merged = lane switch
|
||||
{
|
||||
0 => new System.Numerics.Vector3(next, euler.Y, euler.Z),
|
||||
1 => new System.Numerics.Vector3(euler.X, next, euler.Z),
|
||||
_ => new System.Numerics.Vector3(euler.X, euler.Y, next),
|
||||
};
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
var before = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
var after = (byte[])before.Clone();
|
||||
Editor.FieldValue.EulerDegToQuat(after, offset, merged);
|
||||
if (!before.SequenceEqual(after))
|
||||
{
|
||||
_inspector.Execute(new Editor.SetFieldCommand(session, entity, component, field, before, after));
|
||||
}
|
||||
}
|
||||
|
||||
private StackPanel MakeColorRow(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
byte[] image)
|
||||
{
|
||||
var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, VerticalAlignment = VerticalAlignment.Center };
|
||||
var swatch = new Border
|
||||
{
|
||||
Name = $"Swatch_{field}",
|
||||
Width = 28,
|
||||
Height = 22,
|
||||
CornerRadius = new CornerRadius(3),
|
||||
BorderBrush = Brushes.Gray,
|
||||
BorderThickness = new Thickness(1),
|
||||
Background = new SolidColorBrush(ColorBytes(image, offset)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
row.Children.Add(swatch);
|
||||
for (var lane = 0; lane < 4; lane++)
|
||||
{
|
||||
var laneBox = MakeScalar(session, entity, component, field, Editor.InspectorFieldType.Color4, offset, image, lane);
|
||||
laneBox.LostFocus += (_, _) => swatch.Background = new SolidColorBrush(ColorBytes(RefreshImage(session, entity, component), offset));
|
||||
laneBox.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
swatch.Background = new SolidColorBrush(ColorBytes(RefreshImage(session, entity, component), offset));
|
||||
}
|
||||
};
|
||||
row.Children.Add(laneBox);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static byte[] RefreshImage(Editor.EditorSession session, ulong entity, string component)
|
||||
{
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
return Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
}
|
||||
|
||||
private static Color ColorBytes(byte[] image, int offset)
|
||||
{
|
||||
byte B(int lane) => (byte)Math.Clamp((int)MathF.Round(Editor.FieldValue.ReadFloat(image, offset, Editor.InspectorFieldType.Color4, lane) * 255), 0, 255);
|
||||
return new Color(B(3), B(0), B(1), B(2));
|
||||
}
|
||||
|
||||
private ComboBox MakeEntity(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
byte[] image)
|
||||
{
|
||||
var current = Editor.FieldValue.ReadId(image, offset, Editor.InspectorFieldType.Entity);
|
||||
var combo = new ComboBox
|
||||
{
|
||||
Name = $"Edit_{field}",
|
||||
Width = 140,
|
||||
};
|
||||
var ids = CollectEntityIds(session);
|
||||
combo.ItemsSource = ids.Select(id => id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList();
|
||||
combo.SelectedItem = current.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
combo.DropDownClosed += (_, _) =>
|
||||
{
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ulong.TryParse(combo.SelectedItem as string, out var next))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
var before = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
var after = (byte[])before.Clone();
|
||||
Editor.FieldValue.WriteId(after, offset, Editor.InspectorFieldType.Entity, next);
|
||||
if (!before.SequenceEqual(after))
|
||||
{
|
||||
_inspector.Execute(new Editor.SetFieldCommand(session, entity, component, field, before, after));
|
||||
}
|
||||
};
|
||||
return combo;
|
||||
}
|
||||
|
||||
private TextBox MakeAsset(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
byte[] image)
|
||||
{
|
||||
var current = Editor.FieldValue.ReadId(image, offset, Editor.InspectorFieldType.AssetRef);
|
||||
var box = new TextBox
|
||||
{
|
||||
Name = $"Edit_{field}",
|
||||
Text = current.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Width = 140,
|
||||
};
|
||||
DragDrop.SetAllowDrop(box, true);
|
||||
box.AddHandler(DragDrop.DropEvent, (_, e) =>
|
||||
{
|
||||
var text = TryGetDropText(e.DataTransfer);
|
||||
if (_inspector != null && ulong.TryParse(text, out var next))
|
||||
{
|
||||
CommitAsset(session, entity, component, field, offset, next);
|
||||
}
|
||||
});
|
||||
box.LostFocus += (_, _) => CommitAssetText(session, entity, component, field, offset, box);
|
||||
box.KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
CommitAssetText(session, entity, component, field, offset, box);
|
||||
}
|
||||
};
|
||||
return box;
|
||||
}
|
||||
|
||||
private static string? TryGetDropText(Avalonia.Input.IDataTransfer? transfer)
|
||||
{
|
||||
if (transfer == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var item in transfer.Items)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Avalonia.Input.DataTransferItemExtensions.TryGetText(item);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void CommitAssetText(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
TextBox box)
|
||||
{
|
||||
if (_inspector == null || !ulong.TryParse(box.Text, out var next))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CommitAsset(session, entity, component, field, offset, next);
|
||||
}
|
||||
|
||||
private void CommitAsset(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
string field,
|
||||
int offset,
|
||||
ulong next)
|
||||
{
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
var before = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
var after = (byte[])before.Clone();
|
||||
Editor.FieldValue.WriteId(after, offset, Editor.InspectorFieldType.AssetRef, next);
|
||||
if (!before.SequenceEqual(after))
|
||||
{
|
||||
_inspector.Execute(new Editor.SetFieldCommand(session, entity, component, field, before, after));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddComponent(Editor.EditorSession session, ulong entity, string component)
|
||||
{
|
||||
var size = ComponentByteSize(SafeFields(session, component));
|
||||
if (size <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Already present: nothing to add.
|
||||
_ = Editor.SetFieldCommand.ReadImage(session, entity, component, size);
|
||||
return;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_inspector.Execute(new Editor.AddComponentCommand(session, entity, component, new byte[size]));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
|
||||
Refresh(session);
|
||||
}
|
||||
|
||||
private void RemoveComponent(Editor.EditorSession session, ulong entity, string component)
|
||||
{
|
||||
if (_inspector == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_inspector.Execute(new Editor.RemoveComponentCommand(session, entity, component, ComponentByteSize(SafeFields(session, component))));
|
||||
Refresh(session);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ulong> CollectEntityIds(Editor.EditorSession session)
|
||||
{
|
||||
var ids = new List<ulong>();
|
||||
try
|
||||
{
|
||||
var roots = session.CountRoots();
|
||||
for (ulong i = 0; i < roots; i++)
|
||||
{
|
||||
CollectSubtree(session, session.GetRoot(i), ids);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static void CollectSubtree(Editor.EditorSession session, ulong entity, List<ulong> ids)
|
||||
{
|
||||
ids.Add(entity);
|
||||
var kids = session.CountChildren(entity);
|
||||
for (ulong i = 0; i < kids; i++)
|
||||
{
|
||||
CollectSubtree(session, session.GetChild(entity, i), ids);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryGetName(Editor.EditorSession session, ulong entity)
|
||||
{
|
||||
try
|
||||
{
|
||||
return session.GetName(entity);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<Editor.ComponentFieldInfo> SafeFields(Editor.EditorSession session, string component)
|
||||
{
|
||||
try
|
||||
{
|
||||
return session.FieldsOf(component);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return Array.Empty<Editor.ComponentFieldInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasComponent(
|
||||
Editor.EditorSession session,
|
||||
ulong entity,
|
||||
string component,
|
||||
IReadOnlyList<Editor.ComponentFieldInfo> fields)
|
||||
{
|
||||
var size = ComponentByteSize(fields);
|
||||
if (size <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return session.HasComponent(entity, component, size);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ComponentByteSize(IReadOnlyList<Editor.ComponentFieldInfo> fields)
|
||||
{
|
||||
var end = 0;
|
||||
foreach (var f in fields)
|
||||
{
|
||||
var size = (int)(Editor.FieldValue.ByteSize((Editor.InspectorFieldType)f.Type) + 0);
|
||||
var fieldEnd = (int)f.Offset + size;
|
||||
if (fieldEnd > end)
|
||||
{
|
||||
end = fieldEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
private static string FormatFloat(float value)
|
||||
{
|
||||
if (MathF.Abs(value) < 0.00005f)
|
||||
{
|
||||
return "0";
|
||||
}
|
||||
|
||||
return value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string AxisName(int lane) => lane switch
|
||||
{
|
||||
0 => "x",
|
||||
1 => "y",
|
||||
_ => "z",
|
||||
};
|
||||
|
||||
private static int LaneCount(Editor.InspectorFieldType type) => type switch
|
||||
{
|
||||
Editor.InspectorFieldType.Vec2 => 2,
|
||||
Editor.InspectorFieldType.Vec3 => 3,
|
||||
Editor.InspectorFieldType.Vec4 => 4,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Rendering.cs — P1-09: C# view of the native render components + Renderer.
|
||||
//
|
||||
// Camera, MeshRenderer, DirectionalLight are IComponent structs in
|
||||
// namespace Engine.Runtime so their full names match the native
|
||||
// registration P1-08 performs ("Engine.Runtime.Camera" etc.). Layouts
|
||||
// match the native structs exactly (see ecs_render_components.hpp):
|
||||
// Camera { float fov_y_rad, near_z, far_z; uint is_main; } 16B, align 4
|
||||
// MeshRenderer { uint mesh_id; float base_color[4]; } 20B, align 4
|
||||
// DirectionalLight { float direction[3]; float intensity; } 16B, align 4
|
||||
//
|
||||
// Renderer is an IDisposable over the ABI 4.3 renderer tail: create /
|
||||
// frame / mesh_create / screenshot / destroy. The world is BORROWED
|
||||
// (destroy the Renderer before the world, or frames stop).
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Engine.Bindings;
|
||||
|
||||
namespace Engine.Runtime;
|
||||
|
||||
/// <summary>Perspective camera (16 bytes, align 4 — matches native).</summary>
|
||||
#pragma warning disable CA1051 // Visible instance fields by design: blittable interop struct.
|
||||
public struct Camera : IComponent
|
||||
{
|
||||
public float FovYRad;
|
||||
public float NearZ;
|
||||
public float FarZ;
|
||||
public uint IsMain;
|
||||
}
|
||||
|
||||
/// <summary>Mesh draw (20 bytes, align 4 — matches native).</summary>
|
||||
public unsafe struct MeshRenderer : IComponent
|
||||
{
|
||||
public uint MeshId;
|
||||
public fixed float BaseColor[4];
|
||||
}
|
||||
|
||||
/// <summary>Directional light (16 bytes, align 4 — matches native).</summary>
|
||||
public unsafe struct DirectionalLight : IComponent
|
||||
{
|
||||
public fixed float Direction[3];
|
||||
public float Intensity;
|
||||
}
|
||||
#pragma warning restore CA1051
|
||||
@@ -17,7 +17,7 @@ public static class AbiLayoutTests
|
||||
{
|
||||
var failures = 0;
|
||||
failures += Check(NativeApi.AbiVersionMajor == 4, "abi major == 4");
|
||||
failures += Check(NativeApi.AbiVersionMinor == 4, "abi minor == 4 (4.4: hierarchy + field-metadata tails)");
|
||||
failures += Check(NativeApi.AbiVersionMinor == 5, "abi minor == 5 (4.5: inspector entity_remove tail)");
|
||||
failures += Check(NativeApi.AbiVersionPatch == 0, "abi patch == 0");
|
||||
failures += Check(NativeApi.ColumnStride == 64, "column stride == 64");
|
||||
|
||||
@@ -27,13 +27,14 @@ public static class AbiLayoutTests
|
||||
failures += Check(sizeof(NativeApi.WorldTransform) == 64, "WorldTransform 64B");
|
||||
failures += Check(sizeof(NativeApi.EcsChunkView) == 48, "EcsChunkView 48B");
|
||||
failures += Check(sizeof(NativeApi.FrameDispatch) == 4, "FrameDispatch 4B");
|
||||
failures += Check(sizeof(NativeApi.ApiTable) == 424, "ApiTable 424B (ABI 4.4: +5 hierarchy +3 field-metadata entries)");
|
||||
failures += Check(sizeof(NativeApi.ApiTable) == 432, "ApiTable 432B (ABI 4.5: +5 hierarchy +3 field-metadata +1 entity_remove)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentRegister").ToInt64() == 312, "component_register @ 312 (ABI 4.2 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineSystemsRun").ToInt64() == 352, "systems_run @ 352 (ABI 4.2 tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorDestroyEntity").ToInt64() == 360, "editor_destroy_entity @ 360 (4.3 hierarchy tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorSnapshotRestoreSubtree").ToInt64() == 392, "snapshot_restore_subtree @ 392 (4.3 tail end)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentDescribe").ToInt64() == 400, "component_describe @ 400 (ABI 4.4 field tail)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineComponentFieldAt").ToInt64() == 416, "component_field_at @ 416 (ABI 4.4 field tail end)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEntityRemove").ToInt64() == 424, "entity_remove @ 424 (ABI 4.5 inspector tail)");
|
||||
failures += Check(sizeof(NativeApi.EngineChunkColumns) == 120, "EngineChunkColumns 120B");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EnginePhysicsRaycast").ToInt64() == 136, "physics_raycast @ 136 (M4 tail frozen)");
|
||||
failures += Check(Marshal.OffsetOf<NativeApi.ApiTable>("EngineEditorCreate").ToInt64() == 168, "editor_create @ 168 (M5 tail)");
|
||||
|
||||
@@ -131,6 +131,71 @@ public static class EditorApiTests
|
||||
|
||||
failures += Check(lenGuard, "inspector-cpu: bad pose images rejected");
|
||||
|
||||
// --- P3-05 CPU: FieldValue codec (no native needed) -------------------
|
||||
// Float round-trips, bool bytes, Euler<->quat identity, lane guards.
|
||||
var f32img = new byte[16];
|
||||
FieldValue.WriteFloat(f32img, 4, InspectorFieldType.F32, 2.5f);
|
||||
failures += Check(MathF.Abs(FieldValue.ReadFloat(f32img, 4, InspectorFieldType.F32) - 2.5f) < 1e-6f, "inspector-cpu: f32 round-trip");
|
||||
FieldValue.WriteFloat(f32img, 0, InspectorFieldType.I32, 2.5f);
|
||||
failures += Check(FieldValue.ReadFloat(f32img, 0, InspectorFieldType.I32) == 2.0f, "inspector-cpu: i32 rounds");
|
||||
FieldValue.WriteBool(f32img, 8, true);
|
||||
failures += Check(FieldValue.ReadBool(f32img, 8) && f32img[8] == 1, "inspector-cpu: bool8 is 1 byte");
|
||||
failures += Check(FieldValue.ByteSize(InspectorFieldType.Quat) == 16 && FieldValue.ByteSize(InspectorFieldType.Entity) == 8, "inspector-cpu: sizes quat 16 / entity 8");
|
||||
FieldValue.WriteId(f32img, 8, InspectorFieldType.Entity, 42);
|
||||
failures += Check(FieldValue.ReadId(f32img, 8, InspectorFieldType.Entity) == 42, "inspector-cpu: entity id round-trip");
|
||||
FieldValue.EulerDegToQuat(f32img, 0, new System.Numerics.Vector3(0, 45, 0));
|
||||
var euler = FieldValue.QuatToEulerDeg(f32img, 0);
|
||||
failures += Check(MathF.Abs(euler.Y - 45) < 0.5f && MathF.Abs(euler.X) < 0.5f && MathF.Abs(euler.Z) < 0.5f, "inspector-cpu: 45deg yaw round-trip");
|
||||
FieldValue.EulerDegToQuat(f32img, 0, new System.Numerics.Vector3(10, 20, 30));
|
||||
var q1 = new System.Numerics.Quaternion(
|
||||
BitConverter.ToSingle(f32img, 0), BitConverter.ToSingle(f32img, 4),
|
||||
BitConverter.ToSingle(f32img, 8), BitConverter.ToSingle(f32img, 12));
|
||||
var arb = FieldValue.QuatToEulerDeg(f32img, 0);
|
||||
FieldValue.EulerDegToQuat(f32img, 0, arb);
|
||||
var q2 = new System.Numerics.Quaternion(
|
||||
BitConverter.ToSingle(f32img, 0), BitConverter.ToSingle(f32img, 4),
|
||||
BitConverter.ToSingle(f32img, 8), BitConverter.ToSingle(f32img, 12));
|
||||
var dot = MathF.Abs(q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z + q1.W * q2.W);
|
||||
failures += Check(dot > 0.9999f, "inspector-cpu: 10/20/30 euler-quat round-trip");
|
||||
FieldValue.EulerDegToQuat(f32img, 0, new System.Numerics.Vector3(0, 0, 0));
|
||||
var ident = FieldValue.QuatToEulerDeg(f32img, 0);
|
||||
failures += Check(MathF.Abs(ident.X) < 0.5f && MathF.Abs(ident.Y) < 0.5f && MathF.Abs(ident.Z) < 0.5f, "inspector-cpu: identity euler ~0");
|
||||
var laneGuard = false;
|
||||
try
|
||||
{
|
||||
FieldValue.ReadFloat(f32img, 0, InspectorFieldType.Vec3, 3);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
laneGuard = true;
|
||||
}
|
||||
|
||||
failures += Check(laneGuard, "inspector-cpu: vec3 lane OOB throws");
|
||||
|
||||
// SetFieldCommand validation (no native needed: ctor guards only).
|
||||
var sfGuard = false;
|
||||
try
|
||||
{
|
||||
_ = new SetFieldCommand(null!, 1, "C", "f", new byte[4], new byte[4]);
|
||||
}
|
||||
catch (ArgumentNullException)
|
||||
{
|
||||
sfGuard = true;
|
||||
}
|
||||
|
||||
failures += Check(sfGuard, "inspector-cpu: SetFieldCommand rejects null session");
|
||||
var sfLenGuard = false;
|
||||
try
|
||||
{
|
||||
_ = new SetFieldCommand(null!, 1, "C", "f", new byte[4], new byte[8]);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentNullException || ex is ArgumentException)
|
||||
{
|
||||
sfLenGuard = true;
|
||||
}
|
||||
|
||||
failures += Check(sfLenGuard, "inspector-cpu: SetFieldCommand rejects mismatched images");
|
||||
|
||||
// --- P3-03 CPU: CountingCommand dispose contract ----------------------
|
||||
// Full Inspector disposal paths (redo-clear, Clear(), depth eviction)
|
||||
// run live below where a real session exists; here just pin the
|
||||
@@ -319,6 +384,63 @@ public static class EditorApiTests
|
||||
// succeed, later sibling walk segfaults). Native side keeps the
|
||||
// duplicate() entry; managed coverage returns with the fix.
|
||||
|
||||
// P3-05 live: SetFieldCommand via entity_get/entity_set, one
|
||||
// undo step per commit (LostFocus/Enter semantics approximated
|
||||
// by a single Execute), plus Add/Remove component round-trips.
|
||||
inspector.Clear();
|
||||
session.EnsureLocal(root);
|
||||
var ltBefore = SetFieldCommand.ReadImage(session, root, "LocalTransform", 64);
|
||||
var ltAfter = (byte[])ltBefore.Clone();
|
||||
FieldValue.WriteFloat(ltAfter, 0, InspectorFieldType.Vec3, 2.5f, 0);
|
||||
using (var setCmd = new SetFieldCommand(session, root, "LocalTransform", "position.x", ltBefore, ltAfter))
|
||||
{
|
||||
inspector.Execute(setCmd);
|
||||
var px = new float[10];
|
||||
session.GetLocal(root, px);
|
||||
failures += Check(MathF.Abs(px[0] - 2.5f) < 1e-6f, "live: SetFieldCommand position.x = 2.5");
|
||||
failures += Check(inspector.Undo(), "live: SetFieldCommand undo succeeds");
|
||||
session.GetLocal(root, px);
|
||||
failures += Check(MathF.Abs(px[0] - ltBefore[0]) < 1e-6f && MathF.Abs(px[0] - 1) > 1e-3f, "live: SetFieldCommand undo restores old pose");
|
||||
failures += Check(inspector.Redo(), "live: SetFieldCommand redo succeeds");
|
||||
session.GetLocal(root, px);
|
||||
failures += Check(MathF.Abs(px[0] - 2.5f) < 1e-6f, "live: SetFieldCommand redo re-applies");
|
||||
failures += Check(inspector.Undo(), "live: SetFieldCommand final undo restores pose");
|
||||
}
|
||||
|
||||
// MeshRenderer self-description: mesh_id u32 + base_color color4.
|
||||
var mrFields = ComponentFields.ReadAll(world, "Engine.Runtime.MeshRenderer");
|
||||
failures += Check(mrFields.Count == 2 && mrFields[0].Name == "mesh_id" && mrFields[1].Name == "base_color", "live: MeshRenderer describes 2 rows");
|
||||
var meshId = Engine.Runtime.Ecs.ComponentId<Engine.Runtime.MeshRenderer>(EditorSession.Table, world);
|
||||
failures += Check(meshId != 0, "live: MeshRenderer component id resolves");
|
||||
var mrZero = new byte[20];
|
||||
using (var addMr = new AddComponentCommand(session, root, "Engine.Runtime.MeshRenderer", mrZero))
|
||||
{
|
||||
inspector.Execute(addMr);
|
||||
failures += Check(session.HasComponent(root, "Engine.Runtime.MeshRenderer", 20), "live: AddComponent adds MeshRenderer");
|
||||
var mrAfter = SetFieldCommand.ReadImage(session, root, "Engine.Runtime.MeshRenderer", 20);
|
||||
var mrRed = (byte[])mrAfter.Clone();
|
||||
FieldValue.WriteFloat(mrRed, 4, InspectorFieldType.Color4, 0.25f, 0);
|
||||
using (var setRed = new SetFieldCommand(session, root, "Engine.Runtime.MeshRenderer", "base_color.r", mrAfter, mrRed))
|
||||
{
|
||||
inspector.Execute(setRed);
|
||||
var got = Engine.Runtime.Ecs.Get<Engine.Runtime.MeshRenderer>(EditorSession.Table, world, new Engine.Runtime.Entity(root));
|
||||
failures += Check(MathF.Abs(got.BaseColor[0] - 0.25f) < 1e-6f, "live: MeshRenderer base_color.r edit visible");
|
||||
failures += Check(inspector.Undo(), "live: MeshRenderer color undo succeeds");
|
||||
got = Engine.Runtime.Ecs.Get<Engine.Runtime.MeshRenderer>(EditorSession.Table, world, new Engine.Runtime.Entity(root));
|
||||
failures += Check(MathF.Abs(got.BaseColor[0]) < 1e-6f, "live: MeshRenderer color undo restores zero");
|
||||
}
|
||||
|
||||
using (var removeMr = new RemoveComponentCommand(session, root, "Engine.Runtime.MeshRenderer", 20))
|
||||
{
|
||||
inspector.Execute(removeMr);
|
||||
failures += Check(!session.HasComponent(root, "Engine.Runtime.MeshRenderer", 20), "live: RemoveComponent removes MeshRenderer");
|
||||
failures += Check(inspector.Undo(), "live: RemoveComponent undo succeeds");
|
||||
failures += Check(session.HasComponent(root, "Engine.Runtime.MeshRenderer", 20), "live: RemoveComponent undo restores row");
|
||||
failures += Check(inspector.Redo(), "live: RemoveComponent redo succeeds");
|
||||
failures += Check(!session.HasComponent(root, "Engine.Runtime.MeshRenderer", 20), "live: RemoveComponent redo re-removes");
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: inspector — write pose, undo/redo via Inspector.
|
||||
// (Fresh stack: P3-03 commands above own their assertions.)
|
||||
inspector.Clear();
|
||||
|
||||
@@ -86,7 +86,7 @@ extern "C" {
|
||||
// signature.
|
||||
// ---------------------------------------------------------------------------
|
||||
#define ENGINE_ABI_VERSION_MAJOR 4
|
||||
#define ENGINE_ABI_VERSION_MINOR 4
|
||||
#define ENGINE_ABI_VERSION_MINOR 5
|
||||
#define ENGINE_ABI_VERSION_PATCH 0
|
||||
|
||||
// 64-byte cache-line pitch for the component columns handed to managed
|
||||
@@ -555,6 +555,12 @@ typedef int32_t (*EngineEntityGetFn)(EngineWorld* world, uint64_t entity,
|
||||
uint64_t component, void* out,
|
||||
uint32_t size);
|
||||
|
||||
// Remove `component` from `entity` (no-op when absent). Returns 0 on
|
||||
// success or an ENGINE_ABI_ERR_* code (BAD_ARG for a dead entity or an
|
||||
// unknown component id). P3-05: Remove Component in the inspector.
|
||||
typedef int32_t (*EngineEntityRemoveFn)(EngineWorld* world, uint64_t entity,
|
||||
uint64_t component);
|
||||
|
||||
// Register a system over `term_count` component ids (1..8) with one
|
||||
// access flag per term. Returns the system index (>= 0) or an
|
||||
// ENGINE_ABI_ERR_* code. `name` is copied (truncated to 63 bytes).
|
||||
@@ -696,6 +702,8 @@ typedef struct EngineApiTable {
|
||||
EngineComponentDescribeFn component_describe;
|
||||
EngineComponentFieldCountFn component_field_count;
|
||||
EngineComponentFieldAtFn component_field_at;
|
||||
// P3-05 inspector tail (ABI 4.5). All earlier order frozen.
|
||||
EngineEntityRemoveFn entity_remove;
|
||||
|
||||
} EngineApiTable;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ struct enkiTaskScheduler;
|
||||
#include "engine/ecs/editor_bridge.hpp"
|
||||
#include "engine/core/engine_core_export.hpp"
|
||||
#include "engine/core/job_scheduler.hpp"
|
||||
#include "engine/ecs/ecs_render_components.hpp"
|
||||
#include "engine/ecs/ecs_world.hpp"
|
||||
#include "engine/ecs/transform.hpp"
|
||||
#include "flecs.h"
|
||||
@@ -132,7 +133,9 @@ auto ApiWorldCreate() -> EngineWorld* {
|
||||
return nullptr;
|
||||
}
|
||||
impl->ecs.set_scheduler(impl->sched);
|
||||
if (!impl->ecs.construct()) {
|
||||
if (!impl->ecs.construct() || !impl->ecs.register_render()) {
|
||||
impl->ecs.destroy();
|
||||
engine::core::RemoveTaskHooks();
|
||||
impl->sched->shutdown();
|
||||
delete impl->sched; // NOLINT
|
||||
delete impl; // NOLINT
|
||||
@@ -1027,6 +1030,21 @@ auto ApiEntityGet(EngineWorld* world, uint64_t entity, uint64_t component,
|
||||
return impl->generic.get(impl->ecs.raw(), entity, component, out, size);
|
||||
}
|
||||
|
||||
auto ApiEntityRemove(EngineWorld* world, uint64_t entity,
|
||||
uint64_t component) -> int32_t {
|
||||
if (world == nullptr) {
|
||||
return ENGINE_ABI_ERR_NULL_ARG;
|
||||
}
|
||||
auto* impl = ImplOf(world);
|
||||
const int32_t rc = impl->generic.remove(impl->ecs.raw(), entity, component);
|
||||
if (rc == 0) {
|
||||
// Removing a component moves the entity to another table: drop the
|
||||
// run_systems lane plan (same rule as ApiEntitySet above).
|
||||
impl->plan.reset();
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
auto ApiSystemRegister(EngineWorld* world, const char* name,
|
||||
const uint64_t* components, const uint32_t* access,
|
||||
uint32_t term_count, EngineSystemCallback callback,
|
||||
@@ -1203,6 +1221,7 @@ const EngineApiTable kApiTable = {
|
||||
&ApiComponentDescribe,
|
||||
&ApiComponentFieldCount,
|
||||
&ApiComponentFieldAt,
|
||||
&ApiEntityRemove,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -137,6 +137,32 @@ auto GenericSystems::get(ecs_world_t* world, uint64_t entity,
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto GenericSystems::remove(ecs_world_t* world, uint64_t entity,
|
||||
uint64_t component) noexcept -> int32_t {
|
||||
if (world == nullptr) {
|
||||
return ENGINE_ABI_ERR_NULL_ARG;
|
||||
}
|
||||
if (in_run_) {
|
||||
return ENGINE_ABI_ERR_IN_RUN;
|
||||
}
|
||||
if (entity == 0 || !ecs_is_alive(world, static_cast<ecs_entity_t>(entity))) {
|
||||
return ENGINE_ABI_ERR_BAD_ARG;
|
||||
}
|
||||
const ecs_type_info_t* ti = TypeInfoOf(world, component);
|
||||
if (ti == nullptr) {
|
||||
return ENGINE_ABI_ERR_BAD_ARG;
|
||||
}
|
||||
// No-op when the entity does not carry the component (Remove Component
|
||||
// on an already-removed row must not fail the undo stack).
|
||||
if (!ecs_has_id(world, static_cast<ecs_entity_t>(entity),
|
||||
static_cast<ecs_id_t>(component))) {
|
||||
return 0;
|
||||
}
|
||||
ecs_remove_id(world, static_cast<ecs_entity_t>(entity),
|
||||
static_cast<ecs_id_t>(component));
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto GenericSystems::register_system(ecs_world_t* world, const char* name,
|
||||
const uint64_t* components,
|
||||
const uint32_t* access,
|
||||
@@ -261,18 +287,28 @@ auto GenericSystems::RegisteredSizeAlign(ecs_world_t* world, const char* name,
|
||||
}
|
||||
|
||||
auto GenericSystems::SeedBuiltinRows(const char* name) noexcept -> void {
|
||||
if (name == nullptr || std::strcmp(name, "LocalTransform") != 0) {
|
||||
if (name == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (fields_.find(name) != fields_.end()) {
|
||||
return; // user description wins
|
||||
}
|
||||
try {
|
||||
fields_[name] = std::vector<FieldRow>{
|
||||
{"position", 0, ENGINE_ABI_FIELD_VEC3},
|
||||
{"rotation", 16, ENGINE_ABI_FIELD_QUAT},
|
||||
{"scale", 32, ENGINE_ABI_FIELD_VEC3},
|
||||
};
|
||||
if (std::strcmp(name, "LocalTransform") == 0) {
|
||||
fields_[name] = std::vector<FieldRow>{
|
||||
{"position", 0, ENGINE_ABI_FIELD_VEC3},
|
||||
{"rotation", 16, ENGINE_ABI_FIELD_QUAT},
|
||||
{"scale", 32, ENGINE_ABI_FIELD_VEC3},
|
||||
};
|
||||
} else if (std::strcmp(name, "Engine.Runtime.MeshRenderer") == 0) {
|
||||
// P3-05: MeshRenderer { uint32 mesh_id; float base_color[4]; }
|
||||
// 20 bytes, align 4. mesh_id reads as a u32 scalar; base_color
|
||||
// is a color4 editor (RGBA floats).
|
||||
fields_[name] = std::vector<FieldRow>{
|
||||
{"mesh_id", 0, ENGINE_ABI_FIELD_U32},
|
||||
{"base_color", 4, ENGINE_ABI_FIELD_COLOR4},
|
||||
};
|
||||
}
|
||||
} catch (...) {
|
||||
// OOM: readers report undescribed; scene falls back to $bytes.
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ class GenericSystems {
|
||||
const void* data, uint32_t size) noexcept -> int32_t;
|
||||
auto get(ecs_world_t* world, uint64_t entity, uint64_t component, void* out,
|
||||
uint32_t size) noexcept -> int32_t;
|
||||
// P3-05: remove `component` from `entity` (no-op when absent). Returns
|
||||
// 0 on success or an ENGINE_ABI_ERR_* code.
|
||||
auto remove(ecs_world_t* world, uint64_t entity,
|
||||
uint64_t component) noexcept -> int32_t;
|
||||
auto register_system(ecs_world_t* world, const char* name,
|
||||
const uint64_t* components, const uint32_t* access,
|
||||
uint32_t term_count, EngineSystemCallback callback,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// ecs_render_components.hpp — P1-08: ECS render components.
|
||||
//
|
||||
// Camera, MeshRenderer, DirectionalLight: the native components the
|
||||
// ForwardRenderer::render_world query consumes, and the P1-09 C#
|
||||
// structs (`Engine.Runtime.Camera` etc.) bind to.
|
||||
//
|
||||
// Layouts (spec P1-08; P1-09 size asserts depend on these exactly):
|
||||
// Camera { float fov_y_rad, near_z, far_z; uint32_t is_main; }
|
||||
// 16 bytes, align 4.
|
||||
// MeshRenderer { uint32_t mesh_id; float base_color[4]; }
|
||||
// 20 bytes, align 4.
|
||||
// DirectionalLight { float direction[3]; float intensity; }
|
||||
// 16 bytes, align 4.
|
||||
//
|
||||
// Registration rule (spec §6 gotcha 20): register through the shared
|
||||
// name-registration helper (RegisterRenderComponent, same idempotent
|
||||
// lookup-or-init semantics as GenericSystems::register_component under
|
||||
// the full names "Engine.Runtime.Camera" / "Engine.Runtime.MeshRenderer"
|
||||
// / "Engine.Runtime.DirectionalLight") — never ECS_COMPONENT or
|
||||
// ecs_component_init directly. Any other name, path, or size makes
|
||||
// Flecs abort or creates a second component that C# never sees.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "engine/core/engine_core_export.hpp"
|
||||
|
||||
struct ecs_world_t;
|
||||
|
||||
namespace engine::ecs {
|
||||
|
||||
struct Camera {
|
||||
float fov_y_rad{0.0F};
|
||||
float near_z{0.0F};
|
||||
float far_z{0.0F};
|
||||
std::uint32_t is_main{0};
|
||||
};
|
||||
static_assert(sizeof(Camera) == 16, "Camera must be 16 bytes");
|
||||
static_assert(alignof(Camera) == 4, "Camera must be 4-byte aligned");
|
||||
|
||||
struct MeshRenderer {
|
||||
std::uint32_t mesh_id{0};
|
||||
float base_color[4]{0.0F, 0.0F, 0.0F, 0.0F};
|
||||
};
|
||||
static_assert(sizeof(MeshRenderer) == 20, "MeshRenderer must be 20 bytes");
|
||||
static_assert(alignof(MeshRenderer) == 4, "MeshRenderer must be 4-byte aligned");
|
||||
|
||||
struct DirectionalLight {
|
||||
float direction[3]{0.0F, 0.0F, 0.0F};
|
||||
float intensity{0.0F};
|
||||
};
|
||||
static_assert(sizeof(DirectionalLight) == 16,
|
||||
"DirectionalLight must be 16 bytes");
|
||||
static_assert(alignof(DirectionalLight) == 4,
|
||||
"DirectionalLight must be 4-byte aligned");
|
||||
|
||||
// Idempotent name registration shared by EcsWorld::register_render() and
|
||||
// the ABI bridge (P1-09): looks up `name` with an empty separator (so
|
||||
// "Engine.Runtime.Camera" stays one flat name), returns the existing id
|
||||
// when size/alignment match, registers when absent, and returns 0 on a
|
||||
// layout mismatch (never aborts — Flecs would abort the process on a
|
||||
// size-mismatched ecs_component_init, gotcha 20).
|
||||
ENGINE_CORE_API auto RegisterRenderComponent(ecs_world_t* world,
|
||||
const char* name, std::uint32_t size,
|
||||
std::uint32_t alignment) noexcept
|
||||
-> std::uint64_t;
|
||||
|
||||
} // namespace engine::ecs
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
#include "engine/core/engine_core_export.hpp"
|
||||
#include "engine/core/job_scheduler.hpp"
|
||||
#include "engine/ecs/ecs_render_components.hpp"
|
||||
#include "engine/ecs/transform.hpp"
|
||||
|
||||
struct ecs_world_t;
|
||||
@@ -70,6 +71,14 @@ class ENGINE_CORE_API EcsWorld {
|
||||
// WorldTransform. Returns entities created.
|
||||
auto bulk_create(std::size_t count) noexcept -> std::size_t;
|
||||
|
||||
// Idempotent registration of the P1-08 render components (Camera,
|
||||
// MeshRenderer, DirectionalLight) under their "Engine.Runtime.*"
|
||||
// full names. Same idempotent semantics as GenericSystems::
|
||||
// register_component: registering twice with the same layout returns
|
||||
// the same id (P1-09 C# Ecs.ComponentId<T>() relies on this). Returns
|
||||
// false when the world is not constructed.
|
||||
auto register_render() noexcept -> bool;
|
||||
|
||||
// Component ids (valid after construct()).
|
||||
[[nodiscard]] auto local_id() const noexcept -> std::uint64_t {
|
||||
return local_id_;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// ecs_render_components.cpp — P1-08 registration helper; see header.
|
||||
#include "engine/ecs/ecs_render_components.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#include "flecs.h"
|
||||
|
||||
namespace engine::ecs {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kMaxNameLen = 255;
|
||||
|
||||
auto IsPowerOfTwo(std::uint32_t v) noexcept -> bool {
|
||||
return v != 0U && (v & (v - 1U)) == 0U;
|
||||
}
|
||||
|
||||
auto TypeInfoOf(ecs_world_t* world, std::uint64_t id) noexcept
|
||||
-> const ecs_type_info_t* {
|
||||
if (id == 0 || !ecs_is_alive(world, static_cast<ecs_entity_t>(id))) {
|
||||
return nullptr;
|
||||
}
|
||||
return ecs_get_type_info(world, static_cast<ecs_id_t>(id));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto RegisterRenderComponent(ecs_world_t* world, const char* name,
|
||||
std::uint32_t size, std::uint32_t alignment) noexcept
|
||||
-> std::uint64_t {
|
||||
if (world == nullptr || name == nullptr || name[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
if (std::strlen(name) > kMaxNameLen || size == 0U ||
|
||||
!IsPowerOfTwo(alignment) || alignment > 64U || size % alignment != 0U) {
|
||||
return 0;
|
||||
}
|
||||
// sep = "" keeps "Engine.Runtime.Camera" one flat name (the default '.'
|
||||
// separator would look up a "Camera" child of an "Engine.Runtime"
|
||||
// scope instead).
|
||||
const ecs_entity_t existing =
|
||||
ecs_lookup_path_w_sep(world, 0, name, "", "", false);
|
||||
if (existing != 0) {
|
||||
const ecs_type_info_t* ti = TypeInfoOf(world, existing);
|
||||
if (ti != nullptr && ti->size == static_cast<ecs_size_t>(size) &&
|
||||
ti->alignment == static_cast<ecs_size_t>(alignment)) {
|
||||
return existing;
|
||||
}
|
||||
// Different layout (or a non-component entity with this name). Never
|
||||
// call ecs_component_init here: Flecs aborts the process on a size
|
||||
// mismatch (INVALID_COMPONENT_SIZE), even in release builds.
|
||||
return 0;
|
||||
}
|
||||
ecs_entity_desc_t ed{};
|
||||
ed.name = name;
|
||||
ed.sep = "";
|
||||
ed.root_sep = "";
|
||||
const ecs_entity_t ent = ecs_entity_init(world, &ed);
|
||||
if (ent == 0) {
|
||||
return 0;
|
||||
}
|
||||
ecs_component_desc_t cd{};
|
||||
cd.entity = ent;
|
||||
cd.type.size = static_cast<ecs_size_t>(size);
|
||||
cd.type.alignment = static_cast<ecs_size_t>(alignment);
|
||||
return ecs_component_init(world, &cd);
|
||||
}
|
||||
|
||||
} // namespace engine::ecs
|
||||
@@ -1,6 +1,7 @@
|
||||
// ecs_world.cpp — M1-03 implementation; see header.
|
||||
#include "engine/ecs/ecs_world.hpp"
|
||||
|
||||
#include "engine/ecs/ecs_render_components.hpp"
|
||||
#include "engine/ecs/transform.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -77,6 +78,21 @@ auto EcsWorld::construct() noexcept -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
auto EcsWorld::register_render() noexcept -> bool {
|
||||
if (world_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const std::uint64_t cam = RegisterRenderComponent(
|
||||
world_, "Engine.Runtime.Camera", sizeof(Camera), alignof(Camera));
|
||||
const std::uint64_t mesh = RegisterRenderComponent(
|
||||
world_, "Engine.Runtime.MeshRenderer", sizeof(MeshRenderer),
|
||||
alignof(MeshRenderer));
|
||||
const std::uint64_t light = RegisterRenderComponent(
|
||||
world_, "Engine.Runtime.DirectionalLight", sizeof(DirectionalLight),
|
||||
alignof(DirectionalLight));
|
||||
return cam != 0 && mesh != 0 && light != 0;
|
||||
}
|
||||
|
||||
auto EcsWorld::destroy() noexcept -> void {
|
||||
if (world_ == nullptr) {
|
||||
return;
|
||||
|
||||
@@ -388,6 +388,16 @@ engine_apply_base_flags(engine_field_metadata)
|
||||
add_test(NAME engine_field_metadata COMMAND engine_field_metadata)
|
||||
set_tests_properties(engine_field_metadata PROPERTIES TIMEOUT 120)
|
||||
|
||||
# ABI 4.5 (P3-05): panel-edit round-trip (entity_get/set float lane +
|
||||
# undo replay + entity_remove). Links the ecs render components for
|
||||
# MeshRenderer (same include the ecs_world tests use).
|
||||
add_executable(engine_inspector_edits test_inspector_edits.cpp)
|
||||
target_link_libraries(engine_inspector_edits PRIVATE engine_core)
|
||||
target_include_directories(engine_inspector_edits PRIVATE "${CMAKE_SOURCE_DIR}/native/ecs/include")
|
||||
engine_apply_base_flags(engine_inspector_edits)
|
||||
add_test(NAME engine_inspector_edits COMMAND engine_inspector_edits)
|
||||
set_tests_properties(engine_inspector_edits PROPERTIES TIMEOUT 120)
|
||||
|
||||
# ABI 4.2: generic component/system tail (Illusion Layer native side).
|
||||
add_executable(engine_generic_systems test_generic_systems.cpp)
|
||||
target_link_libraries(engine_generic_systems PRIVATE engine_core)
|
||||
|
||||
@@ -215,7 +215,7 @@ auto main() -> int {
|
||||
failures += check(offsetof(EcsChunkView, world) == 8, "abi: EcsChunkView.world @ 8");
|
||||
failures += check(offsetof(EcsChunkView, entities) == 16, "abi: EcsChunkView.entities @ 16");
|
||||
failures += check(offsetof(EcsChunkView, user_data) == 40, "abi: EcsChunkView.user_data @ 40");
|
||||
failures += check(sizeof(EngineApiTable) == 424, "abi: EngineApiTable 424B (ABI 4.4 stacked tails)");
|
||||
failures += check(sizeof(EngineApiTable) == 432, "abi: EngineApiTable 432B (ABI 4.5 stacked tails)");
|
||||
failures += check(offsetof(EngineApiTable, run_systems) == 64,
|
||||
"abi: run_systems @ 64 (M1/M2 head frozen)");
|
||||
failures += check(offsetof(EngineApiTable, renderpass_create) == 72,
|
||||
@@ -249,10 +249,13 @@ auto main() -> int {
|
||||
failures += check(api->component_describe != nullptr, "abi: component_describe set");
|
||||
failures += check(api->component_field_count != nullptr, "abi: component_field_count set");
|
||||
failures += check(api->component_field_at != nullptr, "abi: component_field_at set");
|
||||
failures += check(offsetof(EngineApiTable, component_describe) == 360,
|
||||
"abi: component_describe @ 360 (ABI 4.4 field tail)");
|
||||
failures += check(offsetof(EngineApiTable, component_field_at) == 376,
|
||||
"abi: component_field_at @ 376 (ABI 4.4 field tail end)");
|
||||
failures += check(offsetof(EngineApiTable, component_describe) == 400,
|
||||
"abi: component_describe @ 400 (ABI 4.4 stacked tail)");
|
||||
failures += check(offsetof(EngineApiTable, component_field_at) == 416,
|
||||
"abi: component_field_at @ 416 (ABI 4.4 stacked tail end)");
|
||||
failures += check(api->entity_remove != nullptr, "abi: entity_remove set");
|
||||
failures += check(offsetof(EngineApiTable, entity_remove) == 424,
|
||||
"abi: entity_remove @ 424 (ABI 4.5 inspector tail)");
|
||||
failures += check(sizeof(FrameDispatch) == 4, "abi: FrameDispatch 4B");
|
||||
failures += check(offsetof(FrameDispatch, dt) == 0, "abi: FrameDispatch.dt @ 0");
|
||||
failures += check(sizeof(EngineVelocity) == 16, "abi: EngineVelocity 16B");
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// test_inspector_edits.cpp — P3-05 acceptance: float edit via the
|
||||
// panel-edit path (entity_get/entity_set over MeshRenderer.base_color)
|
||||
// plus undo-restore and entity_remove round-trip. Mirrors what
|
||||
// SetFieldCommand does in managed code: read the whole component image,
|
||||
// patch one lane, write back, undo by replaying the before-image.
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "engine/bridge/engine_abi.h"
|
||||
#include "engine/ecs/ecs_render_components.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
auto check(bool ok, const char* name) -> int {
|
||||
std::printf("[%s] %s\n", ok ? "PASS" : "FAIL", name);
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto main() -> int {
|
||||
int failures = 0;
|
||||
const EngineApiTable* api = engine_get_api_table();
|
||||
failures += check(api != nullptr, "inspector: api table");
|
||||
if (api == nullptr) {
|
||||
return 1;
|
||||
}
|
||||
failures += check(api->entity_remove != nullptr, "inspector: entity_remove set");
|
||||
if (api->entity_remove == nullptr) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
EngineWorld* world = api->world_create();
|
||||
failures += check(world != nullptr, "inspector: world_create");
|
||||
if (world == nullptr) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const uint64_t mesh = api->component_register(
|
||||
world, "Engine.Runtime.MeshRenderer", sizeof(engine::ecs::MeshRenderer),
|
||||
alignof(engine::ecs::MeshRenderer));
|
||||
failures += check(mesh != 0, "inspector: MeshRenderer registered");
|
||||
const uint64_t ent = api->entity_create(world);
|
||||
failures += check(ent != 0, "inspector: entity created");
|
||||
|
||||
// Panel edit: write a zeroed image (Add Component), then patch
|
||||
// base_color[0] to 2.5 and read it back.
|
||||
engine::ecs::MeshRenderer zero{};
|
||||
failures +=
|
||||
check(api->entity_set(world, ent, mesh, &zero, sizeof(zero)) == 0,
|
||||
"inspector: add MeshRenderer");
|
||||
engine::ecs::MeshRenderer before{};
|
||||
failures +=
|
||||
check(api->entity_get(world, ent, mesh, &before, sizeof(before)) == 0,
|
||||
"inspector: read before-image");
|
||||
engine::ecs::MeshRenderer after = before;
|
||||
after.base_color[0] = 2.5F;
|
||||
failures +=
|
||||
check(api->entity_set(world, ent, mesh, &after, sizeof(after)) == 0,
|
||||
"inspector: panel edit base_color[0] = 2.5");
|
||||
engine::ecs::MeshRenderer got{};
|
||||
failures +=
|
||||
check(api->entity_get(world, ent, mesh, &got, sizeof(got)) == 0 &&
|
||||
got.base_color[0] == 2.5F,
|
||||
"inspector: value reads back 2.5");
|
||||
|
||||
// Undo: replay the before-image, assert the old value returns.
|
||||
failures +=
|
||||
check(api->entity_set(world, ent, mesh, &before, sizeof(before)) == 0,
|
||||
"inspector: undo replays before-image");
|
||||
failures +=
|
||||
check(api->entity_get(world, ent, mesh, &got, sizeof(got)) == 0 &&
|
||||
got.base_color[0] == 0.0F,
|
||||
"inspector: undo restores old value");
|
||||
|
||||
// Remove Component + undo (re-add).
|
||||
failures += check(api->entity_remove(world, ent, mesh) == 0,
|
||||
"inspector: entity_remove ok");
|
||||
failures += check(api->entity_get(world, ent, mesh, &got, sizeof(got)) != 0,
|
||||
"inspector: removed row reads back missing");
|
||||
failures +=
|
||||
check(api->entity_set(world, ent, mesh, &after, sizeof(after)) == 0,
|
||||
"inspector: re-add restores row");
|
||||
failures +=
|
||||
check(api->entity_get(world, ent, mesh, &got, sizeof(got)) == 0 &&
|
||||
got.base_color[0] == 2.5F,
|
||||
"inspector: re-added value intact");
|
||||
|
||||
// Double remove is a no-op success (undo-stack safe).
|
||||
failures += check(api->entity_remove(world, ent, mesh) == 0,
|
||||
"inspector: remove present ok");
|
||||
failures += check(api->entity_remove(world, ent, mesh) == 0,
|
||||
"inspector: remove absent is no-op");
|
||||
|
||||
api->world_destroy(world);
|
||||
std::printf(failures == 0 ? "inspector_edits: all checks passed\n"
|
||||
: "inspector_edits: %d FAILED\n",
|
||||
failures);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -397,6 +397,7 @@ internal static class Program
|
||||
"EngineEditorSnapshotRestoreFn", "EngineEditorSnapshotDiscardFn",
|
||||
"EngineComponentRegisterFn", "EngineEntityCreateFn",
|
||||
"EngineEntitySetFn", "EngineEntityGetFn",
|
||||
"EngineEntityRemoveFn",
|
||||
"EngineSystemRegisterFn", "EngineSystemsRunFn",
|
||||
"EngineComponentDescribeFn", "EngineComponentFieldCountFn",
|
||||
"EngineComponentFieldAtFn",
|
||||
|
||||
Reference in new issue
Block a user