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:
minsung
2026-04-09 10:39:13 +09:00
parent f6b6e7449e
commit 03fb504eea
36 changed files with 542 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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