BREAKING: 3-tier split step 2 + engine-bridge v3 EgBim lambdas wired
Completes the Generic / HmEG-aware / App-specific separation started in
f6b6e74. The legacy EgPlugin / EngineBridge / EngineBridge.Client /
EngineBridge.Probe modules are moved into their proper tiers, namespaces
and csproj/sln entries are renamed, and the HmegDirectStateProvider
lambdas are finally populated with real handles from the EgBim plugin
host. A new Recordingtest.Architecture.Tests project enforces the tier
rule at build time.
Moves (git mv + csproj/RootNamespace/AssemblyName rename + sln):
src/Recordingtest.EgPlugin
-> src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost
src/Recordingtest.EngineBridge
-> src/Hmeg/Recordingtest.Hmeg.Catalog
src/Recordingtest.EngineBridge.Client
-> src/Hmeg/Recordingtest.Hmeg.Bridge.Client
src/Recordingtest.EngineBridge.Probe
-> src/Hmeg/Recordingtest.Hmeg.Catalog.Probe
tests/Recordingtest.EgPlugin.Tests
-> tests/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost.Tests
tests/Recordingtest.EngineBridge.Tests
-> tests/Hmeg/Recordingtest.Hmeg.Catalog.Tests
tests/Recordingtest.EngineBridge.IntegrationTests
-> tests/Hmeg/Recordingtest.Hmeg.Catalog.IntegrationTests
Namespace rename applied across all .cs files and csproj RootNamespace:
Recordingtest.EgPlugin -> Recordingtest.Sut.EgBim.PluginHost
Recordingtest.EngineBridge -> Recordingtest.Hmeg.Catalog
Recordingtest.EngineBridge.Client -> Recordingtest.Hmeg.Bridge.Client
Recordingtest.EngineBridge.Probe -> Recordingtest.Hmeg.Catalog.Probe
New: tests/Recordingtest.Architecture.Tests/
DependencyGraphTests walks Assembly.GetReferencedAssemblies() for each
tier and fails if a forbidden reference leaks in:
- Generic modules must not reference HmEG or any app-specific DLL
- HmEG-aware modules must not reference app-specific DLLs
- Recordingtest.Hmeg.Bridge must reference HmEG (positive check)
11 tests, all passing. Prevents future drift from CLAUDE.md §8.1.
Engine-bridge v3 wire-up (HmEgBridgePlugin.BuildProvider):
Previously the HmegDirectStateProvider lambdas returned null and the
chain fell through to reflection. They now call directly into the
EditorPlugin base class that HmEgBridgePlugin inherits:
spaceProvider = () => RootSpace
// AppManager.ViewportManager.RootSpace
viewportProvider = () => View
// EGViewport : Control, HmEGViewport
documentPathProvider = () => AppManager?.FileManager?.CurrentFile
Every lambda is wrapped in try/catch so plugin construction still
cannot throw back into the SUT. Editor02.HmEGAppManager.dll added as
a reference on Recordingtest.Sut.EgBim.PluginHost.csproj — app-
specific tier, which is allowed by the architecture tests.
Entry points were confirmed from read-only review of the SUT sources at
D:\GiteaAll\EG-BIM_Modeler\EditorPluginInterface\EditorPlugin.cs
D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\HmEGAppManager.cs
D:\GiteaAll\EG-BIM_Modeler\HmEGApplicationManagementLibrary\SubManager\FileManager.cs
closing out Q1/Q2/Q6/Q7 from docs/hmeg-api-survey.md.
Tests: 115 -> 126 (+11 Architecture), 0 failures.
Next step: live verification of /scene /camera /selection with a real
SUT session; any discrepancy in HmegDirectStateProvider reflection will
be tightened after observing real HmEG camera field names.
Ref: #10 follow-up, #14 follow-up, docs/contracts/generic-sut-split.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts an HttpListener that delegates path routing to <see cref="StateRouter"/>.
|
||||
/// Designed to swallow listener errors so it never destabilises the SUT.
|
||||
/// </summary>
|
||||
public sealed class BridgeHttpServer : IDisposable
|
||||
{
|
||||
private readonly HttpListener _listener;
|
||||
private readonly StateRouter _router;
|
||||
private readonly Thread _thread;
|
||||
private volatile bool _stopping;
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public BridgeHttpServer(StateRouter router, int port)
|
||||
{
|
||||
_router = router;
|
||||
Port = port;
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add($"http://localhost:{port}/");
|
||||
_thread = new Thread(Loop) { IsBackground = true, Name = "RecordingtestBridge" };
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try { _listener.Start(); }
|
||||
catch { return; }
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
private void Loop()
|
||||
{
|
||||
while (!_stopping && _listener.IsListening)
|
||||
{
|
||||
HttpListenerContext ctx;
|
||||
try { ctx = _listener.GetContext(); }
|
||||
catch { return; }
|
||||
try
|
||||
{
|
||||
var path = ctx.Request.Url?.AbsolutePath ?? "/";
|
||||
var (status, body) = _router.Route(path);
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
ctx.Response.StatusCode = (int)status;
|
||||
ctx.Response.ContentType = "application/json";
|
||||
ctx.Response.ContentLength64 = bytes.LongLength;
|
||||
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
|
||||
ctx.Response.OutputStream.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { ctx.Response.Abort(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stopping = true;
|
||||
try { if (_listener.IsListening) _listener.Stop(); } catch { }
|
||||
try { _listener.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Recordingtest.Bridge;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
/// <summary>
|
||||
/// Tries the primary provider first; if it returns the empty/default value
|
||||
/// (e.g. <c>HmegDirectStateProvider</c> with unresolved lambdas) the fallback
|
||||
/// provider is consulted. This lets us land the HmEG-aware path now while
|
||||
/// still benefiting from the reflection fallback when EgBim AppManager
|
||||
/// entry-point wiring is incomplete.
|
||||
///
|
||||
/// "Empty/default" is detected per signal:
|
||||
/// - SelectedIds: empty list → try fallback
|
||||
/// - Camera : Up == (0,0,1) AND Eye == (0,0,0) → try fallback
|
||||
/// - Scene : ObjectCount == 0 AND DocumentPath == null → try fallback
|
||||
/// - RenderComplete: primary always wins (boolean)
|
||||
/// </summary>
|
||||
public sealed class ChainedEngineStateProvider : IEngineStateProvider
|
||||
{
|
||||
private readonly IEngineStateProvider _primary;
|
||||
private readonly IEngineStateProvider _fallback;
|
||||
|
||||
public ChainedEngineStateProvider(IEngineStateProvider primary, IEngineStateProvider fallback)
|
||||
{
|
||||
_primary = primary;
|
||||
_fallback = fallback;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetSelectedIds()
|
||||
{
|
||||
var p = _primary.GetSelectedIds();
|
||||
return p.Count > 0 ? p : _fallback.GetSelectedIds();
|
||||
}
|
||||
|
||||
public CameraSnapshot GetCamera()
|
||||
{
|
||||
var p = _primary.GetCamera();
|
||||
return IsDefault(p) ? _fallback.GetCamera() : p;
|
||||
}
|
||||
|
||||
public SceneSnapshot GetScene()
|
||||
{
|
||||
var p = _primary.GetScene();
|
||||
return p.ObjectCount == 0 && p.DocumentPath is null
|
||||
? _fallback.GetScene()
|
||||
: p;
|
||||
}
|
||||
|
||||
public bool GetRenderComplete() => _primary.GetRenderComplete();
|
||||
|
||||
private static bool IsDefault(CameraSnapshot c) =>
|
||||
c.Eye is { Length: >= 3 } e && e[0] == 0 && e[1] == 0 && e[2] == 0 &&
|
||||
c.Target is { Length: >= 3 } t && t[0] == 0 && t[1] == 0 && t[2] == 0;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Editor.PluginInterface;
|
||||
using HmEG;
|
||||
using Recordingtest.Bridge;
|
||||
using Recordingtest.Hmeg.Bridge;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
/// <summary>
|
||||
/// MEF/PluginLoader-discovered plugin. Inherits the SUT's <c>EditorPlugin</c>
|
||||
/// abstract base (which itself implements <c>HmEG.IPlugin</c>), and on construction
|
||||
/// boots a localhost HTTP bridge that exposes HmEG state to recordingtest.
|
||||
/// </summary>
|
||||
public sealed class HmEgBridgePlugin : EditorPlugin, IDisposable
|
||||
{
|
||||
private BridgeHttpServer? _server;
|
||||
|
||||
public HmEgBridgePlugin()
|
||||
{
|
||||
StartBridge();
|
||||
}
|
||||
|
||||
public override string Name => "Recordingtest.Sut.EgBim.PluginHost";
|
||||
public override string Description => "recordingtest engine-bridge v3 (HTTP sidecar)";
|
||||
|
||||
protected override void Initialize()
|
||||
{
|
||||
// Construction already started the bridge; Initialize is a no-op safeguard.
|
||||
}
|
||||
|
||||
private void StartBridge()
|
||||
{
|
||||
try
|
||||
{
|
||||
var port = PortResolver.Resolve();
|
||||
var provider = BuildProvider();
|
||||
var router = new StateRouter(provider, port);
|
||||
_server = new BridgeHttpServer(router, port);
|
||||
_server.Start();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// never throw out of plugin construction; SUT must remain stable.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Choose the best available <see cref="IEngineStateProvider"/>:
|
||||
///
|
||||
/// 1. <c>HmegDirectStateProvider</c> — HmEG-aware tier, calls into HmEG
|
||||
/// public API. Reusable across any HmEG-hosting WPF app. Active
|
||||
/// space/viewport handles are resolved via lambdas; the EgBim
|
||||
/// app-specific entry-point lookup lives in this method only.
|
||||
/// 2. <c>ReflectionEngineStateProvider</c> — fallback that uses
|
||||
/// <c>IAppManagerAccessor</c> reflection on Editor.AppManager.AppManager.
|
||||
/// Used when the direct provider can't resolve any handle.
|
||||
///
|
||||
/// Both providers are wrapped to never throw; the SUT must remain stable.
|
||||
/// </summary>
|
||||
private IEngineStateProvider BuildProvider()
|
||||
{
|
||||
// EG-BIM Modeler entry points (resolved via EditorPlugin base):
|
||||
// RootSpace = AppManager.ViewportManager.RootSpace
|
||||
// View (EGViewport : HmEGViewport) — plugin-injected active viewport
|
||||
// AppManager.FileManager.CurrentFile — document path on disk
|
||||
//
|
||||
// Each lambda is wrapped in try/catch so plugin construction never
|
||||
// throws even if the host state is in flight at boot time.
|
||||
Func<Space?> spaceProvider = () =>
|
||||
{
|
||||
try { return RootSpace; }
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
Func<HmEGViewport?> viewportProvider = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// EGViewport implements HmEGViewport; the base class exposes it
|
||||
// as EGViewport on the Obsolete View property. We catch and
|
||||
// return null to survive the obsolete warning at runtime.
|
||||
#pragma warning disable CS0618 // Obsolete API on EditorPlugin.View
|
||||
return View;
|
||||
#pragma warning restore CS0618
|
||||
}
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
Func<string?> documentPathProvider = () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = AppManager?.FileManager?.CurrentFile;
|
||||
return string.IsNullOrEmpty(path) ? null : path;
|
||||
}
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider);
|
||||
var fallback = new ReflectionEngineStateProvider(this);
|
||||
return new ChainedEngineStateProvider(direct, fallback);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _server?.Dispose(); } catch { }
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
/// <summary>
|
||||
/// engine-bridge v3 — abstraction over the SUT-side AppManager singleton.
|
||||
/// Lets <see cref="ReflectionEngineStateProvider"/> be unit-tested with a
|
||||
/// fake instead of needing the real HmEG runtime in CI.
|
||||
/// </summary>
|
||||
public interface IAppManagerAccessor
|
||||
{
|
||||
/// <summary>The AppManager root, or null if not yet discovered.</summary>
|
||||
object? GetAppManager();
|
||||
|
||||
/// <summary>The currently active document, or null.</summary>
|
||||
object? GetActiveDocument();
|
||||
|
||||
/// <summary>The currently active viewport (camera host), or null.</summary>
|
||||
object? GetActiveViewport();
|
||||
|
||||
/// <summary>Selection IDs as strings. Empty when no selection or not discoverable.</summary>
|
||||
IReadOnlyList<string> GetSelectedIds();
|
||||
|
||||
/// <summary>Object count in the active document, or 0.</summary>
|
||||
int GetObjectCount();
|
||||
|
||||
/// <summary>Active document path on disk, or null when unsaved.</summary>
|
||||
string? GetDocumentPath();
|
||||
|
||||
/// <summary>
|
||||
/// Camera tuple (eye, target, up, fov). Returns null when no viewport.
|
||||
/// </summary>
|
||||
(double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default in-process reflection accessor. Walks well-known HmEG type and
|
||||
/// member names against a runtime-resolved AppManager handle. All accessors
|
||||
/// are best-effort: any failure (missing type, wrong shape, exception) is
|
||||
/// swallowed and the method returns the safe-default value. The plugin must
|
||||
/// never throw out of these calls because it runs inside the SUT process.
|
||||
///
|
||||
/// Type/member names are intentionally lookup-by-string so the v3 reflection
|
||||
/// shape can be tightened against live SUT inspection without recompilation
|
||||
/// of the plugin's public surface.
|
||||
/// </summary>
|
||||
public sealed class ReflectionAppManagerAccessor : IAppManagerAccessor
|
||||
{
|
||||
private readonly object? _seedRoot;
|
||||
|
||||
/// <param name="seedRoot">
|
||||
/// Any object that can act as the entry point to the AppManager graph.
|
||||
/// In production this is the plugin instance itself; the accessor walks
|
||||
/// out to the AppManager singleton via reflection on loaded assemblies.
|
||||
/// </param>
|
||||
public ReflectionAppManagerAccessor(object? seedRoot)
|
||||
{
|
||||
_seedRoot = seedRoot;
|
||||
}
|
||||
|
||||
private object? _cachedAppManager;
|
||||
private bool _appManagerLookupAttempted;
|
||||
|
||||
public object? GetAppManager()
|
||||
{
|
||||
if (_cachedAppManager is not null) return _cachedAppManager;
|
||||
if (_appManagerLookupAttempted) return null;
|
||||
_appManagerLookupAttempted = true;
|
||||
|
||||
// Strategy: scan loaded assemblies for the well-known AppManager type
|
||||
// and look for a static "Instance" / "Current" property. HmEG ships
|
||||
// Editor.AppManager.* types under the Editor02.HmEGAppManager
|
||||
// assembly (per docs/engine-catalog).
|
||||
try
|
||||
{
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
Type? t = null;
|
||||
try { t = asm.GetType("Editor.AppManager.AppManager", throwOnError: false); }
|
||||
catch { /* ignore — assembly may not allow GetType */ }
|
||||
if (t is null) continue;
|
||||
|
||||
foreach (var name in new[] { "Instance", "Current", "Default" })
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Static);
|
||||
var v = p?.GetValue(null);
|
||||
if (v is not null) { _cachedAppManager = v; return v; }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort; keep _cachedAppManager null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public object? GetActiveDocument()
|
||||
{
|
||||
var am = GetAppManager();
|
||||
return TryGetMember(am, new[] { "ActiveDocument", "CurrentDocument", "Document" });
|
||||
}
|
||||
|
||||
public object? GetActiveViewport()
|
||||
{
|
||||
var am = GetAppManager();
|
||||
return TryGetMember(am, new[] { "ActiveViewport", "CurrentViewport", "Viewport" });
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetSelectedIds()
|
||||
{
|
||||
var doc = GetActiveDocument();
|
||||
var sel = TryGetMember(doc, new[] { "Selection", "SelectedObjects", "Selected" });
|
||||
if (sel is not System.Collections.IEnumerable e) return Array.Empty<string>();
|
||||
var ids = new List<string>();
|
||||
try
|
||||
{
|
||||
foreach (var item in e)
|
||||
{
|
||||
if (item is null) continue;
|
||||
var id = TryGetMember(item, new[] { "Id", "ID", "Guid", "ObjectId" });
|
||||
ids.Add(id?.ToString() ?? item.GetHashCode().ToString());
|
||||
}
|
||||
}
|
||||
catch { /* return what we got so far */ }
|
||||
return ids;
|
||||
}
|
||||
|
||||
public int GetObjectCount()
|
||||
{
|
||||
var doc = GetActiveDocument();
|
||||
var objs = TryGetMember(doc, new[] { "Objects", "Entities", "Nodes" });
|
||||
if (objs is null) return 0;
|
||||
if (objs is System.Collections.ICollection coll) return coll.Count;
|
||||
try
|
||||
{
|
||||
int n = 0;
|
||||
if (objs is System.Collections.IEnumerable e)
|
||||
foreach (var _ in e) n++;
|
||||
return n;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
public string? GetDocumentPath()
|
||||
{
|
||||
var doc = GetActiveDocument();
|
||||
var p = TryGetMember(doc, new[] { "FilePath", "Path", "FileName", "DocumentPath" });
|
||||
return p?.ToString();
|
||||
}
|
||||
|
||||
public (double[] Eye, double[] Target, double[] Up, double Fov)? GetCameraTuple()
|
||||
{
|
||||
var vp = GetActiveViewport();
|
||||
var cam = TryGetMember(vp, new[] { "Camera", "ActiveCamera" });
|
||||
if (cam is null) return null;
|
||||
try
|
||||
{
|
||||
var eye = AsVec3(TryGetMember(cam, new[] { "Position", "Eye", "From" }));
|
||||
var tgt = AsVec3(TryGetMember(cam, new[] { "Target", "LookAt", "To" }));
|
||||
var up = AsVec3(TryGetMember(cam, new[] { "UpDirection", "Up" }));
|
||||
var fovObj = TryGetMember(cam, new[] { "FieldOfView", "Fov", "FOV" });
|
||||
double fov = fovObj is null ? 45.0 : Convert.ToDouble(fovObj);
|
||||
return (eye, tgt, up, fov);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static object? TryGetMember(object? owner, string[] candidates)
|
||||
{
|
||||
if (owner is null) return null;
|
||||
var t = owner.GetType();
|
||||
foreach (var name in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (p is not null) return p.GetValue(owner);
|
||||
var f = t.GetField(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (f is not null) return f.GetValue(owner);
|
||||
}
|
||||
catch { /* try next candidate */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double[] AsVec3(object? v)
|
||||
{
|
||||
if (v is null) return new double[] { 0, 0, 0 };
|
||||
// Try common shapes: double[], float[], or X/Y/Z properties.
|
||||
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] };
|
||||
var t = v.GetType();
|
||||
try
|
||||
{
|
||||
double Read(string n)
|
||||
{
|
||||
var p = t.GetProperty(n, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (p is not null) return Convert.ToDouble(p.GetValue(v));
|
||||
var f = t.GetField(n, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (f is not null) return Convert.ToDouble(f.GetValue(v));
|
||||
return 0;
|
||||
}
|
||||
return new[] { Read("X"), Read("Y"), Read("Z") };
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new double[] { 0, 0, 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 3-tier split step 2 — this file now holds only the EgBim-specific
|
||||
// ReflectionEngineStateProvider, which probes Editor.AppManager.AppManager
|
||||
// via reflection as the CI / fallback path. The generic IEngineStateProvider
|
||||
// contract lives in Recordingtest.Bridge.Abstractions; the HmEG-aware
|
||||
// HmegDirectStateProvider lives in Recordingtest.Hmeg.Bridge.
|
||||
|
||||
using Recordingtest.Bridge;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
/// <summary>
|
||||
/// engine-bridge v3 — reflection-backed provider. Delegates all SUT-specific
|
||||
/// lookups to <see cref="IAppManagerAccessor"/>, which is unit-testable via a
|
||||
/// fake accessor (see <c>FakeAccessor</c> in tests). When the real
|
||||
/// AppManager cannot be discovered the provider returns the same safe
|
||||
/// defaults v2 used, so the SUT remains stable even if HmEG type names drift.
|
||||
///
|
||||
/// In the 3-tier model this class is App-specific (EG-BIM Modeler): it
|
||||
/// targets <c>Editor.AppManager.AppManager</c>. It is kept as the CI/fallback
|
||||
/// path; production should prefer <c>HmegDirectStateProvider</c>.
|
||||
/// </summary>
|
||||
public sealed class ReflectionEngineStateProvider : IEngineStateProvider
|
||||
{
|
||||
private readonly IAppManagerAccessor _accessor;
|
||||
|
||||
public ReflectionEngineStateProvider(IAppManagerAccessor accessor)
|
||||
{
|
||||
_accessor = accessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience overload used by the v2 plugin call site, which only had
|
||||
/// the plugin instance to hand. Wraps the seed in the default reflection
|
||||
/// accessor so existing call sites compile unchanged.
|
||||
/// </summary>
|
||||
public ReflectionEngineStateProvider(object? seedRoot)
|
||||
: this(new ReflectionAppManagerAccessor(seedRoot))
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetSelectedIds()
|
||||
{
|
||||
try { return _accessor.GetSelectedIds(); }
|
||||
catch { return Array.Empty<string>(); }
|
||||
}
|
||||
|
||||
private static readonly CameraSnapshot _defaultCamera =
|
||||
new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0);
|
||||
|
||||
public CameraSnapshot GetCamera()
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = _accessor.GetCameraTuple();
|
||||
return t is null
|
||||
? _defaultCamera
|
||||
: new CameraSnapshot(t.Value.Eye, t.Value.Target, t.Value.Up, t.Value.Fov);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return _defaultCamera;
|
||||
}
|
||||
}
|
||||
|
||||
public SceneSnapshot GetScene()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new SceneSnapshot(_accessor.GetObjectCount(), _accessor.GetDocumentPath());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new SceneSnapshot(0, null);
|
||||
}
|
||||
}
|
||||
|
||||
public bool GetRenderComplete()
|
||||
{
|
||||
// v3 still treats render-complete as best-effort true; HmEG does not
|
||||
// expose a stable "frame finished" flag we can poll without an event.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
public static class PortResolver
|
||||
{
|
||||
public const int DefaultPort = 38080;
|
||||
public const string EnvVarName = "RECORDINGTEST_BRIDGE_PORT";
|
||||
|
||||
public static int Resolve(Func<string, string?>? envReader = null)
|
||||
{
|
||||
envReader ??= Environment.GetEnvironmentVariable;
|
||||
var raw = envReader(EnvVarName);
|
||||
if (!string.IsNullOrWhiteSpace(raw) && int.TryParse(raw, out var p) && p > 0 && p < 65536)
|
||||
{
|
||||
return p;
|
||||
}
|
||||
return DefaultPort;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<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.Sut.EgBim.PluginHost</RootNamespace>
|
||||
<AssemblyName>Recordingtest.Sut.EgBim.PluginHost</AssemblyName>
|
||||
<EnableDefaultItems>true</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Recordingtest.Bridge.Abstractions\Recordingtest.Bridge.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\..\..\Hmeg\Recordingtest.Hmeg.Bridge\Recordingtest.Hmeg.Bridge.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Editor03.PluginInterface">
|
||||
<HintPath>..\..\..\..\EG-BIM Modeler\Editor03.PluginInterface.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="Editor02.HmEGAppManager">
|
||||
<HintPath>..\..\..\..\EG-BIM Modeler\Editor02.HmEGAppManager.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="HmEG">
|
||||
<HintPath>..\..\..\..\EG-BIM Modeler\HmEG.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
117
src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/StateRouter.cs
Normal file
117
src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/StateRouter.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Recordingtest.Bridge;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
|
||||
/// <summary>
|
||||
/// Pure logic router: maps a request path to (status, json body).
|
||||
/// No HttpListener dependency so it can be unit tested cheaply.
|
||||
/// </summary>
|
||||
public sealed class StateRouter
|
||||
{
|
||||
private readonly IEngineStateProvider _provider;
|
||||
private readonly int _port;
|
||||
|
||||
public StateRouter(IEngineStateProvider provider, int port)
|
||||
{
|
||||
_provider = provider;
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public (HttpStatusCode Status, string Body) Route(string path)
|
||||
{
|
||||
var p = (path ?? "/").TrimEnd('/');
|
||||
if (p.Length == 0) p = "/";
|
||||
try
|
||||
{
|
||||
return p switch
|
||||
{
|
||||
"/health" => (HttpStatusCode.OK, $"{{\"status\":\"ok\",\"port\":{_port}}}"),
|
||||
"/selection" => (HttpStatusCode.OK, BuildSelection()),
|
||||
"/camera" => (HttpStatusCode.OK, BuildCamera()),
|
||||
"/scene" => (HttpStatusCode.OK, BuildScene()),
|
||||
"/render" => (HttpStatusCode.OK, BuildRender()),
|
||||
_ => (HttpStatusCode.NotFound, "{\"error\":\"not_found\"}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (HttpStatusCode.OK, $"{{\"error\":{JsonString(ex.Message)}}}");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildSelection()
|
||||
{
|
||||
var ids = _provider.GetSelectedIds();
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("{\"selected_ids\":[");
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append(JsonString(ids[i]));
|
||||
}
|
||||
sb.Append("]}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string BuildCamera()
|
||||
{
|
||||
var c = _provider.GetCamera();
|
||||
return "{\"eye\":" + Vec(c.Eye) + ",\"target\":" + Vec(c.Target) + ",\"up\":" + Vec(c.Up) + ",\"fov\":" + Num(c.Fov) + "}";
|
||||
}
|
||||
|
||||
private string BuildScene()
|
||||
{
|
||||
var s = _provider.GetScene();
|
||||
return "{\"object_count\":" + s.ObjectCount.ToString(CultureInfo.InvariantCulture) +
|
||||
",\"document_path\":" + (s.DocumentPath is null ? "null" : JsonString(s.DocumentPath)) + "}";
|
||||
}
|
||||
|
||||
private string BuildRender()
|
||||
{
|
||||
var done = _provider.GetRenderComplete();
|
||||
return "{\"complete\":" + (done ? "true" : "false") + "}";
|
||||
}
|
||||
|
||||
private static string Vec(double[] v)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('[');
|
||||
for (int i = 0; i < v.Length; i++)
|
||||
{
|
||||
if (i > 0) sb.Append(',');
|
||||
sb.Append(Num(v[i]));
|
||||
}
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Num(double d) => d.ToString("R", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string JsonString(string s)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append('"');
|
||||
foreach (var ch in s)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\b': sb.Append("\\b"); break;
|
||||
case '\f': sb.Append("\\f"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (ch < 0x20) sb.Append("\\u").Append(((int)ch).ToString("x4", CultureInfo.InvariantCulture));
|
||||
else sb.Append(ch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user