# 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 --noCache ``` `--noCache` avoids fable's shared project-options cache, which is keyed by fsproj content: reusing it across checkouts of the same project can yield spurious `Fable namespace not defined` errors (and even leave a corrupted `App.js` behind after a failed compile). The same flag is required in the clean-checkout sequence below. The server project copies `public/**` into its build output (`CopyToOutputDirectory=PreserveNewest`), so rerun `dotnet build` on the server after regenerating the bundle. Three.js r160 and GLTFLoader are vendored under `public/vendor/` (import map plus a `three-setup.js` shim exposing `window.THREE`/`window.GLTFLoader`). They are MIT-licensed third-party dependencies, not project assets. ## Browser acceptance driver `tools/acceptance/e2e_acceptance.py` is the checked-in end-to-end gate. It starts the server on an isolated port with its own artifact root, launches headless chromium, and drives a full run through the page form twice (desktop 1440x1000 and narrow 390x844 viewports, `?freeze=1`), plus a failure-path run. It fails unless every check passes; exit code 0 means green. Checks include: - idle page (`待机`, empty-state text), SSE connection, live per-stage viewport ingestion (`运行中` + `steps/01-foundation.glb`, then `已完成` + `steps/03-cabin.glb`), manifest link exposure - geometry framing asserted from the live scene (`window.SOMHAIRLES_VIEWER` + `THREE.Box3` projected to screen space): model fully inside the canvas with margins >= 2% of the smaller canvas dimension, pixel coverage within 40%-95%, center offset <= 10%, on both viewports; stage-1 vs stage-3 bounding boxes must differ by >= 5% - frozen-viewport determinism: two consecutive canvas screenshots are byte-identical (sha256) - served GLB sha256 equality with pinned reference hashes (`01-foundation`, `03-cabin`) and `model/gltf-binary` content type - failure run (pre-existing run directory) surfaces `失败` and the pipeline error in `run-message`; zero browser console/page errors overall Playwright is self-provisioned into the gitignored `tools/acceptance/deps/` directory (`pip --target --break-system-packages`, pinned to an already-installed version when detectable) so the driver never installs into system site-packages; the chromium browser binary is reused from the user cache when present. Full verification from a clean checkout: ```sh dotnet tool restore # Fable must run BEFORE the solution build: the server serves the frontend # from public/ (copied into wwwroot at build time), and a fresh checkout has # no fable_modules/App.js yet. Also run the frontend project restore first, # and use --noCache: fable's project-options cache (keyed by fsproj content) # is shared across checkouts of the same project and can yield spurious # "Fable namespace not defined" errors on a fresh clone. dotnet restore src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj dotnet fable src/SomhairlesDream.Frontend/SomhairlesDream.Frontend.fsproj \ --outDir public --noRestore --noCache dotnet build SomhairlesDream.sln -c Release dotnet test SomhairlesDream.sln -c Release --no-build python3 tools/acceptance/e2e_acceptance.py --port 18201 \ --bpython /tmp/bpyenv/bin/python ``` `--bpython` must point at a Python that can `import bpy` (see below); the driver refuses to start without it. ## 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` and `blender/probe.py` (default `stage`) - `SOMHAIRLES_BPYTHON` — Python interpreter used to run those blender scripts; must be able to `import bpy` (default `python3`) ### Probe design branch The real-probe dataset lives alongside the heritage fixture: steps `probe-01-bus` … `probe-07-livery` are defined in `src/SomhairlesDream.Modeling/Definitions.fs` (`Design.probeSteps`) and rendered by `stage/blender/probe.py` (7 cumulative bpy exports, clay until `probe-07-livery` applies the livery pass). Steps are selected by project id prefix: any `probe-…` project id resolves to the probe design, any `ship-…` project id resolves to the crewed-starship design (`Design.shipSteps` rendered by `stage/blender/ship.py`, same 7-step clay→livery contract), and all other project ids keep the heritage design. The CLI mirrors this with `run --design probe` and `run --design ship`, each with its own id defaults. The public landing page boots into a pre-published probe run (`probe-001 / run-probe-real-2`): after startup the frontend fetches `GET /api/artifacts/manifest` for that run and renders its latest checkpoint with 构建-重演 labels. Serving works for completed runs that are no longer in the in-memory run registry: `GET /api/artifacts/manifest` and `GET /api/artifacts/steps` fall back to the on-disk verified manifest (integrity checks unchanged; unknown/offline environments stay on the honest placeholder idle state). To publish a fresh probe run, run the pipeline against the artifact root the server reads and a new run id. 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`, and `GET /api/artifacts/steps?projectId=...&runId=...&path=steps/.glb` (serve a step GLB for an active or completed run; `.glb` files under `steps/` only, traversal-guarded). Artifact serving is integrity-checked: `file` serves only paths published in the verified run manifest (logs, the manifest itself, and stray files are never exposed), `steps` serves only checkpointed GLBs (manifest members for completed runs), and every served stream is hashed against its recorded SHA-256 and byte count at request time. Filesystem links anywhere in the chain from the configured artifact root through the project and run directories down to the requested file are rejected with `400` — for manifest reads, checkpoint digest recording, and every served stream — and a failed open disposes its stream deterministically. Hash or byte-count deviations return `409`, and manifest verification failures surface as `500`. Link checks are not atomic with the open: a concurrent local writer could substitute a path component between the check and the open (TOCTOU); served content remains bound to the recorded SHA-256 digest, so a substituted file is served only if byte-identical. Operators must treat the artifact root as trusted against local writers. 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 ``` `run` options: `--project-id`, `--run-id`, `--base-id`, `--target-id`, `--output-root`, `--stage-root`, `--python`, `--render`, `--design heritage|probe` (probe selects the real-probe design; ids still at the heritage defaults are swapped to the probe defaults). `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 `///`: - `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 (playwright + bundled chromium, `--enable-unsafe-swiftshader`), server on `:8099` with the real bpy interpreter, run started through the page form: pre-run page shows exactly `当前没有下一版正在开发` and status `待机`; during the run the SSE snapshot drives the viewport to load `steps/01-foundation.glb` (status `运行中`, note `管线几何 · …` — LIVE ingestion, not replay); at completion the viewport shows `steps/03-cabin.glb`, status `已完成`, manifest link exposed; the served `steps/03-cabin.glb` is byte-identical (sha256) to the CLI-produced reference run; a failure run (pre-existing run directory) shows status `失败` with the error surfaced in `run-message`; zero console errors on all pages. - Browser acceptance driver (see above), 71/71 checks green on desktop and narrow viewports: framing asserted numerically from the projected scene bounding box (not by trusting whole-canvas hashes alone), frozen screenshots byte-deterministic, GLB hash equality, failure surfacing, zero console errors. A stale-load regression intercepts step GLB responses and fulfills them late, reordered, or with errors across nine runs, asserting via actual scene geometry (`THREE.Object3D` types/colors) and the visible note that superseded runs' responses never repaint the viewport, same-path reruns still reload, and run switches to empty state fall back to the replay frame. It also proves: a failed current latest-step load (after run completion) shows a visible retry affordance on the note and a user click re-requests the same owner/path without SSE reconnect; within one run an older step resolving after the newer step is ignored; and a live step response still pending after entering replay never repaints the selected replay frame view. A historical replay regression pins a real checkpoint mid-run by clicking its timeline node (the request carries the run's own projectId/runId/path), keeps the pin through later live snapshots (no auto-follow reloads), returns to live scope via 跟随最新 with the newest checkpoint, ignores a late older historical response after a newer selection, and asserts both the served checkpoint identity (sha256 equals the CLI reference GLB) and that selecting a different version changes the actual viewport geometry (THREE.Box3 rel diff ≥ 0.05). Held responses are drained and the route handler removed before the page closes. The frontend refits the camera to the loaded model (`THREE.Box3`-based framing) whenever the canvas resizes, and a resize never reverts the viewport to replay placeholder geometry. - NOT verified: production deployment of the old site remains untouched; `--render` PNG renders.