Implement engine-bridge v2 plugin masquerade (#10)

This commit is contained in:
minsung
2026-04-07 16:08:31 +09:00
parent 4cee3c2d86
commit b1c2383a54
18 changed files with 1017 additions and 0 deletions

View 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 { }
}
}

View 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;
}
}

View 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; }
}
}

View 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;
}
}

View 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>

View 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();
}
}