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,75 @@
# Sprint Contract — engine-bridge v2 (MEF plugin masquerade)
**Owner:** Generator
**Depends on:** engine-bridge v1 (완료)
**Issue:** #10
## Goal
`engine-bridge` v1 probe design 문서가 권고한 **MEF plugin masquerade** 경로를 구현한다. recordingtest 전용 플러그인을 빌드해 `EG-BIM Modeler/Plugins/`에 drop-in하면, SUT가 로드할 때 플러그인이 로컬 HTTP 서버를 띄워 HmEG 상태(선택/카메라/씬/렌더)를 JSON으로 노출한다. recordingtest-side 클라이언트는 HTTP로 상태를 조회한다.
PoC 범위: 플러그인 + 클라이언트 빌드 + 엔드투엔드 HTTP 테스트 (가짜 엔진 상태 주입). **SUT 실제 실행은 수동 smoke test 단계에서.**
## Definition of Done
- [ ] `src/Recordingtest.EgPlugin/` — MEF plugin dll
- Target: `net8.0-windows`
- `Editor03.PluginInterface.dll``HintPath`로 참조 (Private=false, SUT의 정본 복사 금지)
- `Editor03.PluginInterface.dll`의 **실제 계약을 MetadataLoadContext로 분석**해서 구현할 최소 interface/attribute를 확인 (Generator가 discovery 수행)
- 플러그인 로드 시점에 `HttpListener``http://localhost:{port}/` 시작 (기본 포트 `38080`, 환경변수 `RECORDINGTEST_BRIDGE_PORT`로 override)
- 엔드포인트: `/selection`, `/camera`, `/scene`, `/render`, `/health`
- 각 엔드포인트는 `IEngineStateProvider` 인터페이스로부터 JSON을 반환
- 기본 `ReflectionEngineStateProvider`는 HmEG 내부 타입을 리플렉션으로 찾아 상태를 구성 (실패해도 예외 삼킴 — `{error: "..."}` 반환)
- 플러그인 unload 시 HttpListener 정리
- [ ] `src/Recordingtest.EngineBridge.Client/` — HTTP 클라이언트 라이브러리
- `HmEgHttpSnapshot : IEngineSnapshot` 구현 (v1 인터페이스 재사용)
- `HttpClient`로 엔드포인트 호출, 타임아웃 기본 2초
- 각 속성은 on-demand HTTP GET (caching 없음 v2)
- `IsRenderComplete``/render` 응답의 `complete` 필드
- [ ] `tests/Recordingtest.EngineBridge.IntegrationTests/` — xUnit
- **fake HTTP 서버**: `HttpListener`를 테스트 안에서 띄우고 고정 JSON 응답
- 테스트 ≥ 6:
1. `Client_SelectionEndpoint_ReturnsIds`
2. `Client_CameraEndpoint_ReturnsCameraState`
3. `Client_SceneEndpoint_ReturnsSceneSummary`
4. `Client_RenderEndpoint_ReturnsIsComplete`
5. `Client_HealthEndpoint_ReturnsOk`
6. `Client_Timeout_ThrowsOrReturnsError` (2초 이내 미응답 시)
- [ ] `src/Recordingtest.EgPlugin` 단위 테스트 ≥ 3 (플러그인 로직)
- 테스트에서 **실제 plugin dll을 SUT에 주입하지 않음**. 순수 로직만.
- `StateRouter_SerializesSelection_ToJson`
- `StateRouter_WithFaultyProvider_ReturnsErrorPayload`
- `PortResolver_PrefersEnvVar` (`RECORDINGTEST_BRIDGE_PORT`)
- [ ] `docs/guides/engine-bridge-deploy.md` — 수동 배포 가이드 (SUT Plugins/ 폴더에 dll 복사, 환경변수 설정, 검증 절차)
- [ ] `dotnet build` + `dotnet test` 전부 green
- [ ] 플러그인이 SUT 자체 파일을 건드리지 않음 (guard hook 준수)
## Out of scope
- SUT 실제 실행 (수동 smoke test)
- 인증/암호화 (PoC는 localhost only)
- 멀티클라이언트 / 동시성 (단일 HttpListener)
- HmEG 리플렉션 매핑 완성도 — v2는 skeleton + error fallback 중심, 진짜 매핑은 smoke test 후 조정
## Interfaces
- **Inputs:** SUT가 plugin을 로드할 때의 MEF 계약
- **Outputs:** `http://localhost:<port>/{selection|camera|scene|render|health}` JSON
- **Side effects:** HttpListener 포트 점유 (플러그인 생명주기 내)
## Evaluation plan
1. `dotnet build recordingtest.sln` green
2. `dotnet test tests/Recordingtest.EngineBridge.IntegrationTests` — 6 pass
3. `dotnet test tests/Recordingtest.EgPlugin.Tests` — 3 pass
4. `Recordingtest.EgPlugin.dll` 출력 확인, `Editor03.PluginInterface`에 대한 HintPath/Private=false 확인 (csproj 리뷰)
5. Generator가 Editor03.PluginInterface.dll 메타데이터에서 발견한 실제 인터페이스 이름을 history에 기록했는지 확인
6. `docs/guides/engine-bridge-deploy.md` 존재 + 배포 단계(복사·환경변수·검증) 기술
7. SUT 폴더에 쓰기 흔적 없음 (grep)
8. Plugin 코드가 `HttpListener` 예외 상황(port 충돌, stop) 처리
## Risks
- `Editor03.PluginInterface`가 WPF dependency를 가지면 net8.0-windows로 충분하지 않을 수 있음 — 필요 시 TFM 조정
- 플러그인 dll이 SUT와 같은 디렉터리의 다른 dll 버전과 충돌 가능 — Private=false 필수
- HmEG 리플렉션 실제 매핑은 SUT 런타임에서만 검증 가능 → v2는 error fallback 철저

