3-tier split (step 1) + engine-bridge v3 scaffold + HmegDirectStateProvider
Lays down the Generic / HmEG-aware / App-specific separation that lets us
target other HmEG-hosting WPF applications later, and lands the v3 engine
state provider on top of it.
Architecture rule (CLAUDE.md §8.1, new): every module belongs to exactly one
of three tiers — Generic / HmEG-aware / App-specific (e.g. EgBim). Dependency
direction is strictly App-specific → HmEG-aware → Generic. Generic must not
reference HmEG.dll; HmEG-aware must not reference any per-app assembly.
This commit is the first incremental step:
+ src/Recordingtest.Bridge.Abstractions/ (Generic, new csproj)
IEngineStateProvider, CameraSnapshot, SceneSnapshot,
NullEngineStateProvider — extracted from EgPlugin so the generic core
owns the contract. Zero SUT references.
+ src/Hmeg/Recordingtest.Hmeg.Bridge/ (HmEG-aware, new csproj)
HmegDirectStateProvider — IEngineStateProvider implemented against
the HmEG public API (Space, HmEGViewport, ISelectable, ModelBase.Uid).
Decoupled from any specific host app via Func<Space?>/Func<HmEGViewport?>
lambdas; the EgBim plugin host supplies them. Reusable for any other
WPF application that hosts HmEG.
Selection traversal walks Space.Children and collects ModelBase.Uid
for nodes whose ISelectable.IsSelected is true. We deliberately type
nodes as object + late-bound Uid lookup to avoid pulling MemoryPack
into the dependency graph.
+ tests/Hmeg/Recordingtest.Hmeg.Bridge.Tests/
5 unit tests covering null lambdas, throwing lambdas, document path
provider, and constructor null arg validation.
+ src/Recordingtest.EgPlugin/ChainedEngineStateProvider.cs
Wraps two providers; falls back from Hmeg.Direct to the existing
Reflection accessor when the primary returns empty/default. Lets us
land the new wire-up before the EgBim adapter Q1~Q7 lookups are
filled in. 7 new tests.
+ src/Recordingtest.EgPlugin/IAppManagerAccessor.cs
Reflection accessor abstraction (preserved as the v3 fallback). Looks
up Editor.AppManager.AppManager via well-known Instance/Current
property names. Unit-testable through a fake.
~ src/Recordingtest.EgPlugin/IEngineStateProvider.cs
Type definitions removed (now in Bridge.Abstractions); only the
reflection-based provider remains. ReflectionEngineStateProvider
delegates everything to IAppManagerAccessor.
~ src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
BuildProvider() picks ChainedEngineStateProvider(Hmeg.Direct,
Reflection). The HmEG-aware lambdas are stubs (return null) until the
next step wires the EgBim adapter; the chain falls through to the
reflection path so behaviour matches v2 for now.
+ docs/contracts/engine-bridge-v3.md — Sprint Contract
+ docs/contracts/generic-sut-split.md — Sprint Contract for the
remaining mass-rename / folder move (step 2, deferred).
+ docs/hmeg-api-survey.md — Read-only survey of the HmEG
public API (Space, ModelBase, HmEGViewport, IHmCamera, IPlugin) used
to design HmegDirectStateProvider. Open Q1~Q7 listed.
Tests: 94 → 115 passing, 0 failing. The new HmEG-aware test project copies
HmEG.dll next to its output (Private=true) since it runs out-of-process.
Step 2 (deferred to next session): mass-rename
src/Recordingtest.EgPlugin → src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost + .Adapter
src/Recordingtest.EngineBridge → src/Hmeg/Recordingtest.Hmeg.Catalog
src/Recordingtest.EngineBridge.Client → split (Generic + Hmeg)
plus Recordingtest.Architecture.Tests to enforce the §8.1 dependency rule.
Ref: #10 follow-up, #14 follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
212
src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs
Normal file
212
src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using HmEG;
|
||||
using Recordingtest.Bridge;
|
||||
|
||||
namespace Recordingtest.Hmeg.Bridge;
|
||||
|
||||
/// <summary>
|
||||
/// HmEG-aware <see cref="IEngineStateProvider"/> backed by direct calls into
|
||||
/// the HmEG public API. Reusable across any WPF application that hosts HmEG.
|
||||
///
|
||||
/// The provider is decoupled from the *host* application via two lambdas
|
||||
/// supplied at construction:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>spaceProvider</c> — returns the active <see cref="Space"/> tree (root of the scene/document).</item>
|
||||
/// <item><c>viewportProvider</c> — returns the active <see cref="HmEGViewport"/> (camera + renderables).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// App-specific glue (e.g. <c>Recordingtest.Sut.EgBim.*</c>) is responsible for
|
||||
/// resolving those handles from its own <c>AppManager</c> and passing them in.
|
||||
/// This keeps the bridge usable for *any* HmEG-hosting WPF app without
|
||||
/// recompilation.
|
||||
///
|
||||
/// All accessors are best-effort: any exception is swallowed and the method
|
||||
/// returns the same safe default a <see cref="NullEngineStateProvider"/> would.
|
||||
/// The plugin runs in-process inside the SUT and must never throw.
|
||||
/// </summary>
|
||||
public sealed class HmegDirectStateProvider : IEngineStateProvider
|
||||
{
|
||||
private readonly Func<Space?> _spaceProvider;
|
||||
private readonly Func<HmEGViewport?> _viewportProvider;
|
||||
private readonly Func<string?>? _documentPathProvider;
|
||||
|
||||
public HmegDirectStateProvider(
|
||||
Func<Space?> spaceProvider,
|
||||
Func<HmEGViewport?> viewportProvider,
|
||||
Func<string?>? documentPathProvider = null)
|
||||
{
|
||||
_spaceProvider = spaceProvider ?? throw new ArgumentNullException(nameof(spaceProvider));
|
||||
_viewportProvider = viewportProvider ?? throw new ArgumentNullException(nameof(viewportProvider));
|
||||
_documentPathProvider = documentPathProvider;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetSelectedIds()
|
||||
{
|
||||
try
|
||||
{
|
||||
var space = _spaceProvider();
|
||||
if (space is null) return Array.Empty<string>();
|
||||
var ids = new List<string>();
|
||||
CollectSelectedRecursive(space, ids);
|
||||
return ids;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the Space tree and collects the <c>Uid</c> of every node whose
|
||||
/// <see cref="HmEG.ISelectable.IsSelected"/> is true. HmEG does not
|
||||
/// expose a centralized selection list in core; this is the canonical
|
||||
/// traversal pattern.
|
||||
///
|
||||
/// We deliberately type the node parameter as <see cref="object"/> rather
|
||||
/// than <c>HmEG.ModelBase</c> so this assembly does not have to reference
|
||||
/// MemoryPack.Core (a serialization dependency that <c>ModelBase</c>
|
||||
/// transitively pulls in via its attributes). The runtime shape we rely
|
||||
/// on is just <c>ISelectable</c> + <c>Uid</c> + <c>Children</c>, all of
|
||||
/// which are read by reflection-free pattern matching.
|
||||
/// </summary>
|
||||
private static void CollectSelectedRecursive(object node, List<string> ids)
|
||||
{
|
||||
if (node is HmEG.ISelectable sel && sel.IsSelected)
|
||||
{
|
||||
// Read Uid via late-bound property access — avoids the ModelBase
|
||||
// type reference and survives any future field-vs-property change.
|
||||
var uid = node.GetType().GetProperty("Uid")?.GetValue(node);
|
||||
if (uid is not null) ids.Add(uid.ToString() ?? string.Empty);
|
||||
}
|
||||
if (node is Space space)
|
||||
{
|
||||
foreach (var child in space.Children)
|
||||
{
|
||||
if (child is not null) CollectSelectedRecursive(child, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public CameraSnapshot GetCamera()
|
||||
{
|
||||
try
|
||||
{
|
||||
var vp = _viewportProvider();
|
||||
var core = vp?.CameraCore;
|
||||
if (core is null) return Default.GetCamera();
|
||||
|
||||
// CameraCore is the abstract base; common shapes (Position, LookDirection,
|
||||
// UpDirection) come from IHmCamera-like contracts. We use late-binding via
|
||||
// reflection on the concrete CameraCore subclass to stay tolerant of
|
||||
// PerspectiveCamera vs OrthographicCamera vs MatrixCamera variants.
|
||||
//
|
||||
// This is the *only* reflection in the HmEG-aware tier and it sits behind
|
||||
// a try/catch — failure mode is a default snapshot.
|
||||
var t = core.GetType();
|
||||
double[] eye = ReadVec3(core, t, new[] { "Position", "Eye" });
|
||||
double[] look = ReadVec3(core, t, new[] { "LookDirection", "Direction" });
|
||||
double[] up = ReadVec3(core, t, new[] { "UpDirection", "Up" });
|
||||
double fov = ReadDouble(core, t, new[] { "FieldOfView", "Fov", "FOV" }, fallback: 45.0);
|
||||
|
||||
// Target = Eye + LookDirection (HmEG stores look as a direction vector,
|
||||
// not as an explicit target point).
|
||||
var target = new double[]
|
||||
{
|
||||
eye[0] + look[0],
|
||||
eye[1] + look[1],
|
||||
eye[2] + look[2],
|
||||
};
|
||||
|
||||
return new CameraSnapshot(eye, target, up, fov);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Default.GetCamera();
|
||||
}
|
||||
}
|
||||
|
||||
public SceneSnapshot GetScene()
|
||||
{
|
||||
try
|
||||
{
|
||||
var space = _spaceProvider();
|
||||
int count = space?.ItemsCount ?? 0;
|
||||
string? path = null;
|
||||
try { path = _documentPathProvider?.Invoke(); } catch { /* best-effort */ }
|
||||
return new SceneSnapshot(count, path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new SceneSnapshot(0, null);
|
||||
}
|
||||
}
|
||||
|
||||
public bool GetRenderComplete()
|
||||
{
|
||||
// HmEG core does not expose a stable "frame complete" signal we can
|
||||
// poll without subscribing to a render-host event. Treat as always
|
||||
// ready until a Hmeg.Bridge follow-up wires the event.
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly NullEngineStateProvider Default = new();
|
||||
|
||||
private static double[] ReadVec3(object owner, Type t, string[] names)
|
||||
{
|
||||
foreach (var n in names)
|
||||
{
|
||||
object? v = null;
|
||||
try
|
||||
{
|
||||
var p = t.GetProperty(n);
|
||||
if (p is not null) v = p.GetValue(owner);
|
||||
if (v is null)
|
||||
{
|
||||
var f = t.GetField(n);
|
||||
if (f is not null) v = f.GetValue(owner);
|
||||
}
|
||||
}
|
||||
catch { /* try next */ }
|
||||
if (v is null) continue;
|
||||
|
||||
// Common shapes: HmVector3D / Vector3 / double[] / float[]
|
||||
if (v is double[] da && da.Length >= 3) return new[] { da[0], da[1], da[2] };
|
||||
if (v is float[] fa && fa.Length >= 3) return new[] { (double)fa[0], fa[1], fa[2] };
|
||||
|
||||
try
|
||||
{
|
||||
double Read(string memberName)
|
||||
{
|
||||
var vt = v.GetType();
|
||||
var pp = vt.GetProperty(memberName);
|
||||
if (pp is not null) return Convert.ToDouble(pp.GetValue(v));
|
||||
var ff = vt.GetField(memberName);
|
||||
if (ff is not null) return Convert.ToDouble(ff.GetValue(v));
|
||||
return 0;
|
||||
}
|
||||
return new[] { Read("X"), Read("Y"), Read("Z") };
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to next candidate
|
||||
}
|
||||
}
|
||||
return new double[] { 0, 0, 0 };
|
||||
}
|
||||
|
||||
private static double ReadDouble(object owner, Type t, string[] names, double fallback)
|
||||
{
|
||||
foreach (var n in names)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = t.GetProperty(n);
|
||||
if (p is not null) return Convert.ToDouble(p.GetValue(owner));
|
||||
var f = t.GetField(n);
|
||||
if (f is not null) return Convert.ToDouble(f.GetValue(owner));
|
||||
}
|
||||
catch { /* try next */ }
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RootNamespace>Recordingtest.Hmeg.Bridge</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- HmEG-aware tier may reference HmEG.dll only. App-specific assemblies
|
||||
(e.g. Editor03.PluginInterface.dll) are forbidden here — they live
|
||||
in src/Sut/<App>/. -->
|
||||
<Reference Include="HmEG">
|
||||
<HintPath>..\..\..\EG-BIM Modeler\HmEG.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user