[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:
hermes-agent committed 2026-10-31 16:40:04 -09:00
1 parent cb6a1c760d
commit 31c673bb00
25 files changed
+2305 -17

No files matched your search

+8
View File
@@ -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.
+3 -2
View File
@@ -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>
+227
View File
@@ -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;
}
}
+69
View File
@@ -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)
+279
View File
@@ -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.");
}
}
}
+196
View File
@@ -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

+763
View File
@@ -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,
};
}
+43
View File
@@ -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
+3 -2
View File
@@ -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)");
+122
View File
@@ -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;
+20 -1
View File
@@ -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
+42 -6
View File
@@ -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.
}
+4
View File
@@ -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_;
+70
View File
@@ -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
+16
View File
@@ -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;
+10
View File
@@ -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)
+8 -5
View File
@@ -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");
+101
View File
@@ -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;
}
+1
View File
@@ -397,6 +397,7 @@ internal static class Program
"EngineEditorSnapshotRestoreFn", "EngineEditorSnapshotDiscardFn",
"EngineComponentRegisterFn", "EngineEntityCreateFn",
"EngineEntitySetFn", "EngineEntityGetFn",
"EngineEntityRemoveFn",
"EngineSystemRegisterFn", "EngineSystemsRunFn",
"EngineComponentDescribeFn", "EngineComponentFieldCountFn",
"EngineComponentFieldAtFn",