diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 09:27:28 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 09:27:28 +0800 |
| commit | 07d6690b9b9de7db7debe25e8e3def5c0d68bb26 (patch) | |
| tree | dad82e0b692bc34ea1764652c63cb36bc67bcec8 | |
| parent | bba99bea934fe397b6aed59d3f33a5315799fa28 (diff) | |
| download | somhairles-dream-fsharp-07d6690b9b9de7db7debe25e8e3def5c0d68bb26.tar.gz | |
feat(probe): YJ-01 real-probe 7-stage dataset + probe design branch in pipeline/CLI
| -rw-r--r-- | src/SomhairlesDream.Cli/Arguments.fs | 57 | ||||
| -rw-r--r-- | src/SomhairlesDream.Cli/Program.fs | 1 | ||||
| -rw-r--r-- | src/SomhairlesDream.Modeling/BlenderBridge.fs | 13 | ||||
| -rw-r--r-- | src/SomhairlesDream.Modeling/Definitions.fs | 65 | ||||
| -rw-r--r-- | src/SomhairlesDream.Modeling/Pipeline.fs | 13 | ||||
| -rw-r--r-- | src/SomhairlesDream.Server/ArtifactRunCoordinator.fs | 1 | ||||
| -rw-r--r-- | stage/blender/probe.py | 816 | ||||
| -rw-r--r-- | tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs | 27 | ||||
| -rw-r--r-- | tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs | 31 |
9 files changed, 1002 insertions, 22 deletions
diff --git a/src/SomhairlesDream.Cli/Arguments.fs b/src/SomhairlesDream.Cli/Arguments.fs index 2de96be..fa9087b 100644 --- a/src/SomhairlesDream.Cli/Arguments.fs +++ b/src/SomhairlesDream.Cli/Arguments.fs @@ -9,7 +9,8 @@ type RunOptions = OutputRoot: string StageRoot: string Python: string - Render: bool } + Render: bool + Design: string } type Command = | Run of RunOptions @@ -19,22 +20,32 @@ type Command = module Arguments = let usage = "usage: dotnet run --project src/SomhairlesDream.Cli -- run [options]\n" - + " dotnet run --project src/SomhairlesDream.Cli -- verify <manifest.json>" + + " dotnet run --project src/SomhairlesDream.Cli -- verify <manifest.json>\n" + + "options: --project-id --run-id --base-id --target-id --output-root --stage-root --python --render --design heritage|probe" let private defaultPython () = let configured = Environment.GetEnvironmentVariable("SOMHAIRLES_BPYTHON") if String.IsNullOrWhiteSpace(configured) then "python3" else configured + let private heritageIds = + { ProjectId = "heritage-001" + RunId = "run-001" + BaseId = "base-000" + TargetId = "target-003" } + + let private probeIds = + { ProjectId = "probe-001" + RunId = "run-001" + BaseId = "probe-base-000" + TargetId = "probe-target-007" } + let private defaults () = - { Ids = - { ProjectId = "heritage-001" - RunId = "run-001" - BaseId = "base-000" - TargetId = "target-003" } + { Ids = heritageIds OutputRoot = ".artifacts" StageRoot = "stage" Python = defaultPython () - Render = false } + Render = false + Design = "heritage" } let private requiredValue (args: string array) (index: int) (optionName: string) = if index + 1 >= args.Length || String.IsNullOrWhiteSpace(args[index + 1]) then @@ -51,14 +62,34 @@ module Arguments = | "--output-root" -> Ok { options with OutputRoot = value } | "--stage-root" -> Ok { options with StageRoot = value } | "--python" -> Ok { options with Python = value } + | "--design" -> Ok { options with Design = value } | _ -> Error $"unknown option: {optionName}" + let private resolveIds (options: RunOptions) = + if options.Design = "probe" then + let swap heritage probe value = if value = heritage then probe else value + + let ids = + { ProjectId = swap heritageIds.ProjectId probeIds.ProjectId options.Ids.ProjectId + RunId = swap heritageIds.RunId probeIds.RunId options.Ids.RunId + BaseId = swap heritageIds.BaseId probeIds.BaseId options.Ids.BaseId + TargetId = swap heritageIds.TargetId probeIds.TargetId options.Ids.TargetId } + + { options with Ids = ids } + else + options + 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) + if options.Design <> "heritage" && options.Design <> "probe" then + Error $"invalid design: {options.Design}" + else + let resolved = resolveIds options + + [| resolved.Ids.ProjectId; resolved.Ids.RunId; resolved.Ids.BaseId; resolved.Ids.TargetId |] + |> Array.tryFind (Ids.validateResourceId >> Result.isError) + |> function + | Some value -> Error $"invalid resource id: {value}" + | None -> Ok(Run resolved) let private parseRun (args: string array) = let rec loop (index: int) (options: RunOptions) = diff --git a/src/SomhairlesDream.Cli/Program.fs b/src/SomhairlesDream.Cli/Program.fs index 0d9ed1c..dd58ff0 100644 --- a/src/SomhairlesDream.Cli/Program.fs +++ b/src/SomhairlesDream.Cli/Program.fs @@ -14,6 +14,7 @@ module Program = let pipelineOptions : PipelineOptions = { ArtifactRoot = options.OutputRoot Ids = options.Ids + Steps = if options.Design = "probe" then Design.probeSteps else Design.steps Render = options.Render Bridge = bridge Clock = fun () -> DateTimeOffset.UtcNow diff --git a/src/SomhairlesDream.Modeling/BlenderBridge.fs b/src/SomhairlesDream.Modeling/BlenderBridge.fs index c0795e9..c4215ae 100644 --- a/src/SomhairlesDream.Modeling/BlenderBridge.fs +++ b/src/SomhairlesDream.Modeling/BlenderBridge.fs @@ -101,8 +101,13 @@ type BlenderProcessBridge(python: string, stageRoot: string, ?timeout: TimeSpan) File.WriteAllText(path, content) - member private _.StartInfo(request: BridgeRequest) = - let scriptPath = Path.GetFullPath(Path.Combine(stageRoot, "blender", "fixture.py")) + member private _.ScriptPath(stepId: string) = + let scriptName = if stepId.StartsWith("probe-") then "probe.py" else "fixture.py" + + Path.GetFullPath(Path.Combine(stageRoot, "blender", scriptName)) + + member private this.StartInfo(request: BridgeRequest) = + let scriptPath = this.ScriptPath(request.Step.StepId) let info = ProcessStartInfo() info.FileName <- python info.UseShellExecute <- false @@ -128,10 +133,10 @@ type BlenderProcessBridge(python: string, stageRoot: string, ?timeout: TimeSpan) interface IArtifactBridge with member this.Export(request, heartbeat) = try - let scriptPath = Path.GetFullPath(Path.Combine(stageRoot, "blender", "fixture.py")) + let scriptPath = this.ScriptPath(request.Step.StepId) if not (File.Exists(scriptPath)) then - Error(bridgeError $"fixture is missing: {scriptPath}") + Error(bridgeError $"blender script is missing: {scriptPath}") else match Path.GetDirectoryName(request.GlbPath) with | null -> () diff --git a/src/SomhairlesDream.Modeling/Definitions.fs b/src/SomhairlesDream.Modeling/Definitions.fs index 64e6bc6..9a9256e 100644 --- a/src/SomhairlesDream.Modeling/Definitions.fs +++ b/src/SomhairlesDream.Modeling/Definitions.fs @@ -29,3 +29,68 @@ module Design = Changes = [| { ObjectId = "heritage.cabin"; Kind = "add"; Summary = "Add cabin volume" } { ObjectId = "heritage.crossbeam"; Kind = "add"; Summary = "Add cabin crossbeam" } |] } |] + + // YJ-01 Voyager-class current-tech probe (source: interstellar-dual-track/current-tech-probe + // scripts/build_probe.py, line ranges cited per step). Object ids map the legacy + // UPPER_SNAKE names onto lowercase dotted ids via documented rule + // (probe.bus.bay.panel.00 <- BUS_BAY_PANEL_00, probe.rtg.01.fin.00 <- RTG_01_FIN_0). + // Display plinth, information card and MODEL_LABEL are intentionally excluded. + let probeSteps : DesignStep array = + let bus = + [| "probe.bus.electronics.decagon"; "probe.bus.ring.front"; "probe.bus.ring.rear" + "probe.bus.bay.panel.00"; "probe.bus.bay.panel.01"; "probe.bus.bay.panel.02" + "probe.bus.bay.panel.03"; "probe.bus.bay.panel.04"; "probe.bus.bay.panel.05" + "probe.bus.bay.panel.06"; "probe.bus.bay.panel.07"; "probe.bus.bay.panel.08" + "probe.bus.bay.panel.09" + "probe.bus.mli.top"; "probe.bus.mli.bottom" + "probe.bus.radiator.port"; "probe.bus.radiator.starboard" + "probe.aacs" + "probe.thruster.01"; "probe.thruster.02"; "probe.thruster.03"; "probe.thruster.04" |] + let booms = + [| "probe.rtg.boom" + "probe.mag.boom"; "probe.mag.head"; "probe.mag.sensor.a"; "probe.mag.sensor.b" + "probe.pws.antenna.left"; "probe.pws.antenna.right" + "probe.pws.tip.left"; "probe.pws.tip.right" |] + let rtgs = + [| "probe.rtg.01"; "probe.rtg.01.fin.00"; "probe.rtg.01.fin.01"; "probe.rtg.01.fin.02" + "probe.rtg.02"; "probe.rtg.02.fin.00"; "probe.rtg.02.fin.01"; "probe.rtg.02.fin.02" + "probe.rtg.03"; "probe.rtg.03.fin.00"; "probe.rtg.03.fin.01"; "probe.rtg.03.fin.02" |] + let hga = + [| "probe.hga.reflector"; "probe.hga.feed" + "probe.hga.strut.00"; "probe.hga.strut.01"; "probe.hga.strut.02"; "probe.hga.strut.03" |] + let instruments = + [| "probe.scan.pivot"; "probe.scan.platform" + "probe.crs"; "probe.plasma"; "probe.optical"; "probe.optical.aperture" |] + let mast = [| "probe.science.boom" |] + let prior (prefix: string array) (current: string array) = + Array.append prefix current + + let busChanges = bus |> Array.map (fun id -> { ObjectId = id; Kind = "add"; Summary = "Add ten-sided bus with bay panels, MLI and radiators" }) + let boomChanges = booms |> Array.map (fun id -> { ObjectId = id; Kind = "add"; Summary = "Add fields-and-particles and RTG support booms" }) + let rtgChanges = rtgs |> Array.map (fun id -> { ObjectId = id; Kind = "add"; Summary = "Add three RTGs with heat-rejection fins" }) + let hgaChanges = hga |> Array.map (fun id -> { ObjectId = id; Kind = "add"; Summary = "Add high-gain antenna, feed and struts" }) + let instrumentChanges = instruments |> Array.map (fun id -> { ObjectId = id; Kind = "add"; Summary = "Add articulated scan platform with science instruments" }) + let mastChanges = mast |> Array.map (fun id -> { ObjectId = id; Kind = "add"; Summary = "Add deployable science mast" }) + let liveryChanges = + [| { ObjectId = "probe.hga.reflector"; Kind = "update"; Summary = "Livery pass: reflector white finish" } + { ObjectId = "probe.bus.mli.top"; Kind = "update"; Summary = "Livery pass: MLI gold foil finish" } + { ObjectId = "probe.bus.bay.panel.01"; Kind = "update"; Summary = "Livery pass: restrained red accents on bay panels" } + { ObjectId = "probe.pws.tip.left"; Kind = "update"; Summary = "Livery pass: red antenna tip markers" } + { ObjectId = "probe.bus.electronics.decagon"; Kind = "update"; Summary = "Livery pass: body blue basecoat" } + { ObjectId = "probe.rtg.01"; Kind = "update"; Summary = "Livery pass: graphite RTG housings" } |] + + [| { StepId = "probe-01-bus"; Index = 0; ObjectIds = bus; Changes = busChanges } + { StepId = "probe-02-boom"; Index = 1; ObjectIds = prior bus booms; Changes = boomChanges } + { StepId = "probe-03-rtg"; Index = 2; ObjectIds = prior (prior bus booms) rtgs; Changes = rtgChanges } + { StepId = "probe-04-hga"; Index = 3; ObjectIds = prior (prior (prior bus booms) rtgs) hga; Changes = hgaChanges } + { StepId = "probe-05-instruments"; Index = 4 + ObjectIds = prior (prior (prior (prior bus booms) rtgs) hga) instruments; Changes = instrumentChanges } + { StepId = "probe-06-mast"; Index = 5 + ObjectIds = prior (prior (prior (prior (prior bus booms) rtgs) hga) instruments) mast + Changes = mastChanges } + { StepId = "probe-07-livery"; Index = 6 + ObjectIds = prior (prior (prior (prior (prior (prior bus booms) rtgs) hga) instruments) mast) [||] + Changes = liveryChanges } |] + + let forProject (projectId: string) : DesignStep array = + if projectId.StartsWith("probe-") then probeSteps else steps diff --git a/src/SomhairlesDream.Modeling/Pipeline.fs b/src/SomhairlesDream.Modeling/Pipeline.fs index 3f1f105..d9db5f6 100644 --- a/src/SomhairlesDream.Modeling/Pipeline.fs +++ b/src/SomhairlesDream.Modeling/Pipeline.fs @@ -33,6 +33,7 @@ type IArtifactBridge = type PipelineOptions = { ArtifactRoot: string Ids: RunIds + Steps: DesignStep array Render: bool Bridge: IArtifactBridge Clock: unit -> DateTimeOffset @@ -214,10 +215,12 @@ module Pipeline = let emit event = appendEvent log options.OnEvent event let ids = options.Ids - emit (RunEvent.Start { Ids = ids; At = startedAt; TotalSteps = Design.steps.Length }) + let steps = options.Steps + + emit (RunEvent.Start { Ids = ids; At = startedAt; TotalSteps = steps.Length }) let artifacts = - [| for step in Design.steps do + [| for step in steps do currentStep <- Some step.StepId emit (RunEvent.Heartbeat { Ids = ids; At = options.Clock(); Message = $"starting {step.StepId}" }) @@ -275,7 +278,7 @@ module Pipeline = At = options.Clock() StepId = step.StepId StepIndex = step.Index - TotalSteps = Design.steps.Length + TotalSteps = steps.Length Message = "artifact verified" ArtifactPath = relativePath } @@ -424,12 +427,12 @@ module ArtifactVerifier = || 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 + elif manifest.Steps.Length <> (Design.forProject manifest.ProjectId).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 + for expected, actual in Array.zip (Design.forProject manifest.ProjectId) 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 diff --git a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs index 022ac7d..07ac984 100644 --- a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs +++ b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs @@ -217,6 +217,7 @@ type ArtifactRunCoordinator( let options : PipelineOptions = { ArtifactRoot = root Ids = ids + Steps = Design.forProject ids.ProjectId Render = render Bridge = bridgeFactory () Clock = clock diff --git a/stage/blender/probe.py b/stage/blender/probe.py new file mode 100644 index 0000000..aaffaf4 --- /dev/null +++ b/stage/blender/probe.py @@ -0,0 +1,816 @@ +"""YJ-01 Voyager-class current-tech probe dataset for the artifact pipeline. + +Geometry ported from the legacy reference model: + /home/somhairle/Projects/interstellar-dual-track/current-tech-probe/scripts/build_probe.py +(line ranges cited per stage below). Object ids are mapped from the legacy +UPPER_SNAKE names onto lowercase dotted ids, e.g.: + + BUS_ELECTRONICS_DECAGON -> probe.bus.electronics.decagon + BUS_BAY_PANEL_00 -> probe.bus.bay.panel.00 + RTG_01_FIN_0 -> probe.rtg.01.fin.00 + HGA_FEED_STRUT_3 -> probe.hga.strut.03 + MAGNETOMETER_SENSOR_A -> probe.mag.sensor.a + +Display plinth, information card and MODEL_LABEL are intentionally excluded. +工程限制: 非飞行设计 heritage 参考. + +Contract (identical to fixture.py): + --step-id <id> --output <glb> --result-output <json> [--render-output <png>] +""" + +import argparse +import json +import math +import os +import sys +from datetime import datetime, timezone + +import bpy # noqa: F401 (must be imported before mathutils: registers the bundled mathutils module) +from mathutils import Vector + +# Cumulative object id lists per step. Must stay in lockstep with +# Design.probeSteps in src/SomhairlesDream.Modeling/Definitions.fs. +STEP_OBJECTS = { + "probe-01-bus": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + ], + "probe-02-boom": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + "probe.rtg.boom", + "probe.mag.boom", + "probe.mag.head", + "probe.mag.sensor.a", + "probe.mag.sensor.b", + "probe.pws.antenna.left", + "probe.pws.antenna.right", + "probe.pws.tip.left", + "probe.pws.tip.right", + ], + "probe-03-rtg": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + "probe.rtg.boom", + "probe.mag.boom", + "probe.mag.head", + "probe.mag.sensor.a", + "probe.mag.sensor.b", + "probe.pws.antenna.left", + "probe.pws.antenna.right", + "probe.pws.tip.left", + "probe.pws.tip.right", + "probe.rtg.01", + "probe.rtg.01.fin.00", + "probe.rtg.01.fin.01", + "probe.rtg.01.fin.02", + "probe.rtg.02", + "probe.rtg.02.fin.00", + "probe.rtg.02.fin.01", + "probe.rtg.02.fin.02", + "probe.rtg.03", + "probe.rtg.03.fin.00", + "probe.rtg.03.fin.01", + "probe.rtg.03.fin.02", + ], + "probe-04-hga": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + "probe.rtg.boom", + "probe.mag.boom", + "probe.mag.head", + "probe.mag.sensor.a", + "probe.mag.sensor.b", + "probe.pws.antenna.left", + "probe.pws.antenna.right", + "probe.pws.tip.left", + "probe.pws.tip.right", + "probe.rtg.01", + "probe.rtg.01.fin.00", + "probe.rtg.01.fin.01", + "probe.rtg.01.fin.02", + "probe.rtg.02", + "probe.rtg.02.fin.00", + "probe.rtg.02.fin.01", + "probe.rtg.02.fin.02", + "probe.rtg.03", + "probe.rtg.03.fin.00", + "probe.rtg.03.fin.01", + "probe.rtg.03.fin.02", + "probe.hga.reflector", + "probe.hga.feed", + "probe.hga.strut.00", + "probe.hga.strut.01", + "probe.hga.strut.02", + "probe.hga.strut.03", + ], + "probe-05-instruments": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + "probe.rtg.boom", + "probe.mag.boom", + "probe.mag.head", + "probe.mag.sensor.a", + "probe.mag.sensor.b", + "probe.pws.antenna.left", + "probe.pws.antenna.right", + "probe.pws.tip.left", + "probe.pws.tip.right", + "probe.rtg.01", + "probe.rtg.01.fin.00", + "probe.rtg.01.fin.01", + "probe.rtg.01.fin.02", + "probe.rtg.02", + "probe.rtg.02.fin.00", + "probe.rtg.02.fin.01", + "probe.rtg.02.fin.02", + "probe.rtg.03", + "probe.rtg.03.fin.00", + "probe.rtg.03.fin.01", + "probe.rtg.03.fin.02", + "probe.hga.reflector", + "probe.hga.feed", + "probe.hga.strut.00", + "probe.hga.strut.01", + "probe.hga.strut.02", + "probe.hga.strut.03", + "probe.scan.pivot", + "probe.scan.platform", + "probe.crs", + "probe.plasma", + "probe.optical", + "probe.optical.aperture", + ], + "probe-06-mast": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + "probe.rtg.boom", + "probe.mag.boom", + "probe.mag.head", + "probe.mag.sensor.a", + "probe.mag.sensor.b", + "probe.pws.antenna.left", + "probe.pws.antenna.right", + "probe.pws.tip.left", + "probe.pws.tip.right", + "probe.rtg.01", + "probe.rtg.01.fin.00", + "probe.rtg.01.fin.01", + "probe.rtg.01.fin.02", + "probe.rtg.02", + "probe.rtg.02.fin.00", + "probe.rtg.02.fin.01", + "probe.rtg.02.fin.02", + "probe.rtg.03", + "probe.rtg.03.fin.00", + "probe.rtg.03.fin.01", + "probe.rtg.03.fin.02", + "probe.hga.reflector", + "probe.hga.feed", + "probe.hga.strut.00", + "probe.hga.strut.01", + "probe.hga.strut.02", + "probe.hga.strut.03", + "probe.scan.pivot", + "probe.scan.platform", + "probe.crs", + "probe.plasma", + "probe.optical", + "probe.optical.aperture", + "probe.science.boom", + ], + "probe-07-livery": [ + "probe.bus.electronics.decagon", + "probe.bus.ring.front", + "probe.bus.ring.rear", + "probe.bus.bay.panel.00", + "probe.bus.bay.panel.01", + "probe.bus.bay.panel.02", + "probe.bus.bay.panel.03", + "probe.bus.bay.panel.04", + "probe.bus.bay.panel.05", + "probe.bus.bay.panel.06", + "probe.bus.bay.panel.07", + "probe.bus.bay.panel.08", + "probe.bus.bay.panel.09", + "probe.bus.mli.top", + "probe.bus.mli.bottom", + "probe.bus.radiator.port", + "probe.bus.radiator.starboard", + "probe.aacs", + "probe.thruster.01", + "probe.thruster.02", + "probe.thruster.03", + "probe.thruster.04", + "probe.rtg.boom", + "probe.mag.boom", + "probe.mag.head", + "probe.mag.sensor.a", + "probe.mag.sensor.b", + "probe.pws.antenna.left", + "probe.pws.antenna.right", + "probe.pws.tip.left", + "probe.pws.tip.right", + "probe.rtg.01", + "probe.rtg.01.fin.00", + "probe.rtg.01.fin.01", + "probe.rtg.01.fin.02", + "probe.rtg.02", + "probe.rtg.02.fin.00", + "probe.rtg.02.fin.01", + "probe.rtg.02.fin.02", + "probe.rtg.03", + "probe.rtg.03.fin.00", + "probe.rtg.03.fin.01", + "probe.rtg.03.fin.02", + "probe.hga.reflector", + "probe.hga.feed", + "probe.hga.strut.00", + "probe.hga.strut.01", + "probe.hga.strut.02", + "probe.hga.strut.03", + "probe.scan.pivot", + "probe.scan.platform", + "probe.crs", + "probe.plasma", + "probe.optical", + "probe.optical.aperture", + "probe.science.boom", + ], +} + +LIVERY_STEPS = {"probe-07-livery"} + + +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 rgba(rgb, alpha=1.0): + return (*rgb, alpha) + + +def make_material(bpy, name, color, *, metallic=0.0, roughness=0.45, emission=None, emission_strength=0.0): + material = bpy.data.materials.get(name) or bpy.data.materials.new(name) + material.use_nodes = True + material.diffuse_color = rgba(color) + shader = material.node_tree.nodes.get("Principled BSDF") + shader.inputs["Base Color"].default_value = rgba(color) + shader.inputs["Metallic"].default_value = metallic + shader.inputs["Roughness"].default_value = roughness + if emission is not None: + emission_input = shader.inputs.get("Emission Color") or shader.inputs.get("Emission") + if emission_input: + emission_input.default_value = rgba(emission) + strength_input = shader.inputs.get("Emission Strength") + if strength_input: + strength_input.default_value = emission_strength + return material + + +def build_materials(bpy): + return { + "clay_body": make_material(bpy, "CLAY BODY", (0.38, 0.42, 0.46), metallic=0.20, roughness=0.38), + "clay_structure": make_material(bpy, "CLAY STRUCTURE", (0.17, 0.21, 0.25), metallic=0.65, roughness=0.32), + "clay_dark": make_material(bpy, "CLAY DARK", (0.045, 0.055, 0.065), metallic=0.45, roughness=0.28), + "clay_mli": make_material(bpy, "CLAY MLI", (0.54, 0.56, 0.52), metallic=0.08, roughness=0.50), + "clay_white": make_material(bpy, "CLAY REFLECTOR", (0.62, 0.65, 0.67), metallic=0.18, roughness=0.34), + "clay_glass": make_material(bpy, "CLAY GLASS", (0.035, 0.10, 0.13), metallic=0.12, roughness=0.14, emission=(0.01, 0.05, 0.07), emission_strength=0.15), + "livery_body": make_material(bpy, "LIVERY BODY BLUE", (0.045, 0.16, 0.22), metallic=0.35, roughness=0.30), + "livery_structure": make_material(bpy, "LIVERY STRUCTURE", (0.13, 0.18, 0.22), metallic=0.78, roughness=0.24), + "livery_dark": make_material(bpy, "LIVERY GRAPHITE", (0.018, 0.025, 0.032), metallic=0.70, roughness=0.24), + "livery_mli": make_material(bpy, "LIVERY MLI GOLD", (0.58, 0.34, 0.10), metallic=0.30, roughness=0.35), + "livery_white": make_material(bpy, "LIVERY DISH WHITE", (0.78, 0.81, 0.79), metallic=0.15, roughness=0.28), + "livery_red": make_material(bpy, "LIVERY RESTRAINED RED", (0.52, 0.025, 0.018), metallic=0.22, roughness=0.28), + "livery_glass": make_material(bpy, "LIVERY GLASS", (0.008, 0.055, 0.085), metallic=0.20, roughness=0.10, emission=(0.005, 0.045, 0.09), emission_strength=0.20), + } + + +def clear_scene(bpy): + bpy.ops.wm.read_factory_settings(use_empty=True) + + +def add_beveled_cube(bpy, name, location, dimensions, clay_material, livery_material, bevel=0.04, rotation=None): + bpy.ops.mesh.primitive_cube_add(location=location, rotation=rotation or (0.0, 0.0, 0.0)) + obj = bpy.context.object + obj.name = name + obj.data.name = name + ".mesh" + obj.dimensions = dimensions + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + if bevel: + modifier = obj.modifiers.new("Structural edge softening", "BEVEL") + modifier.width = bevel + modifier.segments = 3 + return assign_variants(obj, clay_material, livery_material) + + +def add_cylinder(bpy, name, location, radius, depth, clay_material, livery_material, *, rotation=(0.0, 0.0, 0.0), vertices=32): + bpy.ops.mesh.primitive_cylinder_add( + vertices=vertices, + radius=radius, + depth=depth, + location=location, + rotation=rotation, + ) + obj = bpy.context.object + obj.name = name + obj.data.name = name + ".mesh" + return assign_variants(obj, clay_material, livery_material) + + +def add_cone(bpy, name, location, radius1, radius2, depth, clay_material, livery_material, *, rotation=(0.0, 0.0, 0.0)): + bpy.ops.mesh.primitive_cone_add( + vertices=24, + radius1=radius1, + radius2=radius2, + depth=depth, + location=location, + rotation=rotation, + ) + obj = bpy.context.object + obj.name = name + obj.data.name = name + ".mesh" + return assign_variants(obj, clay_material, livery_material) + + +def add_uv_sphere(bpy, name, location, radius, clay_material, livery_material): + bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=16, radius=radius, location=location) + obj = bpy.context.object + obj.name = name + obj.data.name = name + ".mesh" + return assign_variants(obj, clay_material, livery_material) + + +def add_torus(bpy, name, location, major_radius, minor_radius, clay_material, livery_material, *, rotation=(0.0, 0.0, 0.0)): + bpy.ops.mesh.primitive_torus_add( + major_radius=major_radius, + minor_radius=minor_radius, + major_segments=48, + minor_segments=10, + location=location, + rotation=rotation, + ) + obj = bpy.context.object + obj.name = name + obj.data.name = name + ".mesh" + return assign_variants(obj, clay_material, livery_material) + + +def add_between(bpy, name, start, end, radius, clay_material, livery_material, vertices=20): + start_vec = Vector(start) + end_vec = Vector(end) + delta = end_vec - start_vec + obj = add_cylinder( + bpy, + name, + (start_vec + end_vec) / 2.0, + radius, + delta.length, + clay_material, + livery_material, + vertices=vertices, + ) + obj.rotation_euler = delta.to_track_quat("Z", "Y").to_euler() + return obj + + +def add_dish(bpy, name, base_x, center_y, center_z, radius, depth, clay_material, livery_material): + """Create a shallow open paraboloid whose boresight points along +X.""" + segments = 64 + rings = 9 + vertices = [(base_x, center_y, center_z)] + for ring in range(1, rings + 1): + fraction = ring / rings + ring_radius = radius * fraction + ring_x = base_x + depth * fraction * fraction + for segment in range(segments): + angle = 2.0 * math.pi * segment / segments + vertices.append( + ( + ring_x, + center_y + ring_radius * math.cos(angle), + center_z + ring_radius * math.sin(angle), + ) + ) + faces = [] + for segment in range(segments): + faces.append((0, 1 + segment, 1 + (segment + 1) % segments)) + for ring in range(1, rings): + first = 1 + (ring - 1) * segments + next_first = first + segments + for segment in range(segments): + current = first + segment + following = first + (segment + 1) % segments + next_current = next_first + segment + next_following = next_first + (segment + 1) % segments + faces.append((current, next_current, next_following, following)) + mesh = bpy.data.meshes.new(name + ".mesh") + mesh.from_pydata(vertices, [], faces) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(obj) + assign_variants(obj, clay_material, livery_material) + solidify = obj.modifiers.new("Reflector thickness", "SOLIDIFY") + solidify.thickness = 0.012 + return obj + + +def assign_variants(obj, clay_material, livery_material): + obj["object_id"] = obj.name + obj["clay_material"] = clay_material.name + obj["livery_material"] = livery_material.name + obj.data.materials.clear() + obj.data.materials.append(clay_material) + return obj + + +def apply_material_mode(bpy, materials, mode): + if mode not in {"clay", "livery"}: + raise ValueError(f"unknown material mode: {mode}") + for obj in bpy.context.scene.objects: + material_name = obj.get(f"{mode}_material") + if not material_name or not obj.data or not hasattr(obj.data, "materials"): + continue + material = bpy.data.materials.get(material_name) + if material is None: + raise RuntimeError(f"missing material {material_name!r} for {obj.name}") + obj.data.materials.clear() + obj.data.materials.append(material) + + +def build_geometry(bpy, materials, step_id): + """Build every object of the cumulative step, in STEP_OBJECTS order. + + Stage sources (legacy build_probe.py): + probe-01-bus :364-390, :401-404 + probe-02-boom :406-410, :418-433 + probe-03-rtg :411-416 + probe-04-hga :392-399 + probe-05-instruments :437-442 + probe-06-mast :436, :443-444 + probe-07-livery :450-473 (livery material mode over the full model) + """ + clay_body = materials["clay_body"] + clay_structure = materials["clay_structure"] + clay_dark = materials["clay_dark"] + clay_mli = materials["clay_mli"] + clay_white = materials["clay_white"] + clay_glass = materials["clay_glass"] + livery_body = materials["livery_body"] + livery_structure = materials["livery_structure"] + livery_dark = materials["livery_dark"] + livery_mli = materials["livery_mli"] + livery_white = materials["livery_white"] + livery_red = materials["livery_red"] + livery_glass = materials["livery_glass"] + + objects = {} + bay_panel_livery = {} + for index in range(10): + bay_panel_livery[f"probe.bus.bay.panel.{index:02d}"] = livery_red if index in {1, 6} else livery_structure + + for object_id in STEP_OBJECTS[step_id]: + if object_id == "probe.bus.electronics.decagon": + objects[object_id] = add_cylinder(bpy, object_id, (0.0, 0.0, 0.0), 0.45, 0.48, clay_body, livery_body, rotation=(0.0, math.pi / 2.0, 0.0), vertices=10) + elif object_id == "probe.bus.ring.front": + objects[object_id] = add_torus(bpy, object_id, (0.25, 0.0, 0.0), 0.41, 0.025, clay_structure, livery_structure, rotation=(0.0, math.pi / 2.0, 0.0)) + elif object_id == "probe.bus.ring.rear": + objects[object_id] = add_torus(bpy, object_id, (-0.25, 0.0, 0.0), 0.41, 0.025, clay_structure, livery_structure, rotation=(0.0, math.pi / 2.0, 0.0)) + elif object_id.startswith("probe.bus.bay.panel."): + index = int(object_id.rsplit(".", 1)[-1]) + angle = 2.0 * math.pi * index / 10.0 + y = 0.46 * math.cos(angle) + z = 0.46 * math.sin(angle) + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.01, y, z), (0.36, 0.055, 0.18), clay_structure, bay_panel_livery[object_id], bevel=0.018, rotation=(angle, 0.0, 0.0)) + elif object_id == "probe.bus.mli.top": + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.02, 0.0, 0.465), (0.34, 0.56, 0.055), clay_mli, livery_mli, bevel=0.012) + elif object_id == "probe.bus.mli.bottom": + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.02, 0.0, -0.465), (0.34, 0.56, 0.055), clay_mli, livery_mli, bevel=0.012) + elif object_id == "probe.bus.radiator.port": + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.05, -0.47, 0.0), (0.28, 0.045, 0.34), clay_dark, livery_dark, bevel=0.012) + elif object_id == "probe.bus.radiator.starboard": + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.05, 0.47, 0.0), (0.28, 0.045, 0.34), clay_dark, livery_dark, bevel=0.012) + elif object_id == "probe.aacs": + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.28, 0.0, 0.18), (0.08, 0.22, 0.18), clay_dark, livery_dark, bevel=0.018) + elif object_id.startswith("probe.thruster."): + index = int(object_id.rsplit(".", 1)[-1]) + y, z = ((-0.30, -0.25), (-0.30, 0.25), (0.30, -0.25), (0.30, 0.25))[index - 1] + objects[object_id] = add_cone(bpy, object_id, (-0.28, y, z), 0.045, 0.018, 0.10, clay_dark, livery_dark, rotation=(0.0, math.pi / 2.0, 0.0)) + elif object_id == "probe.rtg.boom": + objects[object_id] = add_between(bpy, object_id, (-0.12, -0.42, -0.16), (-0.12, -1.85, -0.16), 0.028, clay_structure, livery_structure) + elif object_id == "probe.mag.boom": + objects[object_id] = add_between(bpy, object_id, (0.0, 0.18, 0.18), (0.0, 6.50, 0.18), 0.022, clay_structure, livery_structure) + elif object_id == "probe.mag.head": + objects[object_id] = add_cylinder(bpy, object_id, (0.0, 6.56, 0.18), 0.10, 0.20, clay_dark, livery_dark, rotation=(math.pi / 2.0, 0.0, 0.0)) + elif object_id == "probe.mag.sensor.a": + objects[object_id] = add_uv_sphere(bpy, object_id, (0.0, 6.68, 0.25), 0.045, clay_glass, livery_glass) + elif object_id == "probe.mag.sensor.b": + objects[object_id] = add_uv_sphere(bpy, object_id, (0.0, 6.68, 0.11), 0.045, clay_glass, livery_glass) + elif object_id == "probe.pws.antenna.left": + objects[object_id] = add_between(bpy, object_id, (-0.12, 0.0, 0.28), (-0.12, -2.50, 1.10), 0.012, clay_structure, livery_structure, vertices=12) + elif object_id == "probe.pws.antenna.right": + objects[object_id] = add_between(bpy, object_id, (-0.12, 0.0, 0.28), (-0.12, 2.50, 1.10), 0.012, clay_structure, livery_structure, vertices=12) + elif object_id == "probe.pws.tip.left": + objects[object_id] = add_uv_sphere(bpy, object_id, (-0.12, -2.50, 1.10), 0.035, clay_structure, livery_red) + elif object_id == "probe.pws.tip.right": + objects[object_id] = add_uv_sphere(bpy, object_id, (-0.12, 2.50, 1.10), 0.035, clay_structure, livery_red) + elif object_id.startswith("probe.rtg.") and object_id.endswith(tuple(f".fin.{i:02d}" for i in range(3))): + rtg_part, fin_part = object_id.rsplit(".fin.", 1) + rtg_index = int(rtg_part.rsplit(".", 1)[-1]) + fin_index = int(fin_part) + y = (-0.80, -1.22, -1.64)[rtg_index - 1] - 0.09 + fin_index * 0.09 + objects[object_id] = add_torus(bpy, object_id, (-0.12, y, -0.16), 0.112, 0.015, clay_structure, livery_structure, rotation=(math.pi / 2.0, 0.0, 0.0)) + elif object_id.startswith("probe.rtg."): + index = int(object_id.rsplit(".", 1)[-1]) + y = (-0.80, -1.22, -1.64)[index - 1] + objects[object_id] = add_cylinder(bpy, object_id, (-0.12, y, -0.16), 0.105, 0.27, clay_dark, livery_dark, rotation=(math.pi / 2.0, 0.0, 0.0)) + elif object_id == "probe.hga.reflector": + objects[object_id] = add_dish(bpy, object_id, 0.30, 0.0, 0.0, 0.925, 0.14, clay_white, livery_white) + elif object_id == "probe.hga.feed": + objects[object_id] = add_cylinder(bpy, object_id, (0.95, 0.0, 0.0), 0.075, 0.12, clay_glass, livery_glass, rotation=(0.0, math.pi / 2.0, 0.0)) + elif object_id.startswith("probe.hga.strut."): + index = int(object_id.rsplit(".", 1)[-1]) + point = ((0.41, -0.60, -0.60), (0.41, -0.60, 0.60), (0.41, 0.60, -0.60), (0.41, 0.60, 0.60))[index] + objects[object_id] = add_between(bpy, object_id, point, (0.95, 0.0, 0.0), 0.018, clay_structure, livery_structure, vertices=12) + elif object_id == "probe.scan.pivot": + objects[object_id] = add_cylinder(bpy, object_id, (0.0, 0.0, 1.53), 0.11, 0.12, clay_dark, livery_dark) + elif object_id == "probe.scan.platform": + objects[object_id] = add_beveled_cube(bpy, object_id, (0.0, 0.0, 1.60), (0.32, 0.48, 0.12), clay_body, livery_body, bevel=0.025) + elif object_id == "probe.crs": + objects[object_id] = add_beveled_cube(bpy, object_id, (0.15, 0.0, 1.76), (0.18, 0.18, 0.15), clay_dark, livery_dark, bevel=0.02) + elif object_id == "probe.plasma": + objects[object_id] = add_beveled_cube(bpy, object_id, (-0.15, 0.0, 1.76), (0.18, 0.18, 0.15), clay_dark, livery_dark, bevel=0.02) + elif object_id == "probe.optical": + objects[object_id] = add_beveled_cube(bpy, object_id, (0.0, -0.16, 1.76), (0.20, 0.12, 0.15), clay_body, livery_body, bevel=0.018) + elif object_id == "probe.optical.aperture": + objects[object_id] = add_cylinder(bpy, object_id, (0.11, -0.16, 1.76), 0.045, 0.035, clay_glass, livery_glass, rotation=(0.0, math.pi / 2.0, 0.0)) + elif object_id == "probe.science.boom": + objects[object_id] = add_between(bpy, object_id, (0.0, 0.0, 0.34), (0.0, 0.0, 1.50), 0.035, clay_structure, livery_structure) + else: + fail("unknown object id: " + object_id, 4) + + ordered = [objects[object_id] for object_id in STEP_OBJECTS[step_id]] + for obj, object_id in zip(ordered, STEP_OBJECTS[step_id]): + if obj.get("object_id") != object_id: + fail("source object custom property verification failed", 4) + return ordered + + +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("probe.world") + scene.world.color = (0.025, 0.035, 0.045) + + camera_data = bpy.data.cameras.new("probe.camera") + camera_data.type = "ORTHO" + camera_data.ortho_scale = 15.0 + camera = bpy.data.objects.new("probe.camera", camera_data) + bpy.context.collection.objects.link(camera) + camera.location = (8.2, -12.5, 6.3) + camera.rotation_euler = (Vector((0.0, 2.0, 0.2)) - camera.location).to_track_quat("-Z", "Y").to_euler() + scene.camera = camera + + light_data = bpy.data.lights.new("probe.key", type="AREA") + light_data.energy = 900 + light_data.shape = "DISK" + light_data.size = 5.0 + light = bpy.data.objects.new("probe.key", light_data) + bpy.context.collection.objects.link(light) + light.location = (4.0, -4.0, 8.0) + light.rotation_euler = (Vector((0.0, 2.0, 0.2)) - 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, export_apply=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) + materials = build_materials(bpy) + objects = build_geometry(bpy, materials, args.step_id) + apply_material_mode(bpy, materials, "livery" if args.step_id in LIVERY_STEPS else "clay") + 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("probe result written: " + result_output) + except SystemExit: + raise + except Exception as exc: + fail("probe failed: " + str(exc), 4) + + +if __name__ == "__main__": + main() diff --git a/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs b/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs index f55762e..38c8741 100644 --- a/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs +++ b/tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs @@ -60,6 +60,33 @@ let ``run command parses explicit ids and render flag`` () = | Help -> failwith "expected run command" [<Fact>] +let ``run command with probe design applies probe defaults`` () = + match Arguments.parse [| "run"; "--design"; "probe" |] |> expectOk with + | Run options -> + Assert.Equal("probe-001", options.Ids.ProjectId) + Assert.Equal("run-001", options.Ids.RunId) + Assert.Equal("probe-base-000", options.Ids.BaseId) + Assert.Equal("probe-target-007", options.Ids.TargetId) + Assert.Equal("probe", options.Design) + | Verify _ + | Help -> failwith "expected run command" + +[<Fact>] +let ``run command with probe design keeps explicit ids`` () = + match Arguments.parse [| "run"; "--design"; "probe"; "--run-id"; "run-x" |] |> expectOk with + | Run options -> + Assert.Equal("probe-001", options.Ids.ProjectId) + Assert.Equal("run-x", options.Ids.RunId) + Assert.Equal("probe-base-000", options.Ids.BaseId) + | Verify _ + | Help -> failwith "expected run command" + +[<Fact>] +let ``run command rejects unknown design`` () = + match Arguments.parse [| "run"; "--design"; "steampunk" |] |> expectError with + | message -> Assert.Contains("invalid design", message) + +[<Fact>] let ``verify command requires exactly one manifest path`` () = match Arguments.parse [| "verify"; "run"; "extra" |] |> expectError with | message -> Assert.Contains("manifest", message) diff --git a/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs b/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs index 7a5e3e1..221e7d7 100644 --- a/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs +++ b/tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs @@ -60,6 +60,7 @@ type private TestBridge(?wrongObjectIds: bool) = let private options root bridge = { PipelineOptions.ArtifactRoot = root Ids = ids + Steps = Design.steps Render = true Bridge = bridge Clock = fun () -> DateTimeOffset.Parse("2026-09-20T12:00:00Z") @@ -89,6 +90,36 @@ let ``design steps expose the fixed ordered object contract`` () = "heritage.crossbeam" |] = steps[2].ObjectIds) [<Fact>] +let ``probe design steps expose the cumulative real-probe contract`` () = + let steps = Design.probeSteps + + Assert.Equal(7, steps.Length) + Assert.True([| "probe-01-bus"; "probe-02-boom"; "probe-03-rtg"; "probe-04-hga"; "probe-05-instruments"; "probe-06-mast"; "probe-07-livery" |] = (steps |> Array.map _.StepId)) + Assert.True([| 0; 1; 2; 3; 4; 5; 6 |] = (steps |> Array.map _.Index)) + Assert.Equal(22, steps[0].ObjectIds.Length) + Assert.Equal(31, steps[1].ObjectIds.Length) + Assert.Equal(43, steps[2].ObjectIds.Length) + Assert.Equal(49, steps[3].ObjectIds.Length) + Assert.Equal(55, steps[4].ObjectIds.Length) + Assert.Equal(56, steps[5].ObjectIds.Length) + Assert.Equal(56, steps[6].ObjectIds.Length) + Assert.True(steps[0].ObjectIds |> Array.contains "probe.bus.electronics.decagon") + Assert.True(steps[0].ObjectIds |> Array.contains "probe.bus.bay.panel.00") + Assert.True(steps[2].ObjectIds |> Array.contains "probe.rtg.01.fin.02") + Assert.True(steps[3].ObjectIds |> Array.contains "probe.hga.reflector") + Assert.True(steps[4].ObjectIds |> Array.contains "probe.optical.aperture") + Assert.True([| "probe.science.boom" |] = (steps[5].ObjectIds |> Array.except steps[4].ObjectIds)) + Assert.True([||] = (steps[6].ObjectIds |> Array.except steps[5].ObjectIds)) + Assert.Equal(6, steps[6].Changes.Length) + Assert.True(steps[6].Changes |> Array.forall (fun change -> change.Kind = "update")) + +[<Fact>] +let ``design forProject selects steps by project id prefix`` () = + Assert.True(Design.steps = Design.forProject "heritage-001") + Assert.True(Design.probeSteps = Design.forProject "probe-001") + Assert.True(Design.probeSteps = Design.forProject "probe-42") + +[<Fact>] let ``pipeline commits each step and writes manifest after complete event`` () = withTempDirectory (fun root -> let manifest = Pipeline.run (options root (TestBridge() :> IArtifactBridge)) |> expectOk |
