diff --git a/docs/decisions.md b/docs/decisions.md index 18f38f3..1e4469f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -224,3 +224,64 @@ in the roadmap owns that PR. Until it lands, the Wave 2 JSON importer falls back to structural sniff. When this ADR is accepted, restate it as "Accepted" with the date. + +## ADR-0007: Studio-local HTTP API rather than shared library or cloud service + +**Status:** Accepted, 2026-05-14. + +**Context.** Both Unity and Studio need to turn an OpenApparatus JSON +environment into a glTF binary. The Studio repo already has a +`GltfExporter`; Unity does not. Three delivery options: + +1. **Shared library.** Multi-target `OpenApparatus.IO` to + netstandard2.1, ship the DLL with the Unity package, both consumers + link against the same code in-process. +2. **Cloud HTTP API.** Host the converter on a public endpoint; Unity + and Studio both POST JSON, download GLB. +3. **Studio-local HTTP API.** Studio embeds a tiny `HttpListener` bound + to `127.0.0.1`. Unity discovers the port via a JSON file Studio + writes at startup, then makes ordinary HTTP calls. Same machine, + loopback only. + +**Decision.** Option 3 — Studio-local HTTP API. Contract documented in +[studio-api-contract.md](studio-api-contract.md). + +**Why.** Research workstations are single-user, often air-gapped or on +flaky lab WiFi, frequently subject to IRB / DSGVO constraints that +prohibit uploading participant data to third-party services. The +natural workflow (export from Studio, import into Unity) implies +Studio is already running when Unity needs to call the converter, so +"Studio must be running" isn't a new requirement. Loopback transport +eliminates the cloud option's hosting cost, latency, auth, and privacy +costs without inheriting the shared-library option's multi-targeting +and DLL distribution burden. + +**Why not option 1 (shared library).** Forces `OpenApparatus.IO` to +multi-target net8 and netstandard2.1 and audit every dependency for +netstandard2.1 compatibility. Tightly couples Unity package versions +to Core versions. Solves a real problem (in-process speed) but at a +high coordination cost across three repos. + +**Why not option 2 (cloud).** Requires hosting infrastructure, auth, +rate limiting, monitoring; introduces a network dependency for a +fundamentally offline workflow; raises ethics-review questions about +participant data leaving the workstation. A cloud service can be +layered on top of the Studio-local API later if batch processing or +remote collaboration becomes a real need — both share the same +endpoint contract, only the transport address differs. + +**Consequences.** +- Studio must implement a small HTTP server. Trivial via `HttpListener` + (no external dependencies). +- Unity must implement a discovery-file reader and HTTP client. Also + trivial via BCL `HttpClient`. +- When Studio isn't running, Unity falls back to a "please launch + Studio" prompt. Less elegant than in-process but matches the + workflow. +- The contract is the single source of truth across both repos. + Breaking changes require coordinated `/v1/` → `/v2/` migration. +- A hosted cloud variant of the same API is a future option, not a + blocker. The endpoint contract is identical; only the transport + address differs. Adding it would slot in as a `RemoteGltfConverter` + alongside today's `StudioGltfConverter` without breaking either + consumer. diff --git a/docs/roadmap.md b/docs/roadmap.md index e680531..d0584e0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,19 +6,22 @@ fully-componented environment into a scene. ## Wave structure -Three waves. Wave 1 is the foundation every importer depends on; Wave -2 runs as five parallel agents off the same base; Wave 3 follows once -the Wave 2 patterns settle. +Four waves. Wave 1 is the foundation every importer depends on; Wave +2 runs as parallel agents off the same base; Wave 3 follows once the +Wave 2 patterns settle. Wave 4 is the Studio-local API, additive and +optional — useful but not required for the import pipeline. ``` -Wave 1 (foundation, parallel) Wave 2 (parallel) Wave 3 -───────────────────────────── ──────────────── ────── - -F1 Core DLL staging ──┐ A JSON importer ──┐ - │ B glTF postproc ──┤ -F2 Shared infra ──┼──▶ main ──▶ D Samples ─┼──▶ main ──▶ C .oapp importer - │ E Tests skeleton ─┤ -F3 Render pipelines ──┘ G Studio JSON v4 ──┘ polish + integration tests +Wave 1 (foundation) Wave 2 (parallel) Wave 3 Wave 4 (cross-repo) +────────────────── ───────────────── ────── ─────────────────── + +F1 Core DLL ──┐ A JSON importer ──┐ + │ B glTF postproc ──┤ +F2 Shared ─┼─▶ main ─▶ D Samples ──┤ K Studio API server ─┐ + │ E Tests ─┼─▶ main ─▶ C .oapp ─┤ ├─▶ main +F3 Pipelines ─┘ G Studio JSON v4 ──┤ H polish L Unity API client ─┘ + I Colliders ──┤ + J Prefab swap ──┘ ``` Wave 1 tasks are independent of each other and can run in parallel @@ -29,6 +32,23 @@ Wave 2 tasks are independent of each other and merge into `main` in any order. Task C (`.oapp` importer) is deferred to Wave 3 so it can mirror task A's settled patterns rather than diverge. +Wave 4 tasks (K and L) live in different repos (`openapparatus-studio` +and `openapparatus-unity`). Both depend only on +[studio-api-contract.md](studio-api-contract.md) being merged; they +can run as parallel agents in their respective repos. + +Wave 4 file ownership: + +- K → `openapparatus-studio/src/OpenApparatus.Studio/Api/*.cs`, + `openapparatus-studio/src/OpenApparatus.Studio/Settings/ApiSettings.cs` +- L → `Editor/Api/StudioApi.cs`, `Editor/Api/StudioDiscovery.cs`, + adds an "Export GLB via Studio" button to the existing + `Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs` + +Cross-repo touch-point: [studio-api-contract.md](studio-api-contract.md) +in this repo, mirrored by reference from both implementers. Change +there first; propagate. + ## Status Update the box to `x` when the PR for a task is merged. @@ -47,6 +67,8 @@ Update the box to `x` when the PR for a task is merged. | J — Prefab substitution | 2 | | | [ ] | | C — .oapp project importer | 3 | | | [ ] | | H — Integration tests + polish | 3 | | | [ ] | +| K — Studio API server | 4 | | | [ ] | +| L — Unity Studio-API client | 4 | | | [ ] | ## Parallelisation guidance @@ -96,6 +118,17 @@ Task C depends on task A's settled `JsonEnvironmentImporter` patterns. Run sequentially. Task H runs after C and exercises every importer against a real Studio export. +### Wave 4 — Studio-local API (cross-repo) + +Tasks K (Studio server) and L (Unity client) implement the +[studio-api-contract.md](studio-api-contract.md) on either side of the +loopback transport. Both depend only on the contract being merged — +they can run in parallel agents in separate repos. End-to-end +verification needs both halves; the unit-level work doesn't. + +The motivation and rejected alternatives (shared library, cloud API) +are in [decisions.md § ADR-0007](decisions.md#adr-0007-studio-local-http-api-rather-than-shared-library-or-cloud-service). + ## Definition of done per wave **Wave 1 done when:** @@ -137,6 +170,23 @@ against a real Studio export. - Integration test runs every importer against fixtures generated by Studio CI (downloaded artifact, not committed to repo). +**Wave 4 done when:** + +- Studio writes a valid discovery file on launch matching + [studio-api-contract.md](studio-api-contract.md) and deletes it on + graceful shutdown. +- `GET /v1/health` on the bound port returns the documented schema. +- `POST /v1/convert` with a valid JSON body returns a parseable + `.glb`; error responses match the documented codes (400 / 422 / 500). +- Unity's `MultiRoomEnvironmentAsset` inspector exposes an + **Export GLB via Studio** button. +- Clicking the button with Studio running produces a sibling `.glb` + that the existing glTF postprocessor picks up. +- Clicking the button with Studio not running surfaces a clear + user-facing dialog and no console exception. +- Stale discovery file is detected (PID liveness + health probe) and + deleted automatically. + ## Estimated effort Per task. Ballpark only; this is one developer's working-day estimate @@ -156,13 +206,16 @@ based on the brief, not a contractual commitment. | I | 0.5 day | | J | 1 day | | H | 1 day | +| K | 1 day (cross-repo, in openapparatus-studio) | +| L | 1 day | Wave 1 wall-clock: ~1.5 days (with three parallel agents on the longest task). Wave 2 wall-clock: ~2 days (A is still the critical path; I and J are shorter and fit within it). Wave 3 wall-clock: ~3 days sequential. -Total wall-clock with full parallelisation: ~6–7 days. Total -agent-days of work: ~13.5. +Total wall-clock with full parallelisation: ~6–7 days through Wave 3. +Wave 4 adds ~1 day wall-clock (two parallel agents in two repos) and +~2 agent-days. Total agent-days of work: ~15.5. ## Cross-cutting concerns diff --git a/docs/studio-api-contract.md b/docs/studio-api-contract.md new file mode 100644 index 0000000..6c3390f --- /dev/null +++ b/docs/studio-api-contract.md @@ -0,0 +1,225 @@ +# Studio local API contract + +The single source of truth for the localhost HTTP API that Studio hosts +and Unity consumes. Studio implements; Unity calls. The contract +changes here first. + +The motivation, the alternatives considered, and the boundary against a +cloud API live in +[decisions.md § ADR-0007](decisions.md#adr-0007-studio-local-http-api-rather-than-shared-library-or-cloud-service). + +## Transport + +- Bind address: `127.0.0.1` only. **Never** `0.0.0.0`. Other machines on + the LAN must not see this server. +- Protocol: HTTP/1.1, no TLS. Cleartext is acceptable because the + transport never leaves loopback. +- Port: OS-assigned at Studio startup (request port 0; the kernel picks + a free one). Hard-coded ports invite collisions; the discovery file + exists precisely so the port doesn't have to be fixed. + +## Discovery + +Studio writes a single JSON file at startup that tells Unity where to +find the server. Unity reads it before every call (cheap; the file is +small and the OS caches it). + +### File location + +| OS | Path | +|---|---| +| Windows | `%APPDATA%\OpenApparatus\api.json` | +| macOS | `~/Library/Application Support/OpenApparatus/api.json` | +| Linux | `$XDG_RUNTIME_DIR/openapparatus/api.json` (fall back to `~/.config/openapparatus/api.json` if `XDG_RUNTIME_DIR` is unset) | + +### File schema + +```json +{ + "schema": "openapparatus.studio.api", + "version": "1.0", + "port": 47823, + "pid": 18421, + "studioVersion": "2.3.1", + "started": "2026-05-14T18:42:11Z", + "capabilities": ["convert", "health"] +} +``` + +| Field | Type | Notes | +|---|---|---| +| `schema` | string | Always `openapparatus.studio.api`. Discriminator against unrelated `api.json` files. | +| `version` | string | Contract version (this document). Major bumps mean breaking changes; minor adds capabilities. | +| `port` | int | TCP port Studio is listening on. | +| `pid` | int | Studio's process ID. Used by Unity to detect stale discovery files. | +| `studioVersion` | string | Studio's user-visible version. Surface this in Unity error messages. | +| `started` | string (RFC 3339) | When Studio bound the listener. Diagnostic only. | +| `capabilities` | string[] | Which endpoints the server implements. Future versions may omit features; clients consult this before calling. | + +### Write semantics + +Studio must write the discovery file **atomically**: write to +`api.json.tmp` in the same directory, then `rename()` to `api.json`. +Readers (Unity) must never see a half-written file. + +Studio deletes the discovery file on graceful shutdown. After a crash +the file is stale; Unity detects that by checking the `pid` is still +alive (and that the process is actually Studio, not a recycled PID — a +liveness check via `GET /v1/health` is the canonical proof). + +### Stale-file detection (client side) + +Unity treats a discovery file as stale if any of the following: + +1. `pid` does not correspond to a running process. +2. `GET /v1/health` fails or times out within 1s. +3. The `studioVersion` returned by `/v1/health` doesn't match the file. + +Stale files are deleted by the first Unity client that notices, with a +warning logged. + +## Endpoints + +All endpoints are versioned in the URL path (`/v1/...`). Backwards +incompatible changes increment the prefix; the previous version remains +served for at least one minor Studio release. + +### `GET /v1/health` + +Liveness + capability probe. Cheap, no work performed. + +**Response 200:** + +```json +{ + "schema": "openapparatus.studio.api", + "version": "1.0", + "studioVersion": "2.3.1", + "capabilities": ["convert", "health"], + "uptimeSeconds": 1832 +} +``` + +Unity calls this before any other request and considers a non-200 +response or a timeout as "Studio not available." + +### `POST /v1/convert` + +Convert an OpenApparatus JSON environment to a glTF binary (`.glb`). + +**Request:** + +| Header | Value | +|---|---| +| `Content-Type` | `application/json` | +| `Accept` | `model/gltf-binary` | +| `X-Request-Id` | optional; client-generated UUID for log correlation | + +Body: the full JSON document as exported by Studio's `JsonExporter` +(schema documented in +[format-contracts.md § JSON schema v3](format-contracts.md#json-schema-v3---semantic-environment)). + +Optional query parameters: + +| Param | Type | Default | Effect | +|---|---|---|---| +| `includeObjects` | `true`/`false` | `true` | Emit object slot instances | +| `includeMaterials` | `true`/`false` | `true` | Emit per-room materials with Studio naming convention | +| `wallThickness` | float | from JSON `parameters` | Override wall thickness for the build | +| `wallHeight` | float | from JSON `parameters` | Override wall height for the build | + +**Response 200:** + +| Header | Value | +|---|---| +| `Content-Type` | `model/gltf-binary` | +| `X-OpenApparatus-Version` | converter version (e.g. `2.3.1`) | +| `X-Build-Duration-Ms` | server-side build time | + +Body: raw `.glb` bytes. + +**Response 400 (malformed JSON):** + +```json +{ "error": "invalid_request", "message": "JSON parse failed at line 12 column 4" } +``` + +**Response 422 (valid JSON but invalid environment):** + +```json +{ "error": "invalid_environment", "message": "Room 3 has no walls" } +``` + +**Response 500 (converter exception):** + +```json +{ "error": "internal", "message": "Wall assembler exception: ...", "requestId": "..." } +``` + +The server logs the full stack trace; the response carries the +correlation ID so users can attach it to bug reports. + +### Future endpoints (not implemented in v1) + +Reserved for clarity; clients should not depend on these existing: + +- `POST /v1/validate` — return validation errors without building geometry +- `POST /v1/preview` — return a low-poly preview faster than a full build +- `GET /v1/converter-info` — supported features matrix + +## Versioning + +Two version numbers travel together: + +- **Contract version** (this document): `version` field in the + discovery file. `1.0` today. Major bumps for breaking changes. +- **Studio version**: `studioVersion` field. Just the user-visible app + version. Useful for bug reports but not for routing. + +Backwards compatibility policy: + +- A `/v1/...` URL prefix never breaks. To make a breaking change, add + `/v2/...`. Both prefixes can be served simultaneously. +- New optional fields in request/response bodies are allowed within a + major version. Clients must ignore unknown fields. +- Removing or renaming fields requires a new prefix. + +## Security model + +Bound to loopback only. Trust is established by the fact that another +process on the same machine wrote the discovery file under the user's +permissions. There is no authentication header; anyone on the local +machine running as the same user can call the API. + +This is intentional. A research workstation has one user, and that +user already has full filesystem access. Adding tokens would be +theatre. + +If the threat model ever changes (multi-user research servers, remote +collaboration), add an `X-Api-Key` header derived from a random token +written into the discovery file. Clients read both the port and the +token from the file; without filesystem read access there's no way to +make calls. This is a one-screen change in both client and server when +needed. + +## Cross-platform notes + +- The discovery file path uses OS-conventional locations. Both Studio + and Unity resolve them via the same helper to guarantee they agree. +- File locking is not used. Atomic rename is sufficient on all three + major OSes. The file is written infrequently (once at startup, once + at shutdown). +- `HttpListener` (BCL) is sufficient for Studio's server. ASP.NET Core + is overkill. The Unity client uses `System.Net.Http.HttpClient`. + +## Changing this document + +Workflow when adding a capability: + +1. Edit this file with the new endpoint shape. +2. Update both consumer task briefs. +3. Bump the contract `version` if breaking; leave it if additive. +4. Update `openapparatus-studio`'s server first. +5. Update `openapparatus-unity`'s client second. + +Reading without writing first (or vice versa) breaks the contract. diff --git a/docs/tasks/K-studio-api-server.md b/docs/tasks/K-studio-api-server.md new file mode 100644 index 0000000..ba623a9 --- /dev/null +++ b/docs/tasks/K-studio-api-server.md @@ -0,0 +1,254 @@ +# Task K — Studio local API server + +| Field | Value | +|---|---| +| Wave | 4 (Studio-local API) | +| Depends on | [studio-api-contract.md](../studio-api-contract.md) merged | +| Blocks | L (Unity client) | +| Effort | 1 day | +| Repo | **openapparatus-studio** (cross-repo) | +| Files touched | `src/OpenApparatus.Studio/Api/*.cs`, `src/OpenApparatus.Studio/Settings/ApiSettings.cs`, settings UI | + +## Goal + +Studio hosts an HTTP server bound to `127.0.0.1` and writes a +discovery file so other local processes (Unity, future tools) can +convert JSON environments to `.glb` via a documented endpoint. Lives +inside the existing Studio process; lifecycle tied to the app. + +## Context + +The motivation, alternatives, and trade-offs are in +[decisions.md § ADR-0007](../decisions.md#adr-0007-studio-local-http-api-rather-than-shared-library-or-cloud-service). +The endpoint contract — discovery file format, request/response +schemas, error codes, versioning rules — is in +[studio-api-contract.md](../studio-api-contract.md). This task +implements that contract on the Studio side. + +Studio already has `OpenApparatus.IO.GltfExporter` (the converter +this server wraps). The server is glue: HTTP listener + request +dispatch + discovery file. No converter logic lives here. + +## Inputs + +- Read: [studio-api-contract.md](../studio-api-contract.md) in full. + This is the spec. +- Read: `src/OpenApparatus.IO/Exporters/GltfExporter.cs` — + the converter the server invokes. +- Read: `src/OpenApparatus.IO/Exporters/JsonExporter.cs` for the JSON + schema the server accepts. +- Honour: [decisions.md § ADR-0007](../decisions.md#adr-0007-studio-local-http-api-rather-than-shared-library-or-cloud-service). + +## Outputs + +### Server module + +``` +src/OpenApparatus.Studio/ +└── Api/ + ├── ApiServer.cs ← HttpListener bind, request loop, dispatch + ├── ApiEndpoints.cs ← /v1/health, /v1/convert handlers + ├── DiscoveryFile.cs ← atomic write, OS-conventional path + └── ApiServerLifecycle.cs ← start at app launch, stop at shutdown +``` + +`ApiServer.Start()` returns the bound port. Bind to +`http://127.0.0.1:0/` so the OS assigns a free port; pass that port to +`DiscoveryFile.Write(...)`. + +`ApiServer.Stop()` stops the listener and calls `DiscoveryFile.Delete()`. +Graceful shutdown only — a crash leaves the file stale, which is fine +(Unity detects staleness via PID liveness + health probe). + +### Endpoint handlers + +`POST /v1/convert`: + +```csharp +public static async Task HandleConvert(HttpListenerContext ctx) +{ + using var reader = new StreamReader(ctx.Request.InputStream); + var json = await reader.ReadToEndAsync(); + + var options = ParseQuery(ctx.Request.QueryString); + + byte[] glb; + try + { + glb = GltfPipeline.BuildFromJson(json, options); + } + catch (JsonException ex) + { + WriteError(ctx, 400, "invalid_request", ex.Message); + return; + } + catch (InvalidEnvironmentException ex) + { + WriteError(ctx, 422, "invalid_environment", ex.Message); + return; + } + catch (Exception ex) + { + WriteError(ctx, 500, "internal", ex.Message); + Log.Error(ex, "Convert failed; requestId={Id}", ctx.GetRequestId()); + return; + } + + ctx.Response.ContentType = "model/gltf-binary"; + ctx.Response.Headers.Add("X-OpenApparatus-Version", StudioVersion); + await ctx.Response.OutputStream.WriteAsync(glb, 0, glb.Length); + ctx.Response.Close(); +} +``` + +`GET /v1/health` returns the schema+version+capabilities JSON +documented in the contract. No work; runs in microseconds. + +### Wrapper around the existing exporter + +```csharp +public static class GltfPipeline +{ + public static byte[] BuildFromJson(string json, ConvertOptions options) + { + var doc = JsonSerializer.Deserialize(json); + var plan = EnvironmentBuilder.FromDocument(doc); + var glb = new GltfExporter().BuildBinary(plan, + options.WallThickness ?? doc.Parameters.WallThickness, + options.WallHeight ?? doc.Parameters.WallHeight, + options.IncludeObjects, + options.IncludeMaterials); + return glb; + } +} +``` + +If `GltfExporter` doesn't already have a `BuildBinary` overload that +returns `byte[]`, add one — it's a public-API extension, not a +behaviour change. Reuse the existing file-writing path internally. + +### Discovery file + +```csharp +public static class DiscoveryFile +{ + public static string Path { get; } = ResolvePathPerOs(); + + public static void Write(int port, int pid, string studioVersion) + { + var payload = new + { + schema = "openapparatus.studio.api", + version = "1.0", + port, + pid, + studioVersion, + started = DateTimeOffset.UtcNow.ToString("O"), + capabilities = new[] { "convert", "health" }, + }; + var json = JsonSerializer.Serialize(payload); + var tmp = Path + ".tmp"; + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); + File.WriteAllText(tmp, json); + File.Move(tmp, Path, overwrite: true); + } + + public static void Delete() + { + try { File.Delete(Path); } catch (IOException) { } + } + + static string ResolvePathPerOs() { /* see contract doc for the table */ } +} +``` + +Atomic write (write to `.tmp`, rename) is **required**. Readers must +never see a half-written file. + +### Settings + +``` +src/OpenApparatus.Studio/ +└── Settings/ + └── ApiSettings.cs +``` + +User-facing settings persisted alongside other Studio preferences: + +- `EnableApi` (bool, default `true`) — turn the server off entirely + for users who don't want it. +- `BindPort` (int?, default null) — manual port override. Null means + OS-assigned. Surfaced for users behind paranoid firewall rules. + +UI: one section in Studio's preferences pane, two controls. Changes +take effect on next Studio launch (don't bother with hot-restart +plumbing). + +### Lifecycle wiring + +`ApiServerLifecycle`: + +- Subscribed to Studio's app-startup event: if `Settings.EnableApi`, + call `ApiServer.Start(Settings.BindPort)` and + `DiscoveryFile.Write(...)`. +- Subscribed to Studio's app-shutdown event: call `ApiServer.Stop()`, + which itself deletes the discovery file. +- Logs the bound port on startup so users can see "API listening on + 127.0.0.1:47823" in the console / log file. + +## Acceptance criteria + +- Studio launches with `EnableApi = true`; discovery file appears at + the OS-conventional path within 1s. +- `GET http://127.0.0.1:{port}/v1/health` returns a 200 with the + documented schema. +- `POST http://127.0.0.1:{port}/v1/convert` with a valid Studio JSON + body returns 200 + a parseable `.glb`. +- `POST /v1/convert` with malformed JSON returns 400 with + `{ "error": "invalid_request", ... }`. +- `POST /v1/convert` with valid JSON describing an impossible + environment returns 422 with `{ "error": "invalid_environment", ... }`. +- Studio shuts down gracefully; discovery file is deleted. +- `EnableApi = false` prevents the server starting; discovery file is + not written. + +## Out of scope + +- Authentication. Loopback trust model documented in the contract. +- `/v1/validate`, `/v1/preview`, batch endpoints. Future work. +- Hot-restart on settings change. Restart Studio. +- Cross-process locks on the discovery file. Atomic rename is enough. +- TLS. Cleartext is correct on loopback. +- A cloud-hosted variant of the same API. Future ADR if needed. + +## Verification checklist + +``` +Verified by me: +- Launched Studio; discovery file appears at the correct OS path. + [how: tailed the file path during launch] +- curl localhost:{port}/v1/health returns documented JSON. +- curl -X POST -d @sample.json localhost:{port}/v1/convert > out.glb + produces a glb that loads in Blender/three.js. +- Killed Studio with kill -9; discovery file is left stale; Unity + detects this via the health probe (see task L). +- Studio launched twice in succession (after graceful shutdown of the + first); second instance picks a different OS-assigned port; discovery + file reflects the new port. + +Needs your eyes: +- Whether GltfExporter has thread-safety issues if multiple requests + arrive simultaneously. I serialised dispatch with a SemaphoreSlim; + confirm that's necessary or remove if the exporter is already safe. +- Logging volume. I log every request at Info; some users may want + Debug-only. Pick the right default. + +Decisions baked in: +- HttpListener (BCL) not ASP.NET Core. The endpoint surface is tiny; + no DI, no middleware, no routing framework needed. +- OS-assigned port by default. Manual override in settings for users + with firewall constraints. +- Atomic write via tempfile + rename. Documented in the contract. +- Graceful shutdown deletes the discovery file; crash leaves it stale. + Clients detect staleness via PID + health probe. +``` diff --git a/docs/tasks/L-studio-api-client.md b/docs/tasks/L-studio-api-client.md new file mode 100644 index 0000000..2b50ba1 --- /dev/null +++ b/docs/tasks/L-studio-api-client.md @@ -0,0 +1,284 @@ +# Task L — Unity Studio-API client + +| Field | Value | +|---|---| +| Wave | 4 (Studio-local API) | +| Depends on | [studio-api-contract.md](../studio-api-contract.md) merged; K (Studio server) for end-to-end testing but not for the client implementation itself | +| Blocks | nothing | +| Effort | 1 day | +| Repo | openapparatus-unity | +| Files touched | `Editor/Api/StudioApi.cs`, `Editor/Api/StudioDiscovery.cs`, `Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs`, `Tests/Editor/StudioDiscoveryTests.cs` | + +## Goal + +`MultiRoomEnvironmentAsset` gains an **Export GLB via Studio** button. +On click, the editor reads the Studio discovery file, POSTs the asset's +JSON to Studio's local API, writes the returned GLB next to the source +JSON, and triggers an asset reimport so the companion `.glb` is picked +up by the existing glTF postprocessor (task B). + +## Context + +The motivation, alternatives, and trade-offs are in +[decisions.md § ADR-0007](../decisions.md#adr-0007-studio-local-http-api-rather-than-shared-library-or-cloud-service). +The endpoint contract — discovery file format, request/response +schemas, error codes — is in +[studio-api-contract.md](../studio-api-contract.md). This task +implements the client side. + +Task A (JSON importer) populates the `MultiRoomEnvironmentAsset`'s +topology but produces only minimal geometry via +`JsonGeometryBuilder` (rectangular prisms, no door cutouts). Calling +Studio's converter yields the same high-fidelity geometry the existing +glTF postprocessor (task B) consumes. The pair JSON + GLB then lives +side-by-side per the workflow documented in +[architecture.md § scene representation](../architecture.md#scene-representation-pattern). + +## Inputs + +- Read: [studio-api-contract.md](../studio-api-contract.md) in full. +- Read: Task A's [JsonEnvironmentImporter](../../Editor/Importers/JsonEnvironmentImporter.cs) + to know what JSON the asset was imported from. The original text is + reloaded from disk (the asset path is stored on + `ScriptedImporter.assetPath`). +- Read: Task B's [GltfEnvironmentPostprocessor](../../Editor/Importers/GltfEnvironmentPostprocessor.cs) + to know what file name pattern triggers it. + +## Outputs + +### Discovery reader + +``` +Editor/ +└── Api/ + └── StudioDiscovery.cs +``` + +```csharp +public sealed class StudioDiscoveryInfo +{ + public string Schema; + public string Version; + public int Port; + public int Pid; + public string StudioVersion; + public string[] Capabilities; + public DateTimeOffset Started; +} + +public static class StudioDiscovery +{ + public static string FilePath => ResolvePerOs(); + + public static StudioDiscoveryInfo Read() + { + if (!File.Exists(FilePath)) return null; + try + { + var json = File.ReadAllText(FilePath); + var info = JsonConvert.DeserializeObject(json); + if (info?.Schema != "openapparatus.studio.api") return null; + return info; + } + catch (Exception) { return null; } + } + + public static bool IsStale(StudioDiscoveryInfo info) + { + if (info == null) return true; + try { Process.GetProcessById(info.Pid); return false; } + catch (ArgumentException) { return true; } + } + + public static void DeleteStale() + { + try { File.Delete(FilePath); } catch (IOException) { } + } +} +``` + +OS path resolution mirrors the table in the contract. Use +`Environment.SpecialFolder.ApplicationData` on Windows, +`~/Library/Application Support/` on macOS, +`$XDG_RUNTIME_DIR ?? ~/.config/` on Linux. One helper, called from +both `FilePath` and tests. + +### HTTP client + +``` +Editor/ +└── Api/ + └── StudioApi.cs +``` + +```csharp +public static class StudioApi +{ + static readonly HttpClient Http = new HttpClient + { + Timeout = TimeSpan.FromMinutes(2), + }; + + public sealed class StudioUnavailableException : Exception { ... } + public sealed class StudioConvertException : Exception + { + public string ErrorCode { get; } + public string Message { get; } + } + + public static async Task IsAvailableAsync( + StudioDiscoveryInfo info, CancellationToken ct) + { + try + { + var resp = await Http.GetAsync( + $"http://127.0.0.1:{info.Port}/v1/health", ct); + return resp.IsSuccessStatusCode; + } + catch (HttpRequestException) { return false; } + catch (TaskCanceledException) { return false; } + } + + public static async Task ConvertJsonToGlbAsync( + string json, CancellationToken ct) + { + var info = StudioDiscovery.Read(); + if (StudioDiscovery.IsStale(info)) + { + StudioDiscovery.DeleteStale(); + throw new StudioUnavailableException( + "OpenApparatus Studio is not running. Launch Studio and try again."); + } + + if (!await IsAvailableAsync(info, ct)) + { + throw new StudioUnavailableException( + "Studio discovery file is stale; the API did not respond. " + + "Restart Studio and try again."); + } + + using var body = new StringContent(json, Encoding.UTF8, "application/json"); + using var resp = await Http.PostAsync( + $"http://127.0.0.1:{info.Port}/v1/convert", body, ct); + + if (!resp.IsSuccessStatusCode) + throw await BuildConvertException(resp, ct); + + return await resp.Content.ReadAsByteArrayAsync(); + } + + static async Task BuildConvertException( + HttpResponseMessage resp, CancellationToken ct) { /* parse JSON error body */ } +} +``` + +### Inspector button + +Augment [MultiRoomEnvironmentAssetEditor](../../Editor/Inspectors/MultiRoomEnvironmentAssetEditor.cs) +(from task A) with an "Export GLB via Studio" button below the +existing "Spawn into scene" button. + +```csharp +if (GUILayout.Button("Export GLB via Studio")) + EditorCoroutineUtility.StartCoroutineOwnerless(ExportGlbCoroutine(asset)); +``` + +The coroutine: + +1. Reads the source JSON from disk: + `File.ReadAllText(AssetDatabase.GetAssetPath(asset))`. +2. Calls `StudioApi.ConvertJsonToGlbAsync(json, ct)`. +3. On success: writes the GLB to `.glb`, + calls `AssetDatabase.ImportAsset(glbPath)` to trigger + the glTF postprocessor. +4. On `StudioUnavailableException`: shows a dialog with the message and + a "Launch Studio" button (if Studio install path is known) or just + "OK" otherwise. +5. On `StudioConvertException`: shows the error code + message in a + dialog. Logs the full payload to the console. + +Show a `EditorUtility.DisplayProgressBar` during the call; the +converter for a large environment can take a few seconds. + +### Tests + +``` +Tests/Editor/ +└── StudioDiscoveryTests.cs +``` + +The discovery reader is pure I/O over a JSON file; testable without a +real Studio process: + +- **Reads valid file:** write a discovery JSON to a temp path, point + `StudioDiscovery.FilePath` at it via a test seam (an internal + property setter), assert the parsed `StudioDiscoveryInfo` matches. +- **Rejects foreign JSON:** write `{ "schema": "something-else" }`, + assert `Read()` returns null. +- **Rejects malformed file:** write `{ not json`, assert `Read()` + returns null without throwing. +- **IsStale returns true for dead PID:** point at a known-dead PID + (use `int.MaxValue` or fork+wait a short-lived process), assert true. +- **IsStale returns false for live PID:** use `Process.GetCurrentProcess().Id`, + assert false. + +End-to-end test (Studio round-trip) is manual until task K lands; +document the manual procedure in the verification checklist. + +## Acceptance criteria + +- Studio not running: clicking the button shows a clear "Studio is not + running" dialog. No stack trace surfaces in the console. +- Studio running, valid asset: clicking the button produces a `.glb` + next to the source `.json`, triggers a reimport, and the existing + glTF postprocessor attaches `Room` and `Wall` components. +- Stale discovery file: client detects it, deletes the file, shows the + same dialog as "Studio not running." +- Server returns 422: dialog shows the server's error message verbatim; + no exception leaks. +- All five `StudioDiscoveryTests` pass. + +## Out of scope + +- Auto-launching Studio. Surfaced as a follow-up via a "Launch Studio" + button in the error dialog only if Studio's install path is known + via a setting or registry hint; default to a "please launch" prompt. +- Spawning the resulting GLB directly into the scene without + AssetDatabase round-trip. Possible future optimisation; today the + re-import path is fine. +- Settings for the Studio host (always `127.0.0.1`). +- A `RemoteGltfConverter` that talks to a cloud endpoint. Future ADR. +- Batch export across multiple `.json` assets. One asset, one click. + +## Verification checklist + +``` +Verified by me: +- All five StudioDiscoveryTests pass. [how: ran Test Runner edit-mode] +- Manual end-to-end: launched Studio, imported single_room.json into a + Unity project, clicked Export GLB via Studio, observed single_room.glb + appear next to it with Room and Wall components attached after + Unity's reimport. [how: hierarchy + asset inspection] +- Closed Studio, clicked the button, got the expected "Studio is not + running" dialog, no exception in console. +- Killed Studio via Task Manager (simulated crash), clicked the button, + got the stale-file dialog, confirmed the discovery file was deleted. + +Needs your eyes: +- Whether the resulting GLB should be marked as a sub-asset of the + MultiRoomEnvironmentAsset (so the two stay coupled) or kept as a + separate top-level asset. I went with separate so existing glTF + postprocessor flow works unchanged. +- Dialog UX. I went with EditorUtility.DisplayDialog (modal); some + workflows prefer a non-blocking toast. +- Whether to memoise the discovery info or re-read on every call. I + re-read for correctness (Studio could have restarted on a new port). + +Decisions baked in: +- HttpClient is shared (one instance, two-minute timeout). Standard + guidance for cross-call connection pooling. +- Re-import triggers via AssetDatabase.ImportAsset; we don't manually + invoke the glTF postprocessor. +- Errors classified as StudioUnavailable vs StudioConvert so the UI can + distinguish "no server" from "server said no." +```