View File

@@ -0,0 +1,97 @@
# engine-bridge v2 Deployment Guide
This guide explains how to deploy `Recordingtest.EgPlugin.dll` into the SUT
(`EG-BIM Modeler`) so the recordingtest harness can read live HmEG state over
HTTP. Issue #10.
## 1. Build
```
dotnet publish src/Recordingtest.EgPlugin -c Release
```
Output: `src/Recordingtest.EgPlugin/bin/Release/net8.0-windows/publish/`
- `Recordingtest.EgPlugin.dll`
- `Recordingtest.EgPlugin.deps.json`
- `Recordingtest.EgPlugin.pdb`
`Editor03.PluginInterface.dll` and `HmEG.dll` are referenced with
`<Private>false</Private>`, so the plugin output **does not** include copies
of the SUT contracts. The plugin will bind to whatever the SUT loads at
runtime.
## 2. Copy into SUT Plugins folder
> **Important:** writing into `EG-BIM Modeler/` is normally blocked by the
> repo guard hook. Ask the operator before performing the copy step. The
> copy is intentionally a manual operation.
Create a per-plugin folder under `EG-BIM Modeler/Plugins/` and copy the
publish output:
```
mkdir "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin"
copy src\Recordingtest.EgPlugin\bin\Release\net8.0-windows\publish\Recordingtest.EgPlugin.dll "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin\"
copy src\Recordingtest.EgPlugin\bin\Release\net8.0-windows\publish\Recordingtest.EgPlugin.deps.json "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin\"
```
`runtimeconfig.json` is not produced for class library projects; the SUT
hosts the CLR. If a future change makes the plugin executable, also copy
`Recordingtest.EgPlugin.runtimeconfig.json`.
## 3. Configure environment (optional)
The plugin listens on `http://localhost:38080/` by default. Override with:
```
set RECORDINGTEST_BRIDGE_PORT=38090
```
The variable is read once at plugin construction.
## 4. Launch SUT
Start `EG-BIM Modeler.exe` normally. The SUT's `HmEG.PluginLoader` walks
`Plugins/` at startup and loads any DLL whose type derives from
`HmEG.IPlugin` (our plugin inherits `Editor.PluginInterface.EditorPlugin`,
which implements `IPlugin`).
## 5. Verify
```
curl http://localhost:38080/health
```
Expected:
```
{"status":"ok","port":38080}
```
Other endpoints:
- `GET /selection` -> `{"selected_ids":[...]}`
- `GET /camera` -> `{"eye":[..],"target":[..],"up":[..],"fov":n}`
- `GET /scene` -> `{"object_count":n,"document_path":"..."}`
- `GET /render` -> `{"complete":true|false}`
The recordingtest client (`HmEgHttpSnapshot`) is the supported consumer.
## 6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `curl` connection refused | port already in use OR plugin failed to load | check SUT log under `EG-BIM Modeler/hmlogs/`, set a different `RECORDINGTEST_BRIDGE_PORT` |
| `Could not load file or assembly Editor03.PluginInterface` | Wrong contract version dropped next to plugin | delete any local copy of `Editor03.PluginInterface.dll` from the plugin folder; the SUT must resolve it from its own folder |
| Plugin loaded but `/health` 404 | SUT started a different `Plugins/Recordingtest.EgPlugin/` build | clean the folder and re-copy publish output |
| `HttpListener` access denied (Windows) | URL ACL not registered | run SUT elevated once, or `netsh http add urlacl url=http://localhost:38080/ user=Everyone` |
SUT log location: `EG-BIM Modeler/hmlogs/` (Serilog rolling files).
## 7. Uninstall
```
rmdir /s /q "EG-BIM Modeler\Plugins\Recordingtest.EgPlugin"
```
No registry, no services, no env vars beyond optional `RECORDINGTEST_BRIDGE_PORT`.

