summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/SomhairlesDream.Cli/Arguments.fs57
-rw-r--r--src/SomhairlesDream.Cli/Program.fs1
-rw-r--r--src/SomhairlesDream.Modeling/BlenderBridge.fs13
-rw-r--r--src/SomhairlesDream.Modeling/Definitions.fs65
-rw-r--r--src/SomhairlesDream.Modeling/Pipeline.fs13
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunCoordinator.fs1
-rw-r--r--stage/blender/probe.py816
-rw-r--r--tests/SomhairlesDream.Cli.Tests/ArgumentsTests.fs27
-rw-r--r--tests/SomhairlesDream.Modeling.Tests/PipelineTests.fs31
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