1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
|
# 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/<step>.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 <manifest.json>
```
`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 `<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 (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.
|