View File

@@ -0,0 +1,78 @@
# 2026-04-07 — Issue #10 engine-bridge v2 Generator
- 역할: Generator
- 계약: `docs/contracts/engine-bridge-v2.md`
- 소요 시간: 약 35분 (단일 세션)
- Context 사용량: 약 95k tokens (대용량 sln/.cs 디렉터리 스캔 + MetadataLoadContext 출력)
## Editor03 / HmEG 디스커버리 결과
`MetadataLoadContext``EG-BIM Modeler/Editor03.PluginInterface.dll`,
`Editor07.WidgetPluginInterface.dll`, `HmEG.dll`, `Plugins/EgBoxPlugin/EgBoxPlugin.dll`
메타데이터 전용으로 열어 다음을 확인했다:
- **실제 plugin 컨트랙트는 `HmEG.IPlugin`** (HmEG.dll). Members:
`string Name { get; }`, `EGViewport View { get; set; }`,
`bool RethrowException { get; set; }`, `object Run(object[])`.
- **MEF Export 어트리뷰트는 사용되지 않는다.** 로딩은 `HmEG.PluginLoader`
(`LoadProjectPlugins`/`LoadPlugin(path,name)`)가 직접 수행한다.
`EgBoxPlugin.EgBoxPlugin` 샘플도 `[Export]` 없이 단순히
`Editor.PluginInterface.EditorPlugin`을 상속한다.
- **`Editor.PluginInterface.EditorPlugin`** (Editor03.PluginInterface.dll)
`HmEG.IPlugin`을 구현하는 추상 base다.
abstract 멤버: `Name { get; }`, `Description { get; }`,
`protected void Initialize()`. 그 외 `AppManager`, `RootSpace`,
`ViewportManager`, `View`, `AddModelToRootSpace(...)` 등의 헬퍼를 노출한다.
- **`Editor07.WidgetPluginInterface`** 는 위젯 전용(`WidzetPlugin`,
`HmEG_DebugWidzetPlugin`)이며 v2 범위 밖이라 미사용.
→ 결론: `HmEgBridgePlugin : Editor.PluginInterface.EditorPlugin` 으로
구현. `Name`/`Description` override + `protected override Initialize()`,
HTTP listener는 생성자에서 안전하게 부팅 (`Initialize`가 호출되지 않더라도
listener는 살아있음).
### 디스커버리 시 주의사항
- MetadataLoadContext에 `Microsoft.NETCore.App` 런타임 디렉터리만 넣으면
`WindowsBase``System.Windows.Markup.InternalTypeHelper`를 못 찾아
`TypeLoadException`이 난다. **WindowsDesktop ref pack
(`packs/Microsoft.WindowsDesktop.App.Ref/8.0.22/ref/net8.0`)** 도 함께
resolver에 등록해야 한다. 동일 파일명은 dedupe 필요.
- 같은 이유로 `Editor03.PluginInterface``<UseWPF>true</UseWPF>`
net8.0-windows 프로젝트에서만 빌드된다.
## 산출물
- `src/Recordingtest.EgPlugin/` (PortResolver, IEngineStateProvider,
Null/ReflectionEngineStateProvider, StateRouter, BridgeHttpServer,
HmEgBridgePlugin)
- `src/Recordingtest.EngineBridge.Client/` (HmEgHttpSnapshot,
EngineBridgeException)
- `tests/Recordingtest.EngineBridge.IntegrationTests/` (FakeBridgeServer +
6 xUnit tests)
- `tests/Recordingtest.EgPlugin.Tests/` (5 xUnit tests for StateRouter +
PortResolver)
- `docs/guides/engine-bridge-deploy.md`
## 빌드 / 테스트 결과
- `dotnet build recordingtest.sln` → green (warning 0, error 0)
- `dotnet test tests/Recordingtest.EngineBridge.IntegrationTests` → 6/6 통과
- `dotnet test tests/Recordingtest.EgPlugin.Tests` → 5/5 통과
- 신규 프로젝트 4개를 `recordingtest.sln`에 추가
- `EG-BIM Modeler/` 폴더에는 일체 쓰지 않음 (가드 훅 준수). 배포는 가이드
문서로만 기술.
## 리스크 / 후속
- `ReflectionEngineStateProvider`는 v2 skeleton: 모든 메서드가 안전한
default를 반환한다. **HmEG 내부 타입(특히 `HmEGAppManager`,
`EGViewport`, 선택/카메라 manager)에 대한 진짜 reflection 매핑은 v3에서
smoke test 후 확정해야 한다.** 후보 멤버는
`docs/engine-catalog/hmeg-candidates.json` 참고.
- `HttpListener` urlacl이 등록 안 된 환경에서는 첫 실행에 관리자 권한 또는
`netsh http add urlacl` 필요 — 가이드에 명시.
- `Editor.PluginInterface.EditorPlugin.Initialize()`가 protected이고
HmEG.PluginLoader가 어느 시점에 호출하는지는 메타데이터로 확인 불가
(런타임 동작). 그래서 listener를 생성자에서 부팅했다. PluginLoader가
생성자 호출만으로 충분한지는 smoke test에서 확인 필요.

