diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
| commit | 7b29dbff7e43e87741476f47df17ad1582b16546 (patch) | |
| tree | 4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 | |
| parent | 47add3088d50f12c5f48591f26c53cdb59d8283b (diff) | |
| download | somhairles-dream-fsharp-7b29dbff7e43e87741476f47df17ad1582b16546.tar.gz | |
feat(pipeline): add runnable artifact pipeline increment
[Change Nature]
- This commit adds the first runnable increment of the F# artifact
pipeline: CLI, server, frontend bundle, stage fixture, and tests.
It is feature work, not a bug fix.
[New Capability]
- CLI `run` executes the three-step Blender fixture through the process
bridge and writes GLBs, step logs, events, and a manifest; `verify`
validates a manifest without Blender.
- Server exposes run start, run-scoped SSE, artifact manifest/file
routes, and serves the Fable frontend from its build output.
- Frontend starts runs, follows run-scoped SSE progress, and links the
artifact manifest on completion.
[Implementation]
- Shared layer holds domain, run state, replay, and artifact contracts;
Modeling holds the pipeline, 120s bridge timeout, and result-file
protocol; stage/blender/fixture.py owns geometry in Python (bpy owns
geometry, F# owns orchestration).
- Fable bundle is regenerated into public/ and copied by the server
project; solution includes all projects with warnings-as-errors.
- README documents build, Fable, server, interpreter/env, fixture
limitations, and current verification status.
[Impact]
- Verified: 40/40 solution tests pass; Release build 0 warnings/errors;
real CLI run with bpy 5.0.1 produced 3 GLBs, 3 logs, and a
verify-clean manifest; isolated-port server smoke flow succeeded.
- Not yet verified: browser end-to-end behavior and production
fidelity. Artifacts and caches are gitignored.
49 files changed, 5824 insertions, 12 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..02cd60b --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fable": { + "version": "4.24.0", + "commands": [ + "fable" + ] + } + } +}
\ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b209f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +bin/ +obj/ +.artifacts/ +node_modules/ +dist/ +.fable/ +.playwright/ +*.user +*.suo +*.blend1 +*.blend2 +__pycache__/ +*.pyc +.venv/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..abb3c50 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ +<Project> + <PropertyGroup> + <Deterministic>true</Deterministic> + <DebugType>portable</DebugType> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> + </PropertyGroup> +</Project> diff --git a/README.md b/README.md new file mode 100644 index 0000000..7059a51 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# somhairles-dream-fsharp + +F# implementation of the heritage boat modeling pipeline: a Blender-driven +artifact pipeline (CLI), an ASP.NET server that runs and observes artifact +runs, and a Fable/Three.js frontend that follows run progress live. + +Architecture boundary: F# owns the frontend, backend, and orchestration +(pipeline, artifacts, verification, serving). Python (`bpy`) owns geometry +generation inside `stage/blender/fixture.py`. There is no F# geometry DSL. + +## Layout + +- `src/SomhairlesDream.Shared` — domain, run state, replay, artifact contracts +- `src/SomhairlesDream.Modeling` — pipeline, Blender process bridge, verifier +- `src/SomhairlesDream.Cli` — `run` and `verify` commands +- `src/SomhairlesDream.Server` — run API, SSE, static frontend host +- `src/SomhairlesDream.Frontend` — Fable browser app compiled to `public/` +- `stage/blender/fixture.py` — deterministic bpy fixture (3 steps, 7 objects) +- `public/` — static frontend served by the server (index, styles, JS bundle) +- `tests/` — xUnit test projects per layer + +## Build and test + +Requires .NET SDK 8 (pinned by `global.json`). Warnings are errors. + +```sh +dotnet build SomhairlesDream.sln +dotnet test SomhairlesDream.sln +``` + +## Frontend (Fable) + +`src/SomhairlesDream.Frontend` compiles to `public/`. Regenerate after edits: + +```sh +dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj \ + --outDir public --noRestore +``` + +The server project copies `public/**` into its build output +(`CopyToOutputDirectory=PreserveNewest`), so rerun `dotnet build` on the server +after regenerating the bundle. + +## Server + +```sh +dotnet run --project src/SomhairlesDream.Server +``` + +Environment variables (all optional): + +- `SOMHAIRLES_ARTIFACT_ROOT` — artifact output root (default `.artifacts`) +- `SOMHAIRLES_STAGE_ROOT` — stage directory containing `blender/fixture.py` + (default `stage`) +- `SOMHAIRLES_BPYTHON` — Python interpreter used to run the fixture + (default `python3`) + +Routes used by the frontend: `POST /api/runs/start`, +`GET /api/runs/events?projectId=...&runId=...` (run-scoped SSE), +`GET /api/artifacts/manifest`, `GET /api/artifacts/file`. The server binds to +the ASP.NET default (add `--urls http://127.0.0.1:8099` to match the legacy +port). + +## CLI + +```sh +dotnet run --project src/SomhairlesDream.Cli -- run [options] +dotnet run --project src/SomhairlesDream.Cli -- verify <manifest.json> +``` + +`run` options: `--project-id`, `--run-id`, `--base-id`, `--target-id`, +`--output-root`, `--stage-root`, `--python`, `--render`. `SOMHAIRLES_BPYTHON` +sets the default interpreter. Exit codes: `0` success, `2` usage, `3` bridge +failure, `4` pipeline/verify failure. On success it prints the manifest path. + +A run produces, under `<output-root>/<projectId>/<runId>/`: + +- `events.ndjson`, `manifest.json` +- `logs/01-foundation.log`, `02-frame.log`, `03-cabin.log` +- `steps/01-foundation.glb`, `02-frame.glb`, `03-cabin.glb` + +`verify` recomputes hashes, checks log presence, path containment, object-ID +proofs, timestamps, and duplicate steps without needing Blender. + +## Python interpreter for bpy + +The fixture requires a Python that can `import bpy` (Blender as a module, +version >= 3.0.0). A system `python3` usually cannot. This project was +verified with a uv-managed environment at `/tmp/bpyenv/bin/python` +(`bpy 5.0.1`). Note that `/tmp` is ephemeral; recreate or point +`SOMHAIRLES_BPYTHON`/`--python` at any interpreter that provides `bpy`. + +Example: + +```sh +dotnet run --project src/SomhairlesDream.Cli -- run \ + --output-root .artifacts --python /tmp/bpyenv/bin/python +``` + +## Fixture limitations + +- `stage/blender/fixture.py` is a deterministic placeholder geometry fixture + (named cubes with `object_id` custom properties), not a production model. +- Bridge timeout is 120 seconds per step; the bridge requires the fixture to + write its result JSON to `--result-output` (stdout/stderr are diagnostics + only, retained as step logs). +- Renders are produced only with `--render` (512x512 PNG per step). +- Reimport verification counts objects (`2 -> 5 -> 7` across the three steps) + and checks the `object_id` custom properties survive the GLB round-trip. + +## Verification status + +- Full solution test suite and Release build: clean (0 warnings/errors). +- Real CLI pipeline with `/tmp/bpyenv/bin/python`: 3 GLBs + logs + manifest, + `verify` exit 0. +- Browser end-to-end behavior and production fidelity are NOT yet verified; + the pipeline contract tests cover the generated bundle routes only. diff --git a/SomhairlesDream.sln b/SomhairlesDream.sln new file mode 100644 index 0000000..a27531c --- /dev/null +++ b/SomhairlesDream.sln @@ -0,0 +1,85 @@ +
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{ABD4C035-38CD-4DF7-9C2B-4A2002CDF5EA}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Shared", "src\SomhairlesDream.Shared\SomhairlesDream.Shared.fsproj", "{BDBD45C1-B876-4D37-A463-2B97D2D4D88F}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Modeling", "src\SomhairlesDream.Modeling\SomhairlesDream.Modeling.fsproj", "{C3D7D2A1-2B8D-4F8E-9D4F-4F7B9B1C3A10}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Cli", "src\SomhairlesDream.Cli\SomhairlesDream.Cli.fsproj", "{D4E8E3B2-3C9E-4A9F-AE50-508CAC2D4B21}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Server", "src\SomhairlesDream.Server\SomhairlesDream.Server.fsproj", "{9A678030-F8B1-4054-9BA0-1E91C5D7EAD9}" +EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{51636D46-D344-45BD-BC8F-A3AD990B9299}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Shared.Tests", "tests\SomhairlesDream.Shared.Tests\SomhairlesDream.Shared.Tests.fsproj", "{5BCBFA28-6F29-42DB-AF46-77C7F04176A1}"
+EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Server.Tests", "tests\SomhairlesDream.Server.Tests\SomhairlesDream.Server.Tests.fsproj", "{5F20B3AC-293A-4760-8637-8A0BC5E1BE58}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Modeling.Tests", "tests\SomhairlesDream.Modeling.Tests\SomhairlesDream.Modeling.Tests.fsproj", "{E5F9F4C3-4DAF-4BA0-BF61-619DBD3E5C32}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Cli.Tests", "tests\SomhairlesDream.Cli.Tests\SomhairlesDream.Cli.Tests.fsproj", "{F6A0A5D4-5EB0-4CB1-C072-72AECE4F6D43}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "SomhairlesDream.Frontend", "src\SomhairlesDream.Frontend\SomhairlesDream.Frontend.fsproj", "{09E80DAF-4394-4B4A-842B-07F1126F33F4}" +EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {BDBD45C1-B876-4D37-A463-2B97D2D4D88F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BDBD45C1-B876-4D37-A463-2B97D2D4D88F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BDBD45C1-B876-4D37-A463-2B97D2D4D88F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BDBD45C1-B876-4D37-A463-2B97D2D4D88F}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D7D2A1-2B8D-4F8E-9D4F-4F7B9B1C3A10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D7D2A1-2B8D-4F8E-9D4F-4F7B9B1C3A10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D7D2A1-2B8D-4F8E-9D4F-4F7B9B1C3A10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D7D2A1-2B8D-4F8E-9D4F-4F7B9B1C3A10}.Release|Any CPU.Build.0 = Release|Any CPU + {D4E8E3B2-3C9E-4A9F-AE50-508CAC2D4B21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4E8E3B2-3C9E-4A9F-AE50-508CAC2D4B21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4E8E3B2-3C9E-4A9F-AE50-508CAC2D4B21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4E8E3B2-3C9E-4A9F-AE50-508CAC2D4B21}.Release|Any CPU.Build.0 = Release|Any CPU + {9A678030-F8B1-4054-9BA0-1E91C5D7EAD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A678030-F8B1-4054-9BA0-1E91C5D7EAD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9A678030-F8B1-4054-9BA0-1E91C5D7EAD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9A678030-F8B1-4054-9BA0-1E91C5D7EAD9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5BCBFA28-6F29-42DB-AF46-77C7F04176A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5BCBFA28-6F29-42DB-AF46-77C7F04176A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5BCBFA28-6F29-42DB-AF46-77C7F04176A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5BCBFA28-6F29-42DB-AF46-77C7F04176A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5F20B3AC-293A-4760-8637-8A0BC5E1BE58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5F20B3AC-293A-4760-8637-8A0BC5E1BE58}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F20B3AC-293A-4760-8637-8A0BC5E1BE58}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F20B3AC-293A-4760-8637-8A0BC5E1BE58}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F9F4C3-4DAF-4BA0-BF61-619DBD3E5C32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F9F4C3-4DAF-4BA0-BF61-619DBD3E5C32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F9F4C3-4DAF-4BA0-BF61-619DBD3E5C32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F9F4C3-4DAF-4BA0-BF61-619DBD3E5C32}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A0A5D4-5EB0-4CB1-C072-72AECE4F6D43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A0A5D4-5EB0-4CB1-C072-72AECE4F6D43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A0A5D4-5EB0-4CB1-C072-72AECE4F6D43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A0A5D4-5EB0-4CB1-C072-72AECE4F6D43}.Release|Any CPU.Build.0 = Release|Any CPU + {09E80DAF-4394-4B4A-842B-07F1126F33F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {09E80DAF-4394-4B4A-842B-07F1126F33F4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {09E80DAF-4394-4B4A-842B-07F1126F33F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {09E80DAF-4394-4B4A-842B-07F1126F33F4}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {BDBD45C1-B876-4D37-A463-2B97D2D4D88F} = {ABD4C035-38CD-4DF7-9C2B-4A2002CDF5EA} + {C3D7D2A1-2B8D-4F8E-9D4F-4F7B9B1C3A10} = {ABD4C035-38CD-4DF7-9C2B-4A2002CDF5EA} + {D4E8E3B2-3C9E-4A9F-AE50-508CAC2D4B21} = {ABD4C035-38CD-4DF7-9C2B-4A2002CDF5EA} + {9A678030-F8B1-4054-9BA0-1E91C5D7EAD9} = {ABD4C035-38CD-4DF7-9C2B-4A2002CDF5EA} + {5BCBFA28-6F29-42DB-AF46-77C7F04176A1} = {51636D46-D344-45BD-BC8F-A3AD990B9299}
+ {5F20B3AC-293A-4760-8637-8A0BC5E1BE58} = {51636D46-D344-45BD-BC8F-A3AD990B9299} + {E5F9F4C3-4DAF-4BA0-BF61-619DBD3E5C32} = {51636D46-D344-45BD-BC8F-A3AD990B9299} + {F6A0A5D4-5EB0-4CB1-C072-72AECE4F6D43} = {51636D46-D344-45BD-BC8F-A3AD990B9299} + {09E80DAF-4394-4B4A-842B-07F1126F33F4} = {ABD4C035-38CD-4DF7-9C2B-4A2002CDF5EA}
+ EndGlobalSection
+EndGlobal
diff --git a/docs/superpowers/plans/2026-09-20-artifact-pipeline-implementation.md b/docs/superpowers/plans/2026-09-20-artifact-pipeline-implementation.md new file mode 100644 index 0000000..e1cb4e6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-20-artifact-pipeline-implementation.md @@ -0,0 +1,49 @@ +# Artifact Pipeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the typed artifact pipeline from the shared contract through the Blender CLI, server SSE, and browser manifest link. + +**Architecture:** Keep `SomhairlesDream.Shared` as the wire contract, put deterministic artifact writing and Blender invocation in `SomhairlesDream.Modeling`, expose it through a thin CLI, and have the ASP.NET server run the same pipeline asynchronously. Preserve the existing replay store and UI path. + +**Tech Stack:** F#/.NET 8, ASP.NET Core minimal APIs, `System.Text.Json`, `System.Diagnostics.Process`, xUnit, Blender Python fixture. + +--- + +### Task 1: Align contracts and lifecycle validation + +**Files:** `src/SomhairlesDream.Shared/Artifact.fs`, `src/SomhairlesDream.Modeling/Definitions.fs`, `src/SomhairlesDream.Server/Store.fs`, and the corresponding Shared/Modeling/Server tests. + +- [ ] Add checkpoint `TotalSteps` and serialize it as flat camel-case JSON. +- [ ] Make design object IDs cumulative snapshots and require completion only after all ordered checkpoints. +- [ ] Verify focused tests and keep legacy `RunStore` behavior unchanged. + +### Task 2: Implement the Blender bridge and CLI + +**Files:** `src/SomhairlesDream.Modeling/BlenderBridge.fs`, `src/SomhairlesDream.Cli/Program.fs`, `stage/blender/fixture.py`, project files, and CLI/Modeling tests. + +- [ ] Add a process bridge using `ProcessStartInfo.ArgumentList`, bounded to 120 seconds, with structured result/error handling, an explicit result file, and retained stdout/stderr logs. +- [ ] Add the deterministic `bpy` fixture with step validation, object custom properties, GLB reimport verification, and optional PNG render. +- [ ] Add CLI `run` and `verify` exit-code behavior and test fake-bridge/argument paths. + +### Task 3: Wire server execution and SSE + +**Files:** `src/SomhairlesDream.Server/Program.fs`, `src/SomhairlesDream.Server/Store.fs`, server project/tests. + +- [ ] Add the Modeling reference and launch the same pipeline in a background task. +- [ ] Implement exact-run start, SSE, manifest, artifact, validation, conflict, and 404 responses. +- [ ] Add one-second stale observation without changing lifecycle status. + +### Task 4: Update browser and solution wiring + +**Files:** `src/SomhairlesDream.Frontend/App.fs`, `public/index.html`, `public/App.js`, and `SomhairlesDream.sln`. + +- [ ] Start a selected run, subscribe to its exact SSE stream, and expose `artifact-manifest-link` only on completion. +- [ ] Preserve replay behavior and the existing `Uint32BufferAttribute` mesh path. +- [ ] Include Modeling and CLI projects in the solution. + +### Task 5: Verify and publish + +- [ ] Run all project tests and builds. +- [ ] Run the real `bpy 5.0.1` integration and CLI verification, then server/browser checks. +- [ ] Review status/diff, commit only scoped project files, publish to the authorized bare repository, and verify `git ls-remote` plus cgit. diff --git a/docs/superpowers/specs/2026-09-20-artifact-pipeline-design.md b/docs/superpowers/specs/2026-09-20-artifact-pipeline-design.md index 4fe314c..ab0fd6d 100644 --- a/docs/superpowers/specs/2026-09-20-artifact-pipeline-design.md +++ b/docs/superpowers/specs/2026-09-20-artifact-pipeline-design.md @@ -48,9 +48,9 @@ namespaced IDs such as `heritage.foundation` are valid. `checkpoint`, `heartbeat`, `complete`, or `fail`. - `ArtifactChange`: object ID, change kind, and human-readable summary. - `RenderArtifact`: relative PNG path, SHA-256, byte count, and export time. -- `StepArtifact`: step metadata, immutable relative GLB path, SHA-256, byte - count, actual Blender export timestamp, source object IDs, verified object - IDs from GLB reimport, changes, and optional render. +- `StepArtifact`: step metadata, immutable relative GLB path, retained process + log path, SHA-256, byte count, actual Blender export timestamp, source object + IDs, verified object IDs from GLB reimport, changes, and optional render. - `ArtifactManifest`: schema version, run IDs, lifecycle timestamps, Blender version, ordered step artifacts, and final status. @@ -90,6 +90,10 @@ Each run is isolated at: .artifacts/<projectId>/<runId>/ events.ndjson manifest.json + logs/ + 01-foundation.log + 02-frame.log + 03-cabin.log steps/ 01-foundation.glb 02-frame.glb @@ -111,10 +115,11 @@ status is `complete`. The `Complete` event is appended after the rename; a crash or event-log failure at that point leaves the manifest authoritative and is recovered as complete on the next inspection, without deleting or rewriting it. A manifest is never published if any required step fails before -the commit point. `verify` recomputes all -recorded hashes, checks paths stay inside the run directory, checks the source -and verified object ID sets recorded by the bridge, checks timestamps, and -rejects duplicate step paths. Reimport is performed by the real stage +the commit point. `verify` recomputes all recorded hashes, checks retained +process logs are present and non-empty, checks paths stay inside the run +directory, checks the source and verified object ID sets recorded by the +bridge, checks timestamps, and rejects duplicate step paths. Reimport is +performed by the real stage integration command before the manifest is written; `verify` does not need Blender and validates the recorded proof plus the design's expected ID set. @@ -140,15 +145,18 @@ The bridge protocol is fixed. The F# process invokes: ```text <python> stage/blender/fixture.py --step-id <id> --output <tmp.glb> - [--render-output <tmp.png>] + --result-output <tmp.result.json> [--render-output <tmp.png>] ``` `<python>` comes from `--python` or `SOMHAIRLES_BPYTHON`; the default is `python3`. The script must import `bpy` and report version `>= 3.0.0`. Exit 0 -prints exactly one JSON result object containing `ok`, `blenderVersion`, -`exportedAt`, `objectIds`, `verifiedObjectIds`, `glbBytes`, and optional -`renderPath`/`renderedAt`. Exit 2 means invalid step or arguments, exit 3 -means missing/unsupported `bpy`, and exit 4 means export or render failure. +writes exactly one JSON result object to `--result-output` containing `ok`, +`blenderVersion`, `exportedAt`, `objectIds`, `verifiedObjectIds`, `glbBytes`, +and optional `renderPath`/`renderedAt`. Standard output and standard error are +diagnostic streams only; the bridge retains both in the step log so incidental +Blender output cannot corrupt result parsing. Exit 2 means invalid step or +arguments, exit 3 means missing/unsupported `bpy`, and exit 4 means export or +render failure. The bridge timeout is 120 seconds per step. `exportedAt` is emitted by Python immediately after the successful GLB export; F# rejects a missing or non-UTC timestamp. `verifiedObjectIds` comes from reimporting the just-written GLB and diff --git a/global.json b/global.json new file mode 100644 index 0000000..75ff5a4 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.125", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/public/App.js b/public/App.js new file mode 100644 index 0000000..262025a --- /dev/null +++ b/public/App.js @@ -0,0 +1,392 @@ +import { Record } from "./fable_modules/fable-library-js.4.24.0/Types.js"; +import { class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js"; +import { Replay_frames } from "./SomhairlesDream.Shared/Replay.js"; +import { createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js"; +import { tryFind, item } from "./fable_modules/fable-library-js.4.24.0/Array.js"; +import { isNullOrWhiteSpace, printf, toText } from "./fable_modules/fable-library-js.4.24.0/String.js"; +import { MeshData_triangleIndices, MeshData_positionData } from "./SomhairlesDream.Shared/Domain.js"; +import { min, max } from "./fable_modules/fable-library-js.4.24.0/Double.js"; +import { defaultArg } from "./fable_modules/fable-library-js.4.24.0/Option.js"; +import { awaitPromise, startImmediate } from "./fable_modules/fable-library-js.4.24.0/Async.js"; +import { singleton } from "./fable_modules/fable-library-js.4.24.0/AsyncBuilder.js"; + +export class RunSelection extends Record { + constructor(ProjectId, RunId, BaseId, TargetId, Render) { + super(); + this.ProjectId = ProjectId; + this.RunId = RunId; + this.BaseId = BaseId; + this.TargetId = TargetId; + this.Render = Render; + } +} + +export function RunSelection_$reflection() { + return record_type("SomhairlesDream.Frontend.App.RunSelection", [], RunSelection, () => [["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["Render", bool_type]]); +} + +export class ViewState extends Record { + constructor(Renderer, Scene, Camera, Group, Canvas) { + super(); + this.Renderer = Renderer; + this.Scene = Scene; + this.Camera = Camera; + this.Group = Group; + this.Canvas = Canvas; + } +} + +export function ViewState_$reflection() { + return record_type("SomhairlesDream.Frontend.App.ViewState", [], ViewState, () => [["Renderer", string_type], ["Scene", string_type], ["Camera", string_type], ["Group", string_type], ["Canvas", class_type("Browser.Types.HTMLCanvasElement", undefined)]]); +} + +export const frames = Replay_frames(); + +export let viewState = createAtom(undefined); + +export let selectedFrame = createAtom(item(0, frames)); + +export let replayTimer = createAtom(undefined); + +export let replayIndex = createAtom(0); + +export let eventSource = createAtom(undefined); + +export function element(id) { + return document.getElementById(id); +} + +export function setText(id, value) { + element(id).textContent = value; +} + +export function setWidth(id, value) { + element(id).setAttribute("style", toText(printf("width: %s"))(value)); +} + +export function setStatusClass(status) { + const host = element("status-chip"); + const array = ["idle", "queued", "running", "succeeded", "failed", "stale"]; + array.forEach((name) => { + host.classList.remove(`status-${name}`); + }); + host.classList.add(`status-${status}`); + host.setAttribute("data-status", status); +} + +export function statusName(payload) { + if (payload.isStale) { + return "stale"; + } + else { + const matchValue = payload.status; + let matchResult, status; + switch (matchValue) { + case "complete": { + matchResult = 0; + break; + } + case "idle": { + matchResult = 1; + status = matchValue; + break; + } + case "queued": { + matchResult = 1; + status = matchValue; + break; + } + case "running": { + matchResult = 1; + status = matchValue; + break; + } + case "failed": { + matchResult = 1; + status = matchValue; + break; + } + default: + matchResult = 2; + } + switch (matchResult) { + case 0: + return "succeeded"; + case 1: + return status; + default: + return "unknown"; + } + } +} + +export function statusLabel(payload) { + const matchValue = payload.status; + const matchValue_1 = payload.isStale; + if (matchValue_1) { + return "心跳超时"; + } + else { + switch (matchValue) { + case "idle": + return "待机"; + case "queued": + return "排队中"; + case "running": + return "运行中"; + case "complete": + return "已完成"; + case "failed": + return "失败"; + default: + return "未知状态"; + } + } +} + +export function addMesh(view, frame) { + const geometry = new THREE.BufferGeometry(); + const positions = MeshData_positionData(frame.Mesh); + const indices = MeshData_triangleIndices(frame.Mesh); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setIndex(new THREE.Uint32BufferAttribute(indices, 1)); + geometry.computeVertexNormals(); + let color; + const matchValue = frame.Version | 0; + color = ((matchValue === 1) ? "#d7a948" : ((matchValue === 2) ? "#efe4c4" : "#a7c7c0")); + const material = new THREE.MeshStandardMaterial({ + color: color, + roughness: 0.36, + metalness: 0.72, + }); + const mesh = new THREE.Mesh(geometry, material); + view.Group.add(mesh); +} + +export function renderFrame(frame) { + selectedFrame(frame); + if (viewState() != null) { + const view = viewState(); + view.Group.clear(); + addMesh(view, frame); + view.Renderer.render(view.Scene, view.Camera); + } +} + +export function resizeView(view) { + const width = max(1, view.Canvas.clientWidth); + const height = max(1, view.Canvas.clientHeight); + view.Renderer.setSize(width, height, false); + renderFrame(selectedFrame()); +} + +export function initializeView() { + const canvas = document.getElementById("viewport-canvas"); + const width = max(1, canvas.clientWidth); + const height = max(1, canvas.clientHeight); + const renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: true, + }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(width, height, false); + const scene = new THREE.Scene(); + scene.background = (new THREE.Color("#11171a")); + const camera = new THREE.PerspectiveCamera(34, (width / height), 0.1, 100); + camera.position.set(6.4, 4.6, 7.2); + camera.lookAt(0, 1.2, 0); + const group = new THREE.Group(); + scene.add(group); + scene.add(new THREE.AmbientLight("#f0e6c8", 1.9)); + const keyLight = new THREE.DirectionalLight("#f2c76b", 2.8); + keyLight.position.set(4, 7, 5); + scene.add(keyLight); + const view = new ViewState(renderer, scene, camera, group, canvas); + viewState(view); + renderFrame(selectedFrame()); + window.addEventListener("resize", (_arg) => { + resizeView(view); + }); + const animate = (_arg_1) => { + group.rotation.y = (group.rotation.y + 0.003); + renderer.render(scene, camera); + requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); +} + +export function fallbackView() { + setText("viewport-note", "WebGL 视图待命 · 已保留重建数据"); + element("viewport-panel").classList.add("viewport-fallback"); +} + +export function showFrameMetadata(frame) { + setText("mesh-label", frame.Label); + setText("timeline-current", toText(printf("V%02d · %s"))(frame.Version)(frame.Label)); +} + +export function setManifestLink(payload) { + const link = element("artifact-manifest-link"); + const matchValue = payload.manifestUrl; + if (isNullOrWhiteSpace(matchValue)) { + link.removeAttribute("href"); + link.classList.add("is-hidden"); + } + else { + link.setAttribute("href", matchValue); + link.classList.remove("is-hidden"); + } +} + +export function updateState(payload) { + let arg, arg_3; + setStatusClass(statusName(payload)); + setText("status-label", statusLabel(payload)); + setText("run-message", ((payload.status === "failed") && !isNullOrWhiteSpace(payload.error)) ? payload.error : payload.message); + setText("project-id", payload.projectId); + setText("run-id", payload.runId); + const currentVersion = max(0, payload.currentStepIndex + 1) | 0; + setText("target-version", (arg = (payload.totalSteps | 0), toText(printf("V%02d"))(arg))); + setText("current-version", toText(printf("V%02d"))(currentVersion)); + setText("version-readout", (arg_3 = (payload.totalSteps | 0), toText(printf("%02d / %02d"))(currentVersion)(arg_3))); + setText("updated-at", payload.updatedAt); + const progress = (payload.totalSteps <= 0) ? 0 : min(100, (currentVersion / payload.totalSteps) * 100); + setWidth("progress-fill", toText(printf("%.0f%%"))(progress)); + const frame = defaultArg(tryFind((candidate) => (candidate.Version === currentVersion), frames), selectedFrame()); + setManifestLink(payload); + showFrameMetadata(frame); + renderFrame(frame); +} + +export function updateStreamState(message) { + setText("stream-state", message); +} + +export function toggleReplay() { + if (replayTimer() == null) { + replayIndex(0); + setText("replay-button", "暂停重建"); + setText("replay-state", "重建中 · 几何逐帧替换"); + const tick = () => { + if (replayIndex() >= frames.length) { + if (replayTimer() == null) { + } + else { + clearInterval(replayTimer()); + } + replayTimer(undefined); + setText("replay-button", "重建演示"); + setText("replay-state", "重建完成 · 几何已更新"); + } + else { + const frame = item(replayIndex(), frames); + showFrameMetadata(frame); + renderFrame(frame); + setText("replay-state", toText(printf("重建中 · V%02d %s"))(frame.Version)(frame.Label)); + replayIndex(replayIndex() + 1); + } + }; + tick(); + replayTimer(setInterval(tick, 900)); + } + else { + clearInterval(replayTimer()); + replayTimer(undefined); + setText("replay-button", "重建演示"); + setText("replay-state", "演示已暂停"); + } +} + +export function disconnectFromServer() { + if (eventSource() == null) { + } + else { + const source = eventSource(); + source.close(); + eventSource(undefined); + } +} + +export function connectToServer(selection) { + disconnectFromServer(); + const projectId = encodeURIComponent(selection.ProjectId); + const runId = encodeURIComponent(selection.RunId); + const source = new EventSource(toText(printf("/api/runs/events?projectId=%s&runId=%s"))(projectId)(runId)); + eventSource(source); + source.onmessage = ((event) => { + updateState(JSON.parse(event.data)); + updateStreamState("实时链路 · SSE 已连接"); + }); + source.onerror = ((_arg) => { + updateStreamState("实时链路 · 等待自动重连"); + }); +} + +export function inputValue(id) { + return element(id).value; +} + +export function selectedRun() { + return new RunSelection(inputValue("project-id-input"), inputValue("run-id-input"), inputValue("base-id-input"), inputValue("target-id-input"), element("run-render-input").checked); +} + +export function startRun(selection) { + const requestBody = { + projectId: selection.ProjectId, + runId: selection.RunId, + baseId: selection.BaseId, + targetId: selection.TargetId, + render: selection.Render, + }; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }; + setText("run-message", "正在启动设计运行"); + updateStreamState("实时链路 · 等待服务端确认"); + startImmediate(singleton.Delay(() => singleton.TryWith(singleton.Delay(() => singleton.Bind(awaitPromise(fetch("/api/runs/start", requestOptions)), (_arg) => { + let arg; + const response = _arg; + if (response.ok) { + return singleton.Bind(awaitPromise(response.json()), (_arg_1) => { + updateState(_arg_1); + connectToServer(selection); + updateStreamState("实时链路 · SSE 已连接"); + return singleton.Zero(); + }); + } + else { + setText("run-message", (arg = (response.status | 0), toText(printf("启动失败 · HTTP %d"))(arg))); + updateStreamState("实时链路 · 启动失败"); + return singleton.Zero(); + } + })), (_arg_2) => { + setText("run-message", "启动失败 · 无法连接服务端"); + updateStreamState("实时链路 · 连接失败"); + return singleton.Zero(); + }))); +} + +export function boot() { + showFrameMetadata(item(0, frames)); + try { + initializeView(); + } + catch (matchValue) { + fallbackView(); + } + element("replay-button").addEventListener("click", (_arg) => { + toggleReplay(); + }); + element("run-controls").addEventListener("submit", (event) => { + event.preventDefault(); + startRun(selectedRun()); + }); +} + +boot(); + diff --git a/public/SomhairlesDream.Shared/Artifact.js b/public/SomhairlesDream.Shared/Artifact.js new file mode 100644 index 0000000..389d39c --- /dev/null +++ b/public/SomhairlesDream.Shared/Artifact.js @@ -0,0 +1,277 @@ +import { Union, Record } from "../fable_modules/fable-library-js.4.24.0/Types.js"; +import { bool_type, array_type, int64_type, union_type, option_type, int32_type, class_type, record_type, string_type } from "../fable_modules/fable-library-js.4.24.0/Reflection.js"; +import { isMatch, create } from "../fable_modules/fable-library-js.4.24.0/RegExp.js"; +import { FSharpResult$2 } from "../fable_modules/fable-library-js.4.24.0/Result.js"; + +export class RunIds extends Record { + constructor(ProjectId, RunId, BaseId, TargetId) { + super(); + this.ProjectId = ProjectId; + this.RunId = RunId; + this.BaseId = BaseId; + this.TargetId = TargetId; + } +} + +export function RunIds_$reflection() { + return record_type("SomhairlesDream.Shared.RunIds", [], RunIds, () => [["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type]]); +} + +const Ids_resourcePattern = create("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", 512); + +const Ids_objectPattern = create("^[a-z0-9]+(?:[._-][a-z0-9]+)*$", 512); + +export function Ids_validateResourceId(value) { + if ((value == null) ? true : !isMatch(Ids_resourcePattern, value)) { + return new FSharpResult$2(1, ["resource id must contain only lower-case letters, digits, and hyphens"]); + } + else { + return new FSharpResult$2(0, [undefined]); + } +} + +export function Ids_validateObjectId(value) { + if ((value == null) ? true : !isMatch(Ids_objectPattern, value)) { + return new FSharpResult$2(1, ["object id must use the dotted lower-case namespace"]); + } + else { + return new FSharpResult$2(0, [undefined]); + } +} + +export class RunStart extends Record { + constructor(Ids, At, TotalSteps) { + super(); + this.Ids = Ids; + this.At = At; + this.TotalSteps = (TotalSteps | 0); + } +} + +export function RunStart_$reflection() { + return record_type("SomhairlesDream.Shared.RunStart", [], RunStart, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["TotalSteps", int32_type]]); +} + +export class RunHeartbeat extends Record { + constructor(Ids, At, Message) { + super(); + this.Ids = Ids; + this.At = At; + this.Message = Message; + } +} + +export function RunHeartbeat_$reflection() { + return record_type("SomhairlesDream.Shared.RunHeartbeat", [], RunHeartbeat, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["Message", string_type]]); +} + +export class RunCheckpoint extends Record { + constructor(Ids, At, StepId, StepIndex, TotalSteps, Message, ArtifactPath) { + super(); + this.Ids = Ids; + this.At = At; + this.StepId = StepId; + this.StepIndex = (StepIndex | 0); + this.TotalSteps = (TotalSteps | 0); + this.Message = Message; + this.ArtifactPath = ArtifactPath; + } +} + +export function RunCheckpoint_$reflection() { + return record_type("SomhairlesDream.Shared.RunCheckpoint", [], RunCheckpoint, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["StepId", string_type], ["StepIndex", int32_type], ["TotalSteps", int32_type], ["Message", string_type], ["ArtifactPath", string_type]]); +} + +export class RunComplete extends Record { + constructor(Ids, At, ManifestPath) { + super(); + this.Ids = Ids; + this.At = At; + this.ManifestPath = ManifestPath; + } +} + +export function RunComplete_$reflection() { + return record_type("SomhairlesDream.Shared.RunComplete", [], RunComplete, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["ManifestPath", string_type]]); +} + +export class RunFail extends Record { + constructor(Ids, At, StepId, Message) { + super(); + this.Ids = Ids; + this.At = At; + this.StepId = StepId; + this.Message = Message; + } +} + +export function RunFail_$reflection() { + return record_type("SomhairlesDream.Shared.RunFail", [], RunFail, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["StepId", option_type(string_type)], ["Message", string_type]]); +} + +export class RunEvent extends Union { + constructor(tag, fields) { + super(); + this.tag = tag; + this.fields = fields; + } + cases() { + return ["Start", "Heartbeat", "Checkpoint", "Complete", "Fail"]; + } +} + +export function RunEvent_$reflection() { + return union_type("SomhairlesDream.Shared.RunEvent", [], RunEvent, () => [[["Item", RunStart_$reflection()]], [["Item", RunHeartbeat_$reflection()]], [["Item", RunCheckpoint_$reflection()]], [["Item", RunComplete_$reflection()]], [["Item", RunFail_$reflection()]]]); +} + +export class RunEventWire extends Record { + constructor(Kind, At, ProjectId, RunId, BaseId, TargetId, TotalSteps, StepId, StepIndex, Message, ArtifactPath, ManifestPath) { + super(); + this.Kind = Kind; + this.At = At; + this.ProjectId = ProjectId; + this.RunId = RunId; + this.BaseId = BaseId; + this.TargetId = TargetId; + this.TotalSteps = TotalSteps; + this.StepId = StepId; + this.StepIndex = StepIndex; + this.Message = Message; + this.ArtifactPath = ArtifactPath; + this.ManifestPath = ManifestPath; + } +} + +export function RunEventWire_$reflection() { + return record_type("SomhairlesDream.Shared.RunEventWire", [], RunEventWire, () => [["Kind", string_type], ["At", class_type("System.DateTimeOffset")], ["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["TotalSteps", option_type(int32_type)], ["StepId", option_type(string_type)], ["StepIndex", option_type(int32_type)], ["Message", option_type(string_type)], ["ArtifactPath", option_type(string_type)], ["ManifestPath", option_type(string_type)]]); +} + +function RunEventModule_ids(value) { + return [value.ProjectId, value.RunId, value.BaseId, value.TargetId]; +} + +export function RunEventModule_toWire(event) { + let patternInput; + switch (event.tag) { + case 1: { + const value_1 = event.fields[0]; + patternInput = ["heartbeat", value_1.Ids, value_1.At, undefined, undefined, undefined, value_1.Message, undefined, undefined]; + break; + } + case 2: { + const value_2 = event.fields[0]; + patternInput = ["checkpoint", value_2.Ids, value_2.At, value_2.TotalSteps, value_2.StepId, value_2.StepIndex, value_2.Message, value_2.ArtifactPath, undefined]; + break; + } + case 3: { + const value_3 = event.fields[0]; + patternInput = ["complete", value_3.Ids, value_3.At, undefined, undefined, undefined, undefined, undefined, value_3.ManifestPath]; + break; + } + case 4: { + const value_4 = event.fields[0]; + patternInput = ["fail", value_4.Ids, value_4.At, undefined, value_4.StepId, undefined, value_4.Message, undefined, undefined]; + break; + } + default: { + const value = event.fields[0]; + patternInput = ["start", value.Ids, value.At, value.TotalSteps, undefined, undefined, undefined, undefined, undefined]; + } + } + const patternInput_1 = RunEventModule_ids(patternInput[1]); + return new RunEventWire(patternInput[0], patternInput[2], patternInput_1[0], patternInput_1[1], patternInput_1[2], patternInput_1[3], patternInput[3], patternInput[4], patternInput[5], patternInput[6], patternInput[7], patternInput[8]); +} + +export class ArtifactChange extends Record { + constructor(ObjectId, Kind, Summary) { + super(); + this.ObjectId = ObjectId; + this.Kind = Kind; + this.Summary = Summary; + } +} + +export function ArtifactChange_$reflection() { + return record_type("SomhairlesDream.Shared.ArtifactChange", [], ArtifactChange, () => [["ObjectId", string_type], ["Kind", string_type], ["Summary", string_type]]); +} + +export class RenderArtifact extends Record { + constructor(ArtifactPath, Sha256, Bytes, ExportedAt) { + super(); + this.ArtifactPath = ArtifactPath; + this.Sha256 = Sha256; + this.Bytes = Bytes; + this.ExportedAt = ExportedAt; + } +} + +export function RenderArtifact_$reflection() { + return record_type("SomhairlesDream.Shared.RenderArtifact", [], RenderArtifact, () => [["ArtifactPath", string_type], ["Sha256", string_type], ["Bytes", int64_type], ["ExportedAt", class_type("System.DateTimeOffset")]]); +} + +export class StepArtifact extends Record { + constructor(StepId, StepIndex, ArtifactPath, LogPath, Sha256, Bytes, ExportedAt, ObjectIds, VerifiedObjectIds, Changes, Render) { + super(); + this.StepId = StepId; + this.StepIndex = (StepIndex | 0); + this.ArtifactPath = ArtifactPath; + this.LogPath = LogPath; + this.Sha256 = Sha256; + this.Bytes = Bytes; + this.ExportedAt = ExportedAt; + this.ObjectIds = ObjectIds; + this.VerifiedObjectIds = VerifiedObjectIds; + this.Changes = Changes; + this.Render = Render; + } +} + +export function StepArtifact_$reflection() { + return record_type("SomhairlesDream.Shared.StepArtifact", [], StepArtifact, () => [["StepId", string_type], ["StepIndex", int32_type], ["ArtifactPath", string_type], ["LogPath", string_type], ["Sha256", string_type], ["Bytes", int64_type], ["ExportedAt", class_type("System.DateTimeOffset")], ["ObjectIds", array_type(string_type)], ["VerifiedObjectIds", array_type(string_type)], ["Changes", array_type(ArtifactChange_$reflection())], ["Render", option_type(RenderArtifact_$reflection())]]); +} + +export class ArtifactManifest extends Record { + constructor(SchemaVersion, ProjectId, RunId, BaseId, TargetId, Status, StartedAt, CompletedAt, BlenderVersion, Steps) { + super(); + this.SchemaVersion = (SchemaVersion | 0); + this.ProjectId = ProjectId; + this.RunId = RunId; + this.BaseId = BaseId; + this.TargetId = TargetId; + this.Status = Status; + this.StartedAt = StartedAt; + this.CompletedAt = CompletedAt; + this.BlenderVersion = BlenderVersion; + this.Steps = Steps; + } +} + +export function ArtifactManifest_$reflection() { + return record_type("SomhairlesDream.Shared.ArtifactManifest", [], ArtifactManifest, () => [["SchemaVersion", int32_type], ["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["Status", string_type], ["StartedAt", class_type("System.DateTimeOffset")], ["CompletedAt", class_type("System.DateTimeOffset")], ["BlenderVersion", string_type], ["Steps", array_type(StepArtifact_$reflection())]]); +} + +export class RunSnapshot extends Record { + constructor(ProjectId, RunId, BaseId, TargetId, Status, CurrentStepId, CurrentStepIndex, TotalSteps, LastHeartbeatAt, UpdatedAt, Message, ManifestPath, ManifestUrl, Error$, IsStale) { + super(); + this.ProjectId = ProjectId; + this.RunId = RunId; + this.BaseId = BaseId; + this.TargetId = TargetId; + this.Status = Status; + this.CurrentStepId = CurrentStepId; + this.CurrentStepIndex = (CurrentStepIndex | 0); + this.TotalSteps = (TotalSteps | 0); + this.LastHeartbeatAt = LastHeartbeatAt; + this.UpdatedAt = UpdatedAt; + this.Message = Message; + this.ManifestPath = ManifestPath; + this.ManifestUrl = ManifestUrl; + this.Error = Error$; + this.IsStale = IsStale; + } +} + +export function RunSnapshot_$reflection() { + return record_type("SomhairlesDream.Shared.RunSnapshot", [], RunSnapshot, () => [["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["Status", string_type], ["CurrentStepId", option_type(string_type)], ["CurrentStepIndex", int32_type], ["TotalSteps", int32_type], ["LastHeartbeatAt", option_type(class_type("System.DateTimeOffset"))], ["UpdatedAt", class_type("System.DateTimeOffset")], ["Message", string_type], ["ManifestPath", option_type(string_type)], ["ManifestUrl", option_type(string_type)], ["Error", option_type(string_type)], ["IsStale", bool_type]]); +} + diff --git a/public/SomhairlesDream.Shared/Domain.js b/public/SomhairlesDream.Shared/Domain.js new file mode 100644 index 0000000..5384715 --- /dev/null +++ b/public/SomhairlesDream.Shared/Domain.js @@ -0,0 +1,114 @@ +import { Record } from "../fable_modules/fable-library-js.4.24.0/Types.js"; +import { bool_type, option_type, class_type, enum_type, array_type, string_type, int32_type, record_type, float64_type } from "../fable_modules/fable-library-js.4.24.0/Reflection.js"; +import { item, collect } from "../fable_modules/fable-library-js.4.24.0/Array.js"; +import { singleton, append, collect as collect_1, delay, toArray } from "../fable_modules/fable-library-js.4.24.0/Seq.js"; +import { rangeDouble } from "../fable_modules/fable-library-js.4.24.0/Range.js"; +import { op_Subtraction } from "../fable_modules/fable-library-js.4.24.0/DateOffset.js"; + +export class Vector3 extends Record { + constructor(X, Y, Z) { + super(); + this.X = X; + this.Y = Y; + this.Z = Z; + } +} + +export function Vector3_$reflection() { + return record_type("SomhairlesDream.Shared.Vector3", [], Vector3, () => [["X", float64_type], ["Y", float64_type], ["Z", float64_type]]); +} + +export class MeshSnapshot extends Record { + constructor(Version, Label, Vertices, Faces) { + super(); + this.Version = (Version | 0); + this.Label = Label; + this.Vertices = Vertices; + this.Faces = Faces; + } +} + +export function MeshSnapshot_$reflection() { + return record_type("SomhairlesDream.Shared.MeshSnapshot", [], MeshSnapshot, () => [["Version", int32_type], ["Label", string_type], ["Vertices", array_type(Vector3_$reflection())], ["Faces", array_type(array_type(int32_type))]]); +} + +export class LiveSnapshot extends Record { + constructor(ProjectId, RunId, Status, CurrentVersion, TargetVersion, HeartbeatAt, UpdatedAt, Message, Mesh, NextDesignRunScheduled) { + super(); + this.ProjectId = ProjectId; + this.RunId = RunId; + this.Status = (Status | 0); + this.CurrentVersion = (CurrentVersion | 0); + this.TargetVersion = (TargetVersion | 0); + this.HeartbeatAt = HeartbeatAt; + this.UpdatedAt = UpdatedAt; + this.Message = Message; + this.Mesh = Mesh; + this.NextDesignRunScheduled = NextDesignRunScheduled; + } +} + +export function LiveSnapshot_$reflection() { + return record_type("SomhairlesDream.Shared.LiveSnapshot", [], LiveSnapshot, () => [["ProjectId", string_type], ["RunId", string_type], ["Status", enum_type("SomhairlesDream.Shared.RunStatus", int32_type, [["Idle", 0], ["Queued", 1], ["Running", 2], ["Succeeded", 3], ["Failed", 4], ["Stale", 5]])], ["CurrentVersion", int32_type], ["TargetVersion", int32_type], ["HeartbeatAt", option_type(class_type("System.DateTimeOffset"))], ["UpdatedAt", class_type("System.DateTimeOffset")], ["Message", string_type], ["Mesh", option_type(MeshSnapshot_$reflection())], ["NextDesignRunScheduled", bool_type]]); +} + +export function MeshData_positionData(mesh) { + return collect((vertex) => (new Float64Array([vertex.X, vertex.Y, vertex.Z])), mesh.Vertices, Float64Array); +} + +export function MeshData_triangleIndices(mesh) { + return collect((face) => { + if (face.length < 3) { + return new Int32Array([]); + } + else { + return toArray(delay(() => collect_1((index) => append(singleton(item(0, face)), delay(() => append(singleton(item(index, face)), delay(() => singleton(item(index + 1, face)))))), rangeDouble(1, 1, face.length - 2)))); + } + }, mesh.Faces, Int32Array); +} + +export function RunState_initial(projectId, runId, now) { + return new LiveSnapshot(projectId, runId, 0, 0, 0, undefined, now, "等待设计运行", undefined, false); +} + +export function RunState_start(now, state) { + const targetVersion = (state.TargetVersion + 1) | 0; + return new LiveSnapshot(state.ProjectId, state.RunId, 2, state.CurrentVersion, targetVersion, now, now, `正在生成第 ${targetVersion} 版几何`, state.Mesh, false); +} + +export function RunState_heartbeat(now, state) { + if (state.Status === 2) { + return new LiveSnapshot(state.ProjectId, state.RunId, state.Status, state.CurrentVersion, state.TargetVersion, now, now, state.Message, state.Mesh, state.NextDesignRunScheduled); + } + else { + return state; + } +} + +export function RunState_statusAt(timeout, now, state) { + const matchValue = state.HeartbeatAt; + let matchResult, heartbeatAt_1; + if (matchValue != null) { + if ((state.Status === 2) && (op_Subtraction(now, matchValue) > timeout)) { + matchResult = 0; + heartbeatAt_1 = matchValue; + } + else { + matchResult = 1; + } + } + else { + matchResult = 1; + } + switch (matchResult) { + case 0: + return new LiveSnapshot(state.ProjectId, state.RunId, 5, state.CurrentVersion, state.TargetVersion, state.HeartbeatAt, now, "心跳超时,等待重连", state.Mesh, state.NextDesignRunScheduled); + default: + return state; + } +} + +export function RunState_publish(now, mesh, state) { + return new LiveSnapshot(state.ProjectId, state.RunId, 3, mesh.Version, state.TargetVersion, now, now, `已发布${mesh.Label}`, mesh, false); +} + diff --git a/public/SomhairlesDream.Shared/Replay.js b/public/SomhairlesDream.Shared/Replay.js new file mode 100644 index 0000000..9ad2fa2 --- /dev/null +++ b/public/SomhairlesDream.Shared/Replay.js @@ -0,0 +1,29 @@ +import { Record } from "../fable_modules/fable-library-js.4.24.0/Types.js"; +import { record_type, string_type, int32_type } from "../fable_modules/fable-library-js.4.24.0/Reflection.js"; +import { MeshSnapshot, Vector3, MeshSnapshot_$reflection } from "./Domain.js"; + +export class ReplayFrame extends Record { + constructor(Version, Label, Mesh) { + super(); + this.Version = (Version | 0); + this.Label = Label; + this.Mesh = Mesh; + } +} + +export function ReplayFrame_$reflection() { + return record_type("SomhairlesDream.Shared.ReplayFrame", [], ReplayFrame, () => [["Version", int32_type], ["Label", string_type], ["Mesh", MeshSnapshot_$reflection()]]); +} + +function Replay_vertex(x, y, z) { + return new Vector3(x, y, z); +} + +function Replay_frame(version, label, vertices, faces) { + return new ReplayFrame(version, label, new MeshSnapshot(version, label, vertices, faces)); +} + +export function Replay_frames() { + return [Replay_frame(1, "重建起点", [Replay_vertex(-2, 0, -1), Replay_vertex(2, 0, -1), Replay_vertex(2, 0, 1), Replay_vertex(-2, 0, 1), Replay_vertex(-1, 2, -0.5), Replay_vertex(1, 2, -0.5), Replay_vertex(1, 2, 0.5), Replay_vertex(-1, 2, 0.5)], [new Int32Array([0, 1, 2]), new Int32Array([0, 2, 3]), new Int32Array([4, 6, 5]), new Int32Array([4, 7, 6]), new Int32Array([0, 4, 5]), new Int32Array([0, 5, 1]), new Int32Array([1, 5, 6]), new Int32Array([1, 6, 2]), new Int32Array([2, 6, 7]), new Int32Array([2, 7, 3]), new Int32Array([3, 7, 4]), new Int32Array([3, 4, 0])]), Replay_frame(2, "主承力骨架", [Replay_vertex(-2.5, 0, -1), Replay_vertex(2.5, 0, -1), Replay_vertex(2.5, 0, 1), Replay_vertex(-2.5, 0, 1), Replay_vertex(-1.5, 2.2, -0.7), Replay_vertex(1.5, 2.2, -0.7), Replay_vertex(1.5, 2.2, 0.7), Replay_vertex(-1.5, 2.2, 0.7), Replay_vertex(-0.45, 0, -0.45), Replay_vertex(0.45, 0, -0.45), Replay_vertex(0.45, 3, 0.45), Replay_vertex(-0.45, 3, 0.45)], [new Int32Array([0, 1, 2]), new Int32Array([0, 2, 3]), new Int32Array([4, 6, 5]), new Int32Array([4, 7, 6]), new Int32Array([8, 9, 10]), new Int32Array([8, 10, 11]), new Int32Array([0, 4, 8]), new Int32Array([4, 11, 8]), new Int32Array([1, 9, 5]), new Int32Array([1, 5, 2]), new Int32Array([2, 5, 10]), new Int32Array([2, 10, 3]), new Int32Array([3, 10, 7]), new Int32Array([3, 7, 0])]), Replay_frame(3, "舱段重构", [Replay_vertex(-3, 0, -1.3), Replay_vertex(3, 0, -1.3), Replay_vertex(3, 0, 1.3), Replay_vertex(-3, 0, 1.3), Replay_vertex(-2, 2, -1), Replay_vertex(2, 2, -1), Replay_vertex(2, 2, 1), Replay_vertex(-2, 2, 1), Replay_vertex(-1, 3.4, -0.65), Replay_vertex(1, 3.4, -0.65), Replay_vertex(1, 3.4, 0.65), Replay_vertex(-1, 3.4, 0.65), Replay_vertex(-3.8, 0.6, 0), Replay_vertex(3.8, 0.6, 0)], [new Int32Array([0, 1, 2]), new Int32Array([0, 2, 3]), new Int32Array([4, 6, 5]), new Int32Array([4, 7, 6]), new Int32Array([8, 9, 10]), new Int32Array([8, 10, 11]), new Int32Array([0, 4, 8]), new Int32Array([0, 8, 12]), new Int32Array([1, 13, 5]), new Int32Array([1, 2, 13]), new Int32Array([2, 6, 10]), new Int32Array([2, 10, 13]), new Int32Array([3, 11, 7]), new Int32Array([3, 12, 11]), new Int32Array([3, 0, 12])])]; +} + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..47b6245 --- /dev/null +++ b/public/index.html @@ -0,0 +1,190 @@ +<!doctype html> +<html lang="zh-CN"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="theme-color" content="#080b0d"> + <title>Somhairle's Dream · 实时重建台</title> + <link rel="stylesheet" href="/styles.css"> + <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script> +</head> +<body> + <main class="shell" aria-labelledby="page-title"> + <header class="masthead"> + <div class="brand-lockup"> + <span class="eyebrow">SOMHAIRLE'S DREAM / CONTROL SURFACE</span> + <h1 id="page-title">实时重建台</h1> + </div> + <div class="connection-readout" aria-live="polite"> + <span class="signal-mark" aria-hidden="true"></span> + <span id="stream-state">实时链路 · 等待启动</span> + </div> + </header> + + <section class="mission-strip" aria-label="当前任务"> + <div> + <span class="eyebrow">PROJECT</span> + <strong id="project-id">heritage-001</strong> + </div> + <div> + <span class="eyebrow">RUN</span> + <strong id="run-id">run-001</strong> + </div> + <div class="mission-note"> + <span class="eyebrow">MODE</span> + <strong>F# / LIVE GEOMETRY</strong> + </div> + </section> + + <section class="workspace" aria-label="实时运行工作区"> + <aside class="telemetry-panel panel"> + <div class="panel-heading"> + <span class="eyebrow">RUN TELEMETRY</span> + <div id="status-chip" class="status-chip status-idle" data-status="idle"> + <span class="status-dot" aria-hidden="true"></span> + <span id="status-label">待机</span> + </div> + </div> + <p id="run-message" class="status-message">等待下一个设计运行</p> + <form id="run-controls" class="run-controls"> + <div class="control-grid"> + <label> + <span>项目 ID</span> + <input id="project-id-input" value="heritage-001" autocomplete="off"> + </label> + <label> + <span>运行 ID</span> + <input id="run-id-input" value="run-001" autocomplete="off"> + </label> + <label> + <span>基准 ID</span> + <input id="base-id-input" value="base-000" autocomplete="off"> + </label> + <label> + <span>目标 ID</span> + <input id="target-id-input" value="target-003" autocomplete="off"> + </label> + </div> + <div class="control-actions"> + <label class="render-toggle"> + <input id="run-render-input" type="checkbox"> + <span>渲染预览 PNG</span> + </label> + <button id="run-start-button" class="run-start-button" type="submit">启动设计运行</button> + </div> + <a id="artifact-manifest-link" class="artifact-link is-hidden" target="_blank" rel="noreferrer">打开 artifact manifest</a> + </form> + <div class="version-block"> + <div class="label-row"> + <span>设计版本</span> + <strong id="version-readout">00 / 03</strong> + </div> + <div class="progress-track" aria-hidden="true"> + <span id="progress-fill" class="progress-fill" style="width: 0%"></span> + </div> + </div> + <dl class="telemetry-list"> + <div> + <dt>当前版本</dt> + <dd id="current-version">V00</dd> + </div> + <div> + <dt>目标版本</dt> + <dd id="target-version">V03</dd> + </div> + <div> + <dt>最后更新</dt> + <dd id="updated-at">等待心跳</dd> + </div> + </dl> + </aside> + + <section id="viewport-panel" class="viewport-panel panel" aria-labelledby="viewport-title"> + <div class="panel-heading viewport-heading"> + <div> + <span class="eyebrow">GEOMETRY VIEWPORT</span> + <h2 id="viewport-title">结构重建视图</h2> + </div> + <span id="mesh-label" class="mesh-label">重建起点</span> + </div> + <div class="viewport-frame"> + <canvas id="viewport-canvas" aria-label="设计几何三维视图"></canvas> + <div class="axis-readout" aria-hidden="true">X / Y / Z</div> + <div id="viewport-note" class="viewport-note">实时几何 · V00</div> + </div> + </section> + + <aside class="event-panel panel" aria-labelledby="event-title"> + <div class="panel-heading"> + <div> + <span class="eyebrow">EVENT STREAM</span> + <h2 id="event-title">运行记录</h2> + </div> + <span class="event-count">LIVE</span> + </div> + <ol class="event-list"> + <li class="event-item event-item-active"> + <span class="event-index">01</span> + <div> + <strong>实时监视</strong> + <span>服务端心跳与版本状态</span> + </div> + </li> + <li class="event-item"> + <span class="event-index">02</span> + <div> + <strong>目标检查</strong> + <span>等候下一次设计运行</span> + </div> + </li> + <li class="event-item"> + <span class="event-index">03</span> + <div> + <strong>发布闸门</strong> + <span>验证通过后写入快照</span> + </div> + </li> + </ol> + </aside> + </section> + + <section class="timeline panel" aria-labelledby="timeline-title"> + <div class="timeline-header"> + <div> + <span class="eyebrow">RECONSTRUCTION TIMELINE</span> + <h2 id="timeline-title">几何版本轨迹</h2> + </div> + <span id="replay-state" class="replay-state">可回放 · 三个确定性快照</span> + </div> + <div class="timeline-track" aria-label="几何版本"> + <span class="timeline-line" aria-hidden="true"></span> + <div class="timeline-step timeline-step-complete"> + <span class="timeline-dot" aria-hidden="true"></span> + <span class="timeline-version">V01</span> + <span class="timeline-label">重建起点</span> + </div> + <div class="timeline-step timeline-step-complete"> + <span class="timeline-dot" aria-hidden="true"></span> + <span class="timeline-version">V02</span> + <span class="timeline-label">主承力骨架</span> + </div> + <div class="timeline-step"> + <span class="timeline-dot" aria-hidden="true"></span> + <span class="timeline-version">V03</span> + <span class="timeline-label">舱段重构</span> + </div> + </div> + <div class="timeline-footer"> + <span id="timeline-current">V01 · 重建起点</span> + <button id="replay-button" class="replay-button" type="button">重建演示</button> + </div> + </section> + + <footer class="footer-note"> + <span>状态快照 · 原子发布 · 心跳监视</span> + <span>OPEN CODE / F# FIRST</span> + </footer> + </main> + <script type="module" src="/App.js"></script> +</body> +</html> diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..0ee02ef --- /dev/null +++ b/public/styles.css @@ -0,0 +1,692 @@ +:root { + color-scheme: dark; + --ink: #080b0d; + --panel: #11171a; + --panel-raised: #161e21; + --line: #344146; + --line-bright: #627177; + --ivory: #f0eadb; + --muted: #9aa6a8; + --gold: #d7a948; + --gold-soft: #f0c976; + --mint: #a7c7c0; + --green: #76c39a; + --red: #dc827b; + --shadow: rgba(0, 0, 0, 0.28); +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--ink); +} + +body { + min-width: 320px; + margin: 0; + background: var(--ink); + color: var(--ivory); + font-family: "Fira Sans", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.5; +} + +button, +body, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:focus-visible { + outline: 2px solid var(--gold-soft); + outline-offset: 3px; +} + +.shell { + width: min(1480px, calc(100% - 48px)); + margin: 0 auto; + padding: 34px 0 24px; +} + +.masthead, +.mission-strip, +.panel-heading, +.label-row, +.timeline-header, +.timeline-footer, +.footer-note { + display: flex; + align-items: center; + justify-content: space-between; +} + +.masthead { + gap: 24px; + padding-bottom: 30px; + border-bottom: 1px solid var(--line); +} + +.brand-lockup h1, +.panel-heading h2, +.timeline-header h2 { + margin: 0; + font-weight: 500; + letter-spacing: -0.025em; +} + +.brand-lockup h1 { + font-size: clamp(2rem, 4vw, 3.7rem); + line-height: 1; +} + +.panel-heading h2, +.timeline-header h2 { + font-size: 1.12rem; +} + +.eyebrow, +.connection-readout, +.event-count, +.mesh-label, +.replay-state, +.timeline-version, +.timeline-current, +.footer-note, +.axis-readout, +.viewport-note { + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.eyebrow { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 0.66rem; + letter-spacing: 0.16em; +} + +.connection-readout { + display: flex; + align-items: center; + gap: 10px; + color: var(--mint); + font-size: 0.72rem; + white-space: nowrap; +} + +.signal-mark, +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--gold); + box-shadow: 0 0 0 4px rgba(215, 169, 72, 0.12); +} + +.mission-strip { + display: grid; + grid-template-columns: 1fr 1fr 2fr; + gap: 1px; + margin: 20px 0; + border: 1px solid var(--line); + background: var(--line); +} + +.mission-strip > div { + min-height: 76px; + padding: 15px 18px; + background: var(--panel); +} + +.mission-strip strong { + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.83rem; + font-weight: 500; + letter-spacing: 0.08em; +} + +.mission-note { + text-align: right; +} + +.workspace { + display: grid; + grid-template-columns: minmax(200px, 0.72fr) minmax(360px, 1.8fr) minmax(220px, 0.82fr); + gap: 12px; +} + +.panel { + border: 1px solid var(--line); + background: var(--panel); + box-shadow: 0 16px 36px var(--shadow); +} + +.telemetry-panel, +.event-panel { + min-height: 440px; + padding: 20px; +} + +.panel-heading { + align-items: flex-start; + gap: 16px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 9px; + border: 1px solid var(--line-bright); + color: var(--gold-soft); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; + white-space: nowrap; +} + +.status-chip .status-dot { + width: 6px; + height: 6px; + box-shadow: none; +} + +.status-running .status-dot, +.status-succeeded .status-dot { + background: var(--green); +} + +.status-failed .status-dot, +.status-stale .status-dot { + background: var(--red); +} + +.status-failed, +.status-stale { + color: var(--red); +} + +.status-message { + min-height: 56px; + margin: 22px 0 28px; + color: var(--ivory); + font-size: 1.25rem; + line-height: 1.35; +} + +.is-hidden { + display: none !important; +} + +.run-controls { + display: grid; + gap: 14px; + margin: 0 0 22px; + padding: 14px 0 20px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.control-grid { + display: grid; + gap: 9px; +} + +.control-grid label, +.render-toggle { + display: grid; + gap: 4px; + color: var(--muted); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.62rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.control-grid input { + width: 100%; + min-height: 31px; + padding: 5px 7px; + border: 1px solid var(--line-bright); + border-radius: 0; + background: var(--panel-raised); + color: var(--ivory); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; +} + +.control-grid input:focus-visible { + outline: 2px solid var(--gold-soft); + outline-offset: 2px; +} + +.control-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.render-toggle { + display: flex; + align-items: center; + gap: 7px; + letter-spacing: 0; + text-transform: none; +} + +.render-toggle input { + accent-color: var(--gold); +} + +.run-start-button, +.artifact-link { + min-height: 34px; + padding: 7px 9px; + border: 1px solid var(--gold); + border-radius: 0; + background: transparent; + color: var(--gold-soft); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.62rem; + text-decoration: none; + transition: background 180ms ease, color 180ms ease; +} + +.run-start-button:hover, +.artifact-link:hover { + background: var(--gold); + color: var(--ink); +} + +.artifact-link { + justify-self: start; +} + +.version-block { + padding: 16px 0 22px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.label-row { + color: var(--muted); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; +} + +.label-row strong { + color: var(--gold-soft); + font-weight: 500; +} + +.progress-track { + height: 4px; + margin-top: 14px; + background: #293236; +} + +.progress-fill { + display: block; + height: 100%; + background: var(--gold); + transition: width 220ms ease; +} + +.telemetry-list { + margin: 0; +} + +.telemetry-list > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 13px 0; + border-bottom: 1px solid #253035; +} + +.telemetry-list dt { + color: var(--muted); + font-size: 0.76rem; +} + +.telemetry-list dd { + max-width: 60%; + margin: 0; + color: var(--ivory); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.69rem; + text-align: right; + word-break: break-word; +} + +.viewport-panel { + min-height: 440px; + padding: 20px 20px 14px; +} + +.viewport-heading { + align-items: center; +} + +.mesh-label { + color: var(--gold-soft); + font-size: 0.68rem; + text-align: right; +} + +.viewport-frame { + position: relative; + height: 370px; + margin-top: 16px; + overflow: hidden; + border: 1px solid var(--line); + background: #0b1012; +} + +.viewport-frame::after { + position: absolute; + inset: 16px; + border: 1px solid rgba(167, 199, 192, 0.13); + content: ""; + pointer-events: none; +} + +#viewport-canvas { + display: block; + width: 100%; + height: 100%; +} + +.axis-readout, +.viewport-note { + position: absolute; + z-index: 2; + color: var(--muted); + font-size: 0.6rem; + pointer-events: none; +} + +.axis-readout { + right: 18px; + bottom: 16px; +} + +.viewport-note { + left: 18px; + bottom: 16px; + color: var(--gold-soft); +} + +.viewport-fallback .viewport-frame { + display: grid; + place-items: center; +} + +.event-count { + color: var(--green); + font-size: 0.65rem; +} + +.event-list { + display: grid; + gap: 0; + margin: 22px 0 0; + padding: 0; + list-style: none; +} + +.event-item { + display: grid; + grid-template-columns: 32px 1fr; + gap: 10px; + padding: 15px 0; + border-bottom: 1px solid #253035; +} + +.event-item-active { + border-left: 2px solid var(--gold); + padding-left: 10px; +} + +.event-index { + color: var(--gold-soft); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; +} + +.event-item strong, +.event-item span { + display: block; +} + +.event-item strong { + font-size: 0.82rem; + font-weight: 500; +} + +.event-item div > span { + margin-top: 4px; + color: var(--muted); + font-size: 0.72rem; +} + +.timeline { + margin-top: 12px; + padding: 20px; +} + +.timeline-header { + align-items: flex-end; + gap: 16px; + padding-bottom: 18px; + border-bottom: 1px solid var(--line); +} + +.replay-state { + color: var(--muted); + font-size: 0.65rem; + text-align: right; +} + +.timeline-track { + position: relative; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + padding: 30px 0 24px; +} + +.timeline-line { + position: absolute; + top: 35px; + right: 8%; + left: 8%; + height: 1px; + background: var(--line-bright); +} + +.timeline-step { + position: relative; + display: grid; + justify-items: center; + gap: 7px; + color: var(--muted); + text-align: center; +} + +.timeline-dot { + z-index: 1; + width: 11px; + height: 11px; + border: 2px solid var(--line-bright); + background: var(--panel); +} + +.timeline-step-complete .timeline-dot { + border-color: var(--gold); + background: var(--gold); +} + +.timeline-version { + color: var(--gold-soft); + font-size: 0.68rem; +} + +.timeline-label { + font-size: 0.78rem; +} + +.timeline-footer { + padding-top: 14px; + border-top: 1px solid var(--line); + color: var(--ivory); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; +} + +.replay-button { + min-height: 44px; + padding: 8px 16px; + border: 1px solid var(--gold); + border-radius: 0; + background: transparent; + color: var(--gold-soft); + font-family: "Fira Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.68rem; + transition: background 180ms ease, color 180ms ease; +} + +.replay-button:hover { + background: var(--gold); + color: var(--ink); +} + +.footer-note { + padding-top: 18px; + color: #687579; + font-size: 0.59rem; +} + +@media (max-width: 1080px) { + .workspace { + grid-template-columns: minmax(190px, 0.7fr) minmax(360px, 1.3fr); + } + + .event-panel { + grid-column: 1 / -1; + min-height: 0; + } + + .event-list { + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + + .event-item { + border-bottom: 0; + border-top: 1px solid #253035; + } +} + +@media (max-width: 700px) { + .shell { + width: min(100% - 28px, 560px); + padding-top: 20px; + } + + .masthead { + align-items: flex-start; + flex-direction: column; + gap: 18px; + padding-bottom: 22px; + } + + .connection-readout { + font-size: 0.64rem; + } + + .mission-strip { + grid-template-columns: 1fr 1fr; + } + + .mission-note { + grid-column: 1 / -1; + text-align: left; + } + + .workspace { + display: flex; + flex-direction: column; + } + + .telemetry-panel, + .event-panel, + .viewport-panel { + min-height: 0; + } + + .viewport-frame { + height: 320px; + } + + .event-list { + grid-template-columns: 1fr; + } + + .event-item { + border-bottom: 1px solid #253035; + } + + .timeline-header, + .timeline-footer { + align-items: flex-start; + flex-direction: column; + } + + .timeline-footer { + gap: 14px; + } + + .control-actions { + align-items: stretch; + flex-direction: column; + } + + .run-start-button { + width: 100%; + } + + .replay-button { + width: 100%; + } + + .footer-note { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/src/SomhairlesDream.Cli/Arguments.fs b/src/SomhairlesDream.Cli/Arguments.fs new file mode 100644 index 0000000..2de96be --- /dev/null +++ b/src/SomhairlesDream.Cli/Arguments.fs @@ -0,0 +1,91 @@ +namespace SomhairlesDream.Cli + +open System +open SomhairlesDream.Shared + +[<CLIMutable>] +type RunOptions = + { Ids: RunIds + OutputRoot: string + StageRoot: string + Python: string + Render: bool } + +type Command = + | Run of RunOptions + | Verify of string + | Help + +module Arguments = + let usage = + "usage: dotnet run --project src/SomhairlesDream.Cli -- run [options]\n" + + " dotnet run --project src/SomhairlesDream.Cli -- verify <manifest.json>" + + let private defaultPython () = + let configured = Environment.GetEnvironmentVariable("SOMHAIRLES_BPYTHON") + if String.IsNullOrWhiteSpace(configured) then "python3" else configured + + let private defaults () = + { Ids = + { ProjectId = "heritage-001" + RunId = "run-001" + BaseId = "base-000" + TargetId = "target-003" } + OutputRoot = ".artifacts" + StageRoot = "stage" + Python = defaultPython () + Render = false } + + let private requiredValue (args: string array) (index: int) (optionName: string) = + if index + 1 >= args.Length || String.IsNullOrWhiteSpace(args[index + 1]) then + Error $"{optionName} requires a value" + else + Ok args[index + 1] + + let private setValue optionName value (options: RunOptions) = + match optionName with + | "--project-id" -> Ok { options with Ids = { options.Ids with ProjectId = value } } + | "--run-id" -> Ok { options with Ids = { options.Ids with RunId = value } } + | "--base-id" -> Ok { options with Ids = { options.Ids with BaseId = value } } + | "--target-id" -> Ok { options with Ids = { options.Ids with TargetId = value } } + | "--output-root" -> Ok { options with OutputRoot = value } + | "--stage-root" -> Ok { options with StageRoot = value } + | "--python" -> Ok { options with Python = value } + | _ -> Error $"unknown option: {optionName}" + + let private validateRunOptions (options: RunOptions) = + [| options.Ids.ProjectId; options.Ids.RunId; options.Ids.BaseId; options.Ids.TargetId |] + |> Array.tryFind (Ids.validateResourceId >> Result.isError) + |> function + | Some value -> Error $"invalid resource id: {value}" + | None -> Ok(Run options) + + let private parseRun (args: string array) = + let rec loop (index: int) (options: RunOptions) = + if index >= args.Length then + validateRunOptions options + else + match args[index] with + | "--render" -> loop (index + 1) { options with Render = true } + | "--help" -> Ok Help + | optionName -> + match requiredValue args index optionName with + | Error message -> Error message + | Ok value -> + match setValue optionName value options with + | Error message -> Error message + | Ok updated -> loop (index + 2) updated + + loop 0 (defaults ()) + + let parse (args: string array) = + match args with + | [||] + | [| "--help" |] + | [| "help" |] -> Ok Help + | [| "verify" |] -> Error "verify requires a manifest path" + | [| "verify"; path |] when not (String.IsNullOrWhiteSpace(path)) -> Ok(Verify path) + | [| "verify"; _ |] -> Error "verify requires a manifest path" + | values when values.Length > 2 && values[0] = "verify" -> Error "verify accepts exactly one manifest path" + | values when values.Length > 0 && values[0] = "run" -> parseRun (values |> Array.skip 1) + | _ -> Error usage diff --git a/src/SomhairlesDream.Cli/Program.fs b/src/SomhairlesDream.Cli/Program.fs new file mode 100644 index 0000000..0d9ed1c --- /dev/null +++ b/src/SomhairlesDream.Cli/Program.fs @@ -0,0 +1,51 @@ +namespace SomhairlesDream.Cli + +open System +open System.IO +open SomhairlesDream.Modeling + +module Program = + let private bridgeFailure (message: string) = + message.StartsWith("blender bridge:", StringComparison.OrdinalIgnoreCase) + + let private run options = + let bridge = BlenderProcessBridge(options.Python, options.StageRoot) :> IArtifactBridge + + let pipelineOptions : PipelineOptions = + { ArtifactRoot = options.OutputRoot + Ids = options.Ids + Render = options.Render + Bridge = bridge + Clock = fun () -> DateTimeOffset.UtcNow + OnEvent = ignore } + + match Pipeline.run pipelineOptions with + | Ok _ -> + let manifestPath = Path.Combine(options.OutputRoot, options.Ids.ProjectId, options.Ids.RunId, "manifest.json") + printfn "%s" (Path.GetFullPath(manifestPath)) + 0 + | Error message -> + eprintfn "%s" message + if bridgeFailure message then 3 else 4 + + let private verify path = + match ArtifactVerifier.verify path with + | Ok _ -> + printfn "%s" (Path.GetFullPath(path)) + 0 + | Error message -> + eprintfn "%s" message + 4 + + [<EntryPoint>] + let main argv = + match Arguments.parse argv with + | Ok Help -> + printfn "%s" Arguments.usage + 0 + | Ok(Run options) -> run options + | Ok(Verify path) -> verify path + | Error message -> + eprintfn "%s" message + eprintfn "%s" Arguments.usage + 2 diff --git a/src/SomhairlesDream.Cli/SomhairlesDream.Cli.fsproj b/src/SomhairlesDream.Cli/SomhairlesDream.Cli.fsproj new file mode 100644 index 0000000..6c72658 --- /dev/null +++ b/src/SomhairlesDream.Cli/SomhairlesDream.Cli.fsproj @@ -0,0 +1,16 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <RootNamespace>SomhairlesDream.Cli</RootNamespace> + <AssemblyName>SomhairlesDream.Cli</AssemblyName> + <OutputType>Exe</OutputType> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="../SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj" /> + <ProjectReference Include="../SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Arguments.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> +</Project> diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs new file mode 100644 index 0000000..835dfa9 --- /dev/null +++ b/src/SomhairlesDream.Frontend/App.fs @@ -0,0 +1,338 @@ +module SomhairlesDream.Frontend.App + +open System +open Browser.Dom +open Browser.Types +open Fable.Core +open Fable.Core.JsInterop +open SomhairlesDream.Frontend.Bindings +open SomhairlesDream.Shared + +[<Erase>] +type SnapshotPayload = + abstract projectId: string with get + abstract runId: string with get + abstract status: string with get + abstract currentStepIndex: int with get + abstract totalSteps: int with get + abstract lastHeartbeatAt: string with get + abstract updatedAt: string with get + abstract message: string with get + abstract manifestUrl: string with get + abstract error: string with get + abstract isStale: bool with get + +type RunSelection = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string + Render: bool } + +type ViewState = + { Renderer: ThreeRenderer + Scene: ThreeNode + Camera: ThreeNode + Group: ThreeNode + Canvas: HTMLCanvasElement } + +let frames = Replay.frames () +let mutable viewState: ViewState option = None +let mutable selectedFrame = frames[0] +let mutable replayTimer: int option = None +let mutable replayIndex = 0 +let mutable eventSource: EventSource option = None + +let element id = document.getElementById(id) + +let setText id value = + (element id).textContent <- value + +let setWidth id value = + (element id).setAttribute("style", sprintf "width: %s" value) + +let setStatusClass (status: string) = + let host = element "status-chip" + [| "idle"; "queued"; "running"; "succeeded"; "failed"; "stale" |] + |> Array.iter (fun name -> host.classList.remove($"status-{name}")) + host.classList.add($"status-{status}") + host.setAttribute("data-status", status) + +let statusName (payload: SnapshotPayload) = + if payload.isStale then + "stale" + else + match payload.status with + | "complete" -> "succeeded" + | "idle" + | "queued" + | "running" + | "failed" as status -> status + | _ -> "unknown" + +let statusLabel (payload: SnapshotPayload) = + match payload.status, payload.isStale with + | _, true -> "心跳超时" + | "idle", _ -> "待机" + | "queued", _ -> "排队中" + | "running", _ -> "运行中" + | "complete", _ -> "已完成" + | "failed", _ -> "失败" + | _ -> "未知状态" + +let addMesh (view: ViewState) (frame: ReplayFrame) = + let geometry = createBufferGeometry () + let positions = MeshData.positionData frame.Mesh + let indices = MeshData.triangleIndices frame.Mesh + geometry.setAttribute("position", createFloat32BufferAttribute(positions, 3)) |> ignore + geometry.setIndex(createUint32BufferAttribute indices) |> ignore + geometry.computeVertexNormals() + + let color = + match frame.Version with + | 1 -> "#d7a948" + | 2 -> "#efe4c4" + | _ -> "#a7c7c0" + + let material = + createMeshStandardMaterial ( + createObj + [ "color" ==> color + "roughness" ==> 0.36 + "metalness" ==> 0.72 ] + ) + + let mesh = createMesh(geometry, material) + view.Group.add(mesh) + +let renderFrame (frame: ReplayFrame) = + selectedFrame <- frame + + match viewState with + | None -> () + | Some view -> + view.Group.clear() + addMesh view frame + view.Renderer.render(view.Scene, view.Camera) + +let resizeView (view: ViewState) = + let width = max 1.0 (float view.Canvas.clientWidth) + let height = max 1.0 (float view.Canvas.clientHeight) + view.Renderer.setSize(width, height, false) + renderFrame selectedFrame + +let initializeView () = + let canvas = document.getElementById("viewport-canvas") :?> HTMLCanvasElement + let width = max 1.0 (float canvas.clientWidth) + let height = max 1.0 (float canvas.clientHeight) + let renderer = + createRenderer ( + createObj + [ "canvas" ==> canvas + "antialias" ==> true + "alpha" ==> true ] + ) + + renderer.setPixelRatio(window.devicePixelRatio) + renderer.setSize(width, height, false) + let scene = createScene() + scene.background <- createColor("#11171a") + let camera = createPerspectiveCamera(34.0, width / height, 0.1, 100.0) + camera.position.set(6.4, 4.6, 7.2) |> ignore + camera.lookAt(0.0, 1.2, 0.0) + let group = createGroup() + scene.add(group) + scene.add(createAmbientLight("#f0e6c8", 1.9)) + let keyLight = createDirectionalLight("#f2c76b", 2.8) + keyLight.position.set(4.0, 7.0, 5.0) |> ignore + scene.add(keyLight) + + let view = + { Renderer = renderer + Scene = scene + Camera = camera + Group = group + Canvas = canvas } + + viewState <- Some view + renderFrame selectedFrame + window.addEventListener("resize", fun _ -> resizeView view) + + let rec animate (_: float) = + group.rotation.y <- group.rotation.y + 0.003 + renderer.render(scene, camera) + requestAnimationFrame animate |> ignore + + requestAnimationFrame animate |> ignore + +let fallbackView () = + setText "viewport-note" "WebGL 视图待命 · 已保留重建数据" + (element "viewport-panel").classList.add("viewport-fallback") + +let showFrameMetadata (frame: ReplayFrame) = + setText "mesh-label" frame.Label + setText "timeline-current" (sprintf "V%02d · %s" frame.Version frame.Label) + +let setManifestLink (payload: SnapshotPayload) = + let link = element "artifact-manifest-link" + + match payload.manifestUrl with + | value when String.IsNullOrWhiteSpace(value) -> + link.removeAttribute("href") + link.classList.add("is-hidden") + | value -> + link.setAttribute("href", value) + link.classList.remove("is-hidden") + +let updateState (payload: SnapshotPayload) = + let status = statusName payload + setStatusClass status + setText "status-label" (statusLabel payload) + + let message = + if payload.status = "failed" && not (String.IsNullOrWhiteSpace(payload.error)) then + payload.error + else + payload.message + + setText "run-message" message + setText "project-id" payload.projectId + setText "run-id" payload.runId + + let currentVersion = max 0 (payload.currentStepIndex + 1) + setText "target-version" (sprintf "V%02d" payload.totalSteps) + setText "current-version" (sprintf "V%02d" currentVersion) + setText "version-readout" (sprintf "%02d / %02d" currentVersion payload.totalSteps) + setText "updated-at" payload.updatedAt + + let progress = + if payload.totalSteps <= 0 then 0.0 + else min 100.0 (float currentVersion / float payload.totalSteps * 100.0) + + setWidth "progress-fill" (sprintf "%.0f%%" progress) + + let frame = + frames + |> Array.tryFind (fun candidate -> candidate.Version = currentVersion) + |> Option.defaultValue selectedFrame + + setManifestLink payload + showFrameMetadata frame + renderFrame frame + +let updateStreamState message = + setText "stream-state" message + +let toggleReplay () = + match replayTimer with + | Some handle -> + clearInterval handle + replayTimer <- None + setText "replay-button" "重建演示" + setText "replay-state" "演示已暂停" + | None -> + replayIndex <- 0 + setText "replay-button" "暂停重建" + setText "replay-state" "重建中 · 几何逐帧替换" + + let tick () = + if replayIndex >= frames.Length then + match replayTimer with + | Some handle -> clearInterval handle + | None -> () + + replayTimer <- None + setText "replay-button" "重建演示" + setText "replay-state" "重建完成 · 几何已更新" + else + let frame = frames[replayIndex] + showFrameMetadata frame + renderFrame frame + setText "replay-state" (sprintf "重建中 · V%02d %s" frame.Version frame.Label) + replayIndex <- replayIndex + 1 + + tick () + replayTimer <- Some(setInterval tick 900) + +let disconnectFromServer () = + match eventSource with + | Some source -> + source.close() + eventSource <- None + | None -> () + +let connectToServer (selection: RunSelection) = + disconnectFromServer () + let projectId = JS.encodeURIComponent selection.ProjectId + let runId = JS.encodeURIComponent selection.RunId + let source = createEventSource (sprintf "/api/runs/events?projectId=%s&runId=%s" projectId runId) + eventSource <- Some source + + source.onmessage <- fun event -> + let payload = JS.JSON.parse(event.data) :?> SnapshotPayload + updateState payload + updateStreamState "实时链路 · SSE 已连接" + + source.onerror <- fun _ -> updateStreamState "实时链路 · 等待自动重连" + +let inputValue id = + (element id :?> HtmlInput).value + +let selectedRun () = + { ProjectId = inputValue "project-id-input" + RunId = inputValue "run-id-input" + BaseId = inputValue "base-id-input" + TargetId = inputValue "target-id-input" + Render = (element "run-render-input" :?> HtmlInput).``checked`` } + +let startRun (selection: RunSelection) = + let requestBody = + createObj + [ "projectId" ==> selection.ProjectId + "runId" ==> selection.RunId + "baseId" ==> selection.BaseId + "targetId" ==> selection.TargetId + "render" ==> selection.Render ] + + let requestOptions = + createObj + [ "method" ==> "POST" + "headers" ==> createObj [ "Content-Type" ==> "application/json" ] + "body" ==> JS.JSON.stringify requestBody ] + + setText "run-message" "正在启动设计运行" + updateStreamState "实时链路 · 等待服务端确认" + + async { + try + let! response = fetch ("/api/runs/start", requestOptions) |> Async.AwaitPromise + + if response.ok then + let! value = response.json() |> Async.AwaitPromise + let payload = value :?> SnapshotPayload + updateState payload + connectToServer selection + updateStreamState "实时链路 · SSE 已连接" + else + setText "run-message" (sprintf "启动失败 · HTTP %d" response.status) + updateStreamState "实时链路 · 启动失败" + with _ -> + setText "run-message" "启动失败 · 无法连接服务端" + updateStreamState "实时链路 · 连接失败" + } + |> Async.StartImmediate + +let boot () = + showFrameMetadata frames[0] + + try + initializeView () + with _ -> + fallbackView () + + (element "replay-button").addEventListener("click", fun _ -> toggleReplay ()) + (element "run-controls").addEventListener("submit", fun event -> + event.preventDefault() + startRun (selectedRun ())) + +boot () diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs new file mode 100644 index 0000000..dc75d7d --- /dev/null +++ b/src/SomhairlesDream.Frontend/Bindings.fs @@ -0,0 +1,109 @@ +module SomhairlesDream.Frontend.Bindings + +open Fable.Core + +[<Erase>] +type ThreeVector = + abstract set: x: float * y: float * z: float -> ThreeVector + abstract x: float with get, set + abstract y: float with get, set + abstract z: float with get, set + +[<Erase>] +type ThreeNode = + abstract add: child: ThreeNode -> unit + abstract clear: unit -> unit + abstract lookAt: x: float * y: float * z: float -> unit + abstract position: ThreeVector with get + abstract rotation: ThreeVector with get + abstract background: obj with get, set + +[<Erase>] +type ThreeRenderer = + abstract setPixelRatio: ratio: float -> unit + abstract setSize: width: float * height: float * updateStyle: bool -> unit + abstract render: scene: ThreeNode * camera: ThreeNode -> unit + +[<Erase>] +type ThreeGeometry = + abstract setAttribute: name: string * attribute: obj -> ThreeGeometry + abstract setIndex: attribute: obj -> ThreeGeometry + abstract computeVertexNormals: unit -> unit + +[<Erase>] +type ServerEvent = + abstract data: string with get + +[<Erase>] +type EventSource = + abstract onerror: (obj -> unit) with get, set + abstract onmessage: (ServerEvent -> unit) with get, set + abstract close: unit -> unit + +[<Erase>] +type FetchResponse = + abstract ok: bool with get + abstract status: int with get + abstract json: unit -> JS.Promise<obj> + +[<Erase>] +type HtmlInput = + abstract value: string with get + abstract ``checked``: bool with get + +[<Emit("new THREE.Scene()")>] +let createScene () : ThreeNode = jsNative + +[<Emit("new THREE.Group()")>] +let createGroup () : ThreeNode = jsNative + +[<Emit("new THREE.PerspectiveCamera($0, $1, $2, $3)")>] +let createPerspectiveCamera (fieldOfView: float, aspect: float, nearPlane: float, farPlane: float) : ThreeNode = jsNative + +[<Emit("new THREE.WebGLRenderer($0)")>] +let createRenderer (options: obj) : ThreeRenderer = jsNative + +[<Emit("new THREE.BufferGeometry()")>] +let createBufferGeometry () : ThreeGeometry = jsNative + +[<Emit("new THREE.Float32BufferAttribute($0, $1)")>] +let createFloat32BufferAttribute (values: float array, itemSize: int) : obj = jsNative + +[<Emit("new THREE.Uint32BufferAttribute($0, 1)")>] +let createUint32BufferAttribute (values: int array) : obj = jsNative + +[<Emit("new THREE.Color($0)")>] +let createColor (value: string) : obj = jsNative + +[<Emit("new THREE.BoxGeometry($0, $1, $2)")>] +let createBoxGeometry (width: float, height: float, depth: float) : obj = jsNative + +[<Emit("new THREE.MeshStandardMaterial($0)")>] +let createMeshStandardMaterial (options: obj) : obj = jsNative + +[<Emit("new THREE.Mesh($0, $1)")>] +let createMesh (geometry: obj, material: obj) : ThreeNode = jsNative + +[<Emit("new THREE.AmbientLight($0, $1)")>] +let createAmbientLight (color: string, intensity: float) : ThreeNode = jsNative + +[<Emit("new THREE.DirectionalLight($0, $1)")>] +let createDirectionalLight (color: string, intensity: float) : ThreeNode = jsNative + +[<Emit("new EventSource($0)")>] +let createEventSource (url: string) : EventSource = jsNative + +[<Global("fetch")>] +let fetch (url: string, options: obj) : JS.Promise<FetchResponse> = jsNative + +[<Global("encodeURIComponent")>] +let encodeURIComponent (value: string) : string = jsNative + +[<Global("setInterval")>] +let setInterval (callback: unit -> unit) (milliseconds: int) : int = jsNative + +[<Global("clearInterval")>] +let clearInterval (handle: int) : unit = jsNative + +[<Global("requestAnimationFrame")>] +let requestAnimationFrame (callback: float -> unit) : int = jsNative diff --git a/src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj b/src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj new file mode 100644 index 0000000..3ce6225 --- /dev/null +++ b/src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <GenerateDocumentationFile>false</GenerateDocumentationFile> + <OutputType>Library</OutputType> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\SomhairlesDream.Shared\SomhairlesDream.Shared.fsproj" /> + <PackageReference Include="Fable.Browser.Dom" Version="2.20.0" /> + <PackageReference Include="Fable.Core" Version="4.5.0" /> + </ItemGroup> + + <ItemGroup> + <Compile Include="Bindings.fs" /> + <Compile Include="App.fs" /> + </ItemGroup> +</Project> diff --git a/src/SomhairlesDream.Modeling/BlenderBridge.fs b/src/SomhairlesDream.Modeling/BlenderBridge.fs new file mode 100644 index 0000000..c0795e9 --- /dev/null +++ b/src/SomhairlesDream.Modeling/BlenderBridge.fs @@ -0,0 +1,204 @@ +namespace SomhairlesDream.Modeling + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type BlenderProcessBridge(python: string, stageRoot: string, ?timeout: TimeSpan) = + let timeout = defaultArg timeout (TimeSpan.FromSeconds 120.) + + let bridgeError message = + if String.IsNullOrWhiteSpace(message) then + "blender bridge: process failed without diagnostics" + else + $"blender bridge: {message.Trim()}" + + let diagnostics (stderr: string) (stdout: string) = + let value = + if String.IsNullOrWhiteSpace(stderr) then stdout else stderr + + if String.IsNullOrWhiteSpace(value) then + "no process diagnostics" + elif value.Length > 2000 then + value.Substring(0, 2000) + else + value + + let strings (root: JsonElement) (name: string) = + let property = root.GetProperty(name) + + if property.ValueKind <> JsonValueKind.Array then + failwith $"'{name}' must be an array" + + let values = property.EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray + + if values |> Array.exists isNull then + failwith $"'{name}' contains a null value" + + values + + let utcTimestamp (root: JsonElement) (name: string) = + let value = root.GetProperty(name).GetDateTimeOffset() + + if value.Offset <> TimeSpan.Zero then + failwith $"'{name}' must be a UTC timestamp" + + value + + let parseResult (request: BridgeRequest) = + if not (File.Exists(request.ResultPath)) then + failwith $"fixture result file is missing: {request.ResultPath}" + + use document = JsonDocument.Parse(File.ReadAllText(request.ResultPath)) + let root = document.RootElement + + if not (root.GetProperty("ok").GetBoolean()) then + failwith "fixture returned ok=false" + + let renderPath = + let mutable property = Unchecked.defaultof<JsonElement> + + if root.TryGetProperty("renderPath", &property) then + Some(property.GetString()) + else + None + + let renderedAt = + let mutable property = Unchecked.defaultof<JsonElement> + + if root.TryGetProperty("renderedAt", &property) then + Some(utcTimestamp root "renderedAt") + else + None + + if request.RenderPath.IsSome <> renderPath.IsSome then + failwith "fixture render result did not match the request" + + if request.RenderPath.IsSome && request.RenderPath <> renderPath then + failwith "fixture returned an unexpected render path" + + { BlenderVersion = root.GetProperty("blenderVersion").GetString() + ExportedAt = utcTimestamp root "exportedAt" + ObjectIds = strings root "objectIds" + VerifiedObjectIds = strings root "verifiedObjectIds" + GlbBytes = root.GetProperty("glbBytes").GetInt64() + RenderPath = renderPath + RenderedAt = renderedAt } + + let writeProcessLog (path: string) (stdout: string) (stderr: string) = + match Path.GetDirectoryName(path) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + let content = + String.concat + Environment.NewLine + [| "[stdout]" + stdout + "[stderr]" + stderr |] + + File.WriteAllText(path, content) + + member private _.StartInfo(request: BridgeRequest) = + let scriptPath = Path.GetFullPath(Path.Combine(stageRoot, "blender", "fixture.py")) + let info = ProcessStartInfo() + info.FileName <- python + info.UseShellExecute <- false + info.CreateNoWindow <- true + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + info.ArgumentList.Add(scriptPath) + info.ArgumentList.Add("--step-id") + info.ArgumentList.Add(request.Step.StepId) + info.ArgumentList.Add("--output") + info.ArgumentList.Add(Path.GetFullPath(request.GlbPath)) + info.ArgumentList.Add("--result-output") + info.ArgumentList.Add(Path.GetFullPath(request.ResultPath)) + + match request.RenderPath with + | Some path -> + info.ArgumentList.Add("--render-output") + info.ArgumentList.Add(Path.GetFullPath(path)) + | None -> () + + info + + interface IArtifactBridge with + member this.Export(request, heartbeat) = + try + let scriptPath = Path.GetFullPath(Path.Combine(stageRoot, "blender", "fixture.py")) + + if not (File.Exists(scriptPath)) then + Error(bridgeError $"fixture is missing: {scriptPath}") + else + match Path.GetDirectoryName(request.GlbPath) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + match request.RenderPath with + | Some path -> + match Path.GetDirectoryName(path) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + | None -> () + + match Path.GetDirectoryName(request.ResultPath) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + match Path.GetDirectoryName(request.LogPath) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + if File.Exists(request.ResultPath) then + File.Delete(request.ResultPath) + + if File.Exists(request.LogPath) then + File.Delete(request.LogPath) + + use childProcess = new Process() + childProcess.StartInfo <- this.StartInfo(request) + + if not (childProcess.Start()) then + Error(bridgeError "process could not be started") + else + let stdoutTask = childProcess.StandardOutput.ReadToEndAsync() + let stderrTask = childProcess.StandardError.ReadToEndAsync() + let started = Stopwatch.StartNew() + let mutable timedOut = false + + while not childProcess.HasExited && not timedOut do + let remaining = timeout - started.Elapsed + + if remaining <= TimeSpan.Zero then + timedOut <- true + else + let waitMilliseconds = int (min 5000.0 remaining.TotalMilliseconds) + + if not (childProcess.WaitForExit(waitMilliseconds)) then + if childProcess.HasExited then + () + else + heartbeat () + + if timedOut && not childProcess.HasExited then + childProcess.Kill(true) + + childProcess.WaitForExit() + let stdout = stdoutTask.GetAwaiter().GetResult() + let stderr = stderrTask.GetAwaiter().GetResult() + writeProcessLog request.LogPath stdout stderr + + if timedOut then + Error(bridgeError $"process timed out after {timeout.TotalSeconds} seconds") + elif childProcess.ExitCode <> 0 then + Error(bridgeError $"process exited with code {childProcess.ExitCode}: {diagnostics stderr stdout}") + else + try + Ok(parseResult request) + with ex -> + Error(bridgeError $"invalid fixture result: {ex.Message}") + with ex -> + Error(bridgeError ex.Message) diff --git a/src/SomhairlesDream.Modeling/Definitions.fs b/src/SomhairlesDream.Modeling/Definitions.fs new file mode 100644 index 0000000..64e6bc6 --- /dev/null +++ b/src/SomhairlesDream.Modeling/Definitions.fs @@ -0,0 +1,31 @@ +namespace SomhairlesDream.Modeling + +open SomhairlesDream.Shared + +[<CLIMutable>] +type DesignStep = + { StepId: string + Index: int + ObjectIds: string array + Changes: ArtifactChange array } + +module Design = + let steps : DesignStep array = + [| { StepId = "01-foundation"; Index = 0 + ObjectIds = [| "heritage.foundation"; "heritage.deck" |] + Changes = + [| { ObjectId = "heritage.foundation"; Kind = "add"; Summary = "Add foundation slab" } + { ObjectId = "heritage.deck"; Kind = "add"; Summary = "Add raised deck" } |] } + { StepId = "02-frame"; Index = 1 + ObjectIds = + [| "heritage.foundation"; "heritage.deck"; "heritage.frame.left"; "heritage.frame.right"; "heritage.spine" |] + Changes = + [| { ObjectId = "heritage.frame.left"; Kind = "add"; Summary = "Add primary left frame" } + { ObjectId = "heritage.frame.right"; Kind = "add"; Summary = "Add primary right frame" } + { ObjectId = "heritage.spine"; Kind = "add"; Summary = "Add central spine" } |] } + { StepId = "03-cabin"; Index = 2 + ObjectIds = + [| "heritage.foundation"; "heritage.deck"; "heritage.frame.left"; "heritage.frame.right"; "heritage.spine"; "heritage.cabin"; "heritage.crossbeam" |] + Changes = + [| { ObjectId = "heritage.cabin"; Kind = "add"; Summary = "Add cabin volume" } + { ObjectId = "heritage.crossbeam"; Kind = "add"; Summary = "Add cabin crossbeam" } |] } |] diff --git a/src/SomhairlesDream.Modeling/Pipeline.fs b/src/SomhairlesDream.Modeling/Pipeline.fs new file mode 100644 index 0000000..3f1f105 --- /dev/null +++ b/src/SomhairlesDream.Modeling/Pipeline.fs @@ -0,0 +1,469 @@ +namespace SomhairlesDream.Modeling + +open System +open System.IO +open System.Security.Cryptography +open System.Text +open System.Text.Json +open System.Text.Json.Nodes +open SomhairlesDream.Shared + +[<CLIMutable>] +type BridgeRequest = + { Step: DesignStep + GlbPath: string + RenderPath: string option + ResultPath: string + LogPath: string } + +[<CLIMutable>] +type BridgeResult = + { BlenderVersion: string + ExportedAt: DateTimeOffset + ObjectIds: string array + VerifiedObjectIds: string array + GlbBytes: int64 + RenderPath: string option + RenderedAt: DateTimeOffset option } + +type IArtifactBridge = + abstract Export: request: BridgeRequest * heartbeat: (unit -> unit) -> Result<BridgeResult, string> + +[<CLIMutable>] +type PipelineOptions = + { ArtifactRoot: string + Ids: RunIds + Render: bool + Bridge: IArtifactBridge + Clock: unit -> DateTimeOffset + OnEvent: RunEvent -> unit } + +module private ArtifactFiles = + let runDirectory (root: string) (ids: RunIds) = Path.Combine(root, ids.ProjectId, ids.RunId) + + let toSystemPath (relativePath: string) = + relativePath.Replace('/', Path.DirectorySeparatorChar) + + let sha256 path = + use stream = File.OpenRead(path) + use sha = SHA256.Create() + sha.ComputeHash(stream) |> Convert.ToHexString |> fun value -> value.ToLowerInvariant() + + let byteCount path = FileInfo(path).Length + +module private ManifestJson = + let private options () = + let value = JsonSerializerOptions() + value.WriteIndented <- true + value + + let private stringArray (values: string array) = + let result = JsonArray() + + for value in values do + result.Add(JsonValue.Create(value)) + + result :> JsonNode + + let private change (value: ArtifactChange) = + let json = JsonObject() + json["objectId"] <- JsonValue.Create(value.ObjectId) + json["kind"] <- JsonValue.Create(value.Kind) + json["summary"] <- JsonValue.Create(value.Summary) + json :> JsonNode + + let private changes (values: ArtifactChange array) = + let result = JsonArray() + + for value in values do + result.Add(change value) + + result :> JsonNode + + let private render (value: RenderArtifact) = + let json = JsonObject() + json["artifactPath"] <- JsonValue.Create(value.ArtifactPath) + json["sha256"] <- JsonValue.Create(value.Sha256) + json["bytes"] <- JsonValue.Create(value.Bytes) + json["exportedAt"] <- JsonValue.Create(value.ExportedAt) + json :> JsonNode + + let private step (value: StepArtifact) = + let json = JsonObject() + json["stepId"] <- JsonValue.Create(value.StepId) + json["stepIndex"] <- JsonValue.Create(value.StepIndex) + json["artifactPath"] <- JsonValue.Create(value.ArtifactPath) + json["logPath"] <- JsonValue.Create(value.LogPath) + json["sha256"] <- JsonValue.Create(value.Sha256) + json["bytes"] <- JsonValue.Create(value.Bytes) + json["exportedAt"] <- JsonValue.Create(value.ExportedAt) + json["objectIds"] <- stringArray value.ObjectIds + json["verifiedObjectIds"] <- stringArray value.VerifiedObjectIds + json["changes"] <- changes value.Changes + + match value.Render with + | Some renderValue -> json["render"] <- render renderValue + | None -> () + + json :> JsonNode + + let serialize (manifest: ArtifactManifest) = + let json = JsonObject() + let steps = JsonArray() + + for value in manifest.Steps do + steps.Add(step value) + + json["schemaVersion"] <- JsonValue.Create(manifest.SchemaVersion) + json["projectId"] <- JsonValue.Create(manifest.ProjectId) + json["runId"] <- JsonValue.Create(manifest.RunId) + json["baseId"] <- JsonValue.Create(manifest.BaseId) + json["targetId"] <- JsonValue.Create(manifest.TargetId) + json["status"] <- JsonValue.Create(manifest.Status) + json["startedAt"] <- JsonValue.Create(manifest.StartedAt) + json["completedAt"] <- JsonValue.Create(manifest.CompletedAt) + json["blenderVersion"] <- JsonValue.Create(manifest.BlenderVersion) + json["steps"] <- steps + json.ToJsonString(options()) + +module Pipeline = + let private validateIds (ids: RunIds) = + [| ids.ProjectId; ids.RunId; ids.BaseId; ids.TargetId |] + |> Array.tryFind (Ids.validateResourceId >> Result.isError) + |> function + | Some invalid -> Error $"invalid resource id: {invalid}" + | None -> Ok () + + let private appendEvent (log: StreamWriter) (onEvent: RunEvent -> unit) event = + log.WriteLine(RunEvent.toJson event) + log.Flush() + log.BaseStream.Flush() + onEvent event + + let private writeManifest path manifest = + let temporaryPath = path + ".tmp" + let bytes = Encoding.UTF8.GetBytes(ManifestJson.serialize manifest) + + use stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough) + stream.Write(bytes, 0, bytes.Length) + stream.Flush(true) + File.Move(temporaryPath, path) + + let private verifyBridgeResult (step: DesignStep) (request: BridgeRequest) (result: BridgeResult) = + if not (File.Exists(request.GlbPath)) || ArtifactFiles.byteCount request.GlbPath = 0L then + Error $"bridge did not produce a non-empty GLB for {step.StepId}" + elif result.GlbBytes <> ArtifactFiles.byteCount request.GlbPath then + Error $"bridge GLB byte count did not match for {step.StepId}" + elif result.ExportedAt.Offset <> TimeSpan.Zero then + Error $"bridge export timestamp was not UTC for {step.StepId}" + elif result.RenderedAt |> Option.exists (fun value -> value.Offset <> TimeSpan.Zero) then + Error $"bridge render timestamp was not UTC for {step.StepId}" + elif request.RenderPath.IsSome <> result.RenderPath.IsSome then + Error $"bridge render result did not match the request for {step.StepId}" + elif request.RenderPath.IsSome && request.RenderPath <> result.RenderPath then + Error $"bridge render path did not match the request for {step.StepId}" + elif result.ObjectIds <> step.ObjectIds || result.VerifiedObjectIds <> step.ObjectIds then + Error $"object ids did not match expected values for {step.StepId}" + elif result.ObjectIds |> Array.exists (Ids.validateObjectId >> Result.isError) then + Error $"bridge returned an invalid object id for {step.StepId}" + else + Ok () + + let private renderArtifact runDirectory (step: DesignStep) path renderedAt = + if not (File.Exists(path)) || ArtifactFiles.byteCount path = 0L then + Error $"bridge did not produce a non-empty render for {step.StepId}" + else + let relativePath = $"steps/{step.StepId}.png" + let destination = Path.Combine(runDirectory, ArtifactFiles.toSystemPath relativePath) + File.Move(path, destination) + + Ok + { ArtifactPath = relativePath + Sha256 = ArtifactFiles.sha256 destination + Bytes = ArtifactFiles.byteCount destination + ExportedAt = renderedAt } + + let run (options: PipelineOptions) = + match validateIds options.Ids with + | Error message -> Error message + | Ok () -> + let runDirectory = ArtifactFiles.runDirectory options.ArtifactRoot options.Ids + + if Directory.Exists(runDirectory) then + Error "run directory already exists" + else + let stepsDirectory = Path.Combine(runDirectory, "steps") + let logsDirectory = Path.Combine(runDirectory, "logs") + let stagingDirectory = Path.Combine(runDirectory, ".staging") + let eventsPath = Path.Combine(runDirectory, "events.ndjson") + let manifestPath = Path.Combine(runDirectory, "manifest.json") + let startedAt = options.Clock() + let mutable currentStep: string option = None + let mutable eventLog: StreamWriter option = None + let mutable blenderVersion: string option = None + + try + Directory.CreateDirectory(runDirectory) |> ignore + Directory.CreateDirectory(stepsDirectory) |> ignore + Directory.CreateDirectory(logsDirectory) |> ignore + Directory.CreateDirectory(stagingDirectory) |> ignore + + use log = new StreamWriter(File.Open(eventsPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read), UTF8Encoding(false)) + eventLog <- Some log + + let emit event = appendEvent log options.OnEvent event + let ids = options.Ids + + emit (RunEvent.Start { Ids = ids; At = startedAt; TotalSteps = Design.steps.Length }) + + let artifacts = + [| for step in Design.steps do + currentStep <- Some step.StepId + emit (RunEvent.Heartbeat { Ids = ids; At = options.Clock(); Message = $"starting {step.StepId}" }) + + let glbStagingPath = Path.GetFullPath(Path.Combine(stagingDirectory, $"{step.StepId}.glb")) + let renderStagingPath = Path.GetFullPath(Path.Combine(stagingDirectory, $"{step.StepId}.png")) + let resultStagingPath = Path.GetFullPath(Path.Combine(stagingDirectory, $"{step.StepId}.result.json")) + let logPath = Path.GetFullPath(Path.Combine(logsDirectory, $"{step.StepId}.log")) + let request = + { Step = step + GlbPath = glbStagingPath + RenderPath = if options.Render then Some renderStagingPath else None + ResultPath = resultStagingPath + LogPath = logPath } + + let bridgeResult = + match options.Bridge.Export(request, fun () -> emit (RunEvent.Heartbeat { Ids = ids; At = options.Clock(); Message = $"heartbeat {step.StepId}" })) with + | Ok value -> value + | Error message -> failwith message + + blenderVersion <- Some bridgeResult.BlenderVersion + + match verifyBridgeResult step request bridgeResult with + | Ok () -> () + | Error message -> failwith message + + let relativePath = $"steps/{step.StepId}.glb" + let destination = Path.Combine(runDirectory, ArtifactFiles.toSystemPath relativePath) + File.Move(glbStagingPath, destination) + + let render = + if options.Render then + let renderedAt = bridgeResult.RenderedAt |> Option.defaultValue bridgeResult.ExportedAt + + match renderArtifact runDirectory step renderStagingPath renderedAt with + | Ok value -> Some value + | Error message -> failwith message + else + None + + let artifact = + { StepId = step.StepId + StepIndex = step.Index + ArtifactPath = relativePath + LogPath = $"logs/{step.StepId}.log" + Sha256 = ArtifactFiles.sha256 destination + Bytes = ArtifactFiles.byteCount destination + ExportedAt = bridgeResult.ExportedAt + ObjectIds = step.ObjectIds + VerifiedObjectIds = bridgeResult.VerifiedObjectIds + Changes = step.Changes + Render = render } + + let checkpoint = + { Ids = ids + At = options.Clock() + StepId = step.StepId + StepIndex = step.Index + TotalSteps = Design.steps.Length + Message = "artifact verified" + ArtifactPath = relativePath } + + emit (RunEvent.Checkpoint checkpoint) + yield artifact |] + + let completedAt = options.Clock() + + let manifest = + { SchemaVersion = 1 + ProjectId = ids.ProjectId + RunId = ids.RunId + BaseId = ids.BaseId + TargetId = ids.TargetId + Status = "complete" + StartedAt = startedAt + CompletedAt = completedAt + BlenderVersion = blenderVersion |> Option.defaultValue "unknown" + Steps = artifacts } + + writeManifest manifestPath manifest + emit (RunEvent.Complete { Ids = ids; At = completedAt; ManifestPath = "manifest.json" }) + currentStep <- None + if Directory.Exists(stagingDirectory) then + Directory.Delete(stagingDirectory, true) + Ok manifest + with ex -> + match eventLog with + | Some log -> + try + appendEvent log options.OnEvent (RunEvent.Fail { Ids = options.Ids; At = options.Clock(); StepId = currentStep; Message = ex.Message }) + with _ -> () + | None -> () + + if Directory.Exists(stagingDirectory) then + Directory.Delete(stagingDirectory, true) + + Error ex.Message + +module ArtifactVerifier = + let private stringProperty (value: JsonElement) (name: string) = + let result = value.GetProperty(name).GetString() + + if isNull result then + failwith $"manifest property '{name}' is null" + else + result + + let private parseRender (value: JsonElement) = + let mutable renderValue = Unchecked.defaultof<JsonElement> + + if value.TryGetProperty("render", &renderValue) then + Some + { ArtifactPath = stringProperty renderValue "artifactPath" + Sha256 = stringProperty renderValue "sha256" + Bytes = renderValue.GetProperty("bytes").GetInt64() + ExportedAt = renderValue.GetProperty("exportedAt").GetDateTimeOffset() } + else + None + + let private parseStep (value: JsonElement) : StepArtifact = + { StepId = stringProperty value "stepId" + StepIndex = value.GetProperty("stepIndex").GetInt32() + ArtifactPath = stringProperty value "artifactPath" + LogPath = stringProperty value "logPath" + Sha256 = stringProperty value "sha256" + Bytes = value.GetProperty("bytes").GetInt64() + ExportedAt = value.GetProperty("exportedAt").GetDateTimeOffset() + ObjectIds = value.GetProperty("objectIds").EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray + VerifiedObjectIds = value.GetProperty("verifiedObjectIds").EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray + Changes = + value.GetProperty("changes").EnumerateArray() + |> Seq.map (fun item -> + { ObjectId = stringProperty item "objectId" + Kind = stringProperty item "kind" + Summary = stringProperty item "summary" }) + |> Seq.toArray + Render = parseRender value } + + let private parseManifest (value: JsonElement) : ArtifactManifest = + { SchemaVersion = value.GetProperty("schemaVersion").GetInt32() + ProjectId = stringProperty value "projectId" + RunId = stringProperty value "runId" + BaseId = stringProperty value "baseId" + TargetId = stringProperty value "targetId" + Status = stringProperty value "status" + StartedAt = value.GetProperty("startedAt").GetDateTimeOffset() + CompletedAt = value.GetProperty("completedAt").GetDateTimeOffset() + BlenderVersion = stringProperty value "blenderVersion" + Steps = value.GetProperty("steps").EnumerateArray() |> Seq.map parseStep |> Seq.toArray } + + let private safePath runDirectory relativePath = + if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then + Error "artifact path must be relative" + else + let normalized = relativePath.Replace('\\', '/') + let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) + + if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then + Error "artifact path contains traversal" + else + let fullPath = Path.GetFullPath(Path.Combine(runDirectory, ArtifactFiles.toSystemPath normalized)) + let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar + + if fullPath.StartsWith(prefix, StringComparison.Ordinal) then + Ok fullPath + else + Error "artifact path escapes run directory" + + let private verifyFile runDirectory expectedPath expectedHash expectedBytes = + match safePath runDirectory expectedPath with + | Error message -> Error message + | Ok path when not (File.Exists(path)) -> Error $"artifact is missing: {expectedPath}" + | Ok path when FileInfo(path).Length <> expectedBytes -> Error $"artifact byte count mismatch: {expectedPath}" + | Ok path when ArtifactFiles.sha256 path <> expectedHash -> Error $"artifact hash mismatch: {expectedPath}" + | Ok _ -> Ok () + + let private verifyLogFile runDirectory expectedPath = + match safePath runDirectory expectedPath with + | Error message -> Error message + | Ok path when not (File.Exists(path)) -> Error $"log is missing: {expectedPath}" + | Ok path when FileInfo(path).Length = 0L -> Error $"log is empty: {expectedPath}" + | Ok _ -> Ok () + + let verify manifestPath = + try + if not (File.Exists(manifestPath)) then + Error "manifest is missing" + elif Path.GetFileName(manifestPath) <> "manifest.json" then + Error "manifest path must be manifest.json" + else + use document = JsonDocument.Parse(File.ReadAllText(manifestPath)) + let manifest = parseManifest document.RootElement + let runDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath)) + let projectDirectory = Directory.GetParent(runDirectory).Name + let runDirectoryName = DirectoryInfo(runDirectory).Name + + if manifest.SchemaVersion <> 1 then + Error "unsupported manifest schema" + elif manifest.Status <> "complete" then + Error "manifest is not complete" + elif manifest.ProjectId <> projectDirectory || manifest.RunId <> runDirectoryName then + Error "manifest identity does not match its directory" + elif Ids.validateResourceId manifest.ProjectId |> Result.isError + || Ids.validateResourceId manifest.RunId |> Result.isError + || Ids.validateResourceId manifest.BaseId |> Result.isError + || Ids.validateResourceId manifest.TargetId |> Result.isError then + Error "manifest contains an invalid resource id" + elif manifest.Steps.Length <> Design.steps.Length then + Error "manifest step count does not match design" + else + let mutable failure: string option = None + + for expected, actual in Array.zip Design.steps manifest.Steps do + if failure.IsNone && actual.StepId <> expected.StepId then + failure <- Some $"manifest step id mismatch: {expected.StepId}" + elif failure.IsNone && actual.StepIndex <> expected.Index then + failure <- Some $"manifest step index mismatch: {expected.StepId}" + elif failure.IsNone && actual.ObjectIds <> expected.ObjectIds then + failure <- Some $"manifest source object ids mismatch: {expected.StepId}" + elif failure.IsNone && actual.VerifiedObjectIds <> expected.ObjectIds then + failure <- Some $"manifest verified object ids mismatch: {expected.StepId}" + elif failure.IsNone && actual.Changes <> expected.Changes then + failure <- Some $"manifest changes mismatch: {expected.StepId}" + elif failure.IsNone then + let expectedPath = $"steps/{expected.StepId}.glb" + let expectedLogPath = $"logs/{expected.StepId}.log" + + if actual.ArtifactPath <> expectedPath then + failure <- Some $"manifest artifact path mismatch: {expected.StepId}" + elif actual.LogPath <> expectedLogPath then + failure <- Some $"manifest log path mismatch: {expected.StepId}" + else + match verifyLogFile runDirectory actual.LogPath with + | Error message -> failure <- Some message + | Ok () -> + match verifyFile runDirectory actual.ArtifactPath actual.Sha256 actual.Bytes with + | Error message -> failure <- Some message + | Ok () -> + match actual.Render with + | Some render -> + match verifyFile runDirectory render.ArtifactPath render.Sha256 render.Bytes with + | Error message -> failure <- Some message + | Ok () -> () + | None -> () + + match failure with + | Some message -> Error message + | None -> Ok manifest + with ex -> + Error $"invalid manifest: {ex.Message}" diff --git a/src/SomhairlesDream.Modeling/Placeholder.fs b/src/SomhairlesDream.Modeling/Placeholder.fs new file mode 100644 index 0000000..b5e4368 --- /dev/null +++ b/src/SomhairlesDream.Modeling/Placeholder.fs @@ -0,0 +1,4 @@ +namespace SomhairlesDream.Modeling + +module Placeholder = + let ready = false diff --git a/src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj b/src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj new file mode 100644 index 0000000..cf9312e --- /dev/null +++ b/src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj @@ -0,0 +1,15 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <RootNamespace>SomhairlesDream.Modeling</RootNamespace> + <AssemblyName>SomhairlesDream.Modeling</AssemblyName> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="..\SomhairlesDream.Shared\SomhairlesDream.Shared.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Definitions.fs" /> + <Compile Include="Pipeline.fs" /> + <Compile Include="BlenderBridge.fs" /> + </ItemGroup> +</Project> diff --git a/src/SomhairlesDream.Server/ArtifactRunApi.fs b/src/SomhairlesDream.Server/ArtifactRunApi.fs new file mode 100644 index 0000000..8d76ffc --- /dev/null +++ b/src/SomhairlesDream.Server/ArtifactRunApi.fs @@ -0,0 +1,161 @@ +namespace SomhairlesDream.Server + +open System +open System.IO +open System.Text.Json +open System.Threading.Tasks +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open SomhairlesDream.Shared + +[<CLIMutable>] +type RunStartRequest = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string + Render: bool } + +type ArtifactRunApiOptions = + { Coordinator: ArtifactRunCoordinator + Clock: unit -> DateTimeOffset + JsonOptions: JsonSerializerOptions } + +module ArtifactRunApi = + let private json options value = Results.Json(value, options.JsonOptions) + + let private jsonWithStatus options statusCode value = + Results.Json(value, options.JsonOptions, statusCode = Nullable<int>(statusCode)) + + let private error options statusCode message = + jsonWithStatus options statusCode {| error = message |} + + let private readStartRequest options (request: HttpRequest) = + task { + try + let! value = request.ReadFromJsonAsync<RunStartRequest>(options.JsonOptions) + + if isNull (box value) then + return Error "request body must be a JSON object" + else + return Ok value + with + | :? JsonException as ex -> return Error $"invalid JSON: {ex.Message}" + } + + let private start options (context: HttpContext) : Task<IResult> = + task { + let! request = readStartRequest options context.Request + + match request with + | Error message -> return error options 400 message + | Ok request -> + let ids : RunIds = + { ProjectId = request.ProjectId + RunId = request.RunId + BaseId = request.BaseId + TargetId = request.TargetId } + + match options.Coordinator.Start(ids, request.Render) with + | Ok(Accepted snapshot) -> return jsonWithStatus options 202 snapshot + | Ok(Conflict snapshot) -> return jsonWithStatus options 409 snapshot + | Error message when message = "run registry entry disappeared" -> + return error options 500 message + | Error message -> return error options 400 message + } + + let private queryValue (context: HttpContext) name = + let value = context.Request.Query[name].ToString() + + if String.IsNullOrWhiteSpace(value) then + None + else + Some value + + let private selector context = + match queryValue context "projectId", queryValue context "runId" with + | Some projectId, Some runId -> + match Ids.validateResourceId projectId, Ids.validateResourceId runId with + | Error message, _ -> Error $"invalid projectId: {message}" + | _, Error message -> Error $"invalid runId: {message}" + | Ok (), Ok () -> Ok(projectId, runId) + | None, _ -> Error "missing query parameter 'projectId'" + | _, None -> Error "missing query parameter 'runId'" + + let private manifest options (context: HttpContext) = + match selector context with + | Error message -> error options 400 message + | Ok(projectId, runId) -> + match options.Coordinator.Manifest(projectId, runId) with + | Ok value -> json options value + | Error RunNotFound -> error options 404 "run not found" + | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot + | Error(InvalidManifest message) -> error options 500 message + | Error(InvalidArtifactPath message) -> error options 500 message + | Error ArtifactNotFound -> error options 500 "manifest not found" + + let private artifactContentType (path: string) = + match Path.GetExtension(path).ToLowerInvariant() with + | ".glb" -> "model/gltf-binary" + | ".json" -> "application/json" + | ".png" -> "image/png" + | ".log" -> "text/plain" + | _ -> "application/octet-stream" + + let private artifact options (context: HttpContext) = + match selector context, queryValue context "path" with + | Error message, _ -> error options 400 message + | _, None -> error options 400 "missing query parameter 'path'" + | Ok(projectId, runId), Some relativePath -> + match options.Coordinator.Artifact(projectId, runId, relativePath) with + | Ok path -> Results.File(path, artifactContentType path) + | Error RunNotFound -> error options 404 "run not found" + | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot + | Error(InvalidArtifactPath message) -> error options 400 message + | Error ArtifactNotFound -> error options 404 "artifact not found" + | Error(InvalidManifest message) -> error options 500 message + + let private writeError options (context: HttpContext) statusCode message = + task { + context.Response.StatusCode <- statusCode + context.Response.ContentType <- "application/json" + let payload = JsonSerializer.Serialize({| error = message |}, options.JsonOptions) + do! context.Response.WriteAsync(payload) + } + + let private events options (context: HttpContext) : Task = + task { + match selector context with + | Error message -> do! writeError options context 400 message + | Ok(projectId, runId) -> + match options.Coordinator.Subscribe(projectId, runId) with + | None -> do! writeError options context 404 "run not found" + | Some subscription -> + use _lifetime = subscription :> IDisposable + context.Response.StatusCode <- 200 + context.Response.ContentType <- "text/event-stream" + context.Response.Headers.CacheControl <- "no-cache" + context.Response.Headers["X-Accel-Buffering"] <- "no" + + try + while not context.RequestAborted.IsCancellationRequested do + let! snapshot = subscription.Reader.ReadAsync(context.RequestAborted).AsTask() + let payload = JsonSerializer.Serialize(snapshot, options.JsonOptions) + do! context.Response.WriteAsync($"data: {payload}\n\n", context.RequestAborted) + do! context.Response.Body.FlushAsync(context.RequestAborted) + with + | :? OperationCanceledException -> () + } + + let register (app: WebApplication) options = + app.MapPost("/api/runs/start", Func<HttpContext, Task<IResult>>(fun context -> start options context)) + |> ignore + + app.MapGet("/api/runs/events", Func<HttpContext, Task>(fun context -> events options context)) + |> ignore + + app.MapGet("/api/artifacts/manifest", Func<HttpContext, IResult>(fun context -> manifest options context)) + |> ignore + + app.MapGet("/api/artifacts/file", Func<HttpContext, IResult>(fun context -> artifact options context)) + |> ignore diff --git a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs new file mode 100644 index 0000000..78d5dee --- /dev/null +++ b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs @@ -0,0 +1,141 @@ +namespace SomhairlesDream.Server + +open System +open System.IO +open System.Threading.Tasks +open SomhairlesDream.Modeling +open SomhairlesDream.Shared + +type RunStartOutcome = + | Accepted of RunSnapshot + | Conflict of RunSnapshot + +type ArtifactLookupError = + | RunNotFound + | RunNotComplete of RunSnapshot + | InvalidArtifactPath of string + | ArtifactNotFound + | InvalidManifest of string + +type ArtifactRunCoordinator( + artifactRoot: string, + bridgeFactory: unit -> IArtifactBridge, + clock: unit -> DateTimeOffset, + staleAfter: TimeSpan +) = + let root = Path.GetFullPath(artifactRoot) + let registry = ArtifactRunRegistry(staleAfter, clock) + + let validateIds (ids: RunIds) = + [| ids.ProjectId; ids.RunId; ids.BaseId; ids.TargetId |] + |> Array.tryFind (Ids.validateResourceId >> Result.isError) + |> function + | Some invalid -> Error $"invalid resource id: {invalid}" + | None -> Ok () + + let runDirectory (projectId: string) (runId: string) = + Path.Combine(root, projectId, runId) + + let failIfNeeded (store: ArtifactRunStore) (ids: RunIds) message = + let snapshot = store.Snapshot() + + if snapshot.Status = "idle" || snapshot.Status = "running" then + store.Apply( + RunEvent.Fail + { Ids = ids + At = clock () + StepId = snapshot.CurrentStepId + Message = message } + ) + |> ignore + + let runPipeline (store: ArtifactRunStore) (ids: RunIds) render = + try + let options : PipelineOptions = + { ArtifactRoot = root + Ids = ids + Render = render + Bridge = bridgeFactory () + Clock = clock + OnEvent = + fun event -> + match store.Apply event with + | Ok _ -> () + | Error message -> invalidOp message } + + match Pipeline.run options with + | Ok _ -> () + | Error message -> failIfNeeded store ids message + with ex -> + failIfNeeded store ids ex.Message + + let safeArtifactPath (runDirectory: string) (relativePath: string) = + if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then + Error "artifact path must be relative" + else + let normalized = relativePath.Replace('\\', '/') + let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries) + + if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then + Error "artifact path contains traversal" + else + let fullPath = Path.GetFullPath(Path.Combine(runDirectory, normalized.Replace('/', Path.DirectorySeparatorChar))) + let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar + + if fullPath.StartsWith(prefix, StringComparison.Ordinal) then + Ok fullPath + else + Error "artifact path escapes run directory" + + member _.ArtifactRoot = root + + member _.Start(ids: RunIds, render: bool) : Result<RunStartOutcome, string> = + match validateIds ids with + | Error message -> Error message + | Ok () -> + match registry.TryCreate(ids) with + | Some store -> + Task.Run(fun () -> runPipeline store ids render) |> ignore + Ok(Accepted(store.Snapshot())) + | None -> + match registry.TryFind(ids.ProjectId, ids.RunId) with + | Some store -> Ok(Conflict(store.Observe(clock ()))) + | None -> Error "run registry entry disappeared" + + member _.TryFind(projectId: string, runId: string) = registry.TryFind(projectId, runId) + + member _.Subscribe(projectId: string, runId: string) = + registry.TryFind(projectId, runId) |> Option.map (fun store -> store.Subscribe()) + + member _.ObserveAll() = registry.ObserveAll(clock ()) + + member _.Manifest(projectId: string, runId: string) : Result<ArtifactManifest, ArtifactLookupError> = + match registry.TryFind(projectId, runId) with + | None -> Error RunNotFound + | Some store -> + let snapshot = store.Observe(clock ()) + + if snapshot.Status <> "complete" then + Error(RunNotComplete snapshot) + else + let path = Path.Combine(runDirectory projectId runId, "manifest.json") + + match ArtifactVerifier.verify path with + | Ok manifest -> Ok manifest + | Error message -> Error(InvalidManifest message) + + member _.Artifact(projectId: string, runId: string, relativePath: string) : Result<string, ArtifactLookupError> = + match registry.TryFind(projectId, runId) with + | None -> Error RunNotFound + | Some store -> + let snapshot = store.Observe(clock ()) + + if snapshot.Status <> "complete" then + Error(RunNotComplete snapshot) + else + let directory = runDirectory projectId runId + + match safeArtifactPath directory relativePath with + | Error message -> Error(InvalidArtifactPath message) + | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound + | Ok path -> Ok path diff --git a/src/SomhairlesDream.Server/Program.fs b/src/SomhairlesDream.Server/Program.fs new file mode 100644 index 0000000..53cfef6 --- /dev/null +++ b/src/SomhairlesDream.Server/Program.fs @@ -0,0 +1,97 @@ +open System +open System.IO +open System.Text.Json +open System.Threading +open System.Threading.Tasks +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.FileProviders +open Microsoft.Extensions.Hosting +open SomhairlesDream.Server +open SomhairlesDream.Shared + +let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs() |> Array.skip 1) +let publicRoot = Path.Combine(AppContext.BaseDirectory, "public") +builder.Environment.WebRootPath <- publicRoot +builder.Environment.WebRootFileProvider <- new PhysicalFileProvider(publicRoot) + +let app = builder.Build() +let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) +let jsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) + +let environmentOrDefault name fallback = + match Environment.GetEnvironmentVariable(name) with + | value when not (String.IsNullOrWhiteSpace(value)) -> value + | _ -> fallback + +let artifactRoot = environmentOrDefault "SOMHAIRLES_ARTIFACT_ROOT" (Path.Combine(Environment.CurrentDirectory, ".artifacts")) +let stageRoot = environmentOrDefault "SOMHAIRLES_STAGE_ROOT" (Path.Combine(Environment.CurrentDirectory, "stage")) +let python = environmentOrDefault "SOMHAIRLES_BPYTHON" "python3" +let artifactClock () = DateTimeOffset.UtcNow +let coordinator = + ArtifactRunCoordinator( + artifactRoot, + (fun () -> SomhairlesDream.Modeling.BlenderProcessBridge(python, stageRoot, TimeSpan.FromSeconds 120.) :> SomhairlesDream.Modeling.IArtifactBridge), + artifactClock, + TimeSpan.FromMinutes 2. + ) + +let json value = Results.Json(value, jsonOptions) + +let events (context: HttpContext) : Task = + (task { + context.Response.ContentType <- "text/event-stream" + context.Response.Headers.CacheControl <- "no-cache" + + let reader = store.Subscribe() + + try + while not context.RequestAborted.IsCancellationRequested do + let! snapshot = reader.ReadAsync(context.RequestAborted).AsTask() + let payload = JsonSerializer.Serialize(snapshot, jsonOptions) + do! context.Response.WriteAsync($"data: {payload}\n\n", context.RequestAborted) + do! context.Response.Body.FlushAsync(context.RequestAborted) + with + | :? OperationCanceledException -> () + } :> Task) + +app.UseDefaultFiles() |> ignore +app.UseStaticFiles() |> ignore + +app.MapGet("/health", Func<IResult>(fun () -> Results.Ok({| status = "ok" |}))) |> ignore + +ArtifactRunApi.register + app + { Coordinator = coordinator + Clock = artifactClock + JsonOptions = jsonOptions } + +let observeRuns = + Task.Run( + Func<Task>(fun () -> + task { + try + while not app.Lifetime.ApplicationStopping.IsCancellationRequested do + coordinator.ObserveAll() |> ignore + do! Task.Delay(TimeSpan.FromSeconds 1., app.Lifetime.ApplicationStopping) + with + | :? OperationCanceledException -> () + } :> Task) + ) + +app.MapGet( + "/api/state", + Func<IResult>(fun () -> store.Observe(DateTimeOffset.UtcNow) |> json) +) +|> ignore + +app.MapPost( + "/api/runs/heartbeat", + Func<IResult>(fun () -> store.Heartbeat(DateTimeOffset.UtcNow) |> json) +) +|> ignore + +app.MapGet("/api/events", Func<HttpContext, Task>(events)) |> ignore + +app.Run() diff --git a/src/SomhairlesDream.Server/SomhairlesDream.Server.fsproj b/src/SomhairlesDream.Server/SomhairlesDream.Server.fsproj new file mode 100644 index 0000000..17c159d --- /dev/null +++ b/src/SomhairlesDream.Server/SomhairlesDream.Server.fsproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <RootNamespace>SomhairlesDream.Server</RootNamespace> + <AssemblyName>SomhairlesDream.Server</AssemblyName> + <OutputType>Exe</OutputType> + </PropertyGroup> + <ItemGroup> + <ProjectReference Include="../SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj" /> + <ProjectReference Include="../SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="Store.fs" /> + <Compile Include="ArtifactRunCoordinator.fs" /> + <Compile Include="ArtifactRunApi.fs" /> + <Compile Include="Program.fs" /> + </ItemGroup> + <ItemGroup> + <Content Include="../../public/**/*" Link="public/%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" /> + </ItemGroup> +</Project> diff --git a/src/SomhairlesDream.Server/Store.fs b/src/SomhairlesDream.Server/Store.fs new file mode 100644 index 0000000..b4a2293 --- /dev/null +++ b/src/SomhairlesDream.Server/Store.fs @@ -0,0 +1,259 @@ +namespace SomhairlesDream.Server + +open System +open System.Collections.Generic +open System.Threading.Channels +open SomhairlesDream.Shared + +type RunStore(projectId: string, runId: string, staleAfter: TimeSpan) = + let gate = obj () + let mutable state = RunState.initial projectId runId DateTimeOffset.UtcNow + let subscribers = ResizeArray<Channel<LiveSnapshot>>() + + let update transition = + lock gate (fun () -> + state <- transition state + for subscriber in subscribers do + subscriber.Writer.TryWrite(state) |> ignore + state) + + member _.Snapshot() = lock gate (fun () -> state) + + member _.Start(now: DateTimeOffset) = update (RunState.start now) + + member _.Heartbeat(now: DateTimeOffset) = update (RunState.heartbeat now) + + member _.Observe(now: DateTimeOffset) = update (RunState.statusAt staleAfter now) + + member _.Publish(now: DateTimeOffset, mesh: MeshSnapshot) = update (RunState.publish now mesh) + + member _.Subscribe() : ChannelReader<LiveSnapshot> = + let channel = Channel.CreateUnbounded<LiveSnapshot>() + + lock gate (fun () -> + channel.Writer.TryWrite(state) |> ignore + subscribers.Add(channel) + channel.Reader) + +type ArtifactRunSubscription(reader: ChannelReader<RunSnapshot>, dispose: unit -> unit) = + let mutable disposed = false + + member _.Reader = reader + + member _.Dispose() = + if not disposed then + disposed <- true + dispose () + + interface IDisposable with + member this.Dispose() = this.Dispose() + +type ArtifactRunStore(ids: RunIds, staleAfter: TimeSpan, initialAt: DateTimeOffset) = + let gate = obj () + let subscribers = ResizeArray<Channel<RunSnapshot>>() + let mutable state : RunSnapshot = + { ProjectId = ids.ProjectId + RunId = ids.RunId + BaseId = ids.BaseId + TargetId = ids.TargetId + Status = "idle" + CurrentStepId = None + CurrentStepIndex = -1 + TotalSteps = 0 + LastHeartbeatAt = None + UpdatedAt = initialAt + Message = "waiting for design run" + ManifestPath = None + ManifestUrl = None + Error = None + IsStale = false } + + let publish next = + for subscriber in subscribers do + subscriber.Writer.TryWrite(next) |> ignore + + let eventIds = function + | RunEvent.Start value -> value.Ids + | RunEvent.Heartbeat value -> value.Ids + | RunEvent.Checkpoint value -> value.Ids + | RunEvent.Complete value -> value.Ids + | RunEvent.Fail value -> value.Ids + + let eventName = function + | RunEvent.Start _ -> "start" + | RunEvent.Heartbeat _ -> "heartbeat" + | RunEvent.Checkpoint _ -> "checkpoint" + | RunEvent.Complete _ -> "complete" + | RunEvent.Fail _ -> "fail" + + let rejectIfNotRunning event = + if state.Status <> "running" then + Some $"cannot apply {eventName event} to run in status {state.Status}" + else + None + + let checkpointCount () = + if state.CurrentStepIndex < 0 then 0 else state.CurrentStepIndex + 1 + + let commit next = + state <- next + publish next + Ok next + + member _.Snapshot() = lock gate (fun () -> state) + + member _.Apply(event: RunEvent) : Result<RunSnapshot, string> = + lock gate (fun () -> + if eventIds event <> ids then + Error "run event identity does not match the selected run" + else + match event with + | RunEvent.Start value -> + if state.Status <> "idle" then + Error $"cannot apply start to run in status {state.Status}" + elif value.TotalSteps <= 0 then + Error "run must contain at least one step" + else + commit + { state with + Status = "running" + TotalSteps = value.TotalSteps + CurrentStepId = None + CurrentStepIndex = -1 + LastHeartbeatAt = Some value.At + UpdatedAt = value.At + Message = "design run started" + Error = None + IsStale = false } + | RunEvent.Heartbeat value -> + match rejectIfNotRunning event with + | Some message -> Error message + | None -> + commit + { state with + LastHeartbeatAt = Some value.At + UpdatedAt = value.At + Message = value.Message + IsStale = false } + | RunEvent.Checkpoint value -> + match rejectIfNotRunning event with + | Some message -> Error message + | None when value.TotalSteps <> state.TotalSteps -> + Error "checkpoint total steps do not match run" + | None when value.StepIndex < 0 || value.StepIndex >= state.TotalSteps -> + Error "checkpoint step index is outside the run" + | None when value.StepIndex <> checkpointCount () -> + Error "checkpoint step index is out of order" + | None -> + commit + { state with + CurrentStepId = Some value.StepId + CurrentStepIndex = value.StepIndex + LastHeartbeatAt = Some value.At + UpdatedAt = value.At + Message = value.Message + IsStale = false } + | RunEvent.Complete value -> + match rejectIfNotRunning event with + | Some message -> Error message + | None when checkpointCount () <> state.TotalSteps -> + Error "cannot complete run before every step is checkpointed" + | None -> + commit + { state with + Status = "complete" + CurrentStepId = None + LastHeartbeatAt = Some value.At + UpdatedAt = value.At + Message = "design run complete" + ManifestPath = Some value.ManifestPath + ManifestUrl = Some $"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={ids.RunId}" + Error = None + IsStale = false } + | RunEvent.Fail value -> + if state.Status <> "idle" && state.Status <> "running" then + Error $"cannot apply fail to run in status {state.Status}" + else + commit + { state with + Status = "failed" + CurrentStepId = value.StepId + LastHeartbeatAt = Some value.At + UpdatedAt = value.At + Message = value.Message + Error = Some value.Message + IsStale = false }) + + member _.Observe(now: DateTimeOffset) = + lock gate (fun () -> + let next = + match state.Status, state.LastHeartbeatAt with + | "running", Some heartbeat when now - heartbeat > staleAfter && not state.IsStale -> + Some + { state with + UpdatedAt = now + Message = "heartbeat timeout" + IsStale = true } + | "running", Some heartbeat when now - heartbeat <= staleAfter && state.IsStale -> + Some + { state with + UpdatedAt = now + Message = "heartbeat resumed" + IsStale = false } + | _ -> None + + match next with + | Some value -> + state <- value + publish value + value + | None -> state) + + member _.Subscribe() = + let channel = Channel.CreateUnbounded<RunSnapshot>() + + lock gate (fun () -> + channel.Writer.TryWrite(state) |> ignore + subscribers.Add(channel)) + + new ArtifactRunSubscription( + channel.Reader, + fun () -> + lock gate (fun () -> + subscribers.Remove(channel) |> ignore + channel.Writer.TryComplete() |> ignore)) + +type ArtifactRunRegistry(staleAfter: TimeSpan, clock: unit -> DateTimeOffset) = + let gate = obj () + let runs = Dictionary<string * string, ArtifactRunStore>() + + member _.TryCreate(ids: RunIds) = + lock gate (fun () -> + let key = ids.ProjectId, ids.RunId + + match runs.TryGetValue(key) with + | true, _ -> None + | false, _ -> + let store = ArtifactRunStore(ids, staleAfter, clock ()) + runs.Add(key, store) + Some store) + + member _.GetOrCreate(ids: RunIds) = + lock gate (fun () -> + let key = ids.ProjectId, ids.RunId + + match runs.TryGetValue(key) with + | true, store -> store + | false, _ -> + let store = ArtifactRunStore(ids, staleAfter, clock ()) + runs.Add(key, store) + store) + + member _.TryFind(projectId: string, runId: string) = + lock gate (fun () -> + match runs.TryGetValue((projectId, runId)) with + | true, store -> Some store + | false, _ -> None) + + member _.ObserveAll(now: DateTimeOffset) = + lock gate (fun () -> runs.Values |> Seq.map (fun store -> store.Observe(now)) |> Seq.toArray) diff --git a/src/SomhairlesDream.Shared/Artifact.fs b/src/SomhairlesDream.Shared/Artifact.fs new file mode 100644 index 0000000..d1cb4f0 --- /dev/null +++ b/src/SomhairlesDream.Shared/Artifact.fs @@ -0,0 +1,218 @@ +namespace SomhairlesDream.Shared + +open System +#if !FABLE_COMPILER +open System.Text.Json +open System.Text.Json.Nodes +#endif + +[<CLIMutable>] +type RunIds = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string } + +module Ids = + open System.Text.RegularExpressions + + let private resourcePattern = Regex("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", RegexOptions.CultureInvariant) + let private objectPattern = Regex("^[a-z0-9]+(?:[._-][a-z0-9]+)*$", RegexOptions.CultureInvariant) + + let validateResourceId value = + if isNull value || not (resourcePattern.IsMatch(value)) then + Error "resource id must contain only lower-case letters, digits, and hyphens" + else + Ok () + + let validateObjectId value = + if isNull value || not (objectPattern.IsMatch(value)) then + Error "object id must use the dotted lower-case namespace" + else + Ok () + +[<CLIMutable>] +type RunStart = + { Ids: RunIds + At: DateTimeOffset + TotalSteps: int } + +[<CLIMutable>] +type RunHeartbeat = + { Ids: RunIds + At: DateTimeOffset + Message: string } + +[<CLIMutable>] +type RunCheckpoint = + { Ids: RunIds + At: DateTimeOffset + StepId: string + StepIndex: int + TotalSteps: int + Message: string + ArtifactPath: string } + +[<CLIMutable>] +type RunComplete = + { Ids: RunIds + At: DateTimeOffset + ManifestPath: string } + +[<CLIMutable>] +type RunFail = + { Ids: RunIds + At: DateTimeOffset + StepId: string option + Message: string } + +type RunEvent = + | Start of RunStart + | Heartbeat of RunHeartbeat + | Checkpoint of RunCheckpoint + | Complete of RunComplete + | Fail of RunFail + +[<CLIMutable>] +type RunEventWire = + { Kind: string + At: DateTimeOffset + ProjectId: string + RunId: string + BaseId: string + TargetId: string + TotalSteps: int option + StepId: string option + StepIndex: int option + Message: string option + ArtifactPath: string option + ManifestPath: string option } + +module RunEvent = + let private ids (value: RunIds) = + value.ProjectId, value.RunId, value.BaseId, value.TargetId + + let toWire event = + let kind, eventIds, at, totalSteps, stepId, stepIndex, message, artifactPath, manifestPath = + match event with + | Start value -> + "start", value.Ids, value.At, Some value.TotalSteps, None, None, None, None, None + | Heartbeat value -> + "heartbeat", value.Ids, value.At, None, None, None, Some value.Message, None, None + | Checkpoint value -> + "checkpoint", + value.Ids, + value.At, + Some value.TotalSteps, + Some value.StepId, + Some value.StepIndex, + Some value.Message, + Some value.ArtifactPath, + None + | Complete value -> + "complete", value.Ids, value.At, None, None, None, None, None, Some value.ManifestPath + | Fail value -> + "fail", value.Ids, value.At, None, value.StepId, None, Some value.Message, None, None + + let projectId, runId, baseId, targetId = ids eventIds + + { Kind = kind + At = at + ProjectId = projectId + RunId = runId + BaseId = baseId + TargetId = targetId + TotalSteps = totalSteps + StepId = stepId + StepIndex = stepIndex + Message = message + ArtifactPath = artifactPath + ManifestPath = manifestPath } + +#if !FABLE_COMPILER + let private addOptional (json: JsonObject) (name: string) value create = + match value with + | Some item -> json[name] <- create item + | None -> () + + let private jsonOptions () = + let options = JsonSerializerOptions() + options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + options.WriteIndented <- false + options + + let toJson event = + let wire = toWire event + let json = JsonObject() + json["kind"] <- JsonValue.Create(wire.Kind) + json["at"] <- JsonValue.Create(wire.At) + json["projectId"] <- JsonValue.Create(wire.ProjectId) + json["runId"] <- JsonValue.Create(wire.RunId) + json["baseId"] <- JsonValue.Create(wire.BaseId) + json["targetId"] <- JsonValue.Create(wire.TargetId) + addOptional json "totalSteps" wire.TotalSteps (fun value -> JsonValue.Create(value)) + addOptional json "stepId" wire.StepId (fun value -> JsonValue.Create(value)) + addOptional json "stepIndex" wire.StepIndex (fun value -> JsonValue.Create(value)) + addOptional json "message" wire.Message (fun value -> JsonValue.Create(value)) + addOptional json "artifactPath" wire.ArtifactPath (fun value -> JsonValue.Create(value)) + addOptional json "manifestPath" wire.ManifestPath (fun value -> JsonValue.Create(value)) + json.ToJsonString(jsonOptions()) +#endif + +[<CLIMutable>] +type ArtifactChange = + { ObjectId: string + Kind: string + Summary: string } + +[<CLIMutable>] +type RenderArtifact = + { ArtifactPath: string + Sha256: string + Bytes: int64 + ExportedAt: DateTimeOffset } + +[<CLIMutable>] +type StepArtifact = + { StepId: string + StepIndex: int + ArtifactPath: string + LogPath: string + Sha256: string + Bytes: int64 + ExportedAt: DateTimeOffset + ObjectIds: string array + VerifiedObjectIds: string array + Changes: ArtifactChange array + Render: RenderArtifact option } + +[<CLIMutable>] +type ArtifactManifest = + { SchemaVersion: int + ProjectId: string + RunId: string + BaseId: string + TargetId: string + Status: string + StartedAt: DateTimeOffset + CompletedAt: DateTimeOffset + BlenderVersion: string + Steps: StepArtifact array } + +[<CLIMutable>] +type RunSnapshot = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string + Status: string + CurrentStepId: string option + CurrentStepIndex: int + TotalSteps: int + LastHeartbeatAt: DateTimeOffset option + UpdatedAt: DateTimeOffset + Message: string + ManifestPath: string option + ManifestUrl: string option + Error: string option + IsStale: bool } diff --git a/src/SomhairlesDream.Shared/Domain.fs b/src/SomhairlesDream.Shared/Domain.fs new file mode 100644 index 0000000..fd229fa --- /dev/null +++ b/src/SomhairlesDream.Shared/Domain.fs @@ -0,0 +1,102 @@ +namespace SomhairlesDream.Shared + +open System + +[<RequireQualifiedAccess>] +type RunStatus = + | Idle = 0 + | Queued = 1 + | Running = 2 + | Succeeded = 3 + | Failed = 4 + | Stale = 5 + +type Vector3 = + { X: float + Y: float + Z: float } + +type MeshSnapshot = + { Version: int + Label: string + Vertices: Vector3 array + Faces: int array array } + +type LiveSnapshot = + { ProjectId: string + RunId: string + Status: RunStatus + CurrentVersion: int + TargetVersion: int + HeartbeatAt: DateTimeOffset option + UpdatedAt: DateTimeOffset + Message: string + Mesh: MeshSnapshot option + NextDesignRunScheduled: bool } + +module MeshData = + let positionData (mesh: MeshSnapshot) = + mesh.Vertices + |> Array.collect (fun vertex -> [| vertex.X; vertex.Y; vertex.Z |]) + + let triangleIndices (mesh: MeshSnapshot) = + mesh.Faces + |> Array.collect (fun face -> + if face.Length < 3 then + [||] + else + [| for index in 1 .. face.Length - 2 do + yield face[0] + yield face[index] + yield face[index + 1] |]) + +module RunState = + let initial projectId runId now = + { ProjectId = projectId + RunId = runId + Status = RunStatus.Idle + CurrentVersion = 0 + TargetVersion = 0 + HeartbeatAt = None + UpdatedAt = now + Message = "等待设计运行" + Mesh = None + NextDesignRunScheduled = false } + + let start now state = + let targetVersion = state.TargetVersion + 1 + + { state with + Status = RunStatus.Running + TargetVersion = targetVersion + HeartbeatAt = Some now + UpdatedAt = now + Message = $"正在生成第 {targetVersion} 版几何" + NextDesignRunScheduled = false } + + let heartbeat now state = + if state.Status = RunStatus.Running then + { state with + HeartbeatAt = Some now + UpdatedAt = now } + else + state + + let statusAt timeout now state = + match state.HeartbeatAt with + | Some heartbeatAt when state.Status = RunStatus.Running && now - heartbeatAt > timeout -> + { state with + Status = RunStatus.Stale + UpdatedAt = now + Message = "心跳超时,等待重连" } + | _ -> state + + let publish now mesh state = + { state with + Status = RunStatus.Succeeded + CurrentVersion = mesh.Version + HeartbeatAt = Some now + UpdatedAt = now + Message = $"已发布{mesh.Label}" + Mesh = Some mesh + NextDesignRunScheduled = false } diff --git a/src/SomhairlesDream.Shared/Replay.fs b/src/SomhairlesDream.Shared/Replay.fs new file mode 100644 index 0000000..782e632 --- /dev/null +++ b/src/SomhairlesDream.Shared/Replay.fs @@ -0,0 +1,107 @@ +namespace SomhairlesDream.Shared + +type ReplayFrame = + { Version: int + Label: string + Mesh: MeshSnapshot } + +module Replay = + let private vertex x y z = + { X = x + Y = y + Z = z } + + let private frame version label vertices faces = + { Version = version + Label = label + Mesh = + { Version = version + Label = label + Vertices = vertices + Faces = faces } } + + let frames () = + [| frame + 1 + "重建起点" + [| vertex -2.0 0.0 -1.0 + vertex 2.0 0.0 -1.0 + vertex 2.0 0.0 1.0 + vertex -2.0 0.0 1.0 + vertex -1.0 2.0 -0.5 + vertex 1.0 2.0 -0.5 + vertex 1.0 2.0 0.5 + vertex -1.0 2.0 0.5 |] + [| [| 0; 1; 2 |] + [| 0; 2; 3 |] + [| 4; 6; 5 |] + [| 4; 7; 6 |] + [| 0; 4; 5 |] + [| 0; 5; 1 |] + [| 1; 5; 6 |] + [| 1; 6; 2 |] + [| 2; 6; 7 |] + [| 2; 7; 3 |] + [| 3; 7; 4 |] + [| 3; 4; 0 |] |] + frame + 2 + "主承力骨架" + [| vertex -2.5 0.0 -1.0 + vertex 2.5 0.0 -1.0 + vertex 2.5 0.0 1.0 + vertex -2.5 0.0 1.0 + vertex -1.5 2.2 -0.7 + vertex 1.5 2.2 -0.7 + vertex 1.5 2.2 0.7 + vertex -1.5 2.2 0.7 + vertex -0.45 0.0 -0.45 + vertex 0.45 0.0 -0.45 + vertex 0.45 3.0 0.45 + vertex -0.45 3.0 0.45 |] + [| [| 0; 1; 2 |] + [| 0; 2; 3 |] + [| 4; 6; 5 |] + [| 4; 7; 6 |] + [| 8; 9; 10 |] + [| 8; 10; 11 |] + [| 0; 4; 8 |] + [| 4; 11; 8 |] + [| 1; 9; 5 |] + [| 1; 5; 2 |] + [| 2; 5; 10 |] + [| 2; 10; 3 |] + [| 3; 10; 7 |] + [| 3; 7; 0 |] |] + frame + 3 + "舱段重构" + [| vertex -3.0 0.0 -1.3 + vertex 3.0 0.0 -1.3 + vertex 3.0 0.0 1.3 + vertex -3.0 0.0 1.3 + vertex -2.0 2.0 -1.0 + vertex 2.0 2.0 -1.0 + vertex 2.0 2.0 1.0 + vertex -2.0 2.0 1.0 + vertex -1.0 3.4 -0.65 + vertex 1.0 3.4 -0.65 + vertex 1.0 3.4 0.65 + vertex -1.0 3.4 0.65 + vertex -3.8 0.6 0.0 + vertex 3.8 0.6 0.0 |] + [| [| 0; 1; 2 |] + [| 0; 2; 3 |] + [| 4; 6; 5 |] + [| 4; 7; 6 |] + [| 8; 9; 10 |] + [| 8; 10; 11 |] + [| 0; 4; 8 |] + [| 0; 8; 12 |] + [| 1; 13; 5 |] + [| 1; 2; 13 |] + [| 2; 6; 10 |] + [| 2; 10; 13 |] + [| 3; 11; 7 |] + [| 3; 12; 11 |] + [| 3; 0; 12 |] |] |] diff --git a/src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj b/src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj new file mode 100644 index 0000000..7a7e853 --- /dev/null +++ b/src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj @@ -0,0 +1,12 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <RootNamespace>SomhairlesDream.Shared</RootNamespace> + <AssemblyName>SomhairlesDream.Shared</AssemblyName> + </PropertyGroup> + <ItemGroup> + <Compile Include="Domain.fs" /> + <Compile Include="Artifact.fs" /> + <Compile Include="Replay.fs" /> + </ItemGroup> +</Project> diff --git a/stage/blender/fixture.py b/stage/blender/fixture.py new file mode 100644 index 0000000..fe5a232 --- /dev/null +++ b/stage/blender/fixture.py @@ -0,0 +1,202 @@ +import argparse +import json +import os +import sys +from datetime import datetime, timezone + + +STEP_OBJECTS = { + "01-foundation": ["heritage.foundation", "heritage.deck"], + "02-frame": [ + "heritage.foundation", + "heritage.deck", + "heritage.frame.left", + "heritage.frame.right", + "heritage.spine", + ], + "03-cabin": [ + "heritage.foundation", + "heritage.deck", + "heritage.frame.left", + "heritage.frame.right", + "heritage.spine", + "heritage.cabin", + "heritage.crossbeam", + ], +} + +OBJECT_GEOMETRY = { + "heritage.foundation": ((0.0, 0.0, 0.0), (4.8, 0.35, 2.8)), + "heritage.deck": ((0.0, 0.35, 0.0), (4.2, 0.22, 2.3)), + "heritage.frame.left": ((-1.65, 1.35, 0.0), (0.24, 2.0, 2.0)), + "heritage.frame.right": ((1.65, 1.35, 0.0), (0.24, 2.0, 2.0)), + "heritage.spine": ((0.0, 1.45, 0.0), (0.24, 2.2, 1.8)), + "heritage.cabin": ((0.0, 2.15, 0.0), (2.8, 1.1, 1.8)), + "heritage.crossbeam": ((0.0, 2.95, 0.0), (3.3, 0.22, 2.1)), +} + + +def utc_now(): + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def fail(message, code): + print(message, file=sys.stderr) + raise SystemExit(code) + + +def clear_scene(bpy): + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def create_objects(bpy, step_id): + objects = [] + for object_id in STEP_OBJECTS[step_id]: + location, dimensions = OBJECT_GEOMETRY[object_id] + bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) + obj = bpy.context.object + obj.name = object_id + obj.data.name = object_id + ".mesh" + obj.dimensions = dimensions + bpy.context.view_layer.objects.active = obj + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + obj["object_id"] = object_id + objects.append(obj) + + for obj, object_id in zip(objects, STEP_OBJECTS[step_id]): + if obj.get("object_id") != object_id: + fail("source object custom property verification failed", 4) + + return objects + + +def add_render_setup(bpy): + from mathutils import Vector + + scene = bpy.context.scene + scene.render.resolution_x = 512 + scene.render.resolution_y = 512 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = "PNG" + if scene.world is None: + scene.world = bpy.data.worlds.new("fixture.world") + scene.world.color = (0.025, 0.035, 0.045) + + camera_data = bpy.data.cameras.new("fixture.camera") + camera = bpy.data.objects.new("fixture.camera", camera_data) + bpy.context.collection.objects.link(camera) + camera.location = (7.5, -9.0, 6.5) + camera.rotation_euler = (Vector((0.0, 1.0, 0.0)) - camera.location).to_track_quat("-Z", "Y").to_euler() + camera_data.lens = 52 + scene.camera = camera + + light_data = bpy.data.lights.new("fixture.key", type="AREA") + light_data.energy = 900 + light_data.shape = "DISK" + light_data.size = 5.0 + light = bpy.data.objects.new("fixture.key", light_data) + bpy.context.collection.objects.link(light) + light.location = (4.0, -4.0, 8.0) + light.rotation_euler = (Vector((0.0, 1.0, 0.0)) - light.location).to_track_quat("-Z", "Y").to_euler() + + +def export_glb(bpy, objects, output): + for obj in bpy.context.selected_objects: + obj.select_set(False) + for obj in objects: + obj.select_set(True) + bpy.context.view_layer.objects.active = objects[-1] + result = bpy.ops.export_scene.gltf(filepath=output, export_format="GLB", use_selection=True) + if "FINISHED" not in result or not os.path.isfile(output) or os.path.getsize(output) <= 0: + fail("GLB export failed", 4) + + +def write_result(path, response): + path = os.path.abspath(path) + os.makedirs(os.path.dirname(path), exist_ok=True) + temporary_path = path + ".tmp" + with open(temporary_path, "w", encoding="utf-8") as stream: + json.dump(response, stream, separators=(",", ":")) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary_path, path) + + +def verify_reimport(bpy, output, expected): + clear_scene(bpy) + result = bpy.ops.import_scene.gltf(filepath=output) + if "FINISHED" not in result: + fail("GLB reimport failed", 4) + + names = [obj.name for obj in bpy.context.scene.objects if obj.type == "MESH"] + if len(names) != len(expected) or set(names) != set(expected): + fail("GLB reimport object IDs did not match", 4) + + return [object_id for object_id in expected if object_id in names] + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--step-id", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--render-output") + parser.add_argument("--result-output", required=True) + args = parser.parse_args() + + if args.step_id not in STEP_OBJECTS: + fail("unknown step id", 2) + if bool(args.render_output) and os.path.abspath(args.render_output) == os.path.abspath(args.output): + fail("render output must differ from GLB output", 2) + + try: + import bpy + except Exception as exc: + fail("bpy is unavailable: " + str(exc), 3) + + if tuple(bpy.app.version) < (3, 0, 0): + fail("bpy version is unsupported", 3) + + output = os.path.abspath(args.output) + result_output = os.path.abspath(args.result_output) + os.makedirs(os.path.dirname(output), exist_ok=True) + + try: + clear_scene(bpy) + objects = create_objects(bpy, args.step_id) + export_glb(bpy, objects, output) + exported_at = utc_now() + glb_bytes = os.path.getsize(output) + + rendered_at = None + if args.render_output: + render_output = os.path.abspath(args.render_output) + os.makedirs(os.path.dirname(render_output), exist_ok=True) + add_render_setup(bpy) + bpy.context.scene.render.filepath = render_output + result = bpy.ops.render.render(write_still=True) + if "FINISHED" not in result or not os.path.isfile(render_output) or os.path.getsize(render_output) <= 0: + fail("PNG render failed", 4) + rendered_at = utc_now() + + verified = verify_reimport(bpy, output, STEP_OBJECTS[args.step_id]) + response = { + "ok": True, + "blenderVersion": ".".join(str(part) for part in bpy.app.version), + "exportedAt": exported_at, + "objectIds": STEP_OBJECTS[args.step_id], + "verifiedObjectIds": verified, + "glbBytes": glb_bytes, + } + if args.render_output: + response["renderPath"] = os.path.abspath(args.render_output) + response["renderedAt"] = rendered_at + write_result(result_output, response) + print("fixture result written: " + result_output) + except SystemExit: + raise + except Exception as exc: + fail("fixture failed: " + str(exc), 4) + + +if __name__ == "__main__": + main() diff --git a/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs b/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs new file mode 100644 index 0000000..f55762e --- /dev/null +++ b/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs @@ -0,0 +1,70 @@ +module SomhairlesDream.Cli.Tests.ArgumentsTests + +open Xunit +open SomhairlesDream.Cli + +let private expectOk = function + | Ok value -> value + | Error message -> failwith message + +let private expectError = function + | Error message -> message + | Ok _ -> failwith "expected an error" + +[<Fact>] +let ``run command applies documented defaults`` () = + match Arguments.parse [| "run" |] |> expectOk with + | Run options -> + Assert.Equal("heritage-001", options.Ids.ProjectId) + Assert.Equal("run-001", options.Ids.RunId) + Assert.Equal("base-000", options.Ids.BaseId) + Assert.Equal("target-003", options.Ids.TargetId) + Assert.Equal(".artifacts", options.OutputRoot) + Assert.Equal("stage", options.StageRoot) + Assert.False(options.Render) + Assert.NotEmpty(options.Python) + | Verify _ + | Help -> failwith "expected run command" + +[<Fact>] +let ``run command parses explicit ids and render flag`` () = + match + Arguments.parse + [| "run" + "--project-id" + "project-x" + "--run-id" + "run-x" + "--base-id" + "base-x" + "--target-id" + "target-x" + "--output-root" + "out" + "--stage-root" + "fixtures" + "--python" + "/tmp/python" + "--render" |] + |> expectOk with + | Run options -> + Assert.Equal("project-x", options.Ids.ProjectId) + Assert.Equal("run-x", options.Ids.RunId) + Assert.Equal("base-x", options.Ids.BaseId) + Assert.Equal("target-x", options.Ids.TargetId) + Assert.Equal("out", options.OutputRoot) + Assert.Equal("fixtures", options.StageRoot) + Assert.Equal("/tmp/python", options.Python) + Assert.True(options.Render) + | Verify _ + | Help -> failwith "expected run command" + +[<Fact>] +let ``verify command requires exactly one manifest path`` () = + match Arguments.parse [| "verify"; "run"; "extra" |] |> expectError with + | message -> Assert.Contains("manifest", message) + +[<Fact>] +let ``arguments reject unsafe resource ids`` () = + match Arguments.parse [| "run"; "--project-id"; "../escape" |] |> expectError with + | message -> Assert.Contains("resource id", message) diff --git a/tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj b/tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj new file mode 100644 index 0000000..461d5eb --- /dev/null +++ b/tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj @@ -0,0 +1,21 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </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"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Cli/SomhairlesDream.Cli.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="ArgumentsTests.fs" /> + </ItemGroup> +</Project> diff --git a/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs b/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs new file mode 100644 index 0000000..d9c7347 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs @@ -0,0 +1,72 @@ +module SomhairlesDream.Modeling.Tests.BridgeTests + +open System +open System.IO +open Xunit +open SomhairlesDream.Modeling + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-bridge-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +let private fixtureScript = + [| "import argparse" + "import json" + "import os" + "" + "parser = argparse.ArgumentParser()" + "parser.add_argument('--step-id', required=True)" + "parser.add_argument('--output', required=True)" + "parser.add_argument('--render-output')" + "parser.add_argument('--result-output')" + "args = parser.parse_args()" + "os.makedirs(os.path.dirname(args.output), exist_ok=True)" + "with open(args.output, 'wb') as stream:" + " stream.write(b'glTF')" + "result_path = args.result_output or args.output + '.result.json'" + "with open(result_path, 'w', encoding='utf-8') as stream:" + " json.dump({" + " 'ok': True," + " 'blenderVersion': 'test'," + " 'exportedAt': '2026-09-20T12:00:05Z'," + " 'objectIds': ['heritage.foundation', 'heritage.deck']," + " 'verifiedObjectIds': ['heritage.foundation', 'heritage.deck']," + " 'glbBytes': 4" + " }, stream)" + "with open(args.output + '.log', 'w', encoding='utf-8') as stream:" + " stream.write('fixture log retained separately\\n')" + "print('Blender informational output')" |] + |> String.concat Environment.NewLine + +[<Fact>] +let ``bridge reads result file instead of polluted stdout and retains process log`` () = + withTempDirectory (fun root -> + let blenderRoot = Path.Combine(root, "stage", "blender") + Directory.CreateDirectory(blenderRoot) |> ignore + File.WriteAllText(Path.Combine(blenderRoot, "fixture.py"), fixtureScript) + + let output = Path.Combine(root, "run", "step.glb") + let request = + { Step = Design.steps[0] + GlbPath = output + RenderPath = None + ResultPath = output + ".result.json" + LogPath = output + ".log" } + + let result = + (BlenderProcessBridge("python3", Path.Combine(root, "stage")) :> IArtifactBridge) + .Export(request, ignore) + + match result with + | Error message -> failwith message + | Ok value -> + Assert.Equal("test", value.BlenderVersion) + Assert.Equal(4L, value.GlbBytes) + Assert.True(File.Exists(output + ".log")) + Assert.Contains("Blender informational output", File.ReadAllText(output + ".log"))) diff --git a/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs b/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs new file mode 100644 index 0000000..7a5e3e1 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs @@ -0,0 +1,167 @@ +module SomhairlesDream.Modeling.Tests.PipelineTests + +open System +open System.IO +open Xunit +open SomhairlesDream.Modeling +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "heritage" + TargetId = "heritage-001" } + +let private expectOk = function + | Ok value -> value + | Error message -> failwith message + +let private expectError = function + | Error message -> message + | Ok _ -> failwith "expected an error" + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +type private TestBridge(?wrongObjectIds: bool) = + interface IArtifactBridge with + member _.Export(request, _) = + File.WriteAllBytes(request.GlbPath, [| 0x67uy; 0x6Cuy; 0x54uy; byte request.Step.Index |]) + + Directory.CreateDirectory(Path.GetDirectoryName(request.LogPath)) |> ignore + File.WriteAllText(request.LogPath, $"bridge log for {request.Step.StepId}") + + match request.RenderPath with + | Some path -> File.WriteAllBytes(path, [| 0x89uy; 0x50uy; 0x4Euy; 0x47uy |]) + | None -> () + + let objectIds = + if defaultArg wrongObjectIds false then + [| "unexpected.object" |] + else + request.Step.ObjectIds + + Ok + { BlenderVersion = "5.0.1" + ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + ObjectIds = objectIds + VerifiedObjectIds = objectIds + GlbBytes = 4L + RenderPath = request.RenderPath + RenderedAt = request.RenderPath |> Option.map (fun _ -> DateTimeOffset.Parse("2026-09-20T12:00:06Z")) } + +let private options root bridge = + { PipelineOptions.ArtifactRoot = root + Ids = ids + Render = true + Bridge = bridge + Clock = fun () -> DateTimeOffset.Parse("2026-09-20T12:00:00Z") + OnEvent = ignore } + +[<Fact>] +let ``design steps expose the fixed ordered object contract`` () = + let steps = Design.steps + + Assert.Equal(3, steps.Length) + Assert.True([| "01-foundation"; "02-frame"; "03-cabin" |] = (steps |> Array.map _.StepId)) + Assert.True([| 0; 1; 2 |] = (steps |> Array.map _.Index)) + Assert.True([| "heritage.foundation"; "heritage.deck" |] = steps[0].ObjectIds) + Assert.True( + [| "heritage.foundation" + "heritage.deck" + "heritage.frame.left" + "heritage.frame.right" + "heritage.spine" |] = steps[1].ObjectIds) + Assert.True( + [| "heritage.foundation" + "heritage.deck" + "heritage.frame.left" + "heritage.frame.right" + "heritage.spine" + "heritage.cabin" + "heritage.crossbeam" |] = steps[2].ObjectIds) + +[<Fact>] +let ``pipeline commits each step and writes manifest after complete event`` () = + withTempDirectory (fun root -> + let manifest = Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk + let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) + let eventsPath = Path.Combine(runDirectory, "events.ndjson") + let manifestPath = Path.Combine(runDirectory, "manifest.json") + + Assert.Equal("complete", manifest.Status) + Assert.Equal(3, manifest.Steps.Length) + Assert.True(File.Exists(eventsPath)) + Assert.True(File.Exists(manifestPath)) + let verifiedManifest: ArtifactManifest = ArtifactVerifier.verify manifestPath |> expectOk + Assert.Equal(manifest.RunId, verifiedManifest.RunId) + Assert.Equal(manifest.Steps.Length, verifiedManifest.Steps.Length) + Assert.False(Directory.Exists(Path.Combine(runDirectory, ".staging"))) + + for step in manifest.Steps do + Assert.True(File.Exists(Path.Combine(runDirectory, step.ArtifactPath.Replace('/', Path.DirectorySeparatorChar)))) + Assert.Equal($"logs/{step.StepId}.log", step.LogPath) + Assert.True(File.Exists(Path.Combine(runDirectory, step.LogPath.Replace('/', Path.DirectorySeparatorChar)))) + Assert.True(step.Render |> Option.exists (fun render -> File.Exists(Path.Combine(runDirectory, render.ArtifactPath.Replace('/', Path.DirectorySeparatorChar))))) + Assert.True(step.ObjectIds = step.VerifiedObjectIds) + + let events = File.ReadAllLines(eventsPath) + Assert.Contains("\"kind\":\"start\"", events[0]) + Assert.Contains("\"kind\":\"complete\"", events[events.Length - 1])) + +[<Fact>] +let ``pipeline rejects a pre-existing run directory without overwriting it`` () = + withTempDirectory (fun root -> + let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) + Directory.CreateDirectory(runDirectory) |> ignore + let marker = Path.Combine(runDirectory, "marker.txt") + File.WriteAllText(marker, "keep") + + let result = Pipeline.run (options root (TestBridge() :> IArtifactBridge)) + + Assert.True(Result.isError result) + Assert.Equal("keep", File.ReadAllText(marker))) + +[<Fact>] +let ``pipeline fails before manifest commit when bridge object ids differ`` () = + withTempDirectory (fun root -> + let result = Pipeline.run (options root (TestBridge(true) :> IArtifactBridge)) + let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) + + Assert.True(Result.isError result) + Assert.False(File.Exists(Path.Combine(runDirectory, "manifest.json"))) + Assert.Contains("object ids", result |> expectError)) + +[<Fact>] +let ``manifest verifier rejects a tampered artifact hash`` () = + withTempDirectory (fun root -> + Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk |> ignore + let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) + let artifactPath = Path.Combine(runDirectory, "steps", "01-foundation.glb") + let bytes = File.ReadAllBytes(artifactPath) + bytes[0] <- bytes[0] ^^^ 1uy + File.WriteAllBytes(artifactPath, bytes) + + let result = ArtifactVerifier.verify (Path.Combine(runDirectory, "manifest.json")) + + Assert.True(Result.isError result) + Assert.Contains("hash", result |> expectError)) + +[<Fact>] +let ``manifest verifier rejects a missing retained process log`` () = + withTempDirectory (fun root -> + Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk |> ignore + let runDirectory = Path.Combine(root, ids.ProjectId, ids.RunId) + File.Delete(Path.Combine(runDirectory, "logs", "01-foundation.log")) + + let result = ArtifactVerifier.verify (Path.Combine(runDirectory, "manifest.json")) + + Assert.True(Result.isError result) + Assert.Contains("log", result |> expectError)) diff --git a/tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj b/tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj new file mode 100644 index 0000000..f7175b7 --- /dev/null +++ b/tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj @@ -0,0 +1,23 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </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"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj" /> + <ProjectReference Include="../../src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="PipelineTests.fs" /> + <Compile Include="BridgeTests.fs" /> + </ItemGroup> +</Project> diff --git a/tests/SomhairlesDream.Server.Tests/ApiTests.fs b/tests/SomhairlesDream.Server.Tests/ApiTests.fs new file mode 100644 index 0000000..cc8a3da --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/ApiTests.fs @@ -0,0 +1,235 @@ +module SomhairlesDream.Server.Tests.ApiTests + +open System +open System.IO +open System.Net +open System.Net.Http +open System.Text +open System.Text.Json +open System.Threading +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.TestHost +open Xunit +open SomhairlesDream.Modeling +open SomhairlesDream.Server +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-api-001" + BaseId = "base-000" + TargetId = "target-003" } + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-api-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +type private TestBridge() = + interface IArtifactBridge with + member _.Export(request, _) = + File.WriteAllBytes(request.GlbPath, [| 0x67uy; 0x6Cuy; 0x54uy; byte request.Step.Index |]) + Directory.CreateDirectory(Path.GetDirectoryName(request.LogPath)) |> ignore + File.WriteAllText(request.LogPath, $"bridge log for {request.Step.StepId}") + + Ok + { BlenderVersion = "5.0.1" + ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + ObjectIds = request.Step.ObjectIds + VerifiedObjectIds = request.Step.ObjectIds + GlbBytes = 4L + RenderPath = None + RenderedAt = None } + +type private BlockingBridge(gate: ManualResetEventSlim) = + interface IArtifactBridge with + member _.Export(request, _) = + gate.Wait() + (TestBridge() :> IArtifactBridge).Export(request, fun () -> ()) + +let private requestBody runId = + $"{{\"projectId\":\"{ids.ProjectId}\",\"runId\":\"{runId}\",\"baseId\":\"{ids.BaseId}\",\"targetId\":\"{ids.TargetId}\"}}" + +let private withAppUsing (bridgeFactory: unit -> IArtifactBridge) action = + withTempDirectory (fun root -> + let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z") + let coordinator = + ArtifactRunCoordinator( + root, + bridgeFactory, + clock, + TimeSpan.FromSeconds 15. + ) + + let builder = WebApplication.CreateBuilder([||]) + builder.WebHost.UseTestServer() |> ignore + let app = builder.Build() + + ArtifactRunApi.register + app + { Coordinator = coordinator + Clock = clock + JsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) } + + app.StartAsync().GetAwaiter().GetResult() + + try + action (app.GetTestClient()) coordinator + finally + app.StopAsync().GetAwaiter().GetResult() + app.DisposeAsync().AsTask().GetAwaiter().GetResult()) + +let private withApp action = withAppUsing (fun () -> TestBridge() :> IArtifactBridge) action + +let private waitFor predicate = + let deadline = DateTime.UtcNow.AddSeconds(5.) + let mutable matched = predicate () + + while not matched && DateTime.UtcNow < deadline do + Thread.Sleep(10) + matched <- predicate () + + matched + +[<Fact>] +let ``start endpoint accepts a run and conflicts on its second start`` () = + withApp (fun client _ -> + let first = + use content = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") + client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + + Assert.Equal(HttpStatusCode.Accepted, first.StatusCode) + + let firstSnapshot = + first.Content.ReadAsStringAsync().GetAwaiter().GetResult() + |> fun body -> JsonSerializer.Deserialize<RunSnapshot>(body, JsonSerializerOptions(JsonSerializerDefaults.Web)) + + Assert.Equal(ids.ProjectId, firstSnapshot.ProjectId) + Assert.Equal(ids.RunId, firstSnapshot.RunId) + + use duplicateContent = new StringContent(requestBody ids.RunId, Encoding.UTF8, "application/json") + let duplicate = client.PostAsync("/api/runs/start", duplicateContent).GetAwaiter().GetResult() + + Assert.Equal(HttpStatusCode.Conflict, duplicate.StatusCode)) + +[<Fact>] +let ``start endpoint rejects invalid resource ids before creating a run`` () = + withApp (fun client coordinator -> + let body = requestBody "../outside" + use content = new StringContent(body, Encoding.UTF8, "application/json") + let response = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode) + Assert.True(coordinator.TryFind(ids.ProjectId, "../outside").IsNone)) + +[<Fact>] +let ``manifest and artifact endpoints expose a completed run and reject traversal`` () = + withApp (fun client coordinator -> + let runId = "run-api-complete" + + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + let completed = + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + + Assert.True(completed) + + let manifest = + client + .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.OK, manifest.StatusCode) + let manifestBody = manifest.Content.ReadAsStringAsync().GetAwaiter().GetResult() + Assert.Contains("\"schemaVersion\":1", manifestBody) + + let glbPath = Uri.EscapeDataString("steps/01-foundation.glb") + let artifact = + client + .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={glbPath}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.OK, artifact.StatusCode) + + let traversalPath = Uri.EscapeDataString("../manifest.json") + let traversal = + client + .GetAsync($"/api/artifacts/file?projectId={ids.ProjectId}&runId={runId}&path={traversalPath}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.BadRequest, traversal.StatusCode)) + +[<Fact>] +let ``manifest endpoint reports active runs as conflicts`` () = + let gate = new ManualResetEventSlim(false) + + try + withAppUsing + (fun () -> BlockingBridge(gate) :> IArtifactBridge) + (fun client coordinator -> + let runId = "run-api-active" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + let manifest = + client + .GetAsync($"/api/artifacts/manifest?projectId={ids.ProjectId}&runId={runId}") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.Conflict, manifest.StatusCode) + gate.Set() + + Assert.True( + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, runId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete"))) + ) + finally + gate.Set() + gate.Dispose() + +[<Fact>] +let ``events endpoint streams only the selected run`` () = + withApp (fun client _ -> + let runId = "run-api-events" + use content = new StringContent(requestBody runId, Encoding.UTF8, "application/json") + let start = client.PostAsync("/api/runs/start", content).GetAwaiter().GetResult() + Assert.Equal(HttpStatusCode.Accepted, start.StatusCode) + + use response = + client + .GetAsync( + $"/api/runs/events?projectId={ids.ProjectId}&runId={runId}", + HttpCompletionOption.ResponseHeadersRead + ) + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.OK, response.StatusCode) + use reader = new StreamReader(response.Content.ReadAsStream()) + let line = reader.ReadLine() + Assert.StartsWith("data: ", line) + Assert.Contains(runId, line) + + let unknown = + client + .GetAsync($"/api/runs/events?projectId={ids.ProjectId}&runId=run-api-missing") + .GetAwaiter() + .GetResult() + + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode)) diff --git a/tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs b/tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs new file mode 100644 index 0000000..e9689bb --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs @@ -0,0 +1,83 @@ +module SomhairlesDream.Server.Tests.CoordinatorTests + +open System +open System.IO +open System.Threading +open Xunit +open SomhairlesDream.Modeling +open SomhairlesDream.Server +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "heritage" + TargetId = "heritage-001" } + +let private withTempDirectory action = + let path = Path.Combine(Path.GetTempPath(), "somhairles-dream-server-" + Guid.NewGuid().ToString("N")) + Directory.CreateDirectory(path) |> ignore + + try + action path + finally + if Directory.Exists(path) then + Directory.Delete(path, true) + +type private TestBridge() = + interface IArtifactBridge with + member _.Export(request, _) = + File.WriteAllBytes(request.GlbPath, [| 0x67uy; 0x6Cuy; 0x54uy; byte request.Step.Index |]) + Directory.CreateDirectory(Path.GetDirectoryName(request.LogPath)) |> ignore + File.WriteAllText(request.LogPath, $"bridge log for {request.Step.StepId}") + + Ok + { BlenderVersion = "5.0.1" + ExportedAt = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + ObjectIds = request.Step.ObjectIds + VerifiedObjectIds = request.Step.ObjectIds + GlbBytes = 4L + RenderPath = None + RenderedAt = None } + +let private waitFor predicate = + let deadline = DateTime.UtcNow.AddSeconds(5.) + let mutable matched = predicate () + + while not matched && DateTime.UtcNow < deadline do + Thread.Sleep(10) + matched <- predicate () + + matched + +[<Fact>] +let ``coordinator starts one shared pipeline and publishes its manifest`` () = + withTempDirectory (fun root -> + let clock () = DateTimeOffset.Parse("2026-09-20T12:00:00Z") + let coordinator = + ArtifactRunCoordinator( + root, + (fun () -> TestBridge() :> IArtifactBridge), + clock, + TimeSpan.FromSeconds 15. + ) + + match coordinator.Start(ids, false) with + | Error message -> failwith message + | Ok(Accepted snapshot) -> + Assert.Equal(ids.ProjectId, snapshot.ProjectId) + Assert.Equal(ids.RunId, snapshot.RunId) + | Ok(Conflict _) -> failwith "expected the first start to be accepted" + + let completed = + waitFor (fun () -> + coordinator.TryFind(ids.ProjectId, ids.RunId) + |> Option.exists (fun store -> store.Snapshot().Status = "complete")) + + Assert.True(completed) + + match coordinator.Manifest(ids.ProjectId, ids.RunId) with + | Error failure -> failwith $"manifest lookup failed: {failure}" + | Ok manifest -> + Assert.Equal("complete", manifest.Status) + Assert.Equal(3, manifest.Steps.Length)) diff --git a/tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj b/tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj new file mode 100644 index 0000000..88e85f0 --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj @@ -0,0 +1,25 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> + <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="8.0.12" /> + <PackageReference Include="xunit" Version="2.9.2" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Server/SomhairlesDream.Server.fsproj" /> + <ProjectReference Include="../../src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="StoreTests.fs" /> + <Compile Include="CoordinatorTests.fs" /> + <Compile Include="ApiTests.fs" /> + </ItemGroup> +</Project> diff --git a/tests/SomhairlesDream.Server.Tests/StoreTests.fs b/tests/SomhairlesDream.Server.Tests/StoreTests.fs new file mode 100644 index 0000000..4aec16f --- /dev/null +++ b/tests/SomhairlesDream.Server.Tests/StoreTests.fs @@ -0,0 +1,236 @@ +namespace SomhairlesDream.Server.Tests + +open System +open Xunit +open SomhairlesDream.Shared +open SomhairlesDream.Server + +module StoreTests = + let private at hour = DateTimeOffset(2026, 9, 20, hour, 0, 0, 0, TimeSpan.Zero) + + let private artifactIds : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "base-000" + TargetId = "target-003" } + + let private mesh version label = + { Version = version + Label = label + Vertices = + [| { X = 0.; Y = 0.; Z = 0. } + { X = float version; Y = 1.; Z = 0. } |] + Faces = [||] } + + [<Fact>] + let ``store publishes the latest state atomically`` () = + let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) + + let running = store.Start(at 9) + let published = store.Publish(at 9, mesh running.TargetVersion "主承力骨架") + + Assert.Equal(RunStatus.Succeeded, published.Status) + Assert.Equal(published, store.Snapshot()) + Assert.Equal(1, published.CurrentVersion) + + [<Fact>] + let ``store marks an unresponsive run stale when observed`` () = + let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) + store.Start(at 9) |> ignore + + let observed = store.Observe(at 10) + + Assert.Equal(RunStatus.Stale, observed.Status) + Assert.Equal("心跳超时,等待重连", observed.Message) + + [<Fact>] + let ``store sends an initial snapshot and every transition to subscribers`` () = + let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) + let reader = store.Subscribe() + + let initial = reader.ReadAsync().AsTask().GetAwaiter().GetResult() + let running = store.Start(at 9) + let update = reader.ReadAsync().AsTask().GetAwaiter().GetResult() + + Assert.Equal(RunStatus.Idle, initial.Status) + Assert.Equal(running, update) + + [<Fact>] + let ``artifact store applies lifecycle events to a run snapshot`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + let start = + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + + let checkpoint = + RunEvent.Checkpoint + { Ids = artifactIds + At = at 9 + StepId = "01-foundation" + StepIndex = 0 + TotalSteps = 3 + Message = "artifact verified" + ArtifactPath = "steps/01-foundation.glb" } + + Assert.True(store.Apply(start) |> Result.isOk) + Assert.True(store.Apply(checkpoint) |> Result.isOk) + + let snapshot = store.Snapshot() + + Assert.Equal("running", snapshot.Status) + Assert.Equal(artifactIds.BaseId, snapshot.BaseId) + Assert.Equal(artifactIds.TargetId, snapshot.TargetId) + Assert.Equal(Some "01-foundation", snapshot.CurrentStepId) + Assert.Equal(0, snapshot.CurrentStepIndex) + Assert.Equal(3, snapshot.TotalSteps) + Assert.True(snapshot.ManifestPath.IsNone) + Assert.False(snapshot.IsStale) + + [<Fact>] + let ``artifact store derives staleness without changing lifecycle status`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + let snapshot = store.Observe(at 10) + + Assert.Equal("running", snapshot.Status) + Assert.True(snapshot.IsStale) + + [<Fact>] + let ``artifact store rejects lifecycle events after completion`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + for index, stepId in [| 0, "01-foundation"; 1, "02-frame"; 2, "03-cabin" |] do + store.Apply( + RunEvent.Checkpoint + { Ids = artifactIds + At = at 9 + StepId = stepId + StepIndex = index + TotalSteps = 3 + Message = "artifact verified" + ArtifactPath = $"steps/{stepId}.glb" } + ) + |> ignore + + store.Apply( + RunEvent.Complete + { Ids = artifactIds + At = at 9 + ManifestPath = "manifest.json" } + ) + |> ignore + + let result = + store.Apply( + RunEvent.Heartbeat + { Ids = artifactIds + At = at 9 + Message = "late heartbeat" } + ) + + Assert.True(result |> Result.isError) + + [<Fact>] + let ``artifact store rejects completion before every checkpoint`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + let result = + store.Apply( + RunEvent.Complete + { Ids = artifactIds + At = at 9 + ManifestPath = "manifest.json" } + ) + + Assert.True(result |> Result.isError) + Assert.Equal("running", store.Snapshot().Status) + + [<Fact>] + let ``artifact store records a pipeline failure before start`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + let result = + store.Apply( + RunEvent.Fail + { Ids = artifactIds + At = at 9 + StepId = None + Message = "run directory already exists" } + ) + + Assert.True(result |> Result.isOk) + Assert.Equal("failed", store.Snapshot().Status) + Assert.Equal(Some "run directory already exists", store.Snapshot().Error) + + [<Fact>] + let ``artifact store accepts completion after ordered checkpoints`` () = + let store = ArtifactRunStore(artifactIds, TimeSpan.FromSeconds 15., at 9) + + store.Apply( + RunEvent.Start + { Ids = artifactIds + At = at 9 + TotalSteps = 3 } + ) + |> ignore + + for index, stepId in [| 0, "01-foundation"; 1, "02-frame"; 2, "03-cabin" |] do + store.Apply( + RunEvent.Checkpoint + { Ids = artifactIds + At = at 9 + StepId = stepId + StepIndex = index + TotalSteps = 3 + Message = "artifact verified" + ArtifactPath = $"steps/{stepId}.glb" } + ) + |> ignore + + let result = + store.Apply( + RunEvent.Complete + { Ids = artifactIds + At = at 9 + ManifestPath = "manifest.json" } + ) + + Assert.True(result |> Result.isOk) + Assert.Equal("complete", store.Snapshot().Status) + + [<Fact>] + let ``artifact registry atomically creates only one store for a run key`` () = + let registry = ArtifactRunRegistry(TimeSpan.FromSeconds 15., fun () -> at 9) + + let first = registry.TryCreate(artifactIds) + let duplicate = registry.TryCreate(artifactIds) + + Assert.True(first.IsSome) + Assert.True(duplicate.IsNone) diff --git a/tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs b/tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs new file mode 100644 index 0000000..e4f0c86 --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs @@ -0,0 +1,73 @@ +module SomhairlesDream.Shared.Tests.ArtifactContractTests + +open System +open Xunit +open SomhairlesDream.Shared + +let private ids : RunIds = + { ProjectId = "heritage-001" + RunId = "run-20260920-001" + BaseId = "heritage" + TargetId = "heritage-001" } + +[<Fact>] +let ``resource ids reject path traversal and separators`` () = + Assert.True(Ids.validateResourceId "heritage-001" |> Result.isOk) + Assert.True(Ids.validateResourceId "../outside" |> Result.isError) + Assert.True(Ids.validateResourceId "heritage/001" |> Result.isError) + Assert.True(Ids.validateResourceId "heritage_001" |> Result.isError) + +[<Fact>] +let ``object ids use the stable dotted namespace`` () = + Assert.True(Ids.validateObjectId "heritage.foundation" |> Result.isOk) + Assert.True(Ids.validateObjectId "heritage/frame.left" |> Result.isError) + Assert.True(Ids.validateObjectId "heritage.foundation/../../outside" |> Result.isError) + +[<Fact>] +let ``start event serializes as flat camel-case json`` () = + let event = + RunEvent.Start + { Ids = ids + At = DateTimeOffset.Parse("2026-09-20T12:00:00Z") + TotalSteps = 3 } + + let json = RunEvent.toJson event + + Assert.Contains("\"kind\":\"start\"", json) + Assert.Contains("\"projectId\":\"heritage-001\"", json) + Assert.Contains("\"runId\":\"run-20260920-001\"", json) + Assert.Contains("\"totalSteps\":3", json) + Assert.DoesNotContain("\"stepId\":null", json) + +[<Fact>] +let ``checkpoint event includes its step and artifact path`` () = + let event = + RunEvent.Checkpoint + { Ids = ids + At = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + StepId = "01-foundation" + StepIndex = 1 + TotalSteps = 3 + Message = "foundation exported" + ArtifactPath = "steps/01-foundation.glb" } + + let wire = RunEvent.toWire event + + Assert.Equal("checkpoint", wire.Kind) + Assert.Equal(Some "01-foundation", wire.StepId) + Assert.Equal(Some 3, wire.TotalSteps) + Assert.Equal(Some "steps/01-foundation.glb", wire.ArtifactPath) + +[<Fact>] +let ``checkpoint event serializes total steps`` () = + let event = + RunEvent.Checkpoint + { Ids = ids + At = DateTimeOffset.Parse("2026-09-20T12:00:05Z") + StepId = "01-foundation" + StepIndex = 0 + TotalSteps = 3 + Message = "foundation exported" + ArtifactPath = "steps/01-foundation.glb" } + + Assert.Contains("\"totalSteps\":3", RunEvent.toJson event) diff --git a/tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs b/tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs new file mode 100644 index 0000000..d961d8b --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs @@ -0,0 +1,36 @@ +module SomhairlesDream.Shared.Tests.FrontendContractTests + +open System.IO +open Xunit + +let private root = Path.GetFullPath(Path.Combine(__SOURCE_DIRECTORY__, "..", "..")) + +let private readRelative path = File.ReadAllText(Path.Combine(root, path)) + +let private assertBrowserContract contents = + Assert.Contains("/api/runs/start", contents) + Assert.Contains("/api/runs/events?projectId=", contents) + Assert.Contains("artifact-manifest-link", contents) + Assert.DoesNotContain("/api/events", contents) + +[<Fact>] +let ``frontend source and bundle use the selected artifact run lifecycle`` () = + readRelative "src/SomhairlesDream.Frontend/App.fs" + |> assertBrowserContract + + readRelative "public/App.js" + |> assertBrowserContract + + let html = readRelative "public/index.html" + Assert.Contains("id=\"run-start-button\"", html) + Assert.Contains("id=\"artifact-manifest-link\"", html) + +[<Fact>] +let ``solution contains every project in the artifact pipeline`` () = + let solution = readRelative "SomhairlesDream.sln" + + [ "SomhairlesDream.Modeling" + "SomhairlesDream.Cli" + "SomhairlesDream.Modeling.Tests" + "SomhairlesDream.Cli.Tests" ] + |> List.iter (fun project -> Assert.Contains(project, solution)) diff --git a/tests/SomhairlesDream.Shared.Tests/ReplayTests.fs b/tests/SomhairlesDream.Shared.Tests/ReplayTests.fs new file mode 100644 index 0000000..317fabe --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/ReplayTests.fs @@ -0,0 +1,26 @@ +namespace SomhairlesDream.Shared.Tests + +open Xunit +open SomhairlesDream.Shared + +module ReplayTests = + [<Fact>] + let ``reconstruction contains three visibly different geometry frames`` () = + let frames = Replay.frames () + + Assert.Equal(3, frames.Length) + Assert.True([| 1; 2; 3 |] = (frames |> Array.map (fun frame -> frame.Version))) + Assert.Equal("重建起点", frames[0].Label) + Assert.Equal("主承力骨架", frames[1].Label) + Assert.Equal("舱段重构", frames[2].Label) + Assert.False(frames[0].Mesh.Vertices = frames[1].Mesh.Vertices) + Assert.False(frames[1].Mesh.Vertices = frames[2].Mesh.Vertices) + + [<Fact>] + let ``mesh snapshots expose indexed triangle buffers`` () = + let mesh = (Replay.frames ()).[2].Mesh + let positions = MeshData.positionData mesh + let indices = MeshData.triangleIndices mesh + + Assert.Equal(mesh.Vertices.Length * 3, positions.Length) + Assert.Equal(45, indices.Length) diff --git a/tests/SomhairlesDream.Shared.Tests/RunStateTests.fs b/tests/SomhairlesDream.Shared.Tests/RunStateTests.fs new file mode 100644 index 0000000..08e4e89 --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/RunStateTests.fs @@ -0,0 +1,62 @@ +namespace SomhairlesDream.Shared.Tests + +open System +open Xunit +open SomhairlesDream.Shared + +module RunStateTests = + let private at hour = DateTimeOffset(2026, 9, 20, hour, 0, 0, 0, TimeSpan.Zero) + + [<Fact>] + let ``a new run is idle with no design work scheduled`` () = + let snapshot = RunState.initial "heritage-001" "run-001" (at 8) + + Assert.Equal("heritage-001", snapshot.ProjectId) + Assert.Equal("run-001", snapshot.RunId) + Assert.Equal(RunStatus.Idle, snapshot.Status) + Assert.Equal(0, snapshot.CurrentVersion) + Assert.Equal(0, snapshot.TargetVersion) + Assert.False(snapshot.NextDesignRunScheduled) + Assert.Equal("等待设计运行", snapshot.Message) + + [<Fact>] + let ``starting a run increments the target version and heartbeat keeps it running`` () = + let started = + RunState.initial "heritage-001" "run-001" (at 8) + |> RunState.start (at 9) + + let alive = RunState.heartbeat (at 9) started + + Assert.Equal(RunStatus.Running, alive.Status) + Assert.Equal(1, alive.TargetVersion) + Assert.Equal(Some(at 9), alive.HeartbeatAt) + + [<Fact>] + let ``a running run becomes stale after the heartbeat window`` () = + let started = + RunState.initial "heritage-001" "run-001" (at 8) + |> RunState.start (at 9) + + let stale = RunState.statusAt (TimeSpan.FromMinutes 2.) (at 10) started + + Assert.Equal(RunStatus.Stale, stale.Status) + Assert.Equal("心跳超时,等待重连", stale.Message) + + [<Fact>] + let ``publishing a mesh records the current version and makes the run successful`` () = + let started = + RunState.initial "heritage-001" "run-001" (at 8) + |> RunState.start (at 9) + + let mesh = + { Version = 1 + Label = "基础结构" + Vertices = [| { X = 0.; Y = 0.; Z = 0. } |] + Faces = [||] } + + let published = RunState.publish (at 9) mesh started + + Assert.Equal(RunStatus.Succeeded, published.Status) + Assert.Equal(1, published.CurrentVersion) + Assert.Equal(Some mesh, published.Mesh) + Assert.Equal("已发布基础结构", published.Message) diff --git a/tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj b/tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj new file mode 100644 index 0000000..d0e6130 --- /dev/null +++ b/tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net8.0</TargetFramework> + <IsPackable>false</IsPackable> + <IsTestProject>true</IsTestProject> + </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"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="../../src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj" /> + </ItemGroup> + <ItemGroup> + <Compile Include="RunStateTests.fs" /> + <Compile Include="ReplayTests.fs" /> + <Compile Include="ArtifactContractTests.fs" /> + <Compile Include="FrontendContractTests.fs" /> + </ItemGroup> +</Project> |
