feat: camera-restore + LauncherUI UX 개선 + player fallback 강화 (#15)
camera-restore: - IEngineStateProvider.SetCamera 반사 쓰기 (HmegDirectStateProvider) - POST /camera/restore (BridgeHttpServer, StateRouter) - Recorder --sidecar-url + camera_snapshot 캡처 - UiaPlayerHost.TryRestoreCamera, PlayerEngine 재생 전 복원 - 149 tests LauncherUI (#15): - Sidecar URL 체크박스 + 입력란 (녹화/재생 모두 연동) - 재생 속도 슬라이더 (0.25x~4.0x, 기본 1.0x) - 빌드 타임스탬프 타이틀바 표시 - 녹화 완료 후 RecordNameBox 초기화 - UiAnalysisWindow 추가 PlayerEngine (#15): - CancellationToken 지원 (중단 버튼 동작) - Focus 스텝 early return (no-op, issue #11) - Type/Drag unresolvable UIA path fallback - SpeedMultiplier 옵션 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,8 +41,20 @@ public sealed class BridgeHttpServer : IDisposable
|
||||
catch { return; }
|
||||
try
|
||||
{
|
||||
var path = ctx.Request.Url?.AbsolutePath ?? "/";
|
||||
var (status, body) = _router.Route(path);
|
||||
var path = ctx.Request.Url?.AbsolutePath ?? "/";
|
||||
var method = ctx.Request.HttpMethod ?? "GET";
|
||||
string requestBody = string.Empty;
|
||||
if (ctx.Request.HasEntityBody)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new System.IO.StreamReader(
|
||||
ctx.Request.InputStream, Encoding.UTF8);
|
||||
requestBody = reader.ReadToEnd();
|
||||
}
|
||||
catch { /* ignore body read failures */ }
|
||||
}
|
||||
var (status, body) = _router.Route(method, path, requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
ctx.Response.StatusCode = (int)status;
|
||||
ctx.Response.ContentType = "application/json";
|
||||
|
||||
@@ -48,6 +48,17 @@ public sealed class ChainedEngineStateProvider : IEngineStateProvider
|
||||
|
||||
public bool GetRenderComplete() => _primary.GetRenderComplete();
|
||||
|
||||
/// <summary>
|
||||
/// Camera writes go to the primary only (HmegDirectStateProvider).
|
||||
/// The reflection fallback does not support writes; chaining writes would
|
||||
/// risk applying a stale camera twice.
|
||||
/// </summary>
|
||||
public void SetCamera(CameraSnapshot snapshot)
|
||||
{
|
||||
try { _primary.SetCamera(snapshot); }
|
||||
catch { /* never throw from sidecar thread */ }
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -106,7 +106,18 @@ public sealed class HmEgBridgePlugin : EditorPlugin, IDisposable
|
||||
catch { return null; }
|
||||
};
|
||||
|
||||
var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider);
|
||||
// Dispatch camera writes onto the WPF UI thread so DependencyProperty
|
||||
// setters on CameraCore are called from the correct thread.
|
||||
Action<Action>? uiDispatch = null;
|
||||
try
|
||||
{
|
||||
var dispatcher = System.Windows.Application.Current?.Dispatcher;
|
||||
if (dispatcher is not null)
|
||||
uiDispatch = action => dispatcher.Invoke(action);
|
||||
}
|
||||
catch { /* best-effort: leave null, SetCamera falls back to direct call */ }
|
||||
|
||||
var direct = new HmegDirectStateProvider(spaceProvider, viewportProvider, documentPathProvider, uiDispatch);
|
||||
var fallback = new ReflectionEngineStateProvider(this);
|
||||
return new ChainedEngineStateProvider(direct, fallback);
|
||||
}
|
||||
|
||||
@@ -80,4 +80,10 @@ public sealed class ReflectionEngineStateProvider : IEngineStateProvider
|
||||
// expose a stable "frame finished" flag we can poll without an event.
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetCamera(CameraSnapshot snapshot)
|
||||
{
|
||||
// Reflection fallback does not implement camera write; silently no-op.
|
||||
// The primary HmegDirectStateProvider handles this in production.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Recordingtest.Bridge;
|
||||
|
||||
namespace Recordingtest.Sut.EgBim.PluginHost;
|
||||
@@ -20,12 +21,19 @@ public sealed class StateRouter
|
||||
_port = port;
|
||||
}
|
||||
|
||||
public (HttpStatusCode Status, string Body) Route(string path)
|
||||
/// <summary>Backwards-compatible GET overload for unit tests.</summary>
|
||||
public (HttpStatusCode Status, string Body) Route(string path) => Route("GET", path, "");
|
||||
|
||||
public (HttpStatusCode Status, string Body) Route(string method, string path, string body = "")
|
||||
{
|
||||
var p = (path ?? "/").TrimEnd('/');
|
||||
if (p.Length == 0) p = "/";
|
||||
var m = (method ?? "GET").ToUpperInvariant();
|
||||
try
|
||||
{
|
||||
if (m == "POST" && p == "/camera/restore")
|
||||
return (HttpStatusCode.OK, RestoreCamera(body));
|
||||
|
||||
return p switch
|
||||
{
|
||||
"/health" => (HttpStatusCode.OK, $"{{\"status\":\"ok\",\"port\":{_port}}}"),
|
||||
@@ -42,6 +50,34 @@ public sealed class StateRouter
|
||||
}
|
||||
}
|
||||
|
||||
private string RestoreCamera(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var r = doc.RootElement;
|
||||
var eye = ReadVecFromJson(r, "eye");
|
||||
var target = ReadVecFromJson(r, "target");
|
||||
var up = ReadVecFromJson(r, "up");
|
||||
double fov = r.TryGetProperty("fov", out var fovEl) ? fovEl.GetDouble() : 45.0;
|
||||
_provider.SetCamera(new CameraSnapshot(eye, target, up, fov));
|
||||
return "{\"ok\":true}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"{{\"ok\":false,\"error\":{JsonString(ex.Message)}}}";
|
||||
}
|
||||
}
|
||||
|
||||
private static double[] ReadVecFromJson(JsonElement root, string key)
|
||||
{
|
||||
if (!root.TryGetProperty(key, out var arr)) return new double[3];
|
||||
var result = new double[arr.GetArrayLength()];
|
||||
int i = 0;
|
||||
foreach (var e in arr.EnumerateArray()) result[i++] = e.GetDouble();
|
||||
return result;
|
||||
}
|
||||
|
||||
private string BuildSelection()
|
||||
{
|
||||
var ids = _provider.GetSelectedIds();
|
||||
|
||||
Reference in New Issue
Block a user