Implement engine-bridge v2 plugin masquerade (#10)
This commit is contained in:
66
src/Recordingtest.EgPlugin/BridgeHttpServer.cs
Normal file
66
src/Recordingtest.EgPlugin/BridgeHttpServer.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Recordingtest.EgPlugin;
|
||||
|
||||
/// <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 { }
|
||||
}
|
||||
}
|
||||
48
src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
Normal file
48
src/Recordingtest.EgPlugin/HmEgBridgePlugin.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Editor.PluginInterface;
|
||||
|
||||
namespace Recordingtest.EgPlugin;
|
||||
|
||||
/// <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.EgPlugin";
|
||||
public override string Description => "recordingtest engine-bridge v2 (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 = new ReflectionEngineStateProvider(this);
|
||||
var router = new StateRouter(provider, port);
|
||||
_server = new BridgeHttpServer(router, port);
|
||||
_server.Start();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// never throw out of plugin construction; SUT must remain stable.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { _server?.Dispose(); } catch { }
|
||||
_server = null;
|
||||
}
|
||||
}
|
||||
56
src/Recordingtest.EgPlugin/IEngineStateProvider.cs
Normal file
56
src/Recordingtest.EgPlugin/IEngineStateProvider.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
namespace Recordingtest.EgPlugin;
|
||||
|
||||
public interface IEngineStateProvider
|
||||
{
|
||||
IReadOnlyList<string> GetSelectedIds();
|
||||
CameraSnapshot GetCamera();
|
||||
SceneSnapshot GetScene();
|
||||
bool GetRenderComplete();
|
||||
}
|
||||
|
||||
public sealed record CameraSnapshot(double[] Eye, double[] Target, double[] Up, double Fov);
|
||||
public sealed record SceneSnapshot(int ObjectCount, string? DocumentPath);
|
||||
|
||||
public sealed class NullEngineStateProvider : IEngineStateProvider
|
||||
{
|
||||
public IReadOnlyList<string> GetSelectedIds() => Array.Empty<string>();
|
||||
public CameraSnapshot GetCamera() => new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0);
|
||||
public SceneSnapshot GetScene() => new(0, null);
|
||||
public bool GetRenderComplete() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skeleton reflection-based provider. v2 returns safe defaults; real HmEG mapping happens in v3 once SUT smoke tests confirm field shapes.
|
||||
/// </summary>
|
||||
public sealed class ReflectionEngineStateProvider : IEngineStateProvider
|
||||
{
|
||||
private readonly object? _appManager;
|
||||
|
||||
public ReflectionEngineStateProvider(object? appManager)
|
||||
{
|
||||
_appManager = appManager;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetSelectedIds()
|
||||
{
|
||||
try { _ = _appManager; return Array.Empty<string>(); }
|
||||
catch { return Array.Empty<string>(); }
|
||||
}
|
||||
|
||||
public CameraSnapshot GetCamera()
|
||||
{
|
||||
try { return new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0); }
|
||||
catch { return new(new double[] { 0, 0, 0 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45.0); }
|
||||
}
|
||||
|
||||
public SceneSnapshot GetScene()
|
||||
{
|
||||
try { return new(0, null); }
|
||||
catch { return new(0, null); }
|
||||
}
|
||||
|
||||
public bool GetRenderComplete()
|
||||
{
|
||||
try { return true; } catch { return false; }
|
||||
}
|
||||
}
|
||||
18
src/Recordingtest.EgPlugin/PortResolver.cs
Normal file
18
src/Recordingtest.EgPlugin/PortResolver.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace Recordingtest.EgPlugin;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
21
src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj
Normal file
21
src/Recordingtest.EgPlugin/Recordingtest.EgPlugin.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<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.EgPlugin</RootNamespace>
|
||||
<EnableDefaultItems>true</EnableDefaultItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="Editor03.PluginInterface">
|
||||
<HintPath>..\..\EG-BIM Modeler\Editor03.PluginInterface.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="HmEG">
|
||||
<HintPath>..\..\EG-BIM Modeler\HmEG.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
116
src/Recordingtest.EgPlugin/StateRouter.cs
Normal file
116
src/Recordingtest.EgPlugin/StateRouter.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
namespace Recordingtest.EgPlugin;
|
||||
|
||||
/// <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