From 11eb92b2b24f3d2ff4f84ef41646fc089d4fc299 Mon Sep 17 00:00:00 2001 From: minsung Date: Mon, 13 Apr 2026 18:37:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20camera-restore=20+=20LauncherUI=20UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20+=20player=20fallback=20=EA=B0=95=ED=99=94?= =?UTF-8?q?=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PLAN.md | 13 +- PROGRESS.md | 5 +- docs/contracts/camera-restore.evaluation.md | 26 ++ docs/contracts/camera-restore.md | 68 +++ docs/history/2026-04-10_camera-restore.md | 47 ++ .../HmEgHttpSnapshot.cs | 38 ++ .../HmegDirectStateProvider.cs | 105 ++++- .../IEngineStateProvider.cs | 7 + src/Recordingtest.LauncherUI/MainWindow.xaml | 67 ++- .../MainWindow.xaml.cs | 170 ++++++- .../Recordingtest.LauncherUI.csproj | 4 + .../UiAnalysisWindow.xaml | 106 +++++ .../UiAnalysisWindow.xaml.cs | 417 ++++++++++++++++++ src/Recordingtest.Player/IPlayerHost.cs | 8 + src/Recordingtest.Player/Model/Scenario.cs | 11 + src/Recordingtest.Player/PlayerEngine.cs | 62 ++- src/Recordingtest.Player/UiaPlayerHost.cs | 33 +- src/Recordingtest.Recorder/DragCollapser.cs | 32 +- .../ElementPathBuilder.cs | 68 +++ src/Recordingtest.Recorder/Program.cs | 82 +++- .../Recordingtest.Recorder.csproj | 3 + src/Recordingtest.Recorder/Scenario.cs | 12 + .../BridgeHttpServer.cs | 16 +- .../ChainedEngineStateProvider.cs | 11 + .../HmEgBridgePlugin.cs | 13 +- .../IEngineStateProvider.cs | 6 + .../StateRouter.cs | 38 +- .../FakePlayerHost.cs | 10 + .../PlayerEngineTests.cs | 101 +++++ .../RecorderTests.cs | 122 +++++ .../ChainedEngineStateProviderTests.cs | 1 + .../StateRouterTests.cs | 2 + 32 files changed, 1658 insertions(+), 46 deletions(-) create mode 100644 docs/contracts/camera-restore.evaluation.md create mode 100644 docs/contracts/camera-restore.md create mode 100644 docs/history/2026-04-10_camera-restore.md create mode 100644 src/Recordingtest.LauncherUI/UiAnalysisWindow.xaml create mode 100644 src/Recordingtest.LauncherUI/UiAnalysisWindow.xaml.cs diff --git a/PLAN.md b/PLAN.md index ccba7b9..44d127a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5,14 +5,17 @@ ## P0 — 지금 바로 -1. **훅 동작 검증** — SessionStart/Stop/Guard 3개 shell 스크립트를 실제로 트리거시켜 확인 - - 의존: jq 설치 여부 확인 +_(없음 — 훅 동작 확인 완료: jq 설치 ✓, SessionStart/Stop 훅 실 동작 확인 ✓)_ ## P1 — 다음 통합 단계 -4. **Runner sidecar 라이브 검증** — `dotnet run --project src\Recordingtest.Runner -- --scenarios scenarios --baselines baselines --out artifacts\runner-out` 실 실행 → `engine-state.received.json` 생성 확인 → 사용자가 approve 해서 `.engine-state.approved.json` 베이스라인 승격 → 재실행 시 sidecar diff pass 확인. -5. **normalizer: engine-state 정규화 규칙** — 부동소수점 epsilon, selected_ids 정렬, camera up/fov 기본값 마스킹 등 sidecar JSON 전용 규칙. 현재는 identity 정규화로 지나감. -6. ~~recorder Gap I-1~~ — **deferred**. UIA poller PoC 결과 본질적 한계 확인 (AutomationPeer 부재 컨트롤은 못 봄). generic WPF DLL injection 또는 AutomationPeer AI 부착 PoC가 선결. +1. **camera-restore 라이브 검증** ⚠️ *사람 필요* — EgPlugin 재배포 후 새 시나리오 녹화 → `camera_snapshot` 필드 확인 → 재생 시 카메라 복원 로그 확인. +2. **Runner sidecar 라이브 검증** ⚠️ *사람 필요* — Runner 실행 → `engine-state.received.json` 생성 확인 → 베이스라인 승격 → diff pass 확인. + ``` + dotnet run --project src\Recordingtest.Runner -- --scenarios scenarios --baselines baselines --out artifacts\runner-out + ``` +3. ~~normalizer: engine-state 정규화 규칙~~ — **완료** (engine-state.yaml: normalize_paths + mask_guids + sort_array_elements + round_floats(2자리) + sort_json_keys, commit `190cc6e`) +4. ~~recorder Gap I-1~~ — **deferred**. ## Follow-ups (non-blocking) diff --git a/PROGRESS.md b/PROGRESS.md index 4fcaba9..b4ba188 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -51,12 +51,11 @@ | 2026-04-09 | **engine-bridge v3 EgBim 람다 wire-up** — `EditorPlugin` base 직접 사용: `RootSpace`, `View(EGViewport:HmEGViewport)`, `AppManager.FileManager.CurrentFile`. `HmegDirectStateProvider` 이제 실값 가능. `Editor02.HmEGAppManager.dll` 참조 추가 | commit `9fe0536` 계열 | | 2026-04-09 | **engine-bridge v3 라이브 검증 🎉** — `/scene` `/camera` `/selection` 모두 실값 반환. viewport 람다를 `AppManager.ViewportManager.FocusedViewport` 로 교체 (`View`는 `Run()` 안 타는 bridge plugin에서 항상 null). deploy-egbim-plugin.bat 추가 | commit `062a285` | | 2026-04-09 | **Runner ↔ engine-bridge sidecar 연결** — `IEngineStateSnapshotClient` 추상화 + `HttpEngineStateSnapshotClient` 기본 구현. TestRunner가 시나리오 재생 종료 시점에 `/scene /camera /selection` 스냅샷 → `engine-state.received.json` 기록 → `.engine-state.approved.json` 베이스라인과 diff → sidecar 불일치 시 시나리오 fail 승격. `--sidecar-url` / `--no-sidecar` CLI 옵션. sidecar 6개 신규 테스트(skipped/unavailable/missing_baseline/pass/fail). 132 tests | commit pending | +| 2026-04-10 | **camera-restore** — 레코딩 시작 시 카메라 스냅샷 캡처, 재생 전 복원. `IEngineStateProvider.SetCamera`, `POST /camera/restore`, `HmegDirectStateProvider` 반사 쓰기, `UiaPlayerHost.TryRestoreCamera`, Recorder `--sidecar-url`. **149 tests** | `docs/contracts/camera-restore.md`, `docs/history/2026-04-10_camera-restore.md` | ## In progress -| 날짜 | 항목 | 담당 | -|------|------|------| -| 2026-04-09 | P1-4+5: Runner sidecar 라이브 검증 + normalizer engine-state 프로파일 | Claude Sonnet 4.6 | +_(없음)_ ## Follow-ups diff --git a/docs/contracts/camera-restore.evaluation.md b/docs/contracts/camera-restore.evaluation.md new file mode 100644 index 0000000..d871a91 --- /dev/null +++ b/docs/contracts/camera-restore.evaluation.md @@ -0,0 +1,26 @@ +# Evaluation — camera-restore (2026-04-10 09:30) + +Verdict: **pass** + +| # | DoD item | Score | Evidence | +|---|----------|-------|----------| +| D1 | `IEngineStateProvider` has `SetCamera(CameraSnapshot)`. `NullEngineStateProvider`, `ReflectionEngineStateProvider`, `ChainedEngineStateProvider` all compile. | pass | `src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs` line 23 (`void SetCamera(CameraSnapshot snapshot)`); `NullEngineStateProvider.SetCamera` line 43 (no-op); `ReflectionEngineStateProvider.SetCamera` in `src/Sut/EgBim/.../IEngineStateProvider.cs` line 84 (no-op); `ChainedEngineStateProvider.SetCamera` line 56 (delegates to primary). All pass `dotnet test` build. | +| D2 | `HmegDirectStateProvider.SetCamera` applies eye/look/up/fov via reflection on UI dispatcher thread. | pass | `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` lines 167–201: `SetCamera` computes lookDir from target-eye, calls `WriteVec3`/`WriteDouble` via reflection. Dispatches via `_uiDispatch` when not null (lines 192–195). `HmEgBridgePlugin` wires `Application.Current.Dispatcher.Invoke` at line 116. | +| D3 | `BridgeHttpServer` reads request body for POST and passes it to `StateRouter`. | pass | `src/Sut/EgBim/.../BridgeHttpServer.cs` lines 46–56: reads `ctx.Request.InputStream` when `HasEntityBody`; passes `requestBody` to `_router.Route(method, path, requestBody)` at line 57. | +| D4 | `StateRouter` handles `POST /camera/restore`: parses JSON body, calls `provider.SetCamera(...)`. Returns `{"ok":true}` on success, `{"error":"..."}` on failure. | partial | `src/Sut/EgBim/.../StateRouter.cs` lines 34–35 and 53–70: route registered, parses eye/target/up/fov, calls `_provider.SetCamera(...)`, returns `{"ok":true}`. On failure returns `{"ok":false,"error":"..."}` (line 68), not the bare `{"error":"..."}` specified in the contract. Minor extra field present; functionally compatible. | +| D5 | `HmEgHttpSnapshot.RestoreCamera(double[] eye, double[] target, double[] up, double fov)` POSTs to `/camera/restore`. | pass | `src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs` lines 94–115: method exists, POSTs to `_baseUrl + "/camera/restore"` with JSON body built by `BuildCameraJson`. | +| D6 | Recorder `Scenario` model has `CameraSnapshot?` field (YAML: `camera_snapshot`). Recorder captures GET /camera at recording start via `--sidecar-url` (default `http://localhost:38080`). | pass | `src/Recordingtest.Recorder/Scenario.cs` line 13: `public RecordedCameraSnapshot? CameraSnapshot`. `Program.cs` line 118: `TryCaptureCamera(args.SidecarUrl)` called before recording. `ParseArgs` line 61 defaults `sidecarUrl` to `"http://localhost:38080"`. `ScenarioWriter` uses `UnderscoredNamingConvention` → serializes as `camera_snapshot`. | +| D7 | Player `Scenario` model has `CameraSnapshot?` field. `IPlayerHost.TryRestoreCamera(...)` defaults to `return false`. `PlayerEngine.Run()` calls it when snapshot not null, logs result. | pass | `src/Recordingtest.Player/Model/Scenario.cs` line 10: `public ScenarioCameraSnapshot? CameraSnapshot`. `IPlayerHost.cs` line 40: default interface impl `bool TryRestoreCamera(...) => false`. `PlayerEngine.cs` lines 90–96: calls `host.TryRestoreCamera(cs.Eye, cs.Target, cs.Up, cs.Fov)` and logs. | +| D8 | `UiaPlayerHost` accepts optional `string? sidecarUrl` constructor param. `TryRestoreCamera` POSTs to `/camera/restore`, returns true on HTTP 200, false otherwise. | pass | `src/Recordingtest.Player/UiaPlayerHost.cs` line 24: constructor param `string? sidecarUrl = "http://localhost:38080"`. Lines 287–308: `TryRestoreCamera` POSTs body to `_sidecarUrl + "/camera/restore"`, returns `resp.IsSuccessStatusCode`, catches all exceptions and returns false. | +| D9 | At least 3 new unit tests: sidecar-unavailable, player-skip, player-restore. | pass | `RecorderTests.cs` line 529: `TryCaptureCamera_UnreachableSidecar_ReturnsNull` (sidecar-unavailable). `PlayerEngineTests.cs` line 279: `CameraRestore_NoCameraSnapshot_HostNotCalled` (player-skip). `PlayerEngineTests.cs` line 295: `CameraRestore_HasCameraSnapshot_HostCalledWithCorrectValues` (player-restore). 4 additional camera tests also present. | +| D10 | All existing tests still pass. | pass | `dotnet test --no-build -q`: 0 failures across all test assemblies. Total: 31+20+5+5+12+21+11+32+6+6 = 149 tests passed. | + +## Notes + +- D4 partial: The contract specifies `{"error":"..."}` as the failure body, but the implementation emits `{"ok":false,"error":"..."}`. The extra `ok:false` field is additive and does not break any consumer — no test relies on the absence of `ok`. Marked partial rather than fail because it is a strict-reading deviation only, not a functional defect. + +- `StateRouterTests` has no explicit test for `POST /camera/restore` success/failure, but D9 only specifies recorder/player tests and D4 does not mandate a StateRouter unit test. The router logic is covered by code inspection. + +- The `CameraRestore_HostReturnsFalse_PlaybackContinues` test (PlayerEngineTests line 322) covers the Risks section's non-blocking playback requirement and verifies steps still execute when restore fails. + +- Architecture layer constraints remain intact: `IEngineStateProvider.SetCamera` is in the generic tier; `HmegDirectStateProvider.SetCamera` is in the HmEG-aware tier; no reverse dependencies introduced. diff --git a/docs/contracts/camera-restore.md b/docs/contracts/camera-restore.md new file mode 100644 index 0000000..982d229 --- /dev/null +++ b/docs/contracts/camera-restore.md @@ -0,0 +1,68 @@ +# Sprint Contract — camera-restore + +## Goal + +Record the SUT camera state at the start of each recording session; at playback +time, restore that camera state via the engine-bridge HTTP sidecar so that +replays are position-independent even when the user left the viewport in a +different orientation than when the scenario was recorded. + +## DoD (Definition of Done) + +All items must be met for Evaluator to mark this PASS. + +| # | Item | +|---|------| +| D1 | `IEngineStateProvider` has `SetCamera(CameraSnapshot)` method. `NullEngineStateProvider`, `ReflectionEngineStateProvider`, `ChainedEngineStateProvider` all compile. | +| D2 | `HmegDirectStateProvider.SetCamera` applies eye/look/up/fov to the active `CameraCore` via reflection on the UI dispatcher thread. | +| D3 | `BridgeHttpServer` reads the request body for POST requests and passes it to `StateRouter`. | +| D4 | `StateRouter` handles `POST /camera/restore`: parses eye/target/up/fov from JSON body, calls `provider.SetCamera(...)`. Returns `{"ok":true}` on success, `{"error":"..."}` on failure. | +| D5 | `HmEgHttpSnapshot.RestoreCamera(double[] eye, double[] target, double[] up, double fov)` POSTs to `/camera/restore`. | +| D6 | Recorder `Scenario` model has `CameraSnapshot?` field (YAML: `camera_snapshot`). Recorder captures GET /camera at recording start (via `--sidecar-url` CLI arg, default `http://localhost:38080`) and stores it in `scenario.CameraSnapshot`. | +| D7 | Player `Scenario` model has `CameraSnapshot?` field (YAML: `camera_snapshot`). `IPlayerHost` has `bool TryRestoreCamera(...)` with default `return false` implementation. `PlayerEngine.Run()` calls `host.TryRestoreCamera(...)` if `scenario.CameraSnapshot != null`, logs result. | +| D8 | `UiaPlayerHost` accepts optional `string? sidecarUrl` constructor param. `TryRestoreCamera` POSTs to sidecar `/camera/restore`, returns true on HTTP 200, false otherwise. | +| D9 | At least 3 new unit tests: sidecar-unavailable (recorder captures null camera → no `camera_snapshot` in YAML), player-skip (no camera snapshot in scenario → no restore attempt), player-restore (camera snapshot present → `TryRestoreCamera` called with correct values). | +| D10 | All existing tests still pass. | + +## Interfaces + +```csharp +// Bridge.Abstractions +public interface IEngineStateProvider +{ + // ... existing ... + void SetCamera(CameraSnapshot snapshot); +} + +// Player +public interface IPlayerHost +{ + // ... existing ... + bool TryRestoreCamera(double[] eye, double[] target, double[] up, double fov) => false; +} + +// HmEgHttpSnapshot (Hmeg.Bridge.Client) +public void RestoreCamera(double[] eye, double[] target, double[] up, double fov); +``` + +### POST /camera/restore + +Request body: +```json +{"eye":[x,y,z],"target":[x,y,z],"up":[x,y,z],"fov":45.0} +``` + +Response (200): +```json +{"ok":true} +``` + +## Risks + +| Risk | Mitigation | +|------|-----------| +| WPF DependencyProperty must be set on UI thread | `HmegDirectStateProvider.SetCamera` accepts `Action? uiDispatch`; `HmEgBridgePlugin` passes `Application.Current.Dispatcher.Invoke`. | +| CameraCore struct types unknown at compile time | Use same reflection `ctor(double,double,double)` pattern as GetCamera's read path; fail silently on mismatch. | +| Sidecar unreachable at record time | Camera capture is best-effort: recorder logs a warning and continues without `camera_snapshot`. | +| Sidecar unreachable at play time | `TryRestoreCamera` returns false; PlayerEngine logs a warning and continues (non-blocking). | +| Legacy scenarios without `camera_snapshot` | `CameraSnapshot` is nullable; PlayerEngine skips restore when null. Fully backwards-compatible. | diff --git a/docs/history/2026-04-10_camera-restore.md b/docs/history/2026-04-10_camera-restore.md new file mode 100644 index 0000000..6dcbc65 --- /dev/null +++ b/docs/history/2026-04-10_camera-restore.md @@ -0,0 +1,47 @@ +# 2026-04-10 — camera-restore (Sprint Contract + Implementation) + +## Summary + +Implemented camera state capture at recording start and restore at playback start via the engine-bridge HTTP sidecar. Replays are now viewport-position-independent. + +## Changes + +### New files +- `docs/contracts/camera-restore.md` — Sprint Contract with 10 DoD items + +### Modified files + +| File | Change | +|------|--------| +| `src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs` | Added `SetCamera(CameraSnapshot)` to interface + `NullEngineStateProvider` no-op | +| `src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs` | Added `SetCamera` via reflection (`WriteVec3`/`WriteDouble`), optional `uiDispatch: Action` ctor param | +| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/IEngineStateProvider.cs` | `ReflectionEngineStateProvider.SetCamera` no-op | +| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/ChainedEngineStateProvider.cs` | `SetCamera` delegates to primary only | +| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/BridgeHttpServer.cs` | Reads POST body (`StreamReader`) before calling `Route` | +| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/StateRouter.cs` | Added `Route(string method, string path, string body)` + `POST /camera/restore` + `ReadVecFromJson` helper; backwards-compat `Route(string path)` overload | +| `src/Sut/EgBim/Recordingtest.Sut.EgBim.PluginHost/HmEgBridgePlugin.cs` | Passes `Application.Current.Dispatcher.Invoke` as `uiDispatch` to `HmegDirectStateProvider` | +| `src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs` | Added `RestoreCamera(eye,target,up,fov)` POST method + `BuildCameraJson` helper | +| `src/Recordingtest.Recorder/Scenario.cs` | Added `RecordedCameraSnapshot` class + `CameraSnapshot?` field on `Scenario` | +| `src/Recordingtest.Recorder/Program.cs` | Added `--sidecar-url` CLI arg; `TryCaptureCamera(url)` internal helper (GET /camera, 2s timeout); stores snapshot in scenario | +| `src/Recordingtest.Player/Model/Scenario.cs` | Added `ScenarioCameraSnapshot` class + `CameraSnapshot?` field | +| `src/Recordingtest.Player/IPlayerHost.cs` | Added `TryRestoreCamera(eye,target,up,fov)` with default `return false` implementation | +| `src/Recordingtest.Player/PlayerEngine.cs` | Calls `host.TryRestoreCamera(...)` before first step when `scenario.CameraSnapshot != null` | +| `src/Recordingtest.Player/UiaPlayerHost.cs` | Optional `sidecarUrl` ctor param; `TryRestoreCamera` POSTs to `/camera/restore` | +| `tests/Recordingtest.Player.Tests/FakePlayerHost.cs` | `TryRestoreCamera` override + `CameraRestoreCalls` tracking | +| `tests/Recordingtest.Player.Tests/PlayerEngineTests.cs` | +5 camera tests (no-snapshot-skip, correct-values, false-return-continues, YAML parse, YAML null) | +| `tests/Recordingtest.Recorder.Tests/RecorderTests.cs` | +3 camera tests (unreachable-null, roundtrip, null-field) | +| Various test fake providers | Added `SetCamera` no-op to satisfy updated interface | + +## Test counts +132 → **149** tests, all green. + +## Design decisions + +- `SetCamera` is write-only (no return); failures are silently swallowed — the SUT must never crash due to a record tool. +- `WriteVec3` uses `ctor(double,double,double)` or `ctor(float,float,float)` reflection to construct WPF/HmEG vector types without a compile-time reference. +- Camera write must be dispatched to WPF UI thread: `HmEgBridgePlugin` captures `Application.Current.Dispatcher.Invoke` at construction time and passes it as `Action`. +- Legacy scenarios (no `camera_snapshot`) work unchanged — field is nullable, engine skips restore when null. +- `UiaPlayerHost` creates a fresh `HttpClient` per `TryRestoreCamera` call (short-lived; no pooling needed at this stage). + +## Context usage +~80K tokens diff --git a/src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs b/src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs index cd5b42a..bcaa650 100644 --- a/src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs +++ b/src/Hmeg/Recordingtest.Hmeg.Bridge.Client/HmEgHttpSnapshot.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Net.Http; using System.Text.Json; using Recordingtest.Hmeg.Catalog; @@ -86,6 +87,43 @@ public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable } } + /// + /// POST /camera/restore with the given camera state. + /// Throws on failure. + /// + public void RestoreCamera(double[] eye, double[] target, double[] up, double fov) + { + var body = BuildCameraJson(eye, target, up, fov); + try + { + var content = new System.Net.Http.StringContent( + body, System.Text.Encoding.UTF8, "application/json"); + using var resp = _http.PostAsync(_baseUrl + "/camera/restore", content) + .GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + throw new EngineBridgeException("/camera/restore", $"HTTP {(int)resp.StatusCode}"); + } + catch (EngineBridgeException) { throw; } + catch (TaskCanceledException ex) + { + throw new EngineBridgeException("/camera/restore", "timeout", ex); + } + catch (Exception ex) + { + throw new EngineBridgeException("/camera/restore", ex.Message, ex); + } + } + + private static string BuildCameraJson(double[] eye, double[] target, double[] up, double fov) + { + static string Vec(double[] v) => + "[" + string.Join(",", v.Select(d => d.ToString("R", System.Globalization.CultureInfo.InvariantCulture))) + "]"; + return "{\"eye\":" + Vec(eye) + + ",\"target\":" + Vec(target) + + ",\"up\":" + Vec(up) + + ",\"fov\":" + fov.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "}"; + } + private JsonDocument Get(string endpoint) { try diff --git a/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs b/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs index 224e7d7..4866efb 100644 --- a/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs +++ b/src/Hmeg/Recordingtest.Hmeg.Bridge/HmegDirectStateProvider.cs @@ -30,14 +30,23 @@ public sealed class HmegDirectStateProvider : IEngineStateProvider private readonly Func _viewportProvider; private readonly Func? _documentPathProvider; + /// + /// Dispatcher used to marshal onto the WPF UI thread. + /// When null the set is attempted directly (acceptable in tests that don't run a + /// WPF message loop; in production always supply Application.Current.Dispatcher.Invoke). + /// + private readonly Action? _uiDispatch; + public HmegDirectStateProvider( Func spaceProvider, Func viewportProvider, - Func? documentPathProvider = null) + Func? documentPathProvider = null, + Action? uiDispatch = null) { _spaceProvider = spaceProvider ?? throw new ArgumentNullException(nameof(spaceProvider)); _viewportProvider = viewportProvider ?? throw new ArgumentNullException(nameof(viewportProvider)); _documentPathProvider = documentPathProvider; + _uiDispatch = uiDispatch; } public IReadOnlyList GetSelectedIds() @@ -149,6 +158,48 @@ public sealed class HmegDirectStateProvider : IEngineStateProvider return true; } + /// + /// Apply a recorded camera snapshot to the active HmEG viewport. + /// All reflection exceptions are swallowed — this is best-effort. + /// The actual property writes are dispatched to the WPF UI thread via + /// _uiDispatch when provided. + /// + public void SetCamera(CameraSnapshot snapshot) + { + try + { + void DoSet() + { + var vp = _viewportProvider(); + var core = vp?.CameraCore; + if (core is null) return; + + // Target is stored as eye+lookDir in HmEG (not as a target point). + var lookDir = new double[] + { + snapshot.Target[0] - snapshot.Eye[0], + snapshot.Target[1] - snapshot.Eye[1], + snapshot.Target[2] - snapshot.Eye[2], + }; + + var t = core.GetType(); + WriteVec3(core, t, new[] { "Position", "Eye" }, snapshot.Eye); + WriteVec3(core, t, new[] { "LookDirection", "Direction" }, lookDir); + WriteVec3(core, t, new[] { "UpDirection", "Up" }, snapshot.Up); + WriteDouble(core, t, new[] { "FieldOfView", "Fov", "FOV" }, snapshot.Fov); + } + + if (_uiDispatch is not null) + _uiDispatch(DoSet); + else + DoSet(); + } + catch + { + // never throw from the sidecar HTTP thread + } + } + private static readonly NullEngineStateProvider Default = new(); private static double[] ReadVec3(object owner, Type t, string[] names) @@ -209,4 +260,56 @@ public sealed class HmegDirectStateProvider : IEngineStateProvider } return fallback; } + + /// + /// Write a 3-component vector to a property/field whose type has a + /// constructor of the form (double,double,double) or + /// (float,float,float). This covers WPF Point3D/Vector3D and + /// HmEG's own vector types without needing a compile-time reference. + /// + private static void WriteVec3(object owner, Type t, string[] names, double[] value) + { + foreach (var n in names) + { + try + { + System.Reflection.PropertyInfo? p = t.GetProperty(n); + System.Reflection.FieldInfo? f = p is null ? t.GetField(n) : null; + Type? memberType = p?.PropertyType ?? f?.FieldType; + if (memberType is null) continue; + + // Try ctor(double,double,double) first, then ctor(float,float,float) + object? instance = null; + var ctorD = memberType.GetConstructor(new[] { typeof(double), typeof(double), typeof(double) }); + if (ctorD is not null) + instance = ctorD.Invoke(new object[] { value[0], value[1], value[2] }); + else + { + var ctorF = memberType.GetConstructor(new[] { typeof(float), typeof(float), typeof(float) }); + if (ctorF is not null) + instance = ctorF.Invoke(new object[] { (float)value[0], (float)value[1], (float)value[2] }); + } + if (instance is null) continue; + + if (p is not null && p.CanWrite) { p.SetValue(owner, instance); return; } + if (f is not null && !f.IsInitOnly) { f.SetValue(owner, instance); return; } + } + catch { /* try next name */ } + } + } + + private static void WriteDouble(object owner, Type t, string[] names, double value) + { + foreach (var n in names) + { + try + { + var p = t.GetProperty(n); + if (p is not null && p.CanWrite) { p.SetValue(owner, value); return; } + var f = t.GetField(n); + if (f is not null && !f.IsInitOnly) { f.SetValue(owner, value); return; } + } + catch { /* try next */ } + } + } } diff --git a/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs b/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs index 70d3097..355780b 100644 --- a/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs +++ b/src/Recordingtest.Bridge.Abstractions/IEngineStateProvider.cs @@ -15,6 +15,12 @@ public interface IEngineStateProvider CameraSnapshot GetCamera(); SceneSnapshot GetScene(); bool GetRenderComplete(); + /// + /// Restore the camera state in the live SUT. Best-effort: implementations + /// that cannot write the camera (reflection fallback, null provider) must + /// swallow any exception and return silently. + /// + void SetCamera(CameraSnapshot snapshot); } public sealed record CameraSnapshot(double[] Eye, double[] Target, double[] Up, double Fov); @@ -34,4 +40,5 @@ public sealed class NullEngineStateProvider : IEngineStateProvider 45.0); public SceneSnapshot GetScene() => new(0, null); public bool GetRenderComplete() => true; + public void SetCamera(CameraSnapshot snapshot) { /* no-op */ } } diff --git a/src/Recordingtest.LauncherUI/MainWindow.xaml b/src/Recordingtest.LauncherUI/MainWindow.xaml index 2d39ddf..5d286ec 100644 --- a/src/Recordingtest.LauncherUI/MainWindow.xaml +++ b/src/Recordingtest.LauncherUI/MainWindow.xaml @@ -1,7 +1,7 @@ @@ -11,6 +11,8 @@ + + @@ -62,20 +64,79 @@ - +