summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
commit7b29dbff7e43e87741476f47df17ad1582b16546 (patch)
tree4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9
parent47add3088d50f12c5f48591f26c53cdb59d8283b (diff)
downloadsomhairles-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.
-rw-r--r--.config/dotnet-tools.json12
-rw-r--r--.gitignore14
-rw-r--r--Directory.Build.props7
-rw-r--r--README.md117
-rw-r--r--SomhairlesDream.sln85
-rw-r--r--docs/superpowers/plans/2026-09-20-artifact-pipeline-implementation.md49
-rw-r--r--docs/superpowers/specs/2026-09-20-artifact-pipeline-design.md32
-rw-r--r--global.json7
-rw-r--r--public/App.js392
-rw-r--r--public/SomhairlesDream.Shared/Artifact.js277
-rw-r--r--public/SomhairlesDream.Shared/Domain.js114
-rw-r--r--public/SomhairlesDream.Shared/Replay.js29
-rw-r--r--public/index.html190
-rw-r--r--public/styles.css692
-rw-r--r--src/SomhairlesDream.Cli/Arguments.fs91
-rw-r--r--src/SomhairlesDream.Cli/Program.fs51
-rw-r--r--src/SomhairlesDream.Cli/SomhairlesDream.Cli.fsproj16
-rw-r--r--src/SomhairlesDream.Frontend/App.fs338
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs109
-rw-r--r--src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj18
-rw-r--r--src/SomhairlesDream.Modeling/BlenderBridge.fs204
-rw-r--r--src/SomhairlesDream.Modeling/Definitions.fs31
-rw-r--r--src/SomhairlesDream.Modeling/Pipeline.fs469
-rw-r--r--src/SomhairlesDream.Modeling/Placeholder.fs4
-rw-r--r--src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj15
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunApi.fs161
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunCoordinator.fs141
-rw-r--r--src/SomhairlesDream.Server/Program.fs97
-rw-r--r--src/SomhairlesDream.Server/SomhairlesDream.Server.fsproj21
-rw-r--r--src/SomhairlesDream.Server/Store.fs259
-rw-r--r--src/SomhairlesDream.Shared/Artifact.fs218
-rw-r--r--src/SomhairlesDream.Shared/Domain.fs102
-rw-r--r--src/SomhairlesDream.Shared/Replay.fs107
-rw-r--r--src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj12
-rw-r--r--stage/blender/fixture.py202
-rw-r--r--tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs70
-rw-r--r--tests/SomhairlesDream.Cli.Tests/SomhairlesDream.Cli.Tests.fsproj21
-rw-r--r--tests/SomhairlesDream.Modeling.Tests/BridgeTests.fs72
-rw-r--r--tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs167
-rw-r--r--tests/SomhairlesDream.Modeling.Tests/SomhairlesDream.Modeling.Tests.fsproj23
-rw-r--r--tests/SomhairlesDream.Server.Tests/ApiTests.fs235
-rw-r--r--tests/SomhairlesDream.Server.Tests/CoordinatorTests.fs83
-rw-r--r--tests/SomhairlesDream.Server.Tests/SomhairlesDream.Server.Tests.fsproj25
-rw-r--r--tests/SomhairlesDream.Server.Tests/StoreTests.fs236
-rw-r--r--tests/SomhairlesDream.Shared.Tests/ArtifactContractTests.fs73
-rw-r--r--tests/SomhairlesDream.Shared.Tests/FrontendContractTests.fs36
-rw-r--r--tests/SomhairlesDream.Shared.Tests/ReplayTests.fs26
-rw-r--r--tests/SomhairlesDream.Shared.Tests/RunStateTests.fs62
-rw-r--r--tests/SomhairlesDream.Shared.Tests/SomhairlesDream.Shared.Tests.fsproj24
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>