diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
| commit | 7b29dbff7e43e87741476f47df17ad1582b16546 (patch) | |
| tree | 4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 /src/SomhairlesDream.Modeling/BlenderBridge.fs | |
| parent | 47add3088d50f12c5f48591f26c53cdb59d8283b (diff) | |
| download | somhairles-dream-fsharp-7b29dbff7e43e87741476f47df17ad1582b16546.tar.gz | |
feat(pipeline): add runnable artifact pipeline increment
[Change Nature]
- This commit adds the first runnable increment of the F# artifact
pipeline: CLI, server, frontend bundle, stage fixture, and tests.
It is feature work, not a bug fix.
[New Capability]
- CLI `run` executes the three-step Blender fixture through the process
bridge and writes GLBs, step logs, events, and a manifest; `verify`
validates a manifest without Blender.
- Server exposes run start, run-scoped SSE, artifact manifest/file
routes, and serves the Fable frontend from its build output.
- Frontend starts runs, follows run-scoped SSE progress, and links the
artifact manifest on completion.
[Implementation]
- Shared layer holds domain, run state, replay, and artifact contracts;
Modeling holds the pipeline, 120s bridge timeout, and result-file
protocol; stage/blender/fixture.py owns geometry in Python (bpy owns
geometry, F# owns orchestration).
- Fable bundle is regenerated into public/ and copied by the server
project; solution includes all projects with warnings-as-errors.
- README documents build, Fable, server, interpreter/env, fixture
limitations, and current verification status.
[Impact]
- Verified: 40/40 solution tests pass; Release build 0 warnings/errors;
real CLI run with bpy 5.0.1 produced 3 GLBs, 3 logs, and a
verify-clean manifest; isolated-port server smoke flow succeeded.
- Not yet verified: browser end-to-end behavior and production
fidelity. Artifacts and caches are gitignored.
Diffstat (limited to 'src/SomhairlesDream.Modeling/BlenderBridge.fs')
| -rw-r--r-- | src/SomhairlesDream.Modeling/BlenderBridge.fs | 204 |
1 files changed, 204 insertions, 0 deletions
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) |