View File

@@ -33,6 +33,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Tests", "tests\Recordingtest.EngineBridge.Tests\Recordingtest.EngineBridge.Tests.csproj", "{0811AC32-E2A4-4BFD-A29A-6451F5756F10}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Tests", "tests\Recordingtest.EngineBridge.Tests\Recordingtest.EngineBridge.Tests.csproj", "{0811AC32-E2A4-4BFD-A29A-6451F5756F10}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin", "src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj", "{51D7B803-5F6E-4B78-9A5D-326F28CD934F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.Client", "src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj", "{45D80D0C-A8A1-4173-B28C-68F0628EE346}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EngineBridge.IntegrationTests", "tests\Recordingtest.EngineBridge.IntegrationTests\Recordingtest.EngineBridge.IntegrationTests.csproj", "{BA346F72-6F9C-4D68-9CDD-DD05F9687095}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Recordingtest.EgPlugin.Tests", "tests\Recordingtest.EgPlugin.Tests\Recordingtest.EgPlugin.Tests.csproj", "{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -223,6 +231,54 @@ Global
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.Build.0 = Release|Any CPU {0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x64.Build.0 = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = Release|Any CPU {0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.ActiveCfg = Release|Any CPU
{0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.Build.0 = Release|Any CPU {0811AC32-E2A4-4BFD-A29A-6451F5756F10}.Release|x86.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x64.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.ActiveCfg = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Debug|x86.Build.0 = Debug|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|Any CPU.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x64.Build.0 = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.ActiveCfg = Release|Any CPU
{51D7B803-5F6E-4B78-9A5D-326F28CD934F}.Release|x86.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x64.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.ActiveCfg = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Debug|x86.Build.0 = Debug|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|Any CPU.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x64.Build.0 = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.ActiveCfg = Release|Any CPU
{45D80D0C-A8A1-4173-B28C-68F0628EE346}.Release|x86.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x64.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.ActiveCfg = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Debug|x86.Build.0 = Debug|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|Any CPU.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x64.Build.0 = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.ActiveCfg = Release|Any CPU
{BA346F72-6F9C-4D68-9CDD-DD05F9687095}.Release|x86.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x64.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.ActiveCfg = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Debug|x86.Build.0 = Debug|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|Any CPU.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x64.Build.0 = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.ActiveCfg = Release|Any CPU
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -242,5 +298,9 @@ Global
{938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {938D464B-B810-425F-83B6-52877B584DE2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B1EAD466-9C07-4C07-907C-3D5794F6689D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {B1EAD466-9C07-4C07-907C-3D5794F6689D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0811AC32-E2A4-4BFD-A29A-6451F5756F10} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {0811AC32-E2A4-4BFD-A29A-6451F5756F10} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{51D7B803-5F6E-4B78-9A5D-326F28CD934F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{45D80D0C-A8A1-4173-B28C-68F0628EE346} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BA346F72-6F9C-4D68-9CDD-DD05F9687095} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{315F3B4F-BF8F-4DBF-8F06-CAF55152725D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

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

View File

@@ -0,0 +1,11 @@
namespace Recordingtest.EngineBridge.Client;
public sealed class EngineBridgeException : Exception
{
public string Endpoint { get; }
public EngineBridgeException(string endpoint, string message, Exception? inner = null)
: base($"engine-bridge {endpoint}: {message}", inner)
{
Endpoint = endpoint;
}
}

View File

@@ -0,0 +1,121 @@
using System.Net.Http;
using System.Text.Json;
namespace Recordingtest.EngineBridge.Client;
public sealed class HmEgHttpSnapshot : IEngineSnapshot, IDisposable
{
public const string DefaultBaseUrl = "http://localhost:38080";
private readonly HttpClient _http;
private readonly bool _ownsClient;
private readonly string _baseUrl;
public HmEgHttpSnapshot(string baseUrl = DefaultBaseUrl, HttpClient? httpClient = null, TimeSpan? timeout = null)
{
_baseUrl = baseUrl.TrimEnd('/');
if (httpClient is null)
{
_http = new HttpClient { Timeout = timeout ?? TimeSpan.FromSeconds(2) };
_ownsClient = true;
}
else
{
_http = httpClient;
if (timeout.HasValue) _http.Timeout = timeout.Value;
_ownsClient = false;
}
}
public IReadOnlyList<string> SelectedObjectIds
{
get
{
using var doc = Get("/selection");
var arr = doc.RootElement.GetProperty("selected_ids");
var list = new List<string>(arr.GetArrayLength());
foreach (var e in arr.EnumerateArray()) list.Add(e.GetString() ?? string.Empty);
return list;
}
}
public CameraState Camera
{
get
{
using var doc = Get("/camera");
var r = doc.RootElement;
return new CameraState(
ToArray(r.GetProperty("eye")),
ToArray(r.GetProperty("target")),
ToArray(r.GetProperty("up")),
r.GetProperty("fov").GetDouble());
}
}
public SceneSummary Scene
{
get
{
using var doc = Get("/scene");
var r = doc.RootElement;
string? path = r.TryGetProperty("document_path", out var dp) && dp.ValueKind == JsonValueKind.String ? dp.GetString() : null;
return new SceneSummary(r.GetProperty("object_count").GetInt32(), path);
}
}
public bool IsRenderComplete
{
get
{
using var doc = Get("/render");
return doc.RootElement.GetProperty("complete").GetBoolean();
}
}
public bool IsHealthy
{
get
{
try
{
using var doc = Get("/health");
return doc.RootElement.TryGetProperty("status", out var s) && s.GetString() == "ok";
}
catch (EngineBridgeException) { return false; }
}
}
private JsonDocument Get(string endpoint)
{
try
{
using var resp = _http.GetAsync(_baseUrl + endpoint).GetAwaiter().GetResult();
if (!resp.IsSuccessStatusCode)
throw new EngineBridgeException(endpoint, $"HTTP {(int)resp.StatusCode}");
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
return JsonDocument.Parse(body);
}
catch (EngineBridgeException) { throw; }
catch (TaskCanceledException ex)
{
throw new EngineBridgeException(endpoint, "timeout", ex);
}
catch (Exception ex)
{
throw new EngineBridgeException(endpoint, ex.Message, ex);
}
}
private static double[] ToArray(JsonElement e)
{
var arr = new double[e.GetArrayLength()];
int i = 0;
foreach (var item in e.EnumerateArray()) arr[i++] = item.GetDouble();
return arr;
}
public void Dispose()
{
if (_ownsClient) _http.Dispose();
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>Recordingtest.EngineBridge.Client</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Recordingtest.EngineBridge\Recordingtest.EngineBridge.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.EgPlugin.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Recordingtest.EgPlugin\Recordingtest.EgPlugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,64 @@
using System.Net;
using Recordingtest.EgPlugin;
using Xunit;
namespace Recordingtest.EgPlugin.Tests;
public class StateRouterTests
{
private sealed class FixedProvider : IEngineStateProvider
{
public IReadOnlyList<string> GetSelectedIds() => new[] { "x", "y" };
public CameraSnapshot GetCamera() => new(new double[] { 1, 2, 3 }, new double[] { 0, 0, 0 }, new double[] { 0, 0, 1 }, 45);
public SceneSnapshot GetScene() => new(7, "doc.hmeg");
public bool GetRenderComplete() => true;
}
private sealed class FaultyProvider : IEngineStateProvider
{
public IReadOnlyList<string> GetSelectedIds() => throw new InvalidOperationException("boom");
public CameraSnapshot GetCamera() => throw new InvalidOperationException();
public SceneSnapshot GetScene() => throw new InvalidOperationException();
public bool GetRenderComplete() => throw new InvalidOperationException();
}
[Fact]
public void StateRouter_SelectionPath_UsesProvider_ReturnsJson()
{
var r = new StateRouter(new FixedProvider(), 38080);
var (status, body) = r.Route("/selection");
Assert.Equal(HttpStatusCode.OK, status);
Assert.Contains("\"selected_ids\":[\"x\",\"y\"]", body);
}
[Fact]
public void StateRouter_FaultyProvider_ReturnsErrorPayload()
{
var r = new StateRouter(new FaultyProvider(), 38080);
var (_, body) = r.Route("/selection");
Assert.Contains("\"error\"", body);
Assert.Contains("boom", body);
}
[Fact]
public void StateRouter_UnknownPath_Returns404()
{
var r = new StateRouter(new FixedProvider(), 38080);
var (status, _) = r.Route("/nope");
Assert.Equal(HttpStatusCode.NotFound, status);
}
[Fact]
public void PortResolver_EnvVarSet_ReturnsEnvPort()
{
var p = PortResolver.Resolve(_ => "45000");
Assert.Equal(45000, p);
}
[Fact]
public void PortResolver_EnvVarMissing_ReturnsDefault()
{
var p = PortResolver.Resolve(_ => null);
Assert.Equal(PortResolver.DefaultPort, p);
}
}

View File

@@ -0,0 +1,72 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Recordingtest.EngineBridge.IntegrationTests;
public sealed class FakeBridgeServer : IDisposable
{
public Dictionary<string, string> Responses { get; } = new();
public TimeSpan ResponseDelay { get; set; } = TimeSpan.Zero;
private readonly HttpListener _listener;
private readonly Thread _thread;
private volatile bool _stop;
public int Port { get; }
public string BaseUrl => $"http://localhost:{Port}";
public FakeBridgeServer()
{
Port = FindFreePort();
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{Port}/");
_listener.Start();
_thread = new Thread(Loop) { IsBackground = true };
_thread.Start();
}
private static int FindFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
var p = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return p;
}
private void Loop()
{
while (!_stop && _listener.IsListening)
{
HttpListenerContext ctx;
try { ctx = _listener.GetContext(); }
catch { return; }
try
{
if (ResponseDelay > TimeSpan.Zero) Thread.Sleep(ResponseDelay);
var path = ctx.Request.Url?.AbsolutePath ?? "/";
if (Responses.TryGetValue(path, out var body))
{
var bytes = Encoding.UTF8.GetBytes(body);
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/json";
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
}
else
{
ctx.Response.StatusCode = 404;
}
ctx.Response.OutputStream.Close();
}
catch { try { ctx.Response.Abort(); } catch { } }
}
}
public void Dispose()
{
_stop = true;
try { _listener.Stop(); } catch { }
try { _listener.Close(); } catch { }
}
}

View File

@@ -0,0 +1,66 @@
using Recordingtest.EngineBridge.Client;
using Xunit;
namespace Recordingtest.EngineBridge.IntegrationTests;
public class HmEgHttpSnapshotTests
{
[Fact]
public void Client_SelectionEndpoint_ReturnsIds()
{
using var srv = new FakeBridgeServer();
srv.Responses["/selection"] = "{\"selected_ids\":[\"a\",\"b\"]}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
Assert.Equal(new[] { "a", "b" }, c.SelectedObjectIds);
}
[Fact]
public void Client_CameraEndpoint_ReturnsCameraState()
{
using var srv = new FakeBridgeServer();
srv.Responses["/camera"] = "{\"eye\":[1,2,3],\"target\":[4,5,6],\"up\":[0,0,1],\"fov\":50}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
var cam = c.Camera;
Assert.Equal(new double[] { 1, 2, 3 }, cam.EyePoint);
Assert.Equal(new double[] { 4, 5, 6 }, cam.Target);
Assert.Equal(50, cam.Fov);
}
[Fact]
public void Client_SceneEndpoint_ReturnsSceneSummary()
{
using var srv = new FakeBridgeServer();
srv.Responses["/scene"] = "{\"object_count\":42,\"document_path\":\"C:/m.hmeg\"}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
var s = c.Scene;
Assert.Equal(42, s.ObjectCount);
Assert.Equal("C:/m.hmeg", s.DocumentPath);
}
[Fact]
public void Client_RenderEndpoint_ReturnsIsComplete()
{
using var srv = new FakeBridgeServer();
srv.Responses["/render"] = "{\"complete\":true}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
Assert.True(c.IsRenderComplete);
}
[Fact]
public void Client_HealthEndpoint_ReturnsOk()
{
using var srv = new FakeBridgeServer();
srv.Responses["/health"] = "{\"status\":\"ok\",\"port\":1}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl);
Assert.True(c.IsHealthy);
}
[Fact]
public void Client_Timeout_ThrowsEngineBridgeException()
{
using var srv = new FakeBridgeServer { ResponseDelay = TimeSpan.FromSeconds(5) };
srv.Responses["/selection"] = "{\"selected_ids\":[]}";
using var c = new HmEgHttpSnapshot(srv.BaseUrl, timeout: TimeSpan.FromMilliseconds(500));
Assert.Throws<EngineBridgeException>(() => _ = c.SelectedObjectIds);
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<RootNamespace>Recordingtest.EngineBridge.IntegrationTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Recordingtest.EngineBridge.Client\Recordingtest.EngineBridge.Client.csproj" />
</ItemGroup>
</Project>